• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

zestedesavoir / zds-site / 21301029245

23 Jan 2026 09:00PM UTC coverage: 89.343% (+0.003%) from 89.34%
21301029245

Pull #6775

github

web-flow
Merge bf089877e into a92dcf22a
Pull Request #6775: Feat/6633 test que le html qu'on génère est toujours valide

3092 of 4146 branches covered (74.58%)

93 of 103 new or added lines in 16 files covered. (90.29%)

3 existing lines in 2 files now uncovered.

17128 of 19171 relevant lines covered (89.34%)

1.91 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.51
/zds/utils/models.py
1
import logging
3✔
2
import os
3✔
3
import string
3✔
4
import uuid
3✔
5
from datetime import datetime
3✔
6

7
from django.conf import settings
3✔
8
from django.contrib.auth.models import Group, User
3✔
9
from django.db import models
3✔
10
from django.dispatch import receiver
3✔
11
from django.shortcuts import get_object_or_404
3✔
12
from django.template.loader import render_to_string
3✔
13
from django.urls import reverse
3✔
14
from django.utils.encoding import smart_str
3✔
15
from django.utils.translation import gettext_lazy as _
3✔
16
from model_utils.managers import InheritanceManager
3✔
17

18
from zds.member.utils import get_bot_account
3✔
19
from zds.mp.models import PrivateTopic
3✔
20
from zds.mp.utils import send_mp
3✔
21
from zds.tutorialv2.models import TYPE_CHOICES, TYPE_CHOICES_DICT
3✔
22
from zds.utils import old_slugify, signals
3✔
23
from zds.utils.misc import contains_utf8mb4
3✔
24
from zds.utils.templatetags.emarkdown import render_markdown
3✔
25
from zds.utils.uuslug_wrapper import uuslug
3✔
26

27
logger = logging.getLogger(__name__)
3✔
28

29

30
def image_path_category(instance, filename):
3✔
31
    """Return path to an image."""
32
    ext = filename.split(".")[-1]
×
NEW
33
    filename = f"{str(uuid.uuid4())}.{ext.lower()}"
×
34
    return os.path.join("categorie/normal", str(instance.pk), filename)
×
35

36

37
def image_path_help(instance, filename):
3✔
38
    """Return path to an image."""
39
    ext = filename.split(".")[-1]
2✔
40
    filename = f"{str(uuid.uuid4())}.{ext.lower()}"
2✔
41
    return os.path.join("helps/normal", str(instance.pk), filename)
2✔
42

43

44
class Category(models.Model):
3✔
45
    """Common category for several concepts of the application."""
46

47
    class Meta:
3✔
48
        verbose_name = "Categorie"
3✔
49
        verbose_name_plural = "Categories"
3✔
50

51
    title = models.CharField("Titre", unique=True, max_length=80)
3✔
52
    description = models.TextField("Description")
3✔
53
    position = models.IntegerField("Position", default=0, db_index=True)
3✔
54

55
    slug = models.SlugField(max_length=80, unique=True)
3✔
56

57
    def __str__(self):
3✔
58
        return self.title
1✔
59

60
    def get_subcategories(self):
3✔
61
        return [
1✔
62
            a.subcategory
63
            for a in CategorySubCategory.objects.filter(is_main=True, category__pk=self.pk)
64
            .prefetch_related("subcategory")
65
            .all()
66
        ]
67

68

69
class SubCategory(models.Model):
3✔
70
    """Common subcategory for several concepts of the application."""
71

72
    class Meta:
3✔
73
        verbose_name = "Sous-categorie"
3✔
74
        verbose_name_plural = "Sous-categories"
3✔
75

76
    title = models.CharField("Titre", max_length=80, unique=True)
3✔
77
    subtitle = models.CharField("Sous-titre", max_length=200)
3✔
78
    position = models.IntegerField("Position", db_index=True, default=0)
3✔
79

80
    image = models.ImageField(upload_to=image_path_category, blank=True, null=True)
3✔
81

82
    slug = models.SlugField(max_length=80, unique=True)
3✔
83

84
    def __str__(self):
3✔
85
        return self.title
3✔
86

87
    def get_absolute_url_tutorial(self):
3✔
88
        url = reverse("tutorial-index")
×
89
        url += f"?tag={self.slug}"
×
90
        return url
×
91

92
    def get_absolute_url_article(self):
3✔
93
        url = reverse("article-index")
×
94
        url += f"?tag={self.slug}"
×
95
        return url
×
96

97
    def get_parent_category(self):
3✔
98
        """
99
        Get the parent of the category.
100

101
        :return: the parent category.
102
        :rtype: Category
103
        """
104
        try:
2✔
105
            return CategorySubCategory.objects.filter(subcategory=self).last().category
2✔
106
        except AttributeError:  # no CategorySubCategory
×
107
            return None
×
108

109

110
class CategorySubCategory(models.Model):
3✔
111
    """ManyToMany between Category and SubCategory but save a boolean to know
112
    if category is his main category."""
113

114
    class Meta:
3✔
115
        verbose_name = "Hierarchie catégorie"
3✔
116
        verbose_name_plural = "Hierarchies catégories"
3✔
117

118
    category = models.ForeignKey(Category, verbose_name="Catégorie", db_index=True, on_delete=models.CASCADE)
3✔
119
    subcategory = models.ForeignKey(SubCategory, verbose_name="Sous-Catégorie", db_index=True, on_delete=models.CASCADE)
3✔
120
    is_main = models.BooleanField("Est la catégorie principale", default=True, db_index=True)
3✔
121

122
    def __str__(self):
3✔
123
        """Textual Link Form."""
124
        if self.is_main:
×
125
            return f"[{self.category.title}][main]: {self.subcategory.title}"
×
126
        else:
127
            return f"[{self.category.title}]: {self.subcategory.title}"
×
128

129

130
class Licence(models.Model):
3✔
131
    """Publication licence."""
132

133
    class Meta:
3✔
134
        verbose_name = "Licence"
3✔
135
        verbose_name_plural = "Licences"
3✔
136

137
    code = models.CharField("Code", max_length=20)
3✔
138
    title = models.CharField("Titre", max_length=80)
3✔
139
    description = models.TextField("Description")
3✔
140

141
    def __str__(self):
3✔
142
        """Textual Licence Form."""
143
        return self.title
3✔
144

145

146
class Hat(models.Model):
3✔
147
    """
148
    Hats are labels that users can add to their messages.
149
    Each member can be allowed to use several hats.
150
    A hat may also be linked to a group, which
151
    allows all members of the group to use it.
152
    It can be used for exemple to allow members to identify
153
    that a moderation message was posted by a staff member.
154
    """
155

156
    name = models.CharField("Casquette", max_length=40, unique=True)
3✔
157
    group = models.ForeignKey(
3✔
158
        Group,
159
        on_delete=models.SET_NULL,
160
        verbose_name="Groupe possédant la casquette",
161
        related_name="hats",
162
        db_index=True,
163
        null=True,
164
        blank=True,
165
    )
166
    is_staff = models.BooleanField("Casquette interne au site", default=False)
3✔
167

168
    class Meta:
3✔
169
        verbose_name = "Casquette"
3✔
170
        verbose_name_plural = "Casquettes"
3✔
171

172
    def __str__(self):
3✔
173
        return self.name
×
174

175
    def get_absolute_url(self):
3✔
176
        return reverse("hat-detail", args=[self.pk])
3✔
177

178
    def get_users(self):
3✔
179
        """
180
        Return all users being allowed to use this hat.
181
        """
182
        if self.group:
1!
183
            return self.group.user_set.all()
1✔
184
        else:
185
            return [p.user for p in self.profile_set.all()]
×
186

187
    def get_users_count(self):
3✔
188
        return len(self.get_users())
1✔
189

190
    def get_users_preview(self):
3✔
191
        return self.get_users()[: settings.ZDS_APP["member"]["users_in_hats_list"]]
1✔
192

193

194
class HatRequest(models.Model):
3✔
195
    """
196
    A hat requested by a user.
197
    """
198

199
    user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="Utilisateur", related_name="requested_hats")
3✔
200
    hat = models.CharField("Casquette", max_length=40)
3✔
201
    reason = models.TextField("Raison de la demande", max_length=3000)
3✔
202
    date = models.DateTimeField(
3✔
203
        auto_now_add=True, db_index=True, verbose_name="Date de la demande", db_column="request_date"
204
    )
205
    is_granted = models.BooleanField("Est acceptée", null=True)
3✔
206
    solved_at = models.DateTimeField("Date de résolution", blank=True, null=True)
3✔
207
    moderator = models.ForeignKey(User, verbose_name="Modérateur", blank=True, null=True, on_delete=models.SET_NULL)
3✔
208
    comment = models.TextField("Commentaire", max_length=1000, blank=True)
3✔
209

210
    class Meta:
3✔
211
        verbose_name = "Demande de casquette"
3✔
212
        verbose_name_plural = "Demandes de casquettes"
3✔
213

214
    def __str__(self):
3✔
215
        return f"Hat {self.hat} requested by {self.user.username}"
×
216

217
    def get_absolute_url(self):
3✔
218
        return reverse("hat-request", args=[self.pk])
1✔
219

220
    def get_hat(self, create=False):
3✔
221
        """
222
        Get hat that matches this request. If it doesn't exist, it is created
223
        or `None` is returned according to the `create` parameter.
224
        """
225

226
        try:
1✔
227
            return Hat.objects.get(name__iexact=self.hat)
1✔
228
        except Hat.DoesNotExist:
1✔
229
            if create:
1✔
230
                return Hat.objects.create(name=self.hat)
1✔
231
            else:
232
                return None
1✔
233

234
    def solve(self, is_granted, moderator=None, comment="", hat_name=None):
3✔
235
        """
236
        Solve a hat request by granting or denying the requested hat according
237
        to the `is_granted` parameter.
238
        """
239

240
        if self.is_granted is not None:
1!
241
            raise Exception("This request is already solved.")
×
242

243
        if moderator is None:
1!
244
            moderator = get_bot_account()
×
245

246
        if is_granted:
1✔
247
            if self.get_hat() is None and hat_name:
1!
248
                self.hat = get_hat_to_add(hat_name, self.user).name
×
249
            self.user.profile.hats.add(self.get_hat(create=True))
1✔
250
        self.is_granted = is_granted
1✔
251
        self.moderator = moderator
1✔
252
        self.comment = comment[:1000]
1✔
253
        self.solved_at = datetime.now()
1✔
254
        self.save()
1✔
255
        self.notify_member()
1✔
256

257
    def notify_member(self):
3✔
258
        """
259
        Notify the request author about the decision that has been made.
260
        """
261

262
        if self.is_granted is None:
1!
263
            raise Exception("The request must have been solved to use this method.")
×
264

265
        solved_by_bot = self.moderator == get_bot_account()
1✔
266

267
        message = render_to_string(
1✔
268
            "member/messages/hat_request_decision.md",
269
            {
270
                "is_granted": self.is_granted,
271
                "hat": self.hat,
272
                "comment": self.comment,
273
                "solved_by_bot": solved_by_bot,
274
            },
275
        )
276
        send_mp(
1✔
277
            self.moderator,
278
            [self.user],
279
            _("Casquette « {} »").format(self.hat),
280
            "",
281
            message,
282
            leave=solved_by_bot,
283
            hat=get_hat_from_settings("hats_management"),
284
        )
285

286

287
@receiver(models.signals.post_save, sender=Hat)
3✔
288
def prevent_users_getting_hat_linked_to_group(sender, instance, **kwargs):
3✔
289
    """
290
    When a hat is saved with a linked group, all users that have gotten it by another way
291
    lose it to prevent a hat from being linked to a user through their profile and one of their groups.
292
    Hat requests for this hat are also canceled.
293
    """
294
    if instance.group:
3✔
295
        instance.profile_set.clear()
3✔
296
        for request in HatRequest.objects.filter(hat__iexact=instance.name, is_granted__isnull=True):
3!
297
            request.solve(
×
298
                is_granted=False,
299
                comment=_(
300
                    "La demande a été automatiquement annulée car la casquette est désormais accordée "
301
                    "aux membres d’un groupe particulier. Vous l’avez reçue si "
302
                    "vous en êtes membre."
303
                ),
304
            )
305

306

307
def get_hat_from_request(request, author=None):
3✔
308
    """
309
    Return a hat that will be used for a post.
310
    This checks that the user is allowed to use this hat.
311
    """
312

313
    if author is None:
3✔
314
        author = request.user
2✔
315
    if not request.POST.get("with_hat", None):
3✔
316
        return None
3✔
317
    try:
2✔
318
        hat = Hat.objects.get(pk=int(request.POST.get("with_hat")))
