class Poll(Activity): allow_voting = models.BooleanField(default=True) search_indexer = SearchIndexer(("A", "title"), ("B", "indexable_description")) objects = PollManager() def __str__(self): return self.title or _("Poll") @cached_property def total_num_votes(self): """ Returns number of votes. Use in conjunction with with_answers() method in queryset! """ return sum([a.num_votes for a in self.answers.all()]) @notify def notify_on_vote(self, voter): """ Sends a notification when someone has voted. Ignore if you vote on your own poll. """ if voter != self.owner: return self.make_notification(self.owner, "vote", voter) return None
class Photo(Activity): class License(models.TextChoices): ATTRIBUTION = "by", _("Attribution") ATTRIBUTION_SHAREALIKE = "by-sa", _("Attribution ShareAlike") ATTRIBUTION_NODERIVS = "by-nd", _("Attribution NoDerivs") ATTRIBUTION_NONCOMMERCIAL = "by-nc", _("Attribution NonCommercial") ATTRIBUTION_NONCOMMERCIAL_SHAREALIKE = ( "by-nc-sa", _("Attribution NonCommercial ShareAlike"), ) ATTRIBUTION_NONCOMMERCIAL_NODERIVS = ( "by-nc-nd", _("Attribution NonCommercial NoDerivs"), ) RESHARED_FIELDS = Activity.RESHARED_FIELDS + [ "image", "artist", "original_url", "cc_license", "latitude", "longitude", ] INDEXABLE_DESCRIPTION_FIELDS = Activity.INDEXABLE_DESCRIPTION_FIELDS + [ "artist", ] image = ImageField( upload_to="photos", verbose_name=_("Photo"), help_text=_("For best results, photos should be no larger than 1MB. " "If the image is too large it will not be accepted."), ) latitude = models.FloatField(null=True, blank=True) longitude = models.FloatField(null=True, blank=True) artist = models.CharField(max_length=100, blank=True) original_url = models.URLField(max_length=500, null=True, blank=True) cc_license = models.CharField( max_length=10, choices=License.choices, null=True, blank=True, verbose_name="Creative Commons license", ) search_indexer = SearchIndexer(("A", "title"), ("B", "indexable_description")) def __str__(self): return self.title or _("Photo") def has_attribution(self): return any((self.artist, self.original_url, self.cc_license)) def has_map(self): return all((self.latitude, self.longitude))
class Post(Activity): RESHARED_FIELDS = Activity.RESHARED_FIELDS + [ "opengraph_description", "opengraph_image", "url", ] INDEXABLE_DESCRIPTION_FIELDS = Activity.INDEXABLE_DESCRIPTION_FIELDS + [ "opengraph_description", ] title = models.CharField(max_length=300, blank=True) url = models.URLField(max_length=500, blank=True) # metadata fetched from URL if available opengraph_image = models.URLField(max_length=500, blank=True) opengraph_description = models.TextField(blank=True) search_indexer = SearchIndexer(("A", "title"), ("B", "indexable_description")) def __str__(self): return self.title or self.get_domain() or _("Post") def get_domain(self): return get_domain(self.url) or "" def get_opengraph_image_if_safe(self): """ Returns metadata image if it is an https URL and a valid image extension. Otherwise returns empty string. """ return (self.opengraph_image if is_https(self.opengraph_image) and is_image_url(self.opengraph_image) else "")
class Message(TimeStampedModel): community = models.ForeignKey(Community, on_delete=models.CASCADE) sender = models.ForeignKey( settings.AUTH_USER_MODEL, related_name="sent_messages", on_delete=models.CASCADE ) recipient = models.ForeignKey( settings.AUTH_USER_MODEL, related_name="received_messages", on_delete=models.CASCADE, ) message = MarkdownField() parent = models.ForeignKey( "self", null=True, blank=True, on_delete=models.SET_NULL, related_name="replies", ) read = models.DateTimeField(null=True, blank=True) recipient_deleted = models.DateTimeField(null=True, blank=True) sender_deleted = models.DateTimeField(null=True, blank=True) bookmarks = GenericRelation(Bookmark, related_query_name="message") search_document = SearchVectorField(null=True, editable=False) search_indexer = SearchIndexer(("A", "message")) objects = MessageManager() class Meta: indexes = [ GinIndex(fields=["search_document"]), models.Index(fields=["created", "-created", "read"]), ] def __str__(self): return self.message def get_absolute_url(self): return reverse("private_messages:message_detail", args=[self.id]) def get_permalink(self): """Returns absolute URL including complete community URL. Returns: str """ return self.community.resolve_url(self.get_absolute_url()) def get_notifications(self): return get_generic_related_queryset(self, Notification) def abbreviated(self, length=30): """Returns *final* non-HTML/markdown abbreviated version of message. Args: length (int, optional): abbreviated max character length (default: 30) Returns: str """ text = " ".join(self.message.plaintext().splitlines()) total_length = len(text) if total_length < length: return text return "..." + text[total_length - length :] def accessible_to(self, user): """ Checks if user is a) sender or recipient and b) has not deleted the message. Args: user (User): sender or recipient Returns: bool """ return (self.sender == user and self.sender_deleted is None) or ( self.recipient == user and self.recipient_deleted is None ) def soft_delete(self, user): """Does a "soft delete": If user is recipient, sets recipient_deleted to current time. If user is sender, sets sender_deleted to current time. If both are set then the message itself is permanently deleted. Args: user (User): sender or recipient """ field = "recipient_deleted" if user == self.recipient else "sender_deleted" setattr(self, field, timezone.now()) if self.recipient_deleted and self.sender_deleted: self.delete() else: self.save(update_fields=[field]) def get_parent(self, user): """Returns parent if exists and is visible to user. Args: user (User): sender or recipient Returns: Message or None """ if self.parent and self.parent.accessible_to(user): return self.parent return None def get_other_user(self, user): """Return either recipient or sender, depending on user match, i.e. if user is the sender, returns the recipient, and vice versa. Args: user (User): sender or recipient Returns: User: recipient or sender """ return self.recipient if user == self.sender else self.sender def mark_read(self, mark_replies=False): """Marks message read. Any associated Notification instances are marked read. Any unread messages or messages where user is not the recipient are ignored. Args: mark_replies (bool, optional): mark all replies read if recipient (default: False) """ if not self.read: self.read = timezone.now() self.save(update_fields=["read"]) self.get_notifications().mark_read() if mark_replies: self.get_all_replies().for_recipient(self.recipient).mark_read() def get_all_replies(self): """ Returns: QuerySet: all replies including replies' descendants (recursive). """ return self.__class__._default_manager.all_replies(self) def make_notification(self, verb): return Notification( content_object=self, actor=self.sender, recipient=self.recipient, community=self.community, verb=verb, ) @notify def notify_on_send(self): """Send notification to recipient. Returns: Notification """ return self.make_notification("send") @notify def notify_on_reply(self): """Send notification to recipient. Returns: Notification """ return self.make_notification("reply") @notify def notify_on_follow_up(self): """Send notification to recipient. Returns: Notification """ return self.make_notification("follow_up")
class Event(Activity): # not exhaustive! ADDRESS_FORMATS = [ ( ("GB", "IN", "PK", "ZA", "JP"), "{street_address}, {locality}, {region}, {postcode}, {country}", ), ( ("US", "AU", "NZ"), "{street_address} {locality}, {postcode}, {region}, {country}", ), ( ("RU", ), "{street_address} {locality} {postcode}, {region}, {country}", ), ] # default for Europe, S. America, China, S. Korea DEFAULT_ADDRESS_FORMAT = ( "{street_address}, {postcode} {locality}, {region}, {country}") LOCATION_FIELDS = [ "street_address", "locality", "postal_code", "region", "country", ] RESHARED_FIELDS = (Activity.RESHARED_FIELDS + LOCATION_FIELDS + [ "url", "starts", "ends", "timezone", "venue", "latitude", "longitude", ]) class InvalidDate(ValueError): """Used with date queries""" ... class RepeatChoices(models.TextChoices): DAILY = "day", _("Same time every day") WEEKLY = "week", _("Same day of the week at the same time") MONTHLY = ( "month", _("First day of the month at the same time"), ) YEARLY = "year", _("Same date and time every year") url = models.URLField(verbose_name=_("Link"), max_length=500, null=True, blank=True) starts = models.DateTimeField(verbose_name=_("Starts on (UTC)")) # Note: "ends" must be same day if repeating ends = models.DateTimeField(null=True, blank=True) repeats = models.CharField(max_length=20, choices=RepeatChoices.choices, null=True, blank=True) repeats_until = models.DateTimeField(null=True, blank=True) timezone = TimeZoneField(default=settings.TIME_ZONE) canceled = models.DateTimeField(null=True, blank=True) venue = models.CharField(max_length=200, blank=True) street_address = models.CharField(max_length=200, blank=True) locality = models.CharField(verbose_name=_("City or town"), max_length=200, blank=True) postal_code = models.CharField(max_length=20, blank=True) region = models.CharField(max_length=200, blank=True) country = CountryField(null=True, blank=True) latitude = models.FloatField(null=True, blank=True) longitude = models.FloatField(null=True, blank=True) attendees = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True, related_name="attending_events") search_indexer = SearchIndexer( ("A", "title"), ("B", "indexable_location"), ("C", "indexable_description"), ) objects = EventManager() class Meta(Activity.Meta): indexes = Activity.Meta.indexes + [ models.Index(fields=["starts"], name="event_starts_idx") ] def __str__(self): return self.title or self.location def clean(self): if self.ends and self.ends < self.starts: raise ValidationError(_("End date cannot be before start date")) if self.ends and self.ends.date() != self.starts.date( ) and self.repeats: raise ValidationError( _("End date must be same as start date if repeating")) if self.repeats_until and not self.repeats: raise ValidationError( _("Repeat until date cannot be set if not a repeating event")) if self.repeats_until and self.repeats_until < self.starts: raise ValidationError( _("Repeat until date cannot be before start date")) def get_domain(self): return get_domain(self.url) or "" def get_next_starts_with_tz(self): """Returns timezone-adjusted start time. Returns: datetime """ return self.get_next_start_date().astimezone(self.timezone) def get_next_ends_with_tz(self): """Returns timezone-adjusted end time. Returns: datetime or None: returns None if ends is None. """ if ends := self.get_next_end_date(): return ends.astimezone(self.timezone) return None
class Comment(TrackerModelMixin, TimeStampedModel): community = models.ForeignKey(Community, on_delete=models.CASCADE) owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) editor = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+", ) edited = models.DateTimeField(null=True, blank=True) deleted = models.DateTimeField(null=True, blank=True) content_type = models.ForeignKey(ContentType, on_delete=models.SET_NULL, null=True, blank=True) object_id = models.PositiveIntegerField(null=True, blank=True) content_object = GenericForeignKey("content_type", "object_id") parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.SET_NULL) content = MarkdownField() search_document = SearchVectorField(null=True, editable=False) bookmarks = GenericRelation(Bookmark, related_query_name="comment") flags = GenericRelation(Flag, related_query_name="comment") likes = GenericRelation(Like, related_query_name="comment") notifications = GenericRelation(Notification, related_query_name="comment") search_indexer = SearchIndexer(("A", "content")) tracked_fields = ["content"] objects = CommentManager() class Meta: indexes = [ GinIndex(fields=["search_document"]), models.Index(fields=["content_type", "object_id"]), models.Index(fields=["created", "-created"]), ] def __str__(self): return self.abbreviate() def get_absolute_url(self): return reverse("comments:detail", args=[self.id]) def get_permalink(self): """ Returns absolute URL including the community domain. """ return self.community.resolve_url(self.get_absolute_url()) def get_notifications(self): return get_generic_related_queryset(self, Notification) def abbreviate(self, length=30): text = " ".join(self.content.plaintext().splitlines()) return truncatechars(text, length) def get_bookmarks(self): return Bookmark.objects.filter(comment=self) def get_flags(self): return Flag.objects.filter(comment=self) def get_likes(self): return Like.objects.filter(comment=self) def soft_delete(self): self.deleted = timezone.now() self.save(update_fields=["deleted"]) self.get_likes().delete() self.get_flags().delete() self.get_notifications().delete() def make_notification(self, verb, recipient, actor=None): return Notification( content_object=self, recipient=recipient, actor=actor or self.owner, community=self.community, verb=verb, ) def notify_mentioned(self, recipients): return [ self.make_notification("mention", recipient) for recipient in recipients.matches_usernames( self.content.extract_mentions()).exclude(pk=self.owner_id) ] def get_notification_recipients(self): return self.community.members.exclude(blocked=self.owner) def get_content_object(self): """Returns content object; if object is soft deleted, returns None. Returns: Activity or None """ obj = self.content_object if obj and obj.deleted: return None return obj def get_parent(self): """Returns parent; if object is soft deleted, returns None. Note: if "is_parent_owner_member" annotated attribute is present and False will also return None. This attribute is annotated with the `with_is_parent_owner_member` QuerySet method. Returns: Comment or None """ if not getattr(self, "is_parent_owner_member", True): return None obj = self.parent if obj and obj.deleted: return None return obj @notify def notify_on_create(self): if (content_object := self.get_content_object()) is None: return [] notifications = [] recipients = self.get_notification_recipients() notifications += self.notify_mentioned(recipients) # notify the activity owner if self.owner_id != content_object.owner_id: notifications += [ self.make_notification("new_comment", content_object.owner) ] # notify the person being replied to if self.parent: notifications += [ self.make_notification("reply", self.parent.owner) ] # notify anyone who has commented on this post, excluding # this comment owner and parent owner other_commentors = (recipients.filter( comment__in=content_object.get_comments()).exclude( pk__in=(self.owner_id, content_object.owner_id)).distinct()) if self.parent: other_commentors = other_commentors.exclude( pk=self.parent.owner.id) notifications += [ self.make_notification("new_sibling", commentor) for commentor in other_commentors ] notifications += [ self.make_notification("followed_user", follower) for follower in recipients.filter(following=self.owner).exclude( pk__in=other_commentors).distinct() ] return takefirst(notifications, lambda n: n.recipient)
class User(TrackerModelMixin, AbstractUser): class ActivityStreamFilters(models.TextChoices): USERS = "users", _("Limited to only content from people I'm following") TAGS = "tags", _("Limited to only tags I'm following") name = models.CharField(_("Full name"), blank=True, max_length=255) bio = MarkdownField(blank=True) avatar = ImageField(upload_to="avatars", null=True, blank=True) language = models.CharField( max_length=6, choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, ) default_timezone = TimeZoneField(default=settings.TIME_ZONE) activity_stream_filters = ChoiceArrayField( models.CharField(max_length=12, choices=ActivityStreamFilters.choices), default=list, blank=True, ) show_external_images = models.BooleanField(default=True) show_sensitive_content = models.BooleanField(default=False) show_embedded_content = models.BooleanField(default=False) send_email_notifications = models.BooleanField(default=True) dismissed_notices = ArrayField(models.CharField(max_length=30), default=list) following = models.ManyToManyField("self", related_name="followers", blank=True, symmetrical=False) blocked = models.ManyToManyField("self", related_name="blockers", blank=True, symmetrical=False) following_tags = models.ManyToManyField(Tag, related_name="+", blank=True) blocked_tags = models.ManyToManyField(Tag, related_name="+", blank=True) search_document = SearchVectorField(null=True, editable=False) search_indexer = SearchIndexer(("A", "username"), ("B", "name"), ("C", "bio")) tracked_fields = ["avatar", "name", "bio"] objects = UserManager() class Meta(AbstractUser.Meta): indexes = [ GinIndex(fields=["search_document"]), models.Index(fields=["name", "username"]), ] def get_absolute_url(self): return reverse("users:activities", args=[self.username]) def get_display_name(self): """Displays full name or username Returns: str: full display name """ return self.name or self.username def get_initials(self): return "".join([n[0].upper() for n in self.get_display_name().split()][:2]) def get_notifications(self): """Returns notifications where the user is the target content object, *not* necessarily the actor or recipient. Returns: QuerySet """ return get_generic_related_queryset(self, Notification) @cached_property def member_cache(self): """ Returns: A MemberCache instance of membership status/roles across all communities the user belongs to. """ mc = MemberCache() for community_id, role, active in Membership.objects.filter( member=self).values_list("community", "role", "active"): mc.add_role(community_id, role, active) return mc def has_role(self, community, *roles): """Checks if user has given role in the community, if any. Result is cached. Args: community (Community) *roles: roles i.e. one or more of "member", "moderator", "admin". If empty assumes any role. Returns: bool: if user has any of these roles """ return self.member_cache.has_role(community.id, roles) def is_admin(self, community): return self.has_role(community, Membership.Role.ADMIN) def is_moderator(self, community): return self.has_role(community, Membership.Role.MODERATOR) def is_member(self, community): return self.has_role(community, Membership.Role.MEMBER) def is_active_member(self, community): """Checks if user an active member of any role. Returns: bool """ return self.has_role(community) def is_inactive_member(self, community): """Checks if user has an inactive membership for this community. Returns: bool """ return self.member_cache.is_inactive(community.id) def is_blocked(self, user): """Check if user is blocking this other user, or is blocked by this other user. Args: user (User) Returns: bool """ if self == user: return False return self.get_blocked_users().filter(pk=user.id).exists() def is_activity_stream_tags_filter(self): return self.ActivityStreamFilters.TAGS in self.activity_stream_filters def is_activity_stream_users_filter(self): return self.ActivityStreamFilters.USERS in self.activity_stream_filters def is_activity_stream_all_filters(self): return (self.is_activity_stream_tags_filter() and self.is_activity_stream_users_filter()) def get_blocked_users(self): """Return a) users I'm blocking and b) users blocking me. Returns: QuerySet """ return (self.blockers.all() | self.blocked.all()).distinct() @transaction.atomic def block_user(self, user): """Blocks this user. Any following relationships are also removed. Args: user (User) """ self.blocked.add(user) self.following.remove(user) self.followers.remove(user) @notify def notify_on_join(self, community): """Returns notification to all other current members that this user has just joined the community. Args: community (Community) Returns: list: list of Notification instances """ return [ Notification( content_object=self, actor=self, recipient=member, community=community, verb="new_member", ) for member in community.members.exclude(pk=self.pk) ] @notify def notify_on_follow(self, recipient, community): """Sends notification to recipient that they have just been followed. Args: recipient (User) community (Community) Returns: Notification """ return Notification( content_object=self, actor=self, recipient=recipient, community=community, verb="new_follower", ) @notify def notify_on_update(self): """Sends notification to followers that user has updated their profile. This is sent to followers across all communities where the user is an active member. If follower belongs to multiple common communities, we just send notification to one community. We only send notifications if certain tracked fields are updated e.g. bio or avatar. Returns: list: Notifications to followers """ if self.has_tracker_changed(): return takefirst( [ Notification( content_object=self, actor=self, recipient=follower, community=membership.community, verb="update", ) for membership in self.membership_set.filter( active=True).select_related("community") for follower in self.followers.for_community(membership.community) ], lambda n: n.recipient, ) def get_email_addresses(self): """Get set of emails belonging to user. Returns: set: set of email addresses """ return set([self.email]) | set( self.emailaddress_set.values_list("email", flat=True)) def dismiss_notice(self, notice): """ Adds notice permanently to list of dismissed notices. Args: notice (str): unique notice ID e.g. "private-stash" """ if notice not in self.dismissed_notices: self.dismissed_notices.append(notice) self.save(update_fields=["dismissed_notices"])