mirror of
https://github.com/ae-utbm/sith.git
synced 2024-12-22 07:41:14 +00:00
Mise à jour de septembre 2023 (#659)
* integration of 3D secure v2 for eboutic bank payment * edit yml to avoid git conflict when deploying on test * escape html characters on xml (#505) * Change country id to ISO 3166 1 numeric for 3DSV2 (#510) * remove useless tests * Fix le panier de l'Eboutic pour Safari (#518) Co-authored-by: Théo DURR <git@theodurr.fr> Co-authored-by: thomas girod <56346771+imperosol@users.noreply.github.com> * update some dependencies (#523) * [Eboutic] Fix double quote issue & improved user experience on small screen (#522) * Fix #511 Regex issue with escaped double quotes * Fix basket being when reloading the page (when cookie != "") + Added JSDoc + Cleaned some code * Fix #509 Improved user experience on small screens * Fix css class not being added back when reloading page * CSS Fixes (see description) + Fixed overlaping item title with the cart emoji on small screen + Fixed minimal size of the basket on small screen (full width) * Added darkened background circle to items with no image * Fix issue were the basket could be None * Edited CSS to have bette img ratio & the 🛒 icon Adapt, Improve, Overcome * Moved basket down on small screen size * enhance admin pages * update documentation * Update doc/about/tech.rst Co-authored-by: Julien Constant <49886317+Juknum@users.noreply.github.com> * remove csrf_token * Fix 3DSv2 implementation (#542) * Fixed wrong HMAC signature generation * Fix xml du panier Co-authored-by: Julien Constant <julienconstant190@gmail.com> * [FIX] 3DSv2 - Echappement du XML et modif tables (#543) * Fixed wrong HMAC signature generation * Updated migration files Co-authored-by: Julien Constant <julienconstant190@gmail.com> * Update doc/about/tech.rst * Update doc/start/install.rst * Updated lock file according to pyproject * unify account_id creation * upgrade re_path to path (#533) * redirect directly on counter if user is barman * Passage de vue à Alpine pour les comptoirs (#561) Vue, c'est cool, mais avec Django c'est un peu chiant à utiliser. Alpine a l'avantage d'être plus léger et d'avoir une syntaxe qui ne ressemble pas à celle de Jinja (ce qui évite d'avoir à mettre des {% raw %} partout). * resolved importError (#565) * Add galaxy (#562) * style.scss: lint * style.scss: add 'th' padding * core: populate: add much more data for development * Add galaxy * repair user merging tool (#498) * Disabled galaxy feature (only visually) * Disabled Galaxy button & Removed 404 exception display * Update 404.jinja * Fixed broken test * Added eurocks links to eboutic * fix typo * fix wording Co-authored-by: Théo DURR <git@theodurr.fr> * Edited unit tests This test caused a breach in security due to the alert block displaying sensitive data. * Repair NaN bug for autocomplete on counter click * remove-useless-queries-counter-stats (#519) * Amélioration des pages utilisateurs pour les petits écrans (#578, #520) - Refonte de l'organisation des pages utilisateurs (principalement du front) - Page des parrains/fillots - Page d'édition du profil - Page du profil - Page des outils - Page des préférences - Page des stats utilisateurs - Refonte du CSS / organisation de la navbar principale (en haut de l'écran) - Refonte du CSS de la navbar bleu clair (le menu) - Refonte du CSS du SAS : - Page de photo - Page d'albums * Added GA/Clubs Google Calendar to main page (#585) * Added GA/Clubs google calendar to main page * Made tables full width * Create dependabot.yml (#587) * Bump django from 3.2.16 to 3.2.18 (#574) * [CSS] Follow up of #578 (#589) * [FIX] Broken link in readme and license fix (& update) (#591) * Fixes pour la mise à jour de mars (#598) * Fix problème de cache dans le SAS & améliore le CSS du SAS Co-authored-by: Bartuccio Antoine <klmp200@users.noreply.github.com> * Fixes & améliorations du nouveau CSS (#616) * [UPDATE] Bump sentry-sdk from 1.12.1 to 1.19.1 (#620) * [FIX] Fixes supplémentaires pour la màj de mars (#622) - Les photos de l'onglet de la page utilisateur utilise désormais leur version thumbnail au lieu de leur version HD - Une des classes du CSS du SAS a été renommée car elle empiétait sur une class de la navbar - Le profil utilisateur a été revu pour ajouter plus d'espacement entre le tableau des cotisations et le numéro de cotisants - Les images de forum & blouse sont de nouveau cliquable pour les afficher en grands - Sur mobile, lorsqu'on cliquait sur le premier élément de la navbar, ce dernier avait un overlay avec des angles arrondis - Sur mobile, les utilisateurs avec des images de profils non carrées dépassait dans l'onglet Famille * [UPDATE] Bump dict2xml from 1.7.2 to 1.7.3 (#592) Bumps [dict2xml](https://github.com/delfick/python-dict2xml) from 1.7.2 to 1.7.3. - [Release notes](https://github.com/delfick/python-dict2xml/releases) - [Commits](https://github.com/delfick/python-dict2xml/compare/release-1.7.2...release-1.7.3) --- updated-dependencies: - dependency-name: dict2xml dependency-type: direct:production update-type: version-update:semver-patch ... * [UPDATE] Bump django-debug-toolbar from 3.8.1 to 4.0.0 (#593) Bumps [django-debug-toolbar](https://github.com/jazzband/django-debug-toolbar) from 3.8.1 to 4.0.0. - [Release notes](https://github.com/jazzband/django-debug-toolbar/releases) - [Changelog](https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst) - [Commits](https://github.com/jazzband/django-debug-toolbar/compare/3.8.1...4.0.0) --- updated-dependencies: - dependency-name: django-debug-toolbar dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * [UPDATE] Bump cryptography from 37.0.4 to 40.0.1 (#594) * [UPDATE] Bump cryptography from 37.0.4 to 40.0.1 Bumps [cryptography](https://github.com/pyca/cryptography) from 37.0.4 to 40.0.1. - [Release notes](https://github.com/pyca/cryptography/releases) - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/37.0.4...40.0.1) --- updated-dependencies: - dependency-name: cryptography dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> * Updated pyOpenSSL to match cryptography requirements --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Julien Constant <julienconstant190@gmail.com> * Mise à jour de Black vers la version 23.3 (#629) * update link for poetry install * [UPDATE] Bump django-countries from 7.5 to 7.5.1 (#624) Bumps [django-countries](https://github.com/SmileyChris/django-countries) from 7.5 to 7.5.1. - [Release notes](https://github.com/SmileyChris/django-countries/releases) - [Changelog](https://github.com/SmileyChris/django-countries/blob/main/CHANGES.rst) - [Commits](https://github.com/SmileyChris/django-countries/compare/v7.5...v7.5.1) --- updated-dependencies: - dependency-name: django-countries dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * [UPDATE] Bump sentry-sdk from 1.19.1 to 1.21.0 Bumps [sentry-sdk](https://github.com/getsentry/sentry-python) from 1.19.1 to 1.21.0. - [Release notes](https://github.com/getsentry/sentry-python/releases) - [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md) - [Commits](https://github.com/getsentry/sentry-python/compare/1.19.1...1.21.0) --- updated-dependencies: - dependency-name: sentry-sdk dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> * Speed up tests (#638) * Better usage of cache for groups and clubs related operations (#634) * Better usage of cache for group retrieval * Cache clearing on object deletion or update * replace signals by save and delete override * add is_anonymous check in is_owned_by Add in many is_owned_by(self, user) methods that user is not anonymous. Since many of those functions do db queries, this should reduce a little bit the load of the db. * Stricter usage of User.is_in_group Constrain the parameters that can be passed to the function to make sure only a str or an int can be used. Also force to explicitly specify if the group id or the group name is used. * write test and correct bugs * remove forgotten populate commands * Correct test * [FIX] Correction de bugs (#617) * Fix #600 * Fix #602 * Fixes & améliorations du nouveau CSS (#616) * Fix #604 * should fix #605 * Fix #608 * Update core/views/site.py Co-Authored-By: thomas girod <56346771+imperosol@users.noreply.github.com> * Added back the permission denied * Should fix #609 * Fix failing test when 2 user are merged * Should fix #610 * Should fix #627 * Should fix #109 Block les URLs suivantes lorsque le fichier se trouve dans le dir `profiles` ou `SAS` : - `/file/<id>/` - `/file/<id>/[delete|prop|edit]` > Les urls du SAS restent accessiblent pour les roots & les admins SAS > Les urls de profiles sont uniquement accessiblent aux roots * Fix root dir of SAS being unnaccessible for sas admins ⚠️ need to edit the SAS directory & save it (no changes required in sas directory properties) * Remove overwritten code * Should fix duplicated albums in user profile (wtf) * Fix typo * Extended profiles picture access to board members * Should fix #607 * Fix keyboard navigation not working properly * Fix user tagged pictures section inside python rather than in the template * Update utils.py * Apply suggested changes * Fix #604 * Fix #608 * Added back the permission denied * Should fix duplicated albums in user profile (wtf) * Fix user tagged pictures section inside python rather than in the template * Apply suggested changes --------- Co-authored-by: thomas girod <56346771+imperosol@users.noreply.github.com> * Remove duplicated css * Galaxy improvements (#628) * galaxy: improve logging and performance reporting * galaxy: add a full galaxy state test * galaxy: optimize user self score computation * galaxy: add 'generate_galaxy_test_data' command for development at scale * galaxy: big refactor Main changes: - Multiple Galaxy objects can now exist at the same time in DB. This allows for ruling a new galaxy while still displaying the old one. - The criteria to quickly know whether a user is a possible citizen is now a simple query on picture count. This avoids a very complicated query to database, that could often result in huge working memory load. With this change, it should be possible to run the galaxy even on a vanilla Postgres that didn't receive fine tuning for the Sith's galaxy. * galaxy: template: make the galaxy graph work and be usable with a lot of stars - Display focused star and its connections clearly - Display star label faintly by default for other stars to avoid overloading the graph - Hide non-focused lanes - Avoid clicks on non-highlighted, too far stars - Make the canva adapt its width to initial screen size, doesn't work dynamically * galaxy: better docstrings * galaxy: use bulk_create whenever possible This is a big performance gain, especially for the tests. Examples: ---- `./manage.py test galaxy.tests.GalaxyTest.test_full_galaxy_state` Measurements averaged over 3 run on *my machine*™: Before: 2min15s After: 1m41s ---- `./manage.py generate_galaxy_test_data --user-pack-count 1` Before: 48s After: 25s ---- `./manage.py rule_galaxy` (for 600 citizen, corresponding to 1 user-pack) Before: 14m4s After: 12m34s * core: populate: use a less ambiguous 'timezone.now()' When running the tests around midnight, the day is changing, leading to some values being offset to the next day depending on the timezone, and making some tests to fail. This ensure to use a less ambiguous `now` when populating the database. * write more extensive documentation - add documentation to previously documented classes and functions and refactor some of the documented one, in accordance to the PEP257 and ReStructuredText standards ; - add some type hints ; - use a NamedTuple for the `Galaxy.compute_users_score` method instead of a raw tuple. Also change a little bit the logic in the function which call the latter ; - add some additional parameter checks on a few functions ; - change a little bit the logic of the log level setting for the galaxy related commands. * galaxy: tests: split Model and View for more efficient data usage --------- Co-authored-by: maréchal <thgirod@hotmail.com> * [UPDATE] Bump libsass from 0.21.0 to 0.22.0 (#640) Bumps [libsass](https://github.com/sass/libsass-python) from 0.21.0 to 0.22.0. - [Release notes](https://github.com/sass/libsass-python/releases) - [Changelog](https://github.com/sass/libsass-python/blob/main/docs/changes.rst) - [Commits](https://github.com/sass/libsass-python/compare/0.21.0...0.22.0) --- updated-dependencies: - dependency-name: libsass dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * [FIX] Fix cached groups (#647) * Bump sqlparse from 0.4.3 to 0.4.4 (#645) Bumps [sqlparse](https://github.com/andialbrecht/sqlparse) from 0.4.3 to 0.4.4. - [Release notes](https://github.com/andialbrecht/sqlparse/releases) - [Changelog](https://github.com/andialbrecht/sqlparse/blob/master/CHANGELOG) - [Commits](https://github.com/andialbrecht/sqlparse/compare/0.4.3...0.4.4) --- updated-dependencies: - dependency-name: sqlparse dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * [UPDATE] Bump django-ordered-model from 3.6 to 3.7.4 (#625) Bumps [django-ordered-model](https://github.com/django-ordered-model/django-ordered-model) from 3.6 to 3.7.4. - [Release notes](https://github.com/django-ordered-model/django-ordered-model/releases) - [Changelog](https://github.com/django-ordered-model/django-ordered-model/blob/master/CHANGES.md) - [Commits](https://github.com/django-ordered-model/django-ordered-model/compare/3.6...3.7.4) --- updated-dependencies: - dependency-name: django-ordered-model dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Fix immutable default variable in `get_start_of_semester` (#656) Le serveur ne percevait pas le changement de semestre, parce que la valeur par défaut passée à la fonction `get_start_of_semester()` était une fonction appelée une seule fois, lors du lancement du serveur. Bref, c'était ça : https://beta.ruff.rs/docs/rules/function-call-in-default-argument/ --------- Co-authored-by: imperosol <thgirod@hotmail.com> * Add missing method on AnonymousUser (#649) --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Thomas Girod <thgirod@hotmail.com> Co-authored-by: thomas girod <56346771+imperosol@users.noreply.github.com> Co-authored-by: Théo DURR <git@theodurr.fr> Co-authored-by: Skia <skia@hya.sk> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Bartuccio Antoine <klmp200@users.noreply.github.com>
This commit is contained in:
parent
4f9d5ae7b1
commit
b9298792ae
1
.gitignore
vendored
1
.gitignore
vendored
@ -11,6 +11,7 @@ dist/
|
||||
env/
|
||||
doc/html
|
||||
data/
|
||||
galaxy/test_galaxy_state.json
|
||||
/static/
|
||||
sith/settings_custom.py
|
||||
sith/search_indexes/
|
||||
|
27
com/tests.py
27
com/tests.py
@ -159,6 +159,33 @@ class NewsTest(TestCase):
|
||||
self.assertFalse(self.new.is_owned_by(self.anonymous))
|
||||
self.assertFalse(self.new.is_owned_by(self.sli))
|
||||
|
||||
def test_news_viewer(self):
|
||||
"""
|
||||
Test that moderated news can be viewed by anyone
|
||||
and not moderated news only by com admins
|
||||
"""
|
||||
# by default a news isn't moderated
|
||||
self.assertTrue(self.new.can_be_viewed_by(self.com_admin))
|
||||
self.assertFalse(self.new.can_be_viewed_by(self.sli))
|
||||
self.assertFalse(self.new.can_be_viewed_by(self.anonymous))
|
||||
self.assertFalse(self.new.can_be_viewed_by(self.author))
|
||||
|
||||
self.new.is_moderated = True
|
||||
self.new.save()
|
||||
self.assertTrue(self.new.can_be_viewed_by(self.com_admin))
|
||||
self.assertTrue(self.new.can_be_viewed_by(self.sli))
|
||||
self.assertTrue(self.new.can_be_viewed_by(self.anonymous))
|
||||
self.assertTrue(self.new.can_be_viewed_by(self.author))
|
||||
|
||||
def test_news_editor(self):
|
||||
"""
|
||||
Test that only com admins can edit news
|
||||
"""
|
||||
self.assertTrue(self.new.can_be_edited_by(self.com_admin))
|
||||
self.assertFalse(self.new.can_be_edited_by(self.sli))
|
||||
self.assertFalse(self.new.can_be_edited_by(self.anonymous))
|
||||
self.assertFalse(self.new.can_be_edited_by(self.author))
|
||||
|
||||
|
||||
class WeekmailArticleTest(TestCase):
|
||||
@classmethod
|
||||
|
@ -208,6 +208,8 @@ Welcome to the wiki page!
|
||||
|
||||
# Here we add a lot of test datas, that are not necessary for the Sith, but that provide a basic development environment
|
||||
if not options["prod"]:
|
||||
self.now = timezone.now().replace(hour=12)
|
||||
|
||||
# Adding user Skia
|
||||
skia = User(
|
||||
username="skia",
|
||||
@ -914,6 +916,7 @@ Welcome to the wiki page!
|
||||
Membership(
|
||||
user=comunity,
|
||||
club=bar_club,
|
||||
start_date=self.now,
|
||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||
).save()
|
||||
# Adding user tutu
|
||||
@ -1072,7 +1075,7 @@ Welcome to the wiki page!
|
||||
ForumTopic(forum=hall)
|
||||
|
||||
# News
|
||||
friday = timezone.now()
|
||||
friday = self.now
|
||||
while friday.weekday() != 4:
|
||||
friday += timedelta(hours=6)
|
||||
friday.replace(hour=20, minute=0, second=0)
|
||||
@ -1090,8 +1093,8 @@ Welcome to the wiki page!
|
||||
n.save()
|
||||
NewsDate(
|
||||
news=n,
|
||||
start_date=timezone.now() + timedelta(hours=70),
|
||||
end_date=timezone.now() + timedelta(hours=72),
|
||||
start_date=self.now + timedelta(hours=70),
|
||||
end_date=self.now + timedelta(hours=72),
|
||||
).save()
|
||||
n = News(
|
||||
title="Repas barman",
|
||||
@ -1107,8 +1110,8 @@ Welcome to the wiki page!
|
||||
n.save()
|
||||
NewsDate(
|
||||
news=n,
|
||||
start_date=timezone.now() + timedelta(hours=72),
|
||||
end_date=timezone.now() + timedelta(hours=84),
|
||||
start_date=self.now + timedelta(hours=72),
|
||||
end_date=self.now + timedelta(hours=84),
|
||||
).save()
|
||||
n = News(
|
||||
title="Repas fromager",
|
||||
@ -1123,8 +1126,8 @@ Welcome to the wiki page!
|
||||
n.save()
|
||||
NewsDate(
|
||||
news=n,
|
||||
start_date=timezone.now() + timedelta(hours=96),
|
||||
end_date=timezone.now() + timedelta(hours=100),
|
||||
start_date=self.now + timedelta(hours=96),
|
||||
end_date=self.now + timedelta(hours=100),
|
||||
).save()
|
||||
n = News(
|
||||
title="SdF",
|
||||
@ -1140,7 +1143,7 @@ Welcome to the wiki page!
|
||||
NewsDate(
|
||||
news=n,
|
||||
start_date=friday + timedelta(hours=24 * 7 + 1),
|
||||
end_date=timezone.now() + timedelta(hours=24 * 7 + 9),
|
||||
end_date=self.now + timedelta(hours=24 * 7 + 9),
|
||||
).save()
|
||||
# Weekly
|
||||
n = News(
|
||||
@ -1271,28 +1274,28 @@ Welcome to the wiki page!
|
||||
club=troll,
|
||||
role=9,
|
||||
description="Padawan Troll",
|
||||
start_date=timezone.now() - timedelta(days=17),
|
||||
start_date=self.now - timedelta(days=17),
|
||||
).save()
|
||||
Membership(
|
||||
user=krophil,
|
||||
club=troll,
|
||||
role=10,
|
||||
description="Maitre Troll",
|
||||
start_date=timezone.now() - timedelta(days=200),
|
||||
start_date=self.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),
|
||||
start_date=self.now - timedelta(days=400),
|
||||
end_date=self.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),
|
||||
start_date=self.now - timedelta(days=200),
|
||||
end_date=self.now - timedelta(days=100),
|
||||
).save()
|
||||
|
@ -810,6 +810,10 @@ class AnonymousUser(AuthAnonymousUser):
|
||||
def can_edit(self, obj):
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_com_admin(self):
|
||||
return False
|
||||
|
||||
def can_view(self, obj):
|
||||
if (
|
||||
hasattr(obj, "view_groups")
|
||||
|
@ -1031,7 +1031,7 @@ thead {
|
||||
}
|
||||
|
||||
tbody > tr {
|
||||
&:nth-child(even) {
|
||||
&:nth-child(even):not(.highlight) {
|
||||
background: $primary-neutral-light-color;
|
||||
}
|
||||
&.clickable:hover {
|
||||
|
@ -15,17 +15,18 @@
|
||||
#
|
||||
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from datetime import date, timedelta
|
||||
|
||||
import freezegun
|
||||
from django.core.cache import cache
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from django.core.management import call_command
|
||||
from django.utils.timezone import now
|
||||
|
||||
from club.models import Membership
|
||||
from core.models import User, Group, Page, AnonymousUser
|
||||
from core.markdown import markdown
|
||||
from core.models import AnonymousUser, Group, Page, User
|
||||
from core.utils import get_semester_code, get_start_of_semester
|
||||
from sith import settings
|
||||
|
||||
"""
|
||||
@ -617,3 +618,75 @@ class UserIsInGroupTest(TestCase):
|
||||
returns False
|
||||
"""
|
||||
self.assertFalse(self.skia.is_in_group(name="This doesn't exist"))
|
||||
|
||||
|
||||
class DateUtilsTest(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.autumn_month = settings.SITH_SEMESTER_START_AUTUMN[0]
|
||||
cls.autumn_day = settings.SITH_SEMESTER_START_AUTUMN[1]
|
||||
cls.spring_month = settings.SITH_SEMESTER_START_SPRING[0]
|
||||
cls.spring_day = settings.SITH_SEMESTER_START_SPRING[1]
|
||||
|
||||
cls.autumn_semester_january = date(2025, 1, 4)
|
||||
cls.autumn_semester_september = date(2024, 9, 4)
|
||||
cls.autumn_first_day = date(2024, cls.autumn_month, cls.autumn_day)
|
||||
|
||||
cls.spring_semester_march = date(2023, 3, 4)
|
||||
cls.spring_first_day = date(2023, cls.spring_month, cls.spring_day)
|
||||
|
||||
def test_get_semester(self):
|
||||
"""
|
||||
Test that the get_semester function returns the correct semester string
|
||||
"""
|
||||
self.assertEqual(get_semester_code(self.autumn_semester_january), "A24")
|
||||
self.assertEqual(get_semester_code(self.autumn_semester_september), "A24")
|
||||
self.assertEqual(get_semester_code(self.autumn_first_day), "A24")
|
||||
|
||||
self.assertEqual(get_semester_code(self.spring_semester_march), "P23")
|
||||
self.assertEqual(get_semester_code(self.spring_first_day), "P23")
|
||||
|
||||
def test_get_start_of_semester_fixed_date(self):
|
||||
"""
|
||||
Test that the get_start_of_semester correctly the starting date of the semester.
|
||||
"""
|
||||
automn_2024 = date(2024, self.autumn_month, self.autumn_day)
|
||||
self.assertEqual(
|
||||
get_start_of_semester(self.autumn_semester_january), automn_2024
|
||||
)
|
||||
self.assertEqual(
|
||||
get_start_of_semester(self.autumn_semester_september), automn_2024
|
||||
)
|
||||
self.assertEqual(get_start_of_semester(self.autumn_first_day), automn_2024)
|
||||
|
||||
spring_2023 = date(2023, self.spring_month, self.spring_day)
|
||||
self.assertEqual(get_start_of_semester(self.spring_semester_march), spring_2023)
|
||||
self.assertEqual(get_start_of_semester(self.spring_first_day), spring_2023)
|
||||
|
||||
def test_get_start_of_semester_today(self):
|
||||
"""
|
||||
Test that the get_start_of_semester returns the start of the current semester
|
||||
when no date is given
|
||||
"""
|
||||
with freezegun.freeze_time(self.autumn_semester_september):
|
||||
self.assertEqual(get_start_of_semester(), self.autumn_first_day)
|
||||
|
||||
with freezegun.freeze_time(self.spring_semester_march):
|
||||
self.assertEqual(get_start_of_semester(), self.spring_first_day)
|
||||
|
||||
def test_get_start_of_semester_changing_date(self):
|
||||
"""
|
||||
Test that the get_start_of_semester correctly gives the starting date of the semester,
|
||||
even when the semester changes while the server isn't restarted.
|
||||
"""
|
||||
spring_2023 = date(2023, self.spring_month, self.spring_day)
|
||||
autumn_2023 = date(2023, self.autumn_month, self.autumn_day)
|
||||
mid_spring = spring_2023 + timedelta(days=45)
|
||||
mid_autumn = autumn_2023 + timedelta(days=45)
|
||||
|
||||
with freezegun.freeze_time(mid_spring) as frozen_time:
|
||||
self.assertEqual(get_start_of_semester(), spring_2023)
|
||||
|
||||
# forward time to the middle of the next semester
|
||||
frozen_time.move_to(mid_autumn)
|
||||
self.assertEqual(get_start_of_semester(), autumn_2023)
|
||||
|
@ -15,20 +15,19 @@
|
||||
#
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import re
|
||||
|
||||
# Image utils
|
||||
|
||||
from io import BytesIO
|
||||
import subprocess
|
||||
from datetime import date
|
||||
|
||||
from PIL import ExifTags
|
||||
# Image utils
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
|
||||
import PIL
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from PIL import ExifTags
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def get_git_revision_short_hash() -> str:
|
||||
@ -44,34 +43,54 @@ def get_git_revision_short_hash() -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def get_start_of_semester(d=date.today()):
|
||||
def get_start_of_semester(today: Optional[date] = None) -> date:
|
||||
"""
|
||||
This function computes the start date of the semester with respect to the given date (default is today),
|
||||
and the start date given in settings.SITH_START_DATE.
|
||||
It takes the nearest past start date.
|
||||
Exemples: with SITH_START_DATE = (8, 15)
|
||||
Today -> Start date
|
||||
2015-03-17 -> 2015-02-15
|
||||
2015-01-11 -> 2014-08-15
|
||||
Return the date of the start of the semester of the given date.
|
||||
If no date is given, return the start date of the current semester.
|
||||
|
||||
The current semester is computed as follows:
|
||||
|
||||
- If the date is between 15/08 and 31/12 => Autumn semester.
|
||||
- If the date is between 01/01 and 15/02 => Autumn semester of the previous year.
|
||||
- If the date is between 15/02 and 15/08 => Spring semester
|
||||
|
||||
:param today: the date to use to compute the semester. If None, use today's date.
|
||||
:return: the date of the start of the semester
|
||||
"""
|
||||
today = d
|
||||
year = today.year
|
||||
start = date(year, settings.SITH_START_DATE[0], settings.SITH_START_DATE[1])
|
||||
start2 = start.replace(month=(start.month + 6) % 12)
|
||||
spring, autumn = min(start, start2), max(start, start2)
|
||||
if today > autumn: # autumn semester
|
||||
if today is None:
|
||||
today = timezone.now().date()
|
||||
|
||||
autumn = date(today.year, *settings.SITH_SEMESTER_START_AUTUMN)
|
||||
spring = date(today.year, *settings.SITH_SEMESTER_START_SPRING)
|
||||
|
||||
if today >= autumn: # between 15/08 (included) and 31/12 -> autumn semester
|
||||
return autumn
|
||||
if today > spring: # spring semester
|
||||
if today >= spring: # between 15/02 (included) and 15/08 -> spring semester
|
||||
return spring
|
||||
return autumn.replace(year=year - 1) # autumn semester of last year
|
||||
# between 01/01 and 15/02 -> autumn semester of the previous year
|
||||
return autumn.replace(year=autumn.year - 1)
|
||||
|
||||
|
||||
def get_semester(d=date.today()):
|
||||
def get_semester_code(d: Optional[date] = None) -> str:
|
||||
"""
|
||||
Return the semester code of the given date.
|
||||
If no date is given, return the semester code of the current semester.
|
||||
|
||||
The semester code is an upper letter (A for autumn, P for spring),
|
||||
followed by the last two digits of the year.
|
||||
For example, the autumn semester of 2018 is "A18".
|
||||
|
||||
:param d: the date to use to compute the semester. If None, use today's date.
|
||||
:return: the semester code corresponding to the given date
|
||||
"""
|
||||
if d is None:
|
||||
d = timezone.now().date()
|
||||
|
||||
start = get_start_of_semester(d)
|
||||
if start.month <= 6:
|
||||
return "P" + str(start.year)[-2:]
|
||||
else:
|
||||
|
||||
if (start.month, start.day) == settings.SITH_SEMESTER_START_AUTUMN:
|
||||
return "A" + str(start.year)[-2:]
|
||||
return "P" + str(start.year)[-2:]
|
||||
|
||||
|
||||
def file_exist(path):
|
||||
|
@ -15,7 +15,7 @@
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Tuple
|
||||
from typing import Tuple, Optional
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import F, Value, Sum, QuerySet, OuterRef, Exists
|
||||
@ -536,7 +536,7 @@ class Counter(models.Model):
|
||||
.order_by("-perm_sum")
|
||||
)
|
||||
|
||||
def get_top_customers(self, since=get_start_of_semester()) -> QuerySet:
|
||||
def get_top_customers(self, since: Optional[date] = None) -> QuerySet:
|
||||
"""
|
||||
Return a QuerySet querying the money spent by customers of this counter
|
||||
since the specified date, ordered by descending amount of money spent.
|
||||
@ -546,6 +546,8 @@ class Counter(models.Model):
|
||||
- the nickname of the customer
|
||||
- the amount of money spent by the customer
|
||||
"""
|
||||
if since is None:
|
||||
since = get_start_of_semester()
|
||||
return (
|
||||
self.sellings.filter(date__gte=since)
|
||||
.annotate(
|
||||
@ -557,7 +559,8 @@ class Counter(models.Model):
|
||||
)
|
||||
.annotate(nickname=F("customer__user__nick_name"))
|
||||
.annotate(promo=F("customer__user__promo"))
|
||||
.values("customer__user", "name", "nickname")
|
||||
.annotate(user=F("customer__user"))
|
||||
.values("user", "promo", "name", "nickname")
|
||||
.annotate(
|
||||
selling_sum=Sum(
|
||||
F("unit_price") * F("quantity"), output_field=CurrencyField()
|
||||
@ -567,15 +570,17 @@ class Counter(models.Model):
|
||||
.order_by("-selling_sum")
|
||||
)
|
||||
|
||||
def get_total_sales(self, since=get_start_of_semester()) -> CurrencyField:
|
||||
def get_total_sales(self, since=None) -> CurrencyField:
|
||||
"""
|
||||
Compute and return the total turnover of this counter
|
||||
since the date specified in parameter (by default, since the start of the current
|
||||
semester)
|
||||
:param since: timestamp from which to perform the calculation
|
||||
:type since: datetime | date
|
||||
:type since: datetime | date | None
|
||||
:return: Total revenue earned at this counter
|
||||
"""
|
||||
if since is None:
|
||||
since = get_start_of_semester()
|
||||
if isinstance(since, date):
|
||||
since = datetime.combine(since, datetime.min.time())
|
||||
total = self.sellings.filter(date__gte=since).aggregate(
|
||||
|
@ -11,7 +11,9 @@
|
||||
|
||||
{% block content %}
|
||||
<h3>{% trans counter_name=counter %}{{ counter_name }} stats{% endtrans %}</h3>
|
||||
<h4>{% trans counter_name=counter.name %}Top 100 {{ counter_name }}{% endtrans %}</h4>
|
||||
<h4>
|
||||
{% trans counter_name=counter.name %}Top 100 {{ counter_name }}{% endtrans %} ({{ current_semester }})
|
||||
</h4>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@ -35,7 +37,9 @@
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4>{% trans counter_name=counter.name %}Top 100 barman {{ counter_name }}{% endtrans %}</h4>
|
||||
<h4>
|
||||
{% trans counter_name=counter.name %}Top 100 barman {{ counter_name }}{% endtrans %} ({{ current_semester }})
|
||||
</h4>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
|
@ -13,6 +13,7 @@
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
from datetime import date, timedelta
|
||||
import json
|
||||
import re
|
||||
import string
|
||||
@ -322,42 +323,49 @@ class CounterStatsTest(TestCase):
|
||||
Test the result of Counter.get_top_customers() is correct
|
||||
"""
|
||||
top = iter(self.counter.get_top_customers())
|
||||
self.assertEqual(
|
||||
next(top),
|
||||
expected_results = [
|
||||
{
|
||||
"customer__user": self.sli.id,
|
||||
"user": self.sli.id,
|
||||
"name": f"{self.sli.first_name} {self.sli.last_name}",
|
||||
"promo": self.sli.promo,
|
||||
"nickname": self.sli.nick_name,
|
||||
"selling_sum": 2000,
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
next(top),
|
||||
{
|
||||
"customer__user": self.skia.id,
|
||||
"user": self.skia.id,
|
||||
"name": f"{self.skia.first_name} {self.skia.last_name}",
|
||||
"promo": self.skia.promo,
|
||||
"nickname": self.skia.nick_name,
|
||||
"selling_sum": 1000,
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
next(top),
|
||||
{
|
||||
"customer__user": self.krophil.id,
|
||||
"user": self.krophil.id,
|
||||
"name": f"{self.krophil.first_name} {self.krophil.last_name}",
|
||||
"promo": self.krophil.promo,
|
||||
"nickname": self.krophil.nick_name,
|
||||
"selling_sum": 100,
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
next(top),
|
||||
{
|
||||
"customer__user": self.root.id,
|
||||
"user": self.root.id,
|
||||
"name": f"{self.root.first_name} {self.root.last_name}",
|
||||
"promo": self.root.promo,
|
||||
"nickname": self.root.nick_name,
|
||||
"selling_sum": 2,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
for result in expected_results:
|
||||
self.assertEqual(
|
||||
next(top),
|
||||
{
|
||||
"user": result["user"],
|
||||
"name": result["name"],
|
||||
"promo": result["promo"],
|
||||
"nickname": result["nickname"],
|
||||
"selling_sum": result["selling_sum"],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertIsNone(next(top, None))
|
||||
|
||||
|
||||
|
@ -48,7 +48,7 @@ import pytz
|
||||
from datetime import timedelta, datetime
|
||||
from http import HTTPStatus
|
||||
|
||||
from core.utils import get_start_of_semester
|
||||
from core.utils import get_start_of_semester, get_semester_code
|
||||
from core.views import CanViewMixin, TabedViewMixin, CanEditMixin
|
||||
from core.views.forms import LoginForm
|
||||
from core.models import User
|
||||
@ -1354,13 +1354,14 @@ class CounterStatView(DetailView, CounterAdminMixin):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""Add stats to the context"""
|
||||
counter = self.object
|
||||
counter: Counter = self.object
|
||||
semester_start = get_start_of_semester()
|
||||
office_hours = counter.get_top_barmen()
|
||||
kwargs = super(CounterStatView, self).get_context_data(**kwargs)
|
||||
kwargs.update(
|
||||
{
|
||||
"counter": counter,
|
||||
"current_semester": get_semester_code(),
|
||||
"total_sellings": counter.get_total_sales(since=semester_start),
|
||||
"top_customers": counter.get_top_customers(since=semester_start)[:100],
|
||||
"top_barman": office_hours[:100],
|
||||
|
411
galaxy/management/commands/generate_galaxy_test_data.py
Normal file
411
galaxy/management/commands/generate_galaxy_test_data.py
Normal file
@ -0,0 +1,411 @@
|
||||
# -*- 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 warnings
|
||||
from typing import Final, Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import logging
|
||||
|
||||
from club.models import Club, Membership
|
||||
from core.models import User, Group, Page, SithFile
|
||||
from subscription.models import Subscription
|
||||
from sas.models import Album, Picture, PeoplePictureRelation
|
||||
|
||||
|
||||
RED_PIXEL_PNG: Final[bytes] = (
|
||||
b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52"
|
||||
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53"
|
||||
b"\xde\x00\x00\x00\x0c\x49\x44\x41\x54\x08\xd7\x63\xf8\xcf\xc0\x00"
|
||||
b"\x00\x03\x01\x01\x00\x18\xdd\x8d\xb0\x00\x00\x00\x00\x49\x45\x4e"
|
||||
b"\x44\xae\x42\x60\x82"
|
||||
)
|
||||
|
||||
USER_PACK_SIZE: Final[int] = 1000
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Procedurally generate representative data for developing the Galaxy"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.now = timezone.now().replace(hour=12)
|
||||
|
||||
self.users: Optional[list[User]] = None
|
||||
self.clubs: Optional[list[Club]] = None
|
||||
self.picts: Optional[list[Picture]] = None
|
||||
self.pictures_tags: Optional[list[PeoplePictureRelation]] = None
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--user-pack-count",
|
||||
help=f"Number of packs of {USER_PACK_SIZE} users to create",
|
||||
type=int,
|
||||
default=1,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--club-count", help="Number of clubs to create", type=int, default=50
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.logger = logging.getLogger("main")
|
||||
if options["verbosity"] < 0 or 2 < options["verbosity"]:
|
||||
warnings.warn("verbosity level should be between 0 and 2 included")
|
||||
|
||||
if options["verbosity"] == 2:
|
||||
self.logger.setLevel(logging.DEBUG)
|
||||
elif options["verbosity"] == 1:
|
||||
self.logger.setLevel(logging.INFO)
|
||||
else:
|
||||
self.logger.setLevel(logging.ERROR)
|
||||
|
||||
self.logger.info("The Galaxy is being populated by the Sith.")
|
||||
|
||||
self.logger.info("Cleaning old Galaxy population")
|
||||
Club.objects.filter(unix_name__startswith="galaxy-").delete()
|
||||
Group.objects.filter(name__startswith="galaxy-").delete()
|
||||
Page.objects.filter(name__startswith="galaxy-").delete()
|
||||
User.objects.filter(username__startswith="galaxy-").delete()
|
||||
Picture.objects.filter(name__startswith="galaxy-").delete()
|
||||
Album.objects.filter(name__startswith="galaxy-").delete()
|
||||
self.logger.info("Done. Populating a new Galaxy")
|
||||
|
||||
self.NB_USERS = options["user_pack_count"] * USER_PACK_SIZE
|
||||
self.NB_CLUBS = options["club_count"]
|
||||
|
||||
root = User.objects.filter(username="root").first()
|
||||
sas = SithFile.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)
|
||||
self.galaxy_album = Album.objects.create(
|
||||
name="galaxy-register-file",
|
||||
owner=root,
|
||||
is_moderated=True,
|
||||
is_in_sas=True,
|
||||
parent=sas,
|
||||
)
|
||||
|
||||
self.make_clubs()
|
||||
self.make_users()
|
||||
self.make_families()
|
||||
self.make_club_memberships()
|
||||
self.make_pictures()
|
||||
self.make_pictures_memberships()
|
||||
half_pack = USER_PACK_SIZE // 2
|
||||
for u in range(half_pack, self.NB_USERS, half_pack):
|
||||
self.make_important_citizen(u)
|
||||
|
||||
def make_clubs(self):
|
||||
"""
|
||||
Create all the clubs (:class:`club.models.Club`)
|
||||
and store them in `self.clubs` for fast access later.
|
||||
Don't create the meta groups (:class:`core.models.MetaGroup`)
|
||||
nor the pages of the clubs (:class:`core.models.Page`)
|
||||
"""
|
||||
self.clubs = []
|
||||
for i in range(self.NB_CLUBS):
|
||||
self.clubs.append(Club(unix_name=f"galaxy-club-{i}", name=f"club-{i}"))
|
||||
# We don't need to create corresponding groups here, as the Galaxy doesn't care about them
|
||||
Club.objects.bulk_create(self.clubs)
|
||||
self.clubs = Club.objects.filter(unix_name__startswith="galaxy-").all()
|
||||
|
||||
def make_users(self):
|
||||
"""
|
||||
Create all the users and store them in `self.users` for fast access later.
|
||||
|
||||
Also create a subscription for all the generated users.
|
||||
"""
|
||||
self.users = []
|
||||
for i in range(self.NB_USERS):
|
||||
u = User(
|
||||
username=f"galaxy-user-{i}",
|
||||
email=f"{i}@galaxy.test",
|
||||
first_name="Citizen",
|
||||
last_name=f"n°{i}",
|
||||
)
|
||||
self.logger.info(f"Creating {u}")
|
||||
self.users.append(u)
|
||||
User.objects.bulk_create(self.users)
|
||||
self.users = User.objects.filter(username__startswith="galaxy-").all()
|
||||
|
||||
# now that users are created, create their subscription
|
||||
subs = []
|
||||
for i in range(self.NB_USERS):
|
||||
u = self.users[i]
|
||||
self.logger.info(f"Registering {u}")
|
||||
subs.append(
|
||||
Subscription(
|
||||
member=u,
|
||||
subscription_start=Subscription.compute_start(
|
||||
self.now - timedelta(days=self.NB_USERS - i)
|
||||
),
|
||||
subscription_end=Subscription.compute_end(duration=2),
|
||||
)
|
||||
)
|
||||
Subscription.objects.bulk_create(subs)
|
||||
|
||||
def make_families(self):
|
||||
"""
|
||||
Generate the godfather/godchild relations for the users contained in :attr:`self.users`.
|
||||
|
||||
The :meth:`make_users` method must have been called beforehand.
|
||||
|
||||
This will iterate on all citizen after the 200th.
|
||||
Then it will take 14 other citizen among the previous 200
|
||||
(godfathers are usually older), and apply another
|
||||
heuristic to determine whether they should have a family link
|
||||
"""
|
||||
if self.users is None:
|
||||
raise RuntimeError(
|
||||
"The `make_users()` method must be called before `make_families()`"
|
||||
)
|
||||
for i in range(200, self.NB_USERS):
|
||||
godfathers = []
|
||||
for j in range(i - 200, i, 14): # this will loop 14 times (14² = 196)
|
||||
if (i / 10) % 10 == (i + j) % 10:
|
||||
u1 = self.users[i]
|
||||
u2 = self.users[j]
|
||||
self.logger.info(f"Making {u2} the godfather of {u1}")
|
||||
godfathers.append(u2)
|
||||
u1.godfathers.set(godfathers)
|
||||
|
||||
def make_club_memberships(self):
|
||||
"""
|
||||
Assign users to clubs and give them a role in a pseudo-random way.
|
||||
|
||||
The :meth:`make_users` and :meth:`make_clubs` methods
|
||||
must have been called beforehand.
|
||||
|
||||
Work by making multiples passes on all users to affect
|
||||
them some pseudo-random roles in some clubs.
|
||||
The multiple passes are useful to get some variations over who goes where.
|
||||
Each pass for each user has a chance to affect her to two different clubs,
|
||||
increasing a bit more the created chaos, while remaining purely deterministic.
|
||||
"""
|
||||
if self.users is None:
|
||||
raise RuntimeError(
|
||||
"The `make_users()` method must be called before `make_club_memberships()`"
|
||||
)
|
||||
if self.clubs is None:
|
||||
raise RuntimeError(
|
||||
"The `make_clubs()` method must be called before `make_club_memberships()`"
|
||||
)
|
||||
memberships = []
|
||||
for i in range(1, 11): # users can be in up to 20 clubs
|
||||
self.logger.info(f"Club membership, pass {i}")
|
||||
for uid in range(
|
||||
i, self.NB_USERS, i
|
||||
): # Pass #1 will make sure every user is at least in one club
|
||||
user = self.users[uid]
|
||||
club = self.clubs[(uid + i**2) % self.NB_CLUBS]
|
||||
|
||||
start = self.now - timedelta(
|
||||
days=(((self.NB_USERS - uid) * i) // 110)
|
||||
) # older users were in clubs before newer users
|
||||
end = start + timedelta(days=180) # about one semester
|
||||
self.logger.debug(
|
||||
f"Making {user} a member of club {club} from {start} to {end}"
|
||||
)
|
||||
memberships.append(
|
||||
Membership(
|
||||
user=user,
|
||||
club=club,
|
||||
role=(uid + i) % 10 + 1, # spread the different roles
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
)
|
||||
)
|
||||
|
||||
for uid in range(
|
||||
10 + i * 2, self.NB_USERS, 10 + i * 2
|
||||
): # Make a second affectation that will skip most users, to make a few citizen more important
|
||||
user = self.users[uid]
|
||||
club = self.clubs[(uid + i**3) % self.NB_CLUBS]
|
||||
|
||||
start = self.now - timedelta(
|
||||
days=(((self.NB_USERS - uid) * i) // 100)
|
||||
) # older users were in clubs before newer users
|
||||
end = start + timedelta(days=180) # about one semester
|
||||
self.logger.debug(
|
||||
f"Making {user} a member of club {club} from {start} to {end}"
|
||||
)
|
||||
memberships.append(
|
||||
Membership(
|
||||
user=user,
|
||||
club=club,
|
||||
role=((uid // 10) + i) % 10 + 1, # spread the different roles
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
)
|
||||
)
|
||||
Membership.objects.bulk_create(memberships)
|
||||
|
||||
def make_pictures(self):
|
||||
"""
|
||||
Create pictures for users to be tagged on later.
|
||||
|
||||
The :meth:`make_users` method must have been called beforehand.
|
||||
"""
|
||||
if self.users is None:
|
||||
raise RuntimeError(
|
||||
"The `make_users()` method must be called before `make_families()`"
|
||||
)
|
||||
self.picts = []
|
||||
# Create twice as many pictures as users
|
||||
for i in range(self.NB_USERS * 2):
|
||||
u = self.users[i % self.NB_USERS]
|
||||
self.logger.info(f"Making Picture {i // self.NB_USERS} for {u}")
|
||||
self.picts.append(
|
||||
Picture(
|
||||
owner=u,
|
||||
name=f"galaxy-picture {u} {i // self.NB_USERS}",
|
||||
is_moderated=True,
|
||||
is_folder=False,
|
||||
parent=self.galaxy_album,
|
||||
is_in_sas=True,
|
||||
file=ContentFile(RED_PIXEL_PNG),
|
||||
compressed=ContentFile(RED_PIXEL_PNG),
|
||||
thumbnail=ContentFile(RED_PIXEL_PNG),
|
||||
mime_type="image/png",
|
||||
size=len(RED_PIXEL_PNG),
|
||||
)
|
||||
)
|
||||
self.picts[i].file.name = self.picts[i].name
|
||||
self.picts[i].compressed.name = self.picts[i].name
|
||||
self.picts[i].thumbnail.name = self.picts[i].name
|
||||
Picture.objects.bulk_create(self.picts)
|
||||
self.picts = Picture.objects.filter(name__startswith="galaxy-").all()
|
||||
|
||||
def make_pictures_memberships(self):
|
||||
"""
|
||||
Assign users to pictures and make enough of them for our
|
||||
created users to be eligible for promotion as citizen.
|
||||
|
||||
See :meth:`galaxy.models.Galaxy.rule` for details on promotion to citizen.
|
||||
"""
|
||||
self.pictures_tags = []
|
||||
|
||||
# We don't want to handle limits, users in the middle will be far enough
|
||||
def _tag_neighbors(uid, neighbor_dist, pict_offset, pict_dist):
|
||||
u2 = self.users[uid - neighbor_dist]
|
||||
u3 = self.users[uid + neighbor_dist]
|
||||
self.pictures_tags += [
|
||||
PeoplePictureRelation(user=u2, picture=self.picts[uid + pict_offset]),
|
||||
PeoplePictureRelation(user=u3, picture=self.picts[uid + pict_offset]),
|
||||
PeoplePictureRelation(user=u2, picture=self.picts[uid - pict_dist]),
|
||||
PeoplePictureRelation(user=u3, picture=self.picts[uid - pict_dist]),
|
||||
PeoplePictureRelation(user=u2, picture=self.picts[uid + pict_dist]),
|
||||
PeoplePictureRelation(user=u3, picture=self.picts[uid + pict_dist]),
|
||||
]
|
||||
|
||||
for uid in range(200, self.NB_USERS - 200):
|
||||
u1 = self.users[uid]
|
||||
self.logger.info(f"Pictures of {u1}")
|
||||
self.pictures_tags += [
|
||||
PeoplePictureRelation(user=u1, picture=self.picts[uid]),
|
||||
PeoplePictureRelation(user=u1, picture=self.picts[uid - 14]),
|
||||
PeoplePictureRelation(user=u1, picture=self.picts[uid + 14]),
|
||||
PeoplePictureRelation(user=u1, picture=self.picts[uid - 20]),
|
||||
PeoplePictureRelation(user=u1, picture=self.picts[uid + 20]),
|
||||
PeoplePictureRelation(user=u1, picture=self.picts[uid - 21]),
|
||||
PeoplePictureRelation(user=u1, picture=self.picts[uid + 21]),
|
||||
PeoplePictureRelation(user=u1, picture=self.picts[uid - 22]),
|
||||
PeoplePictureRelation(user=u1, picture=self.picts[uid + 22]),
|
||||
PeoplePictureRelation(user=u1, picture=self.picts[uid - 30]),
|
||||
PeoplePictureRelation(user=u1, picture=self.picts[uid + 30]),
|
||||
PeoplePictureRelation(user=u1, picture=self.picts[uid - 31]),
|
||||
PeoplePictureRelation(user=u1, picture=self.picts[uid + 31]),
|
||||
PeoplePictureRelation(user=u1, picture=self.picts[uid - 32]),
|
||||
PeoplePictureRelation(user=u1, picture=self.picts[uid + 32]),
|
||||
]
|
||||
|
||||
if uid % 3 == 0:
|
||||
_tag_neighbors(uid, 1, 0, 40)
|
||||
if uid % 5 == 0:
|
||||
_tag_neighbors(uid, 2, 0, 50)
|
||||
if uid % 10 == 0:
|
||||
_tag_neighbors(uid, 3, 0, 60)
|
||||
if uid % 20 == 0:
|
||||
_tag_neighbors(uid, 5, 0, 70)
|
||||
if uid % 25 == 0:
|
||||
_tag_neighbors(uid, 10, 0, 80)
|
||||
|
||||
if uid % 2 == 1:
|
||||
_tag_neighbors(uid, 1, self.NB_USERS, 90)
|
||||
if uid % 15 == 0:
|
||||
_tag_neighbors(uid, 5, self.NB_USERS, 100)
|
||||
if uid % 30 == 0:
|
||||
_tag_neighbors(uid, 4, self.NB_USERS, 110)
|
||||
PeoplePictureRelation.objects.bulk_create(self.pictures_tags)
|
||||
|
||||
def make_important_citizen(self, uid: int):
|
||||
"""
|
||||
Make the user whose uid is given in parameter a more important citizen,
|
||||
thus triggering many more connections to others (lanes)
|
||||
and dragging him towards the center of the Galaxy.
|
||||
|
||||
This promotion is obtained by adding more family links
|
||||
and by tagging the user in more pictures.
|
||||
|
||||
The users chosen to be added to this user's family shall
|
||||
also be tagged in more pictures, thus making them also
|
||||
more important.
|
||||
|
||||
:param uid: the id of the user to make more important
|
||||
"""
|
||||
u1 = self.users[uid]
|
||||
u2 = self.users[uid - 100]
|
||||
u3 = self.users[uid + 100]
|
||||
u1.godfathers.add(u2)
|
||||
u1.godchildren.add(u3)
|
||||
self.logger.info(f"{u1} will be important and close to {u2} and {u3}")
|
||||
pictures_tags = []
|
||||
for p in range( # Mix them with other citizen for more chaos
|
||||
uid - 400, uid - 200
|
||||
):
|
||||
# users may already be on the pictures
|
||||
if not self.picts[p].people.filter(user=u1).exists():
|
||||
pictures_tags.append(
|
||||
PeoplePictureRelation(user=u1, picture=self.picts[p])
|
||||
)
|
||||
if not self.picts[p].people.filter(user=u2).exists():
|
||||
pictures_tags.append(
|
||||
PeoplePictureRelation(user=u2, picture=self.picts[p])
|
||||
)
|
||||
if not self.picts[p + self.NB_USERS].people.filter(user=u1).exists():
|
||||
pictures_tags.append(
|
||||
PeoplePictureRelation(
|
||||
user=u1, picture=self.picts[p + self.NB_USERS]
|
||||
)
|
||||
)
|
||||
if not self.picts[p + self.NB_USERS].people.filter(user=u2).exists():
|
||||
pictures_tags.append(
|
||||
PeoplePictureRelation(
|
||||
user=u2, picture=self.picts[p + self.NB_USERS]
|
||||
)
|
||||
)
|
||||
PeoplePictureRelation.objects.bulk_create(pictures_tags)
|
@ -21,6 +21,7 @@
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
#
|
||||
#
|
||||
import warnings
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
@ -41,19 +42,21 @@ class Command(BaseCommand):
|
||||
|
||||
def handle(self, *args, **options):
|
||||
logger = logging.getLogger("main")
|
||||
if options["verbosity"] > 1:
|
||||
if options["verbosity"] < 0 or 2 < options["verbosity"]:
|
||||
warnings.warn("verbosity level should be between 0 and 2 included")
|
||||
|
||||
if options["verbosity"] == 2:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
elif options["verbosity"] > 0:
|
||||
elif options["verbosity"] == 1:
|
||||
logger.setLevel(logging.INFO)
|
||||
else:
|
||||
logger.setLevel(logging.NOTSET)
|
||||
logger.setLevel(logging.ERROR)
|
||||
|
||||
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()
|
||||
galaxy = Galaxy.objects.create()
|
||||
galaxy.rule()
|
||||
logger.info("Sending old galaxies' remains to garbage.")
|
||||
Galaxy.objects.filter(state__isnull=True).delete()
|
||||
|
||||
logger.info("Ruled the galaxy in {} queries.".format(len(connection.queries)))
|
||||
if options["verbosity"] > 2:
|
||||
|
45
galaxy/migrations/0002_auto_20230412_1130.py
Normal file
45
galaxy/migrations/0002_auto_20230412_1130.py
Normal file
@ -0,0 +1,45 @@
|
||||
# Generated by Django 3.2.16 on 2023-04-12 09:30
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("galaxy", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="galaxy",
|
||||
options={"ordering": ["pk"]},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="galaxystar",
|
||||
name="galaxy",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="stars",
|
||||
to="galaxy.galaxy",
|
||||
verbose_name="the galaxy this star belongs to",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="galaxy",
|
||||
name="state",
|
||||
field=models.JSONField(null=True, verbose_name="The galaxy current state"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="galaxystar",
|
||||
name="owner",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="stars",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="star owner",
|
||||
),
|
||||
),
|
||||
]
|
495
galaxy/models.py
495
galaxy/models.py
@ -22,16 +22,19 @@
|
||||
#
|
||||
#
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import logging
|
||||
import time
|
||||
|
||||
from typing import List, TypedDict, NamedTuple, Union, Optional
|
||||
|
||||
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
|
||||
@ -40,30 +43,58 @@ 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.
|
||||
Define a star (vertex -> user) in the galaxy graph,
|
||||
storing a reference to its owner citizen.
|
||||
|
||||
It also stores the individual mass of this star, used to push it towards the center of the galaxy.
|
||||
Stars are linked to each others through the :class:`GalaxyLane` model.
|
||||
|
||||
Each GalaxyStar has a mass which push it towards the center of the galaxy.
|
||||
This mass is proportional to the number of pictures the owner of the star
|
||||
is tagged on.
|
||||
"""
|
||||
|
||||
owner = models.OneToOneField(
|
||||
owner = models.ForeignKey(
|
||||
User,
|
||||
verbose_name=_("star owner"),
|
||||
related_name="galaxy_user",
|
||||
related_name="stars",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
mass = models.PositiveIntegerField(
|
||||
_("star mass"),
|
||||
default=0,
|
||||
)
|
||||
galaxy = models.ForeignKey(
|
||||
"Galaxy",
|
||||
verbose_name=_("the galaxy this star belongs to"),
|
||||
related_name="stars",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.owner)
|
||||
|
||||
|
||||
@property
|
||||
def current_star(self) -> Optional[GalaxyStar]:
|
||||
"""
|
||||
The star of this user in the :class:`Galaxy`.
|
||||
Only take into account the most recent active galaxy.
|
||||
|
||||
:return: The star of this user if there is an active Galaxy
|
||||
and this user is a citizen of it, else ``None``
|
||||
"""
|
||||
return self.stars.filter(galaxy=Galaxy.get_current_galaxy()).last()
|
||||
|
||||
|
||||
# Adding a shortcut to User class for getting its star belonging to the latest ruled Galaxy
|
||||
setattr(User, "current_star", current_star)
|
||||
|
||||
|
||||
class GalaxyLane(models.Model):
|
||||
"""
|
||||
This class defines a lane (edge -> link between galaxy citizen) in the galaxy map, storing a reference to both its
|
||||
Define 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.
|
||||
"""
|
||||
@ -110,7 +141,35 @@ class GalaxyDict(TypedDict):
|
||||
links: List
|
||||
|
||||
|
||||
class RelationScore(NamedTuple):
|
||||
family: int
|
||||
pictures: int
|
||||
clubs: int
|
||||
|
||||
|
||||
class Galaxy(models.Model):
|
||||
"""
|
||||
The Galaxy, a graph linking the active users between each others.
|
||||
The distance between two users is given by a relation score which takes
|
||||
into account a few parameter like the number of pictures they are both tagged on,
|
||||
the time during which they were in the same clubs and whether they are
|
||||
in the same family.
|
||||
|
||||
The citizens of the Galaxy are represented by :class:`GalaxyStar`
|
||||
and their relations by :class:`GalaxyLane`.
|
||||
|
||||
Several galaxies can coexist. In this case, only the most recent active one
|
||||
shall usually be taken into account.
|
||||
This is useful to keep the current galaxy while generating a new one
|
||||
and swapping them only at the very end.
|
||||
|
||||
Please take into account that generating the galaxy is a very expensive
|
||||
operation. For this reason, try not to call the :meth:`rule` method more
|
||||
than once a day in production.
|
||||
|
||||
To quickly access to the state of a galaxy, use the :attr:`state` attribute.
|
||||
"""
|
||||
|
||||
logger = logging.getLogger("main")
|
||||
|
||||
GALAXY_SCALE_FACTOR = 2_000
|
||||
@ -118,77 +177,44 @@ class Galaxy(models.Model):
|
||||
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")
|
||||
state = models.JSONField(_("The galaxy current state"), null=True)
|
||||
|
||||
@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()
|
||||
class Meta:
|
||||
ordering = ["pk"]
|
||||
|
||||
def __str__(self):
|
||||
stars_count = self.stars.count()
|
||||
s = f"GLX-ID{self.pk}-SC{stars_count}-"
|
||||
if self.state is None:
|
||||
s += "CHS" # CHAOS
|
||||
else:
|
||||
s += "RLD" # RULED
|
||||
return s
|
||||
|
||||
@classmethod
|
||||
def get_current_galaxy(
|
||||
cls,
|
||||
) -> Galaxy: # __future__.annotations is required for this
|
||||
return Galaxy.objects.filter(state__isnull=False).last()
|
||||
|
||||
###################
|
||||
# User self score #
|
||||
###################
|
||||
|
||||
@classmethod
|
||||
def compute_user_score(cls, user) -> int:
|
||||
def compute_user_score(cls, user: User) -> int:
|
||||
"""
|
||||
This compute an individual score for each citizen. It will later be used by the graph algorithm to push
|
||||
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
|
||||
- ...
|
||||
|
||||
- Forum posts
|
||||
- Picture count
|
||||
- Counter consumption
|
||||
- Barman time
|
||||
- ...
|
||||
"""
|
||||
user_score = 1
|
||||
user_score += cls.query_user_score(user)
|
||||
@ -203,7 +229,11 @@ class Galaxy(models.Model):
|
||||
return user_score
|
||||
|
||||
@classmethod
|
||||
def query_user_score(cls, user) -> int:
|
||||
def query_user_score(cls, user: User) -> int:
|
||||
"""
|
||||
Perform the db query to get the individual score
|
||||
of the given user in the galaxy.
|
||||
"""
|
||||
score_query = (
|
||||
User.objects.filter(id=user.id)
|
||||
.annotate(
|
||||
@ -230,26 +260,48 @@ class Galaxy(models.Model):
|
||||
####################
|
||||
|
||||
@classmethod
|
||||
def compute_users_score(cls, user1, user2) -> Tuple[int, int, int, int]:
|
||||
def compute_users_score(cls, user1: User, user2: User) -> RelationScore:
|
||||
"""
|
||||
Compute the relationship scores of the two given users
|
||||
in the following fields :
|
||||
|
||||
- family: if they have some godfather/godchild relation
|
||||
- pictures: in how many pictures are both tagged
|
||||
- clubs: during how many days they were members of the same clubs
|
||||
"""
|
||||
family = cls.compute_users_family_score(user1, user2)
|
||||
pictures = cls.compute_users_pictures_score(user1, user2)
|
||||
clubs = cls.compute_users_clubs_score(user1, user2)
|
||||
score = family + pictures + clubs
|
||||
return score, family, pictures, clubs
|
||||
return RelationScore(family=family, pictures=pictures, clubs=clubs)
|
||||
|
||||
@classmethod
|
||||
def compute_users_family_score(cls, user1, user2) -> int:
|
||||
def compute_users_family_score(cls, user1: User, user2: User) -> int:
|
||||
"""
|
||||
Compute the family score of the relation between the given users.
|
||||
This takes into account mutual godfathers.
|
||||
|
||||
:return: 366 if user1 is the godfather of user2 (or vice versa) else 0
|
||||
"""
|
||||
link_count = User.objects.filter(
|
||||
Q(id=user1.id, godfathers=user2) | Q(id=user2.id, godfathers=user1)
|
||||
).count()
|
||||
if link_count:
|
||||
if link_count > 0:
|
||||
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:
|
||||
def compute_users_pictures_score(cls, user1: User, user2: User) -> int:
|
||||
"""
|
||||
Compute the pictures score of the relation between the given users.
|
||||
|
||||
The pictures score is obtained by counting the number
|
||||
of :class:`Picture` in which they have been both identified.
|
||||
This score is then multiplied by 2.
|
||||
|
||||
:return: The number of pictures both users have in common, times 2
|
||||
"""
|
||||
picture_count = (
|
||||
Picture.objects.filter(people__user__in=(user1,))
|
||||
.filter(people__user__in=(user2,))
|
||||
@ -262,7 +314,21 @@ class Galaxy(models.Model):
|
||||
return picture_count * cls.PICTURE_POINTS
|
||||
|
||||
@classmethod
|
||||
def compute_users_clubs_score(cls, user1, user2) -> int:
|
||||
def compute_users_clubs_score(cls, user1: User, user2: User) -> int:
|
||||
"""
|
||||
Compute the clubs score of the relation between the given users.
|
||||
|
||||
The club score is obtained by counting the number of days
|
||||
during which the memberships (see :class:`club.models.Membership`)
|
||||
of both users overlapped.
|
||||
|
||||
For example, if user1 was a member of Unitec from 01/01/2020 to 31/12/2021
|
||||
(two years) and user2 was a member of the same club from 01/01/2021 to
|
||||
31/12/2022 (also two years, but with an offset of one year), then their
|
||||
club score is 365.
|
||||
|
||||
:return: the number of days during which both users were in the same club
|
||||
"""
|
||||
common_clubs = Club.objects.filter(members__in=user1.memberships.all()).filter(
|
||||
members__in=user2.memberships.all()
|
||||
)
|
||||
@ -272,6 +338,7 @@ class Galaxy(models.Model):
|
||||
score = 0
|
||||
for user1_membership in user1_memberships:
|
||||
if user1_membership.end_date is None:
|
||||
# user1_membership.save() is not called in this function, hence this is safe
|
||||
user1_membership.end_date = timezone.now().date()
|
||||
query = Q( # start2 <= start1 <= end2
|
||||
start_date__lte=user1_membership.start_date,
|
||||
@ -312,54 +379,17 @@ class Galaxy(models.Model):
|
||||
###################
|
||||
|
||||
@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()
|
||||
def scale_distance(cls, value: Union[int, float]) -> int:
|
||||
"""
|
||||
Given a numeric value, return a scaled value which can
|
||||
be used in the Galaxy's graphical interface to set the distance
|
||||
between two stars
|
||||
|
||||
@classmethod
|
||||
def scale_distance(cls, value) -> int:
|
||||
:return: the scaled value usable in the Galaxy's 3d graph
|
||||
"""
|
||||
# TODO: this will need adjustements with the real, typical data on Taiste
|
||||
if value == 0:
|
||||
return 4000 # Following calculus would give us +∞, we cap it to 4000
|
||||
|
||||
cls.logger.debug(f"\t\t> Score: {value}")
|
||||
# Invert score to draw close users together
|
||||
@ -376,3 +406,224 @@ class Galaxy(models.Model):
|
||||
)
|
||||
cls.logger.debug(f"\t\t> Scaled distance: {value}")
|
||||
return int(value)
|
||||
|
||||
def rule(self, picture_count_threshold=10) -> None:
|
||||
"""
|
||||
Main function of the Galaxy.
|
||||
Iterate over all the rulable users to promote them to citizens.
|
||||
A citizen is a user who has a corresponding star in the Galaxy.
|
||||
Also build up the lanes, which are the links between the different citizen.
|
||||
|
||||
Users who can be ruled are defined with the `picture_count_threshold`:
|
||||
all users who are identified in a strictly lower number of pictures
|
||||
won't be promoted to citizens.
|
||||
This does very effectively limit the quantity of computing to do
|
||||
and only includes users who have had a minimum of activity.
|
||||
|
||||
This method still remains very expensive, so think thoroughly before
|
||||
you call it, especially in production.
|
||||
|
||||
:param picture_count_threshold: the minimum number of picture to have to be
|
||||
included in the galaxy
|
||||
"""
|
||||
total_time = time.time()
|
||||
self.logger.info("Listing rulable citizen.")
|
||||
rulable_users = (
|
||||
User.objects.filter(subscriptions__isnull=False)
|
||||
.annotate(pictures_count=Count("pictures"))
|
||||
.filter(pictures_count__gt=picture_count_threshold)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
# force fetch of the whole query to make sure there won't
|
||||
# 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)
|
||||
rulable_users_count = len(rulable_users)
|
||||
user1_count = 0
|
||||
self.logger.info(
|
||||
f"{rulable_users_count} citizen have been listed. Starting to rule."
|
||||
)
|
||||
|
||||
stars = []
|
||||
self.logger.info("Creating stars for all citizen")
|
||||
for user in rulable_users:
|
||||
star = GalaxyStar(
|
||||
owner=user, galaxy=self, mass=self.compute_user_score(user)
|
||||
)
|
||||
stars.append(star)
|
||||
GalaxyStar.objects.bulk_create(stars)
|
||||
|
||||
stars = {}
|
||||
for star in GalaxyStar.objects.filter(galaxy=self):
|
||||
stars[star.owner.id] = star
|
||||
|
||||
self.logger.info("Creating lanes between stars")
|
||||
# Display current speed every $speed_count_frequency users
|
||||
speed_count_frequency = max(rulable_users_count // 10, 1) # ten time at most
|
||||
global_avg_speed_accumulator = 0
|
||||
global_avg_speed_count = 0
|
||||
t_global_start = time.time()
|
||||
while len(rulable_users) > 0:
|
||||
user1 = rulable_users.pop()
|
||||
user1_count += 1
|
||||
rulable_users_count2 = len(rulable_users)
|
||||
|
||||
star1 = stars[user1.id]
|
||||
|
||||
user_avg_speed = 0
|
||||
user_avg_speed_count = 0
|
||||
|
||||
tstart = time.time()
|
||||
lanes = []
|
||||
for user2_count, user2 in enumerate(rulable_users, start=1):
|
||||
self.logger.debug("")
|
||||
self.logger.debug(
|
||||
f"\t> Examining '{user1}' ({user1_count}/{rulable_users_count}) with '{user2}' ({user2_count}/{rulable_users_count2})"
|
||||
)
|
||||
|
||||
star2 = stars[user2.id]
|
||||
|
||||
score = Galaxy.compute_users_score(user1, user2)
|
||||
distance = self.scale_distance(sum(score))
|
||||
if distance < 30: # TODO: this needs tuning with real-world data
|
||||
lanes.append(
|
||||
GalaxyLane(
|
||||
star1=star1,
|
||||
star2=star2,
|
||||
distance=distance,
|
||||
family=score.family,
|
||||
pictures=score.pictures,
|
||||
clubs=score.clubs,
|
||||
)
|
||||
)
|
||||
|
||||
if user2_count % speed_count_frequency == 0:
|
||||
tend = time.time()
|
||||
delta = tend - tstart
|
||||
speed = float(speed_count_frequency) / delta
|
||||
user_avg_speed += speed
|
||||
user_avg_speed_count += 1
|
||||
self.logger.debug(
|
||||
f"\tSpeed: {speed:.2f} users per second (time for last {speed_count_frequency} citizens: {delta:.2f} second)"
|
||||
)
|
||||
tstart = time.time()
|
||||
|
||||
GalaxyLane.objects.bulk_create(lanes)
|
||||
|
||||
self.logger.info("")
|
||||
|
||||
t_global_end = time.time()
|
||||
global_delta = t_global_end - t_global_start
|
||||
speed = 1.0 / global_delta
|
||||
global_avg_speed_accumulator += speed
|
||||
global_avg_speed_count += 1
|
||||
global_avg_speed = global_avg_speed_accumulator / global_avg_speed_count
|
||||
|
||||
self.logger.info(f" Ruling of {self} ".center(60, "#"))
|
||||
self.logger.info(
|
||||
f"Progression: {user1_count}/{rulable_users_count} citizen -- {rulable_users_count - user1_count} remaining"
|
||||
)
|
||||
self.logger.info(f"Speed: {60.0*global_avg_speed:.2f} citizen per minute")
|
||||
|
||||
# We can divide the computed ETA by 2 because each loop, there is one citizen less to check, and maths tell
|
||||
# us that this averages to a division by two
|
||||
eta = rulable_users_count2 / global_avg_speed / 2
|
||||
eta_hours = int(eta // 3600)
|
||||
eta_minutes = int(eta // 60 % 60)
|
||||
self.logger.info(
|
||||
f"ETA: {eta_hours} hours {eta_minutes} minutes ({eta / 3600 / 24:.2f} days)"
|
||||
)
|
||||
self.logger.info("#" * 60)
|
||||
t_global_start = time.time()
|
||||
|
||||
# Here, we get the IDs of the old galaxies that we'll need to delete. In normal operation, only one galaxy
|
||||
# should be returned, and we can't delete it yet, as it's the one still displayed by the Sith.
|
||||
old_galaxies_pks = list(
|
||||
Galaxy.objects.filter(state__isnull=False).values_list("pk", flat=True)
|
||||
)
|
||||
self.logger.info(
|
||||
f"These old galaxies will be deleted once the new one is ready: {old_galaxies_pks}"
|
||||
)
|
||||
|
||||
# Making the state sets this new galaxy as being ready. From now on, the Sith will show us to the world.
|
||||
self.make_state()
|
||||
|
||||
# Avoid accident if there is nothing to delete
|
||||
if len(old_galaxies_pks) > 0:
|
||||
# Former galaxies can now be deleted.
|
||||
Galaxy.objects.filter(pk__in=old_galaxies_pks).delete()
|
||||
|
||||
total_time = time.time() - total_time
|
||||
total_time_hours = int(total_time // 3600)
|
||||
total_time_minutes = int(total_time // 60 % 60)
|
||||
total_time_seconds = int(total_time % 60)
|
||||
self.logger.info(
|
||||
f"{self} ruled in {total_time:.2f} seconds ({total_time_hours} hours, {total_time_minutes} minutes, {total_time_seconds} seconds)"
|
||||
)
|
||||
|
||||
def make_state(self) -> None:
|
||||
"""
|
||||
Compute JSON structure to send to 3d-force-graph: https://github.com/vasturiano/3d-force-graph/
|
||||
"""
|
||||
self.logger.info(
|
||||
"Caching current Galaxy state for a quicker display of the Empire's power."
|
||||
)
|
||||
|
||||
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.filter(galaxy=self)
|
||||
.order_by(
|
||||
"owner"
|
||||
) # This helps determinism for the tests and doesn't cost much
|
||||
.annotate(
|
||||
owner_name=Case(
|
||||
When(owner__nick_name=None, then=without_nickname),
|
||||
default=with_nickname,
|
||||
)
|
||||
)
|
||||
)
|
||||
lanes = (
|
||||
GalaxyLane.objects.filter(star1__galaxy=self)
|
||||
.order_by(
|
||||
"star1"
|
||||
) # This helps determinism for the tests and doesn't cost much
|
||||
.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=[],
|
||||
)
|
||||
for path in lanes:
|
||||
json["links"].append(
|
||||
{
|
||||
"source": path.star1_owner,
|
||||
"target": path.star2_owner,
|
||||
"value": path.distance,
|
||||
}
|
||||
)
|
||||
self.state = json
|
||||
self.save()
|
||||
self.logger.info(f"{self} is now ready!")
|
||||
|
1
galaxy/ref_galaxy_state.json
Normal file
1
galaxy/ref_galaxy_state.json
Normal file
File diff suppressed because one or more lines are too long
@ -1,37 +1,42 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block title %}
|
||||
{% trans user_name=user.get_display_name() %}{{ user_name }}'s Galaxy{% endtrans %}
|
||||
{% trans user_name=object.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>
|
||||
{% if object.current_star %}
|
||||
<div style="display: flex; flex-wrap: wrap;">
|
||||
<div id="3d-graph"></div>
|
||||
|
||||
<div id="3d-graph" style="margin: 1em;"></div>
|
||||
<div style="margin: 1em;">
|
||||
<p><a onclick="focus_node(get_node_from_id({{ object.id }}))">Reset on {{ object.get_display_name() }}</a></p>
|
||||
<p>Self score: {{ object.current_star.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>
|
||||
</div>
|
||||
<p>#{{ object.current_star.galaxy }}#</p>
|
||||
{% else %}
|
||||
<p>This citizen has not yet joined the galaxy</p>
|
||||
{% endif %}
|
||||
@ -53,9 +58,31 @@
|
||||
return Graph.graphData().nodes.find(n => n.id === id);
|
||||
}
|
||||
|
||||
function get_links_from_node_id(id) {
|
||||
return Graph.graphData().links.filter(l => l.source.id === id || l.target.id === id);
|
||||
}
|
||||
|
||||
function focus_node(node) {
|
||||
highlightNodes.clear();
|
||||
highlightLinks.clear();
|
||||
|
||||
hoverNode = node || null;
|
||||
if (node) { // collect neighbors and links for highlighting
|
||||
get_links_from_node_id(node.id).forEach(link => {
|
||||
highlightLinks.add(link);
|
||||
highlightNodes.add(link.source);
|
||||
highlightNodes.add(link.target);
|
||||
});
|
||||
}
|
||||
|
||||
// refresh node and link display
|
||||
Graph
|
||||
.nodeThreeObject(Graph.nodeThreeObject())
|
||||
.linkWidth(Graph.linkWidth())
|
||||
.linkDirectionalParticles(Graph.linkDirectionalParticles());
|
||||
|
||||
// Aim at node from outside it
|
||||
const distance = 200;
|
||||
const distance = 42;
|
||||
const distRatio = 1 + distance/Math.hypot(node.x, node.y, node.z);
|
||||
|
||||
const newPos = node.x || node.y || node.z
|
||||
@ -69,25 +96,44 @@
|
||||
);
|
||||
}
|
||||
|
||||
const highlightNodes = new Set();
|
||||
const highlightLinks = new Set();
|
||||
let hoverNode = null;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
var graph_div = document.getElementById('3d-graph');
|
||||
Graph = ForceGraph3D();
|
||||
Graph(document.getElementById('3d-graph'));
|
||||
Graph(graph_div);
|
||||
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)
|
||||
.width(graph_div.parentElement.clientWidth > 1200 ? 1200 : graph_div.parentElement.clientWidth) // Not perfect at all. JS-fu master from the future, please fix this :-)
|
||||
.height(1000)
|
||||
.enableNodeDrag(false) // allow easier navigation
|
||||
.onNodeClick(node => {
|
||||
camera = Graph.cameraPosition();
|
||||
var distance = Math.sqrt(Math.pow(node.x - camera.x, 2) + Math.pow(node.y - camera.y, 2) + Math.pow(node.z - camera.z, 2))
|
||||
if (distance < 120 || highlightNodes.has(node)) {
|
||||
focus_node(node);
|
||||
}
|
||||
})
|
||||
.linkWidth(link => highlightLinks.has(link) ? 0.4 : 0.0)
|
||||
.linkColor(link => highlightLinks.has(link) ? 'rgba(255,160,0,1)' : 'rgba(128,255,255,0.6)')
|
||||
.linkVisibility(link => highlightLinks.has(link))
|
||||
.nodeVisibility(node => highlightNodes.has(node) || node.mass > 4)
|
||||
// .linkDirectionalParticles(link => highlightLinks.has(link) ? 3 : 1) // kinda buggy for now, and slows this a bit, but would be great to help visualize lanes
|
||||
.linkDirectionalParticleWidth(0.2)
|
||||
.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;
|
||||
sprite.color = highlightNodes.has(node) ? node === hoverNode ? 'rgba(200,0,0,1)' : 'rgba(255,160,0,0.8)' : 'rgba(0,255,255,0.2)';
|
||||
sprite.textHeight = 2;
|
||||
sprite.center = new THREE.Vector2(1.2, 0.5);
|
||||
return sprite;
|
||||
})
|
||||
.onEngineStop( () => {
|
||||
focus_node(get_node_from_id({{ object.id }}));
|
||||
Graph.onEngineStop(() => {}); // don't call ourselves in a loop while moving the focus
|
||||
});
|
||||
|
||||
// Set distance between stars
|
||||
@ -98,9 +144,6 @@
|
||||
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 %}
|
||||
|
@ -22,14 +22,19 @@
|
||||
#
|
||||
#
|
||||
|
||||
from django.test import TestCase
|
||||
import json
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from core.models import User
|
||||
from galaxy.models import Galaxy
|
||||
|
||||
|
||||
class GalaxyTest(TestCase):
|
||||
class GalaxyTestModel(TestCase):
|
||||
def setUp(self):
|
||||
self.root = User.objects.get(username="root")
|
||||
self.skia = User.objects.get(username="skia")
|
||||
@ -41,6 +46,9 @@ class GalaxyTest(TestCase):
|
||||
self.com = User.objects.get(username="comunity")
|
||||
|
||||
def test_user_self_score(self):
|
||||
"""
|
||||
Test that individual user scores are correct
|
||||
"""
|
||||
with self.assertNumQueries(8):
|
||||
self.assertEqual(Galaxy.compute_user_score(self.root), 9)
|
||||
self.assertEqual(Galaxy.compute_user_score(self.skia), 10)
|
||||
@ -52,6 +60,10 @@ class GalaxyTest(TestCase):
|
||||
self.assertEqual(Galaxy.compute_user_score(self.com), 1)
|
||||
|
||||
def test_users_score(self):
|
||||
"""
|
||||
Test on the default dataset generated by the `populate` command
|
||||
that the relation scores are correct
|
||||
"""
|
||||
expected_scores = {
|
||||
"krophil": {
|
||||
"comunity": {"clubs": 0, "family": 0, "pictures": 0, "score": 0},
|
||||
@ -112,33 +124,78 @@ class GalaxyTest(TestCase):
|
||||
while len(users) > 0:
|
||||
user1 = users.pop(0)
|
||||
for user2 in users:
|
||||
score, family, pictures, clubs = Galaxy.compute_users_score(
|
||||
user1, user2
|
||||
)
|
||||
score = Galaxy.compute_users_score(user1, user2)
|
||||
u1 = computed_scores.get(user1.username, {})
|
||||
u1[user2.username] = {
|
||||
"score": score,
|
||||
"family": family,
|
||||
"pictures": pictures,
|
||||
"clubs": clubs,
|
||||
"score": sum(score),
|
||||
"family": score.family,
|
||||
"pictures": score.pictures,
|
||||
"clubs": score.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_rule(self):
|
||||
"""
|
||||
Test on the default dataset generated by the `populate` command
|
||||
that the number of queries to rule the galaxy is stable.
|
||||
"""
|
||||
galaxy = Galaxy.objects.create()
|
||||
with self.assertNumQueries(58):
|
||||
galaxy.rule(0) # We want everybody here
|
||||
|
||||
|
||||
class GalaxyTestView(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""
|
||||
Generate a plausible Galaxy once for every test
|
||||
"""
|
||||
call_command("generate_galaxy_test_data", "-v", "0")
|
||||
galaxy = Galaxy.objects.create()
|
||||
galaxy.rule(26) # We want a fast test
|
||||
|
||||
def test_page_is_citizen(self):
|
||||
Galaxy.rule()
|
||||
"""
|
||||
Test that users can access the galaxy page of users who are citizens
|
||||
"""
|
||||
self.client.login(username="root", password="plop")
|
||||
response = self.client.get("/galaxy/1/")
|
||||
user = User.objects.get(last_name="n°500")
|
||||
response = self.client.get(reverse("galaxy:user", args=[user.id]))
|
||||
self.assertContains(
|
||||
response,
|
||||
'<a onclick="focus_node(get_node_from_id(8))">Locate</a>',
|
||||
f'<a onclick="focus_node(get_node_from_id({user.id}))">Reset on {user}</a>',
|
||||
status_code=200,
|
||||
)
|
||||
|
||||
def test_page_not_citizen(self):
|
||||
Galaxy.rule()
|
||||
"""
|
||||
Test that trying to access the galaxy page of a user who is not
|
||||
citizens return a 404
|
||||
"""
|
||||
self.client.login(username="root", password="plop")
|
||||
response = self.client.get("/galaxy/2/")
|
||||
user = User.objects.get(last_name="n°1")
|
||||
response = self.client.get(reverse("galaxy:user", args=[user.id]))
|
||||
self.assertEquals(response.status_code, 404)
|
||||
|
||||
def test_full_galaxy_state(self):
|
||||
"""
|
||||
Test on the more complex dataset generated by the `generate_galaxy_test_data`
|
||||
command that the relation scores are correct, and that the view exposes the
|
||||
right data.
|
||||
"""
|
||||
self.client.login(username="root", password="plop")
|
||||
response = self.client.get(reverse("galaxy:data"))
|
||||
state = response.json()
|
||||
|
||||
galaxy_dir = Path(__file__).parent
|
||||
|
||||
# Dump computed state, either for easier debugging, or to copy as new reference if changes are legit
|
||||
(galaxy_dir / "test_galaxy_state.json").write_text(json.dumps(state))
|
||||
|
||||
self.assertEqual(
|
||||
state,
|
||||
json.loads((galaxy_dir / "ref_galaxy_state.json").read_text()),
|
||||
)
|
||||
|
@ -45,32 +45,29 @@ class GalaxyUserView(CanViewMixin, UserTabsMixin, DetailView):
|
||||
|
||||
def get_object(self, *args, **kwargs):
|
||||
user: User = super(GalaxyUserView, self).get_object(*args, **kwargs)
|
||||
if not hasattr(user, "galaxy_user"):
|
||||
if user.current_star is None:
|
||||
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)
|
||||
Q(star1=self.object.current_star) | Q(star2=self.object.current_star)
|
||||
)
|
||||
.order_by("distance")
|
||||
.annotate(
|
||||
other_star_id=Case(
|
||||
When(star1=self.object.galaxy_user, then=F("star2__owner__id")),
|
||||
When(star1=self.object.current_star, then=F("star2__owner__id")),
|
||||
default=F("star1__owner__id"),
|
||||
),
|
||||
other_star_mass=Case(
|
||||
When(star1=self.object.galaxy_user, then=F("star2__mass")),
|
||||
When(star1=self.object.current_star, then=F("star2__mass")),
|
||||
default=F("star1__mass"),
|
||||
),
|
||||
other_star_name=Case(
|
||||
When(
|
||||
star1=self.object.galaxy_user,
|
||||
star1=self.object.current_star,
|
||||
then=Case(
|
||||
When(
|
||||
star2__owner__nick_name=None,
|
||||
@ -101,4 +98,4 @@ class GalaxyUserView(CanViewMixin, UserTabsMixin, DetailView):
|
||||
|
||||
class GalaxyDataView(FormerSubscriberMixin, View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
return JsonResponse(Galaxy.objects.first().state)
|
||||
return JsonResponse(Galaxy.get_current_galaxy().state)
|
||||
|
138
poetry.lock
generated
138
poetry.lock
generated
@ -1,10 +1,9 @@
|
||||
# This file is automatically @generated by Poetry and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "alabaster"
|
||||
version = "0.7.12"
|
||||
description = "A configurable sidebar-enabled Sphinx theme"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = "*"
|
||||
files = [
|
||||
@ -16,7 +15,6 @@ files = [
|
||||
name = "appnope"
|
||||
version = "0.1.3"
|
||||
description = "Disable App Nap on macOS >= 10.9"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
@ -28,7 +26,6 @@ files = [
|
||||
name = "asgiref"
|
||||
version = "3.6.0"
|
||||
description = "ASGI specs, helper code, and adapters"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
@ -43,7 +40,6 @@ tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"]
|
||||
name = "babel"
|
||||
version = "2.11.0"
|
||||
description = "Internationalization utilities"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
@ -58,7 +54,6 @@ pytz = ">=2015.7"
|
||||
name = "backcall"
|
||||
version = "0.2.0"
|
||||
description = "Specifications for callback functions passed in to an API"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
@ -70,7 +65,6 @@ files = [
|
||||
name = "black"
|
||||
version = "23.3.0"
|
||||
description = "The uncompromising code formatter."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
@ -120,7 +114,6 @@ uvloop = ["uvloop (>=0.15.2)"]
|
||||
name = "certifi"
|
||||
version = "2022.12.7"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
@ -132,7 +125,6 @@ files = [
|
||||
name = "cffi"
|
||||
version = "1.15.1"
|
||||
description = "Foreign Function Interface for Python calling C code."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
@ -209,7 +201,6 @@ pycparser = "*"
|
||||
name = "charset-normalizer"
|
||||
version = "2.1.1"
|
||||
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.6.0"
|
||||
files = [
|
||||
@ -224,7 +215,6 @@ unicode-backport = ["unicodedata2"]
|
||||
name = "click"
|
||||
version = "8.1.3"
|
||||
description = "Composable command line interface toolkit"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
@ -239,7 +229,6 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""}
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
description = "Cross-platform colored terminal text."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||
files = [
|
||||
@ -251,7 +240,6 @@ files = [
|
||||
name = "coverage"
|
||||
version = "5.5"
|
||||
description = "Code coverage measurement for Python"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
|
||||
files = [
|
||||
@ -316,7 +304,6 @@ toml = ["toml"]
|
||||
name = "cryptography"
|
||||
version = "40.0.1"
|
||||
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
@ -358,7 +345,6 @@ tox = ["tox"]
|
||||
name = "decorator"
|
||||
version = "5.1.1"
|
||||
description = "Decorators for Humans"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
files = [
|
||||
@ -370,7 +356,6 @@ files = [
|
||||
name = "dict2xml"
|
||||
version = "1.7.3"
|
||||
description = "Small utility to convert a python dictionary into an XML string"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
files = [
|
||||
@ -385,7 +370,6 @@ tests = ["noseofyeti[black] (==2.4.1)", "pytest (==7.2.1)"]
|
||||
name = "django"
|
||||
version = "3.2.18"
|
||||
description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
@ -406,7 +390,6 @@ bcrypt = ["bcrypt"]
|
||||
name = "django-ajax-selects"
|
||||
version = "2.2.0"
|
||||
description = "Edit ForeignKey, ManyToManyField and CharField in Django Admin using jQuery UI AutoComplete."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
@ -417,7 +400,6 @@ files = [
|
||||
name = "django-countries"
|
||||
version = "7.5.1"
|
||||
description = "Provides a country field for Django models."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
@ -439,7 +421,6 @@ test = ["djangorestframework", "graphene-django", "pytest", "pytest-cov", "pytes
|
||||
name = "django-debug-toolbar"
|
||||
version = "4.0.0"
|
||||
description = "A configurable set of panels that display various debug information about the current request/response."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
@ -455,7 +436,6 @@ sqlparse = ">=0.2"
|
||||
name = "django-haystack"
|
||||
version = "3.2.1"
|
||||
description = "Pluggable search for Django."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
@ -472,7 +452,6 @@ elasticsearch = ["elasticsearch (>=5,<8)"]
|
||||
name = "django-jinja"
|
||||
version = "2.10.2"
|
||||
description = "Jinja2 templating language integrated in Django."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
@ -486,21 +465,19 @@ jinja2 = ">=3"
|
||||
|
||||
[[package]]
|
||||
name = "django-ordered-model"
|
||||
version = "3.6"
|
||||
version = "3.7.4"
|
||||
description = "Allows Django models to be ordered and provides a simple admin interface for reordering them."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
{file = "django-ordered-model-3.6.tar.gz", hash = "sha256:62161a6bc51d8b402644854b257605d7b5183d01fd349826682a87e9227c05b5"},
|
||||
{file = "django_ordered_model-3.6-py3-none-any.whl", hash = "sha256:0006b111f472a2348f75554a4e77bee2b1f379a0f96726af6b1a3ebf3a950789"},
|
||||
{file = "django-ordered-model-3.7.4.tar.gz", hash = "sha256:f258b9762525c00a53009e82f8b8bf2a3aa315e8b453e281e8fdbbfe2b8cb3ba"},
|
||||
{file = "django_ordered_model-3.7.4-py3-none-any.whl", hash = "sha256:dfcd3183fe0749dad1c9971cba1d6240ce7328742a30ddc92feca41107bb241d"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-phonenumber-field"
|
||||
version = "6.4.0"
|
||||
description = "An international phone number field for django models."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
@ -519,7 +496,6 @@ phonenumberslite = ["phonenumberslite (>=7.0.2)"]
|
||||
name = "django-ranged-response"
|
||||
version = "0.2.0"
|
||||
description = "Modified Django FileResponse that adds Content-Range headers."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
@ -533,7 +509,6 @@ django = "*"
|
||||
name = "django-simple-captcha"
|
||||
version = "0.5.17"
|
||||
description = "A very simple, yet powerful, Django captcha application"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
@ -553,7 +528,6 @@ test = ["testfixtures"]
|
||||
name = "djangorestframework"
|
||||
version = "3.14.0"
|
||||
description = "Web APIs for Django, made easy."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
@ -569,7 +543,6 @@ pytz = "*"
|
||||
name = "docutils"
|
||||
version = "0.17.1"
|
||||
description = "Docutils -- Python Documentation Utilities"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
files = [
|
||||
@ -577,11 +550,24 @@ files = [
|
||||
{file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "freezegun"
|
||||
version = "1.2.2"
|
||||
description = "Let your Python tests travel through time"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
{file = "freezegun-1.2.2-py3-none-any.whl", hash = "sha256:ea1b963b993cb9ea195adbd893a48d573fda951b0da64f60883d7e988b606c9f"},
|
||||
{file = "freezegun-1.2.2.tar.gz", hash = "sha256:cd22d1ba06941384410cd967d8a99d5ae2442f57dfafeff2fda5de8dc5c05446"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
python-dateutil = ">=2.7"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.4"
|
||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.5"
|
||||
files = [
|
||||
@ -593,7 +579,6 @@ files = [
|
||||
name = "imagesize"
|
||||
version = "1.4.1"
|
||||
description = "Getting image size from png/jpeg/jpeg2000/gif file"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
files = [
|
||||
@ -605,7 +590,6 @@ files = [
|
||||
name = "importlib-metadata"
|
||||
version = "6.0.0"
|
||||
description = "Read metadata from Python packages"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
@ -625,7 +609,6 @@ testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packag
|
||||
name = "ipython"
|
||||
version = "7.34.0"
|
||||
description = "IPython: Productive Interactive Computing"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
@ -662,7 +645,6 @@ test = ["ipykernel", "nbformat", "nose (>=0.10.1)", "numpy (>=1.17)", "pygments"
|
||||
name = "jedi"
|
||||
version = "0.18.2"
|
||||
description = "An autocompletion tool for Python that can be used for text editors."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
@ -682,7 +664,6 @@ testing = ["Django (<3.1)", "attrs", "colorama", "docopt", "pytest (<7.0.0)"]
|
||||
name = "jinja2"
|
||||
version = "3.1.2"
|
||||
description = "A very fast and expressive template engine."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
@ -698,32 +679,22 @@ i18n = ["Babel (>=2.7)"]
|
||||
|
||||
[[package]]
|
||||
name = "libsass"
|
||||
version = "0.21.0"
|
||||
version = "0.22.0"
|
||||
description = "Sass for Python: A straightforward binding of libsass for Python."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
{file = "libsass-0.21.0-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:06c8776417fe930714bdc930a3d7e795ae3d72be6ac883ff72a1b8f7c49e5ffb"},
|
||||
{file = "libsass-0.21.0-cp27-cp27m-win32.whl", hash = "sha256:a005f298f64624f313a3ac618ab03f844c71d84ae4f4a4aec4b68d2a4ffe75eb"},
|
||||
{file = "libsass-0.21.0-cp27-cp27m-win_amd64.whl", hash = "sha256:6b984510ed94993708c0d697b4fef2d118929bbfffc3b90037be0f5ccadf55e7"},
|
||||
{file = "libsass-0.21.0-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:1e25dd9047a9392d3c59a0b869e0404f2b325a03871ee45285ee33b3664f5613"},
|
||||
{file = "libsass-0.21.0-cp36-abi3-macosx_10_14_x86_64.whl", hash = "sha256:12f39712de38689a8b785b7db41d3ba2ea1d46f9379d81ea4595802d91fa6529"},
|
||||
{file = "libsass-0.21.0-cp36-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e2b1a7d093f2e76dc694c17c0c285e846d0b0deb0e8b21dc852ba1a3a4e2f1d6"},
|
||||
{file = "libsass-0.21.0-cp36-abi3-win32.whl", hash = "sha256:abc29357ee540849faf1383e1746d40d69ed5cb6d4c346df276b258f5aa8977a"},
|
||||
{file = "libsass-0.21.0-cp36-abi3-win_amd64.whl", hash = "sha256:659ae41af8708681fa3ec73f47b9735a6725e71c3b66ff570bfce78952f2314e"},
|
||||
{file = "libsass-0.21.0-cp38-abi3-macosx_12_0_arm64.whl", hash = "sha256:c9ec490609752c1d81ff6290da33485aa7cb6d7365ac665b74464c1b7d97f7da"},
|
||||
{file = "libsass-0.21.0.tar.gz", hash = "sha256:d5ba529d9ce668be9380563279f3ffe988f27bc5b299c5a28453df2e0b0fbaf2"},
|
||||
{file = "libsass-0.22.0-cp36-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:f1efc1b612299c88aec9e39d6ca0c266d360daa5b19d9430bdeaffffa86993f9"},
|
||||
{file = "libsass-0.22.0-cp37-abi3-macosx_10_15_x86_64.whl", hash = "sha256:081e256ab3c5f3f09c7b8dea3bf3bf5e64a97c6995fd9eea880639b3f93a9f9a"},
|
||||
{file = "libsass-0.22.0-cp37-abi3-win32.whl", hash = "sha256:89c5ce497fcf3aba1dd1b19aae93b99f68257e5f2026b731b00a872f13324c7f"},
|
||||
{file = "libsass-0.22.0-cp37-abi3-win_amd64.whl", hash = "sha256:65455a2728b696b62100eb5932604aa13a29f4ac9a305d95773c14aaa7200aaf"},
|
||||
{file = "libsass-0.22.0.tar.gz", hash = "sha256:3ab5ad18e47db560f4f0c09e3d28cf3bb1a44711257488ac2adad69f4f7f8425"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
six = "*"
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "2.1.1"
|
||||
description = "Safely add untrusted strings to HTML/XML markup."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
@ -773,7 +744,6 @@ files = [
|
||||
name = "matplotlib-inline"
|
||||
version = "0.1.6"
|
||||
description = "Inline Matplotlib backend for Jupyter"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
files = [
|
||||
@ -788,7 +758,6 @@ traitlets = "*"
|
||||
name = "mistune"
|
||||
version = "0.8.4"
|
||||
description = "The fastest markdown parser in pure Python"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
@ -800,7 +769,6 @@ files = [
|
||||
name = "mypy-extensions"
|
||||
version = "0.4.3"
|
||||
description = "Experimental type system extensions for programs checked with the mypy typechecker."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
@ -812,7 +780,6 @@ files = [
|
||||
name = "packaging"
|
||||
version = "23.0"
|
||||
description = "Core utilities for Python packages"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
@ -824,7 +791,6 @@ files = [
|
||||
name = "parso"
|
||||
version = "0.8.3"
|
||||
description = "A Python Parser"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
@ -840,7 +806,6 @@ testing = ["docopt", "pytest (<6.0.0)"]
|
||||
name = "pathspec"
|
||||
version = "0.10.3"
|
||||
description = "Utility library for gitignore style pattern matching of file paths."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
@ -852,7 +817,6 @@ files = [
|
||||
name = "pexpect"
|
||||
version = "4.8.0"
|
||||
description = "Pexpect allows easy control of interactive console applications."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
@ -867,7 +831,6 @@ ptyprocess = ">=0.5"
|
||||
name = "phonenumbers"
|
||||
version = "8.13.4"
|
||||
description = "Python version of Google's common library for parsing, formatting, storing and validating international phone numbers."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
@ -879,7 +842,6 @@ files = [
|
||||
name = "pickleshare"
|
||||
version = "0.7.5"
|
||||
description = "Tiny 'shelve'-like database with concurrency support"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
@ -891,7 +853,6 @@ files = [
|
||||
name = "pillow"
|
||||
version = "9.4.0"
|
||||
description = "Python Imaging Library (Fork)"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
@ -982,7 +943,6 @@ tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "pa
|
||||
name = "platformdirs"
|
||||
version = "2.6.2"
|
||||
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
@ -998,7 +958,6 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2)", "pytest-
|
||||
name = "prompt-toolkit"
|
||||
version = "3.0.36"
|
||||
description = "Library for building powerful interactive command lines in Python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6.2"
|
||||
files = [
|
||||
@ -1013,7 +972,6 @@ wcwidth = "*"
|
||||
name = "psycopg2-binary"
|
||||
version = "2.9.3"
|
||||
description = "psycopg2 - Python-PostgreSQL Database Adapter"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
@ -1082,7 +1040,6 @@ files = [
|
||||
name = "ptyprocess"
|
||||
version = "0.7.0"
|
||||
description = "Run a subprocess in a pseudo terminal"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
@ -1094,7 +1051,6 @@ files = [
|
||||
name = "pycparser"
|
||||
version = "2.21"
|
||||
description = "C parser in Python"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
|
||||
files = [
|
||||
@ -1106,7 +1062,6 @@ files = [
|
||||
name = "pygments"
|
||||
version = "2.14.0"
|
||||
description = "Pygments is a syntax highlighting package written in Python."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
@ -1121,7 +1076,6 @@ plugins = ["importlib-metadata"]
|
||||
name = "pygraphviz"
|
||||
version = "1.10"
|
||||
description = "Python interface to Graphviz"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
@ -1132,7 +1086,6 @@ files = [
|
||||
name = "pyopenssl"
|
||||
version = "23.1.1"
|
||||
description = "Python wrapper module around the OpenSSL library"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
@ -1151,7 +1104,6 @@ test = ["flaky", "pretend", "pytest (>=3.0.1)"]
|
||||
name = "python-dateutil"
|
||||
version = "2.8.2"
|
||||
description = "Extensions to the standard Python datetime module"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
|
||||
files = [
|
||||
@ -1166,7 +1118,6 @@ six = ">=1.5"
|
||||
name = "pytz"
|
||||
version = "2021.3"
|
||||
description = "World timezone definitions, modern and historical"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
@ -1178,7 +1129,6 @@ files = [
|
||||
name = "reportlab"
|
||||
version = "3.6.12"
|
||||
description = "The Reportlab Toolkit"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7,<4"
|
||||
files = [
|
||||
@ -1240,7 +1190,6 @@ rlpycairo = ["rlPyCairo (>=0.1.0)"]
|
||||
name = "requests"
|
||||
version = "2.28.1"
|
||||
description = "Python HTTP for Humans."
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.7, <4"
|
||||
files = [
|
||||
@ -1262,7 +1211,6 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
|
||||
name = "sentry-sdk"
|
||||
version = "1.21.0"
|
||||
description = "Python client for Sentry (https://sentry.io)"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
@ -1304,7 +1252,6 @@ tornado = ["tornado (>=5)"]
|
||||
name = "setuptools"
|
||||
version = "65.6.3"
|
||||
description = "Easily download, build, install, upgrade, and uninstall Python packages"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
@ -1321,7 +1268,6 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (
|
||||
name = "six"
|
||||
version = "1.16.0"
|
||||
description = "Python 2 and 3 compatibility utilities"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
|
||||
files = [
|
||||
@ -1333,7 +1279,6 @@ files = [
|
||||
name = "snowballstemmer"
|
||||
version = "2.2.0"
|
||||
description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms."
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = "*"
|
||||
files = [
|
||||
@ -1345,7 +1290,6 @@ files = [
|
||||
name = "sphinx"
|
||||
version = "4.5.0"
|
||||
description = "Python documentation generator"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
@ -1381,7 +1325,6 @@ test = ["cython", "html5lib", "pytest", "pytest-cov", "typed-ast"]
|
||||
name = "sphinx-copybutton"
|
||||
version = "0.4.0"
|
||||
description = "Add a copy button to each of your code cells."
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
@ -1400,7 +1343,6 @@ rtd = ["ipython", "sphinx", "sphinx-book-theme"]
|
||||
name = "sphinx-rtd-theme"
|
||||
version = "1.1.1"
|
||||
description = "Read the Docs theme for Sphinx"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
|
||||
files = [
|
||||
@ -1419,7 +1361,6 @@ dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client", "wheel"]
|
||||
name = "sphinxcontrib-applehelp"
|
||||
version = "1.0.3"
|
||||
description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
@ -1435,7 +1376,6 @@ test = ["pytest"]
|
||||
name = "sphinxcontrib-devhelp"
|
||||
version = "1.0.2"
|
||||
description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document."
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.5"
|
||||
files = [
|
||||
@ -1451,7 +1391,6 @@ test = ["pytest"]
|
||||
name = "sphinxcontrib-htmlhelp"
|
||||
version = "2.0.0"
|
||||
description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
@ -1467,7 +1406,6 @@ test = ["html5lib", "pytest"]
|
||||
name = "sphinxcontrib-jsmath"
|
||||
version = "1.0.1"
|
||||
description = "A sphinx extension which renders display math in HTML via JavaScript"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.5"
|
||||
files = [
|
||||
@ -1482,7 +1420,6 @@ test = ["flake8", "mypy", "pytest"]
|
||||
name = "sphinxcontrib-qthelp"
|
||||
version = "1.0.3"
|
||||
description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document."
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.5"
|
||||
files = [
|
||||
@ -1498,7 +1435,6 @@ test = ["pytest"]
|
||||
name = "sphinxcontrib-serializinghtml"
|
||||
version = "1.1.5"
|
||||
description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)."
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.5"
|
||||
files = [
|
||||
@ -1512,21 +1448,24 @@ test = ["pytest"]
|
||||
|
||||
[[package]]
|
||||
name = "sqlparse"
|
||||
version = "0.4.3"
|
||||
version = "0.4.4"
|
||||
description = "A non-validating SQL parser."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
files = [
|
||||
{file = "sqlparse-0.4.3-py3-none-any.whl", hash = "sha256:0323c0ec29cd52bceabc1b4d9d579e311f3e4961b98d174201d5622a23b85e34"},
|
||||
{file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"},
|
||||
{file = "sqlparse-0.4.4-py3-none-any.whl", hash = "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3"},
|
||||
{file = "sqlparse-0.4.4.tar.gz", hash = "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
dev = ["build", "flake8"]
|
||||
doc = ["sphinx"]
|
||||
test = ["pytest", "pytest-cov"]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.0.1"
|
||||
description = "A lil' TOML parser"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
@ -1538,7 +1477,6 @@ files = [
|
||||
name = "traitlets"
|
||||
version = "5.8.1"
|
||||
description = "Traitlets Python configuration system"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
@ -1554,7 +1492,6 @@ test = ["argcomplete (>=2.0)", "pre-commit", "pytest", "pytest-mock"]
|
||||
name = "typing-extensions"
|
||||
version = "4.4.0"
|
||||
description = "Backported and Experimental Type Hints for Python 3.7+"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
@ -1566,7 +1503,6 @@ files = [
|
||||
name = "urllib3"
|
||||
version = "1.26.13"
|
||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
|
||||
files = [
|
||||
@ -1583,7 +1519,6 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||
name = "wcwidth"
|
||||
version = "0.2.5"
|
||||
description = "Measures the displayed width of unicode strings in a terminal"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
@ -1595,7 +1530,6 @@ files = [
|
||||
name = "xapian-bindings"
|
||||
version = "0.1.0"
|
||||
description = "Meta-package to build and install xapian-bindings extension."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
@ -1606,7 +1540,6 @@ files = [
|
||||
name = "xapian-haystack"
|
||||
version = "3.0.1"
|
||||
description = "A Xapian backend for Haystack"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
@ -1621,7 +1554,6 @@ django-haystack = ">=2.8.0"
|
||||
name = "zipp"
|
||||
version = "3.11.0"
|
||||
description = "Backport of pathlib-compatible object wrapper for zip files"
|
||||
category = "main"
|
||||
optional = true
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
@ -1634,10 +1566,10 @@ docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker
|
||||
testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
|
||||
|
||||
[extras]
|
||||
docs = ["Sphinx", "sphinx-rtd-theme", "sphinx-copybutton"]
|
||||
docs = ["Sphinx", "sphinx-copybutton", "sphinx-rtd-theme"]
|
||||
testing = ["coverage"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.8"
|
||||
content-hash = "5069f58a9ba4b47c16b08e1a4191b0d2be68c20163300fc550b41d68c8e26d73"
|
||||
content-hash = "62519616aff5a472dac3dd8071a6404b1ee8eab12a197af717a0520f7ded0331"
|
||||
|
@ -35,8 +35,8 @@ reportlab = "^3.6"
|
||||
django-haystack = "^3.2.1"
|
||||
xapian-haystack = "^3.0.1"
|
||||
xapian-bindings = "^0.1.0"
|
||||
libsass = "^0.21"
|
||||
django-ordered-model = "^3.6"
|
||||
libsass = "^0.22"
|
||||
django-ordered-model = "^3.7"
|
||||
django-simple-captcha = "^0.5.17"
|
||||
python-dateutil = "^2.8.2"
|
||||
psycopg2-binary = "2.9.3"
|
||||
@ -59,6 +59,7 @@ testing = ["coverage"]
|
||||
docs = ["Sphinx", "sphinx-rtd-theme", "sphinx-copybutton"]
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
freezegun = "^1.2.2" # used to test time-dependent code
|
||||
django-debug-toolbar = "^4.0.0"
|
||||
ipython = "^7.28.0"
|
||||
black = "^23.3.0"
|
||||
|
@ -327,7 +327,8 @@ SITH_CLUB_ROOT_PAGE = "clubs"
|
||||
|
||||
# Define the date in the year serving as reference for the subscriptions calendar
|
||||
# (month, day)
|
||||
SITH_START_DATE = (8, 15) # 15th August
|
||||
SITH_SEMESTER_START_AUTUMN = (8, 15) # 15 August
|
||||
SITH_SEMESTER_START_SPRING = (2, 15) # 15 February
|
||||
|
||||
# Used to determine the valid promos
|
||||
SITH_SCHOOL_START_YEAR = 1999
|
||||
|
@ -114,12 +114,12 @@ class Subscription(models.Model):
|
||||
return "No user - " + str(self.pk)
|
||||
|
||||
@staticmethod
|
||||
def compute_start(d=None, duration=1, user=None):
|
||||
def compute_start(d: date = None, duration: int = 1, user: User = None) -> date:
|
||||
"""
|
||||
This function computes the start date of the subscription with respect to the given date (default is today),
|
||||
and the start date given in settings.SITH_START_DATE.
|
||||
and the start date given in settings.SITH_SEMESTER_START_AUTUMN.
|
||||
It takes the nearest past start date.
|
||||
Exemples: with SITH_START_DATE = (8, 15)
|
||||
Exemples: with SITH_SEMESTER_START_AUTUMN = (8, 15)
|
||||
Today -> Start date
|
||||
2015-03-17 -> 2015-02-15
|
||||
2015-01-11 -> 2014-08-15
|
||||
@ -135,9 +135,9 @@ class Subscription(models.Model):
|
||||
return get_start_of_semester(d)
|
||||
|
||||
@staticmethod
|
||||
def compute_end(duration, start=None, user=None):
|
||||
def compute_end(duration: int, start: date = None, user: User = None) -> date:
|
||||
"""
|
||||
This function compute the end date of the subscription given a start date and a duration in number of semestre
|
||||
This function compute the end date of the subscription given a start date and a duration in number of semester
|
||||
Exemple:
|
||||
Start - Duration -> End date
|
||||
2015-09-18 - 1 -> 2016-03-18
|
||||
@ -153,7 +153,7 @@ class Subscription(models.Model):
|
||||
days=math.ceil((6 * duration - round(6 * duration)) * 30),
|
||||
)
|
||||
|
||||
def can_be_edited_by(self, user):
|
||||
def can_be_edited_by(self, user: User):
|
||||
return user.is_board_member or user.is_root
|
||||
|
||||
def is_valid_now(self):
|
||||
|
@ -31,7 +31,7 @@ from django.core.exceptions import ValidationError
|
||||
from datetime import timedelta, date
|
||||
|
||||
from core.models import User
|
||||
from core.utils import get_start_of_semester, get_semester
|
||||
from core.utils import get_start_of_semester, get_semester_code
|
||||
from club.models import Club
|
||||
|
||||
|
||||
@ -164,14 +164,14 @@ class TrombiUser(models.Model):
|
||||
if m.description:
|
||||
role += " (%s)" % m.description
|
||||
if m.end_date:
|
||||
end_date = get_semester(m.end_date)
|
||||
end_date = get_semester_code(m.end_date)
|
||||
else:
|
||||
end_date = ""
|
||||
TrombiClubMembership(
|
||||
user=self,
|
||||
club=str(m.club),
|
||||
role=role[:64],
|
||||
start=get_semester(m.start_date),
|
||||
start=get_semester_code(m.start_date),
|
||||
end=end_date,
|
||||
).save()
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user