24 Commits

Author SHA1 Message Date
imperosol
f3c67fd38b doc: third-party auth 2026-03-12 19:03:47 +01:00
imperosol
338bb5300f translation: third-party authentication 2026-03-12 19:03:47 +01:00
imperosol
0e2ed2f102 write tests 2026-03-12 19:03:47 +01:00
imperosol
22b83692a7 third-party authentication views 2026-03-12 19:01:58 +01:00
imperosol
86f51066c2 add CGU/EULA to populate command 2026-03-11 16:23:03 +01:00
imperosol
494ee518b6 test populate_more command 2026-03-11 16:23:03 +01:00
imperosol
39fd5525cd hmac_hexdigest util function 2026-03-11 16:23:03 +01:00
imperosol
a18178c4a8 compact notifications jinja
C'est pas la branche la plus appropriée, mais ça gène mon debug de devoir scroller plus longtemps dans l'html des réponses à cause de la place que prennent les notifs.
2026-03-11 16:23:03 +01:00
imperosol
b363a5f259 add hmac_key to ApiClient 2026-03-11 16:23:03 +01:00
imperosol
72bc35bac8 move ResultConverter to core app 2026-03-11 16:23:03 +01:00
imperosol
f0b55e3fe3 feat: api route to get api client infos 2026-03-11 16:23:03 +01:00
thomas girod
1845a7cbcf Merge pull request #1312 from ae-utbm/dynamic-formset
Dynamic formset
2026-03-10 19:31:49 +01:00
imperosol
f17f17d8de use dynamic formset for product action formset 2026-03-10 19:26:30 +01:00
imperosol
7bb3d064ee add dynamic-formset-index.ts 2026-03-10 19:26:30 +01:00
thomas girod
296feb6e32 Merge pull request #1305 from ae-utbm/user-all-groups
User all groups
2026-03-10 19:08:24 +01:00
imperosol
30663d87a4 directly work on group ids 2026-03-09 19:36:15 +01:00
thomas girod
b5ff9b4c13 Merge pull request #1314 from ae-utbm/user-clubs
feat: API route to get user memberships
2026-03-09 19:06:30 +01:00
imperosol
e2f6671ad0 apply review comments 2026-03-09 18:59:41 +01:00
imperosol
9a67926a49 feat: API route to get user memberships 2026-03-09 18:11:23 +01:00
imperosol
09a98db786 refactor election views permission check 2026-03-09 16:04:19 +01:00
imperosol
84ed180c1e refactor sas moderation view permission 2026-03-09 16:04:19 +01:00
imperosol
52759764a1 feat: User.all_groups 2026-03-09 16:04:19 +01:00
Titouan
be1563f46f Merge pull request #1313 from ae-utbm/price_fix
modify price on discount
2026-03-08 15:37:26 +01:00
TitouanDor
5d3d44ec67 modify price on discount 2026-03-08 15:09:46 +01:00
69 changed files with 1904 additions and 2381 deletions

View File

@@ -17,6 +17,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
api/api.py Normal file
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
api/forms.py Normal file
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")}

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"
),
),
]

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
api/schemas.py Normal file
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

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
api/tests/test_admin.py Normal file
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]

View File

@@ -0,0 +1,18 @@
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()

59
api/tests/test_client.py Normal file
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

View File

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

119
api/views.py Normal file
View File

@@ -0,0 +1,119 @@
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,9 +6,15 @@ from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from api.auth import ApiKeyAuth
from api.permissions import CanAccessLookup, HasPerm
from api.permissions import CanAccessLookup, CanView, HasPerm
from club.models import Club, Membership
from club.schemas import ClubSchema, ClubSearchFilterSchema, SimpleClubSchema
from club.schemas import (
ClubSchema,
ClubSearchFilterSchema,
SimpleClubSchema,
UserMembershipSchema,
)
from core.models import User
@api_controller("/club")
@@ -38,3 +44,22 @@ class ClubController(ControllerBase):
return self.get_object_or_exception(
Club.objects.prefetch_related(prefetch), id=club_id
)
@api_controller("/user/{int:user_id}/club")
class UserClubController(ControllerBase):
@route.get(
"",
response=list[UserMembershipSchema],
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[CanView],
url_name="fetch_user_clubs",
)
def fetch_user_clubs(self, user_id: int):
"""Get all the active memberships of the given user."""
user = self.get_object_or_exception(User, id=user_id)
return (
Membership.objects.ongoing()
.filter(user=user)
.select_related("club", "user")
)

View File

@@ -40,6 +40,8 @@ class ClubProfileSchema(ModelSchema):
class ClubMemberSchema(ModelSchema):
"""A schema to represent all memberships in a club."""
class Meta:
model = Membership
fields = ["start_date", "end_date", "role", "description"]
@@ -53,3 +55,13 @@ class ClubSchema(ModelSchema):
fields = ["id", "name", "logo", "is_active", "short_description", "address"]
members: list[ClubMemberSchema]
class UserMembershipSchema(ModelSchema):
"""A schema to represent the active club memberships of a user."""
class Meta:
model = Membership
fields = ["id", "start_date", "role", "description"]
club: SimpleClubSchema

View File

@@ -0,0 +1,50 @@
from datetime import timedelta
from django.test import TestCase
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 club.schemas import UserMembershipSchema
from core.baker_recipes import subscriber_user
from core.models import Page
class TestFetchClub(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = subscriber_user.make()
pages = baker.make(Page, _quantity=3, _bulk_create=True)
clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True)
recipe = Recipe(
Membership, user=cls.user, start_date=localdate() - timedelta(days=2)
)
cls.members = Membership.objects.bulk_create(
[
recipe.prepare(club=clubs[0]),
recipe.prepare(club=clubs[1], end_date=localdate() - timedelta(days=1)),
recipe.prepare(club=clubs[1]),
]
)
def test_fetch_memberships(self):
self.client.force_login(subscriber_user.make())
res = self.client.get(
reverse("api:fetch_user_clubs", kwargs={"user_id": self.user.id})
)
assert res.status_code == 200
assert [UserMembershipSchema.model_validate(m) for m in res.json()] == [
UserMembershipSchema.from_orm(m) for m in (self.members[0], self.members[2])
]
def test_fetch_club_nb_queries(self):
self.client.force_login(subscriber_user.make())
with self.assertNumQueries(6):
# - 5 queries for authentication
# - 1 query for the actual data
res = self.client.get(
reverse("api:fetch_user_clubs", kwargs={"user_id": self.user.id})
)
assert res.status_code == 200

View File

@@ -98,9 +98,9 @@ class PageAdmin(admin.ModelAdmin):
@admin.register(SithFile)
class SithFileAdmin(admin.ModelAdmin):
list_display = ("name", "owner", "size", "date")
list_display = ("name", "owner", "size", "date", "is_in_sas")
autocomplete_fields = ("parent", "owner", "moderator")
search_fields = ("name",)
search_fields = ("name", "parent__name")
@admin.register(OperationLog)

View File

@@ -110,7 +110,7 @@ class SithFileController(ControllerBase):
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_files(self, search: Annotated[str, MinLen(1)]):
return SithFile.objects.filter(name__icontains=search)
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
@api_controller("/group")

View File

@@ -307,6 +307,7 @@ class PermissionOrClubBoardRequiredMixin(PermissionRequiredMixin):
return False
if super().has_permission():
return True
return self.club is not None and any(
g.id == self.club.board_group_id for g in self.request.user.cached_groups
return (
self.club is not None
and self.club.board_group_id in self.request.user.all_groups
)

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)"

View File

@@ -28,6 +28,7 @@ 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
@@ -104,12 +105,21 @@ 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)
main_club = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
)
@@ -693,21 +703,33 @@ class Command(BaseCommand):
# SAS
for f in self.SAS_FIXTURE_PATH.glob("*"):
if f.is_dir():
album = Album.objects.create(name=f.name, is_moderated=True)
album = Album(
parent=sas,
name=f.name,
owner=root,
is_folder=True,
is_in_sas=True,
is_moderated=True,
)
album.clean()
album.save()
for p in f.iterdir():
file = resize_image(Image.open(p), 1000, "WEBP")
pict = Picture(
parent=album,
name=p.name,
original=file,
file=file,
owner=root,
is_folder=False,
is_in_sas=True,
is_moderated=True,
mime_type="image/webp",
size=file.size,
)
pict.original.name = pict.name
pict.generate_thumbnails()
pict.file.name = p.name
pict.full_clean()
pict.generate_thumbnails()
pict.save()
album.generate_thumbnail()
img_skia = Picture.objects.get(name="skia.jpg")
img_sli = Picture.objects.get(name="sli.jpg")

View File

@@ -1,3 +1,4 @@
import math
import random
from datetime import date, timedelta
from datetime import timezone as tz
@@ -34,12 +35,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...")
@@ -79,7 +85,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)
@@ -88,7 +94,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")
@@ -107,7 +113,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
@@ -410,8 +416,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))

View File

@@ -1,27 +0,0 @@
# Generated by Django 4.2.17 on 2025-01-26 15:01
from typing import TYPE_CHECKING
from django.db import migrations
from django.db.migrations.state import StateApps
if TYPE_CHECKING:
import core.models
def remove_sas_sithfiles(apps: StateApps, schema_editor):
SithFile: type[core.models.SithFile] = apps.get_model("core", "SithFile")
SithFile.objects.filter(is_in_sas=True).delete()
class Migration(migrations.Migration):
dependencies = [
("core", "0048_alter_user_options"),
("sas", "0007_alter_peoplepicturerelation_picture_and_more"),
]
operations = [
migrations.RunPython(
remove_sas_sithfiles, reverse_code=migrations.RunPython.noop, elidable=True
)
]

View File

@@ -1,9 +0,0 @@
# Generated by Django 4.2.17 on 2025-02-14 11:58
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("core", "0049_remove_sithfiles")]
operations = [migrations.RemoveField(model_name="sithfile", name="is_in_sas")]

View File

