Merge pull request #1509 from ae-utbm/taiste

Third-party auth, UE guide style, CGU, and bugfixes
This commit is contained in:
thomas girod authored and GitHub committed 2026-10-02 18:51:20 +02:00
commit 86730d4f7a
71 files changed
+2885 -1694

No files matched your search

+2 -2
View File
@@ -1,7 +1,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.16.0
rev: v0.16.10
hooks:
- id: ruff-check # just check the code, and print the errors
- id: ruff-check # actually fix the fixable errors, but print nothing
@@ -12,7 +12,7 @@ repos:
rev: v0.6.1
hooks:
- id: biome-check
additional_dependencies: ["@biomejs/biome@2.5.1"]
additional_dependencies: ["@biomejs/biome@2.5.15"]
- repo: https://github.com/rtts/djhtml
rev: 3.0.11
hooks:
+9
View File
@@ -21,6 +21,15 @@ class ApiClientAdmin(admin.ModelAdmin):
"owner__nick_name",
)
autocomplete_fields = ("owner", "groups", "client_permissions")
readonly_fields = ("hmac_key",)
actions = ("reset_hmac_key",)
@admin.action(permissions=["change"], description=_("Reset HMAC key"))
def reset_hmac_key(self, _request: HttpRequest, queryset: QuerySet[ApiClient]):
objs = list(queryset)
for obj in objs:
obj.reset_hmac(commit=False)
ApiClient.objects.bulk_update(objs, fields=["hmac_key"])
@admin.register(ApiKey)
+16
View File
@@ -0,0 +1,16 @@
from ninja_extra import ControllerBase, api_controller, route
from api.auth import ApiKeyAuth
from api.schemas import ApiClientSchema
@api_controller("/client")
class ApiClientController(ControllerBase):
@route.get(
"/me",
auth=[ApiKeyAuth()],
response=ApiClientSchema,
url_name="api-client-infos",
)
def get_client_info(self):
return self.context.request.auth
+35
View File
@@ -0,0 +1,35 @@
from django import forms
from django.forms import HiddenInput
from django.utils.translation import gettext_lazy as _
class ThirdPartyAuthForm(forms.Form):
"""Form to complete to authenticate on the sith from a third-party app.
For the form to be valid, the user approve the EULA (french: CGU)
and give its username from the third-party app.
"""
cgu_accepted = forms.BooleanField(
required=True,
label=_("I have read and I accept the terms and conditions of use"),
error_messages={
"required": _("You must approve the terms and conditions of use.")
},
)
is_username_valid = forms.BooleanField(
required=True,
error_messages={"required": _("You must confirm that this is your username.")},
)
client_id = forms.IntegerField(widget=HiddenInput())
third_party_app = forms.CharField(widget=HiddenInput())
privacy_link = forms.URLField(widget=HiddenInput())
username = forms.CharField(widget=HiddenInput())
callback_url = forms.URLField(widget=HiddenInput())
signature = forms.CharField(widget=HiddenInput())
def __init__(self, *args, label_suffix: str = "", initial, **kwargs):
super().__init__(*args, label_suffix=label_suffix, initial=initial, **kwargs)
self.fields["is_username_valid"].label = _(
"I confirm that %(username)s is my username on %(app)s"
) % {"username": initial.get("username"), "app": initial.get("third_party_app")}
+19
View File
@@ -0,0 +1,19 @@
# Generated by Django 5.2.3 on 2025-10-26 10:15
from django.db import migrations, models
import api.models
class Migration(migrations.Migration):
dependencies = [("api", "0001_initial")]
operations = [
migrations.AddField(
model_name="apiclient",
name="hmac_key",
field=models.CharField(
default=api.models.get_hmac_key, max_length=128, verbose_name="HMAC Key"
),
),
]
+33 -22
View File
@@ -1,13 +1,20 @@
import secrets
from typing import Iterable
from django.contrib.auth.models import Permission
from django.db import models
from django.db.models import Q
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext_lazy
from core.models import Group, User
def get_hmac_key():
return secrets.token_hex(64)
class ApiClient(models.Model):
name = models.CharField(_("name"), max_length=64)
owner = models.ForeignKey(
@@ -26,11 +33,10 @@ class ApiClient(models.Model):
help_text=_("Specific permissions for this api client."),
related_name="clients",
)
hmac_key = models.CharField(_("HMAC Key"), max_length=128, default=get_hmac_key)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
_perm_cache: set[str] | None = None
class Meta:
verbose_name = _("api client")
verbose_name_plural = _("api clients")
@@ -38,33 +44,38 @@ class ApiClient(models.Model):
def __str__(self):
return self.name
@cached_property
def all_permissions(self) -> set[str]:
permissions = (
Permission.objects.filter(
Q(group__group__in=self.groups.all()) | Q(clients=self)
)
.values_list("content_type__app_label", "codename")
.order_by()
)
return {f"{content_type}.{name}" for content_type, name in permissions}
def has_perm(self, perm: str):
"""Return True if the client has the specified permission."""
return perm in self.all_permissions
if self._perm_cache is None:
group_permissions = (
Permission.objects.filter(group__group__in=self.groups.all())
.values_list("content_type__app_label", "codename")
.order_by()
)
client_permissions = self.client_permissions.values_list(
"content_type__app_label", "codename"
).order_by()
self._perm_cache = {
f"{content_type}.{name}"
for content_type, name in (*group_permissions, *client_permissions)
}
return perm in self._perm_cache
def has_perms(self, perm_list):
"""
Return True if the client has each of the specified permissions. If
object is passed, check if the client has all required perms for it.
"""
def has_perms(self, perm_list: Iterable[str]) -> bool:
"""Return True if the client has each of the specified permissions."""
if not isinstance(perm_list, Iterable) or isinstance(perm_list, str):
raise ValueError("perm_list must be an iterable of permissions.")
return all(self.has_perm(perm) for perm in perm_list)
def reset_hmac(self, *, commit: bool = True) -> str:
"""Reset and return the HMAC key for this client.
Args:
commit: if True (the default), persist the new hmac in db.
"""
self.hmac_key = get_hmac_key()
if commit:
self.save()
return self.hmac_key
class ApiKey(models.Model):
PREFIX_LENGTH = 5
+23
View File
@@ -0,0 +1,23 @@
from ninja import ModelSchema, Schema
from pydantic import Field, HttpUrl
from api.models import ApiClient
from core.schemas import SimpleUserSchema
class ApiClientSchema(ModelSchema):
class Meta:
model = ApiClient
fields = ["id", "name"]
owner: SimpleUserSchema
permissions: list[str] = Field(alias="all_permissions")
class ThirdPartyAuthParamsSchema(Schema):
client_id: int
third_party_app: str
privacy_link: HttpUrl
username: str
callback_url: HttpUrl
signature: str
+32
View File
@@ -0,0 +1,32 @@
{% extends "core/base.jinja" %}
{% block content %}
<form method="post">
{% csrf_token %}
<h3>{% trans %}Confidentiality{% endtrans %}</h3>
<p>
{% trans trimmed app=third_party_app %}
By ticking this box and clicking on the send button, you
acknowledge and agree to provide {{ app }} with your
first name, last name, nickname and any other information
that was the third party app was explicitly authorized to fetch
and that it must have acknowledged to you, in a complete and accurate manner.
{% endtrans %}
</p>
<p class="margin-bottom">
{% trans trimmed app=third_party_app, privacy_link=third_party_cgu, sith_cgu_link=sith_cgu %}
The privacy policies of <a href="{{ privacy_link }}">{{ app }}</a>
and of <a href="{{ sith_cgu_link }}">the Students' Association</a>
applies as soon as the form is submitted.
{% endtrans %}
</p>
<div class="row">{{ form.cgu_accepted }} {{ form.cgu_accepted.label_tag() }}</div>
<br>
<h3 class="margin-bottom">{% trans %}Confirmation of identity{% endtrans %}</h3>
<div class="row margin-bottom">
{{ form.is_username_valid }} {{ form.is_username_valid.label_tag() }}
</div>
{% for field in form.hidden_fields() %}{{ field }}{% endfor %}
<input type="submit" class="btn btn-blue">
</form>
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
import pytest
from django.contrib.admin import AdminSite
from django.http import HttpRequest
from model_bakery import baker
from pytest_django.asserts import assertNumQueries
from api.admin import ApiClientAdmin
from api.models import ApiClient
@pytest.mark.django_db
def test_reset_hmac_action():
client_admin = ApiClientAdmin(ApiClient, AdminSite())
api_clients = baker.make(ApiClient, _quantity=4, _bulk_create=True)
old_hmac_keys = [c.hmac_key for c in api_clients]
with assertNumQueries(2):
qs = ApiClient.objects.filter(id__in=[c.id for c in api_clients[2:4]])
client_admin.reset_hmac_key(HttpRequest(), qs)
for c in api_clients:
c.refresh_from_db()
assert api_clients[0].hmac_key == old_hmac_keys[0]
assert api_clients[1].hmac_key == old_hmac_keys[1]
assert api_clients[2].hmac_key != old_hmac_keys[2]
assert api_clients[3].hmac_key != old_hmac_keys[3]
+22
View File
@@ -0,0 +1,22 @@
from typing import TYPE_CHECKING
import pytest
from django.urls import reverse
from model_bakery import baker
from api.hashers import generate_key
from api.models import ApiClient, ApiKey
from api.schemas import ApiClientSchema
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
def test_api_client_controller(client: Client):
key, hashed = generate_key()
api_client = baker.make(ApiClient)
baker.make(ApiKey, client=api_client, hashed_key=hashed)
res = client.get(reverse("api:api-client-infos"), headers={"X-APIKey": key})
assert res.status_code == 200
assert res.json() == ApiClientSchema.from_orm(api_client).model_dump()
+59
View File
@@ -0,0 +1,59 @@
import pytest
from django.contrib.auth.models import Permission
from django.test import TestCase
from model_bakery import baker
from api.models import ApiClient
from core.models import Group
class TestClientPermissions(TestCase):
@classmethod
def setUpTestData(cls):
cls.api_client = baker.make(ApiClient)
cls.perms = baker.make(Permission, _quantity=10, _bulk_create=True)
cls.api_client.groups.set(
[
baker.make(Group, permissions=cls.perms[0:3]),
baker.make(Group, permissions=cls.perms[3:5]),
]
)
cls.api_client.client_permissions.set(
[cls.perms[3], cls.perms[5], cls.perms[6], cls.perms[7]]
)
def test_all_permissions(self):
assert self.api_client.all_permissions == {
f"{p.content_type.app_label}.{p.codename}" for p in self.perms[0:8]
}
def test_has_perm(self):
assert self.api_client.has_perm(
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}"
)
assert not self.api_client.has_perm(
f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}"
)
def test_has_perms(self):
assert self.api_client.has_perms(
[
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}",
f"{self.perms[2].content_type.app_label}.{self.perms[2].codename}",
]
)
assert not self.api_client.has_perms(
[
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}",
f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}",
],
)
@pytest.mark.django_db
def test_reset_hmac_key():
client = baker.make(ApiClient)
original_key = client.hmac_key
client.reset_hmac(commit=True)
assert len(client.hmac_key) == len(original_key)
assert client.hmac_key != original_key
+140
View File
@@ -0,0 +1,140 @@
from unittest import mock
from unittest.mock import Mock
from django.contrib.messages import Message, get_messages
from django.db.models import Max
from django.test import TestCase
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from api.models import ApiClient, get_hmac_key
from core.baker_recipes import subscriber_user
from core.schemas import UserProfileSchema
from core.utils import hmac_hexdigest
def mocked_post(*, ok: bool):
class MockedResponse(Mock):
@property
def ok(self):
return ok
def mocked():
return MockedResponse()
return mocked
class TestThirdPartyAuth(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = subscriber_user.make()
cls.api_client = baker.make(ApiClient)
def setUp(self):
self.query = {
"client_id": self.api_client.id,
"third_party_app": "app",
"privacy_link": "https://foobar.fr/",
"username": "bibou",
"callback_url": "https://callback.fr/",
}
self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query)
self.callback_data = {
"user": UserProfileSchema.from_orm(self.user).model_dump()
}
self.callback_data["signature"] = hmac_hexdigest(
self.api_client.hmac_key, self.callback_data["user"]
)
self.url = reverse("api-link:third-party-auth", query=self.query)
def test_auth_ok(self):
self.client.force_login(self.user)
res = self.client.get(self.url)
assert res.status_code == 200
with mock.patch("requests.post", new_callable=mocked_post(ok=True)) as mocked:
res = self.client.post(
self.url,
data={"cgu_accepted": True, "is_username_valid": True, **self.query},
)
mocked.assert_called_once_with(
self.query["callback_url"], json=self.callback_data
)
assertRedirects(
res,
reverse("api-link:third-party-auth-result", kwargs={"result": "success"}),
)
def test_callback_error(self):
"""Test that the user see the failure page if the callback request failed."""
self.client.force_login(self.user)
with mock.patch("requests.post", new_callable=mocked_post(ok=False)) as mocked:
res = self.client.post(
self.url,
data={"cgu_accepted": True, "is_username_valid": True, **self.query},
)
mocked.assert_called_once_with(
self.query["callback_url"], json=self.callback_data
)
assertRedirects(
res,
reverse("api-link:third-party-auth-result", kwargs={"result": "failure"}),
)
def test_wrong_signature(self):
"""Test that a 403 is raised if the signature of the query is wrong."""
self.client.force_login(subscriber_user.make())
new_key = get_hmac_key()
del self.query["signature"]
self.query["signature"] = hmac_hexdigest(new_key, self.query)
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert list(get_messages(res.wsgi_request)) == [
Message(
level=40,
message=(
"La signature est incorrecte. "
"Nous ne pouvons pas garantir l'authenticité de la requête."
),
)
]
res = self.client.post(self.url, data=self.query)
assert res.status_code == 200
def test_cgu_not_accepted(self):
self.client.force_login(self.user)
res = self.client.get(self.url)
assert res.status_code == 200
res = self.client.post(self.url, data=self.query)
assert res.status_code == 200 # no redirect means invalid form
res = self.client.post(
self.url,
data={"cgu_accepted": False, "is_username_valid": False, **self.query},
)
assert res.status_code == 200
def test_invalid_client(self):
self.client.force_login(self.user)
self.query["client_id"] = ApiClient.objects.aggregate(res=Max("id"))["res"] + 1
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert list(get_messages(res.wsgi_request)) == [
Message(
level=40,
message="Les données fournies pour l'authentification sont incorrectes.",
)
]
def test_missing_parameter(self):
self.client.force_login(self.user)
del self.query["username"]
self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query)
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert list(get_messages(res.wsgi_request)) == [
Message(
level=40,
message="Les données fournies pour l'authentification sont incorrectes.",
)
]
res = self.client.post(self.url, data=self.query)
assert res.status_code == 200
+15
View File
@@ -1,6 +1,10 @@
from django.urls import path, register_converter
from ninja.security import SessionAuth
from ninja_extra import NinjaExtraAPI
from api.views import ThirdPartyAuthResultView, ThirdPartyAuthView
from core.converters import ResultConverter
api = NinjaExtraAPI(
title="PICON",
description="Portail Interactif de Communication avec les Outils Numériques",
@@ -9,3 +13,14 @@ api = NinjaExtraAPI(
auth=[SessionAuth()],
)
api.auto_discover_controllers()
register_converter(ResultConverter, "res")
urlpatterns = [
path("auth/", ThirdPartyAuthView.as_view(), name="third-party-auth"),
path(
"auth/<res:result>/",
ThirdPartyAuthResultView.as_view(),
name="third-party-auth-result",
),
]
+146
View File
@@ -0,0 +1,146 @@
import hmac
from urllib.parse import unquote
import pydantic
import requests
import sentry_sdk
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import AccessMixin, LoginRequiredMixin
from django.shortcuts import render
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import FormView, TemplateView
from ninja_extra.shortcuts import get_object_or_none
from api.forms import ThirdPartyAuthForm
from api.models import ApiClient
from api.schemas import ThirdPartyAuthParamsSchema
from core.models import Page
from core.schemas import UserProfileSchema
from core.utils import hmac_hexdigest
class ThirdPartyAuthView(AccessMixin, FormView):
form_class = ThirdPartyAuthForm
template_name = "api/third_party/auth.jinja"
success_url = reverse_lazy("core:index")
def parse_params(self) -> ThirdPartyAuthParamsSchema | None:
"""Parse and check the authentication parameters.
If parsing fails, messages will be created using the django message
infrastructure.
Returns:
The parses parameters, or None if the parsing failed.
"""
# This is here rather than in ThirdPartyAuthForm because
# the given parameters and their signature are checked during both
# POST (for obvious reasons) and GET (in order not to make
# the user fill a form just to get an error he won't understand)
params = self.request.GET if self.request.method == "GET" else self.request.POST
params = {key: unquote(val) for key, val in params.dict().items()}
try:
params = ThirdPartyAuthParamsSchema(**params)
except pydantic.ValidationError:
messages.error(
self.request, _("The data provided for authentication is incorrect")
)
return None
client: ApiClient | None = get_object_or_none(ApiClient, id=params.client_id)
if not client:
messages.error(
self.request, _("The data provided for authentication is incorrect")
)
return None
if not hmac.compare_digest(
hmac_hexdigest(client.hmac_key, params.model_dump(exclude={"signature"})),
params.signature,
):
messages.error(
self.request,
_(
"The signature is incorrect. "
"We cannot ensure the provenance of the request."
),
)
return None
return params
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
if (params := self.parse_params()) is None:
# if parameters parsing failed, shortcut the operation and display
# an empty page with just the error messages.
return render(request, "core/base.jinja")
self.params = params
return super().dispatch(request, *args, **kwargs)
def get(self, *args, **kwargs):
messages.warning(
self.request,
_(
"You are going to link your AE account and your %(app)s account. "
"Continue only if this page was opened from %(app)s."
)
% {"app": self.params.third_party_app},
)
return super().get(*args, **kwargs)
def get_initial(self):
return self.params.model_dump()
def form_valid(self, form):
client = ApiClient.objects.get(id=form.cleaned_data["client_id"])
user = UserProfileSchema.from_orm(self.request.user).model_dump()
data = {"user": user, "signature": hmac_hexdigest(client.hmac_key, user)}
try:
ok = requests.post(form.cleaned_data["callback_url"], json=data).ok
except requests.RequestException as e:
sentry_sdk.capture_exception(e)
ok = False
self.success_url = reverse(
"api-link:third-party-auth-result",
kwargs={"result": "success" if ok else "failure"},
)
return super().form_valid(form)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"third_party_app": self.params.third_party_app,
"third_party_cgu": self.params.privacy_link,
"sith_cgu": Page.objects.get(_full_name=settings.SITH_CGU_PAGE),
}
class ThirdPartyAuthResultView(LoginRequiredMixin, TemplateView):
"""View that the user will see if its authentication on sith was successful.
This can show either a success or a failure message :
- success : everything is good, the user is successfully authenticated
and can close the page
- failure : the authentication has been processed on the sith side,
but the request to the callback url received an error.
In such a case, there is nothing much we can do but to advice
the user to contact the developers of the third-party app.
"""
template_name = "core/base.jinja"
success_message = _(
"You have been successfully authenticated. You can now close this page."
)
error_message = _(
"Your authentication on the AE website was successful, "
"but an error happened during the interaction "
"with the third-party application. "
"Please contact the managers of the latter."
)
def get(self, request, *args, **kwargs):
if self.kwargs.get("result") == "success":
messages.success(request, self.success_message)
else:
messages.error(request, self.error_message)
return super().get(request, *args, **kwargs)
+12 -6
View File
@@ -39,12 +39,18 @@
<a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a>
</div>
<h4>{{ news.title }}</h4>
<p class="date">
<time datetime="{{ date.start_date.isoformat(timespec="seconds") }}">{{ date.start_date|localtime|date(DATETIME_FORMAT) }}
{{ date.start_date|localtime|time(DATETIME_FORMAT) }}</time> -
<time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">{{ date.end_date|localtime|date(DATETIME_FORMAT) }}
{{ date.end_date|localtime|time(DATETIME_FORMAT) }}</time>
</p>
{% if date %}
<p class="date">
<time datetime="{{ date.start_date.isoformat(timespec="seconds") }}">
{{ date.start_date|localtime|date(DATETIME_FORMAT) }}
{{ date.start_date|localtime|time(DATETIME_FORMAT) }}
</time> -
<time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">
{{ date.end_date|localtime|date(DATETIME_FORMAT) }}
{{ date.end_date|localtime|time(DATETIME_FORMAT) }}
</time>
</p>
{% endif %}
<div class="news_content">
<div><em>{{ news.summary|markdown }}</em></div>
<br/>
+35
View File
@@ -26,6 +26,7 @@ from django.utils import html
from django.utils.timezone import now
from django.utils.translation import gettext as _
from model_bakery import baker
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club, ClubRole, Membership
@@ -362,3 +363,37 @@ def test_moderate_poster(client: Client, referer: str | None):
poster.refresh_from_db()
assert poster.is_moderated
assert poster.moderator == user
class TestNewsDetail(TestCase):
@classmethod
def setUpTestData(cls) -> None:
cls.news = baker.make(News, is_published=True)
cls.user = subscriber_user.make()
cls.url = reverse("com:news_detail", kwargs={"news_id": cls.news.id})
def test_page_ok(self):
self.client.force_login(self.user)
res = self.client.get(self.url)
assert res.status_code == 200
def test_displayed_date(self):
self.client.force_login(self.user)
n = now()
date_recipe = Recipe(NewsDate, news=self.news)
date_recipe.make(
start_date=n - timedelta(days=1, hours=6), end_date=n - timedelta(days=1)
)
date_recipe.make(
start_date=n - timedelta(hours=6), end_date=n + timedelta(hours=1)
)
expected = date_recipe.make( # we expect the next happening date.
start_date=n + timedelta(days=1), end_date=n + timedelta(days=1, hours=1)
)
date_recipe.make(
start_date=n + timedelta(days=2), end_date=n + timedelta(days=2, hours=1)
)
self.client.force_login(self.user)
res = self.client.get(self.url)
assert res.status_code == 200
assert res.context_data["date"] == expected
+3 -1
View File
@@ -301,7 +301,9 @@ class NewsDetailView(CanViewMixin, DetailView):
queryset = News.objects.select_related("club", "author", "moderator")
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"date": self.object.dates.first()}
return super().get_context_data(**kwargs) | {
"date": self.object.dates.filter(start_date__gt=now()).first()
}
class NewsFeed(Feed):
+11 -8
View File
@@ -1,19 +1,16 @@
class FourDigitYearConverter:
regex = "[0-9]{4}"
from django.urls.converters import IntConverter, StringConverter
def to_python(self, value):
return int(value)
class FourDigitYearConverter(IntConverter):
regex = "[0-9]{4}"
def to_url(self, value):
return str(value).zfill(4)
class TwoDigitMonthConverter:
class TwoDigitMonthConverter(IntConverter):
regex = "[0-9]{2}"
def to_python(self, value):
return int(value)
def to_url(self, value):
return str(value).zfill(2)
@@ -28,3 +25,9 @@ class BooleanStringConverter:
def to_url(self, value):
return str(value)
class ResultConverter(StringConverter):
"""Converter whose regex match either "success" or "failure"."""
regex = "(success|failure)"
+49 -33
View File
@@ -20,6 +20,7 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
import itertools
from datetime import date, datetime, timedelta
from io import StringIO
from pathlib import Path
@@ -120,11 +121,6 @@ class Command(BaseCommand):
)
self.profiles_root = SithFile.objects.create(name="profiles", owner=root)
home_root = SithFile.objects.create(name="users", owner=root)
# Page needed for club creation
p = Page(name=settings.SITH_CLUB_ROOT_PAGE)
p.save(force_lock=True)
club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create(
name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID
@@ -256,6 +252,7 @@ class Command(BaseCommand):
date_of_birth="1942-06-12",
password="plop",
)
User.objects.all().update(cgu_approved_at=now())
User.groups.through.objects.bulk_create(
[
User.groups.through(group=groups.counter_admin, user=counter),
@@ -279,34 +276,7 @@ class Command(BaseCommand):
]
)
# Adding syntax help page
syntax_page = Page(name="Aide_sur_la_syntaxe")
syntax_page.save(force_lock=True)
PageRev.objects.create(
page=syntax_page,
title="Aide sur la syntaxe",
author=skia,
content=(self.ROOT_PATH / "core" / "fixtures" / "SYNTAX.md").read_text(),
)
services_page = Page(name="Services")
services_page.save(force_lock=True)
PageRev.objects.create(
page=services_page,
title="Services",
author=skia,
content="- [Eboutic](/eboutic)\n- Matmat\n- SAS\n- Weekmail\n- Forum",
)
index_page = Page(name="Index")
index_page.save(force_lock=True)
PageRev.objects.create(
page=index_page,
title="Wiki index",
author=root,
content="Welcome to the wiki page!",
)
groups.public.viewable_page.set([syntax_page, services_page, index_page])
self._create_pages(groups)
self._create_subscription(root)
self._create_subscription(skia)
@@ -577,6 +547,48 @@ class Command(BaseCommand):
]
)
def _create_pages(self, groups: PopulatedGroups):
pages = Page.objects.bulk_create(
[Page(name=s, _full_name=s) for s in settings.SITH_CGU_PAGE.split("/")]
)
for parent, son in itertools.pairwise(pages):
son.parent = parent
son.save(force_lock=True)
cgu_page = pages[-1]
syntax_page = Page(name="Aide_sur_la_syntaxe")
syntax_page.save(force_lock=True)
services_page = Page(name="Services")
services_page.save(force_lock=True)
index_page = Page(name="Index")
index_page.save(force_lock=True)
page_revs = [
PageRev(page=cgu_page, title="Règlement informatique", content=""),
PageRev(
page=syntax_page,
title="Aide sur la syntaxe",
content=(
self.ROOT_PATH / "core" / "fixtures" / "SYNTAX.md"
).read_text(),
),
PageRev(
page=services_page,
title="Services",
content="- [Eboutic](/eboutic)\n- Matmat\n- SAS\n- Weekmail\n- Forum",
),
PageRev(
page=index_page, title="Wiki index", content="Welcome to the wiki page!"
),
]
for rev in page_revs:
rev.author_id = settings.SITH_ROOT_USER_ID
rev.revision = 1
PageRev.objects.bulk_create(page_revs)
groups.public.viewable_page.set(
[syntax_page, services_page, index_page, cgu_page]
)
def _create_products(self, groups: PopulatedGroups, clubs: PopulatedClubs):
beers_type, cotis_type, refill_type, verre_type = (
ProductType.objects.bulk_create(
@@ -737,6 +749,10 @@ class Command(BaseCommand):
s.save()
def _create_clubs(self) -> PopulatedClubs:
# Page needed for club creation
p = Page(name=settings.SITH_CLUB_ROOT_PAGE)
p.save(force_lock=True)
ae = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
)
+27 -8
View File
@@ -1,3 +1,4 @@
import math
import random
from datetime import date, timedelta
from datetime import timezone as tz
@@ -35,12 +36,17 @@ class Command(BaseCommand):
super().__init__(*args, **kwargs)
self.faker = Faker("fr_FR")
def add_arguments(self, parser):
parser.add_argument(
"-n", "--nb-users", help="Number of users to create", type=int, default=600
)
def handle(self, *args, **options):
if not settings.DEBUG:
raise Exception("Never call this command in prod. Never.")
self.stdout.write("Creating users...")
users = self.create_users()
users = self.create_users(options["nb_users"])
self.create_bans(random.sample(users, k=len(users) // 200)) # 0.5% of users
subscribers = random.sample(users, k=int(0.8 * len(users)))
self.stdout.write("Creating subscriptions...")
@@ -80,7 +86,7 @@ class Command(BaseCommand):
self.stdout.write("Creating products...")
self.create_products()
self.stdout.write("Creating sales and refills...")
sellers = random.sample(list(User.objects.all()), 100)
sellers = random.sample(users, len(users) // 10)
self.create_sales(sellers)
self.stdout.write("Creating permanences...")
self.create_permanences(sellers)
@@ -89,7 +95,7 @@ class Command(BaseCommand):
self.stdout.write("Done")
def create_users(self) -> list[User]:
def create_users(self, nb_users: int = 600) -> list[User]:
# Create a single password hash for all users to make it faster.
# It's insecure as hell, but it's ok since it's only for dev purposes.
password = make_password("plop")
@@ -108,7 +114,7 @@ class Command(BaseCommand):
address=self.faker.address(),
password=password,
)
for _ in range(600)
for _ in range(nb_users)
]
# there may a duplicate or two
# Not a problem, we will just have 599 users instead of 600
@@ -138,10 +144,22 @@ class Command(BaseCommand):
)
def create_subscriptions(self, users: list[User]):
subscription_types = [
"un-semestre",
"deux-semestres",
"cursus-tronc-commun",
"cursus-branche",
]
def prepare_subscription(_user: User, start_date: date) -> Subscription:
payment_method = random.choice(settings.SITH_SUBSCRIPTION_PAYMENT_METHOD)[0]
duration = random.randint(1, 4)
s = Subscription(member=_user, payment_method=payment_method)
subscription_type = random.choice(subscription_types)
s = Subscription(
member=_user,
payment_method=payment_method,
subscription_type=subscription_type,
)
duration = settings.SITH_SUBSCRIPTIONS[subscription_type]["duration"]
s.subscription_start = s.compute_start(d=start_date, duration=duration)
s.subscription_end = s.compute_end(duration)
return s
@@ -415,8 +433,9 @@ class Command(BaseCommand):
Permanency.objects.bulk_create(perms)
def create_forums(self):
forumers = random.sample(list(User.objects.all()), 100)
most_actives = random.sample(forumers, 10)
users = list(User.objects.all())
forumers = random.sample(users, math.ceil(len(users) / 10))
most_actives = random.sample(forumers, math.ceil(len(forumers) / 6))
categories = list(Forum.objects.filter(is_category=True))
new_forums = [
Forum(name=self.faker.text(20), parent=random.choice(categories))
@@ -0,0 +1,15 @@
# Generated by Django 5.2.17 on 2026-09-25 12:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("core", "0050_alter_sithfile_moderator")]
operations = [
migrations.AddField(
model_name="user",
name="cgu_approved_at",
field=models.DateTimeField(null=True, verbose_name="ToS approved at"),
),
]
+12 -4
View File
@@ -44,6 +44,7 @@ from django.core.files.base import ContentFile
from django.core.mail import send_mail
from django.db import models, transaction
from django.db.models import Exists, F, OuterRef, Q
from django.db.models.aggregates import Max
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
@@ -291,6 +292,7 @@ class User(AbstractUser):
),
blank=True,
)
cgu_approved_at = models.DateTimeField(_("ToS approved at"), null=True, blank=False)
godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
objects = CustomUserManager()
@@ -418,6 +420,14 @@ class User(AbstractUser):
)
return age
@cached_property
def approved_current_cgu(self) -> bool:
qs = PageRev.objects.filter(page___full_name=settings.SITH_CGU_PAGE)
return (
self.cgu_approved_at is not None
and self.cgu_approved_at > qs.aggregate(date=Max("date"))["date"]
)
def make_home(self):
if self.home is None:
home_root = SithFile.objects.filter(parent=None, name="users").first()
@@ -1233,9 +1243,8 @@ class Page(models.Model):
raise NotLocked("The page is not locked and thus can not be saved")
self.full_clean()
if not self.id:
super().save(
*args, **kwargs
) # Save a first time to correctly set _full_name
# Save a first time to correctly set _full_name
super().save(*args, **kwargs)
# This reset the _full_name just before saving to maintain a coherent field quicker for queries than the
# recursive method
# It also update all the children to maintain correct names
@@ -1254,7 +1263,6 @@ class Page(models.Model):
return Page.objects.filter(_full_name=name).first()
def clean(self):
"""Cleans up only the name for the moment, but this can be used to make any treatment before saving the object."""
if "/" in self.name:
self.name = self.name.split("/")[-1]
if (
+1
View File
@@ -42,6 +42,7 @@ details.accordion>.accordion-content {
background: #ffffff;
color: #333333;
padding: 1em 2.2em;
margin-top: 0;
border: 1px solid #dddddd;
border-bottom-right-radius: 3px;
border-bottom-left-radius: 3px;
+11 -46
View File
@@ -37,8 +37,7 @@ body {
margin: 0;
}
>div,
>form {
form {
box-sizing: border-box;
display: flex;
flex-direction: column;
@@ -49,67 +48,33 @@ body {
max-width: 500px;
margin-top: 20px;
>p,
>div {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
input[type="submit"] {
width: 100%;
margin: 0;
>label {
width: 100%;
@media (min-width: 500px) {
width: 300px;
}
}
max-width: 300px;
margin-top: 1em;
}
>input,
>p>input,
>div>input {
box-sizing: border-box;
width: 100%;
max-width: 500px;
@media (min-width: 500px) {
max-width: 300px;
}
}
>.errorlist {
.errorlist {
color: red;
text-align: center;
margin: 10px 0 0 0;
list-style-type: none;
}
>.required>.helptext {
text-align: center;
font-style: italic;
}
>.required:last-of-type {
box-sizing: border-box;
div, fieldset {
max-width: 300px;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
}
.captcha {
box-sizing: border-box;
>label {
width: 100%;
fieldset {
margin-bottom: unset
}
>img {
width: 70px;
object-fit: contain;
}
>input {
width: 200px;
}
}
}
}
+39
View File
@@ -0,0 +1,39 @@
{% extends "core/base.jinja" %}
{%- block additional_css -%}
<link rel="stylesheet" href="{{ static('user/login.scss') }}">
{%- endblock -%}
{% block title %}
{% trans %}Login{% endtrans %}
{% endblock %}
{% block info_boxes %}
{% endblock %}
{% block nav %}
{% endblock %}
{% block content %}
<h1 class="title">{% trans %}Terms of Service{% endtrans %}</h1>
<form method="post" id="login-form">
{% csrf_token %}
<div class="alert alert-yellow">
{% trans trimmed %}
To continue using our services,
please read and approve the AE website's terms of service
{% endtrans %}
</div>
<div class="form-group">
{{ form.cgu_approved_at.errors }}
{{ form.cgu_approved_at }}
{{ form.cgu_approved_at.label_tag() }}
</div>
<input type="hidden" name="next" value="{{ next }}">
<input type="submit" class="btn btn-blue">
</form>
{% endblock %}
+8 -19
View File
@@ -35,28 +35,17 @@
{% csrf_token %}
<div>
<label for="{{ form.username.name }}">{{ form.username.label }}</label>
{{ form.username }}
{{ form.username.errors }}
</div>
<div>
<label for="{{ form.password.name }}">{{ form.password.label }}</label>
{{ form.password }}
{{ form.password.errors }}
</div>
{{ form }}
<input type="hidden" name="next" value="{{ next }}">
<input type="submit" value="{% trans %}Login{% endtrans %}">
<input type="submit" class="btn btn-blue" value="{% trans %}Login{% endtrans %}">
{# Assumes you setup the password_reset view in your URLconf #}
<p>
<a href="{{ url('core:password_reset') }}">{% trans %}Lost password?{% endtrans %}</a>
&nbsp;&nbsp;
<a href="{{ url('core:register') }}">{% trans %}Create account{% endtrans %}</a>
</p>
<div>
<a href="{{ url("core:password_reset") }}">{% trans %}Lost password?{% endtrans %}</a>
</div>
<div>
<a href="{{ url("core:register") }}">{% trans %}Create account{% endtrans %}</a>
</div>
</form>
{% endblock %}
+12 -2
View File
@@ -18,7 +18,17 @@
<form action="{{ url('core:register') }}" method="post">
{% csrf_token %}
{% render_honeypot_field %}
{{ form.as_p() }}
<input type="submit" value="{% trans %}Register{% endtrans %}" />
{% for field in form %}
{% if field.name not in ["cgu_approved_at", "captcha"] %}
<div>{{ field.as_field_group() }}</div>
{% endif %}
{% endfor %}
<div class="captcha">{{ form.captcha.as_field_group() }}</div>
<div class="form-group">
{{ form.cgu_approved_at.errors }}
{{ form.cgu_approved_at }}
{{ form.cgu_approved_at.label_tag() }}
</div>
<input type="submit" class="btn btn-blue" value="{% trans %}Register{% endtrans %}" />
</form>
{% endblock %}
+13
View File
@@ -0,0 +1,13 @@
import contextlib
import os
import pytest
from django.core.management import call_command
@pytest.mark.django_db
def test_populate_more(settings):
"""Just check that populate more doesn't crash"""
settings.DEBUG = True
with open(os.devnull, "w") as devnull, contextlib.redirect_stdout(devnull):
call_command("populate_more", "--nb-users", "50")
+38 -2
View File
@@ -27,6 +27,7 @@ from django.core.exceptions import ValidationError
from django.core.mail import EmailMessage
from django.test import Client, RequestFactory, TestCase
from django.urls import reverse
from django.utils.timezone import now
from django.views.generic import View
from django.views.generic.base import ContextMixin
from model_bakery import baker
@@ -55,6 +56,7 @@ class TestUserRegistration:
"password2": "plop",
"captcha_0": "dummy-value",
"captcha_1": "PASSED",
"cgu_approved_at": now(),
}
@pytest.fixture()
@@ -92,6 +94,10 @@ class TestUserRegistration:
({"first_name": ""}, "Ce champ est obligatoire."),
({"last_name": ""}, "Ce champ est obligatoire."),
({"captcha_1": "WRONG_CAPTCHA"}, "CAPTCHA invalide"),
(
{"cgu_approved_at": False},
"Vous devez approuver les conditions générales d'utilisation",
),
],
)
def test_register_user_form_fail(
@@ -150,7 +156,7 @@ class TestUserRegistration:
class TestUserLogin:
@pytest.fixture()
def user(self) -> User:
return baker.make(User, password=make_password("plop"))
return baker.make(User, password=make_password("plop"), cgu_approved_at=now())
@pytest.mark.parametrize(
"identifier_getter",
@@ -191,10 +197,40 @@ class TestUserLogin:
reverse("core:login"),
{"username": identifier_getter(user), "password": "plop"},
)
assertRedirects(response, reverse("core:index"))
assertRedirects(response, settings.LOGIN_REDIRECT_URL)
assert response.wsgi_request.user == user
@pytest.mark.django_db
class TestCGU:
def test_cgu_approval(self, client: Client):
user = baker.make(User, password=make_password("plop"), cgu_approved_at=None)
user_url = user.get_absolute_url()
res = client.post(
reverse("core:login"),
{"username": user.username, "password": "plop", "next": user_url},
)
assertRedirects(res, reverse("core:approve_cgu", query={"next": user_url}))
res = client.post(
reverse("core:approve_cgu"), {"cgu_approved_at": now(), "next": user_url}
)
assertRedirects(res, user_url)
user.refresh_from_db()
assert user.cgu_approved_at is not None
def test_access_cgu_when_already_approved(self, client: Client):
url = reverse("core:approve_cgu")
res = client.get(url)
assertRedirects(res, reverse("core:login"))
client.force_login(baker.make(User, cgu_approved_at=now()))
res = client.get(url)
assertRedirects(res, settings.LOGIN_REDIRECT_URL)
res = client.post(url, {"cgu_approved_at": now()})
assertRedirects(res, settings.LOGIN_REDIRECT_URL)
@pytest.mark.parametrize(
("md", "html"),
[
+2
View File
@@ -31,6 +31,7 @@ from core.converters import (
TwoDigitMonthConverter,
)
from core.views import (
CGUApprovalView,
FileDeleteView,
FileEditPropView,
FileEditView,
@@ -125,6 +126,7 @@ urlpatterns = [
name="password_reset_complete",
),
path("register/", UserCreationView.as_view(), name="register"),
path("cgu/", CGUApprovalView.as_view(), name="approve_cgu"),
# Group handling
path("group/", GroupListView.as_view(), name="group_list"),
path("group/new/", GroupCreateView.as_view(), name="group_new"),
+39
View File
@@ -12,12 +12,15 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from __future__ import annotations
import hmac
from datetime import date, timedelta
# Image utils
from io import BytesIO
from typing import TYPE_CHECKING, Final
from urllib.parse import urlencode
import PIL
from django.conf import settings
@@ -25,6 +28,15 @@ from django.core.files.base import ContentFile
from django.utils.timezone import localdate
from PIL.Image import Image, Resampling
if TYPE_CHECKING:
from _hashlib import HASH
from collections.abc import Buffer, Mapping, Sequence
from typing import Any, Callable, Final
from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest
if TYPE_CHECKING:
from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest
@@ -190,3 +202,30 @@ def get_client_ip(request: HttpRequest) -> str | None:
return ip
return None
def hmac_hexdigest(
key: str | bytes,
data: Mapping[str, Any] | Sequence[tuple[str, Any]],
digest: str | Callable[[Buffer], HASH] = "sha512",
) -> str:
"""Return the hexdigest of the signature of the given data.
Args:
key: the HMAC key used for the signature
data: the data to sign
digest: a PEP247 hashing algorithm (by default, sha512)
Examples:
```python
data = {
"foo": 5,
"bar": "somevalue",
}
hmac_key = secrets.token_hex(64)
signature = hmac_hexdigest(hmac_key, data, "sha256")
```
"""
if isinstance(key, str):
key = key.encode()
return hmac.digest(key, urlencode(data).encode(), digest).hex()
+63 -5
View File
@@ -30,9 +30,7 @@ from django import forms
from django.conf import settings
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
from django.contrib.auth.models import Permission
from django.contrib.staticfiles.management.commands.collectstatic import (
staticfiles_storage,
)
from django.contrib.staticfiles.storage import staticfiles_storage
from django.core.exceptions import ValidationError
from django.db import transaction
from django.forms import (
@@ -42,6 +40,9 @@ from django.forms import (
TextInput,
Widget,
)
from django.urls import reverse
from django.utils.functional import lazy
from django.utils.safestring import mark_safe
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget
@@ -108,6 +109,43 @@ class FutureDateTimeField(forms.DateTimeField):
return {"min": widget.format_value(now())}
class CGUApprovalField(forms.BooleanField):
"""Form field with a checkbox to approve the CGUs.
The checkbox must be checked to be valid.
If valid, then the value of the field is the current timestamp.
"""
default_error_messages = {"required": _("You must approve the terms of service.")}
__label = None
def __init__(self, *, label_suffix: str | None = "", **kwargs):
# Because the core app of the sith is so huge,
# and because we require a url from the latter,
# putting the reverse into the __init__ will result in it
# being evaluated at server startup time (even with reverse_lazy).
# This will result in a circular import.
# Thus, we must keep the label in its own property and force it to be lazy.
kwargs["label"] = lazy(self.get_label, str)
kwargs["required"] = True
super().__init__(label_suffix=label_suffix, **kwargs)
def to_python(self, value):
return now() if super().to_python(value) else None
def get_label(self):
if not self.__label:
url = reverse("core:page", kwargs={"page_name": settings.SITH_CGU_PAGE})
self.__label = mark_safe(
_(
"I have read and I approve the "
'<a href="%(url)s" target="_blank">Terms of Service</a>'
)
% {"url": url}
)
return self.__label
# Forms
@@ -146,8 +184,28 @@ class RegisteringForm(UserCreationForm):
class Meta:
model = User
fields = ("first_name", "last_name", "email")
field_classes = {"email": AntiSpamEmailField}
fields = ("first_name", "last_name", "email", "cgu_approved_at")
field_classes = {
"email": AntiSpamEmailField,
"cgu_approved_at": CGUApprovalField,
}
class CGUApprovalForm(forms.ModelForm):
class Meta:
model = User
fields = ["cgu_approved_at"]
field_classes = {"cgu_approved_at": CGUApprovalField}
def __init__(self, *args, instance: User | None = None, **kwargs):
if instance:
# If this form is displayed,
# then we want the user to explicitly check the button.
# So we pretend cgu were never approved (even if they were).
# If we didn't do that, the button would be initially checked,
# even if the approval was done before the last CGU version.
instance.cgu_approved_at = False
super().__init__(*args, instance=instance, **kwargs)
class UserProfileForm(forms.ModelForm):
+33 -3
View File
@@ -27,12 +27,12 @@ from datetime import timedelta
# This file contains all the views that concern the user model
from operator import itemgetter
from smtplib import SMTPException
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from django.contrib import messages
from django.contrib.auth import login, views
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm
from django.contrib.auth.forms import SetPasswordForm
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import PermissionDenied
@@ -60,6 +60,7 @@ from honeypot.decorators import check_honeypot
from core.auth.mixins import CanEditMixin, CanEditPropMixin, CanViewMixin
from core.models import Gift, Preferences, User
from core.views.forms import (
CGUApprovalForm,
GiftForm,
LoginForm,
RegisteringForm,
@@ -71,6 +72,7 @@ from core.views.forms import (
from core.views.mixins import FragmentMixin, TabedViewMixin, UseFragmentsMixin
from counter.models import Refilling, Selling
from eboutic.models import Invoice
from sith import settings
from trombi.views import UserTrombiForm
if TYPE_CHECKING:
@@ -82,9 +84,16 @@ class SithLoginView(views.LoginView):
template_name = "core/login.jinja"
authentication_form = LoginForm
form_class = PasswordChangeForm
redirect_authenticated_user = True
def get_success_url(self) -> str:
redirect_to = self.get_redirect_url()
default_url = self.get_default_redirect_url()
if not self.request.user.approved_current_cgu:
query = {"next": redirect_to} if redirect_to else {}
return reverse("core:approve_cgu", query=query)
return redirect_to or default_url
class SithPasswordChangeView(views.PasswordChangeView):
"""Allows a user to change its password."""
@@ -188,6 +197,27 @@ class UserCreationView(FormView):
return super().form_valid(form)
class CGUApprovalView(views.RedirectURLMixin, UpdateView):
form_class = CGUApprovalForm
next_page = settings.LOGIN_REDIRECT_URL
template_name = "core/cgu_approve.jinja"
def dispatch(self, request, *args, **kwargs):
if self.request.user.is_anonymous:
return redirect("core:login")
if self.request.user.approved_current_cgu:
return redirect(self.get_success_url())
return super().dispatch(request, *args, **kwargs)
def get_object(self, *args, **kwargs):
return self.request.user
def get_context_data(self, **kwargs) -> dict[str, Any]:
return super().get_context_data(**kwargs) | {
self.redirect_field_name: self.get_redirect_url()
}
class UserMeRedirect(LoginRequiredMixin, RedirectView):
def get_redirect_url(self, *args, **kwargs):
if remaining := kwargs.get("remaining_path"):
@@ -123,14 +123,14 @@ document.addEventListener("alpine:init", () => {
onRefillingSuccess(event: CustomEvent) {
if (
event.type !== "htmx:after-swap" ||
event.detail.failed ||
event.detail.elt.querySelector(".errorlist")
event.type !== "htmx:after:swap" ||
event.detail.ctx.response.status !== 200 ||
event.detail.ctx.target.querySelector(".errorlist")
) {
return;
}
this.customerBalance += Number.parseFloat(
(event.detail.target.querySelector("#id_amount") as HTMLInputElement).value,
(event.detail.ctx.target.querySelector("#id_amount") as HTMLInputElement).value,
);
document.getElementById("selling-accordion")?.setAttribute("open", "");
this.codeField?.widget.focus();
@@ -62,8 +62,7 @@
}
form {
margin-top: .5rem;
margin-bottom: .5rem;
margin: 0;
}
}
@@ -186,7 +186,7 @@
{% if refilling_fragment %}
<div
class="accordion-content"
@htmx:after-swap="onRefillingSuccess"
@htmx:after:swap="onRefillingSuccess"
>
{{ refilling_fragment }}
</div>
@@ -4,6 +4,8 @@
hx-swap="outerHTML"
>
{% csrf_token %}
{{ form.as_p() }}
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
<div class="margin-bottom">
{{ form.as_p() }}
</div>
<input type="submit" class="btn btn-blue" value="{% trans %}Go{% endtrans %}"/>
</form>
+1
View File
@@ -0,0 +1 @@
::: api.schemas
+1
View File
@@ -0,0 +1 @@
::: api.views
+378
View File
@@ -0,0 +1,378 @@
Le site AE offre des mécanismes permettant aux applications tierces
de récupérer les informations sur un utilisateur du site AE.
De cette manière, il devient possible de synchroniser les informations
qu possède l'application tierce sur l'utilisateur, directement depuis
le site AE.
## Fonctionnement général
Pour authentifier vos utilisateurs, vous aurez besoin d'un serveur web
et d'un client d'API (celui auquel est liée votre
[clef d'API](./connect.md#obtenir-une-clef-dapi)).
Deux informations vous sont nécessaires, en plus de votre clef d'API :
- l'id du client : vous pouvez l'obtenir soit en le demandant à l'équipe info,
soit en appelant la route `GET /api/client/me` avec votre clef d'API
renseignée dans le header [X-APIKey](./connect.md#x-apikey)
- la clef HMAC du client : vous devez la demander à l'équipe info.
Ces deux éléments ont une fonction différente : l'id du client permet
de dire au serveur quelle est l'application qui s'adresse à lui,
tandis que la [clef HMAC](https://fr.wikipedia.org/wiki/HMAC)
servira à créer une signature unique permettant de s'assurer
que les données transmises n'ont pas été falsifiées.
Grâce à ces informations, vous allez pouvoir fournir le contexte nécessaire
au site AE pour qu'il authentifie vos utilisateurs.
En effet, la démarche d'authentification s'effectue presque entièrement
sur le site : le travail de l'application tierce consiste uniquement
à fournir à l'utilisateur une url avec les bons paramètres, puis
à recevoir la réponse du serveur si tout s'est bien passé.
Comme un dessin vaut parfois mieux que mille mots,
voici les diagrammes décrivant le processus.
L'un montre l'entièreté de la démarche ;
l'autre dans un souci de simplicité, ne montre que ce qui est visible
directement par l'application tierce.
=== "Intégralité du processus"
```mermaid
sequenceDiagram
actor User
participant App
User->>+App: Authentifie-moi, stp
App-->>-User: url de connexion<br/>avec signature
User->>+Sith: GET url
opt Utilisateur non-connecté
Sith->>+User: Formulaire de connexion
User-->>-Sith: Connexion
end
Sith->>Sith: vérification de la signature
Sith->>+User: Formulaire<br/>des conditions<br/>d'utilisation
User-->>-Sith: Validation
Sith->>+App: URL de retour<br/>avec données utilisateur
App->>App: Traitement des <br/>données utilisateur
App-->>-Sith: 204 OK, No content
Sith-->>-User: Message de succès
App--)User: Message de succès
```
=== "Point de vue de l'application tierce"
```mermaid
sequenceDiagram
actor User
participant App
User->>+App: Authentifie-moi, stp
App-->>-User: url de connexion<br/>avec signature
opt
Sith->>+App: URL de retour<br/>avec données utilisateur
App->>App: Traitement des <br/>données utilisateur
App-->>-Sith: 204 OK, No content
App--)User: Message de succès
end
```
## Données attendues
### URL de connexion
L'URL de connexion que vous allez fournir à l'utilisateur doit
être `https://ae.utbm.fr/api-link/auth/`
et doit contenir les données décrites dans
[`ThirdPartyAuthParamsSchema`][api.schemas.ThirdPartyAuthParamsSchema] :
- `client_id` (integer) : l'id de votre client, que vous pouvez obtenir
de la manière décrite plus haut
- `third_party_app`(string) : le nom de la plateforme pour laquelle
l'authentification va être réalisée (si votre application est un bot
discord, mettez la valeur "discord")
- `privacy_link`(URL) : l'URL vers la page de politique de confidentialité
qui s'appliquera dans le cadre de l'application
(s'il s'agit d'un bot discord, donnez le lien vers celles de Discord)
- `username`(string) : le pseudonyme que l'utilisateur possède sur
votre application
- `callback_url`(URL) : l'URL que le site AE appellera si l'authentification
réussit
- `signature`(string) : la signature des données de la requête.
Il s'agit d'une signature par clef HMAC dont le fonctionnement
est détaillé plus bas.
Ces données doivent être url-encodées et passées dans les paramètres GET.
!!!warning "URL de retour"
Les URLs fournies doivent être des URLs HTTP valides.
En outre, elles doivent obligatoirement inclure la barre oblique finale.
=== "URL correcte ✔️"
`https://exemple.ae.utbm.fr/foo/`
=== "URL incorrecte ❌"
`https://exemple.ae.utbm.fr/foo`
!!!tip
Inclure l'id de votre utilisateur dans l'URL de retour
peut être un bon moyen de l'identifier lors du callback.
Par exemple : `GET /callback/{int:user_id}/`.
???Example
Supposons que votre client d'API soit utilisé dans le cadre d'un bot Discord,
avec les données suivantes :
- l'id du client est 15
- sa clef HMAC est "beb99dd53"
(c'est pour l'exemple, une vraie clef sera beaucoup plus longue)
- le pseudonyme discord de votre utilisateur est Brian
- son id sur discord est 123456789
- votre route de callback est `GET /callback/{int:user_id}/`,
accessible au domaine `https://bot.ae.utbm.fr`
Alors les paramètres de votre URL seront :
| Paramètre | valeur |
|-----------------|-----------------------------------------------------------------------|
| client_id | 15 |
| third_party_app | discord |
| privacy_link | `https://discord.com/privacy` |
| username | Brian |
| callback_url | `https://bot.ae.utbm.fr/callback/123456789/` |
| signature | 1a383c51060be64f07772aa42e07<br/>18ae096b8f21f2cdb4061c0834a416d12101 |
Et l'url fournie à l'utilisateur sera :
`https://ae.utbm.fr/api-link/auth/?client_id=15&third_party_app=discord
&privacy_link=https%3A%2F%2Fdiscord.com%2Fprivacy&username=Brian
&callback_url=https%3A%2F%2Fbot.ae.utbm.fr%2Fcallback%2F123456789%2F
&signature=1a383c51060be64f07772aa42e0718ae096b8f21f2cdb4061c0834a416d12101`
### Données de retour
Si l'authentification réussit, le site AE enverra une requête HTTP POST
à l'URL de retour fournie dans l'URL de connexion.
Le corps de la requête de callback et au format JSON
et contient deux paires clef-valeur :
- `user` : les données utilisateur, telles que décrites
par [UserProfileSchema][core.schemas.UserProfileSchema]
- `signature` : la signature des données utilisateur
???Example
En reprenant les mêmes paramètres que dans l'exemple précédent,
le site AE pourra renvoyer à l'application la requête suivante :
```http
POST https://bot.ae.utbm.fr/callback/123456789/
content-type: application/json
body: {
"user": {
"id": 144131,
"nick_name": "inzekitchen",
"first_name": "Brian",
...
},
"signature": "f16955bab6b805f6e1abbb98a86dfee53fed0bf812aa6513ca46cfd461b70020"
}
```
L'application doit répondre avec un des codes HTTP suivants :
| Code | Raison |
|------|--------------------------------------------------------------------------------|
| 204 | Tout s'est bien passé |
| 403 | Les données de retour ne sont <br>pas signées ou sont mal signées |
| 404 | L'URL de retour ne permet pas <br>d'identifier un utilisateur de l'application |
!!!note "Code d'erreur par défaut"
Si l'appel de la route fait face à plusieurs problèmes en même temps
(par exemple, l'URL ne permet pas de retrouver votre utilisateur,
et en plus les données sont mal signées),
le 403 prime et doit être retourné par défaut.
## Signature des données
Les données de l'URL de connexion doivent être signées,
et la signature de l'URL de retour doit être vérifiée.
Dans le deux cas, la signature est le digest HMAC-SHA512
des données url-encodées, en utilisant la clef HMAC du client d'API.
L'ordre dans lequel ces données sont placées dans l'encodage URL
doit être strictement le même que celui donné plus haut.
???Example "Signature de l'URL de connexion"
En reprenant le même exemple que les fois précédentes,
l'url-encodage des données est :
`client_id=15&third_party_app=discord
&privacy_link=https%3A%2F%2Fdiscord.com%2Fprivacy%2F&username=Brian
&callback_url=https%3A%2F%2Fbot.ae.utbm.fr%2Fcallback%2F123456789%2F`
Notez que la signature n'est pas (encore) dedans.
Cette dernière peut-être obtenue avec le code suivant :
=== ":simple-python: Python"
Dépendances :
- `environs` (>=14.1)
```python
import hmac
from urllib.parse import urlencode
from environs import Env
env = Env()
env.read_env()
key = env.str("HMAC_KEY").encode()
data = {
"client_id": 15,
"third_party_app": "discord",
"privacy_link": "https://discord.com/privacy/",
"username": "Brian",
"callback_url": "https://bot.ae.utbm.fr/callback/123456789/",
}
urlencoded = urlencode(data)
data["signature"] = hmac.digest(key, urlencoded.encode(), "sha512").hex()
# URL a fournir à l'utilisateur pour son authentification
user_url = f"https://ae.ubtm.fr/api-link/auth/?{urlencode(data)}"
```
=== ":simple-rust: Rust"
Dépendances :
- `hmac` (>=0.12.1)
- `url` (>=2.5.7, features `serde`)
- `serde` (>=1.0.228, features `derive`)
- `serde_urlencoded` (>=0.7.1)
- `sha2` (>=0.10.9)
- `dotenvy` (>= 0.15)
```rust
use hmac::{Mac, SimpleHmac};
use serde::Serialize;
use sha2::Sha512;
use url::Url;
#[derive(Serialize, Debug)]
struct UrlData<'a> {
client_id: u32,
third_party_app: &'a str,
privacy_link: Url,
username: &'a str,
callback_url: Url,
}
impl<'a> UrlData<'a> {
pub fn signature(&self, key: &[u8]) -> CtOutput<SimpleHmac<Sha512>> {
let urlencoded = serde_urlencoded::to_string(self).unwrap();
SimpleHmac::<Sha512>::new_from_slice(key)
.unwrap()
.chain_update(urlencoded.as_bytes())
.finalize()
}
}
impl Into<Url> for UrlData<'_> {
fn into(self) -> Url {
let key = std::env::var("HMAC_KEY").unwrap();
let mut url = Url::parse("http://ae.utbm.fr/api-link/auth/").unwrap();
url.set_query(Some(
format!(
"{}&signature={:x}",
serde_urlencoded::to_string(&self).unwrap(),
self.signature(key.as_bytes()).into_bytes()
)
.as_str(),
));
url
}
}
fn main() {
dotenvy::dotenv().expect("Couldn't load env");
let data = UrlData {
client_id: 1,
third_party_app: "discord",
privacy_link: "https://discord.com/privacy/".parse().unwrap(),
username: "Brian",
callback_url: "https://bot.ae.utbm.fr/callback/123456789/"
.parse()
.unwrap(),
};
let url: Url = data.into();
println!("{:?}", url);
}
```
???Example "Vérification de la signature de la réponse"
Les données utilisateur peuvent ressembler à :
```json
{
"user": {
"display_name": "Matthieu Vincent",
"profile_url": "/user/380/",
"profile_pict": "/static/core/img/unknown.jpg",
"id": 380,
"nick_name": None,
"first_name": "Matthieu",
"last_name": "Vincent",
},
"signature": "3802a280fbb01bd9fetc."
}
```
Vous pouvez vérifier la signature ainsi :
```python
import hmac
from urllib.parse import urlencode
from environs import Env
env = Env()
env.read_env()
def is_signature_valid(user_data: dict, signature: str) -> bool:
key = env.str("HMAC_KEY").encode()
urlencoded = urlencode(user_data)
return hmac.compare_digest(
hmac.digest(key, urlencoded.encode(), "sha512").hex(),
signature,
)
post_data = <récupération des données POST>
print(
"signature valide :",
is_signature_valid(post_data["user"], post_data["signature"])
)
```
!!!Warning
Vous devez impérativement vérifier la signature
des données de la requête de callback !
Ne pas vérifier la signature permet à n'importe quel acteur
tierce malveillant de vous appeler sur votre callback.
Ce serait une faille de sécurité majeure de votre côté.
Si l'équipe informatique se rend compte que vous ne le faites pas,
elle se réserve le droit de suspendre votre application,
immédiatement et sans préavis.
+4 -4
View File
@@ -112,7 +112,7 @@ cf. [HTTP persistant connection (wikipedia)](https://en.wikipedia.org/wiki/HTTP_
Voici quelques exemples :
=== "Python (requests)"
=== ":simple-python: Python (requests)"
Dépendances :
@@ -132,7 +132,7 @@ Voici quelques exemples :
print(response.json())
```
=== "Python (aiohttp)"
=== ":simple-python: Python (aiohttp)"
Dépendances :
@@ -159,7 +159,7 @@ Voici quelques exemples :
asyncio.run(main())
```
=== "Javascript (axios)"
=== ":simple-javascript: Javascript (axios)"
Dépendances :
@@ -179,7 +179,7 @@ Voici quelques exemples :
console.log(await instance.get("club/1").json());
```
=== "Rust (reqwest)"
=== ":simple-rust: Rust (reqwest)"
Dépendances :
-37
View File
@@ -1,37 +0,0 @@
#
# Copyright 2022
# - Maréchal <thgirod@hotmail.com
#
# 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.
class PaymentResultConverter:
"""Converter used for url mapping of the `eboutic.views.payment_result` view.
It's meant to build an url that can match
either `/eboutic/pay/success/` or `/eboutic/pay/failure/`
but nothing else.
"""
regex = "(success|failure)"
def to_python(self, value):
return str(value)
def to_url(self, value):
return str(value)
@@ -60,7 +60,7 @@
</p>
<br>
{% if settings.SITH_EBOUTIC_CB_ENABLED %}
<div @htmx:after-request="fill">
<div @htmx:after:request="fill">
{{ billing_infos_form }}
</div>
{% endif %}
+2 -2
View File
@@ -24,7 +24,7 @@
from django.urls import path, register_converter
from eboutic.converters import PaymentResultConverter
from core.converters import ResultConverter
from eboutic.views import (
BillingInfoFormFragment,
EbouticCheckout,
@@ -34,7 +34,7 @@ from eboutic.views import (
payment_result,
)
register_converter(PaymentResultConverter, "res")
register_converter(ResultConverter, "res")
urlpatterns = [
# Subscription views
+216 -64
View File
@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-09 07:21+0200\n"
"POT-Creation-Date: 2026-10-02 12:56+0200\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -35,6 +35,10 @@ msgstr ""
"True si gardé à jour par le biais d'un fournisseur externe de domains "
"toxics, False sinon"
#: api/admin.py
msgid "Reset HMAC key"
msgstr "Réinitialiser la clef HMAC"
#: api/admin.py
#, python-format
msgid ""
@@ -48,6 +52,23 @@ msgstr ""
msgid "Revoke selected API keys"
msgstr "Révoquer les clefs d'API sélectionnées"
#: api/forms.py
msgid "I have read and I accept the terms and conditions of use"
msgstr "J'ai lu et j'accepte les conditions générales d'utilisation."
#: api/forms.py
msgid "You must approve the terms and conditions of use."
msgstr "Vous devez approuver les conditions générales d'utilisation."
#: api/forms.py
msgid "You must confirm that this is your username."
msgstr "Vous devez confirmer que c'est bien votre nom d'utilisateur."
#: api/forms.py
#, python-format
msgid "I confirm that %(username)s is my username on %(app)s"
msgstr "Je confirme que %(username)s est mon nom d'utilisateur sur %(app)s"
#: api/models.py club/models.py com/models.py counter/models.py forum/models.py
msgid "name"
msgstr "nom"
@@ -68,6 +89,10 @@ msgstr "permissions du client"
msgid "Specific permissions for this api client."
msgstr "Permissions spécifiques pour ce client d'API"
#: api/models.py
msgid "HMAC Key"
msgstr "Clef HMAC"
#: api/models.py
msgid "api client"
msgstr "client d'api"
@@ -97,6 +122,76 @@ msgstr "clef d'api"
msgid "api keys"
msgstr "clefs d'api"
#: api/templates/api/third_party/auth.jinja
msgid "Confidentiality"
msgstr "Confidentialité"
#: api/templates/api/third_party/auth.jinja
#, python-format
msgid ""
"By ticking this box and clicking on the send button, you acknowledge and "
"agree to provide %(app)s with your first name, last name, nickname and any "
"other information that was the third party app was explicitly authorized to "
"fetch and that it must have acknowledged to you, in a complete and accurate "
"manner."
msgstr ""
"En cochant cette case et en cliquant sur le bouton « Envoyer », vous "
"reconnaissez et acceptez de fournir à %(app)s votre prénom, nom, pseudonyme "
"et toute autre information que l'application tierce a été explicitement "
"autorisée à récupérer et qu'elle doit vous avoir communiqué de manière "
"complète et exacte."
#: api/templates/api/third_party/auth.jinja
#, python-format
msgid ""
"The privacy policies of <a href=\"%(privacy_link)s\">%(app)s</a> and of <a "
"href=\"%(sith_cgu_link)s\">the Students' Association</a> applies as soon as "
"the form is submitted."
msgstr ""
"Les politiques de confidentialité de <a href=\"%(privacy_link)s\">%(app)s</"
"a> et de <a href=\"%(sith_cgu_link)s\">l'Association des Etudiants</a> "
"s'appliquent dès la soumission du formulaire."
#: api/templates/api/third_party/auth.jinja
msgid "Confirmation of identity"
msgstr "Confirmation d'identité"
#: api/views.py
msgid "The data provided for authentication is incorrect"
msgstr "Les données fournies pour l'authentification sont incorrectes."
#: api/views.py
msgid ""
"The signature is incorrect. We cannot ensure the provenance of the request."
msgstr ""
"La signature est incorrecte. Nous ne pouvons pas garantir l'authenticité de "
"la requête."
#: api/views.py
#, python-format
msgid ""
"You are going to link your AE account and your %(app)s account. Continue "
"only if this page was opened from %(app)s."
msgstr ""
"Vous allez lier votre compte AE et votre compte %(app)s. Poursuivez "
"uniquement si cette page a été ouverte depuis %(app)s."
#: api/views.py
msgid "You have been successfully authenticated. You can now close this page."
msgstr ""
"Vous avez été authentifié avec succès. Vous pouvez maintenant fermer cette "
"page."
#: api/views.py
msgid ""
"Your authentication on the AE website was successful, but an error happened "
"during the interaction with the third-party application. Please contact the "
"managers of the latter."
msgstr ""
"Votre authentification sur le site AE a fonctionné, mais une erreur est "
"arrivée durant l'interaction avec l'application tierce. Veuillez contacter "
"les responsables de cette dernière."
#: club/forms.py
msgid "Users to add"
msgstr "Utilisateurs à ajouter"
@@ -736,8 +831,9 @@ msgstr "Méthode de paiement"
#: counter/templates/counter/fragments/create_student_card.jinja
#: counter/templates/counter/last_ops.jinja
#: election/templates/election/election_detail.jinja
#: forum/templates/forum/macros.jinja pedagogy/templates/pedagogy/guide.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja sas/templates/sas/album.jinja
#: forum/templates/forum/macros.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: pedagogy/templates/pedagogy/guide.jinja sas/templates/sas/album.jinja
#: sas/templates/sas/moderation.jinja sas/templates/sas/picture.jinja
#: trombi/templates/trombi/detail.jinja
#: trombi/templates/trombi/edit_profile.jinja
@@ -931,8 +1027,9 @@ msgstr "Outils"
#: counter/templates/counter/cash_summary_list.jinja
#: counter/templates/counter/counter_list.jinja
#: election/templates/election/election_detail.jinja
#: forum/templates/forum/macros.jinja pedagogy/templates/pedagogy/guide.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja sas/templates/sas/album.jinja
#: forum/templates/forum/macros.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: pedagogy/templates/pedagogy/guide.jinja sas/templates/sas/album.jinja
#: trombi/templates/trombi/detail.jinja
#: trombi/templates/trombi/edit_profile.jinja
msgid "Edit"
@@ -1188,8 +1285,9 @@ msgstr "Actions"
#: com/templates/com/mailing_admin.jinja com/templates/com/poster_list.jinja
#: core/templates/core/file_detail.jinja
#: core/templates/core/file_moderation.jinja sas/templates/sas/moderation.jinja
#: sas/templates/sas/picture.jinja
#: core/templates/core/file_moderation.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: sas/templates/sas/moderation.jinja sas/templates/sas/picture.jinja
msgid "Moderate"
msgstr "Modérer"
@@ -1491,7 +1589,6 @@ msgstr "Descendre"
#: com/templates/com/weekmail_preview.jinja
#: core/templates/core/user_account_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: trombi/templates/trombi/comment_moderation.jinja
#: trombi/templates/trombi/export.jinja
msgid "Back"
@@ -1835,6 +1932,10 @@ msgstr ""
"Même si ce profil est caché, les utilisateurs sur cette liste pourront "
"toujours le voir."
#: core/models.py
msgid "ToS approved at"
msgstr "CGU approuvées le"
#: core/models.py
msgid "A user with that username already exists"
msgstr "Un utilisateur de ce nom d'utilisateur existe déjà"
@@ -1842,7 +1943,9 @@ msgstr "Un utilisateur de ce nom d'utilisateur existe déjà"
#: core/models.py core/templates/core/macros.jinja
#: core/templates/core/user_detail.jinja core/templates/core/user_edit.jinja
#: election/templates/election/election_detail.jinja
#: forum/templates/forum/macros.jinja trombi/templates/trombi/user_tools.jinja
#: forum/templates/forum/macros.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: trombi/templates/trombi/user_tools.jinja
msgid "Profile"
msgstr "Profil"
@@ -2109,7 +2212,8 @@ msgstr "R&D"
msgid "Site created by the IT Department of the AE"
msgstr "Site réalisé par le Pôle Informatique de l'AE"
#: core/templates/core/base/header.jinja core/templates/core/login.jinja
#: core/templates/core/base/header.jinja core/templates/core/cgu_approve.jinja
#: core/templates/core/login.jinja
#: core/templates/core/password_reset_complete.jinja
msgid "Login"
msgstr "Connexion"
@@ -2207,6 +2311,18 @@ msgstr "FAQ"
msgid "Wiki"
msgstr "Wiki"
#: core/templates/core/cgu_approve.jinja
msgid "Terms of Service"
msgstr "Conditions générales d'utilisation"
#: core/templates/core/cgu_approve.jinja
msgid ""
"To continue using our services, please read and approve the AE website's "
"terms of service"
msgstr ""
"Pour continuer à utiliser nos services, veuillez lire et approuver les "
"conditions générales d'utilisation du site AE."
#: core/templates/core/create.jinja
#, python-format
msgid "Create %(name)s"
@@ -2233,6 +2349,7 @@ msgstr "Confirmation"
#: core/templates/core/file_delete_confirm.jinja
#: counter/templates/counter/counter_click.jinja
#: counter/templates/counter/fragments/delete_student_card.jinja
#: pedagogy/templates/pedagogy/fragments/comment_report.jinja
#: sas/templates/sas/ask_picture_removal.jinja
msgid "Cancel"
msgstr "Annuler"
@@ -3133,6 +3250,19 @@ msgstr "Appliquer les droits récursivement"
msgid "Ensure this timestamp is set in the future"
msgstr "Assurez-vous que cet horodatage est dans le futur"
#: core/views/forms.py
msgid "You must approve the terms of service."
msgstr "Vous devez approuver les conditions générales d'utilisation"
#: core/views/forms.py
#, python-format
msgid ""
"I have read and I approve the <a href=\"%(url)s\" target=\"_blank\">Terms of "
"Service</a>"
msgstr ""
"J'ai lu et j'approuve les <a href=\"%(url)s\" target=\"_blank\">Conditions "
"Générales d'utilisation</a>"
#: core/views/forms.py
msgid "Username, email, or account number"
msgstr "Nom d'utilisateur, email, ou numéro de compte AE"
@@ -3779,7 +3909,6 @@ msgstr "Coffre vidé"
#: counter/templates/counter/cash_summary_list.jinja counter/views/cash.py
#: pedagogy/templates/pedagogy/moderation.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: trombi/templates/trombi/comment.jinja
#: trombi/templates/trombi/user_tools.jinja
msgid "Comment"
@@ -5109,6 +5238,62 @@ msgstr "signaler"
msgid "reporter"
msgstr "signalant"
#: pedagogy/templates/pedagogy/fragments/comment_report.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
msgid "Report"
msgstr "Signaler"
#: pedagogy/templates/pedagogy/fragments/ue_comment_form.jinja
msgid "Leave comment"
msgstr "Laisser un commentaire"
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: trombi/templates/trombi/export.jinja
msgid "Comments"
msgstr "Commentaires"
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
msgid "This comment has been reported"
msgstr "Ce commentaire a été signalé"
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
msgid "It will be hidden until moderated."
msgstr "Il sera caché tant qu'il ne sera pas modéré."
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/grade.jinja
msgid "Global grade"
msgstr "Note globale"
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/grade.jinja
msgid "Utility"
msgstr "Utilité"
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/grade.jinja
msgid "Interest"
msgstr "Intérêt"
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/grade.jinja
msgid "Teaching"
msgstr "Enseignement"
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/grade.jinja
msgid "Workload"
msgstr "Charge de travail"
#: pedagogy/templates/pedagogy/fragments/ue_detail/ue_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid ""
"You already posted a comment on this UE. If you want to comment again, "
"please modify or delete your previous comment."
msgstr ""
"Vous avez déjà commenté cette UE. Si vous voulez de nouveau commenter, "
"veuillez modifier ou supprimer votre commentaire précédent."
#: pedagogy/templates/pedagogy/guide.jinja
msgid "A guide of courses available at UTBM."
msgstr "Un guide de tous les cours disponibles à l'UTBM."
@@ -5117,6 +5302,10 @@ msgstr "Un guide de tous les cours disponibles à l'UTBM."
msgid "Search UE"
msgstr "Recherche d'UE"
#: pedagogy/templates/pedagogy/guide.jinja
msgid "Hide closed UEs"
msgstr "Cacher les UEs fermées"
#: pedagogy/templates/pedagogy/guide.jinja
#, python-format
msgid "%(display_name)s"
@@ -5170,44 +5359,36 @@ msgid "UE Details"
msgstr "Détails d'UE"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "CM: "
msgstr "CM : "
msgid "Category"
msgstr "Catégorie"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "TD: "
msgstr "TD : "
msgid "Credits"
msgstr "Crédits"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "TP: "
msgstr "TP : "
msgid "Lectures"
msgstr "Cours magistraux"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "TE: "
msgstr "TE : "
msgid "Tutorials"
msgstr "Travaux dirigés"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "THE: "
msgstr "THE : "
msgid "Practicals"
msgstr "Travaux pratiques"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Global grade"
msgstr "Note globale"
msgid "Work without supervision"
msgstr "Travail hors encadrement"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Utility"
msgstr "Utilité"
msgid "Availability"
msgstr "Disponibilité"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Interest"
msgstr "Intérêt"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Teaching"
msgstr "Enseignement"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Work load"
msgstr "Charge de travail"
msgid "Manager"
msgstr "Responsable"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Objectives"
@@ -5225,35 +5406,6 @@ msgstr "Compétences acquises"
msgid "Key concepts"
msgstr "Concepts clefs"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "UE manager: "
msgstr "Gestionnaire d'UE : "
#: pedagogy/templates/pedagogy/ue_detail.jinja pedagogy/tests/tests.py
msgid ""
"You already posted a comment on this UE. If you want to comment again, "
"please modify or delete your previous comment."
msgstr ""
"Vous avez déjà commenté cette UE. Si vous voulez de nouveau commenter, "
"veuillez modifier ou supprimer votre commentaire précédent."
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Leave comment"
msgstr "Laisser un commentaire"
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: trombi/templates/trombi/export.jinja
msgid "Comments"
msgstr "Commentaires"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "This comment has been reported"
msgstr "Ce commentaire a été signalé"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Report this comment"
msgstr "Signaler ce commentaire"
#: pedagogy/templates/pedagogy/ue_edit.jinja
msgid "Edit UE"
msgstr "Éditer l'UE"
+1 -1
View File
@@ -25,7 +25,7 @@
{% endfor %}
</div>
{% if page_obj.has_other_pages() %}
{{ paginate_htmx(page_obj, paginator) }}
{{ paginate_htmx(request, page_obj, paginator) }}
{% endif %}
<hr>
{% endif %}
+10
View File
@@ -58,3 +58,13 @@ class TestMatmatronch(TestCase):
assert list(response.context_data["object_list"]) == []
assert not response.context_data["form"].is_valid()
assert "Recherche vide" in response.context_data["form"].non_field_errors()
def test_search_many_users(self):
"""Test that the pagination works when a lot of users are returned."""
baker.make(User, promo=17, _quantity=40, _bulk_create=True)
self.client.force_login(subscriber_user.make())
response = self.client.get(reverse("matmat:search", query={"promo": 17}))
assert response.status_code == 200
assert response.context_data["paginator"].count == 43
assert response.context_data["paginator"].num_pages == 3
-1
View File
@@ -20,7 +20,6 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.db.models import F
from django.views.generic import ListView
from django.views.generic.edit import FormMixin
+3
View File
@@ -71,6 +71,7 @@ nav:
- API:
- Développement: tutorial/api/dev.md
- Connexion à l'API: tutorial/api/connect.md
- Liaison avec le compte AE: tutorial/api/account-link.md
- Markdown AE: tutorial/markdown.md
- Etransactions: tutorial/etransaction.md
- How-to:
@@ -95,6 +96,8 @@ nav:
- reference/api/hashers.md
- reference/api/models.md
- reference/api/perms.md
- reference/api/schemas.md
- reference/api/views.md
- club:
- reference/club/models.md
- reference/club/views.md
+385 -355
View File
File diff suppressed because it is too large. Load diff
+14 -14
View File
@@ -24,24 +24,24 @@
"#com:*": "./com/static/bundled/*"
},
"devDependencies": {
"@babel/core": "^8.0.1",
"@babel/preset-env": "^8.0.2",
"@biomejs/biome": "^2.5.1",
"@babel/core": "^8.0.6",
"@babel/preset-env": "^8.0.6",
"@biomejs/biome": "^2.5.15",
"@hey-api/openapi-ts": "^0.99.0",
"@types/alpinejs": "^3.13.11",
"@types/alpinejs": "^3.17.0",
"@types/alpinejs__sort": "^3.13.0",
"@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6",
"@types/node": "^26.2.0",
"@types/node": "^26.6.4",
"@typescript/native": "npm:typescript@^7.0.2",
"rollup-plugin-visualizer": "^7.1.1",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"vite": "^8.2.2"
"vite": "^8.3.2"
},
"dependencies": {
"@ae_utbm/aemark": "^0.1.3",
"@alpinejs/sort": "^3.16.2",
"@alpinejs/sort": "^3.17.4",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.8.0",
"@fortawesome/fontawesome-free": "^7.3.1",
@@ -49,13 +49,13 @@
"@fullcalendar/daygrid": "^6.1.21",
"@fullcalendar/icalendar": "^6.1.21",
"@fullcalendar/list": "^6.1.21",
"@sentry/browser": "^10.70.0",
"@zip.js/zip.js": "^2.8.57",
"3d-force-graph": "^1.80.0",
"alpinejs": "^3.16.2",
"@sentry/browser": "^11.3.0",
"@zip.js/zip.js": "^2.22.0",
"3d-force-graph": "^1.80.1",
"alpinejs": "^3.17.4",
"chart.js": "^4.5.1",
"country-flag-emoji-polyfill": "^0.1.10",
"cytoscape": "^3.34.1",
"cytoscape": "^3.34.3",
"cytoscape-cxtmenu": "^3.5.0",
"cytoscape-klay": "^3.1.4",
"d3-force-3d": "^3.0.6",
@@ -66,8 +66,8 @@
"js-cookie": "^3.0.8",
"lit-html": "^3.3.3",
"native-file-system-adapter": "^3.0.1",
"temporal-polyfill": "^1.0.4",
"three": "^0.185.1",
"temporal-polyfill": "^1.0.5",
"three": "^0.186.1",
"three-spritetext": "^1.10.0",
"tom-select": "^2.6.2"
}
+107 -343
View File
@@ -1,52 +1,22 @@
@import "core/static/core/devices";
@import "core/static/core/colors";
$pedagogy-blue: #1bb9ea;
$pedagogy-orange: #ea7900;
$pedagogy-hover-blue: #0e97ce;
$pedagogy-light-blue: #caf0ff;
$pedagogy-white-text: #f0f0f0;
.pedagogy {
&.star-not-checked {
color: #f7f7f7;
.star-not-checked {
color: lightgray;
margin-bottom: 0;
margin-top: 0;
}
&.star-checked {
.star-checked {
color: $pedagogy-orange;
margin-bottom: 0;
margin-top: 0;
}
&.grade-without-star {
display: none;
}
@media screen and (max-width: $large-devices) {
&.star-not-checked {
margin-left: 5px;
margin-right: 5px;
}
&.star-checked {
margin-left: 5px;
margin-right: 5px;
}
}
@media screen and (max-width: $small-devices) {
&.grade-without-star {
display: block;
}
&.grade-with-star {
display: none;
}
}
#ue-list {
font-size: 1.1em;
@@ -64,12 +34,10 @@ $pedagogy-white-text: #f0f0f0;
}
#search_form {
.radio-guide fieldset {
input[type="checkbox"] {
display: none;
}
margin-top: 10px;
margin-bottom: 0;
color: white;
@@ -85,80 +53,79 @@ $pedagogy-white-text: #f0f0f0;
}
}
#ue_detail {
color: #062f38;
.ue-quick-info-container {
&#ue_detail {
.ue-infos {
display: flex;
gap: 2em;
flex-direction: row;
@media screen and (max-width: 700px) {
flex-direction: column;
}
}
.infos-column {
display: flex;
flex-direction: column;
gap: 1em;
min-width: 300px;
}
.info-container {
// Darken the background color just enough to make this part
// stand out a little, while not being too perceptible
background: darken($white-color, 1%);
display: grid;
grid-template-columns: 20% 20% 20% 20% auto;
grid-template-rows: auto auto;
grid-template-areas:
"hours-cm hours-td hours-tp hours-te hours-the"
"department credit-type semester . .";
}
grid-template-columns: 1fr 1fr;
border-radius: 5px;
padding: .75rem 1rem ;
grid-gap: .5rem;
@include shadow;
.department {
grid-area: department;
}
.info-container-item {
display: grid;
grid-column: span 2;
grid-template-rows: subgrid;
grid-template-columns: subgrid;
grid-column-gap: 1rem;
.credit-type {
grid-area: credit-type;
}
div {
display: flex;
align-items: center;
}
.semester {
grid-area: semester;
}
:first-child {
justify-content: right;
text-align: right;
}
}
.hours-cm {
grid-area: hours-cm;
}
.hours-td {
grid-area: hours-td;
}
.hours-tp {
grid-area: hours-tp;
}
.hours-te {
grid-area: hours-te;
}
.hours-the {
grid-area: hours-the;
}
.leave-comment-not-allowed {
p {
text-align: center;
color: red;
hr {
grid-column: span 2;
width: 50%;
margin: auto;
}
}
.leave-comment {
.leave-comment-grid-container {
display: grid;
grid-template-columns: 270px auto;
grid-template-rows: 100%;
grid-template-areas: "stars comment";
.comment-form-content {
display: flex;
gap: 2em;
@media screen and (max-width: $large-devices) {
grid-template-columns: 100%;
grid-template-rows: auto auto;
grid-template-areas:
"stars"
"comment";
gap: 1em;
flex-direction: column;
}
}
.form-stars {
grid-area: stars;
flex: 0;
min-width: 250px;
display: flex;
align-content: flex-start;
justify-content: center;
flex-wrap: wrap;
gap: 1rem 2em;
height: unset;
}
.form-comment {
grid-area: comment;
flex: 2;
}
.ui-accordion-header-icon {
@@ -166,284 +133,81 @@ $pedagogy-white-text: #f0f0f0;
margin-right: 10px;
}
.input-stars {
margin-top: 20px;
input[type="submit"] {
display: block;
margin-left: auto;
}
}
.ue-details-container {
display: grid;
grid-template-columns: 150px 130px auto;
grid-template-rows: 156px 1fr;
grid-template-areas:
"grade grade-stars ue-infos"
". . ue-infos";
@media screen and (max-width: $large-devices) {
grid-template-columns: 50% 50%;
grid-template-rows: auto auto;
grid-template-areas:
"grade grade-stars"
"ue-infos ue-infos";
summary {
background: $pedagogy-orange;
color: $pedagogy-white-text;
@media screen and (min-width: $large-devices) {
clip-path: polygon(0 0%, 0 100%, 30% 100%, 33% 0);
}
}
}
.grade {
grid-area: grade;
color: $pedagogy-white-text;
background-color: $pedagogy-blue;
padding-right: 10px;
>p {
text-align: right;
font-weight: bold;
}
}
.grade-stars {
grid-area: grade-stars;
color: $pedagogy-white-text;
background-color: $pedagogy-blue;
font-weight: bold;
}
.ue-infos {
grid-area: ue-infos;
padding-left: 10px;
}
.comment-container {
display: grid;
grid-template-columns: 300px auto;
grid-template-rows: auto auto auto;
grid-template-areas:
"grade-block comment"
"grade-block info"
"comment-end-bar comment-end-bar";
margin-bottom: 30px;
margin-top: 10px;
.comment {
padding: 15px;
margin: 20px;
display: flex;
flex-direction: column;
gap: 10px;
background: $primary-neutral-light-color;
border-radius: 5px;
border: 1px darken($secondary-neutral-light-color, 10%) solid;
@media screen and (max-width: $large-devices) {
grid-template-columns: auto;
grid-template-rows: auto auto auto auto;
grid-template-areas:
"grade-block"
"comment"
"info"
"comment-end-bar";
&.reported {
border: #fc8181 1px solid;
}
.grade-block {
grid-area: grade-block;
width: 300px;
.comment-header {
display: flex;
gap: 20px;
display: grid;
grid-template-columns: 150px 150px;
grid-template-rows: 156px auto;
grid-template-areas:
"grade-type grade-stars"
"grade-extension grade-extension";
grid-gap: 15px;
clip-path: polygon(0 0, 0 100%, 100% 100%, 100% 30px, 270px 0);
align-items: start;
background-color: $pedagogy-blue;
@media screen and (max-width: $large-devices) {
grid-template-columns: 50% auto;
grid-template-rows: auto;
grid-template-areas: "grade-type grade-stars";
width: auto;
clip-path: none;
align-content: space-evenly;
align-items: end;
img {
width: 50px;
height: 50px;
border-radius: 50%;
}
.grade-extension {
grid-area: grade-extension;
background-color: $pedagogy-blue;
.comment-metadata {
display: flex;
flex-direction: column;
justify-content: center;
}
.grade-type {
grid-area: grade-type;
>p {
color: $pedagogy-white-text;
font-weight: bold;
text-align: right;
}
}
.grade-stars {
grid-area: grade-stars;
.comment-options {
flex: 2;
display: flex;
justify-content: right;
gap: 15px;
}
}
.comment {
grid-area: comment;
display: grid;
grid-template-columns: auto;
grid-template-rows: auto auto;
grid-template-areas:
"anchor"
"markdown";
@media screen and (max-width: $large-devices) {
border-left: solid;
border-right: solid;
border-color: $pedagogy-blue;
}
.anchor {
grid-area: anchor;
text-align: right;
margin-right: 15px;
}
.markdown {
grid-area: markdown;
min-height: 139px;
margin-top: 0;
margin-right: 0;
padding: 10px;
text-align: justify;
overflow: auto;
}
.comment-stars {
display: flex;
justify-content: space-evenly;
flex-wrap: wrap;
gap: 1em 2em;
padding: 1.5em;
border-top: 0.0625rem grey dotted;
border-bottom: 0.0625rem grey dotted;
}
.info {
grid-area: info;
padding-bottom: 10px;
@media screen and (max-width: $large-devices) {
border-left: solid;
border-right: solid;
border-color: $pedagogy-blue;
}
.status-reported {
color: red;
float: left;
padding-left: 10px;
}
.action {
float: right;
margin-top: 0;
}
}
.comment-end-bar {
grid-area: comment-end-bar;
display: grid;
grid-template-columns: 33% auto auto;
grid-template-rows: 2.5em;
grid-template-areas: "author date report";
background-color: $pedagogy-blue;
margin-top: -1px;
@media screen and (max-width: $large-devices) {
grid-template-columns: auto;
grid-template-rows: auto auto auto;
grid-template-areas:
"report"
"date"
"author";
margin-top: 0;
text-align: center;
}
.author {
grid-area: author;
padding-top: 6px;
padding-left: 20px;
background-color: $pedagogy-orange;
clip-path: polygon(0 10px, 0 100%, 350px 200%, 300px 10px);
@media screen and (max-width: $large-devices) {
clip-path: none;
padding: 0;
padding-bottom: 7px;
}
a {
color: $pedagogy-white-text;
font-weight: bold;
}
a:hover {
color: $pedagogy-hover-blue;
}
}
.date {
grid-area: date;
color: $pedagogy-white-text;
@media screen and (max-width: $large-devices) {
padding-bottom: 7px;
}
}
.report {
grid-area: report;
justify-self: right;
padding-right: 30px;
padding-left: 30px;
a {
color: $pedagogy-white-text;
cursor: pointer;
}
a:hover {
color: $pedagogy-hover-blue;
}
@media screen and (max-width: $large-devices) {
text-align: center;
justify-self: inherit;
padding-bottom: 7px;
background-color: $white-color;
border-left: solid;
border-right: solid;
border-color: $pedagogy-blue;
a {
color: $black-color;
}
}
}
.comment-content {
text-align: justify;
padding: .5em 20px 1em;
}
}
}
}
details.accordion summary {
background: $pedagogy-orange !important;
color: $pedagogy-white-text !important;
clip-path: polygon(0 0%, 0 100%, 30% 100%, 33% 0);
@media screen and (max-width: $large-devices) {
clip-path: none;
}
}
details.accordion>.accordion-content {
background-color: $white-color;
border-color: $pedagogy-orange;
border-right: none;
}
.buttons {
display: flex;
justify-content: flex-end;
@@ -11,44 +11,39 @@
hx-disabled-elt="find input[type='submit']"
>
{% csrf_token %}
<div class="leave-comment-grid-container">
{{ form.non_field_errors() }}
{{ form.author.errors }}
{{ form.ue.errors }}
{{ form.author }}
{{ form.ue }}
<div class="comment-form-content">
<div class="form-stars">
{{ form.non_field_errors() }}
{{ form.author.errors }}
{{ form.ue.errors }}
{{ form.author }}
{{ form.ue }}
<div class="input-stars">
<label for="{{ form.grade_global.id_for_label }}">{{ form.grade_global.label }} :</label>
<fieldset>
<legend>{{ form.grade_global.label }} :</legend>
{{ form.grade_global.errors }}
{{ form.grade_global }}
</div>
<div class="input-stars">
<label for="{{ form.grade_utility.id_for_label }}">{{ form.grade_utility.label }} :</label>
</fieldset>
<fieldset>
<legend>{{ form.grade_utility.label }} :</legend>
{{ form.grade_utility.errors }}
{{ form.grade_utility }}
</div>
<div class="input-stars">
<label for="{{ form.grade_interest.id_for_label }}">{{ form.grade_interest.label }} :</label>
</fieldset>
<fieldset>
<legend>{{ form.grade_interest.label }} :</legend>
{{ form.grade_interest.errors }}
{{ form.grade_interest }}
</div>
<div class="input-stars">
<label for="{{ form.grade_teaching.id_for_label }}">{{ form.grade_teaching.label }} :</label>
</fieldset>
<fieldset>
<legend>{{ form.grade_teaching.label }} :</legend>
{{ form.grade_teaching.errors }}
{{ form.grade_teaching }}
</div>
<div class="input-stars">
<label for="{{ form.grade_work_load.id_for_label }}">{{ form.grade_work_load.label }} :</label>
</fieldset>
<fieldset>
<legend>{{ form.grade_work_load.label }} :</legend>
{{ form.grade_work_load.errors }}
{{ form.grade_work_load }}
</div>
</fieldset>
</div>
<div class="form-comment">
<label for="{{ form.comment.id_for_label }}">{{ form.comment.label }} :</label>
@@ -56,9 +51,7 @@
{{ form.comment }}
</div>
</div>
<div class="buttons">
<input type="submit" value="{% trans %}Comment{% endtrans %}" />
</div>
<p><input type="submit" class="btn btn-blue"/></p>
</form>
{% if form.is_creation %}
@@ -0,0 +1,129 @@
{% from "pedagogy/macros.jinja" import display_star %}
{% from "core/macros.jinja" import user_profile_link %}
{% if comments %}
<h2>{% trans %}Comments{% endtrans %}</h2>
<br>
{% endif %}
<section>
{%- for comment in comments -%}
<article
id="comment-{{ comment.id }}"
class="comment {% if comment.is_reported %}reported{% endif %}"
>
{% if comment.is_reported %}
<div class="alert alert-red">
<div class="alert-main">
<h5 class="alert-title margin-bottom">
{% trans %}This comment has been reported{% endtrans %}
</h5>
<p>{% trans %}It will be hidden until moderated.{% endtrans %}</p>
{% if user.has_perm("pedagogy.view_uecommentreport") %}
{% for report in comment.reports.all() %}
<div class="markdown margin-bottom">
<blockquote>
<em>{{ report.reporter.get_display_name() }}</em>
{{ report.reason|markdown }}
</blockquote>
</div>
{% endfor %}
{% endif %}
</div>
{% if comment.author_id == user.id or user.has_perm("pedagogy.delete_uecomment") %}
<div class="alert-aside">
<a
href="{{ url("pedagogy:comment_delete", comment_id=comment.id) }}"
class="btn btn-red"
>
<i class="fa fa-trash"></i>{% trans %}Delete{% endtrans %}
</a>
<a href="{{ url("pedagogy:moderation") }}" class="btn btn-green">
<i class="fa fa-check"></i>{% trans %}Moderate{% endtrans %}
</a>
</div>
{% endif %}
</div>
{% endif %}
<div class="comment-header">
<img
{% if comment.author.avatar_pict %}
src="{{ comment.author.avatar_pict.get_download_url() }}"
{% elif comment.author.profile_pict %}
src="{{ comment.author.profile_pict.get_download_url() }}"
{% else %}
src="{{ static("core/img/unknown.jpg") }}"
{% endif %}
alt="{% trans %}Profile{% endtrans %}"
/>
<div class="comment-metadata">
<a href="{{ comment.author.get_absolute_url() }}">
<strong>{{ comment.author.get_display_name() }}</strong>
</a>
<time datetime="{{ comment.publish_date.isoformat(timespec="seconds") }}">
{{ comment.publish_date|localtime|date(DATETIME_FORMAT) }}
{{ comment.publish_date|localtime|time(DATETIME_FORMAT) }}
</time>
</div>
<div class="comment-options">
{% if comment.author_id == user.id or user.has_perm("pedagogy.change_uecomment") %}
<a
class="clickable"
hx-get="{{ url('pedagogy:comment_update', comment_id=comment.id) }}"
hx-swap="outerHTML"
hx-target="#comment-{{ comment.id }}"
tooltip="{% trans %}Edit{% endtrans %}"
>
<i class="fa fa-pencil edit-action"></i>
</a>
{% endif %}
{% if not comment.is_reported %}
<a
class="clickable"
hx-get="{{ url('pedagogy:comment_report', comment_id=comment.id) }}"
hx-swap="outerHTML"
hx-target="#comment-{{ comment.id }}"
tooltip="{% trans %}Report{% endtrans %}"
>
<i class="fa fa-exclamation delete-action"></i>
</a>
{% if comment.author_id == user.id or user.has_perm("pedagogy.delete_uecomment") %}
<a
href="{{ url("pedagogy:comment_delete", comment_id=comment.id) }}"
tooltip="{% trans %}Delete{% endtrans %}"
>
<i class="fa fa-trash-can delete-action"></i>
</a>
{% endif %}
{% endif %}
</div>
</div>
<div class="comment-stars">
<div>
<span>{% trans %}Global grade{% endtrans %}</span>
<span>{{ display_star(comment.grade_global) }}</span>
</div>
<div>
<span>{% trans %}Utility{% endtrans %}</span>
<span>{{ display_star(comment.grade_utility) }}</span>
</div>
<div>
<span>{% trans %}Interest{% endtrans %}</span>
<span>{{ display_star(comment.grade_interest) }}</span>
</div>
<div>
<span>{% trans %}Teaching{% endtrans %}</span>
<span>{{ display_star(comment.grade_teaching) }}</span>
</div>
<div>
<span>{% trans %}Workload{% endtrans %}</span>
<span>{{ display_star(comment.grade_work_load) }}</span>
</div>
</div>
<div class="comment-content">
{{ comment.comment|markdown }}
</div>
</article>
{%- endfor -%}
</section>
@@ -0,0 +1,20 @@
<div class="info-container-item">
<span>{% trans %}Global grade{% endtrans %}</span>
<span>{{ display_star(object.grade_global_average) }}</span>
</div>
<div class="info-container-item">
<span>{% trans %}Utility{% endtrans %}</span>
<span>{{ display_star(object.grade_utility_average) }}</span>
</div>
<div class="info-container-item">
<span>{% trans %}Interest{% endtrans %}</span>
<span>{{ display_star(object.grade_interest_average) }}</span>
</div>
<div class="info-container-item">
<span>{% trans %}Teaching{% endtrans %}</span>
<span>{{ display_star(object.grade_teaching_average) }}</span>
</div>
<div class="info-container-item">
<span>{% trans %}Workload{% endtrans %}</span>
<span>{{ display_star(object.grade_work_load_average) }}</span>
</div>
@@ -0,0 +1,25 @@
{# A few hx-partials for swapping a few parts of the main ue_detail.jinja #}
{% from "pedagogy/macros.jinja" import display_star %}
<hx-partial id="ue-grade">
{% include "pedagogy/fragments/ue_detail/grade.jinja" %}
</hx-partial>
<hx-partial id="comment-form">
{% if object.has_user_already_commented(user) %}
<em>
{% trans trimmed %}
You already posted a comment on this UE.
If you want to comment again, please modify or delete your previous comment.
{% endtrans %}
</em>
<br>
{% elif user.has_perm("pedagogy.add_uecomment") %}
{{ add_comment_form }}
{% endif %}
</hx-partial>
<hx-partial id="comments" hx-swap="innerMorph">
{% include "pedagogy/fragments/ue_detail/comments.jinja" %}
</hx-partial>
@@ -1,91 +0,0 @@
{% from "pedagogy/macros.jinja" import display_star %}
{% from "core/macros.jinja" import user_profile_link %}
{% if comments %}
<h2>{% trans %}Comments{% endtrans %}</h2>
<br>
{% endif %}
<section>
{% for comment in comments %}
<div id="comment-{{ comment.id }}" class="comment-container">
<div class="grade-block">
<div class="grade-type">
<p>{% trans %}Global grade{% endtrans %}</p>
<p>{% trans %}Utility{% endtrans %}</p>
<p>{% trans %}Interest{% endtrans %}</p>
<p>{% trans %}Teaching{% endtrans %}</p>
<p>{% trans %}Work load{% endtrans %}</p>
</div>
<div class="grade-stars">
<p>{{ display_star(comment.grade_global) }}</p>
<p>{{ display_star(comment.grade_utility) }}</p>
<p>{{ display_star(comment.grade_interest) }}</p>
<p>{{ display_star(comment.grade_teaching) }}</p>
<p>{{ display_star(comment.grade_work_load) }}</p>
</div>
<div class="grade-extension"></div>
</div>
<div class="comment">
<div class="anchor">
<a href="{{ url('pedagogy:ue_detail', ue_id=ue.id) }}#comment-{{ comment.id }}"><i class="fa fa-paragraph"></i></a>
</div>
{{ comment.comment|markdown }}
</div>
<div class="info">
{% if comment.is_reported %}
<p class="status-reported">
{% trans %}This comment has been reported{% endtrans %}
</p>
{% endif %}
{% if comment.author_id == user.id or user.has_perm("pedagogy.change_comment") %}
<button
class="btn btn-orange action"
hx-get="{{ url('pedagogy:comment_update', comment_id=comment.id) }}"
hx-swap="outerHTML"
hx-target="#comment-{{ comment.id }}"
>
<i class="fa fa-pencil"></i> {% trans %}Edit{% endtrans %}
</button>
{% endif %}
{% if comment.author_id == user.id or user.has_perm("pedagogy.delete_comment") %}
<form class="action"
hx-post="{{ url('pedagogy:comment_delete', comment_id=comment.id) }}"
hx-confirm='{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}'
hx-swap="outerHTML"
hx-target="#comment-{{ comment.id }}"
>
{% csrf_token %}
<button class="btn btn-red action">
<i class="fa fa-trash-can"></i> {% trans %}Delete{% endtrans %}
</button>
</form>
{% endif %}
</div>
<div class="comment-end-bar">
<div class="report">
<p>
<a
hx-get="{{ url('pedagogy:comment_report', comment_id=comment.id) }}"
hx-swap="outerHTML"
hx-target="#comment-{{ comment.id }}"
>
{% trans %}Report this comment{% endtrans %}
</a>
</p>
</div>
<div class="date"><p>{{ comment.publish_date.strftime('%d/%m/%Y') }}</p></div>
<div class="author"><p>{{ user_profile_link(comment.author) }}</p></div>
</div>
</div>
{% endfor %}
</section>
@@ -1,10 +0,0 @@
{% if object.has_user_already_commented(user) %}
<div class="leave-comment-not-allowed">
<p>{% trans %}You already posted a comment on this UE. If you want to comment again, please modify or delete your previous comment.{% endtrans %}</p>
</div>
<br>
{% endif %}
{% if not object.has_user_already_commented(user) and user.has_perm("pedagogy.add_uecomment") %}
{{ add_comment_form }}
{% endif %}
@@ -1,27 +0,0 @@
<div class="ue-details-container">
<div class="grade">
<p>{% trans %}Global grade{% endtrans %}</p>
<p>{% trans %}Utility{% endtrans %}</p>
<p>{% trans %}Interest{% endtrans %}</p>
<p>{% trans %}Teaching{% endtrans %}</p>
<p>{% trans %}Work load{% endtrans %}</p>
</div>
<div class="grade-stars">
<p>{{ display_star(object.grade_global_average) }}</p>
<p>{{ display_star(object.grade_utility_average) }}</p>
<p>{{ display_star(object.grade_interest_average) }}</p>
<p>{{ display_star(object.grade_teaching_average) }}</p>
<p>{{ display_star(object.grade_work_load_average) }}</p>
</div>
<div class="ue-infos">
<p><b>{% trans %}Objectives{% endtrans %}</b></p>
<p>{{ object.objectives|markdown }}</p>
<p><b>{% trans %}Program{% endtrans %}</b></p>
<p>{{ object.program|markdown }}</p>
<p><b>{% trans %}Earned skills{% endtrans %}</b></p>
<p>{{ object.skills|markdown }}</p>
<p><b>{% trans %}Key concepts{% endtrans %}</b></p>
<p>{{ object.key_concepts|markdown }}</p>
<p><b>{% trans %}UE manager: {% endtrans %}</b>{{ object.manager }}</p>
</div>
</div>
-3
View File
@@ -1,5 +1,4 @@
{% macro display_star(grade) -%}
{% if grade >= 0 %}
{% for i in range(5) %}
{% if i <= grade %}
@@ -8,9 +7,7 @@
<span class="fa-solid fa-star pedagogy star-not-checked grade-with-star"></span>
{% endif %}
{% endfor %}
<span class="pedagogy grade-without-star">{{ grade }}/5</span>
{% else %}
<span class="grade-text"> {% trans %} not rated {% endtrans %} </span>
{% endif %}
{%- endmacro %}
+77 -76
View File
@@ -1,91 +1,92 @@
{% extends "core/base.jinja" %}
{% from "pedagogy/macros.jinja" import display_star %}
{% if is_fragment %}
<hx-partial id="ue-grade">
{% include "pedagogy/fragments/ue_details/grade.jinja" %}
</hx-partial>
{% block additional_css %}
<link rel="stylesheet" href="{{ static('pedagogy/css/pedagogy.scss') }}">
{% endblock %}
<hx-partial id="comment-form">
{% include "pedagogy/fragments/ue_details/form.jinja" %}
</hx-partial>
{% block title %}
{% trans %}UE Details{% endtrans %}
{% endblock %}
<hx-partial id="comments" hx-swap="innerMorph">
{% include "pedagogy/fragments/ue_details/comments.jinja" %}
</hx-partial>
{% else %}
{% extends "core/base.jinja" %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static('pedagogy/css/pedagogy.scss') }}">
{% endblock %}
{% block title %}
{% trans %}UE Details{% endtrans %}
{% endblock %}
{% block content %}
<div class="pedagogy">
<div id="ue_detail">
<button onclick='(function(){
// If comes from the guide page, go back with history
if (document.referrer.replace(/\?(.+)/gm,"").endsWith(`{{ url("pedagogy:guide") }}`)){
window.history.back();
return;
}
// Simply goes to the guide page
window.location.href = `{{ url("pedagogy:guide") }}`;
})()' hidden>{% trans %}Back{% endtrans %}</button>
<h1>{{ object.code }} - {{ object.title }}</h1>
<br>
<div class="ue-quick-info-container">
<div class="hours-cm">
<b>{% trans %}CM: {% endtrans %}</b>{{ object.hours_CM }}
{% block content %}
<h1>{{ object.code }}</h1>
<h2 class="margin-bottom">{{ object.title }}</h2>
<div class="pedagogy" id="ue_detail">
<div class="ue-infos">
<div class="infos-column">
<aside class="info-container">
<div class="info-container-item">
<div>{% trans %}Category{% endtrans %}</div>
<div>{{ object.credit_type }}</div>
</div>
<div class="hours-td">
<b>{% trans %}TD: {% endtrans %}</b>{{ object.hours_TD }}
<div class="info-container-item">
<div>{% trans %}Credits{% endtrans %}</div>
<div>{{ object.credits }}</div>
</div>
<div class="hours-tp">
<b>{% trans %}TP: {% endtrans %}</b>{{ object.hours_TP }}
<hr>
<div class="info-container-item">
<div>{% trans %}Lectures{% endtrans %}</div>
<div>{{ object.hours_CM }}h</div>
</div>
<div class="hours-te">
<b>{% trans %}TE: {% endtrans %}</b>{{ object.hours_TE }}
<div class="info-container-item">
<div>{% trans %}Tutorials{% endtrans %}</div>
<div>{{ object.hours_TD }}h</div>
</div>
<div class="hours-the">
<b>{% trans %}THE: {% endtrans %}</b>{{ object.hours_THE }}
<div class="info-container-item">
<div>{% trans %}Practicals{% endtrans %}</div>
<div>{{ object.hours_TP }}h</div>
</div>
<div class="department">
{{ object.department }}
<div class="info-container-item">
<div>{% trans %}Work without supervision{% endtrans %}</div>
<div>{{ object.hours_THE }}h</div>
</div>
<div class="credit-type">
{{ object.credit_type }}
<hr>
<div class="info-container-item">
<div>{% trans %}Availability{% endtrans %}</div>
<div>{{ object.get_semester_display() }}</div>
</div>
<div class="semester">
{{ object.get_semester_display() }}
<hr>
<div class="info-container-item">
<div>{% trans %}Manager{% endtrans %}</div>
<div>{{ object.manager }}</div>
</div>
</div>
<br>
<div id="ue-grade">
{% include "pedagogy/fragments/ue_details/grade.jinja" %}
</div>
<br>
<div id="comment-form">
{% include "pedagogy/fragments/ue_details/form.jinja" %}
</div>
<div id="comments">
{% include "pedagogy/fragments/ue_details/comments.jinja" %}
</div>
</aside>
<aside class="info-container" id="ue-grade">
{% include "pedagogy/fragments/ue_detail/grade.jinja" %}
</aside>
</div>
<section class="ue-main-infos">
<h3>{% trans %}Objectives{% endtrans %}</h3>
<p>{{ object.objectives|markdown }}</p>
<h3>{% trans %}Program{% endtrans %}</h3>
<p>{{ object.program|markdown }}</p>
<h3>{% trans %}Earned skills{% endtrans %}</h3>
<p>{{ object.skills|markdown }}</p>
<h3>{% trans %}Key concepts{% endtrans %}</h3>
<p>{{ object.key_concepts|markdown }}</p>
</section>
</div>
{% endblock %}
{% endif %}
<br>
<div id="comment-form">
{% if object.has_user_already_commented(user) %}
<em>
{% trans trimmed %}
You already posted a comment on this UE.
If you want to comment again, please modify or delete your previous comment.
{% endtrans %}
</em>
<br>
{% elif user.has_perm("pedagogy.add_uecomment") %}
{{ add_comment_form }}
{% endif %}
</div>
<div id="comments">
{% include "pedagogy/fragments/ue_detail/comments.jinja" %}
</div>
</div>
{% endblock %}
+10 -7
View File
@@ -88,16 +88,19 @@ class UEDetailView(
model = UE
pk_url_kwarg = "ue_id"
template_name = "pedagogy/ue_detail.jinja"
permission_required = "pedagogy.view_ue"
fragments = {
"add_comment_form": UECommentCreateView,
}
fragments = {"add_comment_form": UECommentCreateView}
def get_template_names(self) -> list[str]:
is_fragment = self.request.headers.get("HX-Request", False)
return (
["pedagogy/fragments/ue_detail/ue_detail.jinja"]
if is_fragment
else ["pedagogy/ue_detail.jinja"]
)
def get_fragment_data(self):
return {
"add_comment_form": {"ue_id": self.object.id},
}
return {"add_comment_form": {"ue_id": self.object.id}}
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
+14 -14
View File
@@ -20,14 +20,14 @@ license = { text = "GPL-3.0-only" }
requires-python = "<4.0,>=3.14"
dependencies = [
"django>=5.2.17,<6.0.0",
"django-ninja>=1.7.0,<2.0.0",
"django-ninja>=1.7.1,<2.0.0",
"django-ninja-extra>=0.31.7",
"Pillow>=12.3.0,<13.0.0",
"aemark>=0.1.1",
"django-jinja<3.0.0,>=2.11.0",
"cryptography>=50.0.1,<51.0.0",
"cryptography>=50.0.2,<51.0.0",
"django-phonenumber-field>=8.5.0,<9.0.0",
"phonenumbers>=9.0.38,<10.0.0",
"phonenumbers>=9.0.40,<10.0.0",
"reportlab>=5.0.1,<6.0.0",
"django-haystack>=3.4.0,<4.0.0",
"xapian-haystack>=4.0.0,<5.0.0",
@@ -35,21 +35,21 @@ dependencies = [
"django-ordered-model>=3.7.4,<4.0.0",
"django-simple-captcha>=0.7.0,<1.0.0",
"python-dateutil>=2.9.0.post0,<3.0.0",
"sentry-sdk>=2.68.1,<3.0.0",
"sentry-sdk>=2.71.0,<3.0.0",
"jinja2>=3.1.6,<4.0.0",
"django-countries>=9.0.0,<10.0.0",
"django-countries>=9.1.0,<10.0.0",
"dict2xml>=1.7.8,<2.0.0",
"Sphinx>=9.1.0,<10", # Used by xapian during installation
"tomli>=2.4.1,<3.0.0",
"django-honeypot>=1.3.0,<2",
"pydantic-extra-types>=2.11.1,<3.0.0",
"ical>=14.1.1",
"redis[hiredis]>=6.4.0,<9.0.0",
"environs[django]>=15.1.0,<16",
"ical>=14.2.0",
"redis[hiredis]>=8.1.0,<9",
"environs[django]>=15.2.0,<16",
"requests>=2.34.2,<3.0.0",
"honcho>=2.0.0",
"psutil>=7.2.2,<8.0.0",
"celery[redis]>=5.6.2,<7",
"celery[redis]>=5.6.3,<7",
"django-celery-results>=2.6.0",
"django-celery-beat>=2.9.0",
]
@@ -65,10 +65,10 @@ prod = [
dev = [
"django-debug-toolbar>=8.0.0,<9",
"ipython>=9.17.1,<10.0.0",
"pre-commit>=4.6.1,<5.0.0",
"ruff>=0.16.6,<1.0.0",
"pre-commit>=4.6.2,<5.0.0",
"ruff>=0.16.10,<1.0.0",
"djhtml>=3.0.11,<4.0.0",
"faker>=40.38.0,<41.0.0",
"faker>=40.40.0,<41.0.0",
"rjsmin>=1.2.5,<2.0.0",
]
tests = [
@@ -76,7 +76,7 @@ tests = [
"pytest>=9.1.1,<10.0.0",
"pytest-cov>=7.1.0,<8.0.0",
"pytest-django>=4.14.0,<5.0.0",
"model-bakery>=1.24.0,<2.0.0",
"model-bakery>=1.24.1,<2.0.0",
"beautifulsoup4>=4.15.0,<5",
"lxml>=6.1.3,<7",
]
@@ -84,7 +84,7 @@ docs = [
"mkdocs>=1.6.1,<2.0.0",
"mkdocs-material>=9.7.7,<10.0.0",
"mkdocstrings>=1.0.6,<2.0.0",
"mkdocstrings-python>=2.0.7,<3.0.0",
"mkdocstrings-python>=2.0.9,<3.0.0",
"mkdocs-include-markdown-plugin>=7.3.0,<8.0.0",
]
+5 -19
View File
@@ -2,7 +2,6 @@ import type TomSelect from "tom-select";
import type { TomOption } from "tom-select/src/types";
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
import { paginated } from "#core:utils/api";
import { History } from "#core:utils/history";
import {
type IdentifiedUserSchema,
type ModerationRequestSchema,
@@ -228,11 +227,6 @@ document.addEventListener("alpine:init", () => {
* Error message when a moderation operation fails
**/
moderationError: "",
/**
* Method of pushing new url to the browser history
* Used by popstate event and always reset to it's default value when used
**/
pushstate: History.Push,
async init() {
this.pictures = (
@@ -267,12 +261,10 @@ document.addEventListener("alpine:init", () => {
if (!event.state || event.state.sasPictureId === undefined) {
return;
}
this.pushstate = History.Replace;
this.currentPicture = this.pictures.find(
(i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId, 10),
) as PictureWithIdentifications;
});
this.pushstate = History.Replace; /* Avoid first url push */
await this.updatePicture();
},
@@ -285,17 +277,11 @@ document.addEventListener("alpine:init", () => {
* the list of identified users are updated.
*/
async updatePicture(): Promise<void> {
const updateArgs = {
data: { sasPictureId: this.currentPicture.id },
unused: "",
url: this.currentPicture.sas_url,
};
if (this.pushstate === History.Replace) {
window.history.replaceState(updateArgs.data, updateArgs.unused, updateArgs.url);
this.pushstate = History.Push;
} else {
window.history.pushState(updateArgs.data, updateArgs.unused, updateArgs.url);
}
window.history.replaceState(
{ sasPictureId: this.currentPicture.id },
"",
this.currentPicture.sas_url,
);
this.moderationError = "";
const index: number = this.pictures.indexOf(this.currentPicture);
+3
View File
@@ -372,6 +372,9 @@ SITH_PDF_CLUB_ID = env.int("SITH_PDF_CLUB_ID", default=2)
# Main root for club pages
SITH_CLUB_ROOT_PAGE = "clubs"
SITH_CGU_PAGE = env.str("SITH_CGU_PAGE", default="legals/ri")
# Define the date in the year serving as
# reference for the subscriptions calendar (month, day)
SITH_SEMESTER_START_AUTUMN = (8, 15) # 15 August
+1
View File
@@ -34,6 +34,7 @@ urlpatterns = [
path("", include(("core.urls", "core"), namespace="core")),
path("sitemap.xml", cache_page(86400)(sitemap), {"sitemaps": sitemaps}),
path("api/", api.urls),
path("api-link/", include(("api.urls", "api-link"), namespace="api-link")),
path("rootplace/", include(("rootplace.urls", "rootplace"), namespace="rootplace")),
path(
"subscription/",
+1 -12
View File
@@ -8,22 +8,11 @@ from django.utils.translation import gettext_lazy as _
from core.models import User
from core.utils import get_last_promo
from core.views.forms import SelectDate, SelectDateTime
from core.views.forms import SelectDate
from core.views.widgets.ajax_select import AutoCompleteSelectUser
from subscription.models import Subscription
class SelectionDateForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["start_date"] = forms.DateTimeField(
label=_("Start date"), widget=SelectDateTime, required=True
)
self.fields["end_date"] = forms.DateTimeField(
label=_("End date"), widget=SelectDateTime, required=True
)
class SubscriptionForm(forms.ModelForm):
allowed_payment_methods = ["CARD", "CASH", "AE_ACCOUNT"]
@@ -1,6 +1,13 @@
import { BarController, BarElement, CategoryScale, Chart, LinearScale } from "chart.js";
import {
BarController,
BarElement,
CategoryScale,
Chart,
LinearScale,
Tooltip,
} from "chart.js";
Chart.register(BarController, BarElement, CategoryScale, LinearScale);
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip);
function getRandomColor() {
const letters = "0123456789ABCDEF";
+32 -44
View File
@@ -11,51 +11,39 @@
{% block content %}
<p>
<form>
{{ form.start_date.label }}<br>
{{ form.start_date }}<br><br>
{{ form.end_date.label }}<br>
{{ form.end_date }}<br>
<p><input type="submit" value="{% trans %}Go{% endtrans %}" /></p>
</form>
</p>
<canvas id="statsChart" width="400" height="200"></canvas>
<p>
{% trans %}Total subscriptions{% endtrans %} : {{ subscriptions_total.count() }}<br><br>
{% trans %}Subscriptions by type{% endtrans %}<br><br>
{% for location in locations %}
{{ location[1] }} : <i class="nb">{{ subscriptions_total.filter(location=location[0]).count() }}</i><br>
<br>
<table>
<thead>
<th>{% trans %}Subscription type{% endtrans %}</th>
{% for location in locations %}
<th>{{ location[1] }}</th>
{% endfor %}
<th id="graphLabel">{% trans %}Total{% endtrans %}</th>
</thead>
<tr>
<td>{% trans %}All subscriptions{% endtrans %}</td>
{% for location in locations %}
<td><i class="nb">{{ total_location[location[0]] }}</i><br></td>
{% endfor %}
<td><i class="nb">{{ total_location.values()|sum }}</i></td>
</tr>
{% for type in subscriptions_types %}
<tr>
<td><i class="types">{{ subscriptions_types[type]['name'] }}</i></td>
{% for location in locations %}
<td>
{% for p_type in payment_types %}
{% set subtotal = subscriptions[type][location[0]][p_type[0]] %}
{% if subtotal > 0 %}
{{ p_type[1] }} : <i class="nb">{{ subtotal }}</i><br>
{% endif %}
{% endfor %}
</td>
{% endfor %}
<td class="total"><i class="nb">{{ total_type[type] }}</i></td>
</tr>
{% endfor %}
<p>
<br>
<table>
<tr>
<th>{% trans %}Subscription type{% endtrans %}</th>
{% for location in locations %}
<th>{{ location[1] }}</th>
{% endfor %}
<th id="graphLabel">{% trans %}Total{% endtrans %}</th>
{% for type in subscriptions_types %}
<tr>
<td><i class="types" >{{ subscriptions_types[type]['name'] }}</i></td>
{% set subscriptions_total_type = subscriptions_total.filter(subscription_type=type) %}
{% for location in locations %}
<td>
{% set subscriptions_total_type_location = subscriptions_total_type.filter(location=location[0]) %}
{% trans %}Total{% endtrans %} : {{ subscriptions_total_type_location.count()}}<br>
{% for p_type in payment_types %}
{{ p_type[1] }} : <i class="nb">{{ subscriptions_total_type_location.filter(payment_method=p_type[0]).count()}}</i><br>
{% endfor %}
</td>
{% endfor %}
<td class="total"><i class="nb">{{subscriptions_total_type.count()}}</i>
</tr>
{% endfor %}
</table>
</table>
{% endblock %}
+26 -22
View File
@@ -12,21 +12,21 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from collections import defaultdict
from django.conf import settings
from django.contrib.auth.forms import PasswordResetForm
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import PermissionDenied
from django.urls import reverse, reverse_lazy
from django.db.models import Count
from django.urls import reverse
from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, TemplateView
from django.views.generic.edit import FormView
from core.views import FragmentMixin, UseFragmentsMixin
from core.views.group import PermissionGroupsUpdateView
from subscription.forms import (
SelectionDateForm,
SubscriptionExistingUserForm,
SubscriptionNewUserForm,
)
@@ -93,35 +93,39 @@ class SubscriptionPermissionView(PermissionGroupsUpdateView):
extra_context = {"object_name": _("the groups that can create subscriptions")}
class SubscriptionsStatsView(FormView):
class SubscriptionsStatsView(TemplateView):
template_name = "subscription/stats.jinja"
form_class = SelectionDateForm
success_url = reverse_lazy("subscriptions:stats")
def dispatch(self, request, *arg, **kwargs):
self.start_date = localdate()
self.end_date = self.start_date
if request.user.is_root or request.user.is_board_member:
return super().dispatch(request, *arg, **kwargs)
raise PermissionDenied
def post(self, request, *args, **kwargs):
self.form = self.get_form()
self.start_date = self.form["start_date"]
self.end_date = self.form["end_date"]
return super().post(request, *args, **kwargs)
def get_initial(self):
return {
"start_date": self.start_date.strftime("%Y-%m-%d %H:%M:%S"),
"end_date": self.end_date.strftime("%Y-%m-%d %H:%M:%S"),
}
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["subscriptions_total"] = Subscription.objects.filter(
subscription_end__gte=self.end_date, subscription_start__lte=self.start_date
today = localdate()
qs = Subscription.objects.filter(
subscription_end__gte=today, subscription_start__lte=today
)
grouped = qs.values("subscription_type", "location", "payment_method").annotate(
count=Count("*")
)
by_location = qs.values("location").annotate(count=Count("*"))
by_type = qs.values("subscription_type").annotate(count=Count("*"))
kwargs["subscriptions"] = defaultdict(
lambda: defaultdict(lambda: defaultdict(int))
)
for sub in grouped:
kwargs["subscriptions"][sub["subscription_type"]][sub["location"]][
sub["payment_method"]
] = sub["count"]
kwargs["total_location"] = defaultdict(
int, {i["location"]: i["count"] for i in by_location}
)
kwargs["total_type"] = defaultdict(
int, {i["subscription_type"]: i["count"] for i in by_type}
)
kwargs["subscriptions_types"] = settings.SITH_SUBSCRIPTIONS
kwargs["payment_types"] = settings.SITH_SUBSCRIPTION_PAYMENT_METHOD
kwargs["locations"] = settings.SITH_SUBSCRIPTION_LOCATIONS
Generated
+379 -345
View File
File diff suppressed because it is too large. Load diff