コード例 #1
0
ファイル: models.py プロジェクト: danjac/localhub
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
コード例 #2
0
ファイル: models.py プロジェクト: danjac/localhub
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))
コード例 #3
0
ファイル: models.py プロジェクト: danjac/localhub
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 "")
コード例 #4
0
ファイル: models.py プロジェクト: danjac/localhub
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")
コード例 #5
0
ファイル: models.py プロジェクト: danjac/localhub
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
コード例 #6
0
ファイル: models.py プロジェクト: danjac/localhub
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)
コード例 #7
0
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"])