2✔
319
        if hat not in author.profile.get_hats():
2✔
320
            raise ValueError
1✔
321
        return hat
2✔
322
    except (ValueError, Hat.DoesNotExist):
1✔
323
        logger.warning("User #{} failed to use hat #{}.".format(request.user.pk, request.POST.get("hat")))
1✔
324
        return None
1✔
325

326

327
def get_hat_from_settings(key):
3✔
328
    hat_name = settings.ZDS_APP["hats"][key]
3✔
329
    hat, _ = Hat.objects.get_or_create(name__iexact=hat_name, defaults={"name": hat_name})
3✔
330
    return hat
3✔
331

332

333
def get_hat_to_add(hat_name, user):
3✔
334
    """
335
    Return a hat that will be added to a user.
336
    This function creates the hat if it does not exist,
337
    so be sure you will need it!
338
    """
339

340
    hat_name = hat_name.strip()
1✔
341
    if not hat_name:
1!
342
        raise ValueError(_("Veuillez saisir une casquette."))
×
343
    if contains_utf8mb4(hat_name):
1✔
344
        raise ValueError(
1✔
345
            _("La casquette saisie contient des caractères utf8mb4, " "ceux-ci ne peuvent pas être utilisés.")
346
        )
347
    if len(hat_name) > 40:
1✔
348
        raise ValueError(_("La longueur des casquettes est limitée à 40 caractères."))
1✔
349
    hat, created = Hat.objects.get_or_create(name__iexact=hat_name, defaults={"name": hat_name})
1✔
350
    if created:
1✔
351
        logger.info(f'Hat #{hat.pk} "{hat.name}" has been created.')
1✔
352
    if hat in user.profile.get_hats():
1!
353
        raise ValueError(_(f"{user.username} possède déjà la casquette « {hat.name} »."))
×
354
    if hat.group:
1✔
355
        raise ValueError(
1✔
356
            _(
357
                "La casquette « {} » est accordée automatiquement aux membres d'un groupe particulier "
358
                "et ne peut donc pas être ajoutée à un membre externe à ce groupe.".format(hat.name)
359
            )
360
        )
361
    return hat
1✔
362

363

364
class Comment(models.Model):
3✔
365
    """Comment in forum, articles, tutorial, chapter, etc."""
366

367
    class Meta:
3✔
368
        verbose_name = "Commentaire"
3✔
369
        verbose_name_plural = "Commentaires"
3✔
370

371
        permissions = [("change_comment_potential_spam", "Can change the potential spam status of a comment")]
3✔
372

373
    objects = InheritanceManager()
3✔
374

375
    author = models.ForeignKey(
3✔
376
        User,
377
        verbose_name="Auteur",
378
        related_name="comments",
379
        db_index=True,
380
        # better on_delete with "set anonymous?"
381
        on_delete=models.CASCADE,
382
    )
383
    editor = models.ForeignKey(
3✔
384
        User, verbose_name="Editeur", related_name="comments-editor+", null=True, blank=True, on_delete=models.SET_NULL
385
    )
386
    ip_address = models.CharField("Adresse IP de l'auteur ", max_length=39)
3✔
387

388
    position = models.IntegerField("Position", db_index=True)
3✔
389

390
    text = models.TextField("Texte")
3✔
391
    text_html = models.TextField("Texte en Html")
3✔
392

393
    like = models.IntegerField("Likes", default=0)
3✔
394
    dislike = models.IntegerField("Dislikes", default=0)
3✔
395

396
    pubdate = models.DateTimeField("Date de publication", auto_now_add=True, db_index=True)
3✔
397
    update = models.DateTimeField("Date d'édition", null=True, blank=True)
3✔
398

399
    is_visible = models.BooleanField("Est visible", default=True)
3✔
400
    text_hidden = models.CharField("Texte de masquage ", max_length=80, default="")
3✔
401

402
    hat = models.ForeignKey(
3✔
403
        Hat, verbose_name="Casquette", on_delete=models.SET_NULL, related_name="comments", blank=True, null=True
404
    )
405

406
    is_potential_spam = models.BooleanField("Est potentiellement du spam", default=False)
3✔
407

408
    def update_content(self, text, on_error=None):
