mirror of
https://github.com/ae-utbm/sith.git
synced 2025-07-13 05:19:26 +00:00
Compare commits
1 Commits
galaxy
...
dependabot
Author | SHA1 | Date | |
---|---|---|---|
58589a4e9c |
14
.github/actions/setup_project/action.yml
vendored
14
.github/actions/setup_project/action.yml
vendored
@ -1,24 +1,15 @@
|
|||||||
name: "Setup project"
|
name: "Setup project"
|
||||||
description: "Setup Python and Poetry"
|
description: "Setup Python and Poetry"
|
||||||
inputs:
|
|
||||||
full:
|
|
||||||
description: >
|
|
||||||
If true, do a full setup, else install
|
|
||||||
only python, uv and non-xapian python deps
|
|
||||||
required: false
|
|
||||||
default: "false"
|
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- name: Install apt packages
|
- name: Install apt packages
|
||||||
if: ${{ inputs.full == 'true' }}
|
|
||||||
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
|
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
|
||||||
with:
|
with:
|
||||||
packages: gettext
|
packages: gettext
|
||||||
version: 1.0 # increment to reset cache
|
version: 1.0 # increment to reset cache
|
||||||
|
|
||||||
- name: Install Redis
|
- name: Install Redis
|
||||||
if: ${{ inputs.full == 'true' }}
|
|
||||||
uses: shogo82148/actions-setup-redis@v1
|
uses: shogo82148/actions-setup-redis@v1
|
||||||
with:
|
with:
|
||||||
redis-version: "7.x"
|
redis-version: "7.x"
|
||||||
@ -46,20 +37,15 @@ runs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Install Xapian
|
- name: Install Xapian
|
||||||
if: ${{ inputs.full == 'true' }}
|
|
||||||
run: uv run ./manage.py install_xapian
|
run: uv run ./manage.py install_xapian
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
# compiling xapian accounts for almost the entirety of the virtualenv setup,
|
|
||||||
# so we save the virtual environment only on workflows where it has been installed
|
|
||||||
- name: Save cached virtualenv
|
- name: Save cached virtualenv
|
||||||
if: ${{ inputs.full == 'true' }}
|
|
||||||
uses: actions/cache/save@v4
|
uses: actions/cache/save@v4
|
||||||
with:
|
with:
|
||||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
|
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
|
||||||
path: .venv
|
path: .venv
|
||||||
|
|
||||||
- name: Compile gettext messages
|
- name: Compile gettext messages
|
||||||
if: ${{ inputs.full == 'true' }}
|
|
||||||
run: uv run ./manage.py compilemessages
|
run: uv run ./manage.py compilemessages
|
||||||
shell: bash
|
shell: bash
|
||||||
|
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -37,8 +37,6 @@ jobs:
|
|||||||
- name: Check out repository
|
- name: Check out repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- uses: ./.github/actions/setup_project
|
- uses: ./.github/actions/setup_project
|
||||||
with:
|
|
||||||
full: true
|
|
||||||
env:
|
env:
|
||||||
# To avoid race conditions on environment cache
|
# To avoid race conditions on environment cache
|
||||||
CACHE_SUFFIX: ${{ matrix.pytest-mark }}
|
CACHE_SUFFIX: ${{ matrix.pytest-mark }}
|
||||||
|
6
.github/workflows/deploy_docs.yml
vendored
6
.github/workflows/deploy_docs.yml
vendored
@ -2,7 +2,11 @@ name: deploy_docs
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- taiste
|
- master
|
||||||
|
env:
|
||||||
|
SECRET_KEY: notTheRealOne
|
||||||
|
DATABASE_URL: sqlite:///db.sqlite3
|
||||||
|
CACHE_URL: redis://127.0.0.1:6379/0
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
jobs:
|
jobs:
|
||||||
|
@ -2,7 +2,7 @@ from ninja_extra import NinjaExtraAPI
|
|||||||
|
|
||||||
api = NinjaExtraAPI(
|
api = NinjaExtraAPI(
|
||||||
title="PICON",
|
title="PICON",
|
||||||
description="Portail Interactif de Communication avec les Outils Numériques",
|
description="Portail Interaction de Communication avec les Services Étudiants",
|
||||||
version="0.2.0",
|
version="0.2.0",
|
||||||
urls_namespace="api",
|
urls_namespace="api",
|
||||||
csrf=True,
|
csrf=True,
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from annotated_types import MinLen
|
from annotated_types import MinLen
|
||||||
from django.db.models import Prefetch
|
|
||||||
from ninja.security import SessionAuth
|
from ninja.security import SessionAuth
|
||||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||||
@ -9,7 +8,7 @@ from ninja_extra.schemas import PaginatedResponseSchema
|
|||||||
|
|
||||||
from api.auth import ApiKeyAuth
|
from api.auth import ApiKeyAuth
|
||||||
from api.permissions import CanAccessLookup, HasPerm
|
from api.permissions import CanAccessLookup, HasPerm
|
||||||
from club.models import Club, Membership
|
from club.models import Club
|
||||||
from club.schemas import ClubSchema, SimpleClubSchema
|
from club.schemas import ClubSchema, SimpleClubSchema
|
||||||
|
|
||||||
|
|
||||||
@ -34,9 +33,6 @@ class ClubController(ControllerBase):
|
|||||||
url_name="fetch_club",
|
url_name="fetch_club",
|
||||||
)
|
)
|
||||||
def fetch_club(self, club_id: int):
|
def fetch_club(self, club_id: int):
|
||||||
prefetch = Prefetch(
|
|
||||||
"members", queryset=Membership.objects.ongoing().select_related("user")
|
|
||||||
)
|
|
||||||
return self.get_object_or_exception(
|
return self.get_object_or_exception(
|
||||||
Club.objects.prefetch_related(prefetch), id=club_id
|
Club.objects.prefetch_related("members", "members__user"), id=club_id
|
||||||
)
|
)
|
||||||
|
@ -1,10 +1,7 @@
|
|||||||
from datetime import date, timedelta
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.test import Client
|
from django.test import Client
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from model_bakery import baker
|
from model_bakery import baker
|
||||||
from model_bakery.recipe import Recipe
|
|
||||||
from pytest_django.asserts import assertNumQueries
|
from pytest_django.asserts import assertNumQueries
|
||||||
|
|
||||||
from club.models import Club, Membership
|
from club.models import Club, Membership
|
||||||
@ -12,32 +9,13 @@ from core.baker_recipes import subscriber_user
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestFetchClub:
|
def test_fetch_club(client: Client):
|
||||||
@pytest.fixture()
|
|
||||||
def club(self):
|
|
||||||
club = baker.make(Club)
|
club = baker.make(Club)
|
||||||
last_month = date.today() - timedelta(days=30)
|
baker.make(Membership, club=club, _quantity=10, _bulk_create=True)
|
||||||
yesterday = date.today() - timedelta(days=1)
|
|
||||||
membership_recipe = Recipe(Membership, club=club, start_date=last_month)
|
|
||||||
membership_recipe.make(end_date=None, _quantity=10, _bulk_create=True)
|
|
||||||
membership_recipe.make(end_date=yesterday, _quantity=10, _bulk_create=True)
|
|
||||||
return club
|
|
||||||
|
|
||||||
def test_fetch_club_members(self, client: Client, club: Club):
|
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id}))
|
with assertNumQueries(7):
|
||||||
assert res.status_code == 200
|
|
||||||
member_ids = {member["user"]["id"] for member in res.json()["members"]}
|
|
||||||
assert member_ids == set(
|
|
||||||
club.members.ongoing().values_list("user_id", flat=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_fetch_club_nb_queries(self, client: Client, club: Club):
|
|
||||||
user = subscriber_user.make()
|
|
||||||
client.force_login(user)
|
|
||||||
with assertNumQueries(6):
|
|
||||||
# - 4 queries for authentication
|
# - 4 queries for authentication
|
||||||
# - 2 queries for the actual data
|
# - 3 queries for the actual data
|
||||||
res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id}))
|
res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id}))
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
|
@ -170,6 +170,7 @@ def news_notification_callback(notif: Notification):
|
|||||||
if count:
|
if count:
|
||||||
notif.viewed = False
|
notif.viewed = False
|
||||||
notif.param = str(count)
|
notif.param = str(count)
|
||||||
|
notif.date = timezone.now()
|
||||||
else:
|
else:
|
||||||
notif.viewed = True
|
notif.viewed = True
|
||||||
|
|
||||||
|
@ -1,27 +0,0 @@
|
|||||||
# Generated by Django 5.2.1 on 2025-06-11 16:10
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
import core.models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [("core", "0046_permissionrights")]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="notification",
|
|
||||||
name="date",
|
|
||||||
field=models.DateTimeField(auto_now=True, verbose_name="date"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="notification",
|
|
||||||
name="type",
|
|
||||||
field=models.CharField(
|
|
||||||
choices=core.models.get_notification_types,
|
|
||||||
default="GENERIC",
|
|
||||||
max_length=32,
|
|
||||||
verbose_name="type",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1451,10 +1451,6 @@ class PageRev(models.Model):
|
|||||||
return self.page.can_be_edited_by(user)
|
return self.page.can_be_edited_by(user)
|
||||||
|
|
||||||
|
|
||||||
def get_notification_types():
|
|
||||||
return settings.SITH_NOTIFICATIONS
|
|
||||||
|
|
||||||
|
|
||||||
class Notification(models.Model):
|
class Notification(models.Model):
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
User, related_name="notifications", on_delete=models.CASCADE
|
User, related_name="notifications", on_delete=models.CASCADE
|
||||||
@ -1462,9 +1458,9 @@ class Notification(models.Model):
|
|||||||
url = models.CharField(_("url"), max_length=255)
|
url = models.CharField(_("url"), max_length=255)
|
||||||
param = models.CharField(_("param"), max_length=128, default="")
|
param = models.CharField(_("param"), max_length=128, default="")
|
||||||
type = models.CharField(
|
type = models.CharField(
|
||||||
_("type"), max_length=32, choices=get_notification_types, default="GENERIC"
|
_("type"), max_length=32, choices=settings.SITH_NOTIFICATIONS, default="GENERIC"
|
||||||
)
|
)
|
||||||
date = models.DateTimeField(_("date"), auto_now=True)
|
date = models.DateTimeField(_("date"), default=timezone.now)
|
||||||
viewed = models.BooleanField(_("viewed"), default=False, db_index=True)
|
viewed = models.BooleanField(_("viewed"), default=False, db_index=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
@ -1,36 +0,0 @@
|
|||||||
import { exportToHtml } from "#core:utils/globals";
|
|
||||||
|
|
||||||
exportToHtml("showMenu", () => {
|
|
||||||
const navbar = document.getElementById("navbar-content");
|
|
||||||
const current = navbar.getAttribute("mobile-display");
|
|
||||||
navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden");
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("alpine:init", () => {
|
|
||||||
const menuItems = document.querySelectorAll(".navbar details[name='navbar'].menu");
|
|
||||||
const isDesktop = () => {
|
|
||||||
return window.innerWidth >= 500;
|
|
||||||
};
|
|
||||||
for (const item of menuItems) {
|
|
||||||
item.addEventListener("mouseover", () => {
|
|
||||||
if (isDesktop()) {
|
|
||||||
item.setAttribute("open", "");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
item.addEventListener("mouseout", () => {
|
|
||||||
if (isDesktop()) {
|
|
||||||
item.removeAttribute("open");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
item.addEventListener("click", (event: MouseEvent) => {
|
|
||||||
// Don't close when clicking on desktop mode
|
|
||||||
if ((event.target as HTMLElement).nodeName !== "SUMMARY" || event.detail === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDesktop()) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
@ -18,7 +18,6 @@
|
|||||||
<noscript><link rel="stylesheet" href="{{ static('bundled/fontawesome-index.css') }}"></noscript>
|
<noscript><link rel="stylesheet" href="{{ static('bundled/fontawesome-index.css') }}"></noscript>
|
||||||
|
|
||||||
<script src="{{ url('javascript-catalog') }}"></script>
|
<script src="{{ url('javascript-catalog') }}"></script>
|
||||||
<script type="module" src={{ static("bundled/core/navbar-index.ts") }}></script>
|
|
||||||
<script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script>
|
<script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script>
|
||||||
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
|
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
|
||||||
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
|
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
|
||||||
@ -108,6 +107,39 @@
|
|||||||
|
|
||||||
{% block script %}
|
{% block script %}
|
||||||
<script>
|
<script>
|
||||||
|
const menuItems = document.querySelectorAll(".navbar details[name='navbar'].menu");
|
||||||
|
const isDesktop = () => {
|
||||||
|
return window.innerWidth >= 500;
|
||||||
|
}
|
||||||
|
for (const item of menuItems){
|
||||||
|
item.addEventListener("mouseover", () => {
|
||||||
|
if (isDesktop()){
|
||||||
|
item.setAttribute("open", "");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
item.addEventListener("mouseout", () => {
|
||||||
|
if (isDesktop()){
|
||||||
|
item.removeAttribute("open");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
item.addEventListener("click", (event) => {
|
||||||
|
// Ignore keyboard clicks
|
||||||
|
if (event.detail === 0){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDesktop()){
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMenu() {
|
||||||
|
let navbar = document.getElementById("navbar-content");
|
||||||
|
const current = navbar.getAttribute("mobile-display");
|
||||||
|
navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden")
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
// Looking at the `s` key when not typing in a form
|
// Looking at the `s` key when not typing in a form
|
||||||
if (e.keyCode !== 83 || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) {
|
if (e.keyCode !== 83 || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) {
|
||||||
|
339
galaxy/models.py
339
galaxy/models.py
@ -23,21 +23,20 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import itertools
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
|
||||||
from typing import NamedTuple, TypedDict
|
from typing import NamedTuple, TypedDict
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Count, F, Q, QuerySet
|
from django.db.models import Case, Count, F, Q, Value, When
|
||||||
|
from django.db.models.functions import Concat
|
||||||
from django.utils.timezone import localdate
|
from django.utils.timezone import localdate
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from club.models import Membership
|
from club.models import Club
|
||||||
from core.models import User
|
from core.models import User
|
||||||
from sas.models import PeoplePictureRelation, Picture
|
from sas.models import Picture
|
||||||
|
|
||||||
|
|
||||||
class GalaxyStar(models.Model):
|
class GalaxyStar(models.Model):
|
||||||
@ -115,9 +114,18 @@ class GalaxyLane(models.Model):
|
|||||||
default=0,
|
default=0,
|
||||||
help_text=_("Distance separating star1 and star2"),
|
help_text=_("Distance separating star1 and star2"),
|
||||||
)
|
)
|
||||||
family = models.PositiveIntegerField(_("family score"), default=0)
|
family = models.PositiveIntegerField(
|
||||||
pictures = models.PositiveIntegerField(_("pictures score"), default=0)
|
_("family score"),
|
||||||
clubs = models.PositiveIntegerField(_("clubs score"), default=0)
|
default=0,
|
||||||
|
)
|
||||||
|
pictures = models.PositiveIntegerField(
|
||||||
|
_("pictures score"),
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
clubs = models.PositiveIntegerField(
|
||||||
|
_("clubs score"),
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.star1} -> {self.star2} ({self.distance})"
|
return f"{self.star1} -> {self.star2} ({self.distance})"
|
||||||
@ -166,7 +174,6 @@ class Galaxy(models.Model):
|
|||||||
logger = logging.getLogger("main")
|
logger = logging.getLogger("main")
|
||||||
|
|
||||||
GALAXY_SCALE_FACTOR = 2_000
|
GALAXY_SCALE_FACTOR = 2_000
|
||||||
DEFAULT_PICTURE_COUNT_THRESHOLD = 10
|
|
||||||
FAMILY_LINK_POINTS = 366 # Equivalent to a leap year together in a club, because.
|
FAMILY_LINK_POINTS = 366 # Equivalent to a leap year together in a club, because.
|
||||||
PICTURE_POINTS = 2 # Equivalent to two days as random members of a club.
|
PICTURE_POINTS = 2 # Equivalent to two days as random members of a club.
|
||||||
CLUBS_POINTS = 1 # One day together as random members in a club is one point.
|
CLUBS_POINTS = 1 # One day together as random members in a club is one point.
|
||||||
@ -180,13 +187,15 @@ class Galaxy(models.Model):
|
|||||||
stars_count = self.stars.count()
|
stars_count = self.stars.count()
|
||||||
s = f"GLX-ID{self.pk}-SC{stars_count}-"
|
s = f"GLX-ID{self.pk}-SC{stars_count}-"
|
||||||
if self.state is None:
|
if self.state is None:
|
||||||
s += "CHAOS"
|
s += "CHS" # CHAOS
|
||||||
else:
|
else:
|
||||||
s += "RULED"
|
s += "RLD" # RULED
|
||||||
return s
|
return s
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_current_galaxy(cls) -> Galaxy:
|
def get_current_galaxy(
|
||||||
|
cls,
|
||||||
|
) -> Galaxy: # __future__.annotations is required for this
|
||||||
return Galaxy.objects.filter(state__isnull=False).last()
|
return Galaxy.objects.filter(state__isnull=False).last()
|
||||||
|
|
||||||
###################
|
###################
|
||||||
@ -194,18 +203,7 @@ class Galaxy(models.Model):
|
|||||||
###################
|
###################
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_rulable_users(
|
def compute_user_score(cls, user: User) -> int:
|
||||||
cls, picture_count_threshold: int = DEFAULT_PICTURE_COUNT_THRESHOLD
|
|
||||||
) -> QuerySet[User]:
|
|
||||||
return (
|
|
||||||
User.objects.exclude(subscriptions=None)
|
|
||||||
.annotate(pictures_count=Count("pictures"))
|
|
||||||
.filter(pictures_count__gt=picture_count_threshold)
|
|
||||||
.distinct()
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def compute_individual_scores(cls) -> dict[int, int]:
|
|
||||||
"""Compute an individual score for each citizen.
|
"""Compute an individual score for each citizen.
|
||||||
|
|
||||||
It will later be used by the graph algorithm to push
|
It will later be used by the graph algorithm to push
|
||||||
@ -213,50 +211,87 @@ class Galaxy(models.Model):
|
|||||||
|
|
||||||
Idea: This could be added to the computation:
|
Idea: This could be added to the computation:
|
||||||
|
|
||||||
|
- Forum posts
|
||||||
- Picture count
|
- Picture count
|
||||||
- Counter consumption
|
- Counter consumption
|
||||||
- Barman time
|
- Barman time
|
||||||
- ...
|
- ...
|
||||||
"""
|
"""
|
||||||
users = (
|
user_score = 1
|
||||||
User.objects.annotate(
|
user_score += cls.query_user_score(user)
|
||||||
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:
|
# TODO:
|
||||||
# Scale that value with some magic number to accommodate to typical data
|
# 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
|
# 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 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
|
# 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}
|
user_score = int(math.log2(user_score))
|
||||||
return res
|
|
||||||
|
return user_score
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def query_user_score(cls, user: User) -> int:
|
||||||
|
"""Get the individual score of the given user in the galaxy."""
|
||||||
|
score_query = (
|
||||||
|
User.objects.filter(id=user.id)
|
||||||
|
.annotate(
|
||||||
|
godchildren_count=Count("godchildren", distinct=True)
|
||||||
|
* cls.FAMILY_LINK_POINTS,
|
||||||
|
godfathers_count=Count("godfathers", distinct=True)
|
||||||
|
* cls.FAMILY_LINK_POINTS,
|
||||||
|
pictures_score=Count("pictures", distinct=True) * cls.PICTURE_POINTS,
|
||||||
|
clubs_score=Count("memberships", distinct=True) * cls.CLUBS_POINTS,
|
||||||
|
)
|
||||||
|
.aggregate(
|
||||||
|
score=models.Sum(
|
||||||
|
F("godchildren_count")
|
||||||
|
+ F("godfathers_count")
|
||||||
|
+ F("pictures_score")
|
||||||
|
+ F("clubs_score")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return score_query.get("score")
|
||||||
|
|
||||||
####################
|
####################
|
||||||
# Inter-user score #
|
# Inter-user score #
|
||||||
####################
|
####################
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def compute_user_family_score(cls, user: User) -> defaultdict[int, int]:
|
def compute_users_score(cls, user1: User, user2: User) -> RelationScore:
|
||||||
|
"""Compute the relationship scores of the two given users.
|
||||||
|
|
||||||
|
The computation is done with the following fields :
|
||||||
|
|
||||||
|
- family: if they have some godfather/godchild relation
|
||||||
|
- pictures: in how many pictures are both tagged
|
||||||
|
- clubs: during how many days they were members of the same clubs
|
||||||
|
"""
|
||||||
|
family = cls.compute_users_family_score(user1, user2)
|
||||||
|
pictures = cls.compute_users_pictures_score(user1, user2)
|
||||||
|
clubs = cls.compute_users_clubs_score(user1, user2)
|
||||||
|
return RelationScore(family=family, pictures=pictures, clubs=clubs)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def compute_users_family_score(cls, user1: User, user2: User) -> int:
|
||||||
"""Compute the family score of the relation between the given users.
|
"""Compute the family score of the relation between the given users.
|
||||||
|
|
||||||
This takes into account mutual godfathers.
|
This takes into account mutual godfathers.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
366 if user1 is the godfather of user2 (or vice versa) else 0
|
||||||
"""
|
"""
|
||||||
godchildren = User.objects.filter(godchildren=user).values_list("id", flat=True)
|
link_count = User.objects.filter(
|
||||||
godfathers = User.objects.filter(godfathers=user).values_list("id", flat=True)
|
Q(id=user1.id, godfathers=user2) | Q(id=user2.id, godfathers=user1)
|
||||||
result = defaultdict(int)
|
).count()
|
||||||
for parent in itertools.chain(godchildren, godfathers):
|
if link_count > 0:
|
||||||
result[parent] += cls.FAMILY_LINK_POINTS
|
cls.logger.debug(
|
||||||
return result
|
f"\t\t- '{user1}' and '{user2}' have {link_count} direct family link"
|
||||||
|
)
|
||||||
|
return link_count * cls.FAMILY_LINK_POINTS
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def compute_user_pictures_score(cls, user: User) -> defaultdict[int, int]:
|
def compute_users_pictures_score(cls, user1: User, user2: User) -> int:
|
||||||
"""Compute the pictures score of the relation between the given users.
|
"""Compute the pictures score of the relation between the given users.
|
||||||
|
|
||||||
The pictures score is obtained by counting the number
|
The pictures score is obtained by counting the number
|
||||||
@ -266,19 +301,19 @@ class Galaxy(models.Model):
|
|||||||
Returns:
|
Returns:
|
||||||
The number of pictures both users have in common, times 2
|
The number of pictures both users have in common, times 2
|
||||||
"""
|
"""
|
||||||
common_photos = (
|
picture_count = (
|
||||||
PeoplePictureRelation.objects.filter(
|
Picture.objects.filter(people__user__in=(user1,))
|
||||||
picture__in=Picture.objects.filter(people__user=user)
|
.filter(people__user__in=(user2,))
|
||||||
|
.count()
|
||||||
)
|
)
|
||||||
.values("user")
|
if picture_count:
|
||||||
.annotate(count=Count("user"))
|
cls.logger.debug(
|
||||||
)
|
f"\t\t- '{user1}' was pictured with '{user2}' {picture_count} times"
|
||||||
return defaultdict(
|
|
||||||
int, {p["user"]: p["count"] * cls.PICTURE_POINTS for p in common_photos}
|
|
||||||
)
|
)
|
||||||
|
return picture_count * cls.PICTURE_POINTS
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def compute_user_clubs_score(cls, user: User) -> defaultdict[int, int]:
|
def compute_users_clubs_score(cls, user1: User, user2: User) -> int:
|
||||||
"""Compute the clubs score of the relation between the given users.
|
"""Compute the clubs score of the relation between the given users.
|
||||||
|
|
||||||
The club score is obtained by counting the number of days
|
The club score is obtained by counting the number of days
|
||||||
@ -289,36 +324,54 @@ class Galaxy(models.Model):
|
|||||||
(two years) and user2 was a member of the same club from 01/01/2021 to
|
(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
|
31/12/2022 (also two years, but with an offset of one year), then their
|
||||||
club score is 365.
|
club score is 365.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
the number of days during which both users were in the same club
|
||||||
"""
|
"""
|
||||||
memberships = user.memberships.only("start_date", "end_date", "club_id")
|
common_clubs = Club.objects.filter(members__in=user1.memberships.all()).filter(
|
||||||
result = defaultdict(int)
|
members__in=user2.memberships.all()
|
||||||
now = 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 <= now
|
user1_memberships = user1.memberships.filter(club__in=common_clubs)
|
||||||
start_date__lte=membership.start_date, end_date=None
|
user2_memberships = user2.memberships.filter(club__in=common_clubs)
|
||||||
|
|
||||||
|
score = 0
|
||||||
|
for user1_membership in user1_memberships:
|
||||||
|
if user1_membership.end_date is None:
|
||||||
|
# user1_membership.save() is not called in this function, hence this is safe
|
||||||
|
user1_membership.end_date = localdate()
|
||||||
|
query = Q( # start2 <= start1 <= end2
|
||||||
|
start_date__lte=user1_membership.start_date,
|
||||||
|
end_date__gte=user1_membership.start_date,
|
||||||
)
|
)
|
||||||
| Q( # start1 <= start2 <= end2
|
query |= Q( # start2 <= start1 <= now
|
||||||
start_date__gte=membership.start_date,
|
start_date__lte=user1_membership.start_date, end_date=None
|
||||||
start_date__lte=membership.end_date or now,
|
|
||||||
),
|
|
||||||
club_id=membership.club_id,
|
|
||||||
)
|
)
|
||||||
.only("start_date", "end_date", "user_id")
|
query |= Q( # start1 <= start2 <= end2
|
||||||
|
start_date__gte=user1_membership.start_date,
|
||||||
|
start_date__lte=user1_membership.end_date,
|
||||||
)
|
)
|
||||||
for other in common_memberships:
|
for user2_membership in user2_memberships.filter(
|
||||||
start = max(membership.start_date, other.start_date)
|
query, club=user1_membership.club
|
||||||
end = min(membership.end_date or now, other.end_date or now)
|
):
|
||||||
result[other.user_id] += (end - start).days * cls.CLUBS_POINTS
|
if user2_membership.end_date is None:
|
||||||
return result
|
user2_membership.end_date = localdate()
|
||||||
|
latest_start = max(
|
||||||
|
user1_membership.start_date, user2_membership.start_date
|
||||||
|
)
|
||||||
|
earliest_end = min(user1_membership.end_date, user2_membership.end_date)
|
||||||
|
cls.logger.debug(
|
||||||
|
"\t\t- '%s' was with '%s' in %s starting on %s until %s (%s days)"
|
||||||
|
% (
|
||||||
|
user1,
|
||||||
|
user2,
|
||||||
|
user2_membership.club,
|
||||||
|
latest_start,
|
||||||
|
earliest_end,
|
||||||
|
(earliest_end - latest_start).days,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
score += cls.CLUBS_POINTS * (earliest_end - latest_start).days
|
||||||
|
return score
|
||||||
|
|
||||||
###################
|
###################
|
||||||
# Rule the galaxy #
|
# Rule the galaxy #
|
||||||
@ -353,9 +406,7 @@ class Galaxy(models.Model):
|
|||||||
cls.logger.debug(f"\t\t> Scaled distance: {value}")
|
cls.logger.debug(f"\t\t> Scaled distance: {value}")
|
||||||
return int(value)
|
return int(value)
|
||||||
|
|
||||||
def rule(
|
def rule(self, picture_count_threshold=10) -> None:
|
||||||
self, picture_count_threshold: int = DEFAULT_PICTURE_COUNT_THRESHOLD
|
|
||||||
) -> None:
|
|
||||||
"""Main function of the Galaxy.
|
"""Main function of the Galaxy.
|
||||||
|
|
||||||
Iterate over all the rulable users to promote them to citizens.
|
Iterate over all the rulable users to promote them to citizens.
|
||||||
@ -376,30 +427,41 @@ class Galaxy(models.Model):
|
|||||||
"""
|
"""
|
||||||
total_time = time.time()
|
total_time = time.time()
|
||||||
self.logger.info("Listing rulable citizen.")
|
self.logger.info("Listing rulable citizen.")
|
||||||
|
rulable_users = (
|
||||||
|
User.objects.filter(subscriptions__isnull=False)
|
||||||
|
.annotate(pictures_count=Count("pictures"))
|
||||||
|
.filter(pictures_count__gt=picture_count_threshold)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
# force fetch of the whole query to make sure there won't
|
# force fetch of the whole query to make sure there won't
|
||||||
# be any more db hits
|
# be any more db hits
|
||||||
# this is memory expensive but prevents a lot of db hits, therefore
|
# this is memory expensive but prevents a lot of db hits, therefore
|
||||||
# is far more time efficient
|
# is far more time efficient
|
||||||
|
|
||||||
rulable_users = list(self.get_rulable_users(picture_count_threshold))
|
rulable_users = list(rulable_users)
|
||||||
rulable_users_count = len(rulable_users)
|
rulable_users_count = len(rulable_users)
|
||||||
user1_count = 0
|
user1_count = 0
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"{rulable_users_count} citizen have been listed. Starting to rule."
|
f"{rulable_users_count} citizen have been listed. Starting to rule."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
stars = []
|
||||||
self.logger.info("Creating stars for all citizen")
|
self.logger.info("Creating stars for all citizen")
|
||||||
individual_scores = self.compute_individual_scores()
|
for user in rulable_users:
|
||||||
GalaxyStar.objects.bulk_create(
|
star = GalaxyStar(
|
||||||
[
|
owner=user, galaxy=self, mass=self.compute_user_score(user)
|
||||||
GalaxyStar(owner=user, galaxy=self, mass=individual_scores[user.id])
|
|
||||||
for user in rulable_users
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
stars = {star.owner_id: star for star in self.stars.all()}
|
stars.append(star)
|
||||||
|
GalaxyStar.objects.bulk_create(stars)
|
||||||
|
|
||||||
|
stars = {}
|
||||||
|
for star in GalaxyStar.objects.filter(galaxy=self):
|
||||||
|
stars[star.owner.id] = star
|
||||||
|
|
||||||
self.logger.info("Creating lanes between stars")
|
self.logger.info("Creating lanes between stars")
|
||||||
|
# Display current speed every $speed_count_frequency users
|
||||||
|
speed_count_frequency = max(rulable_users_count // 10, 1) # ten time at most
|
||||||
global_avg_speed_accumulator = 0
|
global_avg_speed_accumulator = 0
|
||||||
global_avg_speed_count = 0
|
global_avg_speed_count = 0
|
||||||
t_global_start = time.time()
|
t_global_start = time.time()
|
||||||
@ -410,19 +472,20 @@ class Galaxy(models.Model):
|
|||||||
|
|
||||||
star1 = stars[user1.id]
|
star1 = stars[user1.id]
|
||||||
|
|
||||||
lanes = []
|
user_avg_speed = 0
|
||||||
family_scores = self.compute_user_family_score(user1)
|
user_avg_speed_count = 0
|
||||||
picture_scores = self.compute_user_pictures_score(user1)
|
|
||||||
club_scores = self.compute_user_clubs_score(user1)
|
tstart = time.time()
|
||||||
|
lanes = []
|
||||||
|
for user2_count, user2 in enumerate(rulable_users, start=1):
|
||||||
|
self.logger.debug("")
|
||||||
|
self.logger.debug(
|
||||||
|
f"\t> Examining '{user1}' ({user1_count}/{rulable_users_count}) with '{user2}' ({user2_count}/{rulable_users_count2})"
|
||||||
|
)
|
||||||
|
|
||||||
for user2 in rulable_users:
|
|
||||||
star2 = stars[user2.id]
|
star2 = stars[user2.id]
|
||||||
|
|
||||||
score = RelationScore(
|
score = Galaxy.compute_users_score(user1, user2)
|
||||||
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))
|
distance = self.scale_distance(sum(score))
|
||||||
if distance < 30: # TODO: this needs tuning with real-world data
|
if distance < 30: # TODO: this needs tuning with real-world data
|
||||||
lanes.append(
|
lanes.append(
|
||||||
@ -435,8 +498,22 @@ class Galaxy(models.Model):
|
|||||||
clubs=score.clubs,
|
clubs=score.clubs,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if user2_count % speed_count_frequency == 0:
|
||||||
|
tend = time.time()
|
||||||
|
delta = tend - tstart
|
||||||
|
speed = float(speed_count_frequency) / delta
|
||||||
|
user_avg_speed += speed
|
||||||
|
user_avg_speed_count += 1
|
||||||
|
self.logger.debug(
|
||||||
|
f"\tSpeed: {speed:.2f} users per second (time for last {speed_count_frequency} citizens: {delta:.2f} second)"
|
||||||
|
)
|
||||||
|
tstart = time.time()
|
||||||
|
|
||||||
GalaxyLane.objects.bulk_create(lanes)
|
GalaxyLane.objects.bulk_create(lanes)
|
||||||
|
|
||||||
|
self.logger.info("")
|
||||||
|
|
||||||
t_global_end = time.time()
|
t_global_end = time.time()
|
||||||
global_delta = t_global_end - t_global_start
|
global_delta = t_global_end - t_global_start
|
||||||
speed = 1.0 / global_delta
|
speed = 1.0 / global_delta
|
||||||
@ -444,17 +521,19 @@ class Galaxy(models.Model):
|
|||||||
global_avg_speed_count += 1
|
global_avg_speed_count += 1
|
||||||
global_avg_speed = global_avg_speed_accumulator / global_avg_speed_count
|
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" Ruling of {self} ".center(60, "#"))
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"Progression: {user1_count}/{rulable_users_count} "
|
f"Progression: {user1_count}/{rulable_users_count} citizen -- {rulable_users_count - user1_count} remaining"
|
||||||
f"citizen -- {rulable_users_count - user1_count} remaining"
|
|
||||||
)
|
)
|
||||||
self.logger.info(f"Speed: {global_avg_speed:.2f} citizen per second")
|
self.logger.info(f"Speed: {60.0 * global_avg_speed:.2f} citizen per minute")
|
||||||
eta = rulable_users_count2 // global_avg_speed
|
|
||||||
|
# We can divide the computed ETA by 2 because each loop, there is one citizen less to check, and maths tell
|
||||||
|
# us that this averages to a division by two
|
||||||
|
eta = rulable_users_count2 / global_avg_speed / 2
|
||||||
|
eta_hours = int(eta // 3600)
|
||||||
|
eta_minutes = int(eta // 60 % 60)
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"ETA: {int(eta // 60 % 60)} minutes {int(eta % 60)} seconds"
|
f"ETA: {eta_hours} hours {eta_minutes} minutes ({eta / 3600 / 24:.2f} days)"
|
||||||
)
|
)
|
||||||
self.logger.info("#" * 60)
|
self.logger.info("#" * 60)
|
||||||
t_global_start = time.time()
|
t_global_start = time.time()
|
||||||
@ -477,10 +556,11 @@ class Galaxy(models.Model):
|
|||||||
Galaxy.objects.filter(pk__in=old_galaxies_pks).delete()
|
Galaxy.objects.filter(pk__in=old_galaxies_pks).delete()
|
||||||
|
|
||||||
total_time = time.time() - total_time
|
total_time = time.time() - total_time
|
||||||
|
total_time_hours = int(total_time // 3600)
|
||||||
total_time_minutes = int(total_time // 60 % 60)
|
total_time_minutes = int(total_time // 60 % 60)
|
||||||
total_time_seconds = int(total_time % 60)
|
total_time_seconds = int(total_time % 60)
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"{self} ruled in {total_time_minutes} minutes, {total_time_seconds} seconds"
|
f"{self} ruled in {total_time:.2f} seconds ({total_time_hours} hours, {total_time_minutes} minutes, {total_time_seconds} seconds)"
|
||||||
)
|
)
|
||||||
|
|
||||||
def make_state(self) -> None:
|
def make_state(self) -> None:
|
||||||
@ -488,33 +568,58 @@ class Galaxy(models.Model):
|
|||||||
self.logger.info(
|
self.logger.info(
|
||||||
"Caching current Galaxy state for a quicker display of the Empire's power."
|
"Caching current Galaxy state for a quicker display of the Empire's power."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
without_nickname = Concat(
|
||||||
|
F("owner__first_name"), Value(" "), F("owner__last_name")
|
||||||
|
)
|
||||||
|
with_nickname = Concat(
|
||||||
|
F("owner__first_name"),
|
||||||
|
Value(" "),
|
||||||
|
F("owner__last_name"),
|
||||||
|
Value(" ("),
|
||||||
|
F("owner__nick_name"),
|
||||||
|
Value(")"),
|
||||||
|
)
|
||||||
stars = (
|
stars = (
|
||||||
GalaxyStar.objects.filter(galaxy=self)
|
GalaxyStar.objects.filter(galaxy=self)
|
||||||
.order_by("owner_id")
|
.order_by(
|
||||||
.select_related("owner")
|
"owner"
|
||||||
|
) # This helps determinism for the tests and doesn't cost much
|
||||||
|
.annotate(
|
||||||
|
owner_name=Case(
|
||||||
|
When(owner__nick_name=None, then=without_nickname),
|
||||||
|
default=with_nickname,
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
lanes = (
|
lanes = (
|
||||||
GalaxyLane.objects.filter(star1__galaxy=self)
|
GalaxyLane.objects.filter(star1__galaxy=self)
|
||||||
.order_by("star1")
|
.order_by(
|
||||||
|
"star1"
|
||||||
|
) # This helps determinism for the tests and doesn't cost much
|
||||||
.annotate(
|
.annotate(
|
||||||
star1_owner=F("star1__owner_id"), star2_owner=F("star2__owner_id")
|
star1_owner=F("star1__owner__id"),
|
||||||
|
star2_owner=F("star2__owner__id"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
json = GalaxyDict(
|
json = GalaxyDict(
|
||||||
nodes=[
|
nodes=[
|
||||||
StarDict(
|
StarDict(
|
||||||
id=star.owner_id, name=star.owner.get_display_name(), mass=star.mass
|
id=star.owner_id,
|
||||||
|
name=star.owner_name,
|
||||||
|
mass=star.mass,
|
||||||
)
|
)
|
||||||
for star in stars
|
for star in stars
|
||||||
],
|
],
|
||||||
links=[
|
links=[],
|
||||||
|
)
|
||||||
|
for path in lanes:
|
||||||
|
json["links"].append(
|
||||||
{
|
{
|
||||||
"source": path.star1_owner,
|
"source": path.star1_owner,
|
||||||
"target": path.star2_owner,
|
"target": path.star2_owner,
|
||||||
"value": path.distance,
|
"value": path.distance,
|
||||||
}
|
}
|
||||||
for path in lanes
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
self.state = json
|
self.state = json
|
||||||
self.save()
|
self.save()
|
||||||
|
@ -33,7 +33,7 @@ from core.models import User
|
|||||||
from galaxy.models import Galaxy
|
from galaxy.models import Galaxy
|
||||||
|
|
||||||
|
|
||||||
# @pytest.mark.skip(reason="Galaxy is disabled for now")
|
@pytest.mark.skip(reason="Galaxy is disabled for now")
|
||||||
class TestGalaxyModel(TestCase):
|
class TestGalaxyModel(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
@ -48,19 +48,15 @@ class TestGalaxyModel(TestCase):
|
|||||||
|
|
||||||
def test_user_self_score(self):
|
def test_user_self_score(self):
|
||||||
"""Test that individual user scores are correct."""
|
"""Test that individual user scores are correct."""
|
||||||
with self.assertNumQueries(1):
|
with self.assertNumQueries(8):
|
||||||
scores = Galaxy.compute_individual_scores()
|
assert Galaxy.compute_user_score(self.root) == 9
|
||||||
expected = {
|
assert Galaxy.compute_user_score(self.skia) == 10
|
||||||
self.root.id: 9,
|
assert Galaxy.compute_user_score(self.sli) == 8
|
||||||
self.skia.id: 10,
|
assert Galaxy.compute_user_score(self.krophil) == 2
|
||||||
self.sli.id: 8,
|
assert Galaxy.compute_user_score(self.richard) == 10
|
||||||
self.krophil.id: 2,
|
assert Galaxy.compute_user_score(self.subscriber) == 8
|
||||||
self.richard.id: 10,
|
assert Galaxy.compute_user_score(self.public) == 8
|
||||||
self.subscriber.id: 8,
|
assert Galaxy.compute_user_score(self.com) == 1
|
||||||
self.public.id: 8,
|
|
||||||
self.com.id: 1,
|
|
||||||
}
|
|
||||||
assert scores.items() >= expected.items()
|
|
||||||
|
|
||||||
def test_users_score(self):
|
def test_users_score(self):
|
||||||
"""Test on the default dataset generated by the `populate` command
|
"""Test on the default dataset generated by the `populate` command
|
||||||
@ -122,23 +118,17 @@ class TestGalaxyModel(TestCase):
|
|||||||
self.com,
|
self.com,
|
||||||
]
|
]
|
||||||
|
|
||||||
with self.assertNumQueries(44):
|
with self.assertNumQueries(100):
|
||||||
while len(users) > 0:
|
while len(users) > 0:
|
||||||
user1 = users.pop(0)
|
user1 = users.pop(0)
|
||||||
family_scores = Galaxy.compute_user_family_score(user1)
|
|
||||||
picture_scores = Galaxy.compute_user_pictures_score(user1)
|
|
||||||
club_scores = Galaxy.compute_user_clubs_score(user1)
|
|
||||||
for user2 in users:
|
for user2 in users:
|
||||||
|
score = Galaxy.compute_users_score(user1, user2)
|
||||||
u1 = computed_scores.get(user1.username, {})
|
u1 = computed_scores.get(user1.username, {})
|
||||||
u1[user2.username] = {
|
u1[user2.username] = {
|
||||||
"score": (
|
"score": sum(score),
|
||||||
family_scores[user2.id]
|
"family": score.family,
|
||||||
+ picture_scores[user2.id]
|
"pictures": score.pictures,
|
||||||
+ club_scores[user2.id]
|
"clubs": score.clubs,
|
||||||
),
|
|
||||||
"family": family_scores[user2.id],
|
|
||||||
"pictures": picture_scores[user2.id],
|
|
||||||
"clubs": club_scores[user2.id],
|
|
||||||
}
|
}
|
||||||
computed_scores[user1.username] = u1
|
computed_scores[user1.username] = u1
|
||||||
|
|
||||||
@ -150,12 +140,12 @@ class TestGalaxyModel(TestCase):
|
|||||||
that the number of queries to rule the galaxy is stable.
|
that the number of queries to rule the galaxy is stable.
|
||||||
"""
|
"""
|
||||||
galaxy = Galaxy.objects.create()
|
galaxy = Galaxy.objects.create()
|
||||||
with self.assertNumQueries(39):
|
with self.assertNumQueries(58):
|
||||||
galaxy.rule(0) # We want everybody here
|
galaxy.rule(0) # We want everybody here
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.slow
|
@pytest.mark.slow
|
||||||
# @pytest.mark.skip(reason="Galaxy is disabled for now")
|
@pytest.mark.skip(reason="Galaxy is disabled for now")
|
||||||
class TestGalaxyView(TestCase):
|
class TestGalaxyView(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
@ -5103,9 +5103,8 @@ msgid "There are %s pictures to be moderated in the SAS"
|
|||||||
msgstr "Il y a %s photos à modérer dans le SAS"
|
msgstr "Il y a %s photos à modérer dans le SAS"
|
||||||
|
|
||||||
#: sith/settings.py
|
#: sith/settings.py
|
||||||
#, python-format
|
msgid "You've been identified on some pictures"
|
||||||
msgid "You've been identified in album %s"
|
msgstr "Vous avez été identifié sur des photos"
|
||||||
msgstr "Vous avez été identifié dans l'album %s"
|
|
||||||
|
|
||||||
#: sith/settings.py
|
#: sith/settings.py
|
||||||
#, python-format
|
#, python-format
|
||||||
|
@ -44,7 +44,7 @@ dependencies = [
|
|||||||
"django-honeypot>=1.3.0,<2",
|
"django-honeypot>=1.3.0,<2",
|
||||||
"pydantic-extra-types<3.0.0,>=2.10.3",
|
"pydantic-extra-types<3.0.0,>=2.10.3",
|
||||||
"ical>=10.0.3,<11",
|
"ical>=10.0.3,<11",
|
||||||
"redis[hiredis]<6.0.0,>=5.3.0",
|
"redis[hiredis]>=5.3.0,<7.0.0",
|
||||||
"environs[django]<15.0.0,>=14.1.1",
|
"environs[django]<15.0.0,>=14.1.1",
|
||||||
"requests>=2.32.3",
|
"requests>=2.32.3",
|
||||||
"honcho>=2.0.0",
|
"honcho>=2.0.0",
|
||||||
|
16
sas/api.py
16
sas/api.py
@ -2,6 +2,7 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db.models import F
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from ninja import Body, File, Query
|
from ninja import Body, File, Query
|
||||||
from ninja.security import SessionAuth
|
from ninja.security import SessionAuth
|
||||||
@ -104,7 +105,8 @@ class PicturesController(ControllerBase):
|
|||||||
filters.filter(Picture.objects.viewable_by(user))
|
filters.filter(Picture.objects.viewable_by(user))
|
||||||
.distinct()
|
.distinct()
|
||||||
.order_by("-parent__date", "date")
|
.order_by("-parent__date", "date")
|
||||||
.select_related("owner", "parent")
|
.select_related("owner")
|
||||||
|
.annotate(album=F("parent__name"))
|
||||||
)
|
)
|
||||||
|
|
||||||
@route.post(
|
@route.post(
|
||||||
@ -151,9 +153,7 @@ class PicturesController(ControllerBase):
|
|||||||
|
|
||||||
@route.put("/{picture_id}/identified", permissions=[IsAuthenticated, CanView])
|
@route.put("/{picture_id}/identified", permissions=[IsAuthenticated, CanView])
|
||||||
def identify_users(self, picture_id: NonNegativeInt, users: set[NonNegativeInt]):
|
def identify_users(self, picture_id: NonNegativeInt, users: set[NonNegativeInt]):
|
||||||
picture = self.get_object_or_exception(
|
picture = self.get_object_or_exception(Picture, pk=picture_id)
|
||||||
Picture.objects.select_related("parent"), pk=picture_id
|
|
||||||
)
|
|
||||||
db_users = list(User.objects.filter(id__in=users))
|
db_users = list(User.objects.filter(id__in=users))
|
||||||
if len(users) != len(db_users):
|
if len(users) != len(db_users):
|
||||||
raise NotFound
|
raise NotFound
|
||||||
@ -166,15 +166,13 @@ class PicturesController(ControllerBase):
|
|||||||
]
|
]
|
||||||
PeoplePictureRelation.objects.bulk_create(relations)
|
PeoplePictureRelation.objects.bulk_create(relations)
|
||||||
for u in identified:
|
for u in identified:
|
||||||
html_id = f"album-{picture.parent_id}"
|
|
||||||
url = reverse(
|
|
||||||
"sas:user_pictures", kwargs={"user_id": u.id}, fragment=html_id
|
|
||||||
)
|
|
||||||
Notification.objects.get_or_create(
|
Notification.objects.get_or_create(
|
||||||
user=u,
|
user=u,
|
||||||
viewed=False,
|
viewed=False,
|
||||||
type="NEW_PICTURES",
|
type="NEW_PICTURES",
|
||||||
defaults={"url": url, "param": picture.parent.name},
|
defaults={
|
||||||
|
"url": reverse("sas:user_pictures", kwargs={"user_id": u.id})
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@route.delete("/{picture_id}", permissions=[IsSasAdmin])
|
@route.delete("/{picture_id}", permissions=[IsSasAdmin])
|
||||||
|
@ -1,19 +0,0 @@
|
|||||||
# Generated by Django 5.2.3 on 2025-06-17 18:53
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [("sas", "0004_picturemoderationrequest_and_more")]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name="sasfile",
|
|
||||||
options={
|
|
||||||
"permissions": [
|
|
||||||
("moderate_sasfile", "Can moderate SAS files"),
|
|
||||||
("view_unmoderated_sasfile", "Can view not moderated SAS files"),
|
|
||||||
]
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
@ -25,10 +25,11 @@ from django.core.cache import cache
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Exists, OuterRef, Q
|
from django.db.models import Exists, OuterRef, Q
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from core.models import Notification, SithFile, User
|
from core.models import SithFile, User
|
||||||
from core.utils import exif_auto_rotate, resize_image
|
from core.utils import exif_auto_rotate, resize_image
|
||||||
|
|
||||||
|
|
||||||
@ -41,10 +42,6 @@ class SasFile(SithFile):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
proxy = True
|
proxy = True
|
||||||
permissions = [
|
|
||||||
("moderate_sasfile", "Can moderate SAS files"),
|
|
||||||
("view_unmoderated_sasfile", "Can view not moderated SAS files"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def can_be_viewed_by(self, user):
|
def can_be_viewed_by(self, user):
|
||||||
if user.is_anonymous:
|
if user.is_anonymous:
|
||||||
@ -63,7 +60,7 @@ class SasFile(SithFile):
|
|||||||
return self.id in viewable
|
return self.id in viewable
|
||||||
|
|
||||||
def can_be_edited_by(self, user):
|
def can_be_edited_by(self, user):
|
||||||
return user.has_perm("sas.change_sasfile")
|
return user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
|
||||||
|
|
||||||
|
|
||||||
class PictureQuerySet(models.QuerySet):
|
class PictureQuerySet(models.QuerySet):
|
||||||
@ -73,7 +70,7 @@ class PictureQuerySet(models.QuerySet):
|
|||||||
Warning:
|
Warning:
|
||||||
Calling this queryset method may add several additional requests.
|
Calling this queryset method may add several additional requests.
|
||||||
"""
|
"""
|
||||||
if user.has_perm("sas.moderate_sasfile"):
|
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
|
||||||
return self.all()
|
return self.all()
|
||||||
if user.was_subscribed:
|
if user.was_subscribed:
|
||||||
return self.filter(Q(is_moderated=True) | Q(owner=user))
|
return self.filter(Q(is_moderated=True) | Q(owner=user))
|
||||||
@ -186,7 +183,7 @@ class AlbumQuerySet(models.QuerySet):
|
|||||||
Warning:
|
Warning:
|
||||||
Calling this queryset method may add several additional requests.
|
Calling this queryset method may add several additional requests.
|
||||||
"""
|
"""
|
||||||
if user.has_perm("sas.moderate_sasfile"):
|
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
|
||||||
return self.all()
|
return self.all()
|
||||||
if user.was_subscribed:
|
if user.was_subscribed:
|
||||||
return self.filter(Q(is_moderated=True) | Q(owner=user))
|
return self.filter(Q(is_moderated=True) | Q(owner=user))
|
||||||
@ -259,10 +256,14 @@ class Album(SasFile):
|
|||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
def sas_notification_callback(notif: Notification):
|
def sas_notification_callback(notif):
|
||||||
count = Picture.objects.filter(is_moderated=False).count()
|
count = Picture.objects.filter(is_moderated=False).count()
|
||||||
notif.viewed = not bool(count)
|
if count:
|
||||||
notif.param = str(count)
|
notif.viewed = False
|
||||||
|
else:
|
||||||
|
notif.viewed = True
|
||||||
|
notif.param = "%s" % count
|
||||||
|
notif.date = timezone.now()
|
||||||
|
|
||||||
|
|
||||||
class PeoplePictureRelation(models.Model):
|
class PeoplePictureRelation(models.Model):
|
||||||
|
@ -18,12 +18,6 @@ class AlbumFilterSchema(FilterSchema):
|
|||||||
parent_id: int | None = Field(None, q="parent_id")
|
parent_id: int | None = Field(None, q="parent_id")
|
||||||
|
|
||||||
|
|
||||||
class SimpleAlbumSchema(ModelSchema):
|
|
||||||
class Meta:
|
|
||||||
model = Album
|
|
||||||
fields = ["id", "name"]
|
|
||||||
|
|
||||||
|
|
||||||
class AlbumSchema(ModelSchema):
|
class AlbumSchema(ModelSchema):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Album
|
model = Album
|
||||||
@ -76,7 +70,7 @@ class PictureSchema(ModelSchema):
|
|||||||
full_size_url: str
|
full_size_url: str
|
||||||
compressed_url: str
|
compressed_url: str
|
||||||
thumb_url: str
|
thumb_url: str
|
||||||
album: SimpleAlbumSchema = Field(alias="parent")
|
album: str
|
||||||
report_url: str
|
report_url: str
|
||||||
edit_url: str
|
edit_url: str
|
||||||
|
|
||||||
|
@ -22,11 +22,11 @@ document.addEventListener("alpine:init", () => {
|
|||||||
} as PicturesFetchPicturesData);
|
} as PicturesFetchPicturesData);
|
||||||
|
|
||||||
this.albums = this.pictures.reduce(
|
this.albums = this.pictures.reduce(
|
||||||
(acc: Record<number, PictureSchema[]>, picture: PictureSchema) => {
|
(acc: Record<string, PictureSchema[]>, picture: PictureSchema) => {
|
||||||
if (!acc[picture.album.id]) {
|
if (!acc[picture.album]) {
|
||||||
acc[picture.album.id] = [];
|
acc[picture.album] = [];
|
||||||
}
|
}
|
||||||
acc[picture.album.id].push(picture);
|
acc[picture.album].push(picture);
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
|
@ -20,11 +20,11 @@
|
|||||||
{{ download_button(_("Download all my pictures")) }}
|
{{ download_button(_("Download all my pictures")) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<template x-for="[album_id, pictures] in Object.entries(albums)" x-cloak>
|
<template x-for="[album, pictures] in Object.entries(albums)" x-cloak>
|
||||||
<section>
|
<section>
|
||||||
<br />
|
<br />
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<h4 x-text="pictures[0].album.name" :id="`album-${album_id}`"></h4>
|
<h4 x-text="album"></h4>
|
||||||
{% if user.id == object.id %}
|
{% if user.id == object.id %}
|
||||||
{{ download_button("") }}
|
{{ download_button("") }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -677,7 +677,7 @@ SITH_NOTIFICATIONS = [
|
|||||||
("NEWS_MODERATION", _("There are %s fresh news to be moderated")),
|
("NEWS_MODERATION", _("There are %s fresh news to be moderated")),
|
||||||
("FILE_MODERATION", _("New files to be moderated")),
|
("FILE_MODERATION", _("New files to be moderated")),
|
||||||
("SAS_MODERATION", _("There are %s pictures to be moderated in the SAS")),
|
("SAS_MODERATION", _("There are %s pictures to be moderated in the SAS")),
|
||||||
("NEW_PICTURES", _("You've been identified in album %s")),
|
("NEW_PICTURES", _("You've been identified on some pictures")),
|
||||||
("REFILLING", _("You just refilled of %s €")),
|
("REFILLING", _("You just refilled of %s €")),
|
||||||
("SELLING", _("You just bought %s")),
|
("SELLING", _("You just bought %s")),
|
||||||
("GENERIC", _("You have a notification")),
|
("GENERIC", _("You have a notification")),
|
||||||
|
Reference in New Issue
Block a user