diff --git a/catalog/common/models.py b/catalog/common/models.py index e55383dd..95c81a73 100644 --- a/catalog/common/models.py +++ b/catalog/common/models.py @@ -499,7 +499,7 @@ class Item(PolymorphicModel): return self.title @classmethod - def get_by_url(cls, url_or_b62: str) -> "Self | None": + def get_by_url(cls, url_or_b62: str, resolve_merge=False) -> "Self | None": b62 = url_or_b62.strip().split("/")[-1] if len(b62) not in [21, 22]: r = re.search(r"[A-Za-z0-9]{21,22}", url_or_b62) @@ -507,6 +507,14 @@ class Item(PolymorphicModel): b62 = r[0] try: item = cls.objects.get(uid=uuid.UUID(int=b62_decode(b62))) + if resolve_merge: + resolve_cnt = 5 + while item.merged_to_item and resolve_cnt > 0: + item = item.merged_to_item + resolve_cnt -= 1 + if resolve_cnt == 0: + logger.error(f"resolve merge loop for {item}") + item = None except Exception: item = None return item diff --git a/catalog/templates/_item_user_pieces.html b/catalog/templates/_item_user_pieces.html index ea9f96a3..e961789d 100644 --- a/catalog/templates/_item_user_pieces.html +++ b/catalog/templates/_item_user_pieces.html @@ -60,6 +60,40 @@

{% endfor %} +
+
+ {% trans "my notes" %} + {% if mark.shelf %} + + + + + + + + {% endif %} +
+ {% for note in mark.notes %} + + + + + {% if note.latest_post %} + {% include "action_like_post.html" with post=note.latest_post %} + {% include "action_boost_post.html" with post=note.latest_post %} + {% include "action_open_post.html" with post=note.latest_post %} + {% endif %} + +
{{ note.title|default:'' }}
+

{{ note.content|linebreaks }}

+ {% endfor %} +
{% trans "my review" %} diff --git a/catalog/templates/item_base.html b/catalog/templates/item_base.html index cd4dcd3c..b54fe218 100644 --- a/catalog/templates/item_base.html +++ b/catalog/templates/item_base.html @@ -13,7 +13,7 @@ + content="{% trans item.category.label %} - {{ item.display_title }}"> {% if item.has_cover %}{% endif %} diff --git a/catalog/tests.py b/catalog/tests.py index 1d386a44..6935f405 100644 --- a/catalog/tests.py +++ b/catalog/tests.py @@ -8,4 +8,34 @@ from catalog.performance.tests import * from catalog.podcast.tests import * from catalog.tv.tests import * + # imported tests with same name might be ignored silently +class CatalogCase(TestCase): + databases = "__all__" + + def setUp(self): + self.hyperion_hardcover = Edition.objects.create(title="Hyperion") + self.hyperion_hardcover.pages = 481 + self.hyperion_hardcover.isbn = "9780385249492" + self.hyperion_hardcover.save() + self.hyperion_print = Edition.objects.create(title="Hyperion") + self.hyperion_print.pages = 500 + self.hyperion_print.isbn = "9780553283686" + self.hyperion_print.save() + self.hyperion_ebook = Edition(title="Hyperion") + self.hyperion_ebook.asin = "B0043M6780" + self.hyperion_ebook.save() + self.andymion_print = Edition.objects.create(title="Andymion", pages=42) + # serie = Serie(title="Hyperion Cantos") + self.hyperion = Work(title="Hyperion") + self.hyperion.save() + + def test_merge(self): + self.hyperion_hardcover.merge_to(self.hyperion_print) + self.assertEqual(self.hyperion_hardcover.merged_to_item, self.hyperion_print) + + def test_merge_resolve(self): + self.hyperion_hardcover.merge_to(self.hyperion_print) + self.hyperion_print.merge_to(self.hyperion_ebook) + resloved = Item.get_by_url(self.hyperion_hardcover.url, True) + self.assertEqual(resloved, self.hyperion_ebook) diff --git a/journal/migrations/0002_note.py b/journal/migrations/0002_note.py new file mode 100644 index 00000000..ef33ee41 --- /dev/null +++ b/journal/migrations/0002_note.py @@ -0,0 +1,64 @@ +# Generated by Django 4.2.13 on 2024-06-13 13:10 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0001_initial_0_10"), + ("catalog", "0001_initial_0_10"), + ("journal", "0001_initial_0_10"), + ] + + operations = [ + migrations.CreateModel( + name="Note", + fields=[ + ( + "piece_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="journal.piece", + ), + ), + ("visibility", models.PositiveSmallIntegerField(default=0)), + ( + "created_time", + models.DateTimeField(default=django.utils.timezone.now), + ), + ("edited_time", models.DateTimeField(auto_now=True)), + ("metadata", models.JSONField(default=dict)), + ( + "remote_id", + models.CharField(default=None, max_length=200, null=True), + ), + ("title", models.TextField(blank=True, default=None, null=True)), + ("content", models.TextField()), + ("sensitive", models.BooleanField(default=False)), + ("attachements", models.JSONField(default=list)), + ( + "item", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="catalog.item" + ), + ), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="users.apidentity", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("journal.piece",), + ), + ] diff --git a/journal/models/__init__.py b/journal/models/__init__.py index fa71eb3a..e4d0de3b 100644 --- a/journal/models/__init__.py +++ b/journal/models/__init__.py @@ -1,6 +1,7 @@ from .collection import Collection, CollectionMember, FeaturedCollection from .comment import Comment from .common import ( + Content, Piece, PieceInteraction, PiecePost, @@ -14,6 +15,7 @@ from .common import ( from .like import Like from .mark import Mark from .mixins import UserOwnedObjectMixin +from .note import Note from .rating import Rating from .renderers import render_md from .review import Review @@ -43,6 +45,7 @@ __all__ = [ "q_piece_visible_to_user", "Like", "Mark", + "Note", "Rating", "render_md", "Review", diff --git a/journal/models/collection.py b/journal/models/collection.py index b7155530..7383b777 100644 --- a/journal/models/collection.py +++ b/journal/models/collection.py @@ -9,6 +9,7 @@ from catalog.collection.models import Collection as CatalogCollection from catalog.common import jsondata from catalog.common.utils import DEFAULT_ITEM_COVER, piece_cover_path from catalog.models import Item +from takahe.utils import Takahe from users.models import APIdentity from .common import Piece @@ -113,11 +114,8 @@ class Collection(List): Takahe.post_collection(self) def delete(self, *args, **kwargs): - existing_post = self.latest_post - if existing_post: - from takahe.utils import Takahe - - Takahe.delete_posts([existing_post.pk]) + if self.local: + Takahe.delete_posts(self.all_post_ids) return super().delete(*args, **kwargs) @property diff --git a/journal/models/comment.py b/journal/models/comment.py index 68d5b256..b3ed0940 100644 --- a/journal/models/comment.py +++ b/journal/models/comment.py @@ -5,6 +5,7 @@ from django.db import models from django.utils import timezone from catalog.models import Item +from takahe.utils import Takahe from users.models import APIdentity from .common import Content @@ -33,7 +34,7 @@ class Comment(Content): return d @classmethod - def update_by_ap_object(cls, owner, item, obj, post_id, visibility): + def update_by_ap_object(cls, owner, item, obj, post): p = cls.objects.filter(owner=owner, item=item).first() if p and p.edited_time >= datetime.fromisoformat(obj["updated"]): return p # incoming ap object is older than what we have, no update needed @@ -45,14 +46,14 @@ class Comment(Content): "text": content, "local": False, "remote_id": obj["id"], - "visibility": visibility, + "visibility": Takahe.visibility_t2n(post.visibility), "created_time": datetime.fromisoformat(obj["published"]), "edited_time": datetime.fromisoformat(obj["updated"]), } if obj.get("relatedWithItemPosition"): d["metadata"] = {"position": obj["relatedWithItemPosition"]} p, _ = cls.objects.update_or_create(owner=owner, item=item, defaults=d) - p.link_post_id(post_id) + p.link_post_id(post.id) return p @property diff --git a/journal/models/common.py b/journal/models/common.py index 1c6921e1..5f7404ab 100644 --- a/journal/models/common.py +++ b/journal/models/common.py @@ -1,18 +1,23 @@ import re import uuid +from abc import abstractmethod +from datetime import datetime from functools import cached_property -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Self +# from deepmerge import always_merger from django.conf import settings from django.core.signing import b62_decode, b62_encode from django.db import connection, models from django.db.models import Avg, CharField, Count, Q from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from loguru import logger from polymorphic.models import PolymorphicModel from catalog.common.models import AvailableItemCategory, Item, ItemCategory from catalog.models import item_categories, item_content_types +from mastodon.api import boost_toot_later, delete_toot, delete_toot_later, post_toot2 from takahe.utils import Takahe from users.models import APIdentity, User @@ -123,6 +128,28 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin): "takahe.Post", related_name="pieces", through="PiecePost" ) + def get_mastodon_repost_url(self): + return (self.metadata or {}).get("shared_link") + + def set_mastodon_repost_url(self, url: str | None): + metadata = self.metadata or {} + if metadata.get("shared_link", None) == url: + return + if not url: + metadata.pop("shared_link", None) + else: + metadata["shared_link"] = url + self.metadata = metadata + self.save(update_fields=["metadata"]) + + def delete(self, *args, **kwargs): + if self.local: + Takahe.delete_posts(self.all_post_ids) + toot_url = self.get_mastodon_repost_url() + if toot_url: + delete_toot_later(self.owner.user, toot_url) + return super().delete(*args, **kwargs) + @property def uuid(self): return b62_encode(self.uid.int) @@ -139,10 +166,6 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin): def api_url(self): return f"/api/{self.url}" if self.url_path else None - @property - def shared_link(self): - return Takahe.get_post_url(self.latest_post.pk) if self.latest_post else None - @property def like_count(self): return ( @@ -187,16 +210,13 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin): pp = PiecePost.objects.filter(post_id=post_id).first() return pp.piece if pp else None - @classmethod - def update_by_ap_object(cls, owner, item, obj, post_id, visibility): - raise NotImplementedError("subclass must implement this") - - @property - def ap_object(self): - raise NotImplementedError("subclass must implement this") - def link_post_id(self, post_id: int): PiecePost.objects.get_or_create(piece=self, post_id=post_id) + try: + del self.latest_post_id + del self.latest_post + except AttributeError: + pass def clear_post_ids(self): PiecePost.objects.filter(piece=self).delete() @@ -219,6 +239,160 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin): ) return post_ids + @property + def ap_object(self): + raise NotImplementedError("subclass must implement this") + + @classmethod + @abstractmethod + def params_from_ap_object(cls, post, obj, piece): + return {} + + @abstractmethod + def to_post_params(self): + return {} + + @abstractmethod + def to_mastodon_params(self): + return {} + + @classmethod + def update_by_ap_object(cls, owner: APIdentity, item: Item, obj, post: Post): + """ + Create or update a content piece with related AP message + """ + p = cls.get_by_post_id(post.id) + if p and p.owner.pk != post.author_id: + logger.warning(f"Owner mismatch: {p.owner.pk} != {post.author_id}") + return + local = post.local + visibility = Takahe.visibility_t2n(post.visibility) + d = cls.params_from_ap_object(post, obj, p) + if p: + # update existing piece + edited = post.edited if local else datetime.fromisoformat(obj["updated"]) + if p.edited_time >= edited: + # incoming ap object is older than what we have, no update needed + return p + d["edited_time"] = edited + for k, v in d.items(): + setattr(p, k, v) + p.save(update_fields=d.keys()) + else: + # no previously linked piece, create a new one and link to post + d.update( + { + "item": item, + "owner": owner, + "local": post.local, + "visibility": visibility, + "remote_id": None if local else obj["id"], + } + ) + if local: + d["created_time"] = post.published + d["edited_time"] = post.edited or post.published + else: + d["created_time"] = datetime.fromisoformat(obj["published"]) + d["edited_time"] = datetime.fromisoformat(obj["updated"]) + p = cls.objects.create(**d) + p.link_post_id(post.id) + if local: + # a local piece is reconstructred from a post, update post and fanout + if not post.type_data: + post.type_data = {} + # always_merger.merge( + # post.type_data, + # { + # "object": { + # "tag": [item.ap_object_ref], + # "relatedWith": [p.ap_object], + # } + # }, + # ) + post.type_data = { + "object": { + "tag": [item.ap_object_ref], + "relatedWith": [p.ap_object], + } + } + post.save(update_fields=["type_data"]) + Takahe.update_state(post, "edited") + return p + + def sync_to_mastodon(self, delete_existing=False): + user = self.owner.user + if not user.mastodon_site: + return + if user.preference.mastodon_repost_mode == 1: + if delete_existing: + self.delete_mastodon_repost() + return self.repost_to_mastodon() + elif self.latest_post: + return boost_toot_later(user, self.latest_post.url) + else: + logger.warning("No post found for piece") + return False, 404 + + def delete_mastodon_repost(self): + toot_url = self.get_mastodon_repost_url() + if toot_url: + self.set_mastodon_repost_url(None) + delete_toot(self.owner.user, toot_url) + + def repost_to_mastodon(self): + user = self.owner.user + d = { + "user": user, + "visibility": self.visibility, + "update_toot_url": self.get_mastodon_repost_url(), + } + d.update(self.to_mastodon_params()) + response = post_toot2(**d) + if response is not None and response.status_code in [200, 201]: + j = response.json() + if "url" in j: + metadata = {"shared_link": j["url"]} + if self.metadata != metadata: + self.metadata = metadata + self.save(update_fields=["metadata"]) + return True, 200 + else: + logger.warning(response) + return False, response.status_code if response is not None else -1 + + def sync_to_timeline(self, delete_existing=False): + user = self.owner.user + v = Takahe.visibility_n2t(self.visibility, user.preference.post_public_mode) + existing_post = self.latest_post + if existing_post and existing_post.state in ["deleted", "deleted_fanned_out"]: + existing_post = None + elif existing_post and delete_existing: + Takahe.delete_posts([existing_post.pk]) + existing_post = None + params = { + "author_pk": self.owner.pk, + "visibility": v, + "post_pk": existing_post.pk if existing_post else None, + "post_time": self.created_time, # type:ignore subclass must have this + "edit_time": self.edited_time, # type:ignore subclass must have this + "data": { + "object": { + "tag": ( + [self.item.ap_object_ref] # type:ignore + if hasattr(self, "item") + else [] + ), + "relatedWith": [self.ap_object], + } + }, + } + params.update(self.to_post_params()) + post = Takahe.post(**params) + if post and post != existing_post: + self.link_post_id(post.pk) + return post + class PiecePost(models.Model): post_id: int diff --git a/journal/models/mark.py b/journal/models/mark.py index b70bd915..9b7cb88d 100644 --- a/journal/models/mark.py +++ b/journal/models/mark.py @@ -26,6 +26,7 @@ from takahe.utils import Takahe from users.models import APIdentity from .comment import Comment +from .note import Note from .rating import Rating from .review import Review from .shelf import Shelf, ShelfLogEntry, ShelfManager, ShelfMember, ShelfType @@ -105,6 +106,14 @@ class Mark: else None ) + @cached_property + def notes(self): + return Note.objects.filter(owner=self.owner, item=self.item) + # post_ids = PiecePost.objects.filter( + # piece__note__owner_id=self.owner.pk, piece__note__item_id=self.item.pk + # ).values_list("post_id", flat=True) + # return Takahe.get_posts(list(post_ids)) + @property def created_time(self) -> datetime | None: return self.shelfmember.created_time if self.shelfmember else None diff --git a/journal/models/note.py b/journal/models/note.py new file mode 100644 index 00000000..671e9059 --- /dev/null +++ b/journal/models/note.py @@ -0,0 +1,87 @@ +from functools import cached_property +from typing import override + +from django.db import models +from loguru import logger + +from mastodon.api import delete_toot_later +from takahe.utils import Takahe + +from .common import Content +from .renderers import render_text +from .shelf import ShelfMember + + +class Note(Content): + title = models.TextField(blank=True, null=True, default=None) + content = models.TextField(blank=False, null=False) + sensitive = models.BooleanField(default=False, null=False) + attachements = models.JSONField(default=list) + + @property + def html(self): + return render_text(self.content) + + @property + def ap_object(self): + d = { + "id": self.absolute_url, + "type": "Note", + "title": self.title, + "content": self.content, + "sensitive": self.sensitive, + "published": self.created_time.isoformat(), + "updated": self.edited_time.isoformat(), + "attributedTo": self.owner.actor_uri, + "withRegardTo": self.item.absolute_url, + "href": self.absolute_url, + } + return d + + @override + @classmethod + def params_from_ap_object(cls, post, obj, piece): + return { + "title": obj.get("title", post.summary), + "content": obj.get("content", "").strip(), + "sensitive": obj.get("sensitive", post.sensitive), + # "attachements": obj.get("attachements", []), + } + + @override + @classmethod + def update_by_ap_object(cls, owner, item, obj, post): + p = super().update_by_ap_object(owner, item, obj, post) + if ( + p + and p.local + and owner.user.preference.mastodon_default_repost + and owner.user.mastodon_username + ): + p.sync_to_mastodon() + return p + + @cached_property + def shelfmember(self) -> ShelfMember | None: + return ShelfMember.objects.filter(item=self.item, owner=self.owner).first() + + def to_mastodon_params(self): + return { + "spoiler_text": self.title, + "content": self.content, + "sensitive": self.sensitive, + # "attachements": self.attachements, + "reply_to_toot_url": ( + self.shelfmember.get_mastodon_repost_url() if self.shelfmember else None + ), + } + + def to_post_params(self): + return { + "summary": self.title, + "content": self.content, + "sensitive": self.sensitive, + "reply_to_pk": ( + self.shelfmember.latest_post_id if self.shelfmember else None + ), + } diff --git a/journal/models/rating.py b/journal/models/rating.py index 263b7d55..70803d85 100644 --- a/journal/models/rating.py +++ b/journal/models/rating.py @@ -7,6 +7,7 @@ from django.db.models import Avg, Count, Q from django.utils.translation import gettext_lazy as _ from catalog.models import Item +from takahe.utils import Takahe from users.models import APIdentity from .common import Content @@ -39,7 +40,7 @@ class Rating(Content): } @classmethod - def update_by_ap_object(cls, owner, item, obj, post_id, visibility): + def update_by_ap_object(cls, owner, item, obj, post): p = cls.objects.filter(owner=owner, item=item).first() if p and p.edited_time >= datetime.fromisoformat(obj["updated"]): return p # incoming ap object is older than what we have, no update needed @@ -63,12 +64,12 @@ class Rating(Content): "grade": value, "local": False, "remote_id": obj["id"], - "visibility": visibility, + "visibility": Takahe.visibility_t2n(post.visibility), "created_time": datetime.fromisoformat(obj["published"]), "edited_time": datetime.fromisoformat(obj["updated"]), } p = cls.objects.update_or_create(owner=owner, item=item, defaults=d)[0] - p.link_post_id(post_id) + p.link_post_id(post.id) return p @staticmethod diff --git a/journal/models/review.py b/journal/models/review.py index 22468002..8120a8bb 100644 --- a/journal/models/review.py +++ b/journal/models/review.py @@ -9,6 +9,7 @@ from markdownx.models import MarkdownxField from catalog.models import Item from mastodon.api import boost_toot_later, share_review +from takahe.utils import Takahe from users.models import APIdentity from .common import Content @@ -52,7 +53,7 @@ class Review(Content): } @classmethod - def update_by_ap_object(cls, owner, item, obj, post_id, visibility): + def update_by_ap_object(cls, owner, item, obj, post): p = cls.objects.filter(owner=owner, item=item).first() if p and p.edited_time >= datetime.fromisoformat(obj["updated"]): return p # incoming ap object is older than what we have, no update needed @@ -66,12 +67,12 @@ class Review(Content): "body": content, "local": False, "remote_id": obj["id"], - "visibility": visibility, + "visibility": Takahe.visibility_t2n(post.visibility), "created_time": datetime.fromisoformat(obj["published"]), "edited_time": datetime.fromisoformat(obj["updated"]), } p, _ = cls.objects.update_or_create(owner=owner, item=item, defaults=d) - p.link_post_id(post_id) + p.link_post_id(post.id) return p @cached_property diff --git a/journal/models/shelf.py b/journal/models/shelf.py index 7959dc7d..2a496e7b 100644 --- a/journal/models/shelf.py +++ b/journal/models/shelf.py @@ -10,6 +10,7 @@ from django.utils.translation import pgettext_lazy as __ from loguru import logger from catalog.models import Item, ItemCategory +from takahe.utils import Takahe from users.models import APIdentity from .common import q_item_in_category @@ -335,9 +336,7 @@ class ShelfMember(ListMember): } @classmethod - def update_by_ap_object( - cls, owner: APIdentity, item: Item, obj: dict, post_id: int, visibility: int - ): + def update_by_ap_object(cls, owner: APIdentity, item: Item, obj: dict, post): p = cls.objects.filter(owner=owner, item=item).first() if p and p.edited_time >= datetime.fromisoformat(obj["updated"]): return p # incoming ap object is older than what we have, no update needed @@ -349,12 +348,12 @@ class ShelfMember(ListMember): "parent": shelf, "position": 0, "local": False, - "visibility": visibility, + "visibility": Takahe.visibility_t2n(post.visibility), "created_time": datetime.fromisoformat(obj["published"]), "edited_time": datetime.fromisoformat(obj["updated"]), } p, _ = cls.objects.update_or_create(owner=owner, item=item, defaults=d) - p.link_post_id(post_id) + p.link_post_id(post.id) return p @cached_property diff --git a/journal/templates/action_open_post.html b/journal/templates/action_open_post.html index 2439d552..f1681e76 100644 --- a/journal/templates/action_open_post.html +++ b/journal/templates/action_open_post.html @@ -3,18 +3,17 @@ + onclick="navigator.share({url:'{{ post.object_uri|escapejs }}'});event.preventDefault();"> {% if post.visibility == 1 %} - + {% elif post.visibility == 2 %} - + {% elif post.visibility == 3 %} - + {% elif post.visibility == 4 %} - + {% else %} - + {% endif %} diff --git a/journal/templates/collection.html b/journal/templates/collection.html index 71022317..a634e1ad 100644 --- a/journal/templates/collection.html +++ b/journal/templates/collection.html @@ -139,13 +139,9 @@ {% endif %} - - - + {% if collection.latest_post %} + {% include "action_open_post.html" with post=collection.latest_post %} + {% endif %} {% trans "Created date" %}: {{ collection.created_time|date }}
diff --git a/journal/templates/note.html b/journal/templates/note.html new file mode 100644 index 00000000..88085cd0 --- /dev/null +++ b/journal/templates/note.html @@ -0,0 +1,71 @@ +{% load static %} +{% load i18n %} +{% load l10n %} +{% load humanize %} +{% load mastodon %} +{% load thumb %} + +
+
+ + {% trans 'Note' %} - {{ item.display_title }} +
+
+
+ {% csrf_token %} + + +
+
+
+ + + + + + +
+
+
+
+ {% if request.user.mastodon_acct %} + + {% endif %} +
+
+
+
+ +
+
+
+
+
diff --git a/journal/templates/review.html b/journal/templates/review.html index 5760b0d1..c5f29f23 100644 --- a/journal/templates/review.html +++ b/journal/templates/review.html @@ -86,13 +86,9 @@ {% endif %} - - - + {% if review.latest_post %} + {% include "action_open_post.html" with post=review.latest_post %} + {% endif %} {{ review.created_time|date }} diff --git a/journal/urls.py b/journal/urls.py index a3c712bc..2fd9db3a 100644 --- a/journal/urls.py +++ b/journal/urls.py @@ -21,6 +21,8 @@ urlpatterns = [ path("wish/", wish, name="wish"), path("mark/", mark, name="mark"), path("comment/", comment, name="comment"), + path("note/", note, name="note"), + path("note//", note, name="note"), path("piece//replies", piece_replies, name="piece_replies"), path("post//replies", post_replies, name="post_replies"), path("post//reply", post_reply, name="post_reply"), diff --git a/journal/views/__init__.py b/journal/views/__init__.py index f7782e82..4c6b754b 100644 --- a/journal/views/__init__.py +++ b/journal/views/__init__.py @@ -16,7 +16,7 @@ from .collection import ( user_liked_collection_list, ) from .common import piece_delete -from .mark import comment, mark, mark_log, user_mark_list, wish +from .mark import comment, mark, mark_log, note, user_mark_list, wish from .post import ( piece_replies, post_boost, diff --git a/journal/views/mark.py b/journal/views/mark.py index 582ea967..7ca829f6 100644 --- a/journal/views/mark.py +++ b/journal/views/mark.py @@ -16,7 +16,7 @@ from common.utils import AuthedHttpRequest, get_uuid_or_404 from mastodon.api import boost_toot_later, share_comment from takahe.utils import Takahe -from ..models import Comment, Mark, ShelfManager, ShelfType, TagManager +from ..models import Comment, Mark, Note, ShelfManager, ShelfType, TagManager from .common import render_list, render_relogin, target_identity_required PAGE_SIZE = 10 @@ -192,6 +192,53 @@ def comment(request: AuthedHttpRequest, item_uuid): return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/")) +@login_required +@require_http_methods(["GET", "POST"]) +def note(request: AuthedHttpRequest, item_uuid: str, note_uuid: str = ""): + item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid)) + note_uuid = request.POST.get("uuid", note_uuid) + note = None + content = request.POST.get("content") + if note_uuid: + note = get_object_or_404( + Note, owner=request.user.identity, item=item, uid=get_uuid_or_404(note_uuid) + ) + if request.method == "GET": + return render( + request, + "note.html", + { + "item": item, + "note": note, + }, + ) + else: + if request.POST.get("delete", default=False) or not content: + if not note: + raise Http404(_("Content not found")) + note.delete() + return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/")) + share_to_mastodon = bool(request.POST.get("share_to_mastodon", default=False)) + visibility = int(request.POST.get("visibility", default=0)) + delete_existing_post = False + if note: + delete_existing_post = visibility != note.visibility + note.content = content + note.visibility = visibility + note.save() + else: + note = Note.objects.create( + owner=request.user.identity, + item=item, + content=content, + visibility=visibility, + ) + note.sync_to_timeline(delete_existing=delete_existing_post) + if share_to_mastodon: + note.sync_to_mastodon(delete_existing=delete_existing_post) + return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/")) + + def user_mark_list(request: AuthedHttpRequest, user_name, shelf_type, item_category): return render_list( request, user_name, "mark", shelf_type=shelf_type, item_category=item_category diff --git a/journal/views/wrapped.py b/journal/views/wrapped.py index 5cae4160..391014ba 100644 --- a/journal/views/wrapped.py +++ b/journal/views/wrapped.py @@ -123,9 +123,9 @@ class WrappedShareView(LoginRequiredMixin, TemplateView): ) post = Takahe.post( identity.pk, - "", comment, Takahe.visibility_n2t(visibility, user.preference.post_public_mode), + "", attachments=[media], ) classic_repost = user.preference.mastodon_repost_mode == 1 diff --git a/mastodon/api.py b/mastodon/api.py index 9e56ebae..4fdede0a 100644 --- a/mastodon/api.py +++ b/mastodon/api.py @@ -227,6 +227,83 @@ def post_toot( return response +def delete_toot(user, toot_url): + headers = { + "User-Agent": USER_AGENT, + "Authorization": f"Bearer {user.mastodon_token}", + "Idempotency-Key": random_string_generator(16), + } + toot_id = get_status_id_by_url(toot_url) + url = ( + "https://" + + get_api_domain(user.mastodon_site) + + API_PUBLISH_TOOT + + "/" + + toot_id + ) + try: + response = requests.delete(url, headers=headers) + if response.status_code != 200: + logger.warning(f"Error DELETE {url} {response.status_code}") + except Exception as e: + logger.warning(f"Error deleting {e}") + + +def delete_toot_later(user, toot_url): + if user and user.mastodon_token and user.mastodon_site and toot_url: + django_rq.get_queue("fetch").enqueue(delete_toot, user, toot_url) + + +def post_toot2( + user, + content, + visibility, + update_toot_url: str | None = None, + reply_to_toot_url: str | None = None, + sensitive: bool = False, + spoiler_text: str | None = None, +): + headers = { + "User-Agent": USER_AGENT, + "Authorization": f"Bearer {user.mastodon_token}", + "Idempotency-Key": random_string_generator(16), + } + response = None + url = "https://" + get_api_domain(user.mastodon_site) + API_PUBLISH_TOOT + payload = { + "status": content, + "visibility": get_toot_visibility(visibility, user), + } + update_id = get_status_id_by_url(update_toot_url) + reply_to_id = get_status_id_by_url(reply_to_toot_url) + if reply_to_id: + payload["in_reply_to_id"] = reply_to_id + # if media_id: + # payload["media_ids[]"] = [media_id] + if spoiler_text: + payload["spoiler_text"] = spoiler_text + if sensitive: + payload["sensitive"] = True + try: + if update_id: + response = put(url + "/" + update_id, headers=headers, data=payload) + if not update_id or (response is not None and response.status_code != 200): + headers["Idempotency-Key"] = random_string_generator(16) + response = post(url, headers=headers, data=payload) + if response is not None and response.status_code != 200: + headers["Idempotency-Key"] = random_string_generator(16) + payload["in_reply_to_id"] = None + response = post(url, headers=headers, data=payload) + if response is not None and response.status_code == 201: + response.status_code = 200 + if response is not None and response.status_code != 200: + logger.warning(f"Error {url} {response.status_code}") + except Exception as e: + logger.warning(f"Error posting {e}") + response = None + return response + + def _get_redirect_uris(allow_multiple=True) -> str: u = settings.SITE_INFO["site_url"] + "/account/login/oauth" if not allow_multiple: diff --git a/neodb-takahe b/neodb-takahe index 01cacb7d..03a4f54a 160000 --- a/neodb-takahe +++ b/neodb-takahe @@ -1 +1 @@ -Subproject commit 01cacb7dce19f7fadcda7e629b78b2752d74eece +Subproject commit 03a4f54a8f9bd3d06dd9f466d40d6f5853699923 diff --git a/pyproject.toml b/pyproject.toml index 662eb12f..d73740c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ dependencies = [ "typesense", "urlman", "validators", + "deepmerge>=1.1.1", ] [tool.rye] @@ -66,7 +67,7 @@ dev-dependencies = [ "djlint~=1.34.1", "isort~=5.13.2", "lxml-stubs", - "pyright==1.1.366", + "pyright>=1.1.367", "ruff", "mkdocs-material>=9.5.25", ] diff --git a/requirements-dev.lock b/requirements-dev.lock index 22cc7892..a8d1ff4f 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -56,6 +56,7 @@ cryptography==42.0.8 cssbeautifier==1.15.1 # via djlint dateparser==1.2.0 +deepmerge==1.1.1 discord-py==2.3.2 distlib==0.3.8 # via virtualenv @@ -219,7 +220,7 @@ pygments==2.18.0 # via mkdocs-material pymdown-extensions==10.8.1 # via mkdocs-material -pyright==1.1.366 +pyright==1.1.367 python-dateutil==2.9.0.post0 # via dateparser # via django-auditlog diff --git a/requirements.lock b/requirements.lock index ef0e3cd5..e4178c6f 100644 --- a/requirements.lock +++ b/requirements.lock @@ -42,6 +42,7 @@ click==8.1.7 cryptography==42.0.8 # via jwcrypto dateparser==1.2.0 +deepmerge==1.1.1 discord-py==2.3.2 django==4.2.13 # via django-anymail diff --git a/takahe/ap_handlers.py b/takahe/ap_handlers.py index 0b401a47..aae46630 100644 --- a/takahe/ap_handlers.py +++ b/takahe/ap_handlers.py @@ -1,9 +1,20 @@ from time import sleep +from typing import Any +from django.conf import settings from loguru import logger from catalog.common import * -from journal.models import Comment, Piece, PieceInteraction, Rating, Review, ShelfMember +from journal.models import ( + Comment, + Content, + Note, + Piece, + PieceInteraction, + Rating, + Review, + ShelfMember, +) from users.models.apidentity import APIdentity from .models import Follow, Identity, Post, TimelineEvent @@ -28,10 +39,11 @@ _supported_ap_journal_types = { "Rating": Rating, "Comment": Comment, "Review": Review, + "Note": Note, } -def _parse_items(objects): +def _parse_items(objects) -> list[dict[str, Any]]: logger.debug(f"Parsing item links from {objects}") if not objects: return [] @@ -40,7 +52,7 @@ def _parse_items(objects): return items -def _parse_piece_objects(objects): +def _parse_piece_objects(objects) -> list[dict[str, Any]]: logger.debug(f"Parsing pieces from {objects}") if not objects: return [] @@ -54,10 +66,15 @@ def _parse_piece_objects(objects): return pieces -def _get_or_create_item(item_obj): +def _get_or_create_item(item_obj) -> Item | None: logger.debug(f"Fetching item by ap from {item_obj}") typ = item_obj["type"] url = item_obj["href"] + if url.startswith(settings.SITE_INFO["site_url"]): + item = Item.get_by_url(url, True) + if not item: + logger.warning(f"Item not found for {url}") + return item if typ in ["TVEpisode", "PodcastEpisode"]: # TODO support episode item # match and fetch parent item first @@ -74,25 +91,23 @@ def _get_or_create_item(item_obj): return item -def _get_visibility(post_visibility): - match post_visibility: - case 2: - return 1 - case 3: - return 2 - case _: - return 0 +def post_created(pk, post_data): + return post_fetched(pk, post_data) -def post_fetched(pk, obj): +def post_edited(pk, post_data): + return post_fetched(pk, post_data) + + +def post_fetched(pk, post_data): post = Post.objects.get(pk=pk) owner = Takahe.get_or_create_remote_apidentity(post.author) - if not post.type_data: + if not post.type_data and not post_data: logger.warning(f"Post {post} has no type_data") return - ap_object = post.type_data.get("object", {}) - items = _parse_items(ap_object.get("tag")) - pieces = _parse_piece_objects(ap_object.get("relatedWith")) + ap_objects = post_data or post.type_data.get("object", {}) + items = _parse_items(ap_objects.get("tag")) + pieces = _parse_piece_objects(ap_objects.get("relatedWith")) logger.info(f"Post {post} has items {items} and pieces {pieces}") if len(items) == 0: logger.warning(f"Post {post} has no remote items") @@ -109,17 +124,20 @@ def post_fetched(pk, obj): if not cls: logger.warning(f'Unknown link type {p["type"]}') continue - cls.update_by_ap_object(owner, item, p, pk, _get_visibility(post.visibility)) + cls.update_by_ap_object(owner, item, p, post) -def post_deleted(pk, obj): - for piece in Piece.objects.filter(posts__id=pk, local=False): +def post_deleted(pk, post_data): + for piece in Piece.objects.filter(posts__id=pk): + if piece.local and piece.__class__ != Note: + # no delete other than Note, for backward compatibility, should reconsider later + return # delete piece if the deleted post is the most recent one for the piece if piece.latest_post_id == pk: - logger.debug(f"Deleting remote piece {piece}") + logger.debug(f"Deleting piece {piece}") piece.delete() else: - logger.debug(f"Matched remote piece {piece} has newer posts, not deleting") + logger.debug(f"Matched piece {piece} has newer posts, not deleting") def post_interacted(interaction_pk, interaction, post_pk, identity_pk): diff --git a/takahe/migrations/0001_initial.py b/takahe/migrations/0001_initial.py index 05d80b28..cb8f1c88 100644 --- a/takahe/migrations/0001_initial.py +++ b/takahe/migrations/0001_initial.py @@ -310,6 +310,11 @@ class Migration(migrations.Migration): ), ("state", models.CharField(default="new", max_length=100)), ("state_changed", models.DateTimeField(auto_now_add=True)), + ("state_next_attempt", models.DateTimeField(blank=True, null=True)), + ( + "state_locked_until", + models.DateTimeField(blank=True, db_index=True, null=True), + ), ("local", models.BooleanField()), ( "object_uri", diff --git a/takahe/models.py b/takahe/models.py index ee4889ad..fbe4254a 100644 --- a/takahe/models.py +++ b/takahe/models.py @@ -900,6 +900,7 @@ class Post(models.Model): """ if TYPE_CHECKING: + author_id: int interactions: "models.QuerySet[PostInteraction]" attachments: "models.QuerySet[PostAttachment]" @@ -933,6 +934,8 @@ class Post(models.Model): # state = StateField(PostStates) state = models.CharField(max_length=100, default="new") state_changed = models.DateTimeField(auto_now_add=True) + state_next_attempt = models.DateTimeField(blank=True, null=True) + state_locked_until = models.DateTimeField(null=True, blank=True, db_index=True) # If it is our post or not local = models.BooleanField() @@ -1090,6 +1093,7 @@ class Post(models.Model): attachments: list | None = None, type_data: dict | None = None, published: datetime.datetime | None = None, + edited: datetime.datetime | None = None, ) -> "Post": with transaction.atomic(): # Find mentions in this post @@ -1118,6 +1122,8 @@ class Post(models.Model): "hashtags": hashtags, "in_reply_to": reply_to.object_uri if reply_to else None, } + if edited: + post_obj["edited"] = edited if published: _delta = timezone.now() - published if _delta > datetime.timedelta(0): @@ -1161,6 +1167,7 @@ class Post(models.Model): attachment_attributes: list | None = None, type_data: dict | None = None, published: datetime.datetime | None = None, + edited: datetime.datetime | None = None, ): with transaction.atomic(): # Strip all HTML and apply linebreaks filter @@ -1173,7 +1180,7 @@ class Post(models.Model): self.summary = summary or None self.sensitive = bool(summary) if sensitive is None else sensitive self.visibility = visibility - self.edited = timezone.now() + self.edited = edited or timezone.now() self.mentions.set(self.mentions_from_content(content, self.author)) self.emojis.set(Emoji.emojis_from_content(content, None)) if attachments is not None: @@ -1232,7 +1239,9 @@ class Post(models.Model): type=PostInteraction.Types.boost, state__in=["new", "fanned_out"], ).count(), - "replies": Post.objects.filter(in_reply_to=self.object_uri).count(), + "replies": Post.objects.filter(in_reply_to=self.object_uri) + .exclude(state__in=["deleted", "deleted_fanned_out"]) + .count(), } if save: self.save() diff --git a/takahe/utils.py b/takahe/utils.py index fdffaa47..c2e63d00 100644 --- a/takahe/utils.py +++ b/takahe/utils.py @@ -442,14 +442,15 @@ class Takahe: @staticmethod def post( author_pk: int, - pre_conetent: str, content: str, visibility: Visibilities, + pre_conetent: str = "", summary: str | None = None, sensitive: bool = False, data: dict | None = None, post_pk: int | None = None, post_time: datetime.datetime | None = None, + edit_time: datetime.datetime | None = None, reply_to_pk: int | None = None, attachments: list | None = None, ) -> Post | None: @@ -475,6 +476,7 @@ class Takahe: visibility=visibility, type_data=data, published=post_time, + edited=edit_time, attachments=attachments, ) else: @@ -487,6 +489,7 @@ class Takahe: visibility=visibility, type_data=data, published=post_time, + edited=edit_time, reply_to=reply_to_post, attachments=attachments, ) @@ -509,9 +512,24 @@ class Takahe: post = Post.objects.filter(pk=post_pk).first() if post_pk else None return post.object_uri if post else None + @staticmethod + def update_post(post_pk, **kwargs): + Post.objects.filter(pk=post_pk).update(**kwargs) + @staticmethod def delete_posts(post_pks): + parent_posts = list( + Post.objects.filter( + object_uri__in=Post.objects.filter( + pk__in=post_pks, in_reply_to__isnull=False + ) + .distinct("in_reply_to") + .values_list("in_reply_to", flat=True) + ) + ) Post.objects.filter(pk__in=post_pks).update(state="deleted") + for post in parent_posts: + post.calculate_stats() # TimelineEvent.objects.filter(subject_post__in=[post.pk]).delete() PostInteraction.objects.filter(post__in=post_pks).update(state="undone") @@ -538,6 +556,16 @@ class Takahe: else: return Takahe.Visibilities.public + @staticmethod + def visibility_t2n(visibility: int) -> int: + match visibility: + case 2: + return 1 + case 3: + return 2 + case _: + return 0 + @staticmethod def post_collection(collection: "Collection"): existing_post = collection.latest_post @@ -573,9 +601,9 @@ class Takahe: } post = Takahe.post( collection.owner.pk, - pre_conetent, content, visibility, + pre_conetent, None, False, data, @@ -621,9 +649,9 @@ class Takahe: existing_post = None if share_as_new_post else comment.latest_post post = Takahe.post( comment.owner.pk, - pre_conetent, content, v, + pre_conetent, spoiler, spoiler is not None, data, @@ -668,9 +696,9 @@ class Takahe: existing_post = None if share_as_new_post else review.latest_post post = Takahe.post( # TODO post as Article? review.owner.pk, - pre_conetent, content, v, + pre_conetent, None, False, data, @@ -712,9 +740,9 @@ class Takahe: ) post = Takahe.post( mark.owner.pk, - pre_conetent, content + append_content, v, + pre_conetent, spoiler, spoiler is not None, data, @@ -768,7 +796,7 @@ class Takahe: def reply_post( post_pk: int, identity_pk: int, content: str, visibility: Visibilities ): - return Takahe.post(identity_pk, "", content, visibility, reply_to_pk=post_pk) + return Takahe.post(identity_pk, content, visibility, reply_to_pk=post_pk) @staticmethod def boost_post(post_pk: int, identity_pk: int): @@ -859,7 +887,7 @@ class Takahe: return FediverseHtmlParser(linebreaks_filter(txt)).html @staticmethod - def update_state(obj, state): + def update_state(obj: Post | Relay, state: str): obj.state = state obj.state_changed = timezone.now() obj.state_next_attempt = None