from activities.models import ( Post, PostInteraction, PostInteractionStates, PostStates, TimelineEvent, ) from core.exceptions import capture_message from users.models import Identity class PostService: """ High-level operations on Posts """ @classmethod def queryset(cls): """ Returns the base queryset to use for fetching posts efficiently. """ return ( Post.objects.not_hidden() .prefetch_related( "attachments", "mentions", "emojis", ) .select_related( "author", "author__domain", ) ) def __init__(self, post: Post): self.post = post def interact_as(self, identity: Identity, type: str): """ Performs an interaction on this Post """ interaction = PostInteraction.objects.get_or_create( type=type, identity=identity, post=self.post, )[0] if interaction.state not in PostInteractionStates.group_active(): interaction.transition_perform(PostInteractionStates.new) self.post.calculate_stats() def uninteract_as(self, identity, type): """ Undoes an interaction on this Post """ for interaction in PostInteraction.objects.filter( type=type, identity=identity, post=self.post, ): interaction.transition_perform(PostInteractionStates.undone) self.post.calculate_stats() def like_as(self, identity: Identity): self.interact_as(identity, PostInteraction.Types.like) def unlike_as(self, identity: Identity): self.uninteract_as(identity, PostInteraction.Types.like) def boost_as(self, identity: Identity): self.interact_as(identity, PostInteraction.Types.boost) def unboost_as(self, identity: Identity): self.uninteract_as(identity, PostInteraction.Types.boost) def context( self, identity: Identity | None, num_ancestors: int = 10, num_descendants: int = 50, ) -> tuple[list[Post], list[Post]]: """ Returns ancestor/descendant information. Ancestors are guaranteed to be in order from closest to furthest. Descendants are in depth-first order, starting with closest. If identity is provided, includes mentions/followers-only posts they can see. Otherwise, shows unlisted and above only. """ # Retrieve ancestors via parent walk ancestors: list[Post] = [] ancestor = self.post while ancestor.in_reply_to and len(ancestors) < num_ancestors: object_uri = ancestor.in_reply_to reason = ancestor.object_uri ancestor = self.queryset().filter(object_uri=object_uri).first() if ancestor is None: try: Post.ensure_object_uri(object_uri, reason=reason) except ValueError: capture_message( f"Cannot fetch ancestor Post={self.post.pk}, ancestor_uri={object_uri}" ) break if ancestor.state in [PostStates.deleted, PostStates.deleted_fanned_out]: break ancestors.append(ancestor) # Retrieve descendants via breadth-first-search descendants: list[Post] = [] queue = [self.post] seen: set[str] = set() while queue and len(descendants) < num_descendants: node = queue.pop() child_queryset = ( self.queryset() .filter(in_reply_to=node.object_uri) .order_by("published") ) if identity: child_queryset = child_queryset.visible_to( identity=identity, include_replies=True ) else: child_queryset = child_queryset.unlisted(include_replies=True) for child in child_queryset: if child.pk not in seen: descendants.append(child) queue.append(child) seen.add(child.pk) return ancestors, descendants def delete(self): """ Marks a post as deleted and immediately cleans up its timeline events etc. """ self.post.transition_perform(PostStates.deleted) TimelineEvent.objects.filter(subject_post=self.post).delete() PostInteraction.transition_perform_queryset( PostInteraction.objects.filter( post=self.post, state__in=PostInteractionStates.group_active(), ), PostInteractionStates.undone, ) def pin_as(self, identity: Identity): if identity != self.post.author: raise ValueError("Not the author of this post") if self.post.visibility == Post.Visibilities.mentioned: raise ValueError("Cannot pin a mentioned-only post") if ( PostInteraction.objects.filter( type=PostInteraction.Types.pin, identity=identity, ).count() >= 5 ): raise ValueError("Maximum number of pins already reached") self.interact_as(identity, PostInteraction.Types.pin) def unpin_as(self, identity: Identity): self.uninteract_as(identity, PostInteraction.Types.pin)