@@ -356,23 +356,27 @@ class User(AbstractUser):
)
if group_id is None:
return False
if group_id == settings.SITH_GROUP_SUBSCRIBERS_ID:
return self.is_subscribed
if group_id == settings.SITH_GROUP_ROOT_ID:
return self.is_root
return any(g.id == group_id for g in self.cached_groups)
return group_id in self.all_groups
@cached_property
def cached_groups(self) -> list[Group]:
def all_groups(self) -> dict[int, Group]:
"""Get the list of groups this user is in."""
return list(self.groups.all())
additional_groups = []
if self.is_subscribed:
additional_groups.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
if self.is_superuser:
additional_groups.append(settings.SITH_GROUP_ROOT_ID)
qs = self.groups.all()
if additional_groups:
# This is somewhat counter-intuitive, but this query runs way faster with
# a UNION rather than a OR (in average, 0.25ms vs 14ms).
# For the why, cf. https://dba.stackexchange.com/questions/293836/why-is-an-or-statement-slower-than-union
qs = qs.union(Group.objects.filter(id__in=additional_groups))
return {g.id: g for g in qs}
@cached_property
def is_root(self) -> bool:
if self.is_superuser:
return True
root_id = settings.SITH_GROUP_ROOT_ID
return any(g.id == root_id for g in self.cached_groups)
return self.is_superuser or settings.SITH_GROUP_ROOT_ID in self.all_groups
@cached_property
def is_board_member(self) -> bool:
@@ -833,6 +837,9 @@ class SithFile(models.Model):
on_delete=models.CASCADE,
)
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
is_in_sas = models.BooleanField(
_("is in the SAS"), default=False, db_index=True
) # Allows to query this flag, updated at each call to save()
class Meta:
verbose_name = _("file")
@@ -841,10 +848,22 @@ class SithFile(models.Model):
return self.get_parent_path() + "/" + self.name
def save(self, *args, **kwargs):
sas = SithFile.objects.filter(id=settings.SITH_SAS_ROOT_DIR_ID).first()
self.is_in_sas = sas in self.get_parent_list() or self == sas
adding = self._state.adding
super().save(*args, **kwargs)
if adding:
self.copy_rights()
if self.is_in_sas:
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
):
Notification(
user=user,
url=reverse("sas:moderation"),
type="SAS_MODERATION",
param="1",
).save()
def is_owned_by(self, user: User) -> bool:
if user.is_anonymous:
@@ -857,6 +876,8 @@ class SithFile(models.Model):
return user.is_board_member
if user.is_com_admin:
return True
if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return True
return user.id == self.owner_id
def can_be_viewed_by(self, user: User) -> bool:
@@ -883,6 +904,8 @@ class SithFile(models.Model):
super().clean()
if "/" in self.name:
raise ValidationError(_("Character '/' not authorized in name"))
if self == self.parent:
raise ValidationError(_("Loop in folder tree"), code="loop")
if self == self.parent or (
self.parent is not None and self in self.get_parent_list()
):
@@ -963,6 +986,18 @@ class SithFile(models.Model):
def is_file(self):
return not self.is_folder
@cached_property
def as_picture(self):
from sas.models import Picture
return Picture.objects.filter(id=self.id).first()
@cached_property
def as_album(self):
from sas.models import Album
return Album.objects.filter(id=self.id).first()
def get_parent_list(self):
parents = []
current = self.parent
@@ -1068,10 +1103,7 @@ class PageQuerySet(models.QuerySet):
return self.filter(view_groups=settings.SITH_GROUP_PUBLIC_ID)
if user.has_perm("core.view_page"):
return self.all()
groups_ids = [g.id for g in user.cached_groups]
if user.is_subscribed:
groups_ids.append(settings.SITH_GROUP_SUBSCRIBERS_ID)
return self.filter(view_groups__in=groups_ids)
return self.filter(view_groups__in=user.all_groups)
# This function prevents generating migration upon settings change
@@ -1345,7 +1377,7 @@ class PageRev(models.Model):
return self.page.can_be_edited_by(user)
def is_owned_by(self, user: User) -> bool:
return any(g.id == self.page.owner_group_id for g in user.cached_groups)
return self.page.owner_group_id in user.all_groups
def similarity_ratio(self, text: str) -> float:
"""Similarity ratio between this revision's content and the given text.

View File

@@ -0,0 +1,77 @@
interface Config {
/**
* The prefix of the formset, in case it has been changed.
* See https://docs.djangoproject.com/fr/stable/topics/forms/formsets/#customizing-a-formset-s-prefix
*/
prefix?: string;
}
// biome-ignore lint/style/useNamingConvention: It's the DOM API naming
type HTMLFormInputElement = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
document.addEventListener("alpine:init", () => {
/**
* Alpine data element to allow the dynamic addition of forms to a formset.
*
* To use this, you need :
* - an HTML element containing the existing forms, noted by `x-ref="formContainer"`
* - a template containing the empty form
* (that you can obtain jinja-side with `{{ formset.empty_form }}`),
* noted by `x-ref="formTemplate"`
* - a button with `@click="addForm"`
* - you may also have one or more buttons with `@click="removeForm(element)"`,
* where `element` is the HTML element containing the form.
*
* For an example of how this is used, you can have a look to
* `counter/templates/counter/product_form.jinja`
*/
Alpine.data("dynamicFormSet", (config?: Config) => ({
init() {
this.formContainer = this.$refs.formContainer as HTMLElement;
this.nbForms = this.formContainer.children.length as number;
this.template = this.$refs.formTemplate as HTMLTemplateElement;
const prefix = config?.prefix ?? "form";
this.$root
.querySelector(`#id_${prefix}-TOTAL_FORMS`)
.setAttribute(":value", "nbForms");
},
addForm() {
this.formContainer.appendChild(document.importNode(this.template.content, true));
const newForm = this.formContainer.lastElementChild;
const inputs: NodeListOf<HTMLFormInputElement> = newForm.querySelectorAll(
"input, select, textarea",
);
for (const el of inputs) {
el.name = el.name.replace("__prefix__", this.nbForms.toString());
el.id = el.id.replace("__prefix__", this.nbForms.toString());
}
const labels: NodeListOf<HTMLLabelElement> = newForm.querySelectorAll("label");
for (const el of labels) {
el.htmlFor = el.htmlFor.replace("__prefix__", this.nbForms.toString());
}
inputs[0].focus();
this.nbForms += 1;
},
removeForm(container: HTMLDivElement) {
container.remove();
this.nbForms -= 1;
// adjust the id of remaining forms
for (let i = 0; i < this.nbForms; i++) {
const form: HTMLDivElement = this.formContainer.children[i];
const inputs: NodeListOf<HTMLFormInputElement> = form.querySelectorAll(
"input, select, textarea",
);
for (const el of inputs) {
el.name = el.name.replace(/\d+/, i.toString());
el.id = el.id.replace(/\d+/, i.toString());
}
const labels: NodeListOf<HTMLLabelElement> = form.querySelectorAll("label");
for (const el of labels) {
el.htmlFor = el.htmlFor.replace(/\d+/, i.toString());
}
}
},
}));
});

View File

@@ -141,7 +141,6 @@ 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

@@ -35,8 +35,8 @@
<noscript><link rel="stylesheet" href="{{ static('bundled/fontawesome-index.css') }}"></noscript>
<script src="{{ url('javascript-catalog') }}"></script>
<script type="module" src={{ static("bundled/core/navbar-index.ts") }}></script>
<script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script>
<script type="module" src="{{ static("bundled/core/navbar-index.ts") }}"></script>
<script type="module" src="{{ static("bundled/core/components/include-index.ts") }}"></script>
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
<script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>

View File

@@ -1,14 +1,11 @@
<div id="quick-notifications"
x-data="{
messages: [
{% if messages %}
{% for message in messages %}
{
tag: '{{ message.tags }}',
text: '{{ message }}',
},
{% endfor %}
{% endif %}
{%- if messages -%}
{%- for message in messages -%}
{ tag: '{{ message.tags }}', text: '{{ message }}' },
{%- endfor -%}
{%- endif -%}
]
}"
@quick-notification-add="(e) => messages.push(e?.detail)"

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")

View File

@@ -418,16 +418,16 @@ class TestUserIsInGroup(TestCase):
group_in = baker.make(Group)
self.public_user.groups.add(group_in)
# clear the cached property `User.cached_groups`
self.public_user.__dict__.pop("cached_groups", None)
# clear the cached property `User.all_groups`
self.public_user.__dict__.pop("all_groups", None)
# Test when the user is in the group
with self.assertNumQueries(1):
with self.assertNumQueries(2):
self.public_user.is_in_group(pk=group_in.id)
with self.assertNumQueries(0):
self.public_user.is_in_group(pk=group_in.id)
group_not_in = baker.make(Group)
self.public_user.__dict__.pop("cached_groups", None)
self.public_user.__dict__.pop("all_groups", None)
# Test when the user is not in the group
with self.assertNumQueries(1):
self.public_user.is_in_group(pk=group_not_in.id)

View File

@@ -5,7 +5,6 @@ from typing import Callable
from uuid import uuid4
import pytest
from django.conf import settings
from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
from django.test import Client, TestCase
@@ -18,8 +17,8 @@ from pytest_django.asserts import assertNumQueries
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
from core.models import Group, QuickUploadImage, SithFile, User
from core.utils import RED_PIXEL_PNG
from sas.baker_recipes import picture_recipe
from sas.models import Picture
from sith import settings
@pytest.mark.django_db
@@ -31,19 +30,24 @@ class TestImageAccess:
lambda: baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
),
lambda: baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
),
],
)
def test_sas_image_access(self, user_factory: Callable[[], User]):
"""Test that only authorized users can access the sas image."""
user = user_factory()
picture = picture_recipe.make()
assert user.can_edit(picture)
picture: SithFile = baker.make(
Picture, parent=SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID)
)
assert picture.is_owned_by(user)
def test_sas_image_access_owner(self):
"""Test that the owner of the image can access it."""
user = baker.make(User)
picture = picture_recipe.make(owner=user)
assert user.can_edit(picture)
picture: Picture = baker.make(Picture, owner=user)
assert picture.is_owned_by(user)
@pytest.mark.parametrize(
"user_factory",
@@ -59,41 +63,7 @@ class TestImageAccess:
user = user_factory()
owner = baker.make(User)
picture: Picture = baker.make(Picture, owner=owner)
assert not user.can_edit(picture)
@pytest.mark.django_db
class TestUserPicture:
def test_anonymous_user_unauthorized(self, client):
"""An anonymous user shouldn't have access to an user's photo page."""
response = client.get(
reverse(
"sas:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == 403
@pytest.mark.parametrize(
("username", "status"),
[
("guy", 403),
("root", 200),
("skia", 200),
("sli", 200),
],
)
def test_page_is_working(self, client, username, status):
"""Only user that subscribed (or admins) should be able to see the page."""
# Test for simple user
client.force_login(User.objects.get(username=username))
response = client.get(
reverse(
"sas:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == status
assert not picture.is_owned_by(user)
# TODO: many tests on the pages:

View File

@@ -27,7 +27,6 @@ from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Permanency, Refilling, Selling
from counter.utils import is_logged_in_counter
from eboutic.models import Invoice, InvoiceItem
from sas.models import Picture
class TestSearchUsers(TestCase):
@@ -35,7 +34,6 @@ class TestSearchUsers(TestCase):
def setUpTestData(cls):
# News.author has on_delete=PROTECT, so news must be deleted beforehand
News.objects.all().delete()
Picture.objects.all().delete() # same for pictures
User.objects.all().delete()
user_recipe = Recipe(
User,

View File

@@ -12,27 +12,32 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from dataclasses import dataclass
from __future__ import annotations
import hmac
from datetime import date, timedelta
# Image utils
from io import BytesIO
from typing import Any, Final, Unpack
from typing import TYPE_CHECKING
from urllib.parse import urlencode
import PIL
from django.conf import settings
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile
from django.db import models
from django.forms import BaseForm
from django.http import Http404, HttpRequest
from django.shortcuts import get_list_or_404
from django.template.loader import render_to_string
from django.utils.safestring import SafeString
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"
@@ -47,21 +52,6 @@ to generate a dummy image that is considered valid nonetheless
"""
@dataclass
class FormFragmentTemplateData[T: BaseForm]:
"""Dataclass used to pre-render form fragments"""
form: T
template: str
context: dict[str, Any]
def render(self, request: HttpRequest) -> SafeString:
# Request is needed for csrf_tokens
return render_to_string(
self.template, context={"form": self.form, **self.context}, request=request
)
def get_start_of_semester(today: date | None = None) -> date:
"""Return the date of the start of the semester of the given date.
If no date is given, return the start date of the current semester.
@@ -227,54 +217,28 @@ def get_client_ip(request: HttpRequest) -> str | None:
return None
Filterable = type[models.Model] | models.QuerySet | models.Manager
ListFilter = dict[str, list | tuple | set]
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.
def get_list_exact_or_404(klass: Filterable, **kwargs: Unpack[ListFilter]) -> list:
"""Use filter() to return a list of objects from a list of unique keys (like ids)
or raises Http404 if the list has not the same length as the given one.
Work like `get_object_or_404()` but for lists of objects, with some caveats :
- The filter must be a list, a tuple or a set.
- There can't be more than exactly one filter.
- There must be no duplicate in the filter.
- The filter should consist in unique keys (like ids), or it could fail randomly.
klass may be a Model, Manager, or QuerySet object. All other passed
arguments and keyword arguments are used in the filter() query.
Raises:
Http404: If the list is empty or doesn't have as many elements as the keys list.
ValueError: If the first argument is not a Model, Manager, or QuerySet object.
ValueError: If more than one filter is passed.
TypeError: If the given filter is not a list, a tuple or a set.
Args:
key: the HMAC key used for the signature
data: the data to sign
digest: a PEP247 hashing algorithm (by default, sha512)
Examples:
Get all the products with ids 1, 2, 3: ::
products = get_list_exact_or_404(Product, id__in=[1, 2, 3])
Don't work with duplicate ids: ::
products = get_list_exact_or_404(Product, id__in=[1, 2, 3, 3])
# Raises Http404: "The list of keys must contain no duplicates."
```python
data = {
"foo": 5,
"bar": "somevalue",
}
hmac_key = secrets.token_hex(64)
signature = hmac_hexdigest(hmac_key, data, "sha256")
```
"""
if len(kwargs) > 1:
raise ValueError("get_list_exact_or_404() only accepts one filter.")
key, list_filter = next(iter(kwargs.items()))
if not isinstance(list_filter, (list, tuple, set)):
raise TypeError(
f"The given filter must be a list, a tuple or a set, not {type(list_filter)}"
)
if len(list_filter) != len(set(list_filter)):
raise ValueError("The list of keys must contain no duplicates.")
kwargs = {key: list_filter}
obj_list = get_list_or_404(klass, **kwargs)
if len(obj_list) != len(list_filter):
raise Http404(
"The given list of keys doesn't match the number of objects found."
f"Expected {len(list_filter)} items, got {len(obj_list)}."
)
return obj_list
if isinstance(key, str):
key = key.encode()
return hmac.digest(key, urlencode(data).encode(), digest).hex()

View File

@@ -374,7 +374,7 @@ class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
class FileModerationView(AllowFragment, ListView):
model = SithFile
template_name = "core/file_moderation.jinja"
queryset = SithFile.objects.filter(is_moderated=False)
queryset = SithFile.objects.filter(is_moderated=False, is_in_sas=False)
ordering = "id"
paginate_by = 100

View File

@@ -291,7 +291,8 @@ ScheduledProductActionFormSet = forms.modelformset_factory(
absolute_max=None,
can_delete=True,
can_delete_extra=False,
extra=2,
extra=0,
min_num=1,
)

View File

@@ -1,5 +1,44 @@
{% extends "core/base.jinja" %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/core/dynamic-formset-index.ts") }}"></script>
{% endblock %}
{% macro action_form(form) %}
<fieldset x-data="{action: '{{ form.task.initial }}'}">
{{ form.non_field_errors() }}
<div class="row gap-2x margin-bottom">
<div>
{{ form.task.errors }}
{{ form.task.label_tag() }}
{{ form.task|add_attr("x-model=action") }}
</div>
<div>{{ form.trigger_at.as_field_group() }}</div>
</div>
<div x-show="action==='counter.tasks.change_counters'" class="margin-bottom">
{{ form.counters.as_field_group() }}
</div>
{%- if form.DELETE -%}
<div class="row gap">
{{ form.DELETE.as_field_group() }}
</div>
{%- else -%}
<button
class="btn btn-grey"
@click.prevent="removeForm($event.target.closest('fieldset'))"
>
<i class="fa fa-minus"></i>{% trans %}Remove this action{% endtrans %}
</button>
{%- endif -%}
{%- for field in form.hidden_fields() -%}
{{ field }}
{%- endfor -%}
<hr />
</fieldset>
{% endmacro %}
{% block content %}
{% if object %}
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
@@ -25,34 +64,20 @@
</em>
</p>
{{ form.action_formset.management_form }}
{%- for action_form in form.action_formset.forms -%}
<fieldset x-data="{action: '{{ action_form.task.initial }}'}">
{{ action_form.non_field_errors() }}
<div class="row gap-2x margin-bottom">
<div>
{{ action_form.task.errors }}
{{ action_form.task.label_tag() }}
{{ action_form.task|add_attr("x-model=action") }}
</div>
<div>{{ action_form.trigger_at.as_field_group() }}</div>
</div>
<div x-show="action==='counter.tasks.change_counters'" class="margin-bottom">
{{ action_form.counters.as_field_group() }}
</div>
{%- if action_form.DELETE -%}
<div class="row gap">
{{ action_form.DELETE.as_field_group() }}
</div>
{%- endif -%}
{%- for field in action_form.hidden_fields() -%}
{{ field }}
<div x-data="dynamicFormSet" class="margin-bottom">
{{ form.action_formset.management_form }}
<div x-ref="formContainer">
{%- for f in form.action_formset.forms -%}
{{ action_form(f) }}
{%- endfor -%}
</fieldset>
{%- if not loop.last -%}
<hr class="margin-bottom">
{%- endif -%}
{%- endfor -%}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</div>
<template x-ref="formTemplate">
{{ action_form(form.action_formset.empty_form) }}
</template>
<button @click.prevent="addForm()" class="btn btn-grey">
<i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %}
</button>
</div>
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock %}

View File

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

View File

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

View File

@@ -0,0 +1,353 @@
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 :
=== "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 :
@@ -158,7 +158,7 @@ Voici quelques exemples :
asyncio.run(main())
```
=== "Javascript (axios)"
=== ":simple-javascript: Javascript (axios)"
Dépendances :
@@ -178,7 +178,7 @@ Voici quelques exemples :
console.log(await instance.get("club/1").json());
```
=== "Rust (reqwest)"
=== ":simple-rust: Rust (reqwest)"
Dépendances :

View File

@@ -263,35 +263,3 @@ avec un unique champ permettant de sélectionner des groupes.
Par défaut, seuls les utilisateurs avec la permission
`auth.change_permission` auront accès à ce formulaire
(donc, normalement, uniquement les utilisateurs Root).
```mermaid
sequenceDiagram
participant A as Utilisateur
participant B as ReverseProxy
participant C as MarkdownImage
participant D as Model
A->>B: GET /page/foo
B->>C: GET /page/foo
C-->>B: La page, avec les urls
B-->>A: La page, avec les urls
alt image publique
A->>B: GET markdown/public/2025/img.webp
B-->>A: img.webp
end
alt image privée
A->>B: GET markdown_image/{id}
B->>C: GET markdown_image/{id}
C->>D: user.can_view(image)
alt l'utilisateur a le droit de voir l'image
D-->>C: True
C-->>B: 200 (avec le X-Accel-Redirect)
B-->>A: img.webp
end
alt l'utilisateur n'a pas le droit de l'image
D-->>C: False
C-->>B: 403
B-->>A: 403
end
end
```

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)

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

View File

@@ -1,7 +1,6 @@
from typing import TYPE_CHECKING
from cryptography.utils import cached_property
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import (
LoginRequiredMixin,
@@ -115,16 +114,9 @@ class VoteFormView(LoginRequiredMixin, UserPassesTestMixin, FormView):
def test_func(self):
if not self.election.can_vote(self.request.user):
return False
groups = set(self.election.vote_groups.values_list("id", flat=True))
if (
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
and self.request.user.is_subscribed
):
# the subscriber group isn't truly attached to users,
# so it must be dealt with separately
return True
return self.request.user.groups.filter(id__in=groups).exists()
return self.election.vote_groups.filter(
id__in=self.request.user.all_groups
).exists()
def vote(self, election_data):
with transaction.atomic():
@@ -238,15 +230,9 @@ class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
return False
if self.request.user.has_perm("election.add_role"):
return True
groups = set(self.election.edit_groups.values_list("id", flat=True))
if (
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
and self.request.user.is_subscribed
):
# the subscriber group isn't truly attached to users,
# so it must be dealt with separately
return True
return self.request.user.groups.filter(id__in=groups).exists()
return self.election.edit_groups.filter(
id__in=self.request.user.all_groups
).exists()
def get_initial(self):
return {"election": self.election}
@@ -279,14 +265,7 @@ class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView
.union(self.election.edit_groups.values("id"))
.values_list("id", flat=True)
)
if (
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
and self.request.user.is_subscribed
):
# the subscriber group isn't truly attached to users,
# so it must be dealt with separately
return True
return self.request.user.groups.filter(id__in=groups).exists()
return not groups.isdisjoint(self.request.user.all_groups.keys())
def get_initial(self):
return {"election": self.election}

View File

@@ -25,12 +25,13 @@ import warnings
from datetime import timedelta
from typing import Final, Optional
from django.conf import settings
from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand
from django.utils import timezone
from club.models import Club, Membership
from core.models import Group, Page, User
from core.models import Group, Page, SithFile, User
from core.utils import RED_PIXEL_PNG
from sas.models import Album, PeoplePictureRelation, Picture
from subscription.models import Subscription
@@ -90,8 +91,13 @@ class Command(BaseCommand):
self.NB_CLUBS = options["club_count"]
root = User.objects.filter(username="root").first()
sas = SithFile.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)
self.galaxy_album = Album.objects.create(
name="galaxy-register-file", owner=root, is_moderated=True
name="galaxy-register-file",
owner=root,
is_moderated=True,
is_in_sas=True,
parent=sas,
)
self.make_clubs()
@@ -279,10 +285,14 @@ class Command(BaseCommand):
owner=u,
name=f"galaxy-picture {u} {i // self.NB_USERS}",
is_moderated=True,
is_folder=False,
parent=self.galaxy_album,
original=ContentFile(RED_PIXEL_PNG),
is_in_sas=True,
file=ContentFile(RED_PIXEL_PNG),
compressed=ContentFile(RED_PIXEL_PNG),
thumbnail=ContentFile(RED_PIXEL_PNG),
mime_type="image/png",
size=len(RED_PIXEL_PNG),
)
)
self.picts[i].file.name = self.picts[i].name

View File

@@ -16,767 +16,6 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: accounting/models.py club/models.py com/models.py counter/models.py
#: forum/models.py launderette/models.py sas/models.py
msgid "name"
msgstr "nom"
#: accounting/models.py
msgid "street"
msgstr "rue"
#: accounting/models.py
msgid "city"
msgstr "ville"
#: accounting/models.py
msgid "postcode"
msgstr "code postal"
#: accounting/models.py
msgid "country"
msgstr "pays"
#: accounting/models.py core/models.py
msgid "phone"
msgstr "téléphone"
#: accounting/models.py
msgid "email"
msgstr "email"
#: accounting/models.py
msgid "website"
msgstr "site internet"
#: accounting/models.py
msgid "company"
msgstr "entreprise"
#: accounting/models.py
msgid "iban"
msgstr "IBAN"
#: accounting/models.py
msgid "account number"
msgstr "numéro de compte"
#: accounting/models.py club/models.py com/models.py counter/models.py
#: trombi/models.py
msgid "club"
msgstr "club"
#: accounting/models.py
msgid "Bank account"
msgstr "Compte en banque"
#: accounting/models.py
msgid "bank account"
msgstr "compte en banque"
#: accounting/models.py
msgid "Club account"
msgstr "Compte club"
#: accounting/models.py
#, python-format
msgid "%(club_account)s on %(bank_account)s"
msgstr "%(club_account)s sur %(bank_account)s"
#: accounting/models.py club/models.py counter/models.py election/models.py
#: launderette/models.py
msgid "start date"
msgstr "date de début"
#: accounting/models.py club/models.py counter/models.py election/models.py
msgid "end date"
msgstr "date de fin"
#: accounting/models.py
msgid "is closed"
msgstr "est fermé"
#: accounting/models.py
msgid "club account"
msgstr "compte club"
#: accounting/models.py counter/models.py
msgid "amount"
msgstr "montant"
#: accounting/models.py
msgid "effective_amount"
msgstr "montant effectif"
#: accounting/models.py
msgid "General journal"
msgstr "Classeur"
#: accounting/models.py
msgid "number"
msgstr "numéro"
#: accounting/models.py
msgid "journal"
msgstr "classeur"
#: accounting/models.py core/models.py counter/models.py eboutic/models.py
#: forum/models.py
msgid "date"
msgstr "date"
#: accounting/models.py counter/models.py pedagogy/models.py
msgid "comment"
msgstr "commentaire"
#: accounting/models.py counter/models.py subscription/models.py
msgid "payment method"
msgstr "méthode de paiement"
#: accounting/models.py
msgid "cheque number"
msgstr "numéro de chèque"
#: accounting/models.py eboutic/models.py
msgid "invoice"
msgstr "facture"
#: accounting/models.py
msgid "is done"
msgstr "est fait"
#: accounting/models.py
msgid "simple type"
msgstr "type simplifié"
#: accounting/models.py
msgid "accounting type"
msgstr "type comptable"
#: accounting/models.py core/models.py counter/models.py
msgid "label"
msgstr "étiquette"
#: accounting/models.py
msgid "target type"
msgstr "type de cible"
#: accounting/models.py club/models.py club/templates/club/club_members.jinja
#: club/templates/club/club_old_members.jinja club/templates/club/mailing.jinja
#: counter/templates/counter/cash_summary_list.jinja
#: counter/templates/counter/stats.jinja
#: launderette/templates/launderette/launderette_admin.jinja
msgid "User"
msgstr "Utilisateur"
#: accounting/models.py club/models.py club/templates/club/club_detail.jinja
#: com/templates/com/mailing_admin.jinja
#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja
#: core/templates/core/user_clubs.jinja
#: counter/templates/counter/invoices_call.jinja
#: trombi/templates/trombi/edit_profile.jinja
#: trombi/templates/trombi/export.jinja
#: trombi/templates/trombi/user_profile.jinja
msgid "Club"
msgstr "Club"
#: accounting/models.py core/views/user.py
msgid "Account"
msgstr "Compte"
#: accounting/models.py
msgid "Company"
msgstr "Entreprise"
#: accounting/models.py core/models.py sith/settings.py
msgid "Other"
msgstr "Autre"
#: accounting/models.py
msgid "target id"
msgstr "id de la cible"
#: accounting/models.py
msgid "target label"
msgstr "nom de la cible"
#: accounting/models.py
msgid "linked operation"
msgstr "opération liée"
#: accounting/models.py
msgid "The date must be set."
msgstr "La date doit être indiquée."
#: accounting/models.py
#, python-format
msgid ""
"The date can not be before the start date of the journal, which is\n"
"%(start_date)s."
msgstr ""
"La date ne peut pas être avant la date de début du journal, qui est\n"
"%(start_date)s."
#: accounting/models.py
msgid "Target does not exists"
msgstr "La cible n'existe pas."
#: accounting/models.py
msgid "Please add a target label if you set no existing target"
msgstr ""
"Merci d'ajouter un nom de cible si vous ne spécifiez pas de cible existante"
#: accounting/models.py
msgid ""
"You need to provide ether a simplified accounting type or a standard "
"accounting type"
msgstr ""
"Vous devez fournir soit un type comptable simplifié ou un type comptable "
"standard"
#: accounting/models.py counter/models.py pedagogy/models.py
msgid "code"
msgstr "code"
#: accounting/models.py
msgid "An accounting type code contains only numbers"
msgstr "Un code comptable ne contient que des numéros"
#: accounting/models.py
msgid "movement type"
msgstr "type de mouvement"
#: accounting/models.py
#: accounting/templates/accounting/journal_statement_nature.jinja
#: accounting/templates/accounting/journal_statement_person.jinja
#: accounting/views.py
msgid "Credit"
msgstr "Crédit"
#: accounting/models.py
#: accounting/templates/accounting/journal_statement_nature.jinja
#: accounting/templates/accounting/journal_statement_person.jinja
#: accounting/views.py
msgid "Debit"
msgstr "Débit"
#: accounting/models.py
msgid "Neutral"
msgstr "Neutre"
#: accounting/models.py
msgid "simplified accounting types"
msgstr "type simplifié"
#: accounting/models.py
msgid "simplified type"
msgstr "type simplifié"
#: accounting/templates/accounting/accountingtype_list.jinja
msgid "Accounting type list"
msgstr "Liste des types comptable"
#: accounting/templates/accounting/accountingtype_list.jinja
#: accounting/templates/accounting/bank_account_details.jinja
#: accounting/templates/accounting/bank_account_list.jinja
#: accounting/templates/accounting/club_account_details.jinja
#: accounting/templates/accounting/journal_details.jinja
#: accounting/templates/accounting/label_list.jinja
#: accounting/templates/accounting/operation_edit.jinja
#: accounting/templates/accounting/simplifiedaccountingtype_list.jinja
#: core/templates/core/user_tools.jinja
msgid "Accounting"
msgstr "Comptabilité"
#: accounting/templates/accounting/accountingtype_list.jinja
msgid "Accounting types"
msgstr "Type comptable"
#: accounting/templates/accounting/accountingtype_list.jinja
msgid "New accounting type"
msgstr "Nouveau type comptable"
#: accounting/templates/accounting/accountingtype_list.jinja
#: accounting/templates/accounting/simplifiedaccountingtype_list.jinja
msgid "There is no types in this website."
msgstr "Il n'y a pas de types comptable dans ce site web."
#: accounting/templates/accounting/bank_account_details.jinja
#: core/templates/core/user_tools.jinja
msgid "Bank account: "
msgstr "Compte en banque : "
#: accounting/templates/accounting/bank_account_details.jinja
#: accounting/templates/accounting/club_account_details.jinja
#: accounting/templates/accounting/label_list.jinja
#: club/templates/club/club_sellings.jinja club/templates/club/mailing.jinja
#: com/templates/com/macros.jinja com/templates/com/mailing_admin.jinja
#: com/templates/com/news_admin_list.jinja com/templates/com/poster_edit.jinja
#: com/templates/com/screen_edit.jinja com/templates/com/weekmail.jinja
#: core/templates/core/file_detail.jinja
#: core/templates/core/file_moderation.jinja
#: core/templates/core/group_detail.jinja core/templates/core/group_list.jinja
#: core/templates/core/macros.jinja core/templates/core/page_prop.jinja
#: core/templates/core/user_account_detail.jinja
#: core/templates/core/user_clubs.jinja core/templates/core/user_edit.jinja
#: 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
#: launderette/templates/launderette/launderette_admin.jinja
#: launderette/views.py pedagogy/templates/pedagogy/guide.jinja
#: pedagogy/templates/pedagogy/uv_detail.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
msgid "Delete"
msgstr "Supprimer"
#: accounting/templates/accounting/bank_account_details.jinja club/views.py
#: core/views/user.py sas/templates/sas/picture.jinja
msgid "Infos"
msgstr "Infos"
#: accounting/templates/accounting/bank_account_details.jinja
msgid "IBAN: "
msgstr "IBAN : "
#: accounting/templates/accounting/bank_account_details.jinja
msgid "Number: "
msgstr "Numéro : "
#: accounting/templates/accounting/bank_account_details.jinja
msgid "New club account"
msgstr "Nouveau compte club"
#: accounting/templates/accounting/bank_account_details.jinja
#: accounting/templates/accounting/bank_account_list.jinja
#: accounting/templates/accounting/club_account_details.jinja
#: accounting/templates/accounting/journal_details.jinja club/views.py
#: com/templates/com/news_admin_list.jinja com/templates/com/poster_list.jinja
#: com/templates/com/screen_list.jinja com/templates/com/weekmail.jinja
#: core/templates/core/file.jinja core/templates/core/group_list.jinja
#: core/templates/core/page.jinja core/templates/core/user_tools.jinja
#: core/views/user.py counter/templates/counter/cash_summary_list.jinja
#: counter/templates/counter/counter_list.jinja
#: election/templates/election/election_detail.jinja
#: forum/templates/forum/macros.jinja
#: launderette/templates/launderette/launderette_list.jinja
#: pedagogy/templates/pedagogy/guide.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja sas/templates/sas/album.jinja
#: trombi/templates/trombi/detail.jinja
#: trombi/templates/trombi/edit_profile.jinja
msgid "Edit"
msgstr "Éditer"
#: accounting/templates/accounting/bank_account_list.jinja
msgid "Bank account list"
msgstr "Liste des comptes en banque"
#: accounting/templates/accounting/bank_account_list.jinja
msgid "Manage simplified types"
msgstr "Gérer les types simplifiés"
#: accounting/templates/accounting/bank_account_list.jinja
msgid "Manage accounting types"
msgstr "Gérer les types comptable"
#: accounting/templates/accounting/bank_account_list.jinja
msgid "New bank account"
msgstr "Nouveau compte en banque"
#: accounting/templates/accounting/bank_account_list.jinja
msgid "There is no accounts in this website."
msgstr "Il n'y a pas de comptes dans ce site web."
#: accounting/templates/accounting/club_account_details.jinja
msgid "Club account:"
msgstr "Compte club : "
#: accounting/templates/accounting/club_account_details.jinja
#: accounting/templates/accounting/journal_details.jinja
#: accounting/templates/accounting/label_list.jinja
msgid "New label"
msgstr "Nouvelle étiquette"
#: accounting/templates/accounting/club_account_details.jinja
#: accounting/templates/accounting/journal_details.jinja
#: accounting/templates/accounting/label_list.jinja
msgid "Label list"
msgstr "Liste des étiquettes"
#: accounting/templates/accounting/club_account_details.jinja
msgid "New journal"
msgstr "Nouveau classeur"
#: accounting/templates/accounting/club_account_details.jinja
msgid "You can not create new journal while you still have one opened"
msgstr "Vous ne pouvez pas créer de journal tant qu'il y en a un d'ouvert"
#: accounting/templates/accounting/club_account_details.jinja
#: launderette/templates/launderette/launderette_admin.jinja
msgid "Name"
msgstr "Nom"
#: accounting/templates/accounting/club_account_details.jinja
#: com/templates/com/news_admin_list.jinja
msgid "Start"
msgstr "Début"
#: accounting/templates/accounting/club_account_details.jinja
#: com/templates/com/news_admin_list.jinja
msgid "End"
msgstr "Fin"
#: accounting/templates/accounting/club_account_details.jinja
#: accounting/templates/accounting/journal_details.jinja
#: core/templates/core/user_account_detail.jinja
#: counter/templates/counter/last_ops.jinja
#: counter/templates/counter/refilling_list.jinja
msgid "Amount"
msgstr "Montant"
#: accounting/templates/accounting/club_account_details.jinja
msgid "Effective amount"
msgstr "Montant effectif"
#: accounting/templates/accounting/club_account_details.jinja sith/settings.py
msgid "Closed"
msgstr "Fermé"
#: accounting/templates/accounting/club_account_details.jinja
#: accounting/templates/accounting/journal_details.jinja
#: com/templates/com/mailing_admin.jinja
#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja
#: counter/templates/counter/refilling_list.jinja
msgid "Actions"
msgstr "Actions"
#: accounting/templates/accounting/club_account_details.jinja
#: accounting/templates/accounting/journal_details.jinja
msgid "Yes"
msgstr "Oui"
#: accounting/templates/accounting/club_account_details.jinja
#: accounting/templates/accounting/journal_details.jinja
msgid "No"
msgstr "Non"
#: accounting/templates/accounting/club_account_details.jinja
#: com/templates/com/news_admin_list.jinja core/templates/core/file.jinja
#: core/templates/core/page.jinja
msgid "View"
msgstr "Voir"
#: accounting/templates/accounting/co_list.jinja
#: accounting/templates/accounting/journal_details.jinja
#: core/templates/core/user_tools.jinja
msgid "Company list"
msgstr "Liste des entreprises"
#: accounting/templates/accounting/co_list.jinja
msgid "Create new company"
msgstr "Nouvelle entreprise"
#: accounting/templates/accounting/co_list.jinja
msgid "Companies"
msgstr "Entreprises"
#: accounting/templates/accounting/journal_details.jinja
#: accounting/templates/accounting/journal_statement_accounting.jinja
#: accounting/templates/accounting/journal_statement_nature.jinja
#: accounting/templates/accounting/journal_statement_person.jinja
msgid "General journal:"
msgstr "Classeur : "
#: accounting/templates/accounting/journal_details.jinja
#: accounting/templates/accounting/journal_statement_accounting.jinja
#: core/templates/core/user_account.jinja
#: core/templates/core/user_account_detail.jinja
#: counter/templates/counter/counter_click.jinja
msgid "Amount: "
msgstr "Montant : "
#: accounting/templates/accounting/journal_details.jinja
#: accounting/templates/accounting/journal_statement_accounting.jinja
msgid "Effective amount: "
msgstr "Montant effectif: "
#: accounting/templates/accounting/journal_details.jinja
msgid "Journal is closed, you can not create operation"
msgstr "Le classeur est fermé, vous ne pouvez pas créer d'opération"
#: accounting/templates/accounting/journal_details.jinja
msgid "New operation"
msgstr "Nouvelle opération"
#: accounting/templates/accounting/journal_details.jinja
msgid "Nb"
msgstr "No"
#: accounting/templates/accounting/journal_details.jinja
#: club/templates/club/club_sellings.jinja
#: core/templates/core/user_account_detail.jinja
#: counter/templates/counter/cash_summary_list.jinja
#: counter/templates/counter/last_ops.jinja
#: counter/templates/counter/refilling_list.jinja
#: rootplace/templates/rootplace/logs.jinja sas/forms.py
#: trombi/templates/trombi/user_profile.jinja
msgid "Date"
msgstr "Date"
#: accounting/templates/accounting/journal_details.jinja
#: club/templates/club/club_sellings.jinja
#: core/templates/core/user_account_detail.jinja
#: counter/templates/counter/last_ops.jinja
#: rootplace/templates/rootplace/logs.jinja
msgid "Label"
msgstr "Étiquette"
#: accounting/templates/accounting/journal_details.jinja
msgid "Payment mode"
msgstr "Méthode de paiement"
#: accounting/templates/accounting/journal_details.jinja
msgid "Target"
msgstr "Cible"
#: accounting/templates/accounting/journal_details.jinja
msgid "Code"
msgstr "Code"
#: accounting/templates/accounting/journal_details.jinja
msgid "Nature"
msgstr "Nature"
#: accounting/templates/accounting/journal_details.jinja
msgid "Done"
msgstr "Effectuées"
#: accounting/templates/accounting/journal_details.jinja
#: counter/templates/counter/cash_summary_list.jinja counter/views/cash.py
#: pedagogy/templates/pedagogy/moderation.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: trombi/templates/trombi/comment.jinja
#: trombi/templates/trombi/user_tools.jinja
msgid "Comment"
msgstr "Commentaire"
#: accounting/templates/accounting/journal_details.jinja
msgid "File"
msgstr "Fichier"
#: accounting/templates/accounting/journal_details.jinja
msgid "PDF"
msgstr "PDF"
#: accounting/templates/accounting/journal_details.jinja
msgid ""
"Warning: this operation has no linked operation because the targeted club "
"account has no opened journal."
msgstr ""
"Attention: cette opération n'a pas d'opération liée parce qu'il n'y a pas de "
"classeur ouvert dans le compte club cible"
#: accounting/templates/accounting/journal_details.jinja
#, python-format
msgid ""
"Open a journal in <a href=\"%(url)s\">this club account</a>, then save this "
"operation again to make the linked operation."
msgstr ""
"Ouvrez un classeur dans <a href=\"%(url)s\">ce compte club</a>, puis sauver "
"cette opération à nouveau pour créer l'opération liée."
#: accounting/templates/accounting/journal_details.jinja
msgid "Generate"
msgstr "Générer"
#: accounting/templates/accounting/journal_statement_accounting.jinja
msgid "Accounting statement: "
msgstr "Bilan comptable : "
#: accounting/templates/accounting/journal_statement_accounting.jinja
#: rootplace/templates/rootplace/logs.jinja
msgid "Operation type"
msgstr "Type d'opération"
#: accounting/templates/accounting/journal_statement_accounting.jinja
#: accounting/templates/accounting/journal_statement_nature.jinja
#: accounting/templates/accounting/journal_statement_person.jinja
#: counter/templates/counter/invoices_call.jinja
msgid "Sum"
msgstr "Somme"
#: accounting/templates/accounting/journal_statement_nature.jinja
msgid "Nature of operation"
msgstr "Nature de l'opération"
#: accounting/templates/accounting/journal_statement_nature.jinja
#: club/templates/club/club_sellings.jinja
#: counter/templates/counter/counter_main.jinja
msgid "Total: "
msgstr "Total : "
#: accounting/templates/accounting/journal_statement_nature.jinja
msgid "Statement by nature: "
msgstr "Bilan par nature : "
#: accounting/templates/accounting/journal_statement_person.jinja
msgid "Statement by person: "
msgstr "Bilan par personne : "
#: accounting/templates/accounting/journal_statement_person.jinja
msgid "Target of the operation"
msgstr "Cible de l'opération"
#: accounting/templates/accounting/label_list.jinja
msgid "Back to club account"
msgstr "Retour au compte club"
#: accounting/templates/accounting/label_list.jinja
msgid "There is no label in this club account."
msgstr "Il n'y a pas d'étiquette dans ce compte club."
#: accounting/templates/accounting/operation_edit.jinja
msgid "Edit operation"
msgstr "Éditer l'opération"
#: accounting/templates/accounting/operation_edit.jinja
msgid ""
"Warning: if you select <em>Account</em>, the opposite operation will be "
"created in the target account. If you don't want that, select <em>Club</em> "
"instead of <em>Account</em>."
msgstr ""
"Attention : si vous sélectionnez <em>Compte</em>, l'opération inverse sera "
"créée dans le compte cible. Si vous ne le voulez pas, sélectionnez <em>Club</"
"em> à la place de <em>Compte</em>."
#: accounting/templates/accounting/operation_edit.jinja
msgid "Linked operation:"
msgstr "Opération liée : "
#: accounting/templates/accounting/operation_edit.jinja
#: com/templates/com/news_edit.jinja com/templates/com/poster_edit.jinja
#: com/templates/com/screen_edit.jinja com/templates/com/weekmail.jinja
#: core/templates/core/create.jinja core/templates/core/edit.jinja
#: core/templates/core/file_edit.jinja core/templates/core/macros_pages.jinja
#: core/templates/core/page_prop.jinja
#: core/templates/core/user_godfathers.jinja
#: core/templates/core/user_godfathers_tree.jinja
#: core/templates/core/user_preferences.jinja
#: counter/templates/counter/cash_register_summary.jinja
#: forum/templates/forum/reply.jinja
#: subscription/templates/subscription/fragments/creation_form.jinja
#: trombi/templates/trombi/comment.jinja
#: trombi/templates/trombi/edit_profile.jinja
#: trombi/templates/trombi/user_tools.jinja
msgid "Save"
msgstr "Sauver"
#: accounting/templates/accounting/refound_account.jinja accounting/views.py
msgid "Refound account"
msgstr "Remboursement de compte"
#: accounting/templates/accounting/refound_account.jinja
msgid "Refound"
msgstr "Rembourser"
#: accounting/templates/accounting/simplifiedaccountingtype_list.jinja
msgid "Simplified type list"
msgstr "Liste des types simplifiés"
#: accounting/templates/accounting/simplifiedaccountingtype_list.jinja
msgid "Simplified types"
msgstr "Types simplifiés"
#: accounting/templates/accounting/simplifiedaccountingtype_list.jinja
msgid "New simplified type"
msgstr "Nouveau type simplifié"
#: accounting/views.py
msgid "Journal"
msgstr "Classeur"
#: accounting/views.py
msgid "Statement by nature"
msgstr "Bilan par nature"
#: accounting/views.py
msgid "Statement by person"
msgstr "Bilan par personne"
#: accounting/views.py
msgid "Accounting statement"
msgstr "Bilan comptable"
#: accounting/views.py
msgid "Link this operation to the target account"
msgstr "Lier cette opération au compte cible"
#: accounting/views.py
msgid "The target must be set."
msgstr "La cible doit être indiquée."
#: accounting/views.py
msgid "The amount must be set."
msgstr "Le montant doit être indiqué."
#: accounting/views.py
msgid "Operation"
msgstr "Opération"
#: accounting/views.py
msgid "Financial proof: "
msgstr "Justificatif de libellé : "
#: accounting/views.py
#, python-format
msgid "Club: %(club_name)s"
msgstr "Club : %(club_name)s"
#: accounting/views.py
#, python-format
msgid "Label: %(op_label)s"
msgstr "Libellé : %(op_label)s"
#: accounting/views.py
#, python-format
msgid "Date: %(date)s"
msgstr "Date : %(date)s"
#: accounting/views.py
#, python-format
msgid "Amount: %(amount).2f"
msgstr "Montant : %(amount).2f"
#: accounting/views.py
msgid "Debtor"
msgstr "Débiteur"
#: accounting/views.py
msgid "Creditor"
msgstr "Créditeur"
#: accounting/views.py
msgid "Comment:"
msgstr "Commentaire :"
#: accounting/views.py
msgid "Signature:"
msgstr "Signature :"
#: accounting/views.py
msgid "General statement"
msgstr "Bilan général"
#: accounting/views.py
msgid "No label operations"
msgstr "Opérations sans étiquette"
#: accounting/views.py
msgid "Refound this account"
msgstr "Rembourser ce compte"
#: antispam/forms.py
msgid "Email domain is not allowed."
msgstr "Le domaine de l'addresse e-mail n'est pas autorisé."
@@ -796,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 ""
@@ -809,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"
@@ -829,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"
@@ -858,6 +122,63 @@ 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"
@@ -1027,7 +348,7 @@ msgid "Enter a valid address. Only the root of the address is needed."
msgstr ""
"Entrez une adresse valide. Seule la racine de l'adresse est nécessaire."
#: club/models.py com/models.py core/models.py sas/models.py
#: club/models.py com/models.py core/models.py
msgid "is moderated"
msgstr "est modéré"
@@ -2372,11 +1693,11 @@ msgstr "avoir une notification pour chaque click"
msgid "get a notification for every refilling"
msgstr "avoir une notification pour chaque rechargement"
#: core/models.py sas/models.py
#: core/models.py sas/forms.py
msgid "file name"
msgstr "nom du fichier"
#: core/models.py sas/models.py
#: core/models.py
msgid "parent"
msgstr "parent"
@@ -2384,14 +1705,10 @@ msgstr "parent"
msgid "compressed file"
msgstr "version allégée"
#: core/models.py sas/models.py
#: core/models.py
msgid "thumbnail"
msgstr "miniature"
#: core/models.py sas/models.py
msgid "owner"
msgstr "propriétaire"
#: core/models.py
msgid "edit group"
msgstr "groupe d'édition"
@@ -2420,6 +1737,10 @@ msgstr "date"
msgid "asked for removal"
msgstr "retrait demandé"
#: core/models.py
msgid "is in the SAS"
msgstr "est dans le SAS"
#: core/models.py
msgid "Character '/' not authorized in name"
msgstr "Le caractère '/' n'est pas autorisé dans les noms de fichier"
@@ -4051,7 +3372,7 @@ msgstr "élément de relevé de caisse"
msgid "banner"
msgstr "bannière"
#: counter/models.py sas/models.py
#: counter/models.py
msgid "event date"
msgstr "date de l'événement"
@@ -4518,6 +3839,10 @@ msgstr ""
"votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura "
"aucune conséquence autre que le retrait de l'argent de votre compte."
#: counter/templates/counter/product_form.jinja
msgid "Remove this action"
msgstr "Retirer cette action"
#: counter/templates/counter/product_form.jinja
#, python-format
msgid "Edit product %(name)s"
@@ -4545,6 +3870,10 @@ msgstr ""
"Les actions automatiques vous permettent de planifier des modifications du "
"produit à l'avance."
#: counter/templates/counter/product_form.jinja
msgid "Add action"
msgstr "Ajouter une action"
#: counter/templates/counter/product_list.jinja
msgid "Product list"
msgstr "Liste des produits"
@@ -4958,11 +4287,11 @@ msgstr "début des candidatures"
msgid "end candidature"
msgstr "fin des candidatures"
#: election/models.py sas/models.py
#: election/models.py
msgid "edit groups"
msgstr "groupe d'édition"
#: election/models.py sas/models.py
#: election/models.py
msgid "view groups"
msgstr "groupe de vue"
@@ -5652,22 +4981,6 @@ msgstr "Envoyer les images"
msgid "You already requested moderation for this picture."
msgstr "Vous avez déjà déposé une demande de retrait pour cette photo."
#: sas/models.py
msgid "The date on which the photos in this album were taken"
msgstr "La date à laquelle les photos de cet album ont été prises"
#: sas/models.py
msgid "album"
msgstr "album"
#: sas/models.py
msgid "original image"
msgstr "image originale"
#: sas/models.py
msgid "compressed image"
msgstr "version compressée"
#: sas/models.py
msgid "picture"
msgstr "photo"

View File

@@ -69,6 +69,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
- Etransactions: tutorial/etransaction.md
- How-to:
- L'ORM de Django: howto/querysets.md
@@ -91,6 +92,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

View File

@@ -20,9 +20,9 @@ from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationR
@admin.register(Picture)
class PictureAdmin(admin.ModelAdmin):
list_display = ("name", "parent", "is_moderated")
list_display = ("name", "parent", "date", "size", "is_moderated")
search_fields = ("name",)
autocomplete_fields = ("owner", "parent", "moderator")
autocomplete_fields = ("owner", "parent", "edit_groups", "view_groups", "moderator")
@admin.register(PeoplePictureRelation)
@@ -33,9 +33,9 @@ class PeoplePictureRelationAdmin(admin.ModelAdmin):
@admin.register(Album)
class AlbumAdmin(admin.ModelAdmin):
list_display = ("name", "parent")
list_display = ("name", "parent", "date", "owner", "is_moderated")
search_fields = ("name",)
autocomplete_fields = ("parent", "edit_groups", "view_groups")
autocomplete_fields = ("owner", "parent", "edit_groups", "view_groups")
@admin.register(PictureModerationRequest)

View File

@@ -3,8 +3,7 @@ from typing import Any, Literal
from django.conf import settings
from django.core.exceptions import ValidationError
from django.urls import reverse
from ninja import Body, Query, UploadedFile
from ninja.errors import HttpError
from ninja import Body, File, Query
from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import NotFound, PermissionDenied
@@ -17,12 +16,11 @@ from api.permissions import (
CanAccessLookup,
CanEdit,
CanView,
HasPerm,
IsInGroup,
IsRoot,
)
from core.models import Notification, User
from core.utils import get_list_exact_or_404
from core.schemas import UploadedImage
from sas.models import Album, PeoplePictureRelation, Picture
from sas.schemas import (
AlbumAutocompleteSchema,
@@ -30,7 +28,6 @@ from sas.schemas import (
AlbumSchema,
IdentifiedUserSchema,
ModerationRequestSchema,
MoveAlbumSchema,
PictureFilterSchema,
PictureSchema,
)
@@ -72,44 +69,6 @@ class AlbumController(ControllerBase):
Album.objects.viewable_by(self.context.request.user).order_by("-date")
)
@route.patch("/parent")
def change_album_parent(self, payload: list[MoveAlbumSchema]):
"""Change parents of albums
Note:
For this operation to work, the user must be authorized
to edit both the moved albums and their new parent.
"""
user: User = self.context.request.user
albums: list[Album] = get_list_exact_or_404(
Album, pk__in={a.id for a in payload}
)
if not user.has_perm("sas.change_album"):
unauthorized = [a.id for a in albums if not user.can_edit(a)]
if unauthorized:
raise PermissionDenied(
f"You can't move the following albums : {unauthorized}"
)
parents: list[Album] = get_list_exact_or_404(
Album, pk__in={a.new_parent_id for a in payload}
)
if not user.has_perm("sas.change_album"):
unauthorized = [a.id for a in parents if not user.can_edit(a)]
if unauthorized:
raise PermissionDenied(
f"You can't move to the following albums : {unauthorized}"
)
id_to_new_parent = {i.id: i.new_parent_id for i in payload}
for album in albums:
album.parent_id = id_to_new_parent[album.id]
# known caveat : moving an album won't move it's thumbnail.
# E.g. if the album foo/bar is moved to foo/baz,
# the thumbnail will still be foo/bar/thumb.webp
# This has no impact for the end user
# and doing otherwise would be hard for us to implement,
# because we would then have to manage rollbacks on fail.
Album.objects.bulk_update(albums, fields=["parent_id"])
@api_controller("/sas/picture")
class PicturesController(ControllerBase):
@@ -137,7 +96,7 @@ class PicturesController(ControllerBase):
return (
filters.filter(Picture.objects.viewable_by(user))
.distinct()
.order_by("-parent__event_date", "created_at")
.order_by("-parent__date", "date")
.select_related("owner", "parent")
)
@@ -151,25 +110,27 @@ class PicturesController(ControllerBase):
},
url_name="upload_picture",
)
def upload_picture(self, album_id: Body[int], picture: UploadedFile):
def upload_picture(self, album_id: Body[int], picture: File[UploadedImage]):
album = self.get_object_or_exception(Album, pk=album_id)
user = self.context.request.user
self_moderate = user.has_perm("sas.moderate_sasfile")
new = Picture(
parent=album,
name=picture.name,
original=picture,
file=picture,
owner=user,
is_moderated=self_moderate,
is_folder=False,
mime_type=picture.content_type,
)
if self_moderate:
new.moderator = user
new.generate_thumbnails()
try:
new.generate_thumbnails()
new.full_clean()
new.save()
except ValidationError as e:
raise HttpError(status_code=409, message=str(e)) from e
new.save()
return self.create_response({"detail": dict(e)}, status_code=409)
@route.get(
"/{picture_id}/identified",
@@ -254,9 +215,9 @@ class UsersIdentifiedController(ControllerBase):
relation = self.get_object_or_exception(PeoplePictureRelation, pk=relation_id)
user: User = self.context.request.user
if (
relation.user_id != user.id
and not user.is_root
and not user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
relation.user_id != user.id
and not user.is_root
and not user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
):
raise PermissionDenied
relation.delete()

View File

@@ -1,35 +1,18 @@
from django.core.files.uploadedfile import SimpleUploadedFile
from model_bakery import seq
from model_bakery.recipe import Recipe
from core.utils import RED_PIXEL_PNG
from sas.models import Album, Picture
album_recipe = Recipe(
Album,
name=seq("Album "),
thumbnail=SimpleUploadedFile(
name="thumb.webp", content=b"", content_type="image/webp"
),
)
from sas.models import Picture
picture_recipe = Recipe(
Picture,
is_in_sas=True,
is_folder=False,
is_moderated=True,
name=seq("Picture "),
original=SimpleUploadedFile(
# compressed and thumbnail are generated on save (except if bulk creating).
# For this step no to fail, original must be a valid image.
name="img.png",
content=RED_PIXEL_PNG,
content_type="image/png",
),
compressed=SimpleUploadedFile(
name="img.webp", content=b"", content_type="image/webp"
),
thumbnail=SimpleUploadedFile(
name="img.webp", content=b"", content_type="image/webp"
),
)
"""A SAS Picture fixture."""
"""A SAS Picture fixture.
Warnings:
If you don't `bulk_create` this, you need
to explicitly set the parent album, or it won't work
"""

View File

@@ -48,12 +48,13 @@ class PictureEditForm(forms.ModelForm):
class AlbumEditForm(forms.ModelForm):
class Meta:
model = Album
fields = ["name", "date", "thumbnail", "parent", "edit_groups"]
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)

View File

@@ -1,357 +0,0 @@
# Generated by Django 4.2.17 on 2025-01-22 21:53
import collections
import itertools
import logging
from typing import TYPE_CHECKING
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.db.migrations.state import StateApps
import sas.models
if TYPE_CHECKING:
import core.models
# NB : tous les commentaires sont écrits en français,
# parce qu'on est sur des opérations qui sont complexes,
# et qui sont surtout DANGEREUSES.
# Ici, la clarté des explications prime sur toute autre considération.
def copy_albums_and_pictures(apps: StateApps, schema_editor):
SithFile: type[core.models.SithFile] = apps.get_model("core", "SithFile")
Album: type[sas.models.Album] = apps.get_model("sas", "Album")
Picture: type[sas.models.Picture] = apps.get_model("sas", "Picture")
logger = logging.getLogger("django")
# Il y a environ 1800 albums, 257k photos et 488k identifications
# d'utilisateurs dans la db de prod.
# En supposant qu'une insertion prenne 10ms (ce qui est très optimiste),
# migrer tous les enregistrements de la db prendrait plus de 2h.
# C'est trop long.
# Mais d'un autre côté, j'ai pas assez confiance dans les capacités de nos
# machines pour charger presque un million d'objets en mémoire.
# Pour faire un compromis, les albums sont migrés individuellement un à un,
# mais tous les objets liés à ces albums
# (photos, groupes de vue, groupe d'édition, identification d'utilisateurs)
# sont migrés en tas.
#
# Ordre des opérations :
# 1. On migre les albums 1 à 1 (il y en a 1800, donc c'est relativement court)
# 2. On migre les photos par paquet de 2500 (soit ~une centaine d'opérations)
# 3. On migre tous les groupes de vue et tous les groupes d'édition des albums
#
# Au total, la migration devrait demander aux alentours de 2000 insertions,
# ce qui est un compromis acceptable entre une migration
# pas trop longue et une RAM pas trop surchargée.
#
# Pour ce qui est de la répartition des tables, quatre nouvelles tables
# sont créées : sas_album, sas_picture,
# sas_pictureviewgroups et sas_picture_editgroups.
# Tous les albums et toutes les photos qui sont dans core_sithfile
# vont être copiés dans ces tables.
# Comme les albums sont migrés un à un, ils recevront une nouvelle
# clef primaire.
# Pour les photos, en revanche, c'est beaucoup plus sûr de leur donner
# le même id que celui qu'il y avait dans core_sithfile.
#
# Les identifications des photos ne sont pas migrées pour l'instant.
# Ce qu'on va faire, c'est qu'on va changer la contrainte de clef étrangère
# sur la colonne des photos pour pointer vers sas_picture
# au lieu de core_sithfile.
# Cependant, pour que ça marche,
# il faut qu'au moment où ce changement est effectué,
# toutes les clefs primaires référencées existent à la fois dans
# les deux tables, sinon les contraintes d'intégrité ne sont pas respectées.
# La migration de ce fichier va donc s'occuper de créer les nouvelles tables
# et d'y copier les données nécessaires.
# Puis une deuxième migration s'occupera de changer les contraintes.
# Et enfin une troisième migration supprimera les anciennes données.
#
# Pavé César
albums = SithFile.objects.filter(is_in_sas=True, is_folder=True).prefetch_related(
"view_groups", "edit_groups"
)
old_albums = collections.deque(
albums.filter(parent_id=settings.SITH_SAS_ROOT_DIR_ID)
)
# Changement de représentation en DB.
# Dans l'ancien système, un fichier était dans le SAS si
# un fichier spécial (le SAS_ROOT) était parmi ses ancêtres.
# Comme maintenant les fichiers du SAS sont dans des tables à part,
# il ne peut plus y avoir de confusion.
# Les photos ont donc obligatoirement un parent (qui est un album)
# et les albums peuvent avoir un parent null.
# Un album sans parent est considéré comme se trouvant à la racine
# de l'arborescence.
# En quelque sorte, None est le nouveau SITH_SAS_ROOT_DIR_ID
album_id_old_to_new = {settings.SITH_SAS_ROOT_DIR_ID: None}
logger.info(f"migrating {albums.count()} albums")
while len(old_albums) > 0:
# Comme les albums référencent leur parent, les albums doivent être migrés
# par ordre croissant de profondeur dans l'arborescence.
# Chaque album est donc pris par la gauche de la file
# et ses enfants ajoutés sur la droite.
old_album = old_albums.popleft()
old_albums.extend(list(albums.filter(parent=old_album)))
new_album = Album.objects.create(
parent_id=album_id_old_to_new[old_album.parent_id],
event_date=old_album.date.date(),
name=old_album.name,
thumbnail=(old_album.file or None),
is_moderated=old_album.is_moderated,
)
# on garde un dictionnaire qui associe les id des albums dans l'ancienne table
# à leur id dans la nouvelle table, pour pouvoir recréer
# les liens de parenté entre albums
album_id_old_to_new[old_album.id] = new_album.id
pictures = SithFile.objects.filter(is_in_sas=True, is_folder=False)
nb_pictures = pictures.count()
logger.info(f"migrating {nb_pictures} pictures")
for i, pictures_batch in enumerate(itertools.batched(pictures, 2500), start=1):
Picture.objects.bulk_create(
[
Picture(
id=p.id,
name=p.name,
parent_id=album_id_old_to_new[p.parent_id],
thumbnail=p.thumbnail,
compressed=p.compressed,
original=p.file,
owner_id=p.owner_id,
created_at=p.date,
is_moderated=p.is_moderated,
asked_for_removal=p.asked_for_removal,
moderator_id=p.moderator_id,
)
for p in pictures_batch
]
)
logger.info(f"Migrated {min(i * 2500, nb_pictures)} / {nb_pictures} pictures")
logger.info("Migrating album groups")
albums = SithFile.objects.filter(is_in_sas=True, is_folder=True).exclude(
id=settings.SITH_SAS_ROOT_DIR_ID
)
Album.edit_groups.through.objects.bulk_create(
[
Album.view_groups.through(
album=album_id_old_to_new[g.sithfile_id], group_id=g.group_id
)
for g in SithFile.view_groups.through.objects.filter(sithfile__in=albums)
]
)
Album.edit_groups.through.objects.bulk_create(
[
Album.view_groups.through(
album=album_id_old_to_new[g.sithfile_id], group_id=g.group_id
)
for g in SithFile.view_groups.through.objects.filter(sithfile__in=albums)
]
)
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("core", "0044_alter_userban_options"),
("sas", "0005_alter_sasfile_options"),
]
operations = [
# les relations et les demandes de modération étaient liées à SithFile,
# via le model proxy Picture.
# Pour que la migration marche malgré la disparition du modèle Proxy,
# on change la relation pour qu'elle pointe directement vers SithFile
migrations.AlterField(
model_name="peoplepicturerelation",
name="picture",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="people",
to="core.sithfile",
verbose_name="picture",
),
),
migrations.AlterField(
model_name="picturemoderationrequest",
name="picture",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="moderation_requests",
to="core.sithfile",
verbose_name="Picture",
),
),
migrations.DeleteModel(name="Album"),
migrations.DeleteModel(name="Picture"),
migrations.DeleteModel(name="SasFile"),
migrations.CreateModel(
name="Album",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"thumbnail",
models.FileField(
max_length=256,
upload_to=sas.models.get_thumbnail_directory,
verbose_name="thumbnail",
),
),
("name", models.CharField(max_length=100, verbose_name="name")),
(
"event_date",
models.DateField(
default=django.utils.timezone.localdate,
help_text="The date on which the photos in this album were taken",
verbose_name="event date",
),
),
(
"is_moderated",
models.BooleanField(default=False, verbose_name="is moderated"),
),
(
"edit_groups",
models.ManyToManyField(
related_name="editable_albums",
to="core.group",
verbose_name="edit groups",
),
),
(
"parent",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="children",
to="sas.album",
verbose_name="parent",
),
),
(
"view_groups",
models.ManyToManyField(
related_name="viewable_albums",
to="core.group",
verbose_name="view groups",
),
),
],
options={"verbose_name": "album"},
),
migrations.CreateModel(
name="Picture",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"thumbnail",
models.FileField(
unique=True,
upload_to=sas.models.get_thumbnail_directory,
verbose_name="thumbnail",
max_length=256,
),
),
("name", models.CharField(max_length=256, verbose_name="file name")),
(
"original",
models.FileField(
unique=True,
upload_to=sas.models.get_directory,
verbose_name="original image",
max_length=256,
),
),
(
"compressed",
models.FileField(
unique=True,
upload_to=sas.models.get_compressed_directory,
verbose_name="compressed image",
max_length=256,
),
),
("created_at", models.DateTimeField(default=django.utils.timezone.now)),
(
"is_moderated",
models.BooleanField(default=False, verbose_name="is moderated"),
),
(
"asked_for_removal",
models.BooleanField(
default=False, verbose_name="asked for removal"
),
),
(
"moderator",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="moderated_pictures",
to=settings.AUTH_USER_MODEL,
),
),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="owned_pictures",
to=settings.AUTH_USER_MODEL,
verbose_name="owner",
),
),
(
"parent",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="pictures",
to="sas.album",
verbose_name="album",
),
),
],
options={"abstract": False, "verbose_name": "picture"},
),
migrations.AddConstraint(
model_name="picture",
constraint=models.UniqueConstraint(
fields=("name", "parent"), name="sas_picture_unique_per_album"
),
),
migrations.AddConstraint(
model_name="album",
constraint=models.UniqueConstraint(
fields=("name", "parent"), name="unique_album_name_if_same_parent"
),
),
migrations.RunPython(
copy_albums_and_pictures,
reverse_code=migrations.RunPython.noop,
elidable=True,
),
]

View File

@@ -1,31 +0,0 @@
# Generated by Django 4.2.17 on 2025-01-25 23:50
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("sas", "0006_move_the_whole_sas")]
operations = [
migrations.AlterField(
model_name="peoplepicturerelation",
name="picture",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="people",
to="sas.picture",
verbose_name="picture",
),
),
migrations.AlterField(
model_name="picturemoderationrequest",
name="picture",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="moderation_requests",
to="sas.picture",
verbose_name="Picture",
),
),
]

View File

@@ -18,57 +18,29 @@ from __future__ import annotations
import contextlib
from io import BytesIO
from pathlib import Path
from typing import TYPE_CHECKING, ClassVar, Self
from typing import ClassVar, Self
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.db import models
from django.db.models import Exists, OuterRef, Q
from django.db.models.deletion import Collector
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from PIL import Image
from core.models import Group, Notification, User
from core.models import Notification, SithFile, User
from core.utils import exif_auto_rotate, resize_image
if TYPE_CHECKING:
from django.db.models.fields.files import FieldFile
class SasFile(SithFile):
"""Proxy model for any file in the SAS.
def get_directory(instance: SasFile, filename: str):
return f"./{instance.parent_path}/{filename}"
def get_compressed_directory(instance: SasFile, filename: str):
return f"./.compressed/{instance.parent_path}/{filename}"
def get_thumbnail_directory(instance: SasFile, filename: str):
if isinstance(instance, Album):
_, extension = filename.rsplit(".", 1)
filename = f"{instance.name}/thumb.{extension}"
return f"./.thumbnails/{instance.parent_path}/{filename}"
class SasFile(models.Model):
"""Abstract model for SAS files
This model is used to have logic that should be shared by both
May be used to have logic that should be shared by both
[Picture][sas.models.Picture] and [Album][sas.models.Album].
Notes:
This is an abstract model.
[Album][sas.models.Album] and [Picture][sas.models.Picture]
are separated tables in the database.
"""
class Meta:
abstract = True
proxy = True
permissions = [
("moderate_sasfile", "Can moderate SAS files"),
("view_unmoderated_sasfile", "Can view not moderated SAS files"),
@@ -93,169 +65,6 @@ class SasFile(models.Model):
def can_be_edited_by(self, user):
return user.has_perm("sas.change_sasfile")
@cached_property
def parent_path(self) -> str:
"""The parent location in the SAS album tree (e.g. `SAS/foo/bar`)."""
return "/".join(["SAS", *[p.name for p in self.parent_list]])
@cached_property
def parent_list(self) -> list[Album]:
"""The ancestors of this SAS object.
The result is ordered from the direct parent to the farthest one.
"""
parents = []
current = self.parent
while current is not None:
parents.append(current)
current = current.parent
return parents
class AlbumQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
"""Filter the albums that this user can view.
Warning:
Calling this queryset method may add several additional requests.
"""
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return self.all()
if user.was_subscribed:
return self.filter(is_moderated=True)
# known bug : if all children of an album are also albums
# then this album is excluded, even if one of the sub-albums should be visible.
# The fs-like navigation is likely to be half-broken for non-subscribers,
# but that's ok, since non-subscribers are expected to see only the albums
# containing pictures on which they have been identified (hence, very few).
# Most, if not all, of their albums will be displayed on the
# `latest albums` section of the SAS.
# Moreover, they will still see all of their picture in their profile.
return self.filter(
Exists(Picture.objects.filter(parent_id=OuterRef("pk")).viewable_by(user))
)
class Album(SasFile):
NAME_MAX_LENGTH: ClassVar[int] = 50
name = models.CharField(_("name"), max_length=100)
parent = models.ForeignKey(
"self",
related_name="children",
verbose_name=_("parent"),
null=True,
blank=True,
on_delete=models.CASCADE,
)
thumbnail = models.FileField(
upload_to=get_thumbnail_directory,
verbose_name=_("thumbnail"),
max_length=256,
blank=True,
)
view_groups = models.ManyToManyField(
Group, related_name="viewable_albums", verbose_name=_("view groups"), blank=True
)
edit_groups = models.ManyToManyField(
Group, related_name="editable_albums", verbose_name=_("edit groups"), blank=True
)
event_date = models.DateField(
_("event date"),
help_text=_("The date on which the photos in this album were taken"),
default=timezone.localdate,
blank=True,
)
is_moderated = models.BooleanField(_("is moderated"), default=False)
objects = AlbumQuerySet.as_manager()
class Meta:
verbose_name = _("album")
constraints = [
models.UniqueConstraint(
fields=["name", "parent"],
name="unique_album_name_if_same_parent",
# TODO : add `nulls_distinct=True` after upgrading to django>=5.0
)
]
def __str__(self):
return f"Album {self.name}"
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
):
Notification(
user=user,
url=reverse("sas:moderation"),
type="SAS_MODERATION",
param="1",
).save()
def get_absolute_url(self):
return reverse("sas:album", kwargs={"album_id": self.id})
def clean(self):
super().clean()
if "/" in self.name:
raise ValidationError(_("Character '/' not authorized in name"))
if self.parent_id is not None and (
self.id == self.parent_id or self in self.parent_list
):
raise ValidationError(_("Loop in album tree"), code="loop")
if self.thumbnail:
try:
Image.open(BytesIO(self.thumbnail.read()))
except Image.UnidentifiedImageError as e:
raise ValidationError(_("This is not a valid album thumbnail")) from e
def delete(self, *args, **kwargs):
"""Delete the album, all of its children and all linked disk files"""
collector = Collector(using="default")
collector.collect([self])
albums: set[Album] = collector.data[Album]
pictures: set[Picture] = collector.data[Picture]
files: list[FieldFile] = [
*[a.thumbnail for a in albums],
*[p.thumbnail for p in pictures],
*[p.compressed for p in pictures],
*[p.original for p in pictures],
]
# `bool(f)` checks that the file actually exists on the disk
files = [f for f in files if bool(f)]
folders = {Path(f.path).parent for f in files}
res = super().delete(*args, **kwargs)
# once the model instances have been deleted,
# delete the actual files.
for file in files:
# save=False ensures that django doesn't recreate the db record,
# which would make the whole deletion pointless
# cf. https://docs.djangoproject.com/en/stable/ref/models/fields/#django.db.models.fields.files.FieldFile.delete
file.delete(save=False)
for folder in folders:
# now that the files are deleted, remove the empty folders
if folder.is_dir() and next(folder.iterdir(), None) is None:
folder.rmdir()
return res
def get_download_url(self):
return reverse("sas:album_preview", kwargs={"album_id": self.id})
def generate_thumbnail(self):
p = (
self.pictures.exclude(thumbnail="").order_by("?").first()
or self.children.exclude(thumbnail="").order_by("?").first()
)
if p:
# The file is loaded into memory to duplicate it.
# It may not be the most efficient way, but thumbnails are
# usually quite small, so it's still ok
self.thumbnail = ContentFile(p.thumbnail.read(), name="thumb.webp")
self.save()
class PictureQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
@@ -271,65 +80,23 @@ class PictureQuerySet(models.QuerySet):
return self.filter(people__user_id=user.id, is_moderated=True)
class SASPictureManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_in_sas=True, is_folder=False)
class Picture(SasFile):
name = models.CharField(_("file name"), max_length=256)
parent = models.ForeignKey(
Album,
related_name="pictures",
verbose_name=_("album"),
on_delete=models.CASCADE,
)
thumbnail = models.FileField(
upload_to=get_thumbnail_directory,
verbose_name=_("thumbnail"),
max_length=256,
unique=True,
)
original = models.FileField(
upload_to=get_directory,
verbose_name=_("original image"),
max_length=256,
unique=True,
)
compressed = models.FileField(
upload_to=get_compressed_directory,
verbose_name=_("compressed image"),
max_length=256,
unique=True,
)
created_at = models.DateTimeField(default=timezone.now)
owner = models.ForeignKey(
User,
related_name="owned_pictures",
verbose_name=_("owner"),
on_delete=models.PROTECT,
)
is_moderated = models.BooleanField(_("is moderated"), default=False)
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
moderator = models.ForeignKey(
User,
related_name="moderated_pictures",
null=True,
blank=True,
on_delete=models.SET_NULL,
)
objects = PictureQuerySet.as_manager()
class Meta:
verbose_name = _("picture")
constraints = [
models.UniqueConstraint(
fields=["name", "parent"], name="sas_picture_unique_per_album"
)
]
proxy = True
def __str__(self):
return self.name
objects = SASPictureManager.from_queryset(PictureQuerySet)()
def get_absolute_url(self):
return reverse("sas:picture", kwargs={"picture_id": self.id})
@property
def is_vertical(self):
with open(settings.MEDIA_ROOT / self.file.name, "rb") as f:
im = Image.open(BytesIO(f.read()))
(w, h) = im.size
return (w / h) < 1
def get_download_url(self):
return reverse("sas:download", kwargs={"picture_id": self.id})
@@ -340,34 +107,41 @@ class Picture(SasFile):
def get_download_thumb_url(self):
return reverse("sas:download_thumb", kwargs={"picture_id": self.id})
@property
def is_vertical(self):
# original, compressed and thumbnail image have all three the same ratio,
# so the smallest one is used to tell if the image is vertical
im = Image.open(BytesIO(self.thumbnail.read()))
(w, h) = im.size
return w < h
def get_absolute_url(self):
return reverse("sas:picture", kwargs={"picture_id": self.id})
def generate_thumbnails(self):
im = Image.open(self.original)
def generate_thumbnails(self, *, overwrite=False):
im = Image.open(BytesIO(self.file.read()))
with contextlib.suppress(Exception):
im = exif_auto_rotate(im)
# convert the compressed image and the thumbnail into webp
# The original image keeps its original type, because it's not
# meant to be shown on the website, but rather to keep the real image
# for less frequent cases (like downloading the pictures of an user)
extension = self.mime_type.split("/")[-1]
# the HD version of the image doesn't need to be optimized, because :
# - it isn't frequently queried
# - optimizing large images takes a lot of time, which greatly hinders the UX
# - optimizing large images takes a lot time, which greatly hinders the UX
# - photographers usually already optimize their images
file = resize_image(im, max(im.size), extension, optimize=False)
thumb = resize_image(im, 200, "webp")
compressed = resize_image(im, 1200, "webp")
new_extension_name = str(Path(self.original.name).with_suffix(".webp"))
if overwrite:
self.file.delete()
self.thumbnail.delete()
self.compressed.delete()
new_extension_name = str(Path(self.name).with_suffix(".webp"))
self.file = file
self.file.name = self.name
self.thumbnail = thumb
self.thumbnail.name = new_extension_name
self.compressed = compressed
self.compressed.name = new_extension_name
def rotate(self, degree):
for field in self.original, self.compressed, self.thumbnail:
with open(field.file, "r+b") as file:
for attr in ["file", "compressed", "thumbnail"]:
name = self.__getattribute__(attr).name
with open(settings.MEDIA_ROOT / name, "r+b") as file:
if file:
im = Image.open(BytesIO(file.read()))
file.seek(0)
@@ -380,6 +154,110 @@ class Picture(SasFile):
progressive=True,
)
def get_next(self):
if self.is_moderated:
pictures_qs = self.parent.children.filter(
is_moderated=True,
asked_for_removal=False,
is_folder=False,
id__gt=self.id,
)
else:
pictures_qs = Picture.objects.filter(id__gt=self.id, is_moderated=False)
return pictures_qs.order_by("id").first()
def get_previous(self):
if self.is_moderated:
pictures_qs = self.parent.children.filter(
is_moderated=True,
asked_for_removal=False,
is_folder=False,
id__lt=self.id,
)
else:
pictures_qs = Picture.objects.filter(id__lt=self.id, is_moderated=False)
return pictures_qs.order_by("-id").first()
class AlbumQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
"""Filter the albums that this user can view.
Warning:
Calling this queryset method may add several additional requests.
"""
if user.has_perm("sas.moderate_sasfile"):
return self.all()
if user.was_subscribed:
return self.filter(Q(is_moderated=True) | Q(owner=user))
# known bug : if all children of an album are also albums
# then this album is excluded, even if one of the sub-albums should be visible.
# The fs-like navigation is likely to be half-broken for non-subscribers,
# but that's ok, since non-subscribers are expected to see only the albums
# containing pictures on which they have been identified (hence, very few).
# Most, if not all, of their albums will be displayed on the
# `latest albums` section of the SAS.
# Moreover, they will still see all of their picture in their profile.
return self.filter(
Exists(Picture.objects.filter(parent_id=OuterRef("pk")).viewable_by(user))
)
class SASAlbumManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_in_sas=True, is_folder=True)
class Album(SasFile):
NAME_MAX_LENGTH: ClassVar[int] = 50
"""Maximum length of an album's name.
[SithFile][core.models.SithFile] have a maximum length
of 256 characters.
However, this limit is too high for albums.
Names longer than 50 characters are harder to read
and harder to display on the SAS page.
It is to be noted, though, that this does not
add or modify any db behaviour.
It's just a constant to be used in views and forms.
"""
class Meta:
proxy = True
objects = SASAlbumManager.from_queryset(AlbumQuerySet)()
@property
def children_pictures(self):
return Picture.objects.filter(parent=self)
@property
def children_albums(self):
return Album.objects.filter(parent=self)
def get_absolute_url(self):
if self.id == settings.SITH_SAS_ROOT_DIR_ID:
return reverse("sas:main")
return reverse("sas:album", kwargs={"album_id": self.id})
def get_download_url(self):
return reverse("sas:album_preview", kwargs={"album_id": self.id})
def generate_thumbnail(self):
p = (
self.children_pictures.order_by("?").first()
or self.children_albums.exclude(file=None)
.exclude(file="")
.order_by("?")
.first()
)
if p and p.file:
image = resize_image(Image.open(BytesIO(p.file.read())), 200, "webp")
self.file = image
self.file.name = f"{self.name}/thumb.webp"
self.save()
def sas_notification_callback(notif: Notification):
count = Picture.objects.filter(is_moderated=False).count()

View File

@@ -26,10 +26,19 @@ class SimpleAlbumSchema(ModelSchema):
class AlbumSchema(ModelSchema):
class Meta:
model = Album
fields = ["id", "name", "is_moderated", "thumbnail"]
fields = ["id", "name", "is_moderated"]
thumbnail: str | None
sas_url: str
@staticmethod
def resolve_thumbnail(obj: Album) -> str | None:
# Album thumbnails aren't stored in `Album.thumbnail` but in `Album.file`
# Don't ask me why.
if not obj.file:
return None
return obj.get_download_url()
@staticmethod
def resolve_sas_url(obj: Album) -> str:
return obj.get_absolute_url()
@@ -46,12 +55,7 @@ class AlbumAutocompleteSchema(ModelSchema):
@staticmethod
def resolve_path(obj: Album) -> str:
return str(Path(obj.parent_path) / obj.name)
class MoveAlbumSchema(Schema):
id: int
new_parent_id: int
return str(Path(obj.get_parent_path()) / obj.name)
class PictureFilterSchema(FilterSchema):
@@ -66,7 +70,7 @@ class PictureFilterSchema(FilterSchema):
class PictureSchema(ModelSchema):
class Meta:
model = Picture
fields = ["id", "name", "created_at", "is_moderated", "asked_for_removal"]
fields = ["id", "name", "date", "size", "is_moderated", "asked_for_removal"]
owner: UserProfileSchema
sas_url: str

