Aller au contenu

# Models

## `Club`

Bases: `Model`

The Club class, made as a tree to allow nice tidy organization.

### `president()`

Fetch the membership of the current president of this club.

Source code in `club/models.py`

    138
    139
    140
    141
    @cached_property
    def president(self) -> Membership | None:
        """Fetch the membership of the current president of this club."""
        return self.members.filter(end_date=None).order_by("role__order").first()

### `check_loop()`

Raise a validation error when a loop is found within the parent list.

Source code in `club/models.py`

    143
    144
    145
    146
    147
    148
    149
    150
    151
    def check_loop(self):
        """Raise a validation error when a loop is found within the parent list."""
        objs = []
        cur = self
        while cur.parent is not None:
            if cur in objs:
                raise ValidationError(_("You can not make loops in clubs"))
            objs.append(cur)
            cur = cur.parent

### `create_default_roles()`

Create some roles that should exist by default for this club.

The created roles are : president, treasurer, active member and curious.

Source code in `club/models.py`

    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    def create_default_roles(self):
        """Create some roles that should exist by default for this club.

        The created roles are : president, treasurer, active member and curious.

        Warnings:
            When calling this method, no club must exist yet for this club.
        """
        if self.roles.exists():
            raise ProgrammingError(
                "Default roles can be created only for clubs "
                "that don't have associated roles yet"
            )
        # The names are written in French, because there is no gettext involved
        # for strings stored in database, and the majority of users are french.
        roles = [
            ClubRole(name="Président⸱e", is_board=True, is_presidency=True),
            ClubRole(name="Trésorier⸱e", is_board=True, is_presidency=False),
            ClubRole(name="Membre actif⸱ve", is_board=False, is_presidency=False),
            ClubRole(
                name="Curieux⸱euse",
                description=(
                    "Les gens qui suivent l'activité "
                    "du club sans forcément y participer"
                ),
                is_board=False,
                is_presidency=False,
            ),
        ]
        for i, role in enumerate(roles):
            role.club = self
            role.order = i
        ClubRole.objects.bulk_create(roles)

### `is_owned_by(user)`

Method to see if that object can be super edited by the given user.

Source code in `club/models.py`

    228
    229
    230
    231
    232
    def is_owned_by(self, user: User) -> bool:
        """Method to see if that object can be super edited by the given user."""
        if user.is_anonymous:
            return False
        return user.is_root or user.is_board_member

### `can_be_edited_by(user)`

Method to see if that object can be edited by the given user.

Source code in `club/models.py`

    237
    238
    239
    def can_be_edited_by(self, user: User) -> bool:
        """Method to see if that object can be edited by the given user."""
        return self.has_rights_in_club(user)

### `get_membership_for(user)`

Return the current membership of the given user.

Source code in `club/models.py`

    247
    248
    249
    250
    251
    def get_membership_for(self, user: User) -> Membership | None:
        """Return the current membership of the given user."""
        if user.is_anonymous:
            return None
        return next((m for m in self.current_members if m.user_id == user.id), None)

## `Group`

Bases: `Group`

Wrapper around django.auth.Group

## `BanGroup`

Bases: `Group`

An anti-group, that removes permissions instead of giving them.

Users are linked to BanGroups through UserBan objects.

Example

    user = User.objects.get(username="...")
    ban_group = BanGroup.objects.first()
    UserBan.objects.create(user=user, ban_group=ban_group, reason="...")

    assert user.ban_groups.contains(ban_group)

## `UserQuerySet`

Bases: `QuerySet`

## `CustomUserManager`

Bases: `from_queryset(UserQuerySet)`

## `User`

Bases: `AbstractUser`

Defines the base user class, useable in every app.

This is almost the same as the auth module AbstractUser since it inherits from it, but some fields are required, and the username is generated automatically with the name of the user (see generate_username()).

Added field: nick_name, date_of_birth Required fields: email, first_name, last_name, date_of_birth

### `is_in_group(*, pk=None, name=None)`

Check if this user is in the given group. Either a group id or a group name must be provided. If both are passed, only the id will be considered.

The group will be fetched using the given parameter. If no group is found, return False. If a group is found, check if this user is in the latter.

Returns:

Type | Description
--- | ---
`bool` | True if the user is the group, else False

