def test_custom_validator(self):
     # Setup
     machina_settings.MARKUP_MAX_LENGTH_VALIDATOR = 'django.core.validators.MaxLengthValidator'
     validator = validators.MarkupMaxLengthValidator(2)
     # Run & check
     assert validator('aa') is None
     with pytest.raises(ValidationError):
         validator('aaa')
class AbstractForumProfile(models.Model):
    """ Represents the profile associated with each forum user. """

    user = models.OneToOneField(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name='forum_profile',
        verbose_name=_('User'),
    )

    # The user's avatar.
    avatar = ExtendedImageField(null=True,
                                blank=True,
                                upload_to=get_profile_avatar_upload_to,
                                verbose_name=_('Avatar'),
                                **machina_settings.DEFAULT_AVATAR_SETTINGS)

    # The user's signature.
    signature = MarkupTextField(
        verbose_name=_('Signature'),
        blank=True,
        null=True,
        validators=[
            validators.MarkupMaxLengthValidator(
                machina_settings.PROFILE_SIGNATURE_MAX_LENGTH),
        ],
    )

    # The amount of posts the user has posted (only approved posts are considered here).
    posts_count = models.PositiveIntegerField(verbose_name=_('Total posts'),
                                              blank=True,
                                              default=0)

    class Meta:
        abstract = True
        app_label = 'forum_member'
        verbose_name = _('Forum profile')
        verbose_name_plural = _('Forum profiles')

    def __str__(self):
        return self.user.get_username()

    def get_avatar_upload_to(self, filename):
        """ Returns the path to upload the associated avatar to. """
        dummy, ext = os.path.splitext(filename)
        return os.path.join(
            machina_settings.PROFILE_AVATAR_UPLOAD_TO,
            '{id}{ext}'.format(id=str(uuid.uuid4()).replace('-', ''), ext=ext),
        )
class AbstractPost(DatedModel):
    """ Represents a forum post. A forum post is always linked to a topic. """

    topic = models.ForeignKey(
        'forum_conversation.Topic',
        related_name='posts',
        on_delete=models.CASCADE,
        verbose_name=_('Topic'),
    )
    poster = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        related_name='posts',
        blank=True,
        null=True,
        on_delete=models.CASCADE,
        verbose_name=_('Poster'),
    )
    anonymous_key = models.CharField(
        max_length=100,
        blank=True,
        null=True,
        verbose_name=_('Anonymous user forum key'),
    )

    # Each post can have its own subject. The subject of the thread corresponds to the
    # one associated with the first post
    subject = models.CharField(verbose_name=_('Subject'), max_length=255)

    # Content
    content = MarkupTextField(
        validators=[
            validators.MarkupMaxLengthValidator(
                machina_settings.POST_CONTENT_MAX_LENGTH),
        ],
        verbose_name=_('Content'),
    )

    # Username: if the user creating a topic post is not authenticated, he must enter a username
    username = models.CharField(max_length=155,
                                blank=True,
                                null=True,
                                verbose_name=_('Username'))

    # A post can be approved before publishing ; defaults to True
    approved = models.BooleanField(default=True,
                                   db_index=True,
                                   verbose_name=_('Approved'))

    # The user can choose if they want to display their signature with the content of the post
    enable_signature = models.BooleanField(
        default=True,
        db_index=True,
        verbose_name=_('Attach a signature'),
    )

    # A post can be edited for several reason (eg. moderation) ; the reason why it has been
    # updated can be specified
    update_reason = models.CharField(
        max_length=255,
        blank=True,
        null=True,
        verbose_name=_('Update reason'),
    )

    # Tracking data
    updated_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        editable=False,
        blank=True,
        null=True,
        on_delete=models.SET_NULL,
        verbose_name=_('Lastly updated by'),
    )
    updates_count = models.PositiveIntegerField(
        editable=False,
        blank=True,
        default=0,
        verbose_name=_('Updates count'),
    )

    objects = models.Manager()
    approved_objects = ApprovedManager()

    class Meta:
        abstract = True
        app_label = 'forum_conversation'
        ordering = [
            'created',
        ]
        get_latest_by = 'created'
        verbose_name = _('Post')
        verbose_name_plural = _('Posts')

    def __str__(self):
        return self.subject

    @property
    def is_topic_head(self):
        """ Returns ``True`` if the post is the first post of the topic. """
        return self.topic.first_post.id == self.id if self.topic.first_post else False

    @property
    def is_topic_tail(self):
        """ Returns ``True`` if the post is the last post of the topic. """
        return self.topic.last_post.id == self.id if self.topic.last_post else False

    @property
    def is_alone(self):
        """ Returns ``True`` if the post is the only single post of the topic. """
        return self.topic.posts.count() == 1

    @property
    def position(self):
        """ Returns an integer corresponding to the position of the post in the topic. """
        position = self.topic.posts.filter(
            Q(created__lt=self.created) | Q(id=self.id)).count()
        return position

    def clean(self):
        """ Validates the post instance. """
        super().clean()

        # At least a poster (user) or a session key must be associated with
        # the post.
        if self.poster is None and self.anonymous_key is None:
            raise ValidationError(
                _('A user id or an anonymous key must be associated with a post.'
                  ), )
        if self.poster and self.anonymous_key:
            raise ValidationError(
                _('A user id or an anonymous key must be associated with a post, but not both.'
                  ), )

        if self.anonymous_key and not self.username:
            raise ValidationError(
                _('A username must be specified if the poster is anonymous'))

    def save(self, *args, **kwargs):
        """ Saves the post instance. """
        new_post = self.pk is None
        super().save(*args, **kwargs)

        # Ensures that the subject of the thread corresponds to the one associated
        # with the first post. Do the same with the 'approved' flag.
        if (new_post and self.topic.first_post is None) or self.is_topic_head:
            if self.subject != self.topic.subject or self.approved != self.topic.approved:
                self.topic.subject = self.subject
                self.topic.approved = self.approved

        # Trigger the topic-level trackers update
        self.topic.update_trackers()

    def delete(self, using=None):
        """ Deletes the post instance. """
        if self.is_alone:
            # The default way of operating is to trigger the deletion of the associated topic
            # only if the considered post is the only post embedded in the topic
            self.topic.delete()
        else:
            super(AbstractPost, self).delete(using)
            self.topic.update_trackers()
 def test_default_validator(self):
     # Setup
     validator = validators.MarkupMaxLengthValidator(None)
     # Run & check
     assert validator(faker.text()) is None