diff --git a/core/static/core/style.scss b/core/static/core/style.scss index 7abfa8ca..9ba72e7d 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -1031,7 +1031,7 @@ thead { } tbody > tr { - &:nth-child(even) { + &:nth-child(even):not(.highlight) { background: $primary-neutral-light-color; } &.clickable:hover { diff --git a/core/tests.py b/core/tests.py index a2e9c526..61d497a5 100644 --- a/core/tests.py +++ b/core/tests.py @@ -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) diff --git a/core/utils.py b/core/utils.py index a053e2d5..d30e3ebf 100644 --- a/core/utils.py +++ b/core/utils.py @@ -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): diff --git a/counter/models.py b/counter/models.py index c6389349..476aaf13 100644 --- a/counter/models.py +++ b/counter/models.py @@ -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( diff --git a/counter/templates/counter/stats.jinja b/counter/templates/counter/stats.jinja index 03b7f4e0..d6cc14f0 100644 --- a/counter/templates/counter/stats.jinja +++ b/counter/templates/counter/stats.jinja @@ -11,7 +11,9 @@ {% block content %}

{% trans counter_name=counter %}{{ counter_name }} stats{% endtrans %}

-

{% trans counter_name=counter.name %}Top 100 {{ counter_name }}{% endtrans %}

+

+ {% trans counter_name=counter.name %}Top 100 {{ counter_name }}{% endtrans %} ({{ current_semester }}) +

@@ -35,7 +37,9 @@
-

{% trans counter_name=counter.name %}Top 100 barman {{ counter_name }}{% endtrans %}

+

+ {% trans counter_name=counter.name %}Top 100 barman {{ counter_name }}{% endtrans %} ({{ current_semester }}) +

diff --git a/counter/tests.py b/counter/tests.py index ed83e935..6079099a 100644 --- a/counter/tests.py +++ b/counter/tests.py @@ -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)) diff --git a/counter/views.py b/counter/views.py index 4d5af292..6bbc819d 100644 --- a/counter/views.py +++ b/counter/views.py @@ -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], diff --git a/poetry.lock b/poetry.lock index ff8f76b3..23ef0bda 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 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" @@ -550,6 +550,20 @@ 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" @@ -1558,4 +1572,4 @@ testing = ["coverage"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "9d38fb0dd50ef0a154bd6690afcb24be9fbdb2adbba1c4762b1ed0cdb9508eb2" +content-hash = "62519616aff5a472dac3dd8071a6404b1ee8eab12a197af717a0520f7ded0331" diff --git a/pyproject.toml b/pyproject.toml index d2fca6f0..cb1a6aaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/sith/settings.py b/sith/settings.py index 26a013a0..5ed279af 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -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 diff --git a/subscription/models.py b/subscription/models.py index ee4334f1..f1c2b2d5 100644 --- a/subscription/models.py +++ b/subscription/models.py @@ -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): diff --git a/trombi/models.py b/trombi/models.py index e4439c1a..18f36526 100644 --- a/trombi/models.py +++ b/trombi/models.py @@ -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()