Source code in `core/models.py`

    352
    353
    354
    355
    356
    357
    358
    359
    360
    361
    362
    363
    364
    365
    366
    367
    368
    369
    370
    371
    def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:
        """Check if this user is in the given group.
        Either a group id or a group name must be provided.
        If both are passed, only the id will be considered.

        The group will be fetched using the given parameter.
        If no group is found, return False.
        If a group is found, check if this user is in the latter.

        Returns:
             True if the user is the group, else False
        """
        if not pk and not name:
            raise ValueError("You must either provide the id or the name of the group")
        group_id: int | None = (
            pk or Group.objects.filter(name=name).values_list("id", flat=True).first()
        )
        if group_id is None:
            return False
        return group_id in self.all_groups

### `all_groups()`

Get the list of groups this user is in.

Source code in `core/models.py`

    373
    374
    375
    376
    377
    378
    379
    380
    381
    382
    383
    384
    385
    386
    387
    @cached_property
    def all_groups(self) -> dict[int, Group]:
        """Get the list of groups this user is in."""
        additional_groups = []
        if self.is_subscribed:
            additional_groups.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
        if self.is_superuser:
            additional_groups.append(settings.SITH_GROUP_ROOT_ID)
        qs = self.groups.all()
        if additional_groups:
            # This is somewhat counter-intuitive, but this query runs way faster with
            # a UNION rather than a OR (in average, 0.25ms vs 14ms).
            # For the why, cf. https://dba.stackexchange.com/questions/293836/why-is-an-or-statement-slower-than-union
            qs = qs.union(Group.objects.filter(id__in=additional_groups))
        return {g.id: g for g in qs}

### `age()`

Return the age this user has the day the method is called. If the user has not filled his age, return 0.

Source code in `core/models.py`

    405
    406
    407
    408
    409
    410
    411
    412
    413
    414
    415
    416
    417
    418
    419
    @cached_property
    def age(self) -> int:
        """Return the age this user has the day the method is called.
        If the user has not filled his age, return 0.
        """
        if self.date_of_birth is None:
            return 0
        today = timezone.now()
        age = today.year - self.date_of_birth.year
        # remove a year if this year's birthday is yet to come
        age -= (today.month, today.day) < (
            self.date_of_birth.month,
            self.date_of_birth.day,
        )
        return age

### `get_short_name()`

Returns the short name for the user.

Source code in `core/models.py`

    439
    440
    441
    442
    443
    def get_short_name(self):
        """Returns the short name for the user."""
        if self.nick_name:
            return self.nick_name
        return self.first_name + " " + self.last_name

### `get_display_name()`

Returns the display name of the user.

A nickname if possible, otherwise, the full name.

Source code in `core/models.py`

    445
    446
    447
    448
    449
    450
    451
    452
    def get_display_name(self) -> str:
        """Returns the display name of the user.

        A nickname if possible, otherwise, the full name.
        """
        if self.nick_name:
            return "%s (%s)" % (self.get_full_name(), self.nick_name)
        return self.get_full_name()

### `get_family(godfathers_depth=4, godchildren_depth=4)`

Get the family of the user, with the given depth.

Parameters:

Name | Type | Description | Default
--- | --- | --- | ---
`godfathers_depth` | `NonNegativeInt` | The number of generations of godfathers to fetch | `4`
`godchildren_depth` | `NonNegativeInt` | The number of generations of godchildren to fetch | `4`

Returns:

Type | Description
--- | ---
`set[through]` | A list of family relationships in this user's family

Source code in `core/models.py`

    454
    455
    456
    457
    458
    459
    460
    461
    462
    463
    464
    465
    466
    467
    468
    469
    470
    471
    472
    473
    474
    475
    476
    477
    478
    479
    480
    481
    482
    483
    484
    485
    486
    487
    def get_family(
        self,
        godfathers_depth: NonNegativeInt = 4,
        godchildren_depth: NonNegativeInt = 4,
    ) -> set[User.godfathers.through]:
        """Get the family of the user, with the given depth.

        Args:
            godfathers_depth: The number of generations of godfathers to fetch
            godchildren_depth: The number of generations of godchildren to fetch

        Returns:
            A list of family relationships in this user's family
        """
        res = []
        for depth, key, reverse_key in [
            (godfathers_depth, "from_user_id", "to_user_id"),
            (godchildren_depth, "to_user_id", "from_user_id"),
        ]:
            if depth == 0:
                continue
            links = list(User.godfathers.through.objects.filter(**{key: self.id}))
            res.extend(links)
            for _ in range(1, depth):  # noqa: F402 we don't care about gettext here
                ids = [getattr(c, reverse_key) for c in links]
                links = list(
                    User.godfathers.through.objects.filter(
                        **{f"{key}__in": ids}
                    ).exclude(id__in=[r.id for r in res])
                )
                if not links:
                    break
                res.extend(links)
        return set(res)

### `email_user(subject, message, from_email=None, **kwargs)`

Sends an email to this User.

Source code in `core/models.py`

    489
    490
    491
    492
    493
    def email_user(self, subject, message, from_email=None, **kwargs):
        """Sends an email to this User."""
        if from_email is None:
            from_email = settings.DEFAULT_FROM_EMAIL
        send_mail(subject, message, from_email, [self.email], **kwargs)

### `generate_username()`

Generates a unique username based on the first and last names.

For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.

Returns:

Type | Description
--- | ---
`str` | The generated username.

Source code in `core/models.py`

    495
    496
    497
    498
    499
    500
    501
    502
    503
    504
    505
    506
    507
    508
    509
    510
    511
    512
    513
    514
    515
    516
    517
    518
    519
    520
    521
    522
    523
    524
    525
    526
    527
    528
    529
    530
    531
    def generate_username(self) -> str:
        """Generates a unique username based on the first and last names.

        For example: Guy Carlier gives gcarlier, and gcarlier1 if the first one exists.

        Returns:
            The generated username.
        """

        def remove_accents(data):
            return "".join(
                x
                for x in unicodedata.normalize("NFKD", data)
                if unicodedata.category(x)[0] == "L"
            ).lower()

        user_name = (
            remove_accents(self.first_name[0] + self.last_name)
            .encode("ascii", "ignore")
            .decode("utf-8")
        )
        # load all usernames which could conflict with the new one.
        # we need to actually load them, instead of performing a count,
        # because we cannot be sure that two usernames refer to the
        # actual same word (eg. tmore and tmoreau)
        possible_conflicts: list[str] = list(
            User.objects.filter(username__startswith=user_name).values_list(
                "username", flat=True
            )
        )
        nb_conflicts = sum(
            1 for name in possible_conflicts if name.rstrip(string.digits) == user_name
        )
        if nb_conflicts > 0:
            user_name += str(nb_conflicts)  # exemple => exemple1
        self.username = user_name
        return user_name

### `is_owner(obj)`

Determine if the object is owned by the user.

Source code in `core/models.py`

    533
    534
    535
    536
    537
    538
    539
    def is_owner(self, obj: models.Model):
        """Determine if the object is owned by the user."""
        if hasattr(obj, "is_owned_by") and obj.is_owned_by(self):
            return True
        if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group_id):
            return True
        return self.is_root

### `can_edit(obj)`

Determine if the object can be edited by the user.

Source code in `core/models.py`

    541
    542
    543
    544
    545
    546
    547
    548
    549
    550
    551
    552
    553
    554
    555
    def can_edit(self, obj: models.Model):
        """Determine if the object can be edited by the user."""
        if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self):
            return True
        if hasattr(obj, "edit_groups"):
            if (
                hasattr(obj, "_prefetched_objects_cache")
                and "edit_groups" in obj._prefetched_objects_cache
            ):
                pks = [g.id for g in obj.edit_groups.all()]
            else:
                pks = list(obj.edit_groups.values_list("id", flat=True))
            if any(self.is_in_group(pk=pk) for pk in pks):
                return True
        return self.is_owner(obj)

### `can_view(obj)`

Determine if the object can be viewed by the user.

Source code in `core/models.py`

    557
    558
    559
    560
    561
    562
    563
    564
    565
    566
    567
    568
    569
    570
    571
    572
    573
    574
    def can_view(self, obj: models.Model):
        """Determine if the object can be viewed by the user."""
        if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self):
            return True
        if hasattr(obj, "view_groups"):
            # if "view_groups" has already been prefetched, use
            # the prefetch cache, else fetch only the ids, to make
            # the query lighter.
            if (
                hasattr(obj, "_prefetched_objects_cache")
                and "view_groups" in obj._prefetched_objects_cache
            ):
                pks = [g.id for g in obj.view_groups.all()]
            else:
                pks = list(obj.view_groups.values_list("id", flat=True))
            if any(self.is_in_group(pk=pk) for pk in pks):
                return True
        return self.can_edit(obj)

### `can_be_viewed_by(user)`

Check if the given user can be viewed by this user.

Given users A and B. A can be viewed by B if :

- A and B are the same user
- or B has the permission to view hidden users
- or B can view users in general and A didn't hide its profile
- or B is in A's whitelist.

Source code in `core/models.py`

    579
    580
    581
    582
    583
    584
    585
    586
    587
    588
    589
    590
    591
    592
    593
    594
    595
    596
    597
    598
    599
    600
    601
    602
    603
    604
    605
    def can_be_viewed_by(self, user: User) -> bool:
        """Check if the given user can be viewed by this user.

        Given users A and B. A can be viewed by B if :

        - A and B are the same user
        - or B has the permission to view hidden users
        - or B can view users in general and A didn't hide its profile
        - or B is in A's whitelist.
        """

        def is_in_whitelist(u: User):
            if (
                hasattr(self, "_prefetched_objects_cache")
                and "whitelisted_users" in self._prefetched_objects_cache
            ):
                return u in self.whitelisted_users.all()
            return self.whitelisted_users.contains(u)

        return (
            user.id == self.id
            or user.has_perm("core.view_hidden_user")
            or (
                user.has_perm("core.view_user")
                and (self.is_viewable or is_in_whitelist(user))
            )
        )

### `clubs_with_rights()`

The list of clubs where the user has rights

Source code in `core/models.py`

    639
    640
    641
    642
    643
    @cached_property
    def clubs_with_rights(self) -> list[Club]:
        """The list of clubs where the user has rights"""
        memberships = self.memberships.ongoing().board().select_related("club")
        return [m.club for m in memberships]

## `AnonymousUser`

Bases: `AnonymousUser`

### `is_in_group(*, pk=None, name=None)`

The anonymous user is only in the public group.

Source code in `core/models.py`

    683
    684
    685
    686
    687
    688
    689
    690
    691
    692
    def is_in_group(self, *, pk: int | None = None, name: str | None = None) -> bool:
        """The anonymous user is only in the public group."""
        allowed_id = settings.SITH_GROUP_PUBLIC_ID
        if pk is not None:
            return pk == allowed_id
        elif name is not None:
            group = Group.objects.get(id=allowed_id)
            return group.name == name
        else:
            raise ValueError("You must either provide the id or the name of the group")

## `UserBan`

Bases: `Model`

A ban of a user.

A user can be banned for a specific reason, for a specific duration. The expiration date is indicative, and the ban should be removed manually.

## `GlobalPermissionRights`

Bases: `Model`

Little hack to have permissions not linked to a specific db table.

## `Preferences`

Bases: `Model`

## `SithFile`

Bases: `Model`

### `clean()`

Cleans up the file.

Source code in `core/models.py`

    943
    944
    945
    946
    947
    948
    949
    950
    951
    952
    953
    954
    955
    956
    957
    958
    959
    960
    961
    962
    963
    964
    965
    966
    967
    968
    969
    970
    971
    972
    973
    974
    975
    976
    977
    978
    def clean(self):
        """Cleans up the file."""
        super().clean()
        if "/" in self.name:
            raise ValidationError(_("Character '/' not authorized in name"))
        if self == self.parent:
            raise ValidationError(_("Loop in folder tree"), code="loop")
        if self == self.parent or (
            self.parent is not None and self in self.get_parent_list()
        ):
            raise ValidationError(_("Loop in folder tree"), code="loop")
        if self.parent and self.parent.is_file:
            raise ValidationError(
                _("You can not make a file be a children of a non folder file")
            )
        if (
            self.parent is None
            and SithFile.objects.exclude(id=self.id)
            .filter(parent=None, name=self.name)
            .exists()
        ) or (
            self.parent
            and self.parent.children.exclude(id=self.id).filter(name=self.name).exists()
        ):
            raise ValidationError(_("Duplicate file"), code="duplicate")
        if self.is_folder:
            if self.file:
                try:
                    Image.open(BytesIO(self.file.read()))
                except Image.UnidentifiedImageError as e:
                    raise ValidationError(
                        _("This is not a valid folder thumbnail")
                    ) from e
            self.mime_type = "inode/directory"
        if self.is_file and (self.file is None or self.file == ""):
            raise ValidationError(_("You must provide a file"))

### `apply_rights_recursively(*, only_folders=False)`

Apply the rights of this file to all children recursively.

Parameters:

Name | Type | Description | Default
--- | --- | --- | ---
`only_folders` | `bool` | If True, only apply the rights to SithFiles that are folders. | `False`

Source code in `core/models.py`

     980
     981
     982
     983
     984
     985
     986
     987
     988
     989
     990
     991
     992
     993
     994
     995
     996
     997
     998
     999
    1000
    1001
    1002
    1003
    1004
    1005
    def apply_rights_recursively(self, *, only_folders: bool = False) -> None:
        """Apply the rights of this file to all children recursively.

        Args:
            only_folders: If True, only apply the rights to SithFiles that are folders.
        """
        file_ids = []
        explored_ids = [self.id]
        while len(explored_ids) > 0:  # find all children recursively
            file_ids.extend(explored_ids)
            next_level = SithFile.objects.filter(parent_id__in=explored_ids)
            if only_folders:
                next_level = next_level.filter(is_folder=True)
            explored_ids = list(next_level.values_list("id", flat=True))
        for through in (SithFile.view_groups.through, SithFile.edit_groups.through):
            # force evaluation. Without this, the iterator yields nothing
            groups = list(
                through.objects.filter(sithfile_id=self.id).values_list(
                    "group_id", flat=True
                )
            )
            # delete previous rights
            through.objects.filter(sithfile_id__in=file_ids).delete()
            through.objects.bulk_create(  # create new rights
                [through(sithfile_id=f, group_id=g) for f in file_ids for g in groups]
            )

### `copy_rights()`

Copy, if possible, the rights of the parent folder.

Source code in `core/models.py`

    1007
    1008
    1009
    1010
    1011
    def copy_rights(self):
        """Copy, if possible, the rights of the parent folder."""
        if self.parent is not None:
            self.edit_groups.set(self.parent.edit_groups.all())
            self.view_groups.set(self.parent.view_groups.all())

### `move_to(parent)`

Move a file to a new parent. `parent` must be a SithFile with the `is_folder=True` property. Otherwise, this function doesn't change anything. This is done only at the DB level, so that it's very fast for the user. Indeed, this function doesn't modify SithFiles recursively, so it stays efficient even with top-level folders.

Source code in `core/models.py`

    1013
    1014
    1015
    1016
    1017
    1018
    1019
    1020
    1021
    1022
    1023
    1024
    def move_to(self, parent):
        """Move a file to a new parent.
        `parent` must be a SithFile with the `is_folder=True` property. Otherwise, this function doesn't change
        anything.
        This is done only at the DB level, so that it's very fast for the user. Indeed, this function doesn't modify
        SithFiles recursively, so it stays efficient even with top-level folders.
        """
        if not parent.is_folder:
            return
        self.parent = parent
        self.clean()
        self.save()

## `QuickUploadImage`

Bases: `Model`

Images uploaded by user outside of the SithFile mechanism

## `LockError`

Bases: `Exception`

There was a lock error on the object.

## `AlreadyLocked`

Bases: `LockError`

The object is already locked.

## `NotLocked`

Bases: `LockError`

The object is not locked.

## `PageQuerySet`

Bases: `QuerySet`

## `Page`

Bases: `Model`

The page class to build a Wiki Each page may have a parent and it's URL is of the form my.site/page/// It has an ID field, but don't use it, since it's only there for DB part, and because compound primary key is awkward! Prefere querying pages with Page.get_page_by_full_name().

Be careful with the _full_name attribute: this field may not be valid until you call save(). It's made for fast query, but don't rely on it when playing with a Page object, use get_full_name() instead!

### `save(*args, force_lock=False, **kwargs)`

Performs some needed actions before and after saving a page in database.

Source code in `core/models.py`

    1230
    1231
    1232
    1233
    1234
    1235
    1236
    1237
    1238
    1239
    1240
    1241
    1242
    1243
    1244
    1245
    1246
    def save(self, *args, force_lock: bool = False, **kwargs):
        """Performs some needed actions before and after saving a page in database."""
        if not force_lock and not self.is_locked():
            raise NotLocked("The page is not locked and thus can not be saved")
        self.full_clean()
        if not self.id:
            super().save(
                *args, **kwargs
            )  # Save a first time to correctly set _full_name
        # This reset the _full_name just before saving to maintain a coherent field quicker for queries than the
        # recursive method
        # It also update all the children to maintain correct names
        self._full_name = self.get_full_name()
        for c in self.children.all():
            c.save(force_lock=force_lock)
        super().save(*args, **kwargs)
        self.unset_lock()

### `get_page_by_full_name(name)` `staticmethod`

Quicker to get a page with that method rather than building the request every time.

Source code in `core/models.py`

    1251
    1252
    1253
    1254
    @staticmethod
    def get_page_by_full_name(name):
        """Quicker to get a page with that method rather than building the request every time."""
        return Page.objects.filter(_full_name=name).first()

### `clean()`

Cleans up only the name for the moment, but this can be used to make any treatment before saving the object.

Source code in `core/models.py`

    1256
    1257
    1258
    1259
    1260
    1261
    1262
    1263
    1264
    1265
    1266
    1267
    1268
    def clean(self):
        """Cleans up only the name for the moment, but this can be used to make any treatment before saving the object."""
        if "/" in self.name:
            self.name = self.name.split("/")[-1]
        if (
            Page.objects.exclude(pk=self.pk)
            .filter(_full_name=self.get_full_name())
            .exists()
        ):
            raise ValidationError(_("Duplicate page"), code="duplicate")
        super().clean()
        if self.parent is not None and self in self.get_parent_list():
            raise ValidationError(_("Loop in page tree"), code="loop")

### `is_locked()`

Is True if the page is locked, False otherwise.

This is where the timeout is handled, so a locked page for which the timeout is reach will be unlocked and this function will return False.

Source code in `core/models.py`

    1287
    1288
    1289
    1290
    1291
    1292
    1293
    1294
    1295
    1296
    1297
    1298
    1299
    1300
    1301
    1302
    def is_locked(self):
        """Is True if the page is locked, False otherwise.

        This is where the timeout is handled,
        so a locked page for which the timeout is reach will be unlocked and this
        function will return False.
        """
        if self.lock_timeout and (
            timezone.now() - self.lock_timeout > timedelta(minutes=5)
        ):
            self.unset_lock()
        return (
            self.lock_user
            and self.lock_timeout
            and (timezone.now() - self.lock_timeout < timedelta(minutes=5))
        )

### `set_lock(user)`

Sets a lock on the current page or raise an AlreadyLocked exception.

Source code in `core/models.py`

    1304
    1305
    1306
    1307
    1308
    1309
    1310
    def set_lock(self, user):
        """Sets a lock on the current page or raise an AlreadyLocked exception."""
        if self.is_locked() and self.get_lock() != user:
            raise AlreadyLocked("The page is already locked by someone else")
        self.lock_user = user
        self.lock_timeout = timezone.now()
        super().save()

### `set_lock_recursive(user)`

Locks recursively all the child pages for editing properties.

Source code in `core/models.py`

    1312
    1313
    1314
    1315
    1316
    def set_lock_recursive(self, user):
        """Locks recursively all the child pages for editing properties."""
        for p in self.children.all():
            p.set_lock_recursive(user)
        self.set_lock(user)

### `unset_lock_recursive()`

Unlocks recursively all the child pages.

Source code in `core/models.py`

    1318
    1319
    1320
    1321
    1322
    def unset_lock_recursive(self):
        """Unlocks recursively all the child pages."""
        for p in self.children.all():
            p.unset_lock_recursive()
        self.unset_lock()

### `unset_lock()`

Always try to unlock, even if there is no lock.

Source code in `core/models.py`

    1324
    1325
    1326
    1327
    1328
    def unset_lock(self):
        """Always try to unlock, even if there is no lock."""
        self.lock_user = None
        self.lock_timeout = None
        super().save()

### `get_lock()`

Returns the page's mutex containing the time and the user in a dict.

Source code in `core/models.py`

    1330
    1331
    1332
    1333
    1334
    def get_lock(self):
        """Returns the page's mutex containing the time and the user in a dict."""
        if self.lock_user:
            return self.lock_user
        raise NotLocked("The page is not locked and thus can not return its user")

### `get_full_name()`

Computes the real full_name of the page based on its name and its parent's name You can and must rely on this function when working on a page object that is not freshly fetched from the DB (For example when treating a Page object coming from a form).

Source code in `core/models.py`

    1336
    1337
    1338
    1339
    1340
    1341
    1342
    1343
    def get_full_name(self):
        """Computes the real full_name of the page based on its name and its parent's name
        You can and must rely on this function when working on a page object that is not freshly fetched from the DB
        (For example when treating a Page object coming from a form).
        """
        if self.parent is None:
            return self.name
        return f"{self.parent.get_full_name()}/{self.name}"

## `PageRev`

Bases: `Model`

True content of the page.

Each page object has a revisions field that is a list of PageRev, ordered by date. my_page.revisions.last() gives the PageRev object that is the most up-to-date, and thus, is the real content of the page. The content is in PageRev.title and PageRev.content .

### `similarity_ratio(text)`

Similarity ratio between this revision's content and the given text.

The result is a float in [0; 1], 0 meaning the contents are entirely different, and 1 they are strictly the same.

Source code in `core/models.py`

    1423
    1424
    1425
    1426
    1427
    1428
    1429
    1430
    def similarity_ratio(self, text: str) -> float:
        """Similarity ratio between this revision's content and the given text.

        The result is a float in [0; 1], 0 meaning the contents are entirely different,
        and 1 they are strictly the same.
        """
        # cf. https://docs.python.org/3/library/difflib.html#difflib.SequenceMatcher.ratio
        return difflib.SequenceMatcher(None, self.content, text).quick_ratio()

### `should_merge(other)`

Return True if `other` should be merged into `self`, else False.

It's considered the other revision should be merged into this one if :

- it was made less than 20 minutes after
- by the same author
- with a similarity ratio higher than 80%

Source code in `core/models.py`

    1432
    1433
    1434
    1435
    1436
    1437
    1438
    1439
    1440
    1441
    1442
    1443
    1444
    1445
    1446
    1447
    def should_merge(self, other: Self) -> bool:
        """Return True if `other` should be merged into `self`, else False.

        It's considered the other revision should be merged into this one if :

        - it was made less than 20 minutes after
        - by the same author
        - with a similarity ratio higher than 80%
        """
        return (
            not self._state.adding  # cannot merge if the original rev doesn't exist
            and self.author == other.author
            and (other.date - self.date) < self.MERGE_TIME_THRESHOLD
            and (not other._state.adding or other.revision == self.revision + 1)
            and self.similarity_ratio(other.content) >= (1 - other.MERGE_DIFF_THRESHOLD)
        )

## `Notification`

Bases: `Model`

## `Gift`

Bases: `Model`

## `OperationLog`

Bases: `Model`

General purpose log object to register operations.

## `get_last_promo()`

Get the latest promo at the time the function is called.

For example, if called in october 2022 return 24, if called in march 2026 return 27, etc.

Source code in `core/utils.py`

    114
    115
    116
    117
    118
    119
    120
    121
    def get_last_promo() -> int:
        """Get the latest promo at the time the function is called.

        For example, if called in october 2022 return 24,
        if called in march 2026 return 27, etc.
        """
        start_year = settings.SITH_SCHOOL_START_YEAR
        return (localdate() + timedelta(days=180)).year - start_year

## `validate_promo(value)`

Source code in `core/models.py`

    80
    81
    82
    83
    84
    85
    86
    def validate_promo(value: int) -> None:
        last_promo = get_last_promo()
        if not 0 < value <= last_promo:
            raise ValidationError(
                _("%(value)s is not a valid promo (between 0 and %(end)s)"),
                params={"value": value, "end": last_promo},
            )

## `get_directory(instance, filename)`

Source code in `core/models.py`

    809
    810
    def get_directory(instance, filename):
        return ".{0}/{1}".format(instance.get_parent_path(), filename)

## `get_compressed_directory(instance, filename)`

Source code in `core/models.py`

    813
    814
    def get_compressed_directory(instance, filename):
        return "./.compressed/{0}/{1}".format(instance.get_parent_path(), filename)

## `get_thumbnail_directory(instance, filename)`

Source code in `core/models.py`

    817
    818
    def get_thumbnail_directory(instance, filename):
        return "./.thumbnails/{0}/{1}".format(instance.get_parent_path(), filename)

## `get_default_owner_group()`

Source code in `core/models.py`

    1151
    1152
    def get_default_owner_group():
        return settings.SITH_GROUP_ROOT_ID

## `get_notification_types()`

Source code in `core/models.py`

    1450
    1451
    def get_notification_types():
        return settings.SITH_NOTIFICATIONS