Co-authored-by: Skia <florent.jacquet@eshard.com>
This commit is contained in:
Skia 2023-03-02 15:11:23 +01:00 committed by GitHub
parent 73305c0b28
commit b7f20fed6c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 2030 additions and 750 deletions

View File

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -6,8 +6,15 @@ from django.core.management.base import BaseCommand
# see https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
# added "v?"
# Please note that this does not match the version of the three.js library.
# Hence, you shall have to check this one by yourself
semver_regex = re.compile(
"""^v?(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"""
r"^v?"
r"(?P<major>\d+)"
r"\.(?P<minor>\d+)"
r"\.(?P<patch>\d+)"
r"(?:-(?P<prerelease>(?:\d+|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:\d+|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?"
r"(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"
)

View File

@ -1,7 +1,7 @@
# -*- coding:utf-8 -*
#
# Copyright 2016,2017
# - Skia <skia@libskia.so>
# Copyright 2016,2017,2023
# - Skia <skia@hya.sk>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
@ -25,6 +25,7 @@
import os
from datetime import date, datetime, timedelta
from io import StringIO, BytesIO
from pathlib import Path
from django.contrib.auth.models import Permission
from django.core.management.base import BaseCommand
@ -54,6 +55,7 @@ from com.models import Sith, Weekmail, News, NewsDate
from election.models import Election, Role, Candidature, ElectionList
from forum.models import Forum, ForumTopic
from pedagogy.models import UV
from sas.models import Album, Picture, PeoplePictureRelation
class Command(BaseCommand):
@ -71,9 +73,7 @@ class Command(BaseCommand):
def handle(self, *args, **options):
os.environ["DJANGO_COLORS"] = "nocolor"
Site(id=4000, domain=settings.SITH_URL, name=settings.SITH_NAME).save()
root_path = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
)
root_path = Path(__file__).parent.parent.parent.parent
root_group, _ = Group.objects.get_or_create(name="Root")
Group(name="Public").save()
Group(name="Subscribers").save()
@ -84,7 +84,7 @@ class Command(BaseCommand):
Group(name="Banned from buying alcohol").save()
Group(name="Banned from counters").save()
Group(name="Banned to subscribe").save()
Group(name="SAS admin").save()
sas_admin, _ = Group.objects.get_or_create(name="SAS admin")
Group(name="Forum admin").save()
Group(name="Pedagogy admin").save()
self.reset_index("core", "auth")
@ -119,7 +119,8 @@ class Command(BaseCommand):
club_root = SithFile(parent=None, name="clubs", is_folder=True, owner=root)
club_root.save()
SithFile(parent=None, name="SAS", is_folder=True, owner=root).save()
sas = SithFile(parent=None, name="SAS", is_folder=True, owner=root)
sas.save()
main_club = Club(
id=1,
name=settings.SITH_MAIN_CLUB["name"],
@ -223,7 +224,15 @@ Welcome to the wiki page!
Group.objects.filter(name=settings.SITH_MAIN_MEMBERS_GROUP).first().id
]
skia.save()
skia_profile_path = os.path.join(root_path, "core/fixtures/images/3.jpg")
skia_profile_path = (
root_path
/ "core"
/ "fixtures"
/ "images"
/ "sas"
/ "Family"
/ "skia.jpg"
)
with open(skia_profile_path, "rb") as f:
name = str(skia.id) + "_profile.jpg"
skia_profile = SithFile(
@ -233,7 +242,7 @@ Welcome to the wiki page!
owner=skia,
is_folder=False,
mime_type="image/jpeg",
size=os.path.getsize(skia_profile_path),
size=skia_profile_path.stat().st_size,
)
skia_profile.file.name = name
skia_profile.save()
@ -351,23 +360,48 @@ Welcome to the wiki page!
]
u.save()
# Adding user Richard Batsbak
r = User(
richard = User(
username="rbatsbak",
last_name="Batsbak",
first_name="Richard",
email="richard@git.an",
date_of_birth="1982-06-12",
)
r.set_password("plop")
r.save()
r.view_groups = [
richard.set_password("plop")
richard.save()
richard.godfathers.add(comptable)
richard_profile_path = (
root_path
/ "core"
/ "fixtures"
/ "images"
/ "sas"
/ "Family"
/ "richard.jpg"
)
with open(richard_profile_path, "rb") as f:
name = f"{richard.id}_profile.jpg"
richard_profile = SithFile(
parent=profiles_root,
name=name,
file=resize_image(Image.open(BytesIO(f.read())), 400, "JPEG"),
owner=richard,
is_folder=False,
mime_type="image/jpeg",
size=richard_profile_path.stat().st_size,
)
richard_profile.file.name = name
richard_profile.save()
richard.profile_pict = richard_profile
richard.save()
richard.view_groups = [
Group.objects.filter(name=settings.SITH_MAIN_MEMBERS_GROUP).first().id
]
r.save()
richard.save()
# Adding syntax help page
p = Page(name="Aide_sur_la_syntaxe")
p.save(force_lock=True)
with open(os.path.join(root_path) + "/doc/SYNTAX.md", "r") as rm:
with open(root_path / "doc" / "SYNTAX.md", "r") as rm:
PageRev(
page=p, title="Aide sur la syntaxe", author=skia, content=rm.read()
).save()
@ -442,7 +476,7 @@ Welcome to the wiki page!
s.save()
# Richard
s = Subscription(
member=User.objects.filter(pk=r.pk).first(),
member=User.objects.filter(pk=richard.pk).first(),
subscription_type=default_subscription,
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[0][0],
)
@ -514,7 +548,7 @@ Welcome to the wiki page!
subscribers = Group.objects.get(name="Subscribers")
old_subscribers = Group.objects.get(name="Old subscribers")
Customer(user=skia, account_id="6568j", amount=0).save()
Customer(user=r, account_id="4000k", amount=0).save()
Customer(user=richard, account_id="4000k", amount=0).save()
p = ProductType(name="Bières bouteilles")
p.save()
c = ProductType(name="Cotisations")
@ -825,7 +859,15 @@ Welcome to the wiki page!
Group.objects.filter(name=settings.SITH_MAIN_MEMBERS_GROUP).first().id
]
sli.save()
sli_profile_path = os.path.join(root_path, "core/fixtures/images/5.jpg")
sli_profile_path = (
root_path
/ "core"
/ "fixtures"
/ "images"
/ "sas"
/ "Family"
/ "sli.jpg"
)
with open(sli_profile_path, "rb") as f:
name = str(sli.id) + "_profile.jpg"
sli_profile = SithFile(
@ -835,7 +877,7 @@ Welcome to the wiki page!
owner=sli,
is_folder=False,
mime_type="image/jpeg",
size=os.path.getsize(sli_profile_path),
size=sli_profile_path.stat().st_size,
)
sli_profile.file.name = name
sli_profile.save()
@ -851,7 +893,15 @@ Welcome to the wiki page!
)
krophil.set_password("plop")
krophil.save()
krophil_profile_path = os.path.join(root_path, "core/fixtures/images/6.jpg")
krophil_profile_path = (
root_path
/ "core"
/ "fixtures"
/ "images"
/ "sas"
/ "Family"
/ "krophil.jpg"
)
with open(krophil_profile_path, "rb") as f:
name = str(krophil.id) + "_profile.jpg"
krophil_profile = SithFile(
@ -861,7 +911,7 @@ Welcome to the wiki page!
owner=krophil,
is_folder=False,
mime_type="image/jpeg",
size=os.path.getsize(krophil_profile_path),
size=krophil_profile_path.stat().st_size,
)
krophil_profile.file.name = name
krophil_profile.save()
@ -1164,3 +1214,106 @@ Welcome to the wiki page!
hours_THE=121,
hours_TE=4,
).save()
# SAS
skia.groups.add(sas_admin.id)
sas_fixtures_path = root_path / "core" / "fixtures" / "images" / "sas"
for f in sas_fixtures_path.glob("*"):
if f.is_dir():
album = Album(
parent=sas,
name=f.name,
owner=root,
is_folder=True,
is_in_sas=True,
is_moderated=True,
)
album.clean()
album.save()
for p in f.iterdir():
pict = Picture(
parent=album,
name=p.name,
file=resize_image(
Image.open(BytesIO(p.read_bytes())), 1000, "JPEG"
),
owner=root,
is_folder=False,
is_in_sas=True,
is_moderated=True,
mime_type="image/jpeg",
size=p.stat().st_size,
)
pict.file.name = p.name
pict.clean()
pict.generate_thumbnails()
pict.save()
p = Picture.objects.get(name="skia.jpg")
PeoplePictureRelation(user=skia, picture=p).save()
p = Picture.objects.get(name="sli.jpg")
PeoplePictureRelation(user=sli, picture=p).save()
p = Picture.objects.get(name="krophil.jpg")
PeoplePictureRelation(user=krophil, picture=p).save()
p = Picture.objects.get(name="skia_sli.jpg")
PeoplePictureRelation(user=skia, picture=p).save()
PeoplePictureRelation(user=sli, picture=p).save()
p = Picture.objects.get(name="skia_sli_krophil.jpg")
PeoplePictureRelation(user=skia, picture=p).save()
PeoplePictureRelation(user=sli, picture=p).save()
PeoplePictureRelation(user=krophil, picture=p).save()
p = Picture.objects.get(name="richard.jpg")
PeoplePictureRelation(user=richard, picture=p).save()
with open(skia_profile_path, "rb") as f:
name = str(skia.id) + "_profile.jpg"
skia_profile = SithFile(
parent=profiles_root,
name=name,
file=resize_image(Image.open(BytesIO(f.read())), 400, "JPEG"),
owner=skia,
is_folder=False,
mime_type="image/jpeg",
size=skia_profile_path.stat().st_size,
)
skia_profile.file.name = name
skia_profile.save()
skia.profile_pict = skia_profile
skia.save()
# Create some additional data for galaxy to work with
root.godfathers.add(skia)
skia.godfathers.add(root)
sli.godfathers.add(skia)
richard.godchildren.add(subscriber)
richard.godchildren.add(public)
Membership(
user=sli,
club=troll,
role=9,
description="Padawan Troll",
start_date=timezone.now() - timedelta(days=17),
).save()
Membership(
user=krophil,
club=troll,
role=10,
description="Maitre Troll",
start_date=timezone.now() - timedelta(days=200),
).save()
Membership(
user=skia,
club=troll,
role=2,
description="Grand Ancien Troll",
start_date=timezone.now() - timedelta(days=400),
end_date=timezone.now() - timedelta(days=86),
).save()
Membership(
user=richard,
club=troll,
role=2,
description="",
start_date=timezone.now() - timedelta(days=200),
end_date=timezone.now() - timedelta(days=100),
).save()

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,12 @@
{% block content %}
<h3>{% trans %}404, Not Found{% endtrans %}</h3>
<div id="page">
<h3>{% trans %}404, Not Found{% endtrans %}</h3>
<p class="alert alert-red">
{{ exception }}
</p>
</div>
{% endblock %}

View File

@ -72,7 +72,9 @@ def forbidden(request, exception):
def not_found(request, exception):
return HttpResponseNotFound(render(request, "core/404.jinja"))
return HttpResponseNotFound(
render(request, "core/404.jinja", context={"exception": exception})
)
def internal_servor_error(request):

View File

@ -189,78 +189,72 @@ class UserTabsMixin(TabedViewMixin):
return self.object.get_display_name()
def get_list_of_tabs(self):
tab_list = []
tab_list.append(
user: User = self.object
tab_list = [
{
"url": reverse("core:user_profile", kwargs={"user_id": self.object.id}),
"url": reverse("core:user_profile", kwargs={"user_id": user.id}),
"slug": "infos",
"name": _("Infos"),
}
)
tab_list.append(
},
{
"url": reverse(
"core:user_godfathers", kwargs={"user_id": self.object.id}
),
"url": reverse("core:user_godfathers", kwargs={"user_id": user.id}),
"slug": "godfathers",
"name": _("Family"),
}
)
tab_list.append(
},
{
"url": reverse(
"core:user_pictures", kwargs={"user_id": self.object.id}
),
"url": reverse("core:user_pictures", kwargs={"user_id": user.id}),
"slug": "pictures",
"name": _("Pictures"),
}
)
if self.request.user == self.object:
},
]
if (
False and self.request.user.was_subscribed
): # TODO: display galaxy once it's ready
tab_list.append(
{
"url": reverse("galaxy:user", kwargs={"user_id": user.id}),
"slug": "galaxy",
"name": _("Galaxy"),
}
)
if self.request.user == user:
tab_list.append(
{"url": reverse("core:user_tools"), "slug": "tools", "name": _("Tools")}
)
if self.request.user.can_edit(self.object):
if self.request.user.can_edit(user):
tab_list.append(
{
"url": reverse(
"core:user_edit", kwargs={"user_id": self.object.id}
),
"url": reverse("core:user_edit", kwargs={"user_id": user.id}),
"slug": "edit",
"name": _("Edit"),
}
)
tab_list.append(
{
"url": reverse(
"core:user_prefs", kwargs={"user_id": self.object.id}
),
"url": reverse("core:user_prefs", kwargs={"user_id": user.id}),
"slug": "prefs",
"name": _("Preferences"),
}
)
if self.request.user.can_view(self.object):
if self.request.user.can_view(user):
tab_list.append(
{
"url": reverse(
"core:user_clubs", kwargs={"user_id": self.object.id}
),
"url": reverse("core:user_clubs", kwargs={"user_id": user.id}),
"slug": "clubs",
"name": _("Clubs"),
}
)
if self.request.user.is_owner(self.object):
if self.request.user.is_owner(user):
tab_list.append(
{
"url": reverse(
"core:user_groups", kwargs={"user_id": self.object.id}
),
"url": reverse("core:user_groups", kwargs={"user_id": user.id}),
"slug": "groups",
"name": _("Groups"),
}
)
try:
if self.object.customer and (
self.object == self.request.user
if user.customer and (
user == self.request.user
or self.request.user.is_in_group(
settings.SITH_GROUP_ACCOUNTING_ADMIN_ID
)
@ -271,9 +265,7 @@ class UserTabsMixin(TabedViewMixin):
):
tab_list.append(
{
"url": reverse(
"core:user_stats", kwargs={"user_id": self.object.id}
),
"url": reverse("core:user_stats", kwargs={"user_id": user.id}),
"slug": "stats",
"name": _("Stats"),
}
@ -281,10 +273,10 @@ class UserTabsMixin(TabedViewMixin):
tab_list.append(
{
"url": reverse(
"core:user_account", kwargs={"user_id": self.object.id}
"core:user_account", kwargs={"user_id": user.id}
),
"slug": "account",
"name": _("Account") + " (%s €)" % self.object.customer.amount,
"name": _("Account") + " (%s €)" % user.customer.amount,
}
)
except:

0
galaxy/__init__.py Normal file
View File

30
galaxy/apps.py Normal file
View File

@ -0,0 +1,30 @@
# -*- coding:utf-8 -*
#
# Copyright 2023
# - Skia <skia@hya.sk>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.apps import AppConfig
class GalaxyConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "galaxy"

View File

@ -0,0 +1,61 @@
# -*- coding:utf-8 -*
#
# Copyright 2023
# - Skia <skia@hya.sk>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.core.management.base import BaseCommand
from django.db import connection
from galaxy.models import Galaxy
import logging
class Command(BaseCommand):
help = (
"Rule the Galaxy! "
"Reset the whole galaxy and compute once again all the relation scores of all users. "
"As the sith's users are rather numerous, this command might be quite expensive in memory "
"and CPU time. Please keep this fact in mind when scheduling calls to this command in a production "
"environment."
)
def handle(self, *args, **options):
logger = logging.getLogger("main")
if options["verbosity"] > 1:
logger.setLevel(logging.DEBUG)
elif options["verbosity"] > 0:
logger.setLevel(logging.INFO)
else:
logger.setLevel(logging.NOTSET)
logger.info("The Galaxy is being ruled by the Sith.")
Galaxy.rule()
logger.info(
"Caching current Galaxy state for a quicker display of the Empire's power."
)
Galaxy.make_state()
logger.info("Ruled the galaxy in {} queries.".format(len(connection.queries)))
if options["verbosity"] > 2:
for q in connection.queries:
logger.debug(q)

View File

@ -0,0 +1,113 @@
# Generated by Django 3.2.16 on 2023-03-02 10:07
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Galaxy",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("state", models.JSONField(verbose_name="current state")),
],
),
migrations.CreateModel(
name="GalaxyStar",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"mass",
models.PositiveIntegerField(default=0, verbose_name="star mass"),
),
(
"owner",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="galaxy_user",
to=settings.AUTH_USER_MODEL,
verbose_name="star owner",
),
),
],
),
migrations.CreateModel(
name="GalaxyLane",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"distance",
models.PositiveIntegerField(
default=0,
help_text="Distance separating star1 and star2",
verbose_name="distance",
),
),
(
"family",
models.PositiveIntegerField(default=0, verbose_name="family score"),
),
(
"pictures",
models.PositiveIntegerField(
default=0, verbose_name="pictures score"
),
),
(
"clubs",
models.PositiveIntegerField(default=0, verbose_name="clubs score"),
),
(
"star1",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="lanes1",
to="galaxy.galaxystar",
verbose_name="galaxy star 1",
),
),
(
"star2",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="lanes2",
to="galaxy.galaxystar",
verbose_name="galaxy star 2",
),
),
],
),
]

View File

379
galaxy/models.py Normal file
View File

@ -0,0 +1,379 @@
# -*- coding:utf-8 -*
#
# Copyright 2023
# - Skia <skia@hya.sk>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
import math
import logging
from typing import Tuple
from django.db import models
from django.db.models import Q, Case, F, Value, When, Count
from django.db.models.functions import Concat
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from typing import List, TypedDict
from core.models import User
from club.models import Club
from sas.models import Picture
class GalaxyStar(models.Model):
"""
This class defines a star (vertex -> user) in the galaxy graph, storing a reference to its owner citizen, and being
referenced by GalaxyLane.
It also stores the individual mass of this star, used to push it towards the center of the galaxy.
"""
owner = models.OneToOneField(
User,
verbose_name=_("star owner"),
related_name="galaxy_user",
on_delete=models.CASCADE,
)
mass = models.PositiveIntegerField(
_("star mass"),
default=0,
)
def __str__(self):
return str(self.owner)
class GalaxyLane(models.Model):
"""
This class defines a lane (edge -> link between galaxy citizen) in the galaxy map, storing a reference to both its
ends and the distance it covers.
Score details between citizen owning the stars is also stored here.
"""
star1 = models.ForeignKey(
GalaxyStar,
verbose_name=_("galaxy star 1"),
related_name="lanes1",
on_delete=models.CASCADE,
)
star2 = models.ForeignKey(
GalaxyStar,
verbose_name=_("galaxy star 2"),
related_name="lanes2",
on_delete=models.CASCADE,
)
distance = models.PositiveIntegerField(
_("distance"),
default=0,
help_text=_("Distance separating star1 and star2"),
)
family = models.PositiveIntegerField(
_("family score"),
default=0,
)
pictures = models.PositiveIntegerField(
_("pictures score"),
default=0,
)
clubs = models.PositiveIntegerField(
_("clubs score"),
default=0,
)
class StarDict(TypedDict):
id: int
name: str
mass: int
class GalaxyDict(TypedDict):
nodes: List[StarDict]
links: List
class Galaxy(models.Model):
logger = logging.getLogger("main")
GALAXY_SCALE_FACTOR = 2_000
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.
CLUBS_POINTS = 1 # One day together as random members in a club is one point.
state = models.JSONField("current state")
@staticmethod
def make_state() -> None:
"""
Compute JSON structure to send to 3d-force-graph: https://github.com/vasturiano/3d-force-graph/
"""
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 = GalaxyStar.objects.annotate(
owner_name=Case(
When(owner__nick_name=None, then=without_nickname),
default=with_nickname,
)
)
lanes = GalaxyLane.objects.annotate(
star1_owner=F("star1__owner__id"),
star2_owner=F("star2__owner__id"),
)
json = GalaxyDict(
nodes=[
StarDict(id=star.owner_id, name=star.owner_name, mass=star.mass)
for star in stars
],
links=[],
)
# Make bidirectional links
# TODO: see if this impacts performance with a big graph
for path in lanes:
json["links"].append(
{
"source": path.star1_owner,
"target": path.star2_owner,
"value": path.distance,
}
)
json["links"].append(
{
"source": path.star2_owner,
"target": path.star1_owner,
"value": path.distance,
}
)
Galaxy.objects.all().delete()
Galaxy(state=json).save()
###################
# User self score #
###################
@classmethod
def compute_user_score(cls, user) -> int:
"""
This 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:
- Forum posts
- Picture count
- Counter consumption
- Barman time
- ...
"""
user_score = 1
user_score += cls.query_user_score(user)
# 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
user_score = int(math.log2(user_score))
return user_score
@classmethod
def query_user_score(cls, user) -> int:
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 #
####################
@classmethod
def compute_users_score(cls, user1, user2) -> Tuple[int, int, int, int]:
family = cls.compute_users_family_score(user1, user2)
pictures = cls.compute_users_pictures_score(user1, user2)
clubs = cls.compute_users_clubs_score(user1, user2)
score = family + pictures + clubs
return score, family, pictures, clubs
@classmethod
def compute_users_family_score(cls, user1, user2) -> int:
link_count = User.objects.filter(
Q(id=user1.id, godfathers=user2) | Q(id=user2.id, godfathers=user1)
).count()
if link_count:
cls.logger.debug(
f"\t\t- '{user1}' and '{user2}' have {link_count} direct family link"
)
return link_count * cls.FAMILY_LINK_POINTS
@classmethod
def compute_users_pictures_score(cls, user1, user2) -> int:
picture_count = (
Picture.objects.filter(people__user__in=(user1,))
.filter(people__user__in=(user2,))
.count()
)
if picture_count:
cls.logger.debug(
f"\t\t- '{user1}' was pictured with '{user2}' {picture_count} times"
)
return picture_count * cls.PICTURE_POINTS
@classmethod
def compute_users_clubs_score(cls, user1, user2) -> int:
common_clubs = Club.objects.filter(members__in=user1.memberships.all()).filter(
members__in=user2.memberships.all()
)
user1_memberships = user1.memberships.filter(club__in=common_clubs)
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.end_date = timezone.now().date()
query = Q( # start2 <= start1 <= end2
start_date__lte=user1_membership.start_date,
end_date__gte=user1_membership.start_date,
)
query |= Q( # start2 <= start1 <= now
start_date__lte=user1_membership.start_date, end_date=None
)
query |= Q( # start1 <= start2 <= end2
start_date__gte=user1_membership.start_date,
start_date__lte=user1_membership.end_date,
)
for user2_membership in user2_memberships.filter(
query, club=user1_membership.club
):
if user2_membership.end_date is None:
user2_membership.end_date = timezone.now().date()
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 #
###################
@classmethod
def rule(cls) -> None:
GalaxyStar.objects.all().delete()
# The following is a no-op thanks to cascading, but in case that changes in the future, better keep it anyway.
GalaxyLane.objects.all().delete()
rulable_users = (
User.objects.filter(subscriptions__isnull=False)
.filter(
Q(godchildren__isnull=False)
| Q(godfathers__isnull=False)
| Q(pictures__isnull=False)
| Q(memberships__isnull=False)
)
.distinct()
)
# 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 = list(rulable_users)
while len(rulable_users) > 0:
user1 = rulable_users.pop()
for user2 in rulable_users:
cls.logger.debug("")
cls.logger.debug(f"\t> Ruling '{user1}' against '{user2}'")
star1, _ = GalaxyStar.objects.get_or_create(owner=user1)
star2, _ = GalaxyStar.objects.get_or_create(owner=user2)
if star1.mass == 0:
star1.mass = cls.compute_user_score(user1)
star1.save()
if star2.mass == 0:
star2.mass = cls.compute_user_score(user2)
star2.save()
users_score, family, pictures, clubs = cls.compute_users_score(
user1, user2
)
if users_score > 0:
GalaxyLane(
star1=star1,
star2=star2,
distance=cls.scale_distance(users_score),
family=family,
pictures=pictures,
clubs=clubs,
).save()
@classmethod
def scale_distance(cls, value) -> int:
# TODO: this will need adjustements with the real, typical data on Taiste
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)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

6
galaxy/static/galaxy/js/three.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,108 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans user_name=user.get_display_name() %}{{ user_name }}'s Galaxy{% endtrans %}
{% endblock %}
{% block content %}
{% if object.galaxy_user %}
<p><a onclick="focus_node(get_node_from_id({{ object.id }}))">Reset on {{ object.get_display_name() }}</a></p>
<p>Self score: {{ object.galaxy_user.mass }}</p>
<table style="width: initial;">
<tr>
<th></th>
<th>Citizen</th>
<th>Score</th>
<th>Distance</th>
<th>Family</th>
<th>Pictures</th>
<th>Clubs</th>
</tr>
{% for lane in lanes %}
<tr>
<td><a onclick="focus_node(get_node_from_id({{ lane.other_star_id }}))">Locate</a></td>
<td><a href="{{ url("galaxy:user", user_id=lane.other_star_id) }}">{{ lane.other_star_name }}</a></td>
<td>{{ lane.other_star_mass }}</td>
<td>{{ lane.distance }}</td>
<td>{{ lane.family }}</td>
<td>{{ lane.pictures }}</td>
<td>{{ lane.clubs }}</td>
</tr>
{% endfor %}
</table>
<div id="3d-graph" style="margin: 1em;"></div>
{% else %}
<p>This citizen has not yet joined the galaxy</p>
{% endif %}
{% endblock %}
{% block script %}
{{ super() }}
<script src="{{ static('galaxy/js/three.min.js') }}" defer></script>
<script src="{{ static('galaxy/js/three-spritetext.min.js') }}" defer></script>
<script src="{{ static('galaxy/js/3d-force-graph.min.js') }}" defer></script>
<script src="{{ static('galaxy/js/d3-force-3d.min.js') }}" defer></script>
<script>
var Graph;
function get_node_from_id(id) {
return Graph.graphData().nodes.find(n => n.id === id);
}
function focus_node(node) {
// Aim at node from outside it
const distance = 200;
const distRatio = 1 + distance/Math.hypot(node.x, node.y, node.z);
const newPos = node.x || node.y || node.z
? { x: node.x * distRatio, y: node.y * distRatio, z: node.z * distRatio }
: { x: 0, y: 0, z: distance }; // special case if node is in (0,0,0)
Graph.cameraPosition(
newPos, // new position
node, // lookAt ({ x, y, z })
3000 // ms transition duration
);
}
document.addEventListener("DOMContentLoaded", () => {
Graph = ForceGraph3D();
Graph(document.getElementById('3d-graph'));
Graph
.jsonUrl('{{ url("galaxy:data") }}')
.width(1000)
.height(700)
.nodeAutoColorBy('id')
.nodeLabel(node => `${node.name}`)
.onNodeClick(node => focus_node(node))
.linkDirectionalParticles(3)
.linkDirectionalParticleWidth(0.8)
.linkDirectionalParticleSpeed(0.006)
.nodeThreeObject(node => {
const sprite = new SpriteText(node.name);
sprite.material.depthWrite = false; // make sprite background transparent
sprite.color = node.color;
sprite.textHeight = 5;
return sprite;
});
// Set distance between stars
Graph.d3Force('link').distance(link => link.value);
// Set high masses nearer the center of the galaxy
// TODO: quick and dirty strength computation, this will need tuning.
Graph.d3Force('positionX', d3.forceX().strength(node => { return 1 - (1 / node.mass); }));
Graph.d3Force('positionY', d3.forceY().strength(node => { return 1 - (1 / node.mass); }));
Graph.d3Force('positionZ', d3.forceZ().strength(node => { return 1 - (1 / node.mass); }));
// Focus current user
setTimeout(() => focus_node(get_node_from_id({{ object.id }})), 1000);
})
</script>
{% endblock %}

149
galaxy/tests.py Normal file
View File

@ -0,0 +1,149 @@
# -*- coding:utf-8 -*
#
# Copyright 2023
# - Skia <skia@hya.sk>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.test import TestCase
from django.core.management import call_command
from core.models import User
from galaxy.models import Galaxy
class GalaxyTest(TestCase):
def setUp(self):
call_command("populate")
self.root = User.objects.get(username="root")
self.skia = User.objects.get(username="skia")
self.sli = User.objects.get(username="sli")
self.krophil = User.objects.get(username="krophil")
self.richard = User.objects.get(username="rbatsbak")
self.subscriber = User.objects.get(username="subscriber")
self.public = User.objects.get(username="public")
self.com = User.objects.get(username="comunity")
def test_user_self_score(self):
with self.assertNumQueries(8):
self.assertEqual(Galaxy.compute_user_score(self.root), 9)
self.assertEqual(Galaxy.compute_user_score(self.skia), 10)
self.assertEqual(Galaxy.compute_user_score(self.sli), 8)
self.assertEqual(Galaxy.compute_user_score(self.krophil), 2)
self.assertEqual(Galaxy.compute_user_score(self.richard), 10)
self.assertEqual(Galaxy.compute_user_score(self.subscriber), 8)
self.assertEqual(Galaxy.compute_user_score(self.public), 8)
self.assertEqual(Galaxy.compute_user_score(self.com), 1)
def test_users_score(self):
expected_scores = {
"krophil": {
"comunity": {"clubs": 0, "family": 0, "pictures": 0, "score": 0},
"public": {"clubs": 0, "family": 0, "pictures": 0, "score": 0},
"rbatsbak": {"clubs": 100, "family": 0, "pictures": 0, "score": 100},
"subscriber": {"clubs": 0, "family": 0, "pictures": 0, "score": 0},
},
"public": {
"comunity": {"clubs": 0, "family": 0, "pictures": 0, "score": 0}
},
"rbatsbak": {
"comunity": {"clubs": 0, "family": 0, "pictures": 0, "score": 0},
"public": {"clubs": 0, "family": 366, "pictures": 0, "score": 366},
"subscriber": {"clubs": 0, "family": 366, "pictures": 0, "score": 366},
},
"root": {
"comunity": {"clubs": 0, "family": 0, "pictures": 0, "score": 0},
"krophil": {"clubs": 0, "family": 0, "pictures": 0, "score": 0},
"public": {"clubs": 0, "family": 0, "pictures": 0, "score": 0},
"rbatsbak": {"clubs": 0, "family": 0, "pictures": 0, "score": 0},
"skia": {"clubs": 0, "family": 732, "pictures": 0, "score": 732},
"sli": {"clubs": 0, "family": 0, "pictures": 0, "score": 0},
"subscriber": {"clubs": 0, "family": 0, "pictures": 0, "score": 0},
},
"skia": {
"comunity": {"clubs": 0, "family": 0, "pictures": 0, "score": 0},
"krophil": {"clubs": 114, "family": 0, "pictures": 2, "score": 116},
"public": {"clubs": 0, "family": 0, "pictures": 0, "score": 0},
"rbatsbak": {"clubs": 100, "family": 0, "pictures": 0, "score": 100},
"sli": {"clubs": 0, "family": 366, "pictures": 4, "score": 370},
"subscriber": {"clubs": 0, "family": 0, "pictures": 0, "score": 0},
},
"sli": {
"comunity": {"clubs": 0, "family": 0, "pictures": 0, "score": 0},
"krophil": {"clubs": 17, "family": 0, "pictures": 2, "score": 19},
"public": {"clubs": 0, "family": 0, "pictures": 0, "score": 0},
"rbatsbak": {"clubs": 0, "family": 0, "pictures": 0, "score": 0},
"subscriber": {"clubs": 0, "family": 0, "pictures": 0, "score": 0},
},
"subscriber": {
"comunity": {"clubs": 0, "family": 0, "pictures": 0, "score": 0},
"public": {"clubs": 0, "family": 0, "pictures": 0, "score": 0},
},
}
computed_scores = {}
users = [
self.root,
self.skia,
self.sli,
self.krophil,
self.richard,
self.subscriber,
self.public,
self.com,
]
with self.assertNumQueries(100):
while len(users) > 0:
user1 = users.pop(0)
for user2 in users:
score, family, pictures, clubs = Galaxy.compute_users_score(
user1, user2
)
u1 = computed_scores.get(user1.username, {})
u1[user2.username] = {
"score": score,
"family": family,
"pictures": pictures,
"clubs": clubs,
}
computed_scores[user1.username] = u1
self.maxDiff = None # Yes, we want to see the diff if any
self.assertDictEqual(expected_scores, computed_scores)
def test_page_is_citizen(self):
Galaxy.rule()
self.client.login(username="root", password="plop")
response = self.client.get("/galaxy/1/")
self.assertContains(
response,
'<a onclick="focus_node(get_node_from_id(8))">Locate</a>',
status_code=200,
)
def test_page_not_citizen(self):
Galaxy.rule()
self.client.login(username="root", password="plop")
response = self.client.get("/galaxy/2/")
self.assertContains(
response,
"Ce citoyen n&#39;a pas encore rejoint la galaxie",
status_code=404,
)

40
galaxy/urls.py Normal file
View File

@ -0,0 +1,40 @@
# -*- coding:utf-8 -*
#
# Copyright 2023
# - Skia <skia@hya.sk>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.urls import path
from galaxy.views import *
urlpatterns = [
path(
"<int:user_id>/",
GalaxyUserView.as_view(),
name="user",
),
path(
"data.json",
GalaxyDataView.as_view(),
name="data",
),
]

104
galaxy/views.py Normal file
View File

@ -0,0 +1,104 @@
# -*- coding:utf-8 -*
#
# Copyright 2023
# - Skia <skia@hya.sk>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.views.generic import DetailView, View
from django.http import JsonResponse, Http404
from django.db.models import Q, Case, F, When, Value
from django.db.models.functions import Concat
from django.utils.translation import gettext_lazy as _
from core.views import (
CanViewMixin,
FormerSubscriberMixin,
)
from core.models import User
from core.views import UserTabsMixin
from galaxy.models import Galaxy, GalaxyLane
class GalaxyUserView(CanViewMixin, UserTabsMixin, DetailView):
model = User
pk_url_kwarg = "user_id"
template_name = "galaxy/user.jinja"
current_tab = "galaxy"
def get_object(self, *args, **kwargs):
user: User = super(GalaxyUserView, self).get_object(*args, **kwargs)
if not hasattr(user, "galaxy_user"):
raise Http404(_("This citizen has not yet joined the galaxy"))
return user
def get_queryset(self):
return super(GalaxyUserView, self).get_queryset().select_related("galaxy_user")
def get_context_data(self, **kwargs):
kwargs = super(GalaxyUserView, self).get_context_data(**kwargs)
kwargs["lanes"] = (
GalaxyLane.objects.filter(
Q(star1=self.object.galaxy_user) | Q(star2=self.object.galaxy_user)
)
.order_by("distance")
.annotate(
other_star_id=Case(
When(star1=self.object.galaxy_user, then=F("star2__owner__id")),
default=F("star1__owner__id"),
),
other_star_mass=Case(
When(star1=self.object.galaxy_user, then=F("star2__mass")),
default=F("star1__mass"),
),
other_star_name=Case(
When(
star1=self.object.galaxy_user,
then=Case(
When(
star2__owner__nick_name=None,
then=Concat(
F("star2__owner__first_name"),
Value(" "),
F("star2__owner__last_name"),
),
)
),
),
default=Case(
When(
star1__owner__nick_name=None,
then=Concat(
F("star1__owner__first_name"),
Value(" "),
F("star1__owner__last_name"),
),
)
),
),
)
.select_related("star1", "star2")
)
return kwargs
class GalaxyDataView(FormerSubscriberMixin, View):
def get(self, request, *args, **kwargs):
return JsonResponse(Galaxy.objects.first().state)

File diff suppressed because it is too large Load Diff

View File

@ -97,6 +97,7 @@ INSTALLED_APPS = (
"trombi",
"matmat",
"pedagogy",
"galaxy",
)
MIDDLEWARE = (
@ -210,6 +211,29 @@ DATABASES = {
}
}
# Logging
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"simple": {"format": "%(levelname)s %(message)s"},
},
"handlers": {
"log_to_stdout": {
"level": "DEBUG",
"class": "logging.StreamHandler",
"formatter": "simple",
},
},
"loggers": {
"main": {
"handlers": ["log_to_stdout"],
"level": "INFO",
"propagate": True,
}
},
}
# Internationalization
# https://docs.djangoproject.com/en/1.8/topics/i18n/
@ -691,4 +715,8 @@ SITH_FRONT_DEP_VERSIONS = {
"https://github.com/jhuckaby/webcamjs/": "1.0.0",
"https://github.com/vuejs/vue-next": "3.2.18",
"https://github.com/alpinejs/alpine": "3.10.5",
"https://github.com/mrdoob/three.js/": "r148",
"https://github.com/vasturiano/three-spritetext": "1.6.5",
"https://github.com/vasturiano/3d-force-graph/": "1.70.19",
"https://github.com/vasturiano/d3-force-3d": "3.0.3",
}

View File

@ -76,6 +76,7 @@ urlpatterns = [
path("api/v1/", include(("api.urls", "api"), namespace="api")),
path("election/", include(("election.urls", "election"), namespace="election")),
path("forum/", include(("forum.urls", "forum"), namespace="forum")),
path("galaxy/", include(("galaxy.urls", "galaxy"), namespace="galaxy")),
path("trombi/", include(("trombi.urls", "trombi"), namespace="trombi")),
path("matmatronch/", include(("matmat.urls", "matmat"), namespace="matmat")),
path("pedagogy/", include(("pedagogy.urls", "pedagogy"), namespace="pedagogy")),