class Fragment(models.Model): """ Configurable HTML fragments that can be inserted in pages. """ ref = models.CharField( _("Identifier"), max_length=100, unique=True, db_index=True, help_text=_("Unique identifier for fragment name"), ) title = models.CharField( max_length=100, blank=True, help_text=_( "Optional description that helps humans identify the content and " "role of the fragment."), ) format = EnumField(Format, _("Format"), help_text=_("Defines how content is interpreted.")) content = models.TextField( _("content"), blank=True, help_text=_("Raw fragment content in HTML or Markdown"), ) editable = models.BooleanField(default=True, editable=False) def __str__(self): return self.ref def __html__(self): return str(self.render()) def save(self, *args, **kwargs): super().save(*args, **kwargs) invalidate_cache(self.ref) def delete(self, using=None, keep_parents=False): super().delete(using, keep_parents) invalidate_cache(self.ref) def lock(self): """ Prevents fragment from being deleted on the admin. """ FragmentLock.objects.update_or_create(fragment=self) def unlock(self): """ Allows fragment being deleted. """ FragmentLock.objects.filter(fragment=self).delete() def render(self, request=None, **kwargs): """Render element to HTML""" return self.format.render(self.content, request, kwargs)
class Message(TimeStampedModel): channel = models.ForeignKey(Channel, on_delete=models.CASCADE, null=True) title = models.CharField(max_length=100) body = models.CharField(max_length=250) link = models.CharField(max_length=250, blank=True) status = models.CharField(max_length=100, default="pending") target = models.IntegerField(blank=True, default=0) class Meta: ordering = ["title"]
class User(models.Model): name = models.CharField(max_length=100) age = models.IntegerField(blank=True, null=True) created = models.DateTimeField(default=now) modified = models.DateTimeField(auto_now=True) gender = models.EnumField(Gender, blank=True, null=True) __str__ = (lambda self: self.name)
class Color(models.Model): """ Generic color reference that can be configured in the admin interface. """ name = models.CharField(_("Color name"), max_length=150) hex_value = models.CharField( _("Color"), max_length=30, help_text=_("Color code in hex (e.g., #RRGGBBAA) format."), validators=[validate_color], ) def __str__(self): return f"{self.name}: {self.hex_value}" def __html__(self): return self.hex_value
class Channel(TimeStampedModel): name = models.CharField(max_length=100) users = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name="channels", blank=True) purpose = models.EnumField(Purpose, _("Purpose"), default=Purpose.GENERAL) owner = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, related_name="owned_channels" ) slug = AutoSlugField(unique=True, populate_from="name") class Meta: ordering = ["slug"]
class ConversationMautic(models.Model): """ Allows correlation between a conversation and an instance of Mautic """ user_name = models.CharField(_("Mautic username"), max_length=100) password = models.CharField(_("Mautic password"), max_length=200) url = models.URLField(_("Mautic URL"), max_length=255, help_text=_("Generated Url from Mautic.")) conversation = models.ForeignKey("Conversation", on_delete=models.CASCADE, related_name="mautic_integration") class Meta: unique_together = (("conversation", "url"), ) ordering = ["-id"]
class Discipline(DescriptiveModel): """ An academic discipline. """ organization = models.ForeignKey( 'Organization', blank=True, on_delete=models.CASCADE, ) school_id = models.CharField( max_length=50, blank=True ) since = models.DateField(blank=True, null=True) # These were modeled as in https://matriculaweb.unb.br/, which is not # particularly good. In the future we may want more structured data types. syllabus = models.TextField(blank=True) program = models.TextField(blank=True) bibliography = models.TextField(blank=True)
class AbstractUser(auth.AbstractUser, models.Model): """ A user object with a single name field instead of separate first_name and last_name. This is the abstract version of the model. Use it for subclassing. """ name = models.CharField(_("Name"), max_length=255, default="", help_text=_("User's full name")) first_name = sk_property(this.name.partition(" ")[0]) last_name = sk_property(this.name.partition(" ")[-1]) @first_name.setter def first_name(self, value): pre, _, post = self.name.partition(" ") self.name = f"{value} {post}" if post else value @last_name.setter def last_name(self, value): pre, _, post = self.name.partition(" ") self.name = f"{pre} {value}" if post else value objects = UserManager() class Meta: abstract = True def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not self.name: self.name = self.username def __str__(self): return self.email
class NumericQuestion(Question): """ A very simple question with a simple numeric answer. """ correct_answer = models.FloatField( _('Correct answer'), help_text=_( 'The expected numeric answer for question.' ) ) tolerance = models.FloatField( _('Tolerance'), default=0, help_text=_( 'If tolerance is zero, the responses must be exact.' ), ) label = models.CharField( _('Label'), max_length=100, default=_('Answer'), help_text=_( 'The label text that is displayed in the submission form.' ), ) help_text = models.TextField( _('Help text'), blank=True, help_text=_( 'Additional explanation that is displayed bellow the response field ' 'in the input form.' ) ) class Meta: verbose_name = _('Numeric question') verbose_name_plural = _('Numeric questions')
class Book(models.Model): title = models.CharField(max_length=100) author = models.ForeignKey(User, on_delete=models.CASCADE) def __str__(self): return f'{self.title} ({self.author})'
class Cluster(TimeStampedModel): """ Represents an opinion group. """ clusterization = models.ForeignKey("Clusterization", on_delete=models.CASCADE, related_name="clusters") name = models.CharField(_("Name"), max_length=64) description = models.TextField( _("Description"), blank=True, help_text=_("How was this cluster conceived?")) users = models.ManyToManyField(get_user_model(), related_name="clusters", blank=True) stereotypes = models.ManyToManyField("Stereotype", related_name="clusters") conversation = delegate_to("clusterization") comments = delegate_to("clusterization") objects = ClusterManager() @property def votes(self): return self.clusterization.votes.filter(author__in=self.users.all()) @property def stereotype_votes(self): return self.clusterization.stereotype_votes.filter( author__in=self.stereotypes.all()) n_votes = lazy(this.votes.count()) n_users = lazy(this.users.count()) n_stereotypes = lazy(this.stereotypes.count()) n_stereotype_votes = lazy(this.n_stereotype_votes.count()) def __str__(self): msg = _('{name} ("{conversation}" conversation, {n} users)') return msg.format(name=self.name, conversation=self.conversation, n=self.users.count()) def get_absolute_url(self): args = {"conversation": self.conversation, "cluster": self} return reverse("cluster:detail", kwargs=args) def mean_stereotype(self): """ Return the mean stereotype for cluster. """ stereotypes = self.stereotypes.all() votes = StereotypeVote.objects.filter( author__in=Subquery(stereotypes.values("id"))).values_list( "comment", "choice") df = pd.DataFrame(list(votes), columns=["comment", "choice"]) if len(df) == 0: return pd.DataFrame([], columns=["choice"]) else: return df.pivot_table("choice", index="comment", aggfunc="mean") def comments_statistics_summary_dataframe(self, normalization=1.0): """ Like comments.statistics_summary_dataframe(), but restricts votes to users in the current clusters. """ kwargs = dict(normalization=normalization, votes=self.votes) return self.comments.statistics_summary_dataframe(**kwargs) def separate_comments(self, sort=True): """ Separate comments into a pair for comments that cluster agrees to and comments that cluster disagree. """ tol = 1e-6 table = self.votes.votes_table() n_agree = (table > 0).sum() n_disagree = (table < 0).sum() total = n_agree + n_disagree + (table == 0).sum() + tol d_agree = dict( ((n_agree[n_agree >= n_disagree] + tol) / total).dropna().items()) d_disagree = dict(((n_disagree[n_disagree > n_agree] + tol) / total).dropna().items()) agree = [] disagree = [] for comment in Comment.objects.filter(id__in=d_agree): # It would accept 0% agreement since we test sfor n_agree >= n_disagree # We must prevent cases with 0 agrees (>= 0 disagrees) to enter in # the calculation n_agree = d_agree[comment.id] if n_agree: comment.agree = n_agree agree.append(comment) for comment in Comment.objects.filter(id__in=d_disagree): comment.disagree = d_disagree[comment.id] disagree.append(comment) if sort: agree.sort(key=lambda c: c.agree, reverse=True) disagree.sort(key=lambda c: c.disagree, reverse=True) return agree, disagree
class Classroom(DescriptiveModel): """ One specific occurrence of a course for a given teacher in a given period. """ long_description = models.LongDescriptionField(blank=True) discipline = models.ForeignKey( 'Discipline', blank=True, null=True, on_delete=models.SET_NULL, ) location = models.CharField( _('location'), blank=True, max_length=140, help_text=_('Physical location of classroom, if applicable.'), ) teacher = models.ForeignKey( User, related_name='classrooms_as_teacher', on_delete=models.PROTECT ) students = models.ManyToManyField( User, verbose_name=_('students'), related_name='classrooms_as_student', blank=True, ) staff = models.ManyToManyField( User, verbose_name=_('staff'), related_name='classrooms_as_staff', blank=True, ) is_accepting_subscriptions = models.BooleanField( _('accept subscriptions'), default=True, help_text=_( 'Set it to false to prevent new student subscriptions.' ), ) is_public = models.BooleanField( _('is it public?'), default=False, help_text=_( 'If true, all students will be able to see the contents of the ' 'course. Most activities will not be available to non-subscribed ' 'students.' ), ) comments_policy = models.EnumField( CommentPolicyEnum, blank=True, null=True, ) subscription_passphrase = models.CharField( _('subscription passphrase'), default=phrase_lower, max_length=140, help_text=_( 'A passphrase/word that students must enter to subscribe in the ' 'course. Leave empty if no passphrase should be necessary.' ), blank=True, ) objects = ClassroomManager() def register_student(self, user): """ Register a new student in the course. """ if user == self.teacher: raise ValidationError(_('Teacher cannot enroll as student.')) elif self.staff.filter(id=user.id): raise ValidationError(_('Staff member cannot enroll as student.')) self.students.add(user) def register_staff(self, user): """ Register a new user as staff. """ if user == self.teacher: raise ValidationError(_('Teacher cannot enroll as staff.')) self.students.add(user)
class Submission(HasProgressMixin, models.CopyMixin, models.TimeStampedModel, models.PolymorphicModel): """ Represents a student's simple submission in response to some activity. """ progress = models.ForeignKey('Progress', related_name='submissions') hash = models.CharField(max_length=32, blank=True) ip_address = models.CharField(max_length=20, blank=True) num_recycles = models.IntegerField(default=0) recycled = False has_feedback = property(lambda self: hasattr(self, 'feedback')) objects = SubmissionManager() class Meta: verbose_name = _('submission') verbose_name_plural = _('submissions') # Delegated properties @property def final_grade_pc(self): if self.has_feedback: return None return self.feedback.final_grade_pc @property def feedback_class(self): name = self.__class__.__name__.replace('Submission', 'Feedback') return apps.get_model(self._meta.app_label, name) def __repr__(self): return '<%s: %s>' % (self.__class__.__name__, self) def __str__(self): base = '%s by %s' % (self.activity_title, self.sender_username) # if self.feedback_set.last(): # points = self.final_feedback_pc.given_grade # base += ' (%s%%)' % points return base def save(self, *args, **kwargs): if not self.hash: self.hash = self.compute_hash() super().save(*args, **kwargs) def compute_hash(self): """ Computes a hash of data to deduplicate submissions. """ raise ImproperlyConfigured( 'Submission subclass must implement the compute_hash() method.') def auto_feedback(self, silent=False): """ Performs automatic grading and return the feedback object. Args: silent: Prevents the submission_graded_signal from triggering in the end of a successful grading. """ feedback = self.feedback_class(submission=self, manual_grading=False) feedback.update_autograde() feedback.update_final_grade() feedback.save() self.progress.register_feedback(feedback) self.register_feedback(feedback) # Send signal if not silent: submission_graded_signal.send(Submission, submission=self, feedback=feedback, automatic=True) return feedback def register_feedback(self, feedback, commit=True): """ Update itself when a new feedback becomes available. This method should not update the progress instance. """ self.final_feedback = feedback if commit: self.save() def bump_recycles(self): """ Increase the recycle count by one. """ self.num_recycles += 1 self.save(update_fields=['num_recycles']) def is_equal(self, other): """ Check both submissions are equal/equivalent to each other. """ if self.hash == other.hash and self.hash is not None: return True return self.submission_data() == other.submission_data() def submission_data(self): """ Return a dictionary with data specific for submission. It ignores metadata such as creation and modification times, number of recycles, etc. This method should only return data relevant to grading the submission. """ blacklist = { 'id', 'num_recycles', 'ip_address', 'created', 'modified', 'hash', 'final_feedback_id', 'submission_ptr_id', 'polymorphic_ctype_id', } def forbidden_attr(k): return k.startswith('_') or k in blacklist return { k: v for k, v in self.__dict__.items() if not forbidden_attr(k) } def autograde_value(self, *args, **kwargs): """ This method should be implemented in subclasses. """ raise ImproperlyConfigured( 'Progress subclass %r must implement the autograde_value().' 'This method should perform the automatic grading and return the ' 'resulting grade. Any additional relevant feedback data might be ' 'saved to the `feedback_data` attribute, which is then is pickled ' 'and saved into the database.' % type(self).__name__) def manual_grade(self, grade, commit=True, raises=False, silent=False): """ Saves result of manual grading. Args: grade (number): Given grade, as a percentage value. commit: If false, prevents saving the object when grading is complete. The user must save the object manually after calling this method. raises: If submission has already been graded, raises a GradingError. silent: Prevents the submission_graded_signal from triggering in the end of a successful grading. """ if self.status != self.STATUS_PENDING and raises: raise GradingError('Submission has already been graded!') raise NotImplementedError('TODO') def update_progress(self, commit=True): """ Update all parameters for the progress object. Return True if update was required or False otherwise. """ update = False progress = self.progress if self.is_correct and not progress.is_correct: update = True progress.is_correct = True if self.given_grade_pc > progress.best_given_grade_pc: update = True fmt = self.description, progress.best_given_grade_pc, self.given_grade_pc progress.best_given_grade_pc = self.given_grade_pc logger.info('(%s) grade: %s -> %s' % fmt) if progress.best_given_grade_pc > progress.grade: old = progress.grade new = progress.grade = progress.best_given_grade_pc logger.info('(%s) grade: %s -> %s' % (progress.description, old, new)) if commit and update: progress.save() return update def regrade(self, method, commit=True): """ Recompute the grade for the given submission. If status != 'done', it simply calls the .autograde() method. Otherwise, it accept different strategies for updating to the new grades: 'update': Recompute the grades and replace the old values with the new ones. Only saves the submission if the feedback_data or the given_grade_pc attributes change. 'best': Only update if the if the grade increase. 'worst': Only update if the grades decrease. 'best-feedback': Like 'best', but updates feedback_data even if the grades change. 'worst-feedback': Like 'worst', but updates feedback_data even if the grades change. Return a boolean telling if the regrading was necessary. """ if self.status != self.STATUS_DONE: return self.auto_feedback() # We keep a copy of the state, if necessary. We only have to take some # action if the state changes. def rollback(): self.__dict__.clear() self.__dict__.update(state) state = self.__dict__.copy() self.auto_feedback(force=True, commit=False) # Each method deals with the new state in a different manner if method == 'update': if state != self.__dict__: if commit: self.save() return False return True elif method in ('best', 'best-feedback'): if self.given_grade_pc <= state.get('given_grade_pc', 0): new_feedback_data = self.feedback_data rollback() if new_feedback_data != self.feedback_data: self.feedback_data = new_feedback_data if commit: self.save() return True return False elif commit: self.save() return True elif method in ('worst', 'worst-feedback'): if self.given_grade_pc >= state.get('given_grade_pc', 0): new_feedback_data = self.feedback_data rollback() if new_feedback_data != self.feedback_data: self.feedback_data = new_feedback_data if commit: self.save() return True return False elif commit: self.save() return True else: rollback() raise ValueError('invalid method: %s' % method) def get_feedback_title(self): """ Return the title for the feedback message. """ try: feedback = self.feedback except AttributeError: return _('Not graded') else: return feedback.get_feedback_title()
class Stereotype(models.Model): """ A "fake" user created to help with classification. """ name = models.CharField( _("Name"), max_length=64, help_text=_("Public identification of persona.") ) owner = models.ForeignKey( settings.AUTH_USER_MODEL, related_name="stereotypes", on_delete=models.CASCADE ) description = models.TextField( _("Description"), blank=True, help_text=_( "Specify a background history, or give hints about the profile this persona wants to " "capture. This information is optional and is not made public." ), ) objects = StereotypeQuerySet.as_manager() class Meta: unique_together = [("name", "owner")] __str__ = lambda self: f'{self.name} ({self.owner})' def vote(self, comment, choice, commit=True): """ Cast a single vote for the stereotype. """ choice = Choice.normalize(choice) log.debug(f"Vote: {self.name} (stereotype) - {choice}") vote = StereotypeVote(author=self, comment=comment, choice=choice) vote.full_clean() if commit: vote.save() return vote def cast_votes(self, choices): """ Create votes from dictionary of comments to choices. """ votes = [] for comment, choice in choices.items(): votes.append(self.vote(comment, choice, commit=False)) StereotypeVote.objects.bulk_update(votes) return votes def non_voted_comments(self, conversation): """ Return a queryset with all comments that did not receive votes. """ voted = StereotypeVote.objects.filter( author=self, comment__conversation=conversation ) comment_ids = voted.values_list("comment", flat=True) return conversation.comments.exclude(id__in=comment_ids) def voted_comments(self, conversation): """ Return a queryset with all comments that the stereotype has cast votes. The resulting queryset is annotated with the vote value using the choice attribute. """ voted = StereotypeVote.objects.filter( author=self, comment__conversation=conversation ) voted_subquery = voted.filter(comment=OuterRef("id")).values("choice") comment_ids = voted.values_list("comment", flat=True) return conversation.comments.filter(id__in=comment_ids).annotate( choice=Subquery(voted_subquery) )
class SocialIcon(models.Model): """ Configurable reference to a social media icon. """ social_network = models.CharField( _("Social network"), max_length=50, unique=True, help_text=_("Name of the social network (e.g., Facebook)"), ) icon_name = models.CharField( _("Icon name"), max_length=50, help_text=_("Icon name in font-awesome. Use short version like " '"google", "facebook-f", etc.'), validators=[validate_icon_name], ) index = models.PositiveSmallIntegerField( _("Ordering"), default=0, help_text=_( "You can manually define the ordering that each icon should " "appear in the interface. Otherwise, icons will be shown in " "insertion order."), ) url = models.URLField(_("URL"), help_text=_("Link to your social account page.")) @property def fa_class(self): collection = FA_COLLECTIONS.get(self.icon_name) return collection and f"{collection} fa-{self.icon_name}" class Meta: ordering = ["index", "id"] verbose_name = _("Social media icon") verbose_name_plural = _("Social media icons") def __str__(self): return self.social_network def __html__(self): if self.url: return str(self.link_tag()) else: return str(self.icon_tag()) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._fill_social_icon() def _fill_social_icon(self): if not self.icon_name: self.icon_name = default_icon_name(self.social_network.casefold()) def icon_tag(self, classes=()): """ Render an icon tag for the given icon. >>> print(icon.icon_tag(classes=['header-icon'])) # doctest: +SKIP <i class="fa fa-icon header-icon"></i> """ return fa_icon(self.icon_name, class_=classes) def link_tag(self, classes=(), icon_classes=()): """ Render an anchor tag with the link for the social network. >>> print(icon.link_tag(classes=['header-icon'])) # doctest: +SKIP <a href="url"><i class="fa fa-icon header-icon"></i></a> """ return a(href=self.url, class_=classes)[self.icon_tag(icon_classes)]
class Conversation(TimeStampedModel): """ A topic of conversation. """ title = models.CharField( _("Title"), max_length=255, help_text=_( "Short description used to create URL slugs (e.g. School system)." ), ) text = models.TextField(_("Question"), help_text=_("What do you want to ask?")) author = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="conversations", help_text= _("Only the author and administrative staff can edit this conversation." ), ) moderators = models.ManyToManyField( settings.AUTH_USER_MODEL, blank=True, related_name="moderated_conversations", help_text=_("Moderators can accept and reject comments."), ) slug = AutoSlugField(unique=False, populate_from="title") is_promoted = models.BooleanField( _("Promote conversation?"), default=False, help_text=_( "Promoted conversations appears in the main /conversations/ " "endpoint."), ) is_hidden = models.BooleanField( _("Hide conversation?"), default=False, help_text=_( "Hidden conversations does not appears in boards or in the main /conversations/ " "endpoint."), ) objects = ConversationQuerySet.as_manager() tags = TaggableManager(through="ConversationTag", blank=True) votes = property( lambda self: Vote.objects.filter(comment__conversation=self)) @property def users(self): return (get_user_model().objects.filter( votes__comment__conversation=self).distinct()) @property def approved_comments(self): return self.comments.filter(status=Comment.STATUS.approved) class Meta: ordering = ["created"] permissions = ( ("can_publish_promoted", _("Can publish promoted conversations")), ("is_moderator", _("Can moderate comments in any conversation")), ) # # Statistics and annotated values # author_name = lazy(this.author.name) first_tag = lazy(this.tags.values_list("name", flat=True).first()) tag_names = lazy(this.tags.values_list("name", flat=True)) # Statistics n_comments = lazy( this.comments.filter(status=Comment.STATUS.approved).count()) n_pending_comments = lazy( this.comments.filter(status=Comment.STATUS.pending).count()) n_rejected_comments = lazy( this.comments.filter(status=Comment.STATUS.rejected).count()) n_favorites = lazy(this.favorites.count()) n_tags = lazy(this.tags.count()) n_votes = lazy(this.votes.count()) n_participants = lazy(this.users.count()) # Statistics for the request user user_comments = property(this.comments.filter(author=this.for_user)) user_votes = property(this.votes.filter(author=this.for_user)) n_user_comments = lazy( this.user_comments.filter(status=Comment.STATUS.approved).count()) n_user_rejected_comments = lazy( this.user_comments.filter(status=Comment.STATUS.rejected).count()) n_user_pending_comments = lazy( this.user_comments.filter(status=Comment.STATUS.pending).count()) n_user_votes = lazy(this.user_votes.count()) is_user_favorite = lazy(this.is_favorite(this.for_user)) @lazy def for_user(self): return self.request.user @lazy def request(self): msg = "Set the request object by calling the .set_request(request) method first" raise RuntimeError(msg) # TODO: move as patches from other apps @lazy def n_clusters(self): try: return self.clusterization.n_clusters except AttributeError: return 0 @lazy def n_stereotypes(self): try: return self.clusterization.n_clusters except AttributeError: return 0 n_endorsements = 0 def __str__(self): return self.title def set_request(self, request_or_user): """ Saves optional user and request attributes in model. Those attributes are used to compute and cache many other attributes and statistics in the conversation model instance. """ request = None user = request_or_user if not isinstance(request_or_user, get_user_model()): user = request_or_user.user request = request_or_user if (self.__dict__.get("for_user", user) != user or self.__dict__.get("request", request) != request): raise ValueError("user/request already set in conversation!") self.for_user = user self.request = request def save(self, *args, **kwargs): if self.id is None: pass super().save(*args, **kwargs) def clean(self): can_edit = "ej.can_edit_conversation" if (self.is_promoted and self.author_id is not None and not self.author.has_perm(can_edit, self)): raise ValidationError( _("User does not have permission to create a promoted " "conversation.")) def get_absolute_url(self, board=None): kwargs = {"conversation": self, "slug": self.slug} if board is None: board = getattr(self, "board", None) if board: kwargs["board"] = board return SafeUrl("boards:conversation-detail", **kwargs) else: return SafeUrl("conversation:detail", **kwargs) def url(self, which, board=None, **kwargs): """ Return a url pertaining to the current conversation. """ if board is None: board = getattr(self, "board", None) kwargs["conversation"] = self kwargs["slug"] = self.slug if board: kwargs["board"] = board which = "boards:" + which.replace(":", "-") return SafeUrl(which, **kwargs) return SafeUrl(which, **kwargs) def votes_for_user(self, user): """ Get all votes in conversation for the given user. """ if user.id is None: return Vote.objects.none() return self.votes.filter(author=user) def create_comment(self, author, content, commit=True, *, status=None, check_limits=True, **kwargs): """ Create a new comment object for the given user. If commit=True (default), comment is persisted on the database. By default, this method check if the user can post according to the limits imposed by the conversation. It also normalizes duplicate comments and reuse duplicates from the database. """ # Convert status, if necessary if status is None and (author.id == self.author.id or author.has_perm( "ej.can_edit_conversation", self)): kwargs["status"] = Comment.STATUS.approved else: kwargs["status"] = normalize_status(status) # Check limits if check_limits and not author.has_perm("ej.can_comment", self): log.info("failed attempt to create comment by %s" % author) raise PermissionError("user cannot comment on conversation.") # Check if comment is created with rejected status if status == Comment.STATUS.rejected: msg = _("automatically rejected") kwargs.setdefault("rejection_reason", msg) kwargs.update(author=author, content=content.strip()) comment = make_clean(Comment, commit, conversation=self, **kwargs) log.info("new comment: %s" % comment) return comment def vote_count(self, which=None): """ Return the number of votes of a given type. """ kwargs = {"comment__conversation_id": self.id} if which is not None: kwargs["choice"] = which return Vote.objects.filter(**kwargs).count() def statistics(self, cache=True): """ Return a dictionary with basic statistics about conversation. """ if cache: try: return self._cached_statistics except AttributeError: self._cached_statistics = self.statistics(False) return self._cached_statistics return { # Vote counts "votes": self.votes.aggregate( agree=Count("choice", filter=Q(choice=Choice.AGREE)), disagree=Count("choice", filter=Q(choice=Choice.DISAGREE)), skip=Count("choice", filter=Q(choice=Choice.SKIP)), total=Count("choice"), ), # Comment counts "comments": self.comments.aggregate( approved=Count("status", filter=Q(status=Comment.STATUS.approved)), rejected=Count("status", filter=Q(status=Comment.STATUS.rejected)), pending=Count("status", filter=Q(status=Comment.STATUS.pending)), total=Count("status"), ), # Participants count "participants": { "voters": (get_user_model().objects.filter( votes__comment__conversation_id=self.id).distinct().count( )), "commenters": (get_user_model().objects.filter( comments__conversation_id=self.id, comments__status=Comment.STATUS.approved, ).distinct().count()), }, } def statistics_for_user(self, user): """ Get information about user. """ max_votes = (self.comments.filter( status=Comment.STATUS.approved).exclude(author_id=user.id).count()) given_votes = (0 if user.id is None else (Vote.objects.filter(comment__conversation_id=self.id, author=user).count())) e = 1e-50 # for numerical stability return { "votes": given_votes, "missing_votes": max_votes - given_votes, "participation_ratio": given_votes / (max_votes + e), } def next_comment(self, user, default=NOT_GIVEN): """ Returns a random comment that user didn't vote yet. If default value is not given, raises a Comment.DoesNotExit exception if no comments are available for user. """ comment = rules.compute("ej.next_comment", self, user) if comment: return comment elif default is NOT_GIVEN: msg = _("No comments available for this user") raise Comment.DoesNotExist(msg) else: return default def is_favorite(self, user): """ Checks if conversation is favorite for the given user. """ return bool(self.favorites.filter(user=user).exists()) def make_favorite(self, user): """ Make conversation favorite for user. """ self.favorites.update_or_create(user=user) def remove_favorite(self, user): """ Remove favorite status for conversation """ if self.is_favorite(user): self.favorites.filter(user=user).delete() def toggle_favorite(self, user): """ Toggles favorite status of conversation. Return the final favorite status. """ try: self.favorites.get(user=user).delete() return False except ObjectDoesNotExist: self.make_favorite(user) return True
class Book(models.Model): title = models.CharField(max_length=100) author = models.ForeignKey(User, on_delete=models.CASCADE)
class Conversation(HasFavoriteMixin, TimeStampedModel): """ A topic of conversation. """ title = models.CharField( _("Title"), max_length=255, help_text=_( "Short description used to create URL slugs (e.g. School system)." ), ) text = models.TextField(_("Question"), help_text=_("What do you want to ask?")) author = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="conversations", help_text= _("Only the author and administrative staff can edit this conversation." ), ) moderators = models.ManyToManyField( settings.AUTH_USER_MODEL, blank=True, related_name="moderated_conversations", help_text=_("Moderators can accept and reject comments."), ) slug = AutoSlugField(unique=False, populate_from="title") is_promoted = models.BooleanField( _("Promote conversation?"), default=False, help_text=_( "Promoted conversations appears in the main /conversations/ " "endpoint."), ) is_hidden = models.BooleanField( _("Hide conversation?"), default=False, help_text=_( "Hidden conversations does not appears in boards or in the main /conversations/ " "endpoint."), ) objects = ConversationQuerySet.as_manager() tags = TaggableManager(through="ConversationTag", blank=True) votes = property( lambda self: Vote.objects.filter(comment__conversation=self)) @property def users(self): return get_user_model().objects.filter( votes__comment__conversation=self).distinct() # Comment managers def _filter_comments(*args): *_, which = args status = getattr(Comment.STATUS, which) return property(lambda self: self.comments.filter(status=status)) approved_comments = _filter_comments("approved") rejected_comments = _filter_comments("rejected") pending_comments = _filter_comments("pending") del _filter_comments class Meta: ordering = ["created"] verbose_name = _("Conversation") verbose_name_plural = _("Conversations") permissions = ( ("can_publish_promoted", _("Can publish promoted conversations")), ("is_moderator", _("Can moderate comments in any conversation")), ) # # Statistics and annotated values # author_name = lazy(this.author.name) first_tag = lazy(this.tags.values_list("name", flat=True).first()) tag_names = lazy(this.tags.values_list("name", flat=True)) # Statistics n_comments = deprecate_lazy( this.n_approved_comments, "Conversation.n_comments was deprecated in favor of .n_approved_comments." ) n_approved_comments = lazy(this.approved_comments.count()) n_pending_comments = lazy(this.pending_comments.count()) n_rejected_comments = lazy(this.rejected_comments.count()) n_total_comments = lazy(this.comments.count().count()) n_favorites = lazy(this.favorites.count()) n_tags = lazy(this.tags.count()) n_votes = lazy(this.votes.count()) n_final_votes = lazy(this.votes.exclude(choice=Choice.SKIP).count()) n_participants = lazy(this.users.count()) # Statistics for the request user user_comments = property(this.comments.filter(author=this.for_user)) user_votes = property(this.votes.filter(author=this.for_user)) n_user_total_comments = lazy(this.user_comments.count()) n_user_comments = lazy( this.user_comments.filter(status=Comment.STATUS.approved).count()) n_user_rejected_comments = lazy( this.user_comments.filter(status=Comment.STATUS.rejected).count()) n_user_pending_comments = lazy( this.user_comments.filter(status=Comment.STATUS.pending).count()) n_user_votes = lazy(this.user_votes.count()) n_user_final_votes = lazy( this.user_votes.exclude(choice=Choice.SKIP).count()) is_user_favorite = lazy(this.is_favorite(this.for_user)) # Statistical methods vote_count = vote_count statistics = statistics statistics_for_user = statistics_for_user @lazy def for_user(self): return self.request.user @lazy def request(self): msg = "Set the request object by calling the .set_request(request) method first" raise RuntimeError(msg) # TODO: move as patches from other apps @lazy def n_clusters(self): try: return self.clusterization.n_clusters except AttributeError: return 0 @lazy def n_stereotypes(self): try: return self.clusterization.n_clusters except AttributeError: return 0 n_endorsements = 0 # FIXME: endorsements def __str__(self): return self.title def set_request(self, request_or_user): """ Saves optional user and request attributes in model. Those attributes are used to compute and cache many other attributes and statistics in the conversation model instance. """ request = None user = request_or_user if not isinstance(request_or_user, get_user_model()): user = request_or_user.user request = request_or_user if self.__dict__.get("for_user", user) != user or self.__dict__.get( "request", request) != request: raise ValueError("user/request already set in conversation!") self.for_user = user self.request = request def save(self, *args, **kwargs): if self.id is None: pass super().save(*args, **kwargs) def clean(self): can_edit = "ej.can_edit_conversation" if self.is_promoted and self.author_id is not None and not self.author.has_perm( can_edit, self): raise ValidationError( _("User does not have permission to create a promoted " "conversation.")) def get_absolute_url(self, board=None): kwargs = {"conversation": self, "slug": self.slug} if board is None: board = getattr(self, "board", None) if board: kwargs["board"] = board return SafeUrl("boards:conversation-detail", **kwargs) else: return SafeUrl("conversation:detail", **kwargs) def url(self, which="conversation:detail", board=None, **kwargs): """ Return a url pertaining to the current conversation. """ if board is None: board = getattr(self, "board", None) kwargs["conversation"] = self kwargs["slug"] = self.slug if board: kwargs["board"] = board which = "boards:" + which.replace(":", "-") return SafeUrl(which, **kwargs) return SafeUrl(which, **kwargs) def votes_for_user(self, user): """ Get all votes in conversation for the given user. """ if user.id is None: return Vote.objects.none() return self.votes.filter(author=user) def create_comment(self, author, content, commit=True, *, status=None, check_limits=True, **kwargs): """ Create a new comment object for the given user. If commit=True (default), comment is persisted on the database. By default, this method check if the user can post according to the limits imposed by the conversation. It also normalizes duplicate comments and reuse duplicates from the database. """ # Convert status, if necessary if status is None and (author.id == self.author.id or author.has_perm( "ej.can_edit_conversation", self)): kwargs["status"] = Comment.STATUS.approved else: kwargs["status"] = normalize_status(status) # Check limits if check_limits and not author.has_perm("ej.can_comment", self): log.info("failed attempt to create comment by %s" % author) raise PermissionError("user cannot comment on conversation.") # Check if comment is created with rejected status if status == Comment.STATUS.rejected: msg = _("automatically rejected") kwargs.setdefault("rejection_reason", msg) kwargs.update(author=author, content=content.strip()) comment = make_clean(Comment, commit, conversation=self, **kwargs) if comment.status == comment.STATUS.approved and author != self.author: comment_moderated.send( Comment, comment=comment, moderator=comment.moderator, is_approved=True, author=comment.author, ) log.info("new comment: %s" % comment) return comment def next_comment(self, user, default=NOT_GIVEN): """ Returns a random comment that user didn't vote yet. If default value is not given, raises a Comment.DoesNotExit exception if no comments are available for user. """ comment = rules.compute("ej.next_comment", self, user) if comment: return comment return None def next_comment_with_id(self, user, comment_id=None): """ Returns a comment with id if user didn't vote yet, otherwhise return a random comment. """ if comment_id: try: return self.approved_comments.exclude(votes__author=user).get( id=comment_id) except Exception as e: pass return self.next_comment(user)