25 Commits

Author SHA1 Message Date
Sli
70feecde30 Fix bug where you can't select /SAS as a parent album 2026-04-03 16:49:19 +02:00
64ebe30f5e Merge pull request #1331 from ae-utbm/upgrade_xapian
Upgrade xapian to 1.4.31
2026-04-02 16:19:46 +02:00
Sli
744ea20c33 Apply review comments 2026-04-02 16:16:27 +02:00
Sli
3e0a1c7334 Add documentation for xapian 2026-04-02 11:21:46 +02:00
Sli
ef036c135c Upgrade xapian to 1.4.31 and add sha256 check to avoid supply chain attack 2026-04-02 10:51:19 +02:00
366d6c7f03 Merge pull request #1326 from ae-utbm/fix-link-script-once
Fix link-once and script-once not triggering when another one disappears
2026-04-01 22:45:00 +02:00
2151bbf4c7 Merge pull request #1335 from ae-utbm/htmx_pagination
Allow custom target for htmx pagination
2026-04-01 22:26:12 +02:00
Sli
64b3acff07 Allow custom target for htmx pagination 2026-04-01 19:56:54 +02:00
Sli
6ef8b6b159 Create InheritedHtmlElements interface and create an ElementOnce interface 2026-04-01 19:48:02 +02:00
Sli
39dee782cc InheritedHtmlElements outside of factory 2026-04-01 19:01:37 +02:00
thomas girod
bda65f39af Merge pull request #1321 from ae-utbm/club-list
Rework club list page
2026-04-01 18:25:14 +02:00
imperosol
dfe884484d apply correct default club_status when GET data is missing 2026-04-01 14:04:10 +02:00
Sli
a0cce91bd5 Simplify ElementOnce observer 2026-03-31 18:04:22 +02:00
imperosol
0f00c91b59 push url on club search form submit 2026-03-30 19:39:13 +02:00
imperosol
b5d8db0187 apply review comments 2026-03-29 10:41:00 +02:00
imperosol
26178f10c5 use HTMX for club list page search 2026-03-29 10:37:26 +02:00
imperosol
842ae5615d add tests 2026-03-29 10:31:53 +02:00
imperosol
5cd748e313 add translations 2026-03-29 10:31:53 +02:00
imperosol
2944804074 rework club list page 2026-03-29 10:31:53 +02:00
fcce34fde5 Merge pull request #1329 from ae-utbm/fix_pagination
Fix pagination
2026-03-29 09:59:50 +02:00
Sli
a213bc058d Add request argument to paginate_jinja and paginate_htmx 2026-03-28 23:13:42 +01:00
Sli
71bfdf68b7 Use urlencode for querystring macro 2026-03-28 23:01:52 +01:00
Sli
2a9bb46e97 Fix 500 error on all broken pagination pages where with context was missing 2026-03-28 14:01:47 +01:00
Sli
efdf71d69e Over engineer ElementOnce web components 2026-03-26 23:48:40 +01:00
Sli
3bc4f1300e Fix link-once and script-once not triggering when another one disappears 2026-03-26 23:48:38 +01:00
55 changed files with 613 additions and 1207 deletions

View File

@@ -17,15 +17,6 @@ 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)

View File

@@ -1,16 +0,0 @@
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

View File

@@ -1,35 +0,0 @@
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")}

View File

@@ -1,19 +0,0 @@
# 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"
),
),
]

View File

@@ -1,20 +1,13 @@
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(
@@ -33,10 +26,11 @@ 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")
@@ -44,38 +38,33 @@ 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
def has_perms(self, perm_list: Iterable[str]) -> bool:
"""Return True if the client has each of the specified 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.
"""
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

View File

@@ -1,23 +0,0 @@
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

View File

@@ -1,32 +0,0 @@
{% 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 %}

View File

@@ -1,24 +0,0 @@
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]

View File

@@ -1,18 +0,0 @@
import pytest
from django.test import Client
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
@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()

View File

@@ -1,59 +0,0 @@
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

View File

@@ -1,114 +0,0 @@
from unittest import mock
from unittest.mock import Mock
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"]
)
def test_auth_ok(self):
self.client.force_login(self.user)
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert res.status_code == 200
with mock.patch("requests.post", new_callable=mocked_post(ok=True)) as mocked:
res = self.client.post(
reverse("api-link:third-party-auth"),
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(
reverse("api-link:third-party-auth"),
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 res.status_code == 403
def test_cgu_not_accepted(self):
self.client.force_login(self.user)
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert res.status_code == 200
res = self.client.post(reverse("api-link:third-party-auth"), data=self.query)
assert res.status_code == 200 # no redirect means invalid form
res = self.client.post(
reverse("api-link:third-party-auth"),
data={"cgu_accepted": False, "is_username_valid": False, **self.query},
)
assert res.status_code == 200
def test_invalid_client(self):
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 res.status_code == 403
def test_missing_parameter(self):
"""Test that a 403 is raised if there is a missing parameter."""
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 res.status_code == 403

View File

@@ -1,10 +1,6 @@
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",
@@ -13,14 +9,3 @@ 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",
),
]

View File

@@ -1,119 +0,0 @@
import hmac
from urllib.parse import unquote
import pydantic
import requests
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
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 SithFile
from core.schemas import UserProfileSchema
from core.utils import hmac_hexdigest
class ThirdPartyAuthView(LoginRequiredMixin, FormView):
form_class = ThirdPartyAuthForm
template_name = "api/third_party/auth.jinja"
success_url = reverse_lazy("core:index")
def parse_params(self) -> ThirdPartyAuthParamsSchema:
"""Parse and check the authentication parameters.
Raises:
PermissionDenied: if the verification 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 or self.request.POST
params = {key: unquote(val) for key, val in params.items()}
try:
params = ThirdPartyAuthParamsSchema(**params)
except pydantic.ValidationError as e:
raise PermissionDenied("Wrong data format") from e
client: ApiClient = get_object_or_none(ApiClient, id=params.client_id)
if not client:
raise PermissionDenied
if not hmac.compare_digest(
hmac_hexdigest(client.hmac_key, params.model_dump(exclude={"signature"})),
params.signature,
):
raise PermissionDenied("Bad signature")
return params
def dispatch(self, request, *args, **kwargs):
self.params = self.parse_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)}
response = requests.post(form.cleaned_data["callback_url"], json=data)
self.success_url = reverse(
"api-link:third-party-auth-result",
kwargs={"result": "success" if response.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": SithFile.objects.get(id=settings.SITH_CGU_FILE_ID),
}
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)

View File

@@ -6,7 +6,7 @@ from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from api.auth import ApiKeyAuth
from api.permissions import CanAccessLookup, CanView, HasPerm
from api.permissions import CanView, HasPerm
from club.models import Club, Membership
from club.schemas import (
ClubSchema,
@@ -22,13 +22,11 @@ class ClubController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[SimpleClubSchema],
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[CanAccessLookup],
url_name="search_club",
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_club(self, filters: Query[ClubSearchFilterSchema]):
return filters.filter(Club.objects.all())
return filters.filter(Club.objects.order_by("name")).values()
@route.get(
"/{int:club_id}",

View File

@@ -315,3 +315,27 @@ class JoinClubForm(ClubMemberForm):
_("You are already a member of this club"), code="invalid"
)
return super().clean()
class ClubSearchForm(forms.ModelForm):
class Meta:
model = Club
fields = ["name"]
widgets = {"name": forms.SearchInput(attrs={"autocomplete": "off"})}
club_status = forms.NullBooleanField(
label=_("Club status"),
widget=forms.RadioSelect(
choices=[(True, _("Active")), (False, _("Inactive")), ("", _("All clubs"))],
),
initial=True,
)
def __init__(self, *args, data: dict | None = None, **kwargs):
super().__init__(*args, data=data, **kwargs)
if data is not None and "club_status" not in data:
# if the key is missing, it is considered as None,
# even though we want the default True value to be applied in such a case
# so we enforce it.
self.fields["club_status"].value = True
self.fields["name"].required = False

View File

@@ -30,7 +30,7 @@ class ClubProfileSchema(ModelSchema):
class Meta:
model = Club
fields = ["id", "name", "logo"]
fields = ["id", "name", "logo", "is_active", "short_description"]
url: str

View File

@@ -0,0 +1,47 @@
#club-list {
display: flex;
flex-direction: column;
gap: 2em;
padding: 2em;
.card {
display: block;
background-color: unset;
.club-image {
float: left;
margin-right: 2rem;
margin-bottom: .5rem;
width: 150px;
height: 150px;
border-radius: 10%;
background-color: rgba(173, 173, 173, 0.2);
@media screen and (max-width: 500px) {
width: 100px;
height: 100px;
}
}
i.club-image {
display: flex;
flex-direction: column;
justify-content: center;
color: black;
}
.content {
display: block;
text-align: justify;
h4 {
margin-top: 0;
margin-right: .5rem;
}
p {
font-size: 100%;
}
}
}
}

View File

@@ -1,52 +1,76 @@
{% extends "core/base.jinja" %}
{% if is_fragment %}
{% extends "core/base_fragment.jinja" %}
{% block title -%}
{% trans %}Club list{% endtrans %}
{%- endblock %}
{# Don't display tabs and errors #}
{% block tabs %}
{% endblock %}
{% block errors %}
{% endblock %}
{% else %}
{% extends "core/base.jinja" %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("club/list.scss") }}">
{% endblock %}
{% block description -%}
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
{%- endblock %}
{% block title -%}
{% trans %}Club list{% endtrans %}
{%- endblock %}
{% endif %}
{% block description -%}
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
{%- endblock %}
{% macro display_club(club) -%}
{% if club.is_active or user.is_root %}
<li><a href="{{ url('club:club_view', club_id=club.id) }}">{{ club.name }}</a>
{% if not club.is_active %}
({% trans %}inactive{% endtrans %})
{% endif %}
{% if club.president %} - <a href="{{ url('core:user_profile', user_id=club.president.user.id) }}">{{ club.president.user }}</a>{% endif %}
{% if club.short_description %}<p>{{ club.short_description|markdown }}</p>{% endif %}
{% endif %}
{%- if club.children.all()|length != 0 %}
<ul>
{%- for c in club.children.order_by('name').prefetch_related("children") %}
{{ display_club(c) }}
{%- endfor %}
</ul>
{%- endif -%}
</li>
{%- endmacro %}
{% from "core/macros.jinja" import paginate_htmx %}
{% block content %}
{% if user.is_root %}
<p><a href="{{ url('club:club_new') }}">{% trans %}New club{% endtrans %}</a></p>
{% endif %}
{% if club_list %}
<main>
<h3>{% trans %}Filters{% endtrans %}</h3>
<form
id="club-list-filters"
hx-get="{{ url("club:club_list") }}"
hx-target="#content"
hx-swap="outerHtml"
hx-push-url="true"
>
<div class="row gap-4x">
{{ form }}
</div>
<button type="submit" class="btn btn-blue margin-bottom">
<i class="fa fa-magnifying-glass"></i>{% trans %}Search{% endtrans %}
</button>
</form>
<h3>{% trans %}Club list{% endtrans %}</h3>
<ul>
{%- for club in club_list %}
{{ display_club(club) }}
{%- endfor %}
</ul>
{% else %}
{% trans %}There is no club in this website.{% endtrans %}
{% endif %}
{% if user.has_perm("club.add_club") %}
<br>
<a href="{{ url('club:club_new') }}" class="btn btn-blue">
<i class="fa fa-plus"></i> {% trans %}New club{% endtrans %}
</a>
{% endif %}
<section class="aria-busy-grow" id="club-list">
{% for club in object_list %}
<div class="card">
{% set club_url = club.get_absolute_url() %}
<a href="{{ club_url }}">
{% if club.logo %}
<img class="club-image" src="{{ club.logo.url }}" alt="logo {{ club.name }}">
{% else %}
<i class="fa-regular fa-image fa-4x club-image"></i>
{% endif %}
</a>
<div class="content">
<a href="{{ club_url }}">
<h4>
{{ club.name }} {% if not club.is_active %}({% trans %}inactive{% endtrans %}){% endif %}
</h4>
</a>
{{ club.short_description|markdown }}
</div>
</div>
{% endfor %}
</section>
{% if is_paginated %}
{{ paginate_htmx(request, page_obj, paginator) }}
{% endif %}
</main>
{% endblock %}

View File

@@ -1,11 +1,7 @@
{% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import user_profile_link, select_all_checkbox %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/core/components/ajax-select-index.ts") }}"></script>
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
<link rel="stylesheet" href="{{ static("club/members.scss") }}">
{% endblock %}

View File

@@ -1,12 +1,15 @@
from datetime import timedelta
import pytest
from django.test import Client
from django.urls import reverse
from django.utils.timezone import localdate
from model_bakery import baker
from model_bakery.recipe import Recipe
from club.models import Club, Membership
from core.baker_recipes import subscriber_user
from core.models import User
@pytest.mark.django_db
@@ -25,3 +28,14 @@ def test_club_queryset_having_board_member():
club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)
assert set(club_ids) == {clubs[1].id, clubs[2].id}
@pytest.mark.parametrize("nb_additional_clubs", [10, 30])
@pytest.mark.parametrize("is_fragment", [True, False])
@pytest.mark.django_db
def test_club_list(client: Client, nb_additional_clubs: int, is_fragment):
client.force_login(baker.make(User))
baker.make(Club, _quantity=nb_additional_clubs)
headers = {"HX-Request": True} if is_fragment else {}
res = client.get(reverse("club:club_list"), headers=headers)
assert res.status_code == 200

View File

@@ -44,13 +44,19 @@ from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, View
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from django.views.generic.edit import (
CreateView,
DeleteView,
FormMixin,
UpdateView,
)
from club.forms import (
ClubAddMemberForm,
ClubAdminEditForm,
ClubEditForm,
ClubOldMemberForm,
ClubSearchForm,
JoinClubForm,
MailingForm,
SellingsForm,
@@ -66,7 +72,12 @@ from com.views import (
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
from core.models import Page, PageRev
from core.views import BasePageEditView, DetailFormView, UseFragmentsMixin
from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin
from core.views.mixins import (
AllowFragment,
FragmentMixin,
FragmentRenderer,
TabedViewMixin,
)
from counter.models import Selling
if TYPE_CHECKING:
@@ -180,15 +191,41 @@ class ClubTabsMixin(TabedViewMixin):
return tab_list
class ClubListView(ListView):
"""List the Clubs."""
class ClubListView(AllowFragment, FormMixin, ListView):
"""List the clubs of the AE, with a form to perform basic search.
Notes:
This view is fully public, because we want to advertise as much as possible
the cultural life of the AE.
In accordance with that matter, searching and listing the clubs is done
entirely server-side (no AlpineJS involved) ;
this is done this way in order to be sure the page is the most accessible
and SEO-friendly possible, even if it makes the UX slightly less smooth.
"""
model = Club
template_name = "club/club_list.jinja"
queryset = (
Club.objects.filter(parent=None).order_by("name").prefetch_related("children")
)
context_object_name = "club_list"
form_class = ClubSearchForm
queryset = Club.objects.order_by("name")
paginate_by = 20
def get_form_kwargs(self):
res = super().get_form_kwargs()
if self.request.method == "GET":
res |= {"data": self.request.GET, "initial": self.request.GET}
return res
def get_queryset(self):
form: ClubSearchForm = self.get_form()
qs = self.queryset
if not form.is_bound:
return qs.filter(is_active=True)
if not form.is_valid():
return qs.none()
if name := form.cleaned_data.get("name"):
qs = qs.filter(name__icontains=name)
if (is_active := form.cleaned_data.get("club_status")) is not None:
qs = qs.filter(is_active=is_active)
return qs
class ClubView(ClubTabsMixin, DetailView):

View File

@@ -1,16 +1,19 @@
from django.urls.converters import IntConverter, StringConverter
class FourDigitYearConverter(IntConverter):
class FourDigitYearConverter:
regex = "[0-9]{4}"
def to_python(self, value):
return int(value)
def to_url(self, value):
return str(value).zfill(4)
class TwoDigitMonthConverter(IntConverter):
class TwoDigitMonthConverter:
regex = "[0-9]{2}"
def to_python(self, value):
return int(value)
def to_url(self, value):
return str(value).zfill(2)
@@ -25,9 +28,3 @@ class BooleanStringConverter:
def to_url(self, value):
return str(value)
class ResultConverter(StringConverter):
"""Converter whose regex match either "success" or "failure"."""
regex = "(success|failure)"

View File

@@ -39,12 +39,16 @@ class Command(BaseCommand):
return None
return xapian.version_string()
def _desired_version(self) -> str:
def _desired_version(self) -> tuple[str, str, str]:
with open(
Path(__file__).parent.parent.parent.parent / "pyproject.toml", "rb"
) as f:
pyproject = tomli.load(f)
return pyproject["tool"]["xapian"]["version"]
return (
pyproject["tool"]["xapian"]["version"],
pyproject["tool"]["xapian"]["core-sha256"],
pyproject["tool"]["xapian"]["bindings-sha256"],
)
def handle(self, *args, force: bool, **options):
if not os.environ.get("VIRTUAL_ENV", None):
@@ -53,7 +57,7 @@ class Command(BaseCommand):
)
return
desired = self._desired_version()
desired, core_checksum, bindings_checksum = self._desired_version()
if desired == self._current_version():
if not force:
self.stdout.write(
@@ -65,7 +69,12 @@ class Command(BaseCommand):
f"Installing xapian version {desired} at {os.environ['VIRTUAL_ENV']}"
)
subprocess.run(
[str(Path(__file__).parent / "install_xapian.sh"), desired],
[
str(Path(__file__).parent / "install_xapian.sh"),
desired,
core_checksum,
bindings_checksum,
],
env=dict(os.environ),
check=True,
)

View File

@@ -1,7 +1,11 @@
#!/usr/bin/env bash
# Originates from https://gist.github.com/jorgecarleitao/ab6246c86c936b9c55fd
# first argument of the script is Xapian version (e.g. 1.2.19)
# second argument of the script is core sha256
# second argument of the script is binding sha256
VERSION="$1"
CORE_SHA256="$2"
BINDINGS_SHA256="$3"
# Cleanup env vars for auto discovery mechanism
unset CPATH
@@ -21,9 +25,15 @@ BINDINGS=xapian-bindings-$VERSION
# download
echo "Downloading source..."
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${CORE}.tar.xz"
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${CORE}.tar.xz" || exit 1
echo "${CORE_SHA256} ${CORE}.tar.xz" | sha256sum -c - || exit 1
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${BINDINGS}.tar.xz"
echo "${BINDINGS_SHA256} ${BINDINGS}.tar.xz" | sha256sum -c - || exit 1
# extract
echo "Extracting source..."
tar xf "${CORE}.tar.xz"

View File

@@ -28,7 +28,6 @@ from typing import ClassVar, NamedTuple
from django.conf import settings
from django.contrib.auth.models import Permission
from django.contrib.sites.models import Site
from django.core.files.base import ContentFile
from django.core.management import call_command
from django.core.management.base import BaseCommand
from django.db import connection
@@ -105,21 +104,13 @@ class Command(BaseCommand):
)
self.profiles_root = SithFile.objects.create(name="profiles", owner=root)
home_root = SithFile.objects.create(name="users", owner=root)
club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create(name="SAS", owner=root)
SithFile.objects.create(
name="CGU",
is_folder=False,
file=ContentFile(
content="Conditions générales d'utilisation", name="cgu.txt"
),
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)
main_club = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
)

View File

@@ -1,4 +1,3 @@
import math
import random
from datetime import date, timedelta
from datetime import timezone as tz
@@ -35,17 +34,12 @@ 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(options["nb_users"])
users = self.create_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...")
@@ -85,7 +79,7 @@ class Command(BaseCommand):
self.stdout.write("Creating products...")
self.create_products()
self.stdout.write("Creating sales and refills...")
sellers = random.sample(users, len(users) // 10)
sellers = random.sample(list(User.objects.all()), 100)
self.create_sales(sellers)
self.stdout.write("Creating permanences...")
self.create_permanences(sellers)
@@ -94,7 +88,7 @@ class Command(BaseCommand):
self.stdout.write("Done")
def create_users(self, nb_users: int = 600) -> list[User]:
def create_users(self) -> 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")
@@ -113,7 +107,7 @@ class Command(BaseCommand):
address=self.faker.address(),
password=password,
)
for _ in range(nb_users)
for _ in range(600)
]
# there may a duplicate or two
# Not a problem, we will just have 599 users instead of 600
@@ -416,9 +410,8 @@ class Command(BaseCommand):
Permanency.objects.bulk_create(perms)
def create_forums(self):
users = list(User.objects.all())
forumers = random.sample(users, math.ceil(len(users) / 10))
most_actives = random.sample(forumers, math.ceil(len(forumers) / 6))
forumers = random.sample(list(User.objects.all()), 100)
most_actives = random.sample(forumers, 10)
categories = list(Forum.objects.filter(is_category=True))
new_forums = [
Forum(name=self.faker.text(20), parent=random.choice(categories))

View File

@@ -1,18 +1,136 @@
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
import {
type InheritedHtmlElement,
inheritHtmlElement,
registerComponent,
} from "#core:utils/web-components.ts";
/**
* ElementOnce web components
*
* Those elements ensures that their content is always included only once on a document
* They are compatible with elements that are not managed with our Web Components
**/
export interface ElementOnce<K extends keyof HTMLElementTagNameMap>
extends InheritedHtmlElement<K> {
getElementQuerySelector(): string;
refresh(): void;
}
/**
* Create an abstract class for ElementOnce types Web Components
**/
export function elementOnce<K extends keyof HTMLElementTagNameMap>(tagName: K) {
abstract class ElementOnceImpl
extends inheritHtmlElement(tagName)
implements ElementOnce<K>
{
abstract getElementQuerySelector(): string;
clearNode() {
while (this.firstChild) {
this.removeChild(this.lastChild);
}
}
refresh() {
this.clearNode();
if (document.querySelectorAll(this.getElementQuerySelector()).length === 0) {
this.appendChild(this.node);
}
}
connectedCallback() {
super.connectedCallback(false);
this.refresh();
}
disconnectedCallback() {
// The MutationObserver can't see web components being removed
// It also can't see if something is removed inside after the component gets deleted
// We need to manually clear the containing node to trigger the observer
this.clearNode();
}
}
return ElementOnceImpl;
}
// Set of ElementOnce type components to refresh with the observer
const registeredComponents: Set<string> = new Set();
/**
* Helper to register ElementOnce types Web Components
* It's a wrapper around registerComponent that registers that component on
* a MutationObserver that activates a refresh on them when elements are removed
*
* You are not supposed to unregister an element
**/
export function registerElementOnce(name: string, options?: ElementDefinitionOptions) {
registeredComponents.add(name);
return registerComponent(name, options);
}
// Refresh all ElementOnce components on the document based on the tag name of the removed element
const refreshElement = <
T extends keyof HTMLElementTagNameMap,
K extends keyof HTMLElementTagNameMap,
>(
components: HTMLCollectionOf<ElementOnce<T>>,
removedTagName: K,
) => {
for (const element of components) {
// We can't guess if an element is compatible before we get one
// We exit the function completely if it's not compatible
if (element.inheritedTagName.toUpperCase() !== removedTagName.toUpperCase()) {
return;
}
element.refresh();
}
};
// Since we need to pause the observer, we make an helper to start it with consistent arguments
const startObserver = (observer: MutationObserver) => {
observer.observe(document, {
// We want to also listen for elements contained in the header (eg: link)
subtree: true,
childList: true,
});
};
// Refresh ElementOnce components when changes happens
const observer = new MutationObserver((mutations: MutationRecord[]) => {
// To avoid infinite recursion, we need to pause the observer while manipulation nodes
observer.disconnect();
for (const mutation of mutations) {
for (const node of mutation.removedNodes) {
if (node.nodeType !== node.ELEMENT_NODE) {
continue;
}
for (const registered of registeredComponents) {
refreshElement(
document.getElementsByTagName(registered) as HTMLCollectionOf<
ElementOnce<"html"> // The specific tag doesn't really matter
>,
(node as HTMLElement).tagName as keyof HTMLElementTagNameMap,
);
}
}
}
// We then resume the observer
startObserver(observer);
});
startObserver(observer);
/**
* Web component used to import css files only once
* If called multiple times or the file was already imported, it does nothing
**/
@registerComponent("link-once")
export class LinkOnce extends inheritHtmlElement("link") {
connectedCallback() {
super.connectedCallback(false);
@registerElementOnce("link-once")
export class LinkOnce extends elementOnce("link") {
getElementQuerySelector(): string {
// We get href from node.attributes instead of node.href to avoid getting the domain part
const href = this.node.attributes.getNamedItem("href").nodeValue;
if (document.querySelectorAll(`link[href='${href}']`).length === 0) {
this.appendChild(this.node);
}
return `link[href='${this.node.attributes.getNamedItem("href").nodeValue}']`;
}
}
@@ -20,14 +138,10 @@ export class LinkOnce extends inheritHtmlElement("link") {
* Web component used to import javascript files only once
* If called multiple times or the file was already imported, it does nothing
**/
@registerComponent("script-once")
@registerElementOnce("script-once")
export class ScriptOnce extends inheritHtmlElement("script") {
connectedCallback() {
super.connectedCallback(false);
// We get src from node.attributes instead of node.src to avoid getting the domain part
const src = this.node.attributes.getNamedItem("src").nodeValue;
if (document.querySelectorAll(`script[src='${src}']`).length === 0) {
this.appendChild(this.node);
}
getElementQuerySelector(): string {
// We get href from node.attributes instead of node.src to avoid getting the domain part
return `script[src='${this.node.attributes.getNamedItem("src").nodeValue}']`;
}
}

View File

@@ -23,10 +23,17 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio
* The technique is to:
* create a new web component
* create the desired type inside
* pass all attributes to the child component
* move all attributes to the child component
* store is at as `node` inside the parent
*
* Since we can't use the generic type to instantiate the node, we create a generator function
**/
export interface InheritedHtmlElement<K extends keyof HTMLElementTagNameMap>
extends HTMLElement {
readonly inheritedTagName: K;
node: HTMLElementTagNameMap[K];
}
/**
* Generator function that creates an InheritedHtmlElement compatible class
*
* ```js
* class MyClass extends inheritHtmlElement("select") {
@@ -35,11 +42,15 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio
* ```
**/
export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagName: K) {
return class Inherited extends HTMLElement {
protected node: HTMLElementTagNameMap[K];
return class InheritedHtmlElementImpl
extends HTMLElement
implements InheritedHtmlElement<K>
{
readonly inheritedTagName = tagName;
node: HTMLElementTagNameMap[K];
connectedCallback(autoAddNode?: boolean) {
this.node = document.createElement(tagName);
this.node = document.createElement(this.inheritedTagName);
const attributes: Attr[] = []; // We need to make a copy to delete while iterating
for (const attr of this.attributes) {
if (attr.name in this.node) {
@@ -47,6 +58,10 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
}
}
// We move compatible attributes to the child element
// This avoids weird inconsistencies between attributes
// when we manipulate the dom in the future
// This is especially important when using attribute based reactivity
for (const attr of attributes) {
this.removeAttributeNode(attr);
this.node.setAttributeNode(attr);

View File

@@ -141,6 +141,7 @@ form {
display: block;
margin: calc(var(--nf-input-size) * 1.5) auto 10px;
line-height: 1;
white-space: nowrap;
.fields-centered {
padding: 10px 10px 0;

View File

@@ -5,9 +5,8 @@
<details name="navbar" class="menu">
<summary class="head">{% trans %}Associations & Clubs{% endtrans %}</summary>
<ul class="content">
<li><a href="{{ url('core:page', page_name='ae') }}">{% trans %}AE{% endtrans %}</a></li>
<li><a href="{{ url('core:page', page_name='clubs') }}">{% trans %}AE's clubs{% endtrans %}</a></li>
<li><a href="{{ url('core:page', page_name='utbm-associations') }}">{% trans %}Others UTBM's Associations{% endtrans %}</a></li>
<li><a href="{{ url("core:page", page_name="ae") }}">{% trans %}AE{% endtrans %}</a></li>
<li><a href="{{ url("club:club_list") }}">{% trans %}AE's clubs{% endtrans %}</a></li>
</ul>
</details>
<details name="navbar" class="menu">

View File

@@ -48,6 +48,6 @@
>{% trans %}Delete{% endtrans %}</button></p>
</div>
{% endfor %}
{{ paginate_htmx(page_obj, paginator) }}
{{ paginate_htmx(request, page_obj, paginator) }}
</div>
{% endblock %}

View File

@@ -118,20 +118,21 @@
</nav>
{% endmacro %}
{% macro paginate_jinja(current_page, paginator) %}
{% macro paginate_jinja(request, current_page, paginator) %}
{# Add pagination buttons for pages without Alpine.
This must be coupled with a view that handles pagination
with the Django Paginator object.
Parameters:
request (django.http.request.HttpRequest): the current django request
current_page (django.core.paginator.Page): the current page object
paginator (django.core.paginator.Paginator): the paginator object
#}
{{ paginate_server_side(current_page, paginator, False) }}
{{ paginate_server_side(request, current_page, paginator, "") }}
{% endmacro %}
{% macro paginate_htmx(current_page, paginator) %}
{% macro paginate_htmx(request, current_page, paginator, htmx_target="#content") %}
{# Add pagination buttons for pages without Alpine but supporting fragments.
This must be coupled with a view that handles pagination
@@ -140,24 +141,26 @@
The replaced fragment will be #content so make sure you are calling this macro inside your content block.
Parameters:
request (django.http.request.HttpRequest): the current django request
current_page (django.core.paginator.Page): the current page object
paginator (django.core.paginator.Paginator): the paginator object
htmx_target (string): htmx target selector (default '#content')
#}
{{ paginate_server_side(current_page, paginator, True) }}
{{ paginate_server_side(request, current_page, paginator, htmx_target) }}
{% endmacro %}
{% macro paginate_server_side(current_page, paginator, use_htmx) %}
{% macro paginate_server_side(request, current_page, paginator, htmx_target) %}
<nav class="pagination">
{% if current_page.has_previous() %}
<a
{% if use_htmx -%}
hx-get="?{{ querystring(page=current_page.previous_page_number()) }}"
{% if htmx_target -%}
hx-get="?{{ querystring(request, page=current_page.previous_page_number()) }}"
hx-swap="innerHTML"
hx-target="#content"
hx-target="{{ htmx_target }}"
hx-push-url="true"
hx-trigger="click, keyup[key=='ArrowLeft'] from:body"
{%- else -%}
href="?{{ querystring(page=current_page.previous_page_number()) }}"
href="?{{ querystring(request, page=current_page.previous_page_number()) }}"
{%- endif -%}
>
<button>
@@ -174,13 +177,13 @@
<strong>{{ paginator.ELLIPSIS }}</strong>
{% else %}
<a
{% if use_htmx -%}
hx-get="?{{ querystring(page=i) }}"
{% if htmx_target -%}
hx-get="?{{ querystring(request, page=i) }}"
hx-swap="innerHTML"
hx-target="#content"
hx-target="{{ htmx_target }}"
hx-push-url="true"
{%- else -%}
href="?{{ querystring(page=i) }}"
href="?{{ querystring(request, page=i) }}"
{%- endif -%}
>
<button>{{ i }}</button>
@@ -189,14 +192,14 @@
{% endfor %}
{% if current_page.has_next() %}
<a
{% if use_htmx -%}
hx-get="?{{querystring(page=current_page.next_page_number())}}"
{% if htmx_target -%}
hx-get="?{{querystring(request, page=current_page.next_page_number())}}"
hx-swap="innerHTML"
hx-target="#content"
hx-target="{{ htmx_target }}"
hx-push-url="true"
hx-trigger="click, keyup[key=='ArrowRight'] from:body"
{%- else -%}
href="?{{querystring(page=current_page.next_page_number())}}"
href="?{{querystring(request, page=current_page.next_page_number())}}"
{%- endif -%}
><button>
<i class="fa fa-caret-right"></i>
@@ -247,15 +250,8 @@
{% endmacro %}
{% macro querystring() %}
{%- for key, values in request.GET.lists() -%}
{%- if key not in kwargs -%}
{%- for value in values -%}
{{ key }}={{ value }}&amp;
{%- endfor -%}
{%- endif -%}
{%- endfor -%}
{%- for key, value in kwargs.items() -%}
{{ key }}={{ value }}&amp;
{%- endfor -%}
{% macro querystring(request) %}
{%- set qs = request.GET.copy() -%}
{%- do qs.update(kwargs) -%}
{{- qs | urlencode -}}
{% endmacro %}

View File

@@ -1,14 +1,7 @@
{% extends "core/base.jinja" %}
{%- block additional_js -%}
<script type="module" src="{{ static("bundled/core/components/ajax-select-index.ts") }}"></script>
{%- endblock -%}
{%- block additional_css -%}
<link rel="stylesheet" href="{{ static('user/user_preferences.scss') }}">
{# importing ajax-select-index is necessary for it to be applied after HTMX reload #}
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
<link rel="stylesheet" href="{{ static("core/components/ajax-select.scss") }}">
{%- endblock -%}
{% block title %}

View File

@@ -1,13 +0,0 @@
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")

View File

@@ -12,32 +12,22 @@
# 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
from urllib.parse import urlencode
from typing import Final
import PIL
from django.conf import settings
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest
from django.utils.timezone import localdate
from PIL import ExifTags
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
RED_PIXEL_PNG: Final[bytes] = (
b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52"
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53"
@@ -215,30 +205,3 @@ 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()

View File

@@ -57,7 +57,7 @@
</table>
<br>
{% if is_paginated %}
{{ paginate_jinja(page_obj, paginator) }}
{{ paginate_jinja(request, page_obj, paginator) }}
{% endif %}
{% else %}
{% trans %}There is no cash register summary in this website.{% endtrans %}

View File

@@ -28,7 +28,7 @@
{%- endfor %}
</table>
{% if is_paginated %}
{{ paginate_jinja(page_obj, paginator) }}
{{ paginate_jinja(request, page_obj, paginator) }}
{% endif %}
{%- endblock %}

73
docs/howto/xapian.md Normal file
View File

@@ -0,0 +1,73 @@
## Pourquoi Xapian
Xapian permet de faire de la recherche fulltext.
C'est une librairie écrite en C++ avec des bindings Python
qu'on utilise avec la dépendance `django-haystack` via `xapian-haystack`.
Elle a les avantages suivants:
* C'est très rapide et ça correspond très bien à notre échelle
* C'est performant
* Pas besoin de service supplémentaire, c'est une librairie qui utilise des fichiers, comme sqlite
Mais elle a un défaut majeur: on ne peut pas « juste » la `pip install`,
il faut installer une librairie système et des bindings et ça a toujours été
l'étape la plus frustrante et buggée de notre process d'installation. C'est
aussi la seule raison qui fait que le projet n'es pas compatible windows.
## Mettre à jour Xapian
Pour installer xapian le plus simplement possible, on le compile depuis les
sources via la commande `./manage.py install_xapian` comme indiqué dans la
documentation d'installation.
La version de xapian est contrôlée par le `pyproject.toml` dans la section
`[tool.xapian]`.
Cette section ressemble à ceci:
```toml
[tool.xapian]
version = "x.y.z"
core-sha256 = "abcdefghijklmnopqrstuvwyz0123456789"
bindings-sha256 = "abcdefghijklmnopqrstuvwyz0123456789"
```
Comme on peut le voir, il y a 3 variables différentes, une variable de version,
qui sert à choisir la version à télécharger, et deux variables sha256.
Ces variables sha256 permettent de protéger des attaques par supply chain, un
peu comme uv et npm font avec leurs respectifs `uv.lock` et `package-lock.json`
. Elles permettent de vérifier que les fichiers téléchargés n'ont pas été
altérés entre la configuration du fichier et l'installation par l'utilisateur
et/ou le déploiement.
L'installation de xapian passe par deux fichiers, `xapian-core` et
`xapian-bindings` disponibles sur [https://xapian.org/download](https://xapian.org/download).
Lorsque le script d'installation télécharge les fichiers, il vérifie leur
signature sha256 contre celles contenues dans ces deux variables. Si la
signature n'est pas la même, une erreur est levée, protégant l'utilisateur
d'une potentielle attaque.
Pour mettre à jour, il faut donc changer la version ET modifier la signature !
Pour récupérer ces signatures, il suffit de télécharger soi-même les archives
du logiciel sur ce site, utiliser la commande `sha256sum` dessus et, enfin,
reporter la valeur sortie par cette commande.
Pour ce qui est de la correspondance, `core-sha256` correspond à la signature
de `xapian-core` et `bindings-sha256` de `xapian-bindings`.
Voici un bout de script qui peut faciliter une mise à jour:
```bash
VERSION="x.y.z" # À modifier avec la bonne version
curl -O "https://oligarchy.co.uk/xapian/${VERSION}/xapian-core-${VERSION}.tar.xz"
sha256sum xapian-core-${VERSION}.tar.xz # Affiche la signature pour `core-sha256`
rm -f xapian-core-${VERSION}
curl -O "https://oligarchy.co.uk/xapian/${VERSION}/xapian-bindings-${VERSION}.tar.xz"
sha256sum xapian-bindings-${VERSION}.tar.xz # Affiche la signature pour `bindingse-sha256`
rm -f xapian-bindings-${VERSION}.tar.xz
```

View File

@@ -1 +0,0 @@
::: api.schemas

View File

@@ -1 +0,0 @@
::: api.views

View File

@@ -1,353 +0,0 @@
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 /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.
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.
Ces données doivent être url-encodées et passées dans les paramètres GET.
!!!tip "URL de retour"
Notre système n'impose aucune contrainte quant à la manière
de construire votre URL (hormis le fait que ce doit être une URL HTTPS valide),
mais il est tout de même conseillé d'utiliser l'identifiant de votre
utilisateur comme paramètre dans l'URL
(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.
???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 !
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.

View File

@@ -112,7 +112,7 @@ cf. [HTTP persistant connection (wikipedia)](https://en.wikipedia.org/wiki/HTTP_
Voici quelques exemples :
=== ":simple-python: Python (requests)"
=== "Python (requests)"
Dépendances :
@@ -132,7 +132,7 @@ Voici quelques exemples :
print(response.json())
```
=== ":simple-python: Python (aiohttp)"
=== "Python (aiohttp)"
Dépendances :
@@ -158,7 +158,7 @@ Voici quelques exemples :
asyncio.run(main())
```
=== ":simple-javascript: Javascript (axios)"
=== "Javascript (axios)"
Dépendances :
@@ -178,7 +178,7 @@ Voici quelques exemples :
console.log(await instance.get("club/1").json());
```
=== ":simple-rust: Rust (reqwest)"
=== "Rust (reqwest)"
Dépendances :

37
eboutic/converters.py Normal file
View File

@@ -0,0 +1,37 @@
#
# 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)

View File

@@ -24,7 +24,7 @@
from django.urls import path, register_converter
from core.converters import ResultConverter
from eboutic.converters import PaymentResultConverter
from eboutic.views import (
BillingInfoFormFragment,
EbouticCheckout,
@@ -35,7 +35,7 @@ from eboutic.views import (
payment_result,
)
register_converter(ResultConverter, "res")
register_converter(PaymentResultConverter, "res")
urlpatterns = [
# Subscription views

View File

@@ -46,7 +46,7 @@
</section>
{%- endfor %}
{% if is_paginated %}
{{ paginate_jinja(page_obj, paginator) }}
{{ paginate_jinja(request, page_obj, paginator) }}
{% endif %}
{%- endblock %}

View File

@@ -27,7 +27,7 @@
</p>
{{ display_search_bar(request) }}
{{ paginate_jinja(msgs, msgs.paginator) }}
{{ paginate_jinja(request, msgs, msgs.paginator) }}
<main class="message-list">
{% for m in msgs %}
@@ -44,7 +44,9 @@
<p><a class="ib button" href="{{ url('forum:new_message', topic_id=topic.id) }}">{% trans %}Reply{% endtrans %}</a></p>
{{ paginate_jinja(msgs, msgs.paginator) }}
{% if is_paginated %}
{{ paginate_jinja(request, msgs, msgs.paginator) }}
{% endif %}
</div>
{% endblock %}

View File

@@ -35,10 +35,6 @@ 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 ""
@@ -52,23 +48,6 @@ 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"
@@ -89,10 +68,6 @@ 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"
@@ -122,63 +97,6 @@ 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
#, 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"
@@ -392,16 +310,36 @@ msgid "The list of all clubs existing at UTBM."
msgstr "La liste de tous les clubs existants à l'UTBM"
#: club/templates/club/club_list.jinja
msgid "inactive"
msgstr "inactif"
msgid "Filters"
msgstr "Filtres"
#: club/templates/club/club_list.jinja
msgid "Name"
msgstr "Nom"
#: club/templates/club/club_list.jinja
msgid "Club state"
msgstr "Etat du club"
#: club/templates/club/club_list.jinja
msgid "Active"
msgstr "Actif"
#: club/templates/club/club_list.jinja
msgid "Inactive"
msgstr "Inactif"
#: club/templates/club/club_list.jinja
msgid "All clubs"
msgstr "Tous les clubs"
#: club/templates/club/club_list.jinja core/templates/core/user_tools.jinja
msgid "New club"
msgstr "Nouveau club"
#: club/templates/club/club_list.jinja
msgid "There is no club in this website."
msgstr "Il n'y a pas de club dans ce site web."
msgid "inactive"
msgstr "inactif"
#: club/templates/club/club_members.jinja
msgid "Club members"
@@ -1963,10 +1901,6 @@ msgstr "L'AE"
msgid "AE's clubs"
msgstr "Les clubs de L'AE"
#: core/templates/core/base/navbar.jinja
msgid "Others UTBM's Associations"
msgstr "Les autres associations de l'UTBM"
#: core/templates/core/base/navbar.jinja
msgid "Big event"
msgstr "Grandes Activités"

View File

@@ -69,7 +69,6 @@ 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
- Etransactions: tutorial/etransaction.md
- How-to:
- L'ORM de Django: howto/querysets.md
@@ -81,6 +80,7 @@ nav:
- Ajouter un logo de promo: howto/logo.md
- Ajouter une cotisation: howto/subscriptions.md
- Modifier le weekmail: howto/weekmail.md
- Mettre à jour xapian: howto/xapian.md
- Terminal: howto/terminal.md
- Direnv: howto/direnv.md
- Reference:
@@ -92,8 +92,6 @@ 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

View File

@@ -92,7 +92,11 @@ docs = [
default-groups = ["dev", "tests", "docs"]
[tool.xapian]
version = "1.4.29"
version = "1.4.31"
# Those hashes are here to protect against supply chains attacks
# See `https://ae-utbm.github.io/sith/howto/xapian/` for more information
core-sha256 = "fecf609ea2efdc8a64be369715aac733336a11f7480a6545244964ae6bc80811"
bindings-sha256 = "a38cc7ba4188cc0bd27dc7369f03906772047087a1c54f1b93355d5e9103c304"
[tool.ruff]
output-format = "concise" # makes ruff error logs easier to read

View File

@@ -28,5 +28,7 @@
</table>
<br>
{{ paginate_jinja(page_obj, paginator) }}
{% if is_paginated %}
{{ paginate_jinja(request, page_obj, paginator) }}
{% endif %}
{% endblock content %}

View File

@@ -50,13 +50,15 @@ class AlbumEditForm(forms.ModelForm):
model = Album
fields = ["name", "date", "file", "parent", "edit_groups"]
widgets = {
"parent": AutoCompleteSelectAlbum,
"edit_groups": AutoCompleteSelectMultipleGroup,
}
name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name"))
date = forms.DateField(label=_("Date"), widget=SelectDate, required=True)
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
parent = forms.ModelChoiceField(
Album.objects.all(), required=True, widget=AutoCompleteSelectAlbum
)
class PictureModerationRequestForm(forms.ModelForm):

View File

@@ -205,7 +205,13 @@ class AlbumQuerySet(models.QuerySet):
class SASAlbumManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_in_sas=True, is_folder=True)
return (
super()
.get_queryset()
.filter(
Q(id=settings.SITH_SAS_ROOT_DIR_ID) | Q(is_in_sas=True, is_folder=True)
)
)
class Album(SasFile):

View File

@@ -404,8 +404,6 @@ SITH_FORUM_PAGE_LENGTH = 30
SITH_SAS_ROOT_DIR_ID = env.int("SITH_SAS_ROOT_DIR_ID", default=4)
SITH_SAS_IMAGES_PER_PAGE = 60
SITH_CGU_FILE_ID = env.int("SITH_CGU_FILE_ID", default=5)
SITH_PROFILE_DEPARTMENTS = [
("TC", _("TC")),
("IMSI", _("IMSI")),

View File

@@ -34,7 +34,6 @@ 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/",

View File

@@ -6,14 +6,8 @@
{% trans %}New subscription{% endtrans %}
{% endblock %}
{# The following statics are bundled with our autocomplete select.
However, if one tries to swap a form by another, then the urls in script-once
and link-once disappear.
So we give them here.
If the aforementioned bug is resolved, you can remove this. #}
{% block additional_js %}
<script type="module" src="{{ static('bundled/core/components/tabs-index.ts') }}"></script>
<script type="module" src="{{ static("bundled/core/components/ajax-select-index.ts") }}"></script>
<script
type="module"
src="{{ static("bundled/subscription/creation-form-existing-user-index.ts") }}"
@@ -21,8 +15,6 @@
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("core/components/tabs.scss") }}">
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
<link rel="stylesheet" href="{{ static("core/components/ajax-select.scss") }}">
<link rel="stylesheet" href="{{ static("subscription/css/subscription.scss") }}">
{% endblock %}