diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8d72985b..b334a7c0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: # Run the formatter. - id: ruff-format - repo: https://github.com/biomejs/pre-commit - rev: "v0.1.0" # Use the sha / tag you want to point at + rev: v0.6.1 hooks: - id: biome-check additional_dependencies: ["@biomejs/biome@1.9.4"] diff --git a/com/api.py b/com/api.py index 6de78a3c..9dd70606 100644 --- a/com/api.py +++ b/com/api.py @@ -1,8 +1,6 @@ -from pathlib import Path from typing import Literal -from django.conf import settings -from django.http import Http404, HttpResponse +from django.http import HttpResponse from ninja import Query from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.pagination import PageNumberPaginationExtra @@ -18,23 +16,6 @@ from core.views.files import send_raw_file @api_controller("/calendar") class CalendarController(ControllerBase): - CACHE_FOLDER: Path = settings.MEDIA_ROOT / "com" / "calendars" - - @route.get("/external.ics", url_name="calendar_external") - def calendar_external(self): - """Return the ICS file of the AE Google Calendar - - Because of Google's cors rules, we can't just do a request to google ics - from the frontend. Google is blocking CORS request in its responses headers. - The only way to do it from the frontend is to use Google Calendar API with an API key - This is not especially desirable as your API key is going to be provided to the frontend. - - This is why we have this backend based solution. - """ - if (calendar := IcsCalendar.get_external()) is not None: - return send_raw_file(calendar) - raise Http404 - @route.get("/internal.ics", url_name="calendar_internal") def calendar_internal(self): return send_raw_file(IcsCalendar.get_internal()) diff --git a/com/ics_calendar.py b/com/ics_calendar.py index 1c95a2b3..e5324c8a 100644 --- a/com/ics_calendar.py +++ b/com/ics_calendar.py @@ -1,8 +1,6 @@ -from datetime import datetime, timedelta from pathlib import Path from typing import final -import requests from dateutil.relativedelta import relativedelta from django.conf import settings from django.db.models import F, QuerySet @@ -19,35 +17,8 @@ from core.models import User @final class IcsCalendar: _CACHE_FOLDER: Path = settings.MEDIA_ROOT / "com" / "calendars" - _EXTERNAL_CALENDAR = _CACHE_FOLDER / "external.ics" _INTERNAL_CALENDAR = _CACHE_FOLDER / "internal.ics" - @classmethod - def get_external(cls, expiration: timedelta = timedelta(hours=1)) -> Path | None: - if ( - cls._EXTERNAL_CALENDAR.exists() - and timezone.make_aware( - datetime.fromtimestamp(cls._EXTERNAL_CALENDAR.stat().st_mtime) - ) - + expiration - > timezone.now() - ): - return cls._EXTERNAL_CALENDAR - return cls.make_external() - - @classmethod - def make_external(cls) -> Path | None: - calendar = requests.get( - "https://calendar.google.com/calendar/ical/ae.utbm%40gmail.com/public/basic.ics" - ) - if not calendar.ok: - return None - - cls._CACHE_FOLDER.mkdir(parents=True, exist_ok=True) - with open(cls._EXTERNAL_CALENDAR, "wb") as f: - _ = f.write(calendar.content) - return cls._EXTERNAL_CALENDAR - @classmethod def get_internal(cls) -> Path: if not cls._INTERNAL_CALENDAR.exists(): diff --git a/com/static/bundled/com/components/ics-calendar-index.ts b/com/static/bundled/com/components/ics-calendar-index.ts index 0b4976b0..d8fc79d7 100644 --- a/com/static/bundled/com/components/ics-calendar-index.ts +++ b/com/static/bundled/com/components/ics-calendar-index.ts @@ -8,7 +8,6 @@ import dayGridPlugin from "@fullcalendar/daygrid"; import iCalendarPlugin from "@fullcalendar/icalendar"; import listPlugin from "@fullcalendar/list"; import { - calendarCalendarExternal, calendarCalendarInternal, calendarCalendarUnpublished, newsDeleteNews, @@ -151,11 +150,6 @@ export class IcsCalendar extends inheritHtmlElement("div") { format: "ics", className: "internal", }, - { - url: `${await makeUrl(calendarCalendarExternal)}${cacheInvalidate}`, - format: "ics", - className: "external", - }, { url: `${await makeUrl(calendarCalendarUnpublished)}${cacheInvalidate}`, format: "ics", @@ -224,9 +218,6 @@ export class IcsCalendar extends inheritHtmlElement("div") { }; const makePopupTools = (event: EventImpl) => { - if (event.source.internalEventSource.ui.classNames.includes("external")) { - return null; - } if (!(this.canDelete || this.canModerate)) { return null; } diff --git a/com/tests/test_api.py b/com/tests/test_api.py index 7c3bcb7b..bfb7bb94 100644 --- a/com/tests/test_api.py +++ b/com/tests/test_api.py @@ -1,8 +1,6 @@ from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import timedelta from pathlib import Path -from typing import Callable -from unittest.mock import MagicMock, patch from urllib.parse import quote import pytest @@ -11,7 +9,6 @@ from django.contrib.auth.models import Permission from django.http import HttpResponse from django.test import Client, TestCase from django.urls import reverse -from django.utils import timezone from django.utils.timezone import now from model_bakery import baker, seq from pytest_django.asserts import assertNumQueries @@ -41,78 +38,6 @@ def accel_redirect_to_file(response: HttpResponse) -> Path | None: ) -@pytest.mark.django_db -class TestExternalCalendar: - @pytest.fixture - def mock_request(self): - mock = MagicMock() - with patch("requests.get", mock): - yield mock - - @pytest.fixture - def mock_current_time(self): - mock = MagicMock() - original = timezone.now - with patch("django.utils.timezone.now", mock): - yield mock, original - - @pytest.fixture(autouse=True) - def clear_cache(self): - IcsCalendar._EXTERNAL_CALENDAR.unlink(missing_ok=True) - - def test_fetch_error(self, client: Client, mock_request: MagicMock): - mock_request.return_value = MockResponse(ok=False, value="not allowed") - assert client.get(reverse("api:calendar_external")).status_code == 404 - - def test_fetch_success(self, client: Client, mock_request: MagicMock): - external_response = MockResponse(ok=True, value="Definitely an ICS") - mock_request.return_value = external_response - response = client.get(reverse("api:calendar_external")) - assert response.status_code == 200 - out_file = accel_redirect_to_file(response) - assert out_file is not None - assert out_file.exists() - with open(out_file, "r") as f: - assert f.read() == external_response.value - - def test_fetch_caching( - self, - client: Client, - mock_request: MagicMock, - mock_current_time: tuple[MagicMock, Callable[[], datetime]], - ): - fake_current_time, original_timezone = mock_current_time - start_time = original_timezone() - - fake_current_time.return_value = start_time - external_response = MockResponse(200, "Definitely an ICS") - mock_request.return_value = external_response - - with open( - accel_redirect_to_file(client.get(reverse("api:calendar_external"))), "r" - ) as f: - assert f.read() == external_response.value - - mock_request.return_value = MockResponse(200, "This should be ignored") - with open( - accel_redirect_to_file(client.get(reverse("api:calendar_external"))), "r" - ) as f: - assert f.read() == external_response.value - - mock_request.assert_called_once() - - fake_current_time.return_value = start_time + timedelta(hours=1, seconds=1) - external_response = MockResponse(200, "This won't be ignored") - mock_request.return_value = external_response - - with open( - accel_redirect_to_file(client.get(reverse("api:calendar_external"))), "r" - ) as f: - assert f.read() == external_response.value - - assert mock_request.call_count == 2 - - @pytest.mark.django_db class TestInternalCalendar: @pytest.fixture(autouse=True) diff --git a/com/tests/test_views.py b/com/tests/test_views.py index 03d28adc..607d4b3f 100644 --- a/com/tests/test_views.py +++ b/com/tests/test_views.py @@ -19,7 +19,7 @@ import pytest from django.conf import settings from django.contrib.sites.models import Site from django.core.files.uploadedfile import SimpleUploadedFile -from django.test import TestCase +from django.test import Client, TestCase from django.urls import reverse from django.utils import html from django.utils.timezone import localtime, now @@ -323,7 +323,7 @@ class TestNewsCreation(TestCase): @pytest.mark.django_db -def test_feed(client): +def test_feed(client: Client): """Smoke test that checks that the atom feed is working""" Site.objects.clear_cache() with assertNumQueries(2): @@ -332,3 +332,22 @@ def test_feed(client): resp = client.get(reverse("com:news_feed")) assert resp.status_code == 200 assert resp.headers["Content-Type"] == "application/rss+xml; charset=utf-8" + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "url", + [ + reverse("com:poster_list"), + reverse("com:poster_create"), + reverse("com:poster_moderate_list"), + ], +) +def test_poster_management_views_crash_test(client: Client, url: str): + """Test that poster management views work""" + user = baker.make( + User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)] + ) + client.force_login(user) + res = client.get(url) + assert res.status_code == 200 diff --git a/com/views.py b/com/views.py index f6e12fd2..024cb781 100644 --- a/com/views.py +++ b/com/views.py @@ -61,8 +61,7 @@ sith = Sith.objects.first class ComTabsMixin(TabedViewMixin): - def get_tabs_title(self): - return _("Communication administration") + tabs_title = _("Communication administration") def get_list_of_tabs(self): return [ @@ -559,7 +558,11 @@ class MailingModerateView(View): raise PermissionDenied -class PosterListBaseView(ListView): +class PosterAdminViewMixin(IsComAdminMixin, ComTabsMixin): + current_tab = "posters" + + +class PosterListBaseView(PosterAdminViewMixin, ListView): """List communication posters.""" current_tab = "posters" @@ -586,7 +589,7 @@ class PosterListBaseView(ListView): return kwargs -class PosterCreateBaseView(CreateView): +class PosterCreateBaseView(PosterAdminViewMixin, CreateView): """Create communication poster.""" current_tab = "posters" @@ -618,7 +621,7 @@ class PosterCreateBaseView(CreateView): return super().form_valid(form) -class PosterEditBaseView(UpdateView): +class PosterEditBaseView(PosterAdminViewMixin, UpdateView): """Edit communication poster.""" pk_url_kwarg = "poster_id" @@ -664,7 +667,7 @@ class PosterEditBaseView(UpdateView): return super().form_valid(form) -class PosterDeleteBaseView(DeleteView): +class PosterDeleteBaseView(PosterAdminViewMixin, DeleteView): """Edit communication poster.""" pk_url_kwarg = "poster_id" @@ -681,7 +684,7 @@ class PosterDeleteBaseView(DeleteView): return super().dispatch(request, *args, **kwargs) -class PosterListView(IsComAdminMixin, ComTabsMixin, PosterListBaseView): +class PosterListView(PosterListBaseView): """List communication posters.""" def get_context_data(self, **kwargs): @@ -690,7 +693,7 @@ class PosterListView(IsComAdminMixin, ComTabsMixin, PosterListBaseView): return kwargs -class PosterCreateView(IsComAdminMixin, ComTabsMixin, PosterCreateBaseView): +class PosterCreateView(PosterCreateBaseView): """Create communication poster.""" success_url = reverse_lazy("com:poster_list") @@ -701,7 +704,7 @@ class PosterCreateView(IsComAdminMixin, ComTabsMixin, PosterCreateBaseView): return kwargs -class PosterEditView(IsComAdminMixin, ComTabsMixin, PosterEditBaseView): +class PosterEditView(PosterEditBaseView): """Edit communication poster.""" success_url = reverse_lazy("com:poster_list") @@ -712,13 +715,13 @@ class PosterEditView(IsComAdminMixin, ComTabsMixin, PosterEditBaseView): return kwargs -class PosterDeleteView(IsComAdminMixin, ComTabsMixin, PosterDeleteBaseView): +class PosterDeleteView(PosterDeleteBaseView): """Delete communication poster.""" success_url = reverse_lazy("com:poster_list") -class PosterModerateListView(IsComAdminMixin, ComTabsMixin, ListView): +class PosterModerateListView(PosterAdminViewMixin, ListView): """Moderate list communication poster.""" current_tab = "posters" @@ -732,7 +735,7 @@ class PosterModerateListView(IsComAdminMixin, ComTabsMixin, ListView): return kwargs -class PosterModerateView(IsComAdminMixin, ComTabsMixin, View): +class PosterModerateView(PosterAdminViewMixin, View): """Moderate communication poster.""" def get(self, request, *args, **kwargs): diff --git a/core/tests/test_user.py b/core/tests/test_user.py index 133f26a5..d14bfc3c 100644 --- a/core/tests/test_user.py +++ b/core/tests/test_user.py @@ -4,9 +4,10 @@ import pytest from django.conf import settings from django.contrib import auth from django.core.management import call_command -from django.test import Client, TestCase +from django.test import Client, RequestFactory, TestCase from django.urls import reverse from django.utils.timezone import now +from django.views.generic import DetailView from model_bakery import baker, seq from model_bakery.recipe import Recipe, foreign_key from pytest_django.asserts import assertRedirects @@ -18,6 +19,7 @@ from core.baker_recipes import ( very_old_subscriber_user, ) from core.models import Group, User +from core.views import UserTabsMixin from counter.models import Counter, Refilling, Selling from eboutic.models import Invoice, InvoiceItem @@ -229,3 +231,88 @@ def test_logout(client: Client): res = client.post(reverse("core:logout")) assertRedirects(res, reverse("core:login")) assert auth.get_user(client).is_anonymous + + +class UserTabTestView(UserTabsMixin, DetailView): ... + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ["user_factory", "expected_tabs"], + [ + ( + subscriber_user.make, + [ + "infos", + "godfathers", + "pictures", + "tools", + "edit", + "prefs", + "clubs", + "stats", + "account", + ], + ), + ( + # this user is superuser, but still won't see a few tabs, + # because he is not subscribed + lambda: baker.make(User, is_superuser=True), + [ + "infos", + "godfathers", + "pictures", + "tools", + "edit", + "prefs", + "clubs", + "groups", + ], + ), + ], +) +def test_displayed_user_self_tabs(user_factory, expected_tabs: list[str]): + """Test that a user can view the appropriate tabs in its own profile""" + user = user_factory() + request = RequestFactory().get("") + request.user = user + view = UserTabTestView() + view.setup(request) + view.object = user + tabs = [tab["slug"] for tab in view.get_list_of_tabs()] + assert tabs == expected_tabs + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ["user_factory", "expected_tabs"], + [ + (subscriber_user.make, ["infos", "godfathers", "pictures", "clubs"]), + ( + # this user is superuser, but still won't see a few tabs, + # because he is not subscribed + lambda: baker.make(User, is_superuser=True), + [ + "infos", + "godfathers", + "pictures", + "edit", + "prefs", + "clubs", + "groups", + "stats", + "account", + ], + ), + ], +) +def test_displayed_other_user_tabs(user_factory, expected_tabs: list[str]): + """Test that a user can view the appropriate tabs in another user's profile.""" + request_user = user_factory() + request = RequestFactory().get("") + request.user = request_user + view = UserTabTestView() + view.setup(request) + view.object = subscriber_user.make() # user whose page is being seen + tabs = [tab["slug"] for tab in view.get_list_of_tabs()] + assert tabs == expected_tabs diff --git a/core/views/user.py b/core/views/user.py index a9ce811f..cd27cbba 100644 --- a/core/views/user.py +++ b/core/views/user.py @@ -242,7 +242,10 @@ class UserTabsMixin(TabedViewMixin): if ( hasattr(user, "customer") and user.customer - and (user == self.request.user or user.has_perm("counter.view_customer")) + and ( + user == self.request.user + or self.request.user.has_perm("counter.view_customer") + ) ): tab_list.append( { diff --git a/counter/migrations/0031_alter_counter_options.py b/counter/migrations/0031_alter_counter_options.py new file mode 100644 index 00000000..c6d68529 --- /dev/null +++ b/counter/migrations/0031_alter_counter_options.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.20 on 2025-04-06 11:29 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("counter", "0030_returnableproduct_returnableproductbalance_and_more") + ] + + operations = [ + migrations.AlterModelOptions( + name="counter", + options={ + "permissions": [("view_counter_stats", "Can view counter stats")], + "verbose_name": "counter", + }, + ), + ] diff --git a/counter/models.py b/counter/models.py index ee6088d9..8581b19d 100644 --- a/counter/models.py +++ b/counter/models.py @@ -526,6 +526,7 @@ class Counter(models.Model): class Meta: verbose_name = _("counter") + permissions = [("view_counter_stats", "Can view counter stats")] def __str__(self): return self.name @@ -598,13 +599,12 @@ class Counter(models.Model): - the promo of the barman - the total number of office hours the barman did attend """ + name_expr = Concat(F("user__first_name"), Value(" "), F("user__last_name")) return ( self.permanencies.exclude(end=None) .annotate( - name=Concat(F("user__first_name"), Value(" "), F("user__last_name")) + name=name_expr, nickname=F("user__nick_name"), promo=F("user__promo") ) - .annotate(nickname=F("user__nick_name")) - .annotate(promo=F("user__promo")) .values("user", "name", "nickname", "promo") .annotate(perm_sum=Sum(F("end") - F("start"))) .exclude(perm_sum=None) @@ -628,18 +628,17 @@ class Counter(models.Model): since = get_start_of_semester() if isinstance(since, date): since = datetime(since.year, since.month, since.day, tzinfo=tz.utc) + name_expr = Concat( + F("customer__user__first_name"), Value(" "), F("customer__user__last_name") + ) return ( self.sellings.filter(date__gte=since) .annotate( - name=Concat( - F("customer__user__first_name"), - Value(" "), - F("customer__user__last_name"), - ) + name=name_expr, + nickname=F("customer__user__nick_name"), + promo=F("customer__user__promo"), + user=F("customer__user"), ) - .annotate(nickname=F("customer__user__nick_name")) - .annotate(promo=F("customer__user__promo")) - .annotate(user=F("customer__user")) .values("user", "promo", "name", "nickname") .annotate( selling_sum=Sum( diff --git a/counter/tests/test_counter.py b/counter/tests/test_counter.py index f0773897..e3b885b7 100644 --- a/counter/tests/test_counter.py +++ b/counter/tests/test_counter.py @@ -18,7 +18,7 @@ from decimal import Decimal import pytest from django.conf import settings -from django.contrib.auth.models import make_password +from django.contrib.auth.models import Permission, make_password from django.core.cache import cache from django.http import HttpResponse from django.shortcuts import resolve_url @@ -28,9 +28,10 @@ from django.utils import timezone from django.utils.timezone import localdate, now from freezegun import freeze_time from model_bakery import baker +from model_bakery.recipe import Recipe from pytest_django.asserts import assertRedirects -from club.models import Club, Membership +from club.models import Membership from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user from core.models import BanGroup, User from counter.baker_recipes import product_recipe, sale_recipe @@ -572,121 +573,86 @@ class TestCounterClick(TestFullClickBase): class TestCounterStats(TestCase): @classmethod def setUpTestData(cls): - cls.counter = Counter.objects.get(id=2) - cls.krophil = User.objects.get(username="krophil") - cls.skia = User.objects.get(username="skia") - cls.sli = User.objects.get(username="sli") - cls.root = User.objects.get(username="root") - cls.subscriber = User.objects.get(username="subscriber") - cls.old_subscriber = User.objects.get(username="old_subscriber") - cls.counter.sellers.add(cls.sli, cls.root, cls.skia, cls.krophil) - - barbar = Product.objects.get(code="BARB") - - # remove everything to make sure the fixtures bring no side effect - Permanency.objects.all().delete() - Selling.objects.all().delete() - - now = timezone.now() - # total of sli : 5 hours - Permanency.objects.create( - user=cls.sli, start=now, end=now + timedelta(hours=1), counter=cls.counter - ) - Permanency.objects.create( - user=cls.sli, - start=now + timedelta(hours=4), - end=now + timedelta(hours=6), - counter=cls.counter, - ) - Permanency.objects.create( - user=cls.sli, - start=now + timedelta(hours=7), - end=now + timedelta(hours=9), - counter=cls.counter, + cls.users = subscriber_user.make(_quantity=4) + product = product_recipe.make(selling_price=1) + cls.counter = baker.make( + Counter, type=["BAR"], sellers=cls.users[:4], products=[product] ) - # total of skia : 16 days, 2 hours, 35 minutes and 54 seconds - Permanency.objects.create( - user=cls.skia, start=now, end=now + timedelta(hours=1), counter=cls.counter - ) - Permanency.objects.create( - user=cls.skia, - start=now + timedelta(days=4, hours=1), - end=now + timedelta(days=20, hours=2, minutes=35, seconds=54), - counter=cls.counter, - ) + _now = timezone.now() + permanence_recipe = Recipe(Permanency, counter=cls.counter) + perms = [ + *[ # total of user 0 : 5 hours + permanence_recipe.prepare(user=cls.users[0], start=start, end=end) + for start, end in [ + (_now, _now + timedelta(hours=1)), + (_now + timedelta(hours=4), _now + timedelta(hours=6)), + (_now + timedelta(hours=7), _now + timedelta(hours=9)), + ] + ], + *[ # total of user 1 : 16 days, 2 hours, 35 minutes and 54 seconds + permanence_recipe.prepare(user=cls.users[1], start=start, end=end) + for start, end in [ + (_now, _now + timedelta(hours=1)), + ( + _now + timedelta(days=4, hours=1), + _now + timedelta(days=20, hours=2, minutes=35, seconds=54), + ), + ] + ], + *[ # total of user 2 : 2 hour + 20 hours (but the 20 hours were on last year) + permanence_recipe.prepare(user=cls.users[2], start=start, end=end) + for start, end in [ + (_now + timedelta(days=5), _now + timedelta(days=5, hours=1)), + (_now - timedelta(days=300, hours=20), _now - timedelta(days=300)), + ] + ], + ] + # user 3 has 0 hours of permanence + Permanency.objects.bulk_create(perms) - # total of root : 1 hour + 20 hours (but the 20 hours were on last year) - Permanency.objects.create( - user=cls.root, - start=now + timedelta(days=5), - end=now + timedelta(days=5, hours=1), - counter=cls.counter, - ) - Permanency.objects.create( - user=cls.root, - start=now - timedelta(days=300, hours=20), - end=now - timedelta(days=300), - counter=cls.counter, - ) - - # total of krophil : 0 hour - s = Selling( - label=barbar.name, - product=barbar, - club=baker.make(Club), + _sale_recipe = Recipe( + Selling, + club=cls.counter.club, counter=cls.counter, + product=product, unit_price=2, - seller=cls.skia, ) + sales = [ + *_sale_recipe.prepare( + quantity=100, customer=cls.users[0].customer, _quantity=10 + ), # 2000 € + *_sale_recipe.prepare( + quantity=100, customer=cls.users[1].customer, _quantity=5 + ), # 1000 € + _sale_recipe.prepare(quantity=1, customer=cls.users[2].customer), # 2€ + _sale_recipe.prepare(quantity=50, customer=cls.users[3].customer), # 100€ + ] + Selling.objects.bulk_create(sales) - krophil_customer = Customer.get_or_create(cls.krophil)[0] - sli_customer = Customer.get_or_create(cls.sli)[0] - skia_customer = Customer.get_or_create(cls.skia)[0] - root_customer = Customer.get_or_create(cls.root)[0] - - # moderate drinker. Total : 100 € - s.quantity = 50 - s.customer = krophil_customer - s.save(allow_negative=True) - - # Sli is a drunkard. Total : 2000 € - s.quantity = 100 - s.customer = sli_customer - for _ in range(10): - # little trick to make sure the instance is duplicated in db - s.pk = None - s.save(allow_negative=True) # save ten different sales - - # Skia is a heavy drinker too. Total : 1000 € - s.customer = skia_customer - for _ in range(5): - s.pk = None - s.save(allow_negative=True) - - # Root is quite an abstemious one. Total : 2 € - s.pk = None - s.quantity = 1 - s.customer = root_customer - s.save(allow_negative=True) - - def test_not_authenticated_user_fail(self): - # Test with not login user - response = self.client.get(reverse("counter:stats", args=[self.counter.id])) - assert response.status_code == 403 + def test_not_authenticated_access_fail(self): + url = reverse("counter:stats", args=[self.counter.id]) + response = self.client.get(url) + assertRedirects(response, reverse("core:login") + f"?next={url}") def test_unauthorized_user_fails(self): - self.client.force_login(User.objects.get(username="public")) + self.client.force_login(baker.make(User)) response = self.client.get(reverse("counter:stats", args=[self.counter.id])) assert response.status_code == 403 + def test_authorized_user_ok(self): + perm = Permission.objects.get(codename="view_counter_stats") + self.client.force_login(baker.make(User, user_permissions=[perm])) + response = self.client.get(reverse("counter:stats", args=[self.counter.id])) + assert response.status_code == 200 + def test_get_total_sales(self): """Test the result of the Counter.get_total_sales() method.""" assert self.counter.get_total_sales() == 3102 def test_top_barmen(self): """Test the result of Counter.get_top_barmen() is correct.""" - users = [self.skia, self.root, self.sli] + users = [self.users[1], self.users[2], self.users[0]] perm_times = [ timedelta(days=16, hours=2, minutes=35, seconds=54), timedelta(hours=21), @@ -700,12 +666,12 @@ class TestCounterStats(TestCase): "nickname": user.nick_name, "perm_sum": perm_time, } - for user, perm_time in zip(users, perm_times, strict=False) + for user, perm_time in zip(users, perm_times, strict=True) ] def test_top_customer(self): """Test the result of Counter.get_top_customers() is correct.""" - users = [self.sli, self.skia, self.krophil, self.root] + users = [self.users[0], self.users[1], self.users[3], self.users[2]] sale_amounts = [2000, 1000, 100, 2] assert list(self.counter.get_top_customers()) == [ { @@ -715,7 +681,7 @@ class TestCounterStats(TestCase): "nickname": user.nick_name, "selling_sum": sale_amount, } - for user, sale_amount in zip(users, sale_amounts, strict=False) + for user, sale_amount in zip(users, sale_amounts, strict=True) ] diff --git a/counter/views/admin.py b/counter/views/admin.py index f7d4a66b..ddd7a40e 100644 --- a/counter/views/admin.py +++ b/counter/views/admin.py @@ -27,7 +27,7 @@ from django.utils.translation import gettext as _ from django.views.generic import DetailView, ListView, TemplateView from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView -from core.auth.mixins import CanEditMixin, CanViewMixin +from core.auth.mixins import CanViewMixin from core.utils import get_semester_code, get_start_of_semester from counter.forms import ( CloseCustomerAccountForm, @@ -274,12 +274,13 @@ class SellingDeleteView(DeleteView): raise PermissionDenied -class CounterStatView(DetailView, CounterAdminMixin): +class CounterStatView(PermissionRequiredMixin, DetailView): """Show the bar stats.""" model = Counter pk_url_kwarg = "counter_id" template_name = "counter/stats.jinja" + permission_required = "counter.view_counter_stats" def get_context_data(self, **kwargs): """Add stats to the context.""" @@ -301,18 +302,6 @@ class CounterStatView(DetailView, CounterAdminMixin): ) return kwargs - def dispatch(self, request, *args, **kwargs): - try: - return super().dispatch(request, *args, **kwargs) - except PermissionDenied: - if ( - request.user.is_root - or request.user.is_board_member - or self.get_object().is_owned_by(request.user) - ): - return super(CanEditMixin, self).dispatch(request, *args, **kwargs) - raise PermissionDenied - class CounterRefillingListView(CounterAdminTabsMixin, CounterAdminMixin, ListView): """List of refillings on a counter.""" diff --git a/eboutic/api.py b/eboutic/api.py index 0054c02a..797adf20 100644 --- a/eboutic/api.py +++ b/eboutic/api.py @@ -26,7 +26,7 @@ class EtransactionInfoController(ControllerBase): customer=customer, defaults=info.model_dump(exclude_none=True) ) - @route.get("/data", url_name="etransaction_data", include_in_schema=False) + @route.get("/data", url_name="etransaction_data") def fetch_etransaction_data(self): """Generate the data to pay an eboutic command with paybox. diff --git a/eboutic/static/bundled/eboutic/makecommand-index.ts b/eboutic/static/bundled/eboutic/makecommand-index.ts new file mode 100644 index 00000000..c1e4b52f --- /dev/null +++ b/eboutic/static/bundled/eboutic/makecommand-index.ts @@ -0,0 +1,91 @@ +import { exportToHtml } from "#core:utils/globals"; +import { + type BillingInfoSchema, + etransactioninfoFetchEtransactionData, + etransactioninfoPutUserBillingInfo, +} from "#openapi"; + +enum BillingInfoReqState { + Success = "0", + Failure = "1", + Sending = "2", +} + +exportToHtml("BillingInfoReqState", BillingInfoReqState); + +document.addEventListener("alpine:init", () => { + Alpine.data("etransactionData", (initialData) => ({ + data: initialData, + + async fill() { + const button = document.getElementById("bank-submit-button") as HTMLButtonElement; + button.disabled = true; + const res = await etransactioninfoFetchEtransactionData(); + if (res.response.ok) { + this.data = res.data; + button.disabled = false; + } + }, + })); + + Alpine.data("billing_infos", (userId: number) => ({ + /** @type {BillingInfoReqState | null} */ + reqState: null, + + async sendForm() { + this.reqState = BillingInfoReqState.Sending; + const form = document.getElementById("billing_info_form"); + const submitButton = document.getElementById( + "bank-submit-button", + ) as HTMLButtonElement; + submitButton.disabled = true; + const payload = Object.fromEntries( + Array.from(form.querySelectorAll("input, select")) + .filter((elem: HTMLInputElement) => elem.type !== "submit" && elem.value) + .map((elem: HTMLInputElement) => [elem.name, elem.value]), + ); + const res = await etransactioninfoPutUserBillingInfo({ + // biome-ignore lint/style/useNamingConvention: API is snake_case + path: { user_id: userId }, + body: payload as unknown as BillingInfoSchema, + }); + this.reqState = res.response.ok + ? BillingInfoReqState.Success + : BillingInfoReqState.Failure; + if (res.response.status === 422) { + const errors = await res.response + .json() + .detail.flatMap((err: Record<"loc", string>) => err.loc); + for (const elem of Array.from(form.querySelectorAll("input")).filter((elem) => + errors.includes(elem.name), + )) { + elem.setCustomValidity(gettext("Incorrect value")); + elem.reportValidity(); + elem.oninput = () => elem.setCustomValidity(""); + } + } else if (res.response.ok) { + this.$dispatch("billing-infos-filled"); + } + }, + + getAlertColor() { + if (this.reqState === BillingInfoReqState.Success) { + return "green"; + } + if (this.reqState === BillingInfoReqState.Failure) { + return "red"; + } + return ""; + }, + + getAlertMessage() { + if (this.reqState === BillingInfoReqState.Success) { + return gettext("Billing info registration success"); + } + if (this.reqState === BillingInfoReqState.Failure) { + return gettext("Billing info registration failure"); + } + return ""; + }, + })); +}); diff --git a/eboutic/static/eboutic/css/eboutic.css b/eboutic/static/eboutic/css/eboutic.css index abf121d0..6ca6beef 100644 --- a/eboutic/static/eboutic/css/eboutic.css +++ b/eboutic/static/eboutic/css/eboutic.css @@ -158,4 +158,3 @@ flex-direction: column; } } - diff --git a/eboutic/static/eboutic/js/makecommand.js b/eboutic/static/eboutic/js/makecommand.js deleted file mode 100644 index 3ccb4280..00000000 --- a/eboutic/static/eboutic/js/makecommand.js +++ /dev/null @@ -1,87 +0,0 @@ -/** - * @readonly - * @enum {number} - */ -const BillingInfoReqState = { - // biome-ignore lint/style/useNamingConvention: this feels more like an enum - SUCCESS: 1, - // biome-ignore lint/style/useNamingConvention: this feels more like an enum - FAILURE: 2, - // biome-ignore lint/style/useNamingConvention: this feels more like an enum - SENDING: 3, -}; - -document.addEventListener("alpine:init", () => { - Alpine.store("billing_inputs", { - // biome-ignore lint/correctness/noUndeclaredVariables: defined in eboutic_makecommand.jinja - data: etData, - - async fill() { - document.getElementById("bank-submit-button").disabled = true; - // biome-ignore lint/correctness/noUndeclaredVariables: defined in eboutic_makecommand.jinja - const res = await fetch(etDataUrl); - if (res.ok) { - this.data = await res.json(); - document.getElementById("bank-submit-button").disabled = false; - } - }, - }); - - Alpine.data("billing_infos", () => ({ - /** @type {BillingInfoReqState | null} */ - reqState: null, - - async sendForm() { - this.reqState = BillingInfoReqState.SENDING; - const form = document.getElementById("billing_info_form"); - document.getElementById("bank-submit-button").disabled = true; - const payload = Object.fromEntries( - Array.from(form.querySelectorAll("input, select")) - .filter((elem) => elem.type !== "submit" && elem.value) - .map((elem) => [elem.name, elem.value]), - ); - // biome-ignore lint/correctness/noUndeclaredVariables: defined in eboutic_makecommand.jinja - const res = await fetch(billingInfoUrl, { - method: "PUT", - body: JSON.stringify(payload), - }); - this.reqState = res.ok - ? BillingInfoReqState.SUCCESS - : BillingInfoReqState.FAILURE; - if (res.status === 422) { - const errors = (await res.json()).detail.flatMap((err) => err.loc); - for (const elem of Array.from(form.querySelectorAll("input")).filter((elem) => - errors.includes(elem.name), - )) { - elem.setCustomValidity(gettext("Incorrect value")); - elem.reportValidity(); - elem.oninput = () => elem.setCustomValidity(""); - } - } else if (res.ok) { - Alpine.store("billing_inputs").fill(); - } - }, - - getAlertColor() { - if (this.reqState === BillingInfoReqState.SUCCESS) { - return "green"; - } - if (this.reqState === BillingInfoReqState.FAILURE) { - return "red"; - } - return ""; - }, - - getAlertMessage() { - if (this.reqState === BillingInfoReqState.SUCCESS) { - // biome-ignore lint/correctness/noUndeclaredVariables: defined in eboutic_makecommand.jinja - return billingInfoSuccessMessage; - } - if (this.reqState === BillingInfoReqState.FAILURE) { - // biome-ignore lint/correctness/noUndeclaredVariables: defined in eboutic_makecommand.jinja - return billingInfoFailureMessage; - } - return ""; - }, - })); -}); diff --git a/eboutic/templates/eboutic/eboutic_main.jinja b/eboutic/templates/eboutic/eboutic_main.jinja index 3e6049f2..ee563dd0 100644 --- a/eboutic/templates/eboutic/eboutic_main.jinja +++ b/eboutic/templates/eboutic/eboutic_main.jinja @@ -78,7 +78,11 @@ {% if not request.user.date_of_birth %}
+ {%- trans trimmed %} + You must be subscribed to benefit from the partnership with the Eurockéennes. + {% endtrans -%} +
++ {%- trans trimmed %} + This partnership offers a discount of up to 33% + on tickets for Friday, Saturday and Sunday, + as well as the 3-day package from Friday to Sunday. + {% endtrans -%} +
+ {% endif %} +- {% trans %}Basket amount: {% endtrans %}{{ "%0.2f"|format(basket.total) }} € +
+ {% trans %}Basket amount: {% endtrans %}{{ "%0.2f"|format(basket.total) }} €
- {% if customer_amount != None %}
-
- {% trans %}Current account amount: {% endtrans %}
- {{ "%0.2f"|format(customer_amount) }} €
-
- {% if not basket.contains_refilling_item %}
-
- {% trans %}Remaining account amount: {% endtrans %}
- {{ "%0.2f"|format(customer_amount|float - basket.total) }} €
- {% endif %}
- {% endif %}
-
{% trans %}AE account payment disabled because your basket contains refilling items.{% endtrans %}
- {% elif basket.total > user.account_balance %} -{% trans %}AE account payment disabled because you do not have enough money remaining.{% endtrans %}
- {% else %} - + {% trans %}Remaining account amount: {% endtrans %} + {{ "%0.2f"|format(customer_amount|float - basket.total) }} € {% endif %} + {% endif %} + +{% trans %}AE account payment disabled because your basket contains refilling items.{% endtrans %}
+ {% elif basket.total > user.account_balance %} +{% trans %}AE account payment disabled because you do not have enough money remaining.{% endtrans %}
+ {% else %} + + {% endif %} + {% endblock %} {% block script %} {{ super() }} {% endblock %} diff --git a/eboutic/views.py b/eboutic/views.py index 14f129fd..dfa79c22 100644 --- a/eboutic/views.py +++ b/eboutic/views.py @@ -26,7 +26,9 @@ from cryptography.hazmat.primitives.hashes import SHA1 from cryptography.hazmat.primitives.serialization import load_pem_public_key from django.conf import settings from django.contrib.auth.decorators import login_required -from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import ( + LoginRequiredMixin, +) from django.core.exceptions import SuspiciousOperation from django.db import DatabaseError, transaction from django.http import HttpRequest, HttpResponse diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 25d03fa4..1e944299 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-04-04 10:35+0200\n" +"POT-Creation-Date: 2025-04-06 15:54+0200\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal