mirror of
https://github.com/ae-utbm/sith.git
synced 2026-10-02 17:04:19 +00:00
Add galaxy (#562)
* style.scss: lint * style.scss: add 'th' padding * core: populate: add much more data for development * Add galaxy
This commit is contained in:
30 files changed
+1691
-497
No files matched your search
File renamed without changes.
File renamed without changes.
File renamed without changes.
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
File renamed without changes.
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -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-]+)*))?$"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
+480
-435
File diff suppressed because it is too large.
Load diff
+30
-40
@@ -189,78 +189,70 @@ 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 self.request.user.was_subscribed:
|
||||
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 +263,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 +271,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:
|
||||
|
||||
Whitespace-only changes.
@@ -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"
|
||||
@@ -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)
|
||||
@@ -0,0 +1,113 @@
|
||||
# Generated by Django 3.2.16 on 2023-02-03 10:31
|
||||
|
||||
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="galaxy user",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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 lanes 1",
|
||||
),
|
||||
),
|
||||
(
|
||||
"star2",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="lanes2",
|
||||
to="galaxy.galaxystar",
|
||||
verbose_name="galaxy lanes 2",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
Whitespace-only changes.
@@ -0,0 +1,377 @@
|
||||
# -*- 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 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=_("galaxy user"),
|
||||
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 lanes 1"),
|
||||
related_name="lanes1",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
star2 = models.ForeignKey(
|
||||
GalaxyStar,
|
||||
verbose_name=_("galaxy lanes 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() -> GalaxyDict:
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
# 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)
|
||||
@@ -0,0 +1,5 @@
|
||||
// Version 1.70.19 3d-force-graph - https://github.com/vasturiano/3d-force-graph
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).ForceGraph3D=e()}(this,(function(){"use strict";function t(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(t);e&&(i=i.filter((function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable}))),n.push.apply(n,i)}return n}function e(e){for(var i=1;i<arguments.length;i++){var r=null!=arguments[i]?arguments[i]:{};i%2?t(Object(r),!0).forEach((function(t){n(e,t,r[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(r)):t(Object(r)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(r,t))}))}return e}function n(t,e,n){return(e=function(t){var e=function(t,e){if("object"!=typeof t||null===t)return t;var n=t[Symbol.toPrimitive];if(void 0!==n){var i=n.call(t,e||"default");if("object"!=typeof i)return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===e?String:Number)(t)}(t,"string");return"symbol"==typeof e?e:String(e)}(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function i(t){return function(t){if(Array.isArray(t))return r(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||function(t,e){if(!t)return;if("string"==typeof t)return r(t,e);var n=Object.prototype.toString.call(t).slice(8,-1);"Object"===n&&t.constructor&&(n=t.constructor.name);if("Map"===n||"Set"===n)return Array.from(t);if("Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return r(t,e)}(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function r(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,i=new Array(e);n<e;n++)i[n]=t[n];return i}!function(t,e){void 0===e&&(e={});var n=e.insertAt;if(t&&"undefined"!=typeof document){var i=document.head||document.getElementsByTagName("head")[0],r=document.createElement("style");r.type="text/css","top"===n&&i.firstChild?i.insertBefore(r,i.firstChild):i.appendChild(r),r.styleSheet?r.styleSheet.cssText=t:r.appendChild(document.createTextNode(t))}}(".graph-info-msg {\n top: 50%;\n width: 100%;\n text-align: center;\n color: lavender;\n opacity: 0.7;\n font-size: 22px;\n position: absolute;\n font-family: Sans-serif;\n}\n\n.scene-container .clickable {\n cursor: pointer;\n}\n\n.scene-container .grabbable {\n cursor: move;\n cursor: grab;\n cursor: -moz-grab;\n cursor: -webkit-grab;\n}\n\n.scene-container .grabbable:active {\n cursor: grabbing;\n cursor: -moz-grabbing;\n cursor: -webkit-grabbing;\n}");const a="148",o={LEFT:0,MIDDLE:1,RIGHT:2,ROTATE:0,DOLLY:1,PAN:2},s=0,l=1,c=2,u=3,h=100,d=301,p=302,f=306,m=1e3,g=1001,v=1002,_=1003,y=1005,x=1006,b=1008,w=1009,M=1014,S=1015,E=1016,T=1020,A=1023,C=1026,L=1027,P=33776,R=33777,D=33778,O=33779,I=3e3,k=3001,N="srgb",z="srgb-linear",U=7680,F="300 es",B=1035;class j{addEventListener(t,e){void 0===this._listeners&&(this._listeners={});const n=this._listeners;void 0===n[t]&&(n[t]=[]),-1===n[t].indexOf(e)&&n[t].push(e)}hasEventListener(t,e){if(void 0===this._listeners)return!1;const n=this._listeners;return void 0!==n[t]&&-1!==n[t].indexOf(e)}removeEventListener(t,e){if(void 0===this._listeners)return;const n=this._listeners[t];if(void 0!==n){const t=n.indexOf(e);-1!==t&&n.splice(t,1)}}dispatchEvent(t){if(void 0===this._listeners)return;const e=this._listeners[t.type];if(void 0!==e){t.target=this;const n=e.slice(0);for(let e=0,i=n.length;e<i;e++)n[e].call(this,t);t.target=null}}}const G=["00","01","02","03","04","05","06","07","08","09","0a","0b","0c","0d","0e","0f","10","11","12","13","14","15","16","17","18","19","1a","1b","1c","1d","1e","1f","20","21","22","23","24","25","26","27","28","29","2a","2b","2c","2d","2e","2f","30","31","32","33","34","35","36","37","38","39","3a","3b","3c","3d","3e","3f","40","41","42","43","44","45","46","47","48","49","4a","4b","4c","4d","4e","4f","50","51","52","53","54","55","56","57","58","59","5a","5b","5c","5d","5e","5f","60","61","62","63","64","65","66","67","68","69","6a","6b","6c","6d","6e","6f","70","71","72","73","74","75","76","77","78","79","7a","7b","7c","7d","7e","7f","80","81","82","83","84","85","86","87","88","89","8a","8b","8c","8d","8e","8f","90","91","92","93","94","95","96","97","98","99","9a","9b","9c","9d","9e","9f","a0","a1","a2","a3","a4","a5","a6","a7","a8","a9","aa","ab","ac","ad","ae","af","b0","b1","b2","b3","b4","b5","b6","b7","b8","b9","ba","bb","bc","bd","be","bf","c0","c1","c2","c3","c4","c5","c6","c7","c8","c9","ca","cb","cc","cd","ce","cf","d0","d1","d2","d3","d4","d5","d6","d7","d8","d9","da","db","dc","dd","de","df","e0","e1","e2","e3","e4","e5","e6","e7","e8","e9","ea","eb","ec","ed","ee"Line truncated
|
||||
// <http://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef (WCAG Version 2)
|
||||
// Analyze the 2 colors and returns the color contrast defined by (WCAG Version 2)
|
||||
c.readability=function(t,n){var i=c(t),r=c(n);return(e.max(i.getLuminance(),r.getLuminance())+.05)/(e.min(i.getLuminance(),r.getLuminance())+.05)},c.isReadable=function(t,e,n){var i,r,a=c.readability(t,e);switch(r=!1,(i=function(t){var e,n;e=((t=t||{level:"AA",size:"small"}).level||"AA").toUpperCase(),n=(t.size||"small").toLowerCase(),"AA"!==e&&"AAA"!==e&&(e="AA");"small"!==n&&"large"!==n&&(n="small");return{level:e,size:n}}(n)).level+i.size){case"AAsmall":case"AAAlarge":r=a>=4.5;break;case"AAlarge":r=a>=3;break;case"AAAsmall":r=a>=7}return r},c.mostReadable=function(t,e,n){var i,r,a,o,s=null,l=0;r=(n=n||{}).includeFallbackColors,a=n.level,o=n.size;for(var u=0;u<e.length;u++)(i=c.readability(t,e[u]))>l&&(l=i,s=c(e[u]));return c.isReadable(t,s,{level:a,size:o})||!r?s:(n.includeFallbackColors=!1,c.mostReadable(t,["#fff","#000"],n))};var A=c.names={aliceblue:"f0f8ff",antiquewhite:"faebd7",aqua:"0ff",aquamarine:"7fffd4",azure:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"000",blanchedalmond:"ffebcd",blue:"00f",blueviolet:"8a2be2",brown:"a52a2a",burlywood:"deb887",burntsienna:"ea7e5d",cadetblue:"5f9ea0",chartreuse:"7fff00",chocolate:"d2691e",coral:"ff7f50",cornflowerblue:"6495ed",cornsilk:"fff8dc",crimson:"dc143c",cyan:"0ff",darkblue:"00008b",darkcyan:"008b8b",darkgoldenrod:"b8860b",darkgray:"a9a9a9",darkgreen:"006400",darkgrey:"a9a9a9",darkkhaki:"bdb76b",darkmagenta:"8b008b",darkolivegreen:"556b2f",darkorange:"ff8c00",darkorchid:"9932cc",darkred:"8b0000",darksalmon:"e9967a",darkseagreen:"8fbc8f",darkslateblue:"483d8b",darkslategray:"2f4f4f",darkslategrey:"2f4f4f",darkturquoise:"00ced1",darkviolet:"9400d3",deeppink:"ff1493",deepskyblue:"00bfff",dimgray:"696969",dimgrey:"696969",dodgerblue:"1e90ff",firebrick:"b22222",floralwhite:"fffaf0",forestgreen:"228b22",fuchsia:"f0f",gainsboro:"dcdcdc",ghostwhite:"f8f8ff",gold:"ffd700",goldenrod:"daa520",gray:"808080",green:"008000",greenyellow:"adff2f",grey:"808080",honeydew:"f0fff0",hotpink:"ff69b4",indianred:"cd5c5c",indigo:"4b0082",ivory:"fffff0",khaki:"f0e68c",lavender:"e6e6fa",lavenderblush:"fff0f5",lawngreen:"7cfc00",lemonchiffon:"fffacd",lightblue:"add8e6",lightcoral:"f08080",lightcyan:"e0ffff",lightgoldenrodyellow:"fafad2",lightgray:"d3d3d3",lightgreen:"90ee90",lightgrey:"d3d3d3",lightpink:"ffb6c1",lightsalmon:"ffa07a",lightseagreen:"20b2aa",lightskyblue:"87cefa",lightslategray:"789",lightslategrey:"789",lightsteelblue:"b0c4de",lightyellow:"ffffe0",lime:"0f0",limegreen:"32cd32",linen:"faf0e6",magenta:"f0f",maroon:"800000",mediumaquamarine:"66cdaa",mediumblue:"0000cd",mediumorchid:"ba55d3",mediumpurple:"9370db",mediumseagreen:"3cb371",mediumslateblue:"7b68ee",mediumspringgreen:"00fa9a",mediumturquoise:"48d1cc",mediumvioletred:"c71585",midnightblue:"191970",mintcream:"f5fffa",mistyrose:"ffe4e1",moccasin:"ffe4b5",navajowhite:"ffdead",navy:"000080",oldlace:"fdf5e6",olive:"808000",olivedrab:"6b8e23",orange:"ffa500",orangered:"ff4500",orchid:"da70d6",palegoldenrod:"eee8aa",palegreen:"98fb98",paleturquoise:"afeeee",palevioletred:"db7093",papayawhip:"ffefd5",peachpuff:"ffdab9",peru:"cd853f",pink:"ffc0cb",plum:"dda0dd",powderblue:"b0e0e6",purple:"800080",rebeccapurple:"663399",red:"f00",rosybrown:"bc8f8f",royalblue:"4169e1",saddlebrown:"8b4513",salmon:"fa8072",sandybrown:"f4a460",seagreen:"2e8b57",seashell:"fff5ee",sienna:"a0522d",silver:"c0c0c0",skyblue:"87ceeb",slateblue:"6a5acd",slategray:"708090",slategrey:"708090",snow:"fffafa",springgreen:"00ff7f",steelblue:"4682b4",tan:"d2b48c",teal:"008080",thistle:"d8bfd8",tomato:"ff6347",turquoise:"40e0d0",violet:"ee82ee",wheat:"f5deb3",white:"fff",whitesmoke:"f5f5f5",yellow:"ff0",yellowgreen:"9acd32"},C=c.hexNames=function(t){var e={};for(var n in t)t.hasOwnProperty(n)&&(e[t[n]]=n);return e}(A);function L(t){return t=parseFloat(t),(isNaN(t)||t<0||t>1)&&(t=1),t}function P(t,n){(function(t){return"string"==typeof t&&-1!=t.indexOf(".")&&1===parseFloat(t)})(t)&&(t="100%");var i=function(t){return"string"==typeof t&&-1!=t.indexOf("%")}(t);return t=o(n,s(0,parseFloat(t))),i&&(t=parseInt(t*n,10)/100),e.abs(t-n)<1e-6?1:t%n/parseFloat(n)}function R(t){return o(1,s(0,t))}function D(t){return parseInt(t,16)}function O(t){return 1==t.length?"0"+t:""+t}function I(t){return t<=1&&(t=100*t+"%"),t}function k(t){return e.round(255*parseFloat(t)).toString(16)}function N(t){return D(t)/255}var z,U,F,B=(U="[\\s|\\(]+("+(z="(?:[-\\+]?\\d*\\.\\d+%?)|(?:[-\\+]?\\d+%?)")+")[,|\\s]+("+z+")[,|\\s]+("+z+")\\s*\\)?",F="[\\s|\\(]+("+z+")[,|\\s]+("+z+")[,|\\s]+("+z+")[,|\\s]+("+z+")\\s*\\)?",{CSS_UNIT:new RegExp(z),rgb:new RegExp("rgb"+U),rgba:new RegExp("rgba"+F),hsl:new RegExp("hsl"+U),hsla:new RegExp("hsla"+F),hsv:new RegExp("hsv"+U),hsva:new RegExp("hsva"+F),hex3:/^#?([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/,hex6:/^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/,hex4:/^#?([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})$/,hex8:/^#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/});function j(t){return!!B.CSSLine truncated
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
// https://github.com/vasturiano/d3-force-3d v3.0.3 Copyright 2022 Vasco Asturiano
|
||||
!function(n,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports,require("d3-binarytree"),require("d3-quadtree"),require("d3-octree"),require("d3-dispatch"),require("d3-timer")):"function"==typeof define&&define.amd?define(["exports","d3-binarytree","d3-quadtree","d3-octree","d3-dispatch","d3-timer"],t):t((n="undefined"!=typeof globalThis?globalThis:n||self).d3=n.d3||{},n.d3,n.d3,n.d3,n.d3,n.d3)}(this,(function(n,t,e,r,i,u){"use strict";function o(n){return function(){return n}}function f(n){return 1e-6*(n()-.5)}function a(n){return n.x+n.vx}function c(n){return n.y+n.vy}function l(n){return n.z+n.vz}function h(n){return n.index}function v(n,t){var e=n.get(t);if(!e)throw new Error("node not found: "+t);return e}const d=4294967296;function y(n){return n.x}function s(n){return n.y}function g(n){return n.z}var x=Math.PI*(3-Math.sqrt(5)),z=20*Math.PI/(9+Math.sqrt(221));n.forceCenter=function(n,t,e){var r,i=1;function u(){var u,o,f=r.length,a=0,c=0,l=0;for(u=0;u<f;++u)a+=(o=r[u]).x||0,c+=o.y||0,l+=o.z||0;for(a=(a/f-n)*i,c=(c/f-t)*i,l=(l/f-e)*i,u=0;u<f;++u)o=r[u],a&&(o.x-=a),c&&(o.y-=c),l&&(o.z-=l)}return null==n&&(n=0),null==t&&(t=0),null==e&&(e=0),u.initialize=function(n){r=n},u.x=function(t){return arguments.length?(n=+t,u):n},u.y=function(n){return arguments.length?(t=+n,u):t},u.z=function(n){return arguments.length?(e=+n,u):e},u.strength=function(n){return arguments.length?(i=+n,u):i},u},n.forceCollide=function(n){var i,u,h,v,d=1,y=1;function s(){for(var n,o,s,x,z,p,M,w,q=i.length,N=0;N<y;++N)for(o=(1===u?t.binarytree(i,a):2===u?e.quadtree(i,a,c):3===u?r.octree(i,a,c,l):null).visitAfter(g),n=0;n<q;++n)s=i[n],M=h[s.index],w=M*M,x=s.x+s.vx,u>1&&(z=s.y+s.vy),u>2&&(p=s.z+s.vz),o.visit(m);function m(n,t,e,r,i,o,a){var c=[t,e,r,i,o,a],l=c[0],h=c[1],y=c[2],g=c[u],q=c[u+1],N=c[u+2],m=n.data,A=n.r,b=M+A;if(!m)return l>x+b||g<x-b||u>1&&(h>z+b||q<z-b)||u>2&&(y>p+b||N<p-b);if(m.index>s.index){var k=x-m.x-m.vx,E=u>1?z-m.y-m.vy:0,j=u>2?p-m.z-m.vz:0,D=k*k+E*E+j*j;D<b*b&&(0===k&&(D+=(k=f(v))*k),u>1&&0===E&&(D+=(E=f(v))*E),u>2&&0===j&&(D+=(j=f(v))*j),D=(b-(D=Math.sqrt(D)))/D*d,s.vx+=(k*=D)*(b=(A*=A)/(w+A)),u>1&&(s.vy+=(E*=D)*b),u>2&&(s.vz+=(j*=D)*b),m.vx-=k*(b=1-b),u>1&&(m.vy-=E*b),u>2&&(m.vz-=j*b))}}}function g(n){if(n.data)return n.r=h[n.data.index];for(var t=n.r=0;t<Math.pow(2,u);++t)n[t]&&n[t].r>n.r&&(n.r=n[t].r)}function x(){if(i){var t,e,r=i.length;for(h=new Array(r),t=0;t<r;++t)e=i[t],h[e.index]=+n(e,t,i)}}return"function"!=typeof n&&(n=o(null==n?1:+n)),s.initialize=function(n,...t){i=n,v=t.find((n=>"function"==typeof n))||Math.random,u=t.find((n=>[1,2,3].includes(n)))||2,x()},s.iterations=function(n){return arguments.length?(y=+n,s):y},s.strength=function(n){return arguments.length?(d=+n,s):d},s.radius=function(t){return arguments.length?(n="function"==typeof t?t:o(+t),x(),s):n},s},n.forceLink=function(n){var t,e,r,i,u,a,c,l=h,d=function(n){return 1/Math.min(u[n.source.index],u[n.target.index])},y=o(30),s=1;function g(r){for(var u=0,o=n.length;u<s;++u)for(var l,h,v,d,y,g=0,x=0,z=0,p=0;g<o;++g)h=(l=n[g]).source,x=(v=l.target).x+v.vx-h.x-h.vx||f(c),i>1&&(z=v.y+v.vy-h.y-h.vy||f(c)),i>2&&(p=v.z+v.vz-h.z-h.vz||f(c)),x*=d=((d=Math.sqrt(x*x+z*z+p*p))-e[g])/d*r*t[g],z*=d,p*=d,v.vx-=x*(y=a[g]),i>1&&(v.vy-=z*y),i>2&&(v.vz-=p*y),h.vx+=x*(y=1-y),i>1&&(h.vy+=z*y),i>2&&(h.vz+=p*y)}function x(){if(r){var i,o,f=r.length,c=n.length,h=new Map(r.map(((n,t)=>[l(n,t,r),n])));for(i=0,u=new Array(f);i<c;++i)(o=n[i]).index=i,"object"!=typeof o.source&&(o.source=v(h,o.source)),"object"!=typeof o.target&&(o.target=v(h,o.target)),u[o.source.index]=(u[o.source.index]||0)+1,u[o.target.index]=(u[o.target.index]||0)+1;for(i=0,a=new Array(c);i<c;++i)o=n[i],a[i]=u[o.source.index]/(u[o.source.index]+u[o.target.index]);t=new Array(c),z(),e=new Array(c),p()}}function z(){if(r)for(var e=0,i=n.length;e<i;++e)t[e]=+d(n[e],e,n)}function p(){if(r)for(var t=0,i=n.length;t<i;++t)e[t]=+y(n[t],t,n)}return null==n&&(n=[]),g.initialize=function(n,...t){r=n,c=t.find((n=>"function"==typeof n))||Math.random,i=t.find((n=>[1,2,3].includes(n)))||2,x()},g.links=function(t){return arguments.length?(n=t,x(),g):n},g.id=function(n){return arguments.length?(l=n,g):l},g.iterations=function(n){return arguments.length?(s=+n,g):s},g.strength=function(n){return arguments.length?(d="function"==typeof n?n:o(+n),z(),g):d},g.distance=function(n){return arguments.length?(y="function"==typeof n?n:o(+n),p(),g):y},g},n.forceManyBody=function(){var n,i,u,a,c,l,h=o(-30),v=1,d=1/0,x=.81;function z(o){var f,a=n.length,l=(1===i?t.binarytree(n,y):2===i?e.quadtree(n,y,s):3===i?r.octree(n,y,s,g):null).visitAfter(M);for(c=o,f=0;f<a;++f)u=n[f],l.visit(w)}function p(){if(n){var t,e,r=n.length;for(l=new Array(r),t=0;t<r;++t)e=n[t],l[e.index]=+h(e,t,n)}}function M(n){var t,e,r,u,o,f,a=0,c=0,h=n.length;if(h){for(r=u=o=f=0;f<h;++f)(t=n[f])&&(e=Math.abs(t.value))&&(a+=t.value,c+=e,r+=e*(t.x||0),u+=e*(t.y||0),o+=e*(t.z||0));a*=Math.sqrt(4/h),n.x=r/c,i>1&&(n.y=u/c),i>2&&(n.z=o/c)}else{(t=n).Line truncated
|
||||
@@ -0,0 +1,2 @@
|
||||
// Version 1.6.5 three-spritetext - https://github.com/vasturiano/three-spritetext
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e(require("three")):"function"==typeof define&&define.amd?define(["three"],e):(t="undefined"!=typeof globalThis?globalThis:t||self).SpriteText=e(t.THREE)}(this,(function(t){"use strict";function e(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function r(t,e){for(var r=0;r<e.length;r++){var i=e[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(t,i.key,i)}}function i(t){return i=Object.setPrototypeOf?Object.getPrototypeOf:function(t){return t.__proto__||Object.getPrototypeOf(t)},i(t)}function n(t,e){return n=Object.setPrototypeOf||function(t,e){return t.__proto__=e,t},n(t,e)}function o(t,e){if(e&&("object"==typeof e||"function"==typeof e))return e;if(void 0!==e)throw new TypeError("Derived constructors may only return object or undefined");return function(t){if(void 0===t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return t}(t)}function a(t){var e=function(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){}))),!0}catch(t){return!1}}();return function(){var r,n=i(t);if(e){var a=i(this).constructor;r=Reflect.construct(n,arguments,a)}else r=n.apply(this,arguments);return o(this,r)}}function h(t,e){return function(t){if(Array.isArray(t))return t}(t)||function(t,e){var r=null==t?null:"undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(null==r)return;var i,n,o=[],a=!0,h=!1;try{for(r=r.call(t);!(a=(i=r.next()).done)&&(o.push(i.value),!e||o.length!==e);a=!0);}catch(t){h=!0,n=t}finally{try{a||null==r.return||r.return()}finally{if(h)throw n}}return o}(t,e)||u(t,e)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function s(t){return function(t){if(Array.isArray(t))return c(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||u(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function u(t,e){if(t){if("string"==typeof t)return c(t,e);var r=Object.prototype.toString.call(t).slice(8,-1);return"Object"===r&&t.constructor&&(r=t.constructor.name),"Map"===r||"Set"===r?Array.from(t):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?c(t,e):void 0}}function c(t,e){(null==e||e>t.length)&&(e=t.length);for(var r=0,i=new Array(e);r<e;r++)i[r]=t[r];return i}var f="undefined"!=typeof window&&window.THREE?window.THREE:{LinearFilter:t.LinearFilter,Sprite:t.Sprite,SpriteMaterial:t.SpriteMaterial,Texture:t.Texture},l=function(t){!function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Super expression must either be null or a function");t.prototype=Object.create(e&&e.prototype,{constructor:{value:t,writable:!0,configurable:!0}}),Object.defineProperty(t,"prototype",{writable:!1}),e&&n(t,e)}(l,t);var i,o,u,c=a(l);function l(){var t,r=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"",i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:10,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"rgba(255, 255, 255, 1)";return e(this,l),(t=c.call(this,new f.SpriteMaterial))._text="".concat(r),t._textHeight=i,t._color=n,t._backgroundColor=!1,t._padding=0,t._borderWidth=0,t._borderRadius=0,t._borderColor="white",t._strokeWidth=0,t._strokeColor="white",t._fontFace="Arial",t._fontSize=90,t._fontWeight="normal",t._canvas=document.createElement("canvas"),t._genCanvas(),t}return i=l,(o=[{key:"text",get:function(){return this._text},set:function(t){this._text=t,this._genCanvas()}},{key:"textHeight",get:function(){return this._textHeight},set:function(t){this._textHeight=t,this._genCanvas()}},{key:"color",get:function(){return this._color},set:function(t){this._color=t,this._genCanvas()}},{key:"backgroundColor",get:function(){return this._backgroundColor},set:function(t){this._backgroundColor=t,this._genCanvas()}},{key:"padding",get:function(){return this._padding},set:function(t){this._padding=t,this._genCanvas()}},{key:"borderWidth",get:function(){return this._borderWidth},set:function(t){this._borderWidth=t,this._genCanvas()}},{key:"borderRadius",get:function(){return this._borderRadius},set:function(t){this._borderRadius=t,this._genCanvas()}},{key:"borderColor",get:function(){return this._borderColor},set:function(t){this._borderColor=t,this._genCanvas()}},{key:"fontFace",get:function(){return this._fontFace},set:function(t){this._fontFace=t,this._genCanvas()}},{key:"fontSize",get:function(){return this._fontSize},set:function(t){this._fontSize=t,this._genCanvas()}},{key:"fontWeight",get:function(){Line truncated
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2010-2022 Three.js Authors
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).THREE={})}(this,(function(t){"use strict";const e="148",i=100,n=300,r=301,s=302,a=303,o=304,l=306,c=1e3,h=1001,u=1002,d=1003,p=1004,m=1005,f=1006,g=1007,v=1008,x=1009,_=1012,y=1014,M=1015,b=1016,S=1020,w=1023,T=1026,A=1027,E=33776,C=33777,L=33778,R=33779,P=35840,I=35841,D=35842,N=35843,O=37492,z=37496,U=37808,B=37809,F=37810,k=37811,G=37812,V=37813,H=37814,W=37815,j=37816,q=37817,X=37818,Y=37819,Z=37820,J=37821,K=36492,$=2300,Q=2301,tt=2302,et=2400,it=2401,nt=2402,rt=2500,st=2501,at=3e3,ot=3001,lt="srgb",ct="srgb-linear",ht=7680,ut=35044,dt="300 es",pt=1035;class mt{addEventListener(t,e){void 0===this._listeners&&(this._listeners={});const i=this._listeners;void 0===i[t]&&(i[t]=[]),-1===i[t].indexOf(e)&&i[t].push(e)}hasEventListener(t,e){if(void 0===this._listeners)return!1;const i=this._listeners;return void 0!==i[t]&&-1!==i[t].indexOf(e)}removeEventListener(t,e){if(void 0===this._listeners)return;const i=this._listeners[t];if(void 0!==i){const t=i.indexOf(e);-1!==t&&i.splice(t,1)}}dispatchEvent(t){if(void 0===this._listeners)return;const e=this._listeners[t.type];if(void 0!==e){t.target=this;const i=e.slice(0);for(let e=0,n=i.length;e<n;e++)i[e].call(this,t);t.target=null}}}const ft=["00","01","02","03","04","05","06","07","08","09","0a","0b","0c","0d","0e","0f","10","11","12","13","14","15","16","17","18","19","1a","1b","1c","1d","1e","1f","20","21","22","23","24","25","26","27","28","29","2a","2b","2c","2d","2e","2f","30","31","32","33","34","35","36","37","38","39","3a","3b","3c","3d","3e","3f","40","41","42","43","44","45","46","47","48","49","4a","4b","4c","4d","4e","4f","50","51","52","53","54","55","56","57","58","59","5a","5b","5c","5d","5e","5f","60","61","62","63","64","65","66","67","68","69","6a","6b","6c","6d","6e","6f","70","71","72","73","74","75","76","77","78","79","7a","7b","7c","7d","7e","7f","80","81","82","83","84","85","86","87","88","89","8a","8b","8c","8d","8e","8f","90","91","92","93","94","95","96","97","98","99","9a","9b","9c","9d","9e","9f","a0","a1","a2","a3","a4","a5","a6","a7","a8","a9","aa","ab","ac","ad","ae","af","b0","b1","b2","b3","b4","b5","b6","b7","b8","b9","ba","bb","bc","bd","be","bf","c0","c1","c2","c3","c4","c5","c6","c7","c8","c9","ca","cb","cc","cd","ce","cf","d0","d1","d2","d3","d4","d5","d6","d7","d8","d9","da","db","dc","dd","de","df","e0","e1","e2","e3","e4","e5","e6","e7","e8","e9","ea","eb","ec","ed","ee","ef","f0","f1","f2","f3","f4","f5","f6","f7","f8","f9","fa","fb","fc","fd","fe","ff"];let gt=1234567;const vt=Math.PI/180,xt=180/Math.PI;function _t(){const t=4294967295*Math.random()|0,e=4294967295*Math.random()|0,i=4294967295*Math.random()|0,n=4294967295*Math.random()|0;return(ft[255&t]+ft[t>>8&255]+ft[t>>16&255]+ft[t>>24&255]+"-"+ft[255&e]+ft[e>>8&255]+"-"+ft[e>>16&15|64]+ft[e>>24&255]+"-"+ft[63&i|128]+ft[i>>8&255]+"-"+ft[i>>16&255]+ft[i>>24&255]+ft[255&n]+ft[n>>8&255]+ft[n>>16&255]+ft[n>>24&255]).toLowerCase()}function yt(t,e,i){return Math.max(e,Math.min(i,t))}function Mt(t,e){return(t%e+e)%e}function bt(t,e,i){return(1-i)*t+i*e}function St(t){return 0==(t&t-1)&&0!==t}function wt(t){return Math.pow(2,Math.ceil(Math.log(t)/Math.LN2))}function Tt(t){return Math.pow(2,Math.floor(Math.log(t)/Math.LN2))}function At(t,e){switch(e.constructor){case Float32Array:return t;case Uint16Array:return t/65535;case Uint8Array:return t/255;case Int16Array:return Math.max(t/32767,-1);case Int8Array:return Math.max(t/127,-1);default:throw new Error("Invalid component type.")}}function Et(t,e){switch(e.constructor){case Float32Array:return t;case Uint16Array:return Math.round(65535*t);case Uint8Array:return Math.round(255*t);case Int16Array:return Math.round(32767*t);case Int8Array:return Math.round(127*t);default:throw new Error("Invalid component type.")}}var Ct=Object.freeze({__proto__:null,DEG2RAD:vt,RAD2DEG:xt,generateUUID:_t,clamp:yt,euclideanModulo:Mt,mapLinear:function(t,e,i,n,r){return n+(t-e)*(r-n)/(i-e)},inverseLerp:function(t,e,i){return t!==e?(i-t)/(e-t):0},lerp:bt,damp:function(t,e,i,n){return bt(t,e,1-Math.exp(-i*n))},pingpong:function(t,e=1){return e-Math.abs(Mt(t,2*e)-e)},smoothstep:function(t,e,i){return t<=e?0:t>=i?1:(t=(t-e)/(i-e))*t*(3-2*t)},smootherstep:function(t,e,i){return t<=e?0:t>=i?1:(t=(t-e)/(i-e))*t*t*(t*(6*t-15)+10)},randInt:function(t,e){return t+Math.floor(Math.random()*(e-t+1))},randFloat:function(t,e){return t+Math.random()*(e-t)},randFloatSpread:function(t){return t*(.5-Math.random())},seededRandom:function(t){void 0!==t&&(gt=t);let e=gt+=1831565813;return e=Math.imul(e^e>>>15,1|e),e^=e+Math.imul(e^e>>>7,61|e),((e^e>>>14)>>>0)/4294967296},degToRad:function(t){return t*vt},radToDeg:function(t){return t*xt},isPowerOfTwo:St,ceilPowerOfTwo:wt,floorPowerOfTwo:Tt,setQuaternionFromProperEuler:function(t,e,i,n,r){const s=Math.cos,a=Math.sin,o=s(i/2)Line truncated
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
# -*- 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)
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,97 @@
|
||||
# -*- 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
|
||||
from django.db.models import Q, Case, F, When, Value
|
||||
from django.db.models.functions import Concat
|
||||
|
||||
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_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)
|
||||
@@ -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/
|
||||
|
||||
@@ -690,4 +714,8 @@ SITH_FRONT_DEP_VERSIONS = {
|
||||
"https://github.com/getsentry/sentry-javascript/": "4.0.6",
|
||||
"https://github.com/jhuckaby/webcamjs/": "1.0.0",
|
||||
"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",
|
||||
}
|
||||
@@ -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")),
|
||||
|
||||
Reference in new issue
Block a user