3✔
409
        """
410
        Updates the content of this comment.
411

412
        This method will render the new comment to HTML, store the rendered
413
        version, and store data to later analyze pings and spam.
414

415
        This method updates fields, but does not save the instance.
416

417
        :param text: The new comment content.
418
        :param on_error: A callable called if zmd returns an error, provided
419
                         with a single argument: a list of user-friendly errors.
420
                         See render_markdown.
421
        """
422
        # This attribute will be used by `_save_check_spam`, called after save (but not saved into the database).
423
        # We only update it if it does not already exist, so if this method is called multiple times, the oldest
424
        # version (i.e. the one currently in the database) is used for comparison. `_save_check_spam` will delete
425
        # the attribute, so if we re-save the same instance, this will be re-set.
426
        if not hasattr(self, "old_text"):
3!
427
            self.old_text = self.text
3✔
428

429
        _, old_metadata, _ = render_markdown(self.text)
3✔
430
        html, new_metadata, _ = render_markdown(text, on_error=on_error)
3✔
431

432
        # These attributes will be used by `_save_compute_pings` to create notifications if needed.
433
        # For the same reason as `old_text`, we only update `old_metadata` if not already set.
434
        if not hasattr(self, "old_metadata"):
3!
435
            self.old_metadata = old_metadata
3✔
436
        self.new_metadata = new_metadata
3✔
437

438
        self.text = text
3✔
439
        self.text_html = html
3✔
440

441
    def save(self, *args, **kwargs):
3✔
442
        """
443
        We override the save method for two tasks:
444
        1. we want to analyze the pings in the message to know if notifications
445
        needs to be created;
446
        2. if this comment is marked as potential spam, we need to open an alert
447
        in case of update by its author.
448
        """
449
        super().save(*args, **kwargs)
3✔
450

451
        self._save_compute_pings()
3✔
452
        self._save_check_spam()
3✔
453

454
    def _save_compute_pings(self):
3✔
455
        """
456
        This method, called on the save method when the save is complete,
457
        analyzes pings to create, or delete, ping notifications. It must run
458
        when the instance is saved (for new messages) as notifications
459
        references messages in the database.
460
        """
461

462
        # If `update_content` was not called, there is nothing to do as the
463
        # message's content stayed the same.
464
        if not hasattr(self, "old_metadata") or not hasattr(self, "new_metadata"):
3✔
465
            return
3✔
466

467
        def filter_usernames(original_list):
3✔
468
            # removes duplicates and the message's author
469
            filtered_list = []
3✔
470
            for username in original_list:
3✔
471
                if username != self.author.username and username not in filtered_list:
1✔
472
                    filtered_list.append(username)
1✔
473
            return filtered_list
3✔
474

475
        max_pings_allowed = settings.ZDS_APP["comment"]["max_pings"]
3✔
476
        pinged_usernames_from_new_text = filter_usernames(self.new_metadata.get("ping", []))[:max_pings_allowed]
3✔
477
        pinged_usernames_from_old_text = filter_usernames(self.old_metadata.get("ping", []))[:max_pings_allowed]
3✔
478

479
        pinged_usernames = set(pinged_usernames_from_new_text) - set(pinged_usernames_from_old_text)
3✔
480
        pinged_users = User.objects.filter(username__in=pinged_usernames)
3✔
481
        for pinged_user in pinged_users:
3✔
482
            signals.ping.send(sender=self.__class__, instance=self, user=pinged_user)
1✔
483

484
        unpinged_usernames = set(pinged_usernames_from_old_text) - set(pinged_usernames_from_new_text)
3✔
485
        unpinged_users = User.objects.filter(username__in=unpinged_usernames)
3✔
486
        for unpinged_user in unpinged_users:
3✔
487
            signals.unping.send(self.author, instance=self, user=unpinged_user)
1✔
488

489
        del self.old_metadata
3✔
490
        del self.new_metadata
3✔
491

492
    def _save_check_spam(self):
3✔
493
        """
494
        This method checks if this message is marked as spam and if it was
495
        modified by its author. If so, an alert is created.
496
        """
497
        # For new comments (if there is no editor), this does not apply.
498
        # If we edit a comment but the `old_text` attribute is not set, this means
499
        # `Comment.update_content` was not called: the content was not updated.
500
        if not hasattr(self, "old_text") or not self.editor:
3✔
501
            return
3✔
502

503
        # If this post is marked as potential spam, we open an alert to notify the staff that
504
        # the post was edited. If an open alert already exists for this reason, we update the
505
        # date of this alert to avoid lots of them stacking up.
506
        if self.old_text != self.text and self.is_potential_spam and self.editor == self.author:
3✔
507
            bot = get_bot_account()
1✔
508
            alert_text = _("Ce message, soupçonné d'être un spam, a été modifié.")
1✔
509

510
            try:
1✔
511
                alert = self.alerts_on_this_comment.filter(author=bot, text=alert_text, solved=False).latest()
1✔
512
                alert.pubdate = datetime.now()
1✔
513
                alert.save()
1✔
514
            except Alert.DoesNotExist:
1✔
515
                # We first have to compute the correct scope
516
                if type(self).__name__ == "ContentReaction":
1✔
517
                    scope = self.related_content.type
1✔
518
                else:
519
                    scope = "FORUM"
1✔
520

521
                Alert(author=bot, comment=self, scope=scope, text=alert_text, pubdate=datetime.now()).save()
1✔
522

523
        del self.old_text
3✔
524

525
    def hide_comment_by_user(self, user, text_hidden):
3✔
526
        """Hide a comment and save it
527

528
        :param user: the user that hid the comment
529
        :param text_hidden: the hide reason
530
        :return:
531
        """
532
        self.is_visible = False
2✔
533
        self.text_hidden = text_hidden
2✔
534
        self.editor = user
2✔
535
        self.save()
2✔
536

537
    def get_user_vote(self, user):
3✔
538
        """Get a user vote (like, dislike or neutral)"""
539
        if user.is_authenticated:
2!
540
            try:
2✔
541
                user_vote = "like" if CommentVote.objects.get(user=user, comment=self).positive else "dislike"
2✔
542
            except CommentVote.DoesNotExist:
2✔
543
                user_vote = "neutral"
2✔
544
        else:
545
            user_vote = "neutral"
×
546

547
        return user_vote
2✔
548

549
    def set_user_vote(self, user, vote):
3✔
550
        """Set a user vote (like, dislike or neutral)"""
551
        if vote == "neutral":
2✔
552
            CommentVote.objects.filter(user=user, comment=self).delete()
2✔
553
        else:
554
            CommentVote.objects.update_or_create(user=user, comment=self, defaults={"positive": (vote == "like")})
2✔
555

556
        self.like = CommentVote.objects.filter(positive=True, comment=self).count()
2✔
557
        self.dislike = CommentVote.objects.filter(positive=False, comment=self).count()
2✔
558

559
    def get_votes(self, type=None):
3✔
560
        """Get the non-anonymous votes"""
561
        if not hasattr(self, "votes"):
2✔
562
            self.votes = (
2✔
563
                CommentVote.objects.filter(comment=self, id__gt=settings.VOTES_ID_LIMIT).select_related("user").all()
564
            )
565

566
        return self.votes
2✔
567

568
    def get_likers(self):
3✔
569
        """Get the list of the users that liked this Comment"""
570
        return [vote.user for vote in self.get_votes() if vote.positive]
2✔
571

572
    def get_dislikers(self):
3✔
573
        """Get the list of the users that disliked this Comment"""
574
        return [vote.user for vote in self.get_votes() if not vote.positive]
2✔
575

576
    def get_absolute_url(self):
3✔
577
        return Comment.objects.get_subclass(id=self.id).get_absolute_url()
1✔
578

579
    def __str__(self):
3✔
580
        return f"Comment by {self.author.username}"
×
581

582

583
class CommentEdit(models.Model):
3✔
584
    """Archive for editing a comment."""
585

586
    class Meta:
3✔
587
        verbose_name = "Édition d'un message"
3✔
588
        verbose_name_plural = "Éditions de messages"
3✔
589

590
    comment = models.ForeignKey(
3✔
591
        Comment, on_delete=models.CASCADE, verbose_name="Message", related_name="edits", db_index=True
592
    )
593
    editor = models.ForeignKey(
3✔
594
        User, on_delete=models.CASCADE, verbose_name="Éditeur", related_name="edits", db_index=True
595
    )
596
    date = models.DateTimeField(
3✔
597
        auto_now_add=True, db_index=True, verbose_name="Date de l'édition", db_column="edit_date"
598
    )
599
    original_text = models.TextField("Contenu d'origine", blank=True)
3✔
600
    deleted_at = models.DateTimeField(db_index=True, verbose_name="Date de suppression", blank=True, null=True)
3✔
601
    deleted_by = models.ForeignKey(
3✔
602
        User,
603
        on_delete=models.CASCADE,
604
        verbose_name="Supprimé par",
605
        related_name="deleted_edits",
606
        db_index=True,
607
        null=True,
608
        blank=True,
609
    )
610

611
    def __str__(self):
3✔
612
        return f"Edit by {self.editor.username} on a comment of {self.comment.author.username}"
×
613

614

615
class Alert(models.Model):
3✔
616
    """Alerts on Profiles, PublishedContents and all kinds of Comments.
617

618
    The scope field indicates on which type of element the alert is made:
619
    - PROFILE: the profile of a member
620
    - FORUM: a post on a topic in a forum
621
    - CONTENT: the content (article, opinion or tutorial) itself
622
    - elements of TYPE_CHOICES (ARTICLE, OPINION, TUTORIAL): a comment on a content of this type
623
    """
624

625
    SCOPE_CHOICES = [
3✔
626
        ("PROFILE", _("Profil")),
627
        ("FORUM", _("Forum")),
628
        ("CONTENT", _("Contenu")),
629
    ] + TYPE_CHOICES
630

631
    SCOPE_CHOICES_DICT = dict(SCOPE_CHOICES)
3✔
632

633
    author = models.ForeignKey(
3✔
634
        User, verbose_name="Auteur", related_name="alerts", db_index=True, on_delete=models.CASCADE
635
    )
636
    comment = models.ForeignKey(
3✔
637
        Comment,
638
        verbose_name="Commentaire",
639
        related_name="alerts_on_this_comment",
640
        db_index=True,
641
        null=True,
642
        blank=True,
643
        on_delete=models.CASCADE,
644
    )
645
    # use of string definition of pk to avoid circular import.
646
    profile = models.ForeignKey(
3✔
647
        "member.Profile",
648
        verbose_name="Profil",
649
        related_name="alerts_on_this_profile",
650
        db_index=True,
651
        null=True,
652
        blank=True,
653
        on_delete=models.CASCADE,
654
    )
655
    content = models.ForeignKey(
3✔
656
        "tutorialv2.PublishableContent",
657
        verbose_name="Contenu",
658
        related_name="alerts_on_this_content",
659
        db_index=True,
660
        null=True,
661
        blank=True,
662
        on_delete=models.CASCADE,
663
    )
664
    scope = models.CharField(max_length=10, choices=SCOPE_CHOICES, db_index=True)
3✔
665
    text = models.TextField("Texte d'alerte")
3✔
666
    pubdate = models.DateTimeField("Date de création", db_index=True)
3✔
667
    solved = models.BooleanField("Est résolue", default=False)
3✔
668
    moderator = models.ForeignKey(
3✔
669
        User,
670
        verbose_name="Modérateur",
671
        related_name="solved_alerts",
672
        db_index=True,
673
        null=True,
674
        blank=True,
675
        on_delete=models.SET_NULL,
676
    )
677
    # sent to the alert creator
678
    resolve_reason = models.TextField("Texte de résolution", null=True, blank=True)
3✔
679
    # PrivateTopic sending the resolve_reason to the alert creator
680
    privatetopic = models.ForeignKey(
3✔
681
        PrivateTopic, on_delete=models.SET_NULL, verbose_name="Message privé", db_index=True, null=True, blank=True
682
    )
683
    solved_date = models.DateTimeField("Date de résolution", db_index=True, null=True, blank=True)
3✔
684

685
    def get_type(self):
3✔
686
        if self.scope in TYPE_CHOICES_DICT:
1✔
687
            assert self.comment is not None
1✔
688
            if self.is_on_comment_on_unpublished_content():
1✔
689
                return _(f"Commentaire sur un {self.SCOPE_CHOICES_DICT[self.scope].lower()} dépublié")
1✔
690
            else:
691
                return _(f"Commentaire sur un {self.SCOPE_CHOICES_DICT[self.scope].lower()}")
1✔
692
        elif self.scope == "FORUM":
1!
693
            assert self.comment is not None
×
694
            return _("Message de forum")
×
695
        elif self.scope == "PROFILE":