View File

@@ -128,108 +128,3 @@ document.addEventListener("alpine:init", () => {
},
}));
});
// Todo: migrate to alpine.js if we have some time
// $("form#upload_form").submit(function (event) {
// const formData = new FormData($(this)[0]);
//
// if (!formData.get("album_name") && !formData.get("images").name) return false;
//
// if (!formData.get("images").name) {
// return true;
// }
//
// event.preventDefault();
//
// let errorList = this.querySelector("#upload_form ul.errorlist.nonfield");
// if (errorList === null) {
// errorList = document.createElement("ul");
// errorList.classList.add("errorlist", "nonfield");
// this.insertBefore(errorList, this.firstElementChild);
// }
//
// while (errorList.childElementCount > 0)
// errorList.removeChild(errorList.firstElementChild);
//
// let progress = this.querySelector("progress");
// if (progress === null) {
// progress = document.createElement("progress");
// progress.value = 0;
// const p = document.createElement("p");
// p.appendChild(progress);
// this.insertBefore(p, this.lastElementChild);
// }
//
// let dataHolder;
//
// if (formData.get("album_name")) {
// dataHolder = new FormData();
// dataHolder.set("csrfmiddlewaretoken", "{{ csrf_token }}");
// dataHolder.set("album_name", formData.get("album_name"));
// $.ajax({
// method: "POST",
// url: "{{ url('sas:album_upload', album_id=object.id) }}",
// data: dataHolder,
// processData: false,
// contentType: false,
// success: onSuccess,
// });
// }
//
// const images = formData.getAll("images");
// const imagesCount = images.length;
// let completeCount = 0;
//
// const poolSize = 1;
// const imagePool = [];
//
// while (images.length > 0 && imagePool.length < poolSize) {
// const image = images.shift();
// imagePool.push(image);
// sendImage(image);
// }
//
// function sendImage(image) {
// dataHolder = new FormData();
// dataHolder.set("csrfmiddlewaretoken", "{{ csrf_token }}");
// dataHolder.set("images", image);
//
// $.ajax({
// method: "POST",
// url: "{{ url('sas:album_upload', album_id=object.id) }}",
// data: dataHolder,
// processData: false,
// contentType: false,
// })
// .fail(onSuccess.bind(undefined, image))
// .done(onSuccess.bind(undefined, image))
// .always(next.bind(undefined, image));
// }
//
// function next(image, _, __) {
// const index = imagePool.indexOf(image);
// const nextImage = images.shift();
//
// if (index !== -1) {
// imagePool.splice(index, 1);
// }
//
// if (nextImage) {
// imagePool.push(nextImage);
// sendImage(nextImage);
// }
// }
//
// function onSuccess(image, data, _, __) {
// let errors = [];
//
// if ($(data.responseText).find(".errorlist.nonfield")[0])
// errors = Array.from($(data.responseText).find(".errorlist.nonfield")[0].children);
//
// while (errors.length > 0) errorList.appendChild(errors.shift());
//
// progress.value = ++completeCount / imagesCount;
// if (progress.value === 1 && errorList.children.length === 0)
// document.location.reload();
// }
// });

View File

@@ -31,10 +31,10 @@ document.addEventListener("alpine:init", () => {
await Promise.all(
this.downloadPictures.map((p: PictureSchema) => {
const imgName = `${p.album.name}/IMG_${p.id}_${p.created_at.replace(/[:-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
const imgName = `${p.album.name}/IMG_${p.id}_${p.date.replace(/[:-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
level: 9,
lastModDate: new Date(p.created_at),
lastModDate: new Date(p.date),
onstart: incrementProgressBar,
});
}),

View File

@@ -109,233 +109,225 @@ interface ViewerConfig {
/** id of the first picture to load on the page */
firstPictureId: number;
/** if the user is sas admin */
userIsSasAdmin: boolean;
userCanModerate: boolean;
}
/**
* Load user picture page with a nice download bar
**/
exportToHtml("loadViewer", (config: ViewerConfig) => {
document.addEventListener("alpine:init", () => {
Alpine.data("picture_viewer", () => ({
/**
* All the pictures that can be displayed on this picture viewer
**/
pictures: [] as PictureWithIdentifications[],
/**
* The currently displayed picture
* Default dummy data are pre-loaded to avoid javascript error
* when loading the page at the beginning
* @type PictureWithIdentifications
**/
currentPicture: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
is_moderated: true,
id: null as number,
name: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
display_name: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
compressed_url: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
profile_url: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
full_size_url: "",
owner: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
created_at: new Date(),
identifications: [] as IdentifiedUserSchema[],
},
/**
* The picture which will be displayed next if the user press the "next" button
**/
nextPicture: null as PictureWithIdentifications,
/**
* The picture which will be displayed next if the user press the "previous" button
**/
previousPicture: null as PictureWithIdentifications,
/**
* The select2 component used to identify users
**/
selector: undefined as UserAjaxSelect,
/**
* 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,
document.addEventListener("alpine:init", () => {
Alpine.data("picture_viewer", (config: ViewerConfig) => ({
/**
* All the pictures that can be displayed on this picture viewer
**/
pictures: [] as PictureWithIdentifications[],
/**
* The currently displayed picture
* Default dummy data are pre-loaded to avoid javascript error
* when loading the page at the beginning
* @type PictureWithIdentifications
**/
currentPicture: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
is_moderated: true,
id: null as number,
name: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
display_name: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
compressed_url: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
profile_url: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
full_size_url: "",
owner: "",
date: new Date(),
identifications: [] as IdentifiedUserSchema[],
},
/**
* The picture which will be displayed next if the user press the "next" button
**/
nextPicture: null as PictureWithIdentifications,
/**
* The picture which will be displayed next if the user press the "previous" button
**/
previousPicture: null as PictureWithIdentifications,
/**
* The select2 component used to identify users
**/
selector: undefined as UserAjaxSelect,
/**
* 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 = (
await paginated(picturesFetchPictures, {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
query: { album_id: config.albumId },
} as PicturesFetchPicturesData)
).map(PictureWithIdentifications.fromPicture);
this.selector = this.$refs.search;
this.selector.setFilter((users: UserProfileSchema[]) => {
const resp: UserProfileSchema[] = [];
const ids = [
...(this.currentPicture.identifications || []).map(
(i: IdentifiedUserSchema) => i.user.id,
),
];
for (const user of users) {
if (!ids.includes(user.id)) {
resp.push(user);
}
async init() {
this.pictures = (
await paginated(picturesFetchPictures, {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
query: { album_id: config.albumId },
} as PicturesFetchPicturesData)
).map(PictureWithIdentifications.fromPicture);
this.selector = this.$refs.search;
this.selector.setFilter((users: UserProfileSchema[]) => {
const resp: UserProfileSchema[] = [];
const ids = [
...(this.currentPicture.identifications || []).map(
(i: IdentifiedUserSchema) => i.user.id,
),
];
for (const user of users) {
if (!ids.includes(user.id)) {
resp.push(user);
}
return resp;
});
this.currentPicture = this.pictures.find(
(i: PictureSchema) => i.id === config.firstPictureId,
);
this.$watch(
"currentPicture",
(current: PictureSchema, previous: PictureSchema) => {
if (current === previous) {
/* Avoid recursive updates */
return;
}
this.updatePicture();
},
);
window.addEventListener("popstate", async (event) => {
if (!event.state || event.state.sasPictureId === undefined) {
}
return resp;
});
this.currentPicture = this.pictures.find(
(i: PictureSchema) => i.id === config.firstPictureId,
);
this.$watch(
"currentPicture",
(current: PictureSchema, previous: PictureSchema) => {
if (current === previous) {
/* Avoid recursive updates */
return;
}
this.pushstate = History.Replace;
this.currentPicture = this.pictures.find(
(i: PictureSchema) =>
i.id === Number.parseInt(event.state.sasPictureId, 10),
);
});
this.pushstate = History.Replace; /* Avoid first url push */
await this.updatePicture();
},
/**
* Update the page.
* Called when the `currentPicture` property changes.
*
* The url is modified without reloading the page,
* and the previous picture, the next picture and
* 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);
}
this.moderationError = "";
const index: number = this.pictures.indexOf(this.currentPicture);
this.previousPicture = this.pictures[index - 1] || null;
this.nextPicture = this.pictures[index + 1] || null;
this.$refs.mainPicture?.addEventListener("load", () => {
// once the current picture is loaded,
// start preloading the next and previous pictures
this.nextPicture?.preload();
this.previousPicture?.preload();
});
if (this.currentPicture.asked_for_removal && config.userIsSasAdmin) {
await Promise.all([
this.currentPicture.loadIdentifications(),
this.currentPicture.loadModeration(),
]);
} else {
await this.currentPicture.loadIdentifications();
}
},
async moderatePicture() {
const res = await picturesModeratePicture({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.currentPicture.id },
});
if (res.error) {
this.moderationError = `${gettext("Couldn't moderate picture")} : ${(res.error as { detail: string }).detail}`;
this.updatePicture();
},
);
window.addEventListener("popstate", async (event) => {
if (!event.state || event.state.sasPictureId === undefined) {
return;
}
this.currentPicture.is_moderated = true;
this.currentPicture.asked_for_removal = false;
},
this.pushstate = History.Replace;
this.currentPicture = this.pictures.find(
(i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId, 10),
);
});
this.pushstate = History.Replace; /* Avoid first url push */
await this.updatePicture();
},
async deletePicture() {
const res = await picturesDeletePicture({
/**
* Update the page.
* Called when the `currentPicture` property changes.
*
* The url is modified without reloading the page,
* and the previous picture, the next picture and
* 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);
}
this.moderationError = "";
const index: number = this.pictures.indexOf(this.currentPicture);
this.previousPicture = this.pictures[index - 1] || null;
this.nextPicture = this.pictures[index + 1] || null;
this.$refs.mainPicture?.addEventListener("load", () => {
// once the current picture is loaded,
// start preloading the next and previous pictures
this.nextPicture?.preload();
this.previousPicture?.preload();
});
if (this.currentPicture.asked_for_removal && config.userCanModerate) {
await Promise.all([
this.currentPicture.loadIdentifications(),
this.currentPicture.loadModeration(),
]);
} else {
await this.currentPicture.loadIdentifications();
}
},
async moderatePicture() {
const res = await picturesModeratePicture({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.currentPicture.id },
});
if (res.error) {
this.moderationError = `${gettext("Couldn't moderate picture")} : ${(res.error as { detail: string }).detail}`;
return;
}
this.currentPicture.is_moderated = true;
this.currentPicture.asked_for_removal = false;
},
async deletePicture() {
const res = await picturesDeletePicture({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.currentPicture.id },
});
if (res.error) {
this.moderationError = `${gettext("Couldn't delete picture")} : ${(res.error as { detail: string }).detail}`;
return;
}
this.pictures.splice(this.pictures.indexOf(this.currentPicture), 1);
if (this.pictures.length === 0) {
// The deleted picture was the only one in the list.
// As the album is now empty, go back to the parent page
document.location.href = config.albumUrl;
}
this.currentPicture = this.nextPicture || this.previousPicture;
},
/**
* Send the identification request and update the list of identified users.
*/
async submitIdentification(): Promise<void> {
const widget: TomSelect = this.selector.widget;
await picturesIdentifyUsers({
path: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.currentPicture.id },
});
if (res.error) {
this.moderationError = `${gettext("Couldn't delete picture")} : ${(res.error as { detail: string }).detail}`;
return;
}
this.pictures.splice(this.pictures.indexOf(this.currentPicture), 1);
if (this.pictures.length === 0) {
// The deleted picture was the only one in the list.
// As the album is now empty, go back to the parent page
document.location.href = config.albumUrl;
}
this.currentPicture = this.nextPicture || this.previousPicture;
},
picture_id: this.currentPicture.id,
},
body: widget.items.map((i: string) => Number.parseInt(i, 10)),
});
// refresh the identified users list
await this.currentPicture.loadIdentifications({ forceReload: true });
/**
* Send the identification request and update the list of identified users.
*/
async submitIdentification(): Promise<void> {
const widget: TomSelect = this.selector.widget;
await picturesIdentifyUsers({
path: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
picture_id: this.currentPicture.id,
},
body: widget.items.map((i: string) => Number.parseInt(i, 10)),
});
// refresh the identified users list
await this.currentPicture.loadIdentifications({ forceReload: true });
// Clear selection and cache of retrieved user so they can be filtered again
widget.clear(false);
widget.clearOptions();
widget.setTextboxValue("");
},
// Clear selection and cache of retrieved user so they can be filtered again
widget.clear(false);
widget.clearOptions();
widget.setTextboxValue("");
},
/**
* Check if an identification can be removed by the currently logged user
*/
canBeRemoved(identification: IdentifiedUserSchema): boolean {
return config.userCanModerate || identification.user.id === config.userId;
},
/**
* Check if an identification can be removed by the currently logged user
*/
canBeRemoved(identification: IdentifiedUserSchema): boolean {
return config.userIsSasAdmin || identification.user.id === config.userId;
},
/**
* Untag a user from the current picture
*/
async removeIdentification(identification: IdentifiedUserSchema): Promise<void> {
const res = await usersidentifiedDeleteRelation({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { relation_id: identification.id },
});
if (!res.error && Array.isArray(this.currentPicture.identifications)) {
this.currentPicture.identifications =
this.currentPicture.identifications.filter(
(i: IdentifiedUserSchema) => i.id !== identification.id,
);
}
},
}));
});
/**
* Untag a user from the current picture
*/
async removeIdentification(identification: IdentifiedUserSchema): Promise<void> {
const res = await usersidentifiedDeleteRelation({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { relation_id: identification.id },
});
if (!res.error && Array.isArray(this.currentPicture.identifications)) {
this.currentPicture.identifications =
this.currentPicture.identifications.filter(
(i: IdentifiedUserSchema) => i.id !== identification.id,
);
}
},
}));
});

View File

@@ -20,7 +20,7 @@
{% block content %}
<code>
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album.parent) }} {{ album.name }}
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album.parent) }} {{ album.get_display_name() }}
</code>
{% set is_sas_admin = user.can_edit(album) %}
@@ -30,7 +30,7 @@
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="album-navbar">
<h3>{{ album.name }}</h3>
<h3>{{ album.get_display_name() }}</h3>
<div class="toolbar">
<a href="{{ url('sas:album_edit', album_id=album.id) }}">{% trans %}Edit{% endtrans %}</a>
@@ -40,17 +40,17 @@
</div>
</div>
{# {% if clipboard %}#}
{# <div class="clipboard">#}
{# {% trans %}Clipboard: {% endtrans %}#}
{# <ul>#}
{# {% for f in clipboard["albums"] %}#}
{# <li>{{ f.get_full_path() }}</li>#}
{# {% endfor %}#}
{# </ul>#}
{# <input name="clear" type="submit" value="{% trans %}Clear clipboard{% endtrans %}">#}
{# </div>#}
{# {% endif %}#}
{% if clipboard %}
<div class="clipboard">
{% trans %}Clipboard: {% endtrans %}
<ul>
{% for f in clipboard %}
<li>{{ f.get_full_path() }}</li>
{% endfor %}
</ul>
<input name="clear" type="submit" value="{% trans %}Clear clipboard{% endtrans %}">
</div>
{% endif %}
{% endif %}
{% if show_albums %}
@@ -73,8 +73,8 @@
<div class="text">{% trans %}To be moderated{% endtrans %}</div>
</template>
</div>
{% if edit_mode %}
<input type="checkbox" name="album_list" :value="album.id">
{% if is_sas_admin %}
<input type="checkbox" name="file_list" :value="album.id">
{% endif %}
</a>
</template>
@@ -100,7 +100,7 @@
</template>
</div>
{% if is_sas_admin %}
<input type="checkbox" name="picture_list" :value="picture.id">
<input type="checkbox" name="file_list" :value="picture.id">
{% endif %}
</a>
</template>
@@ -120,9 +120,9 @@
{% csrf_token %}
<div class="inputs">
<p>
<label for="{{ form.images.id_for_label }}">{{ form.images.label }} :</label>
{{ form.images|add_attr("x-ref=pictures") }}
<span class="helptext">{{ form.images.help_text }}</span>
<label for="{{ upload_form.images.id_for_label }}">{{ upload_form.images.label }} :</label>
{{ upload_form.images|add_attr("x-ref=pictures") }}
<span class="helptext">{{ upload_form.images.help_text }}</span>
</p>
<input type="submit" value="{% trans %}Upload{% endtrans %}" />
<progress x-ref="progress" x-show="sending"></progress>

View File

@@ -1,13 +1,19 @@
{% macro display_album(a, edit_mode) %}
<a href="{{ url('sas:album', album_id=a.id) }}">
{% if a.thumbnail %}
{% if a.file %}
{% set img = a.get_download_url() %}
{% set src = a.name %}
{% elif a.children.filter(is_folder=False, is_moderated=True).exists() %}
{% set picture = a.children.filter(is_folder=False).first().as_picture %}
{% set img = picture.get_download_thumb_url() %}
{% set src = picture.name %}
{% else %}
{% set img = static('core/img/sas.jpg') %}
{% set src = "sas.jpg" %}
{% endif %}
<div class="album{% if not a.is_moderated %} not_moderated{% endif %}">
<div
class="album{% if not a.is_moderated %} not_moderated{% endif %}"
>
<img src="{{ img }}" alt="{{ src }}" loading="lazy" />
{% if not a.is_moderated %}
<div class="overlay">&nbsp;</div>
@@ -25,7 +31,7 @@
{% macro print_path(file) %}
{% if file and file.parent %}
{{ print_path(file.parent) }}
<a href="{{ url("sas:album", album_id=file.id) }}">{{ file.name }}</a> /
<a href="{{ url('sas:album', album_id=file.id) }}">{{ file.get_display_name() }}</a> /
{% endif %}
{% endmacro %}

View File

@@ -1,9 +1,9 @@
{% extends "core/base.jinja" %}
{%- block additional_css -%}
<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('sas/css/picture.scss') }}">
<link defer rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}">
<link defer rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
<link defer rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
{%- endblock -%}
{%- block additional_js -%}
@@ -17,10 +17,8 @@
{% from "sas/macros.jinja" import print_path %}
{% set user_is_sas_admin = user.is_root or user.is_in_group(pk = settings.SITH_GROUP_SAS_ADMIN_ID) %}
{% block content %}
<main x-data="picture_viewer">
<main x-data="picture_viewer(config)">
<code>
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album) }} <span x-text="currentPicture.name"></span>
</code>
@@ -50,15 +48,13 @@
It will be hidden to other users until it has been moderated.
{% endtrans %}
</p>
{% if user_is_sas_admin %}
{% if user.has_perm("sas.moderate_sasfile") %}
<template x-if="currentPicture.asked_for_removal">
<div>
<h5>{% trans %}The following issues have been raised:{% endtrans %}</h5>
<template x-for="req in (currentPicture.moderationRequests ?? [])" :key="req.id">
<div>
<h6
x-text="`${req.author.first_name} ${req.author.last_name}`"
></h6>
<h6 x-text="`${req.author.first_name} ${req.author.last_name}`"></h6>
<i x-text="Intl.DateTimeFormat(
'{{ LANGUAGE_CODE }}',
{dateStyle: 'long', timeStyle: 'short'}
@@ -70,7 +66,7 @@
</template>
{% endif %}
</div>
{% if user_is_sas_admin %}
{% if user.has_perm("sas.moderate_sasfile") %}
<div class="alert-aside">
<button class="btn btn-blue" @click="moderatePicture()">
{% trans %}Moderate{% endtrans %}
@@ -104,7 +100,7 @@
<span
x-text="Intl.DateTimeFormat(
'{{ LANGUAGE_CODE }}', {dateStyle: 'long'}
).format(new Date(currentPicture.created_at))"
).format(new Date(currentPicture.date))"
>
</span>
</div>
@@ -204,16 +200,13 @@
{% endblock %}
{% block script %}
{{ super() }}
<script>
window.addEventListener("DOMContentLoaded", () => {
loadViewer({
albumId: {{ album.id }} ,
albumUrl: "{{ album.get_absolute_url() }}",
firstPictureId: {{ picture.id }}, {# id of the first picture to show after page load #}
userId: {{ user.id }},
userIsSasAdmin: {{ user_is_sas_admin|tojson }}
});
})
const config = {
albumId: {{ album.id }},
albumUrl: "{{ album.get_absolute_url() }}",
firstPictureId: {{ picture.id }}, {# id of the first picture to show after page load #}
userId: {{ user.id }},
userCanModerate: {{ user.has_perm("sas.moderate_sasfile")|tojson }}
}
</script>
{% endblock %}

View File

@@ -27,8 +27,8 @@ class TestSas(TestCase):
cls.user_b, cls.user_c = subscriber_user.make(_quantity=2)
picture = picture_recipe.extend(owner=owner)
cls.album_a = baker.make(Album)
cls.album_b = baker.make(Album)
cls.album_a = baker.make(Album, is_in_sas=True, parent=sas)
cls.album_b = baker.make(Album, is_in_sas=True, parent=sas)
relation_recipe = Recipe(PeoplePictureRelation)
relations = []
for album in cls.album_a, cls.album_b:
@@ -61,7 +61,7 @@ class TestPictureSearch(TestSas):
self.client.force_login(self.user_b)
res = self.client.get(self.url + f"?album_id={self.album_a.id}")
assert res.status_code == 200
expected = list(self.album_a.pictures.values_list("id", flat=True))
expected = list(self.album_a.children_pictures.values_list("id", flat=True))
assert [i["id"] for i in res.json()["results"]] == expected
def test_filter_by_user(self):
@@ -70,7 +70,7 @@ class TestPictureSearch(TestSas):
assert res.status_code == 200
expected = list(
self.user_a.pictures.order_by(
"-picture__parent__event_date", "picture__created_at"
"-picture__parent__date", "picture__date"
).values_list("picture_id", flat=True)
)
assert [i["id"] for i in res.json()["results"]] == expected
@@ -84,7 +84,7 @@ class TestPictureSearch(TestSas):
assert res.status_code == 200
expected = list(
self.user_a.pictures.union(self.user_b.pictures.all())
.order_by("-picture__parent__event_date", "picture__created_at")
.order_by("-picture__parent__date", "picture__date")
.values_list("picture_id", flat=True)
)
assert [i["id"] for i in res.json()["results"]] == expected
@@ -97,7 +97,7 @@ class TestPictureSearch(TestSas):
assert res.status_code == 200
expected = list(
self.user_a.pictures.order_by(
"-picture__parent__event_date", "picture__created_at"
"-picture__parent__date", "picture__date"
).values_list("picture_id", flat=True)
)
assert [i["id"] for i in res.json()["results"]] == expected
@@ -123,7 +123,7 @@ class TestPictureSearch(TestSas):
assert res.status_code == 200
expected = list(
self.user_b.pictures.intersection(self.user_a.pictures.all())
.order_by("-picture__parent__event_date", "picture__created_at")
.order_by("-picture__parent__date", "picture__date")
.values_list("picture_id", flat=True)
)
assert [i["id"] for i in res.json()["results"]] == expected

View File

@@ -4,8 +4,8 @@ from model_bakery import baker
from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import User
from sas.baker_recipes import album_recipe, picture_recipe
from sas.models import Album, PeoplePictureRelation, Picture
from sas.baker_recipes import picture_recipe
from sas.models import PeoplePictureRelation, Picture
class TestPictureQuerySet(TestCase):
@@ -67,22 +67,3 @@ def test_identifications_viewable_by_user():
assert list(picture.people.viewable_by(identifications[1].user)) == [
identifications[1]
]
class TestDeleteAlbum(TestCase):
def setUp(cls):
cls.album: Album = album_recipe.make()
cls.album_pictures = picture_recipe.make(parent=cls.album, _quantity=5)
cls.sub_album = album_recipe.make(parent=cls.album)
cls.sub_album_pictures = picture_recipe.make(parent=cls.sub_album, _quantity=5)
def test_delete(self):
album_ids = [self.album.id, self.sub_album.id]
picture_ids = [
*[p.id for p in self.album_pictures],
*[p.id for p in self.sub_album_pictures],
]
self.album.delete()
# assert not p.exists()
assert not Album.objects.filter(id__in=album_ids).exists()
assert not Picture.objects.filter(id__in=picture_ids).exists()

View File

@@ -136,7 +136,9 @@ class TestAlbumUpload:
class TestSasModeration(TestCase):
@classmethod
def setUpTestData(cls):
album = baker.make(Album)
album = baker.make(
Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID, is_moderated=True
)
cls.pictures = picture_recipe.make(
parent=album, _quantity=10, _bulk_create=True
)
@@ -159,16 +161,22 @@ class TestSasModeration(TestCase):
assert len(res.context_data["pictures"]) == 1
assert res.context_data["pictures"][0] == self.to_moderate
res = self.client.post(
reverse("sas:moderation"),
data={"album_id": self.to_moderate.id, "picture_id": self.to_moderate.id},
)
def test_moderation_page_forbidden(self):
self.client.force_login(self.simple_user)
res = self.client.get(reverse("sas:moderation"))
assert res.status_code == 403
def test_moderate_album(self):
self.client.force_login(self.moderator)
url = reverse("sas:moderation")
album = baker.make(
Album, is_moderated=False, parent_id=settings.SITH_SAS_ROOT_DIR_ID
)
res = self.client.post(url, data={"album_id": album.id, "moderate": ""})
assertRedirects(res, url)
album.refresh_from_db()
assert album.is_moderated
def test_moderate_picture(self):
self.client.force_login(self.moderator)
res = self.client.get(

View File

@@ -12,23 +12,22 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from pathlib import Path
from typing import Any
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Count, OuterRef, Subquery
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.safestring import SafeString
from django.views.generic import CreateView, DetailView, TemplateView
from django.views.generic.edit import FormMixin, FormView, UpdateView
from django.views.generic.edit import FormView, UpdateView
from core.auth.mixins import CanEditMixin, CanViewMixin
from core.models import SithFile, User
from core.views import FileView, UseFragmentsMixin
from core.views.files import send_raw_file
from core.views import UseFragmentsMixin
from core.views.files import FileView, send_file
from core.views.mixins import FragmentMixin, FragmentRenderer
from core.views.user import UserTabsMixin
from sas.forms import (
@@ -64,7 +63,6 @@ class AlbumCreateFragment(FragmentMixin, CreateView):
class SASMainView(UseFragmentsMixin, TemplateView):
form_class = AlbumCreateForm
template_name = "sas/main.jinja"
def get_fragments(self) -> dict[str, FragmentRenderer]:
@@ -81,26 +79,12 @@ class SASMainView(UseFragmentsMixin, TemplateView):
root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID)
return {"album_create_fragment": {"owner": root_user}}
def dispatch(self, request, *args, **kwargs):
if request.method == "POST" and not self.request.user.has_perm("sas.add_album"):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def get_form(self, form_class=None):
if not self.request.user.has_perm("sas.add_album"):
return None
return super().get_form(form_class)
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"owner": User.objects.get(pk=settings.SITH_ROOT_USER_ID),
"parent": None,
}
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
albums_qs = Album.objects.viewable_by(self.request.user)
kwargs["categories"] = list(albums_qs.filter(parent=None).order_by("id"))
kwargs["categories"] = list(
albums_qs.filter(parent_id=settings.SITH_SAS_ROOT_DIR_ID).order_by("id")
)
kwargs["latest"] = list(albums_qs.order_by("-id")[:5])
return kwargs
@@ -110,9 +94,6 @@ class PictureView(CanViewMixin, DetailView):
pk_url_kwarg = "picture_id"
template_name = "sas/picture.jinja"
def get_queryset(self):
return super().get_queryset().select_related("parent")
def get(self, request, *args, **kwargs):
self.object = self.get_object()
if "rotate_right" in request.GET:
@@ -122,42 +103,31 @@ class PictureView(CanViewMixin, DetailView):
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"album": self.object.parent}
return super().get_context_data(**kwargs) | {
"album": Album.objects.get(children=self.object)
}
def send_album(request, album_id):
album = get_object_or_404(Album, id=album_id)
if not album.can_be_viewed_by(request.user):
raise PermissionDenied
return send_raw_file(Path(album.thumbnail.path))
return send_file(request, album_id, Album)
def send_pict(request, picture_id):
picture = get_object_or_404(Picture, id=picture_id)
if not picture.can_be_viewed_by(request.user):
raise PermissionDenied
return send_raw_file(Path(picture.original.path))
return send_file(request, picture_id, Picture)
def send_compressed(request, picture_id):
picture = get_object_or_404(Picture, id=picture_id)
if not picture.can_be_viewed_by(request.user):
raise PermissionDenied
return send_raw_file(Path(picture.compressed.path))
return send_file(request, picture_id, Picture, "compressed")
def send_thumb(request, picture_id):
picture = get_object_or_404(Picture, id=picture_id)
if not picture.can_be_viewed_by(request.user):
raise PermissionDenied
return send_raw_file(Path(picture.thumbnail.path))
return send_file(request, picture_id, Picture, "thumbnail")
class AlbumView(CanViewMixin, UseFragmentsMixin, FormMixin, DetailView):
class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView):
model = Album
pk_url_kwarg = "album_id"
template_name = "sas/album.jinja"
form_class = PictureUploadForm
def get_fragments(self) -> dict[str, FragmentRenderer]:
return {
@@ -172,32 +142,27 @@ class AlbumView(CanViewMixin, UseFragmentsMixin, FormMixin, DetailView):
except ValueError as e:
raise Http404 from e
if "clipboard" not in request.session:
request.session["clipboard"] = {"albums": [], "pictures": []}
request.session["clipboard"] = []
return super().dispatch(request, *args, **kwargs)
def get_form(self, *args, **kwargs):
if not self.request.user.can_edit(self.object):
return None
return super().get_form(*args, **kwargs)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
form = self.get_form()
if not form:
# the form is reserved for users that can edit this album.
# If there is no form, it means the user has no right to do a POST
raise PermissionDenied
FileView.handle_clipboard(self.request, self.object)
if not form.is_valid():
return self.form_invalid(form)
return self.form_valid(form)
if not self.object.file:
self.object.generate_thumbnail()
if request.user.can_edit(self.object): # Handle the copy-paste functions
FileView.handle_clipboard(request, self.object)
return HttpResponseRedirect(self.request.path)
def get_fragment_data(self) -> dict[str, dict[str, Any]]:
return {"album_create_fragment": {"owner": self.request.user}}
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["clipboard"] = {}
if ids := self.request.session.get("clipboard", None):
kwargs["clipboard"] = SithFile.objects.filter(id__in=ids)
kwargs["upload_form"] = PictureUploadForm()
# if True, the albums will be fetched with a request to the API
# if False, the section won't be displayed at all
kwargs["show_albums"] = (
Album.objects.viewable_by(self.request.user)
.filter(parent_id=self.object.id)
@@ -226,31 +191,26 @@ class UserPicturesView(UserTabsMixin, CanViewMixin, DetailView):
# Admin views
class ModerationView(TemplateView):
class ModerationView(PermissionRequiredMixin, TemplateView):
template_name = "sas/moderation.jinja"
def get(self, request, *args, **kwargs):
if request.user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return super().get(request, *args, **kwargs)
raise PermissionDenied
permission_required = "sas.moderate_sasfile"
def post(self, request, *args, **kwargs):
if "album_id" not in request.POST:
raise Http404
if request.user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
album = get_object_or_404(Album, pk=request.POST["album_id"])
if "moderate" in request.POST:
album.moderator = request.user
album.is_moderated = True
album.save()
elif "delete" in request.POST:
album.delete()
return super().get(request, *args, **kwargs)
album = get_object_or_404(Album, pk=request.POST["album_id"])
if "moderate" in request.POST:
album.moderator = request.user
album.is_moderated = True
album.save()
elif "delete" in request.POST:
album.delete()
return redirect(self.request.path)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["albums_to_moderate"] = Album.objects.filter(
is_moderated=False
is_moderated=False, is_in_sas=True, is_folder=True
).order_by("id")
pictures = Picture.objects.filter(is_moderated=False).select_related("parent")
kwargs["pictures"] = pictures

View File

@@ -405,6 +405,8 @@ 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")),
@@ -551,27 +553,27 @@ SITH_SUBSCRIPTIONS = {
# Discount subscriptions
"un-semestre-reduction": {
"name": _("One semester (-20%)"),
"price": 12,
"price": 16,
"duration": 1,
},
"deux-semestres-reduction": {
"name": _("Two semesters (-20%)"),
"price": 22,
"price": 28,
"duration": 2,
},
"cursus-tronc-commun-reduction": {
"name": _("Common core cursus (-20%)"),
"price": 36,
"price": 48,
"duration": 4,
},
"cursus-branche-reduction": {
"name": _("Branch cursus (-20%)"),
"price": 36,
"price": 48,
"duration": 6,
},
"cursus-alternant-reduction": {
"name": _("Alternating cursus (-20%)"),
"price": 24,
"price": 28,
"duration": 6,
},
# CA special offer

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/",

View File

@@ -182,12 +182,13 @@ class OpenApi:
path[action]["operationId"] = "_".join(
desc["operationId"].split("_")[:-1]
)
schema = str(schema)
if old_hash == sha1(schema.encode("utf-8")).hexdigest():
logging.getLogger("django").info("✨ Api did not change, nothing to do ✨")
return
out.write_text(schema)
with open(out, "w") as f:
_ = f.write(schema)
return subprocess.Popen(["npm", "run", "openapi"])