Aller au contenu

# Schemas

## `NonEmptyStr = Annotated[str, MinLen(1)]` `module-attribute`

## `Group`

Bases: `Group`

Wrapper around django.auth.Group

## `QuickUploadImage`

Bases: `Model`

Images uploaded by user outside of the SithFile mechanism

## `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()

## `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]

## `UploadedImage`

Bases: `UploadedFile`

## `SimpleUserSchema`

Bases: `ModelSchema`

A schema with the minimum amount of information to represent a user.

## `UserSchema`

Bases: `ModelSchema`

## `UserProfileSchema`

Bases: `ModelSchema`

The necessary information to show a user profile

## `UploadedFileSchema`

Bases: `ModelSchema`

## `SithFileSchema`

Bases: `ModelSchema`

## `GroupSchema`

Bases: `ModelSchema`

## `UserFilterSchema`

Bases: `FilterSchema`

## `MarkdownSchema`

Bases: `Schema`

## `FamilyGodfatherSchema`

Bases: `Schema`

## `UserFamilySchema`

Bases: `Schema`

Represent a graph of a user's family

## `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

## `is_image(file)`

Source code in `core/utils.py`

    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    def is_image(file: UploadedFile):
        try:
            im = PIL.Image.open(file.file)
            im.verify()
            # go back to the start of the file, without closing it.
            # Otherwise, further checks on django side will fail
            file.seek(0)
        except PIL.UnidentifiedImageError:
            return False
        return True