1!
696
            assert self.profile is not None
×
697
            return _("Profil")
×
698
        else:
699
            assert self.content is not None
1✔
700
            return self.SCOPE_CHOICES_DICT[self.content.type]
1✔
701

702
    def is_on_comment_on_unpublished_content(self):
3✔
703
        return self.scope in TYPE_CHOICES_DICT and not self.get_comment_subclass().related_content.in_public()
1✔
704

705
    def is_automated(self):
3✔
706
        """Returns true if this alert was opened automatically."""
707
        return self.author.username == settings.ZDS_APP["member"]["bot_account"]
×
708

709
    def solve(self, moderator, resolve_reason="", msg_title="", msg_content=""):
3✔
710
        """Solve the alert and send a private message to the author if a reason is given
711

712
        :param resolve_reason: reason
713
        :type resolve_reason: str
714
        """
715
        self.resolve_reason = resolve_reason or None
3✔
716
        if msg_title and msg_content:
3✔
717
            bot = get_bot_account()
3✔
718
            privatetopic = send_mp(
3✔
719
                bot,
720
                [self.author],
721
                msg_title,
722
                "",
723
                msg_content,
724
                send_by_mail=True,
725
                hat=get_hat_from_settings("moderation"),
726
            )
727
            self.privatetopic = privatetopic
3✔
728

729
        self.solved = True
3✔
730
        self.moderator = moderator
3✔
731
        self.solved_date = datetime.now()
3✔
732
        self.save()
3✔
733

734
    def get_comment(self):
3✔
735
        return Comment.objects.get(id=self.comment.id)
×
736

737
    def get_comment_subclass(self):
3✔
738
        """Used to retrieve comment URLs (simple call to get_absolute_url
739
        doesn't work: objects are retrived as Comment and not subclasses) As
740
        real Comment implementation (subclasses) can't be hard-coded due to
741
        unresolvable import loops, use InheritanceManager from django-model-
742
        utils."""
743
        return Comment.objects.get_subclass(id=self.comment.id)
1✔
744

745
    def __str__(self):
3✔
746
        return self.text
×
747

748
    class Meta:
3✔
749
        verbose_name = "Alerte"
3✔
750
        verbose_name_plural = "Alertes"
3✔
751
        get_latest_by = "pubdate"
3✔
752

753

754
class CommentVote(models.Model):
3✔
755
    """Set of comment votes."""
756

757
    class Meta:
3✔
758
        verbose_name = "Vote"
3✔
759
        verbose_name_plural = "Votes"
3✔
760
        unique_together = ("user", "comment")
3✔
761

762
    comment = models.ForeignKey(Comment, db_index=True, on_delete=models.CASCADE)
3✔
763
    user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE)
3✔
764
    positive = models.BooleanField("Est un vote positif", default=True)
3✔
765

766
    def __str__(self):
3✔
767
        return f"Vote from {self.user.username} about Comment#{self.comment.pk} thumb_up={self.positive}"
×
768

769

770
class Tag(models.Model):
3✔
771
    """Set of tags."""
772

773
    class Meta:
3✔
774
        verbose_name = "Tag"
3✔
775
        verbose_name_plural = "Tags"
3✔
776

777
    title = models.CharField("Titre", max_length=30, unique=True, db_index=True)
3✔
778
    slug = models.SlugField("Slug", max_length=30, unique=True)
3✔
779

780
    def __str__(self):
3✔
781
        """Textual Link Form."""
782
        return self.title
1✔
783

784
    def get_absolute_url(self):
3✔
785
        return reverse("forum:topic-tag-find", kwargs={"tag_slug": self.slug})
1✔
786

787
    def save(self, *args, **kwargs):
3✔
788
        self.title = self.title.strip()
3✔
789
        if not self.title or not old_slugify(self.title.replace("-", "")):
3✔
790
            raise ValueError(f'Tag "{self.title}" is not correct')
2✔
791
        self.title = smart_str(self.title).lower()
3✔
792
        self.slug = uuslug(self.title, instance=self, max_length=Tag._meta.get_field("slug").max_length)
3✔
793
        super().save(*args, **kwargs)
3✔
794

795
    @staticmethod
3✔
796
    def has_read_permission(request):
3✔
797
        return True
×
798

799
    def has_object_read_permission(self, request):
3✔
800
        return True
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc