Aller au contenu

# Models

## `current_star` `property`

The star of this user in the :class:`Galaxy`.

Only take into account the most recent active galaxy.

Returns:

Type | Description
--- | ---
`GalaxyStar | None` | The star of this user if there is an active Galaxy
`GalaxyStar | None` | and this user is a citizen of it, else `None`

## `Membership`

Bases: `Model`

The Membership class makes the connection between User and Clubs.

Both Users and Clubs can have many Membership objects

- a user can be a member of many clubs at a time
- a club can have many members at a time too

A User is currently member of all the Clubs where its Membership has an end_date set to null/None. Otherwise, it's a past membership kept because it can be very useful to see who was in which Club in the past.

### `is_owned_by(user)`

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

Source code in `club/models.py`

    513
    514
    515
    516
    517
    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)`

Check if that object can be edited by the given user.

Source code in `club/models.py`

    519
    520
    521
    522
    523
    524
    525
    526
    527
    528
    def can_be_edited_by(self, user: User) -> bool:
        """Check if that object can be edited by the given user."""
        if user.is_root or user.is_board_member:
            return True
        membership = self.club.get_membership_for(user)
        if not membership:
            return False
        return membership.user_id == user.id or (
            membership.is_board and membership.role.order < self.role.order
        )

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

## `GalaxyStar`

Bases: `Model`

Define a star (vertex -> user) in the galaxy graph.

Store a reference to its owner citizen.

Stars are linked to each others through the :class:`GalaxyLane` model.

Each GalaxyStar has a mass which push it towards the center of the galaxy. This mass is proportional to the number of pictures the owner of the star is tagged on.

## `GalaxyLane`

Bases: `Model`

Define a lane (edge -> link between galaxy citizen) in the galaxy map.

Store a reference to both its ends and the distance it covers. Score details between citizen owning the stars is also stored here.

## `StarDict`

Bases: `TypedDict`

## `GalaxyDict`

Bases: `TypedDict`

## `RelationScore`

Bases: `NamedTuple`

## `Galaxy`

Bases: `Model`

The Galaxy, a graph linking the active users between each others.

The distance between two users is given by a relation score which takes into account a few parameter like the number of pictures they are both tagged on, the time during which they were in the same clubs and whether they are in the same family.

The citizens of the Galaxy are represented by :class:`GalaxyStar` and their relations by :class:`GalaxyLane`.

Several galaxies can coexist. In this case, only the most recent active one shall usually be taken into account. This is useful to keep the current galaxy while generating a new one and swapping them only at the very end.

Please take into account that generating the galaxy is a very expensive operation. For this reason, try not to call the :meth:`rule` method more than once a day in production.

To quickly access to the state of a galaxy, use the :attr:`state` attribute.

### `compute_individual_scores()` `classmethod`

Compute an individual score for each citizen.

It will later be used by the graph algorithm to push higher scores towards the center of the galaxy.

Idea: This could be added to the computation:

- Picture count
- Counter consumption
- Barman time
- ...

Source code in `galaxy/models.py`

    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    241
    242
    243
    244
    245
    246
    247
    248
    @classmethod
    def compute_individual_scores(cls) -> dict[int, int]:
        """Compute an individual score for each citizen.

        It will later be used by the graph algorithm to push
        higher scores towards the center of the galaxy.

        Idea: This could be added to the computation:

        - Picture count
        - Counter consumption
        - Barman time
        - ...
        """
        users = (
            User.objects.annotate(
                score=(
                    Count("godchildren", distinct=True) * cls.FAMILY_LINK_POINTS
                    + Count("godfathers", distinct=True) * cls.FAMILY_LINK_POINTS
                    + Count("pictures", distinct=True) * cls.PICTURE_POINTS
                    + Count("memberships", distinct=True) * cls.CLUBS_POINTS
                )
            )
            .filter(score__gt=0)
            .values("id", "score")
        )
        # TODO:
        # Scale that value with some magic number to accommodate to typical data
        # Really active galaxy citizen after 5 years typically have a score of about XXX
        # Citizen that were seen regularly without taking much part in organizations typically have a score of about XXX
        # Citizen that only went to a few events typically score about XXX
        res = {u["id"]: int(math.log2(u["score"] + 1)) for u in users}
        return res

### `compute_user_family_score(user)` `classmethod`

Compute the family score of the relation between the given users.

This takes into account mutual godfathers.

Source code in `galaxy/models.py`

    254
    255
    256
    257
    258
    259
    260
    261
    262
    263
    264
    265
    @classmethod
    def compute_user_family_score(cls, user: User) -> defaultdict[int, int]:
        """Compute the family score of the relation between the given users.

        This takes into account mutual godfathers.
        """
        godchildren = User.objects.filter(godchildren=user).values_list("id", flat=True)
        godfathers = User.objects.filter(godfathers=user).values_list("id", flat=True)
        result = defaultdict(int)
        for parent in itertools.chain(godchildren, godfathers):
            result[parent] += cls.FAMILY_LINK_POINTS
        return result

### `compute_user_pictures_score(user)` `classmethod`

Compute the pictures score of the relation between the given users.

The pictures score is obtained by counting the number of :class:`Picture` in which they have been both identified. This score is then multiplied by 2.

Returns:

Type | Description
--- | ---
`defaultdict[int, int]` | The number of pictures both users have in common, times 2

Source code in `galaxy/models.py`

    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    280
    281
    282
    283
    284
    285
    286
    287
    @classmethod
    def compute_user_pictures_score(cls, user: User) -> defaultdict[int, int]:
        """Compute the pictures score of the relation between the given users.

        The pictures score is obtained by counting the number
        of :class:`Picture` in which they have been both identified.
        This score is then multiplied by 2.

        Returns:
             The number of pictures both users have in common, times 2
        """
        common_photos = (
            PeoplePictureRelation.objects.filter(
                picture__in=Picture.objects.filter(people__user=user)
            )
            .values("user")
            .annotate(count=Count("user"))
        )
        return defaultdict(
            int, {p["user"]: p["count"] * cls.PICTURE_POINTS for p in common_photos}
        )

### `compute_user_clubs_score(user)` `classmethod`

Compute the clubs score of the relation between the given users.

The club score is obtained by counting the number of days during which the memberships (see :class:`club.models.Membership`) of both users overlapped.

For example, if user1 was a member of Unitec from 01/01/2020 to 31/12/2021 (two years) and user2 was a member of the same club from 01/01/2021 to 31/12/2022 (also two years, but with an offset of one year), then their club score is 365.

Source code in `galaxy/models.py`

    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    305
    306
    307
    308
    309
    310
    311
    312
    313
    314
    315
    316
    317
    318
    319
    320
    321
    322
    323
    324
    325
    326
    327
    328
    329
    330
    @classmethod
    def compute_user_clubs_score(cls, user: User) -> defaultdict[int, int]:
        """Compute the clubs score of the relation between the given users.

        The club score is obtained by counting the number of days
        during which the memberships (see :class:`club.models.Membership`)
        of both users overlapped.

        For example, if user1 was a member of Unitec from 01/01/2020 to 31/12/2021
        (two years) and user2 was a member of the same club from 01/01/2021 to
        31/12/2022 (also two years, but with an offset of one year), then their
        club score is 365.
        """
        memberships = user.memberships.values("start_date", "end_date", "club_id")
        result = defaultdict(int)
        today = localdate()
        for membership in memberships:
            # This is a N+1 query, but 92% of galaxy users have less than 10 memberships.
            # Only 5 users have more than 30 memberships.
            common_memberships = (
                Membership.objects.exclude(user=user)
                .filter(
                    Q(  # start2 <= start1 <= end2
                        start_date__lte=membership["start_date"],
                        end_date__gte=membership["start_date"],
                    )
                    | Q(  # start2 <= start1 <= today
                        start_date__lte=membership["start_date"], end_date=None
                    )
                    | Q(  # start1 <= start2 <= end2
                        start_date__gte=membership["start_date"],
                        start_date__lte=membership["end_date"] or today,
                    ),
                    club_id=membership["club_id"],
                )
                .only("start_date", "end_date", "user_id")
            )
            for other in common_memberships:
                start = max(membership["start_date"], other.start_date)
                end = min(membership["end_date"] or today, other.end_date or today)
                result[other.user_id] += (end - start).days * cls.CLUBS_POINTS
        return result

### `scale_distance(value)` `classmethod`

Given a numeric value, return a scaled value which can be used in the Galaxy's graphical interface to set the distance between two stars.

Returns:

Type | Description
--- | ---
`int` | the scaled value usable in the Galaxy's 3d graph

Source code in `galaxy/models.py`

    336
    337
    338
    339
    340
    341
    342
    343
    344
    345
    346
    347
    348
    349
    350
    351
    352
    353
    354
    355
    356
    357
    358
    359
    360
    361
    362
    363
    @classmethod
    def scale_distance(cls, value: int | float) -> int:
        """Given a numeric value, return a scaled value which can
        be used in the Galaxy's graphical interface to set the distance
        between two stars.

        Returns:
            the scaled value usable in the Galaxy's 3d graph
        """
        # TODO: this will need adjustements with the real, typical data on Taiste
        if value == 0:
            return 4000  # Following calculus would give us +∞, we cap it to 4000

        cls.logger.debug(f"\t\t> Score: {value}")
        # Invert score to draw close users together
        value = 1 / value  # Cannot be 0
        value += 2  # We use log2 just below and need to stay above 1
        value = (  # Let's get something in the range ]0; log2(3)-1≈0.58[ that we can multiply later
            math.log2(value) - 1
        )
        value *= (  # Scale that value with a magic number to accommodate to typical data
            # Really close galaxy citizen after 5 years typically have a score of about XXX
            # Citizen that were in the same year without being really friends typically have a score of about XXX
            # Citizen that have met once or twice only have a couple of pictures together typically score about XXX
            cls.GALAXY_SCALE_FACTOR
        )
        cls.logger.debug(f"\t\t> Scaled distance: {value}")
        return int(value)

### `rule(picture_count_threshold=DEFAULT_PICTURE_COUNT_THRESHOLD)`

Main function of the Galaxy.

Iterate over all the rulable users to promote them to citizens. A citizen is a user who has a corresponding star in the Galaxy. Also build up the lanes, which are the links between the different citizen.

Users who can be ruled are defined with the `picture_count_threshold`: all users who are identified in a strictly lower number of pictures won't be promoted to citizens. This does very effectively limit the quantity of computing to do and only includes users who have had a minimum of activity.

This method still remains very expensive, so think thoroughly before you call it, especially in production.

:param picture_count_threshold: the minimum number of picture to have to be included in the galaxy

Source code in `galaxy/models.py`

    365
    366
    367
    368
    369
    370
    371
    372
    373
    374
    375
    376
    377
    378
    379
    380
    381
    382
    383
    384
    385
    386
    387
    388
    389
    390
    391
    392
    393
    394
    395
    396
    397
    398
    399
    400
    401
    402
    403
    404
    405
    406
    407
    408
    409
    410
    411
    412
    413
    414
    415
    416
    417
    418
    419
    420
    421
    422
    423
    424
    425
    426
    427
    428
    429
    430
    431
    432
    433
    434
    435
    436
    437
    438
    439
    440
    441
    442
    443
    444
    445
    446
    447
    448
    449
    450
    451
    452
    453
    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
    488
    489
    490
    491
    492
    493
    494
    495
    496
    497
    498
    499
    500
    def rule(
        self, picture_count_threshold: int = DEFAULT_PICTURE_COUNT_THRESHOLD
    ) -> None:
        """Main function of the Galaxy.

        Iterate over all the rulable users to promote them to citizens.
        A citizen is a user who has a corresponding star in the Galaxy.
        Also build up the lanes, which are the links between the different citizen.

        Users who can be ruled are defined with the `picture_count_threshold`:
        all users who are identified in a strictly lower number of pictures
        won't be promoted to citizens.
        This does very effectively limit the quantity of computing to do
        and only includes users who have had a minimum of activity.

        This method still remains very expensive, so think thoroughly before
        you call it, especially in production.

        :param picture_count_threshold: the minimum number of picture to have to be
                                        included in the galaxy
        """
        total_time = time.time()
        self.logger.info("Listing rulable citizen.")

        # force fetch of the whole query to make sure there won't
        # be any more db hits
        # this is memory expensive but prevents a lot of db hits, therefore
        # is far more time efficient

        rulable_users_qs = self.get_rulable_users(picture_count_threshold)
        active_users_count = rulable_users_qs.filter(is_active_in_galaxy=True).count()
        rulable_users = list(rulable_users_qs)
        user1_count = 0
        self.logger.info(
            f" {len(rulable_users)} citizens (with {active_users_count} active ones) "
            f"have been listed. Starting to rule."
        )

        self.logger.info("Creating stars for all citizen")
        individual_scores = self.compute_individual_scores()
        GalaxyStar.objects.bulk_create(
            [
                GalaxyStar(
                    owner_id=user.id, galaxy=self, mass=individual_scores[user.id]
                )
                for user in rulable_users
            ]
        )
        stars = {star.owner_id: star for star in self.stars.all()}

        self.logger.info("Creating lanes between stars")
        global_avg_speed_accumulator = 0
        global_avg_speed_count = 0
        t_global_start = time.time()
        while len(rulable_users) > 0:
            user1 = rulable_users.pop()
            if not user1.is_active_in_galaxy:
                continue
            user1_count += 1
            star1 = stars[user1.id]

            lanes = []
            family_scores = self.compute_user_family_score(user1)
            picture_scores = self.compute_user_pictures_score(user1)
            club_scores = self.compute_user_clubs_score(user1)

            for user2 in rulable_users:
                star2 = stars[user2.id]

                score = RelationScore(
                    family=family_scores.get(user2.id, 0),
                    pictures=picture_scores.get(user2.id, 0),
                    clubs=club_scores.get(user2.id, 0),
                )
                distance = self.scale_distance(sum(score))
                if distance < 30:  # TODO: this needs tuning with real-world data
                    lanes.append(
                        GalaxyLane(
                            star1=star1,
                            star2=star2,
                            distance=distance,
                            family=score.family,
                            pictures=score.pictures,
                            clubs=score.clubs,
                        )
                    )
            GalaxyLane.objects.bulk_create(lanes)

            t_global_end = time.time()
            global_delta = t_global_end - t_global_start
            speed = 1.0 / global_delta
            global_avg_speed_accumulator += speed
            global_avg_speed_count += 1
            global_avg_speed = global_avg_speed_accumulator / global_avg_speed_count

            if user1_count % 50 == 0:
                self.logger.info("")
                self.logger.info(f" Ruling of {self} ".center(60, "#"))
                self.logger.info(
                    f"Progression: {user1_count}/{active_users_count} "
                    f"citizen -- {active_users_count - user1_count} remaining"
                )
                self.logger.info(f"Speed: {global_avg_speed:.2f} citizen per second")
                eta = len(rulable_users) // global_avg_speed
                self.logger.info(
                    f"ETA: {int(eta // 60 % 60)} minutes {int(eta % 60)} seconds"
                )
                self.logger.info("#" * 60)
            t_global_start = time.time()

        count, _ = self.stars.filter(Q(lanes1=None) & Q(lanes2=None)).delete()
        self.logger.info(f"{count} orphan stars have been trimmed.")

        # Here, we get the IDs of the old galaxies that we'll need to delete. In normal operation, only one galaxy
        # should be returned, and we can't delete it yet, as it's the one still displayed by the Sith.
        old_galaxies_pks = list(
            Galaxy.objects.filter(state__isnull=False).values_list("pk", flat=True)
        )
        self.logger.info(
            f"These old galaxies will be deleted once the new one is ready: {old_galaxies_pks}"
        )

        # Making the state sets this new galaxy as being ready. From now on, the Sith will show us to the world.
        self.make_state()

        # Avoid accident if there is nothing to delete
        if len(old_galaxies_pks) > 0:
            # Former galaxies can now be deleted.
            Galaxy.objects.filter(pk__in=old_galaxies_pks).delete()

        total_time = time.time() - total_time
        total_time_minutes = int(total_time // 60 % 60)
        total_time_seconds = int(total_time % 60)
        self.logger.info(
            f"{self} ruled in {total_time_minutes} minutes, {total_time_seconds} seconds"
        )

### `make_state()`

Compute JSON structure to send to 3d-force-graph: https://github.com/vasturiano/3d-force-graph/.

Source code in `galaxy/models.py`

    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
    532
    533
    534
    535
    536
    537
    def make_state(self) -> None:
        """Compute JSON structure to send to 3d-force-graph: https://github.com/vasturiano/3d-force-graph/."""
        self.logger.info(
            "Caching current Galaxy state for a quicker display of the Empire's power."
        )
        stars = (
            GalaxyStar.objects.filter(galaxy=self)
            .order_by("owner_id")
            .select_related("owner")
        )
        lanes = (
            GalaxyLane.objects.filter(star1__galaxy=self)
            .order_by("star1")
            .annotate(
                star1_owner=F("star1__owner_id"), star2_owner=F("star2__owner_id")
            )
        )
        json = GalaxyDict(
            nodes=[
                StarDict(
                    id=star.owner_id, name=star.owner.get_display_name(), mass=star.mass
                )
                for star in stars
            ],
            links=[
                {
                    "source": path.star1_owner,
                    "target": path.star2_owner,
                    "value": path.distance,
                }
                for path in lanes
            ],
        )
        self.state = json
        self.save()
        self.logger.info(f"{self} is now ready!")