Author SHA1 Message Date
imperosol 7bc980113a allow is_logged_in_counter to target a specific counter 2026-10-30 14:38:00 +01:00
thomas girod 83e8001eac Merge pull request #1520 from ae-utbm/remove-mailing
Remove mailing list feature
2026-10-30 12:17:02 +01:00
imperosol 52a70054f7 Remove mailing list feature 2026-10-30 12:16:31 +01:00
thomas girod 143a9849cd Merge pull request #1533 from ae-utbm/counter-msg
more precise error messages when searching users for click
2026-10-28 19:16:45 +01:00
imperosol 01edf1f688 more precise error messages when searching users for click 2026-10-27 23:28:34 +01:00
thomas girod 366551559d Merge pull request #1532 from ae-utbm/fixes
Fixes
2026-10-26 23:20:43 +01:00
imperosol 59cdebbf4f fix: permission check on SithFile move 2026-10-26 23:18:41 +01:00
imperosol f5a5782bac fix: privilege escalation for DeleteAllForumUserMessagesView 2026-10-26 23:00:54 +01:00
imperosol f11ac5d3be fix: arbitrary file suppression 2026-10-26 23:00:54 +01:00
thomas girod c6f518fffb Merge pull request #1530 from ae-utbm/fixes
Fixes
2026-10-26 13:36:02 +01:00
imperosol c5015f4448 fix: add missing permission in populate 2026-10-26 13:24:16 +01:00
imperosol 9bc6518d95 fix: wrong style for some elements
- mauvais background-color sur le formulaire de login
- rajout d'une margin qui avait disparu sur <hr/>
- enlèvement du border sur <fieldset>
2026-10-26 13:18:54 +01:00
thomas girod c66af5eaff Merge pull request #1529 from ae-utbm/update-deps
Update deps
2026-10-26 13:03:06 +01:00
imperosol c188dd0c18 update JS deps 2026-10-26 13:00:33 +01:00
imperosol 4f5af29d43 update python deps 2026-10-26 12:54:18 +01:00
thomas girod 80f1a7e574 Merge pull request #1528 from ae-utbm/past-news
Club news list view
2026-10-26 12:36:42 +01:00
thomas girod 7548c76d5e Merge pull request #1518 from ae-utbm/css-vars
replace scss variables by css ones
2026-10-26 11:28:35 +01:00
imperosol d7e1cdafde add translations 2026-10-25 12:17:55 +01:00
imperosol 93b6238c75 Test ClubNewsView 2026-10-25 12:17:54 +01:00
imperosol 91865fe7a1 add club news view to ClubTabsMixin 2026-10-25 12:17:54 +01:00
imperosol 1e189f7b2b display club past news 2026-10-25 12:17:54 +01:00
imperosol 1798406dde style: clean footer.scss 2026-10-24 12:57:00 +02:00
imperosol 20fc7d587a style: clean unused classes
- `.form_button` : ancienne interface des clics (changée en 2021)
- `.ui-dialog` : jquery-ui (retiré milieu 2025)
- `td>ul` : déjà déclaré dans `td,th{>ul}`, quelques lignes plus haut
- `.edit-bar` : littéralement jamais utilisé
2026-10-24 12:57:00 +02:00
imperosol 94b108d288 style: remove base.css
Ça a pu être utile dans le passé, mais avec l'évolution des navigateurs (merci à Skia d'avoir précisé dans les commentaires que c'était là pour ça) et l'étoffement du reste du css, il y a seulement trois lignes du fichier qui ont encore réellement une utilité.

Le peu qui sert encore peut donc être déplacé dans `style.scss` et le reste supprimé sans problème.
2026-10-24 12:57:00 +02:00
imperosol 059d5210ac style: replace scss variable by css ones 2026-10-24 12:57:00 +02:00
imperosol 562f75f410 style: remove unused colours 2026-10-24 12:57:00 +02:00
thomas girod 32c30da0fc Merge pull request #1507 from ae-utbm/edt-save
Add user timetable saving
2026-10-24 01:09:43 +02:00
thomas girod d3f3b18660 Merge pull request #1519 from ae-utbm/page-edit-link
Move the club page edit link to the club main page
2026-10-23 20:53:47 +02:00
thomas girod e50b1fd71e Merge pull request #1525 from ae-utbm/show-all-counters-in-stats
Show all counters in user stats view
2026-10-23 16:27:33 +02:00
imperosol 78376eaca4 don't restict user counter stats to bar counters 2026-10-23 16:08:40 +02:00
thomas girod 8d56c72054 Merge pull request #1524 from ae-utbm/remove-gentoken
remove unused Counter methods
2026-10-22 19:10:47 +02:00
imperosol a794f02a8d remove unused Counter methods
`Counter.gen_token` et `Counter.get_random_barman` étaient utilisés avant la refonte du système de connexion des barmen (PR #1413), mais on a oublié de les retirer.
2026-10-22 11:56:04 +02:00
imperosol cb0a2f866d TimetableTabMixin 2026-10-22 11:39:52 +02:00
imperosol 4b4c5a73b6 add tests 2026-10-22 11:39:52 +02:00
imperosol b37b98b932 view to see a user's timetables 2026-10-22 11:39:52 +02:00
imperosol ed888834d6 split timetable parsing and display 2026-10-22 11:39:52 +02:00
imperosol 0657888076 User timetable save 2026-10-22 11:39:52 +02:00
imperosol 3f135777cd add show_my_timetable to VisibilityForm 2026-10-22 11:39:51 +02:00
imperosol 73ebe9c674 add timetable visibility to UserVisibilityForm 2026-10-22 11:39:51 +02:00
imperosol 816cbe3a2c api to save timetable 2026-10-22 11:39:51 +02:00
imperosol a33a4da143 add models 2026-10-22 11:39:51 +02:00
imperosol 2c77ed3c54 add tests 2026-10-22 11:25:40 +02:00
imperosol 3312a6214e move the club page edit link to the club main page 2026-10-22 10:07:26 +02:00
thomas girod 87533a7327 Merge pull request #1521 from ae-utbm/account-dump-msg
Add success message when refunding AE account
2026-10-21 16:35:05 +02:00
imperosol 2db46e3958 add success message when refunding AE account 2026-10-21 16:34:12 +02:00
thomas girod f2469873fd Merge pull request #1522 from ae-utbm/md-smallfix
fix: add back `.markdown` on EasyMde preview editor
2026-10-20 18:38:19 +02:00
imperosol 68c05b5b1d fix: add back .markdown on EasyMde preview editor
A l'origine, l'API retournait le markdown encapsulé dans une div avec la class `.markdown`. Quand on est passé sur notre propre lib WASM pour faire la preview entièrement côté front, on a oublié ce détail.

En même temps, on résout ici un petit glitch d'affichage qui fait qu'on avait le background des blocs de codes qui se mettait en dessous de notre propre style.
2026-10-20 12:52:20 +02:00
thomas girod 2d492e0157 Merge pull request #1511 from ae-utbm/counter-admin-mixin
remove `CounterAdminMixin`
2026-10-14 20:13:43 +02:00
thomas girod 67cfcb66b6 Merge pull request #1512 from ae-utbm/docs-populate
Small documentation fixes
2026-10-13 11:32:02 +02:00
thomas girod b560c6860b Merge pull request #1510 from ae-utbm/forum-message-403
short-circuit `ForumMessageView` redirection on 403
2026-10-13 00:12:07 +02:00
imperosol 15c5a37c99 docs: fix nushell scripts 2026-10-12 14:08:12 +02:00
imperosol 07648f596e docs: describe populated user accounts 2026-10-12 13:51:18 +02:00
imperosol 0c6594b3cd remove CounterAdminMixin 2026-10-09 21:59:37 +02:00
imperosol 22b510e25b short-circuit ForumMessageView redirection on 403
Quand on accède à cette vue, on est immédiatement redirigé vers le topic correspondant (quitte à se faire 403 une fois arrivé là). Comme la redirection est une opération qui a un cout, on peut gagner du temps en mettant un PermissionDenied directement (d'autant plus que les bots aiment beaucoup rechercher les messages du forum, et qu'ils se font systématiquement 403)
2026-10-09 16:09:54 +02:00
thomas girod c9137a2a67 Merge pull request #1508 from ae-utbm/update-deps
Update deps
2026-10-02 18:42:38 +02:00
imperosol 9f4b76dc1a update JS deps 2026-10-02 17:07:01 +02:00
imperosol ffc509c1a1 update python deps 2026-10-02 17:06:56 +02:00
thomas girod c2d76d025e Merge pull request #1485 from ae-utbm/ue-style
Ue style
2026-10-02 16:47:40 +02:00
imperosol 80f0517bf8 hide regular comment delete button when the ue comment is reported 2026-10-02 15:09:14 +02:00
imperosol 9924dc05d0 apply review comments 2026-10-02 15:09:14 +02:00
imperosol 614b870133 add translations 2026-10-02 15:09:14 +02:00
imperosol 8437728251 style: improve ue comments appearance 2026-10-02 15:09:14 +02:00
imperosol 8d3d5630c8 style: improve ue comment form appearance 2026-10-02 15:09:14 +02:00
imperosol 45cca918f1 style: improve ue detail infos appearance 2026-10-02 15:09:14 +02:00
thomas girod f211d0ff85 Merge pull request #1220 from ae-utbm/discord-auth
Third-party authentication
2026-10-02 14:55:47 +02:00
thomas girod 0bd557ae73 Merge pull request #1495 from ae-utbm/cotis-stat-quick-fix
Small improvements on subscription stats view
2026-10-02 13:49:01 +02:00
imperosol 3deefbe847 apply docs review comments 2026-10-02 09:48:23 +02:00
imperosol b2707b7862 fix: don't send callback request if data has been modified 2026-10-02 09:48:23 +02:00
imperosol 7a0f4217cc apply review comments 2026-10-02 09:48:23 +02:00
imperosol f067c60a8c tweak documentation 2026-10-02 09:48:23 +02:00
imperosol 5bea3f14e8 doc: third-party auth 2026-10-02 09:48:21 +02:00
imperosol 1efbf93f62 translation: third-party authentication 2026-10-02 09:48:09 +02:00
imperosol a84224c6ff write tests 2026-10-02 09:48:07 +02:00
imperosol 6ef0166540 third-party authentication views 2026-10-02 09:46:52 +02:00
imperosol e3a9e23d6a hmac_hexdigest util function 2026-10-01 10:17:06 +02:00
imperosol d6ab0e17f8 add hmac_key to ApiClient 2026-10-01 10:17:06 +02:00
imperosol 225b8cced9 move ResultConverter to core app 2026-10-01 10:17:06 +02:00
imperosol f271b9d1df feat: api route to get api client infos 2026-10-01 10:17:06 +02:00
thomas girod e39a73ae32 Merge pull request #1488 from ae-utbm/cgu-approve
Force CGU/ToS approval on account creation
2026-10-01 09:43:02 +02:00
thomas girod ad02026d63 Merge pull request #1506 from ae-utbm/news-next-date
display the date of next happening in NewsDetailView
2026-09-30 14:34:04 +02:00
imperosol a5440e3166 display the date of next happening in NewsDetailView 2026-09-28 19:32:40 +02:00
thomas girod 796ef72fa1 Merge pull request #1499 from ae-utbm/sas-replacestate
don't push state on sas image change
2026-09-28 17:33:46 +02:00
imperosol 44f9965557 show tooltips on subscription chart hover 2026-09-28 17:30:20 +02:00
imperosol 37871b386a optimize subscriptions stats 2026-09-28 17:30:20 +02:00
imperosol c8f4d12fc4 remove date selection from subscription stats view
Ca n'a jamais marché depuis 2017
2026-09-28 17:30:20 +02:00
imperosol 63764f54ba better subscription generation in populate_more 2026-09-28 17:30:20 +02:00
thomas girod 2d8f56f1f7 Merge pull request #1500 from ae-utbm/test-populate-more
test populate_more command
2026-09-28 16:22:02 +02:00
imperosol 809df909fd test populate_more command 2026-09-27 21:49:47 +02:00
imperosol ef1ff862a8 don't push state on sas image change 2026-09-27 19:26:02 +02:00
imperosol 19f0f23a05 use DatetimeField to store cgu approval
Au lieu de dire si l'utilisateur a approuvé les CGUs ou non, on dit quand est-ce qu'il les a approuvées. Si la date de la dernière approbation est antérieure à la date de la dernière révision des CGUs, alors on demande à nouveau de les approuver.
2026-09-25 15:43:36 +02:00
imperosol 6f701a9579 default cgu_approved=True for populated users 2026-09-25 13:21:19 +02:00
imperosol b607c86ab7 make CGU a Page instead of a File 2026-09-25 12:11:04 +02:00
imperosol 0c6a96a7ab add tests 2026-09-24 23:10:40 +02:00
imperosol c8813f708a make existing users approve CGU after login 2026-09-24 23:10:40 +02:00
imperosol 03514e4d97 refactor login.jinja 2026-09-24 23:10:40 +02:00
imperosol 4e3ed3212e force CGU/EULA approval on account creation 2026-09-24 23:10:40 +02:00
imperosol 5b02bc2e3b add CGU/EULA to populate command 2026-09-24 23:10:40 +02:00
133 changed files with 4737 additions and 3514 deletions

No files matched your search

+2 -2
View File
@@ -1,7 +1,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.16.0
rev: v0.17.2
hooks:
- id: ruff-check # just check the code, and print the errors
- id: ruff-check # actually fix the fixable errors, but print nothing
@@ -12,7 +12,7 @@ repos:
rev: v0.6.1
hooks:
- id: biome-check
additional_dependencies: ["@biomejs/biome@2.5.1"]
additional_dependencies: ["@biomejs/biome@2.6.3"]
- repo: https://github.com/rtts/djhtml
rev: 3.0.11
hooks:
+9
View File
@@ -21,6 +21,15 @@ class ApiClientAdmin(admin.ModelAdmin):
"owner__nick_name",
)
autocomplete_fields = ("owner", "groups", "client_permissions")
readonly_fields = ("hmac_key",)
actions = ("reset_hmac_key",)
@admin.action(permissions=["change"], description=_("Reset HMAC key"))
def reset_hmac_key(self, _request: HttpRequest, queryset: QuerySet[ApiClient]):
objs = list(queryset)
for obj in objs:
obj.reset_hmac(commit=False)
ApiClient.objects.bulk_update(objs, fields=["hmac_key"])
@admin.register(ApiKey)
+16
View File
@@ -0,0 +1,16 @@
from ninja_extra import ControllerBase, api_controller, route
from api.auth import ApiKeyAuth
from api.schemas import ApiClientSchema
@api_controller("/client")
class ApiClientController(ControllerBase):
@route.get(
"/me",
auth=[ApiKeyAuth()],
response=ApiClientSchema,
url_name="api-client-infos",
)
def get_client_info(self):
return self.context.request.auth
+35
View File
@@ -0,0 +1,35 @@
from django import forms
from django.forms import HiddenInput
from django.utils.translation import gettext_lazy as _
class ThirdPartyAuthForm(forms.Form):
"""Form to complete to authenticate on the sith from a third-party app.
For the form to be valid, the user approve the EULA (french: CGU)
and give its username from the third-party app.
"""
cgu_accepted = forms.BooleanField(
required=True,
label=_("I have read and I accept the terms and conditions of use"),
error_messages={
"required": _("You must approve the terms and conditions of use.")
},
)
is_username_valid = forms.BooleanField(
required=True,
error_messages={"required": _("You must confirm that this is your username.")},
)
client_id = forms.IntegerField(widget=HiddenInput())
third_party_app = forms.CharField(widget=HiddenInput())
privacy_link = forms.URLField(widget=HiddenInput())
username = forms.CharField(widget=HiddenInput())
callback_url = forms.URLField(widget=HiddenInput())
signature = forms.CharField(widget=HiddenInput())
def __init__(self, *args, label_suffix: str = "", initial, **kwargs):
super().__init__(*args, label_suffix=label_suffix, initial=initial, **kwargs)
self.fields["is_username_valid"].label = _(
"I confirm that %(username)s is my username on %(app)s"
) % {"username": initial.get("username"), "app": initial.get("third_party_app")}
+19
View File
@@ -0,0 +1,19 @@
# Generated by Django 5.2.3 on 2025-10-26 10:15
from django.db import migrations, models
import api.models
class Migration(migrations.Migration):
dependencies = [("api", "0001_initial")]
operations = [
migrations.AddField(
model_name="apiclient",
name="hmac_key",
field=models.CharField(
default=api.models.get_hmac_key, max_length=128, verbose_name="HMAC Key"
),
),
]
+33 -22
View File
@@ -1,13 +1,20 @@
import secrets
from typing import Iterable
from django.contrib.auth.models import Permission
from django.db import models
from django.db.models import Q
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext_lazy
from core.models import Group, User
def get_hmac_key():
return secrets.token_hex(64)
class ApiClient(models.Model):
name = models.CharField(_("name"), max_length=64)
owner = models.ForeignKey(
@@ -26,11 +33,10 @@ class ApiClient(models.Model):
help_text=_("Specific permissions for this api client."),
related_name="clients",
)
hmac_key = models.CharField(_("HMAC Key"), max_length=128, default=get_hmac_key)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
_perm_cache: set[str] | None = None
class Meta:
verbose_name = _("api client")
verbose_name_plural = _("api clients")
@@ -38,33 +44,38 @@ class ApiClient(models.Model):
def __str__(self):
return self.name
@cached_property
def all_permissions(self) -> set[str]:
permissions = (
Permission.objects.filter(
Q(group__group__in=self.groups.all()) | Q(clients=self)
)
.values_list("content_type__app_label", "codename")
.order_by()
)
return {f"{content_type}.{name}" for content_type, name in permissions}
def has_perm(self, perm: str):
"""Return True if the client has the specified permission."""
return perm in self.all_permissions
if self._perm_cache is None:
group_permissions = (
Permission.objects.filter(group__group__in=self.groups.all())
.values_list("content_type__app_label", "codename")
.order_by()
)
client_permissions = self.client_permissions.values_list(
"content_type__app_label", "codename"
).order_by()
self._perm_cache = {
f"{content_type}.{name}"
for content_type, name in (*group_permissions, *client_permissions)
}
return perm in self._perm_cache
def has_perms(self, perm_list):
"""
Return True if the client has each of the specified permissions. If
object is passed, check if the client has all required perms for it.
"""
def has_perms(self, perm_list: Iterable[str]) -> bool:
"""Return True if the client has each of the specified permissions."""
if not isinstance(perm_list, Iterable) or isinstance(perm_list, str):
raise ValueError("perm_list must be an iterable of permissions.")
return all(self.has_perm(perm) for perm in perm_list)
def reset_hmac(self, *, commit: bool = True) -> str:
"""Reset and return the HMAC key for this client.
Args:
commit: if True (the default), persist the new hmac in db.
"""
self.hmac_key = get_hmac_key()
if commit:
self.save()
return self.hmac_key
class ApiKey(models.Model):
PREFIX_LENGTH = 5
+23
View File
@@ -0,0 +1,23 @@
from ninja import ModelSchema, Schema
from pydantic import Field, HttpUrl
from api.models import ApiClient
from core.schemas import SimpleUserSchema
class ApiClientSchema(ModelSchema):
class Meta:
model = ApiClient
fields = ["id", "name"]
owner: SimpleUserSchema
permissions: list[str] = Field(alias="all_permissions")
class ThirdPartyAuthParamsSchema(Schema):
client_id: int
third_party_app: str
privacy_link: HttpUrl
username: str
callback_url: HttpUrl
signature: str
+32
View File
@@ -0,0 +1,32 @@
{% extends "core/base.jinja" %}
{% block content %}
<form method="post">
{% csrf_token %}
<h3>{% trans %}Confidentiality{% endtrans %}</h3>
<p>
{% trans trimmed app=third_party_app %}
By ticking this box and clicking on the send button, you
acknowledge and agree to provide {{ app }} with your
first name, last name, nickname and any other information
that was the third party app was explicitly authorized to fetch
and that it must have acknowledged to you, in a complete and accurate manner.
{% endtrans %}
</p>
<p class="margin-bottom">
{% trans trimmed app=third_party_app, privacy_link=third_party_cgu, sith_cgu_link=sith_cgu %}
The privacy policies of <a href="{{ privacy_link }}">{{ app }}</a>
and of <a href="{{ sith_cgu_link }}">the Students' Association</a>
applies as soon as the form is submitted.
{% endtrans %}
</p>
<div class="row">{{ form.cgu_accepted }} {{ form.cgu_accepted.label_tag() }}</div>
<br>
<h3 class="margin-bottom">{% trans %}Confirmation of identity{% endtrans %}</h3>
<div class="row margin-bottom">
{{ form.is_username_valid }} {{ form.is_username_valid.label_tag() }}
</div>
{% for field in form.hidden_fields() %}{{ field }}{% endfor %}
<input type="submit" class="btn btn-blue">
</form>
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
import pytest
from django.contrib.admin import AdminSite
from django.http import HttpRequest
from model_bakery import baker
from pytest_django.asserts import assertNumQueries
from api.admin import ApiClientAdmin
from api.models import ApiClient
@pytest.mark.django_db
def test_reset_hmac_action():
client_admin = ApiClientAdmin(ApiClient, AdminSite())
api_clients = baker.make(ApiClient, _quantity=4, _bulk_create=True)
old_hmac_keys = [c.hmac_key for c in api_clients]
with assertNumQueries(2):
qs = ApiClient.objects.filter(id__in=[c.id for c in api_clients[2:4]])
client_admin.reset_hmac_key(HttpRequest(), qs)
for c in api_clients:
c.refresh_from_db()
assert api_clients[0].hmac_key == old_hmac_keys[0]
assert api_clients[1].hmac_key == old_hmac_keys[1]
assert api_clients[2].hmac_key != old_hmac_keys[2]
assert api_clients[3].hmac_key != old_hmac_keys[3]
+22
View File
@@ -0,0 +1,22 @@
from typing import TYPE_CHECKING
import pytest
from django.urls import reverse
from model_bakery import baker
from api.hashers import generate_key
from api.models import ApiClient, ApiKey
from api.schemas import ApiClientSchema
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
def test_api_client_controller(client: Client):
key, hashed = generate_key()
api_client = baker.make(ApiClient)
baker.make(ApiKey, client=api_client, hashed_key=hashed)
res = client.get(reverse("api:api-client-infos"), headers={"X-APIKey": key})
assert res.status_code == 200
assert res.json() == ApiClientSchema.from_orm(api_client).model_dump()
+59
View File
@@ -0,0 +1,59 @@
import pytest
from django.contrib.auth.models import Permission
from django.test import TestCase
from model_bakery import baker
from api.models import ApiClient
from core.models import Group
class TestClientPermissions(TestCase):
@classmethod
def setUpTestData(cls):
cls.api_client = baker.make(ApiClient)
cls.perms = baker.make(Permission, _quantity=10, _bulk_create=True)
cls.api_client.groups.set(
[
baker.make(Group, permissions=cls.perms[0:3]),
baker.make(Group, permissions=cls.perms[3:5]),
]
)
cls.api_client.client_permissions.set(
[cls.perms[3], cls.perms[5], cls.perms[6], cls.perms[7]]
)
def test_all_permissions(self):
assert self.api_client.all_permissions == {
f"{p.content_type.app_label}.{p.codename}" for p in self.perms[0:8]
}
def test_has_perm(self):
assert self.api_client.has_perm(
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}"
)
assert not self.api_client.has_perm(
f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}"
)
def test_has_perms(self):
assert self.api_client.has_perms(
[
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}",
f"{self.perms[2].content_type.app_label}.{self.perms[2].codename}",
]
)
assert not self.api_client.has_perms(
[
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}",
f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}",
],
)
@pytest.mark.django_db
def test_reset_hmac_key():
client = baker.make(ApiClient)
original_key = client.hmac_key
client.reset_hmac(commit=True)
assert len(client.hmac_key) == len(original_key)
assert client.hmac_key != original_key
+140
View File
@@ -0,0 +1,140 @@
from unittest import mock
from unittest.mock import Mock
from django.contrib.messages import Message, get_messages
from django.db.models import Max
from django.test import TestCase
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from api.models import ApiClient, get_hmac_key
from core.baker_recipes import subscriber_user
from core.schemas import UserProfileSchema
from core.utils import hmac_hexdigest
def mocked_post(*, ok: bool):
class MockedResponse(Mock):
@property
def ok(self):
return ok
def mocked():
return MockedResponse()
return mocked
class TestThirdPartyAuth(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = subscriber_user.make()
cls.api_client = baker.make(ApiClient)
def setUp(self):
self.query = {
"client_id": self.api_client.id,
"third_party_app": "app",
"privacy_link": "https://foobar.fr/",
"username": "bibou",
"callback_url": "https://callback.fr/",
}
self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query)
self.callback_data = {
"user": UserProfileSchema.from_orm(self.user).model_dump()
}
self.callback_data["signature"] = hmac_hexdigest(
self.api_client.hmac_key, self.callback_data["user"]
)
self.url = reverse("api-link:third-party-auth", query=self.query)
def test_auth_ok(self):
self.client.force_login(self.user)
res = self.client.get(self.url)
assert res.status_code == 200
with mock.patch("requests.post", new_callable=mocked_post(ok=True)) as mocked:
res = self.client.post(
self.url,
data={"cgu_accepted": True, "is_username_valid": True, **self.query},
)
mocked.assert_called_once_with(
self.query["callback_url"], json=self.callback_data
)
assertRedirects(
res,
reverse("api-link:third-party-auth-result", kwargs={"result": "success"}),
)
def test_callback_error(self):
"""Test that the user see the failure page if the callback request failed."""
self.client.force_login(self.user)
with mock.patch("requests.post", new_callable=mocked_post(ok=False)) as mocked:
res = self.client.post(
self.url,
data={"cgu_accepted": True, "is_username_valid": True, **self.query},
)
mocked.assert_called_once_with(
self.query["callback_url"], json=self.callback_data
)
assertRedirects(
res,
reverse("api-link:third-party-auth-result", kwargs={"result": "failure"}),
)
def test_wrong_signature(self):
"""Test that a 403 is raised if the signature of the query is wrong."""
self.client.force_login(subscriber_user.make())
new_key = get_hmac_key()
del self.query["signature"]
self.query["signature"] = hmac_hexdigest(new_key, self.query)
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert list(get_messages(res.wsgi_request)) == [
Message(
level=40,
message=(
"La signature est incorrecte. "
"Nous ne pouvons pas garantir l'authenticité de la requête."
),
)
]
res = self.client.post(self.url, data=self.query)
assert res.status_code == 200
def test_cgu_not_accepted(self):
self.client.force_login(self.user)
res = self.client.get(self.url)
assert res.status_code == 200
res = self.client.post(self.url, data=self.query)
assert res.status_code == 200 # no redirect means invalid form
res = self.client.post(
self.url,
data={"cgu_accepted": False, "is_username_valid": False, **self.query},
)
assert res.status_code == 200
def test_invalid_client(self):
self.client.force_login(self.user)
self.query["client_id"] = ApiClient.objects.aggregate(res=Max("id"))["res"] + 1
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert list(get_messages(res.wsgi_request)) == [
Message(
level=40,
message="Les données fournies pour l'authentification sont incorrectes.",
)
]
def test_missing_parameter(self):
self.client.force_login(self.user)
del self.query["username"]
self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query)
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert list(get_messages(res.wsgi_request)) == [
Message(
level=40,
message="Les données fournies pour l'authentification sont incorrectes.",
)
]
res = self.client.post(self.url, data=self.query)
assert res.status_code == 200
+15
View File
@@ -1,6 +1,10 @@
from django.urls import path, register_converter
from ninja.security import SessionAuth
from ninja_extra import NinjaExtraAPI
from api.views import ThirdPartyAuthResultView, ThirdPartyAuthView
from core.converters import ResultConverter
api = NinjaExtraAPI(
title="PICON",
description="Portail Interactif de Communication avec les Outils Numériques",
@@ -9,3 +13,14 @@ api = NinjaExtraAPI(
auth=[SessionAuth()],
)
api.auto_discover_controllers()
register_converter(ResultConverter, "res")
urlpatterns = [
path("auth/", ThirdPartyAuthView.as_view(), name="third-party-auth"),
path(
"auth/<res:result>/",
ThirdPartyAuthResultView.as_view(),
name="third-party-auth-result",
),
]
+146
View File
@@ -0,0 +1,146 @@
import hmac
from urllib.parse import unquote
import pydantic
import requests
import sentry_sdk
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import AccessMixin, LoginRequiredMixin
from django.shortcuts import render
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import FormView, TemplateView
from ninja_extra.shortcuts import get_object_or_none
from api.forms import ThirdPartyAuthForm
from api.models import ApiClient
from api.schemas import ThirdPartyAuthParamsSchema
from core.models import Page
from core.schemas import UserProfileSchema
from core.utils import hmac_hexdigest
class ThirdPartyAuthView(AccessMixin, FormView):
form_class = ThirdPartyAuthForm
template_name = "api/third_party/auth.jinja"
success_url = reverse_lazy("core:index")
def parse_params(self) -> ThirdPartyAuthParamsSchema | None:
"""Parse and check the authentication parameters.
If parsing fails, messages will be created using the django message
infrastructure.
Returns:
The parses parameters, or None if the parsing failed.
"""
# This is here rather than in ThirdPartyAuthForm because
# the given parameters and their signature are checked during both
# POST (for obvious reasons) and GET (in order not to make
# the user fill a form just to get an error he won't understand)
params = self.request.GET if self.request.method == "GET" else self.request.POST
params = {key: unquote(val) for key, val in params.dict().items()}
try:
params = ThirdPartyAuthParamsSchema(**params)
except pydantic.ValidationError:
messages.error(
self.request, _("The data provided for authentication is incorrect")
)
return None
client: ApiClient | None = get_object_or_none(ApiClient, id=params.client_id)
if not client:
messages.error(
self.request, _("The data provided for authentication is incorrect")
)
return None
if not hmac.compare_digest(
hmac_hexdigest(client.hmac_key, params.model_dump(exclude={"signature"})),
params.signature,
):
messages.error(
self.request,
_(
"The signature is incorrect. "
"We cannot ensure the provenance of the request."
),
)
return None
return params
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
if (params := self.parse_params()) is None:
# if parameters parsing failed, shortcut the operation and display
# an empty page with just the error messages.
return render(request, "core/base.jinja")
self.params = params
return super().dispatch(request, *args, **kwargs)
def get(self, *args, **kwargs):
messages.warning(
self.request,
_(
"You are going to link your AE account and your %(app)s account. "
"Continue only if this page was opened from %(app)s."
)
% {"app": self.params.third_party_app},
)
return super().get(*args, **kwargs)
def get_initial(self):
return self.params.model_dump()
def form_valid(self, form):
client = ApiClient.objects.get(id=form.cleaned_data["client_id"])
user = UserProfileSchema.from_orm(self.request.user).model_dump()
data = {"user": user, "signature": hmac_hexdigest(client.hmac_key, user)}
try:
ok = requests.post(form.cleaned_data["callback_url"], json=data).ok
except requests.RequestException as e:
sentry_sdk.capture_exception(e)
ok = False
self.success_url = reverse(
"api-link:third-party-auth-result",
kwargs={"result": "success" if ok else "failure"},
)
return super().form_valid(form)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"third_party_app": self.params.third_party_app,
"third_party_cgu": self.params.privacy_link,
"sith_cgu": Page.objects.get(_full_name=settings.SITH_CGU_PAGE),
}
class ThirdPartyAuthResultView(LoginRequiredMixin, TemplateView):
"""View that the user will see if its authentication on sith was successful.
This can show either a success or a failure message :
- success : everything is good, the user is successfully authenticated
and can close the page
- failure : the authentication has been processed on the sith side,
but the request to the callback url received an error.
In such a case, there is nothing much we can do but to advice
the user to contact the developers of the third-party app.
"""
template_name = "core/base.jinja"
success_message = _(
"You have been successfully authenticated. You can now close this page."
)
error_message = _(
"Your authentication on the AE website was successful, "
"but an error happened during the interaction "
"with the third-party application. "
"Please contact the managers of the latter."
)
def get(self, request, *args, **kwargs):
if self.kwargs.get("result") == "success":
messages.success(request, self.success_message)
else:
messages.error(request, self.error_message)
return super().get(request, *args, **kwargs)
+2 -113
View File
@@ -31,20 +31,10 @@ from django.forms.models import ModelChoiceField, ModelChoiceIterator
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from club.models import (
Club,
ClubLink,
ClubRole,
Mailing,
MailingSubscription,
Membership,
)
from club.models import Club, ClubLink, ClubRole, Membership
from core.models import User
from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import (
AutoCompleteSelectMultipleUser,
AutoCompleteSelectUser,
)
from core.views.widgets.ajax_select import AutoCompleteSelectUser
from counter.models import Counter, Selling
from counter.schemas import SaleFilterSchema
@@ -131,107 +121,6 @@ class ClubAdminEditForm(ClubEditForm):
fields = ["name", "parent", "is_active", *ClubEditForm.Meta.fields]
class MailingForm(forms.Form):
"""Form handling mailing lists right."""
ACTION_NEW_MAILING = 1
ACTION_NEW_SUBSCRIPTION = 2
ACTION_REMOVE_SUBSCRIPTION = 3
subscription_users = forms.ModelMultipleChoiceField(
label=_("Users to add"),
help_text=_("Search users to add (one or more)."),
required=False,
widget=AutoCompleteSelectMultipleUser,
queryset=User.objects.all(),
)
def __init__(self, club_id, user_id, mailings, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["action"] = forms.TypedChoiceField(
choices=(
(self.ACTION_NEW_MAILING, _("New Mailing")),
(self.ACTION_NEW_SUBSCRIPTION, _("Subscribe")),
(self.ACTION_REMOVE_SUBSCRIPTION, _("Remove")),
),
coerce=int,
label=_("Action"),
initial=1,
required=True,
widget=forms.HiddenInput(),
)
# Generate bulk removal forms, they are never required
for mailing in mailings:
self.fields["removal_" + str(mailing.id)] = forms.ModelMultipleChoiceField(
mailing.subscriptions.all(),
label=_("Remove"),
required=False,
widget=forms.CheckboxSelectMultiple,
)
# Include fields for handling mailing creation
mailing_fields = ("email",)
self.fields.update(forms.fields_for_model(Mailing, fields=mailing_fields))
for field in mailing_fields:
self.fields["mailing_" + field] = self.fields.pop(field)
self.fields["mailing_" + field].required = False
# Include fields for handling subscription creation
subscription_fields = ("mailing", "email")
self.fields.update(
forms.fields_for_model(MailingSubscription, fields=subscription_fields)
)
for field in subscription_fields:
self.fields["subscription_" + field] = self.fields.pop(field)
self.fields["subscription_" + field].required = False
self.fields["subscription_mailing"].queryset = Mailing.objects.filter(
club__id=club_id, is_moderated=True
)
def check_required(self, cleaned_data, field):
"""If the given field doesn't exist or has no value, add a required error on it."""
if not cleaned_data.get(field, None):
self.add_error(field, _("This field is required"))
def clean_subscription_users(self):
"""Convert given users into real users and check their validity."""
cleaned_data = super().clean()
users = []
for user in cleaned_data["subscription_users"]:
if not user.email:
raise forms.ValidationError(
_("One of the selected users doesn't have an email address"),
code="invalid",
)
users.append(user)
return users
def clean(self):
cleaned_data = super().clean()
if "action" not in cleaned_data:
# If there is no action provided, we can stop here
raise forms.ValidationError(_("An action is required"), code="invalid")
if cleaned_data["action"] == self.ACTION_NEW_MAILING:
self.check_required(cleaned_data, "mailing_email")
if cleaned_data["action"] == self.ACTION_NEW_SUBSCRIPTION:
self.check_required(cleaned_data, "subscription_mailing")
if not cleaned_data.get(
"subscription_users", None
) and not cleaned_data.get("subscription_email", None):
raise forms.ValidationError(
_("You must specify at least an user or an email address"),
code="invalid",
)
return cleaned_data
class SellingsForm(forms.Form):
begin_date = forms.DateTimeField(
label=_("Begin date"), widget=SelectDateTime, required=False
@@ -0,0 +1,16 @@
# Generated by Django 5.2.17 on 2026-10-15 13:13
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("club", "0018_clubrole_linked_groups")]
operations = [
migrations.AlterUniqueTogether(
name="mailingsubscription", unique_together=None
),
migrations.RemoveField(model_name="mailingsubscription", name="user"),
migrations.DeleteModel(name="Mailing"),
migrations.DeleteModel(name="MailingSubscription"),
]
+3 -167
View File
@@ -28,8 +28,7 @@ from functools import reduce
from typing import Iterable, Self
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email
from django.core.exceptions import ValidationError
from django.db import ProgrammingError, models, transaction
from django.db.models import Exists, F, OuterRef, Q
from django.urls import reverse
@@ -41,7 +40,7 @@ from django.utils.translation import gettext_lazy as _
from ordered_model.models import OrderedModel
from core.fields import ResizedImageField
from core.models import Group, Notification, Page, SithFile, User
from core.models import Group, Page, SithFile, User
class ClubQuerySet(models.QuerySet):
@@ -238,7 +237,7 @@ class Club(models.Model):
def can_be_edited_by(self, user: User) -> bool:
"""Method to see if that object can be edited by the given user."""
return self.has_rights_in_club(user)
return user.has_perm("club.change_club") or self.has_rights_in_club(user)
def can_roles_be_edited_by(self, user: User) -> bool:
"""Return True if the given user can edit the roles of this club"""
@@ -633,169 +632,6 @@ class Membership(models.Model):
return User.groups.through.objects.bulk_create(groups, ignore_conflicts=True)
class Mailing(models.Model):
"""A Mailing list for a club.
Warning:
Remember that mailing lists should be validated by UTBM.
"""
club = models.ForeignKey(
Club,
verbose_name=_("Club"),
related_name="mailings",
null=False,
blank=False,
on_delete=models.CASCADE,
)
email = models.CharField(
_("Email address"),
unique=True,
null=False,
blank=False,
max_length=256,
validators=[
RegexValidator(
validate_email.user_regex,
_("Enter a valid address. Only the root of the address is needed."),
)
],
)
is_moderated = models.BooleanField(_("is moderated"), default=False)
moderator = models.ForeignKey(
User,
related_name="moderated_mailings",
verbose_name=_("moderator"),
null=True,
on_delete=models.CASCADE,
)
def __str__(self):
return "%s - %s" % (self.club, self.email_full)
def save(self, *args, **kwargs):
if not self.is_moderated:
unread_notif_subquery = Notification.objects.filter(
user=OuterRef("pk"), type="MAILING_MODERATION", viewed=False
)
for user in User.objects.filter(
~Exists(unread_notif_subquery),
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID],
):
Notification(
user=user,
url=reverse("com:mailing_admin"),
type="MAILING_MODERATION",
).save(*args, **kwargs)
super().save(*args, **kwargs)
def clean(self):
if Mailing.objects.filter(email=self.email).exists():
raise ValidationError(_("This mailing list already exists."))
if self.can_moderate(self.moderator):
self.is_moderated = True
else:
self.moderator = None
super().clean()
@property
def email_full(self):
return self.email + "@" + settings.SITH_MAILING_DOMAIN
def can_moderate(self, user):
return user.is_root or user.is_com_admin
def is_owned_by(self, user):
if user.is_anonymous:
return False
return user.is_root or user.is_com_admin
def can_view(self, user):
return self.club.has_rights_in_club(user)
def can_be_edited_by(self, user):
return self.club.has_rights_in_club(user)
def delete(self, *args, **kwargs):
self.subscriptions.all().delete()
super().delete()
def fetch_format(self):
destination = "".join(s.fetch_format() for s in self.subscriptions.all())
return f"{self.email}: {destination}"
class MailingSubscription(models.Model):
"""Link between user and mailing list."""
mailing = models.ForeignKey(
Mailing,
verbose_name=_("Mailing"),
related_name="subscriptions",
null=False,
blank=False,
on_delete=models.CASCADE,
)
user = models.ForeignKey(
User,
verbose_name=_("User"),
related_name="mailing_subscriptions",
null=True,
blank=True,
on_delete=models.CASCADE,
)
email = models.EmailField(_("Email address"), blank=False, null=False)
class Meta:
unique_together = (("user", "email", "mailing"),)
def __str__(self):
return "(%s) - %s : %s" % (self.mailing, self.get_username, self.email)
def clean(self):
if not self.user and not self.email:
raise ValidationError(_("At least user or email is required"))
try:
if self.user and not self.email:
self.email = self.user.email
if MailingSubscription.objects.filter(
mailing=self.mailing, email=self.email
).exists():
raise ValidationError(
_("This email is already suscribed in this mailing")
)
except ObjectDoesNotExist:
pass
super().clean()
def is_owned_by(self, user):
if user.is_anonymous:
return False
return (
self.mailing.club.has_rights_in_club(user)
or user.is_root
or self.user.is_com_admin
)
def can_be_edited_by(self, user):
return self.user is not None and user.id == self.user.id
@cached_property
def get_email(self):
if self.user and not self.email:
return self.user.email
return self.email
@cached_property
def get_username(self):
if self.user:
return str(self.user)
return _("Unregistered user")
def fetch_format(self):
return self.get_email + " "
class LinkType(models.Model):
"""A link type, in order to group links and give them icons.
+9
View File
@@ -26,6 +26,15 @@
{% endblock %}
{% block content %}
{% if user.is_authenticated and user.can_edit(club) %}
<a
href="{{ url("core:page_edit", page_name=club.page.get_full_name()) }}"
class="btn btn-blue margin-bottom"
>
<i class="fa fa-pen-to-square"></i> {% trans %}Edit{% endtrans %}
</a>
{% endif %}
<h3>{{ club.name }}</h3>
<div id="club-detail" {% if links %}class="has-links"{% endif %}>
<div id="club-attributes">
+79
View File
@@ -0,0 +1,79 @@
{% extends "core/base.jinja" %}
{% block content %}
{% if club.has_rights_in_club(user) %}
<a class="btn btn-blue" href="{{ url("com:news_new") }}">
<i class="fa fa-plus"></i> {% trans %}Create news{% endtrans %}
</a>
{% endif %}
{% for semester_code, semester_events in events %}
<h3>{{ semester_code }}</h3>
{% for event in semester_events %}
<details class="accordion">
<summary>{{ event.title }}</summary>
<div class="accordion-content">
{# Events that occur weekly from the start to the end of the semester
would take a lot of space if shown by default.
Thus, if the event has more than one date, those are
hidden by a details element.
If there is only one date, it is directly displayed. #}
{% set dates = event.dates.all()|list %}
{% if dates|length == 1 %}
<em>
<time datetime="{{ dates[0].start_date.isoformat(timespec="seconds") }}">
{{ dates[0].start_date|localtime|date(DATETIME_FORMAT) }},
{{ dates[0].start_date|localtime|time(DATETIME_FORMAT) }}
</time> -
<time datetime="{{ dates[0].end_date.isoformat(timespec="seconds") }}">
{{ dates[0].end_date|localtime|date(DATETIME_FORMAT) }},
{{ dates[0].end_date|localtime|time(DATETIME_FORMAT) }}
</time>
</em>
{% else %}
<details>
<summary>
{% trans trimmed nb_occurrences=dates|length %}
Dates ({{ nb_occurrences }} occurrences)
{% endtrans %}
</summary>
<ul>
{% for d in dates %}
<li>
<time datetime="{{ d.start_date.isoformat(timespec="seconds") }}">
{{ d.start_date|localtime|date(DATETIME_FORMAT) }},
{{ d.start_date|localtime|time(DATETIME_FORMAT) }}
</time> -
<time datetime="{{ d.end_date.isoformat(timespec="seconds") }}">
{{ d.end_date|localtime|date(DATETIME_FORMAT) }},
{{ d.end_date|localtime|time(DATETIME_FORMAT) }}
</time>
</li>
{% endfor %}
</ul>
</details>
{% endif %}
<h4>{% trans %}Permalink{% endtrans %}</h4>
<p>{{ request.build_absolute_uri(event.get_absolute_url())|urlize }}</p>
<h4>{% trans %}Summary{% endtrans %}</h4>
{% if event.summary %}
{{ event.summary|markdown }}
{% else %}
<p><em>{% trans %}No summary{% endtrans %}</em></p>
{% endif %}
<h4>{% trans %}Description{% endtrans %}</h4>
{% if event.content %}
{{ event.content|markdown }}
{% else %}
<p><em>{% trans %}No detailed description{% endtrans %}</em></p>
{% endif %}
</div>
</details>
{% endfor %}
<br />
{% endfor %}
{% endblock %}
-3
View File
@@ -15,9 +15,6 @@
{% trans %}Post in the Weekmail{% endtrans %}
</a>
</li>
{% if object.can_roles_be_edited_by(user) %}
<li><a href="{{ url("club:club_roles", club_id=object.id) }}"></a></li>
{% endif %}
{% if object.trombi %}
<li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li>
{% else %}
-108
View File
@@ -1,108 +0,0 @@
{% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import select_all_checkbox %}
{% block title %}
{% trans %}Mailing lists{% endtrans %}
{% endblock %}
{% block content %}
<b>{% trans %}Remember : mailing lists need to be moderated, if your new created list is not shown wait until moderation takes action{% endtrans %}</b>
{% if mailings_not_moderated %}
<p>{% trans %}Mailing lists waiting for moderation{% endtrans %}</p>
<ul>
{% for mailing in mailings_not_moderated %}
<li>{{ mailing.email_full }}<a href="{{ url('club:mailing_delete', mailing_id=mailing.id) }}"> - {% trans %}Delete{% endtrans %}</a></li>
{% endfor %}
</ul>
{% endif %}
{% if mailings_moderated %}
{% for mailing in mailings_moderated %}
<h2>{% trans %}Mailing{% endtrans %} {{ mailing.email_full }}
{%- if user.is_owner(mailing) -%}
<a href="{{ url('club:mailing_delete', mailing_id=mailing.id) }}"> - {% trans %}Delete{% endtrans %}</a>
{%- endif -%}
</h2>
<form method="GET" action="{{ url('club:mailing_generate', mailing_id=mailing.id) }}" style="display:inline-block;">
<input type="submit" name="generateMalingList" value="{% trans %}Generate mailing list{% endtrans %}">
</form>
{% set form_mailing_removal = form["removal_" + mailing.id|string] %}
{% if form_mailing_removal.field.choices %}
{% set ms = dict(mailing.subscriptions.all() | groupby('id')) %}
<form action="{{ url('club:mailing', club_id=club.id) }}" id="{{ form_mailing_removal.auto_id }}" method="post" enctype="multipart/form-data">
<p style="margin-bottom: 1em;">{{ select_all_checkbox(form_mailing_removal.auto_id) }}</p>
{% csrf_token %}
<input hidden type="number" name="{{ form.action.name }}" value="{{ form_actions.REMOVE_SUBSCRIPTION }}" />
<table>
<thead>
<tr>
<td>{% trans %}User{% endtrans %}</td>
<td>{% trans %}Email{% endtrans %}</td>
<td>{% trans %}Delete{% endtrans %}</td>
</tr>
</thead>
<tbody>
{% for widget in form_mailing_removal.subwidgets %}
{% set user = ms[widget.data.value.value][0] %}
<tr>
<td>{{ user.get_username }}</td>
<td>{{ user.get_email }}</td>
<td>{{ widget.tag() }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ form_mailing_removal.errors }}
<p><input type="submit" value="{% trans %}Remove from mailing list{% endtrans %}" /></p>
</form>
{% else %}
<p><b>{% trans %}There is no subscriber for this mailing list{% endtrans %}</b></p>
{% endif %}
{% endfor %}
{% else %}
<p>{% trans %}No mailing list existing for this club{% endtrans %}</p>
{% endif %}
<p>{{ form.non_field_errors() }}</p>
{% if mailings_moderated %}
<h2>{% trans %}New member{% endtrans %}</h2>
<form action="{{ url('club:mailing', club_id=club.id) }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<p>
{{ form.subscription_mailing.errors }}
<label for="{{ form.subscription_mailing.id_for_label }}">{{ form.subscription_mailing.label }}</label>
{{ form.subscription_mailing }}
</p>
<p>
{{ form.subscription_users.errors }}
<label for="{{ form.subscription_users.id_for_label }}">{{ form.subscription_users.label }}</label>
{{ form.subscription_users }}
<span class="helptext">{{ form.subscription_users.help_text }}</span>
</p>
<p>
{{ form.subscription_email.errors }}
<label for="{{ form.subscription_email.id_for_label }}">{{ form.subscription_email.label }}</label>
{{ form.subscription_email }}
</p>
<input hidden type="number" name="{{ form.action.name }}" value="{{ form_actions.NEW_SUBSCRIPTION }}" />
<p><input type="submit" value="{% trans %}Add to mailing list{% endtrans %}" /></p>
</form>
{% endif %}
<h2>{% trans %}New mailing{% endtrans %}</h2>
<form action="{{ url('club:mailing', club_id=club.id) }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<p>
{{ form.mailing_email.errors }}
<label for="{{ form.mailing_email.id_for_label }}">{{ form.mailing_email.label }}</label>
{{ form.mailing_email }}
</p>
<input hidden type="number" name="{{ form.action.name }}" value="{{ form_actions.NEW_MALING }}" />
<p><input type="submit" value="{% trans %}Create mailing list{% endtrans %}" /></p>
</form>
{% endblock %}
+60
View File
@@ -0,0 +1,60 @@
from datetime import datetime
from django.test import RequestFactory, TestCase
from django.urls import reverse
from django.utils.timezone import get_current_timezone
from model_bakery import baker
from club.models import Club
from club.views import ClubNewsView
from com.models import News, NewsDate
from core.baker_recipes import subscriber_user
from core.models import User
class TestClubNews(TestCase):
@classmethod
def setUpTestData(cls):
cls.club = baker.make(Club)
cls.news = baker.make(
News, club=cls.club, is_published=True, author=baker.make(User), _quantity=5
)
d = datetime(year=2025, month=10, day=1, tzinfo=get_current_timezone())
dates = [
baker.prepare(NewsDate, news=cls.news[0], start_date=d),
baker.prepare(NewsDate, news=cls.news[0], start_date=d.replace(day=8)),
baker.prepare(NewsDate, news=cls.news[0], start_date=d.replace(day=15)),
baker.prepare(NewsDate, news=cls.news[1], start_date=d.replace(month=5)),
baker.prepare(NewsDate, news=cls.news[2], start_date=d.replace(month=4)),
baker.prepare(NewsDate, news=cls.news[3], start_date=d.replace(year=2024)),
baker.prepare(NewsDate, news=cls.news[4], start_date=d.replace(year=2020)),
]
NewsDate.objects.bulk_create(dates)
cls.url = reverse("club:news_list", kwargs={"club_id": cls.club.id})
def test_load_page(self):
"""Just test that the page loads."""
self.client.force_login(subscriber_user.make())
res = self.client.get(self.url)
assert res.status_code == 200
def test_page_data(self):
"""Test that we display the good events."""
# For this test, the object we want to test
# is built with `itertools.groupby`.
# However, it is consumed when rendering the template,
# making it unusable for later testing purposes,
# so we must use lower level django APIs in this test.
request = RequestFactory().get(self.url)
request.user = subscriber_user.make()
response = ClubNewsView.as_view()(request, club_id=self.club.id)
events = [
(semester_code, list(dates))
for semester_code, dates in response.context_data["events"]
]
assert events == [
("A25", [self.news[0]]),
("P25", [self.news[1], self.news[2]]),
("A24", [self.news[3]]),
("A20", [self.news[4]]),
]
-328
View File
@@ -1,328 +0,0 @@
from django.conf import settings
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
from model_bakery import baker
from club.forms import MailingForm
from club.models import Club, ClubRole, Mailing, Membership
from core.models import User
class TestMailingForm(TestCase):
"""Perform validation tests for MailingForm."""
@classmethod
def setUpTestData(cls):
cls.skia = User.objects.get(username="skia")
cls.rbatsbak = User.objects.get(username="rbatsbak")
cls.krophil = User.objects.get(username="krophil")
cls.comunity = User.objects.get(username="comunity")
cls.root = User.objects.get(username="root")
cls.club = Club.objects.get(id=settings.SITH_PDF_CLUB_ID)
cls.mail_url = reverse("club:mailing", kwargs={"club_id": cls.club.id})
Membership(
user=cls.rbatsbak,
club=cls.club,
start_date=timezone.now(),
role=baker.make(ClubRole, club=cls.club, is_board=True),
).save()
def test_mailing_list_add_no_moderation(self):
# Test with Communication admin
self.client.force_login(self.comunity)
response = self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "foyer"},
)
self.assertRedirects(response, self.mail_url)
response = self.client.get(self.mail_url)
assert response.status_code == 200
assert "Liste de diffusion foyer@utbm.fr" in response.text
# Test with Root
self.client.force_login(self.root)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
assert "Liste de diffusion mde@utbm.fr" in response.text
def test_mailing_list_add_moderation(self):
self.client.force_login(self.rbatsbak)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "Liste de diffusion mde@utbm.fr" not in content
assert "<p>Listes de diffusions en attente de modération</p>" in content
assert "<li>mde@utbm.fr" in content
def test_mailing_list_forbidden(self):
# With anonymous user
response = self.client.get(self.mail_url)
self.assertContains(response, "", status_code=403)
# With user not in club
self.client.force_login(self.krophil)
response = self.client.get(self.mail_url)
assert response.status_code == 403
def test_add_new_subscription_fail_not_moderated(self):
self.client.force_login(self.rbatsbak)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.skia.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
assert "skia@git.an" not in response.text
def test_add_new_subscription_success(self):
# Prepare mailing list
self.client.force_login(self.comunity)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
# Add single user
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.skia.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
assert "skia@git.an" in response.text
# Add multiple users
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": (self.comunity.id, self.rbatsbak.id),
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "richard@git.an" in content
assert "comunity@git.an" in content
assert "skia@git.an" in content
# Add arbitrary email
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_email": "arbitrary@git.an",
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "richard@git.an" in content
assert "comunity@git.an" in content
assert "skia@git.an" in content
assert "arbitrary@git.an" in content
# Add user and arbitrary email
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_email": "more.arbitrary@git.an",
"subscription_users": self.krophil.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "richard@git.an" in content
assert "comunity@git.an" in content
assert "skia@git.an" in content
assert "arbitrary@git.an" in content
assert "more.arbitrary@git.an" in content
assert "krophil@git.an" in content
def test_add_new_subscription_fail_form_errors(self):
# Prepare mailing list
self.client.force_login(self.comunity)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
# Neither email or email is specified
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
assert response.status_code
self.assertInHTML(
_("You must specify at least an user or an email address"),
response.text,
)
# No mailing specified
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
},
)
assert response.status_code == 200
assert _("This field is required") in response.text
# One of the selected users doesn't exist
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": [789],
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
assert response.status_code == 200
self.assertInHTML(
_("You must specify at least an user or an email address"),
response.text,
)
# An user has no email address
self.krophil.email = ""
self.krophil.save()
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
assert response.status_code == 200
self.assertInHTML(
_("One of the selected users doesn't have an email address"),
response.text,
)
self.krophil.email = "krophil@git.an"
self.krophil.save()
# An user is added twice
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
response = self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": self.krophil.id,
"subscription_mailing": Mailing.objects.get(email="mde").id,
},
)
assert response.status_code == 200
self.assertInHTML(
_("This email is already suscribed in this mailing"),
response.text,
)
def test_remove_subscription_success(self):
# Prepare mailing list
self.client.force_login(self.comunity)
self.client.post(
self.mail_url,
{"action": MailingForm.ACTION_NEW_MAILING, "mailing_email": "mde"},
)
mde = Mailing.objects.get(email="mde")
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_NEW_SUBSCRIPTION,
"subscription_users": (
self.comunity.id,
self.rbatsbak.id,
self.krophil.id,
),
"subscription_mailing": mde.id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "comunity@git.an" in content
assert "richard@git.an" in content
assert "krophil@git.an" in content
# Delete one user
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_REMOVE_SUBSCRIPTION,
"removal_%d" % mde.id: mde.subscriptions.get(user=self.krophil).id,
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "comunity@git.an" in content
assert "richard@git.an" in content
assert "krophil@git.an" not in content
# Delete multiple users
self.client.post(
self.mail_url,
{
"action": MailingForm.ACTION_REMOVE_SUBSCRIPTION,
"removal_%d" % mde.id: [
user.id
for user in mde.subscriptions.filter(
user__in=[self.rbatsbak, self.comunity]
).all()
],
},
)
response = self.client.get(self.mail_url)
assert response.status_code == 200
content = response.text
assert "comunity@git.an" not in content
assert "richard@git.an" not in content
assert "krophil@git.an" not in content
+57 -9
View File
@@ -1,8 +1,9 @@
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Callable
import pytest
from aemark import markdown
from bs4 import BeautifulSoup
from django.contrib.auth.models import Permission
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects
@@ -16,13 +17,20 @@ if TYPE_CHECKING:
@pytest.mark.django_db
def test_page_display_on_club_main_page(client: Client):
@pytest.mark.parametrize("user_factory", [lambda: None, subscriber_user.make])
def test_page_display_on_club_main_page(
client: Client, user_factory: Callable[[], User | None]
):
"""Test the club Page is properly displayed on the club main view"""
club = baker.make(Club)
# create another previous revision, to test that we display the most recent one
baker.make(PageRev, page=club.page, revision=1)
content = "# foo\nLorem ipsum dolor sit amet"
baker.make(PageRev, page=club.page, revision=1, content=content)
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
baker.make(PageRev, page=club.page, revision=2, content=content)
if user := user_factory():
client.force_login(user)
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(id="club-page").find(class_="markdown")
@@ -30,20 +38,60 @@ def test_page_display_on_club_main_page(client: Client):
@pytest.mark.django_db
def test_club_main_page_without_content(client: Client):
"""Test the club view works, even if the club page is empty"""
@pytest.mark.parametrize(
("user_factory", "can_view_tabs", "can_view_edit_button"),
[
(lambda _: None, False, False),
(lambda _: baker.make(User), False, False),
(lambda _: subscriber_user.make(), True, False),
(
# admin user
lambda _: subscriber_user.make(
user_permissions=[Permission.objects.get(codename="change_club")]
),
True,
True,
),
(
# club board member
lambda club: (
baker.make(
Membership,
club=club,
role=baker.make(ClubRole, is_board=True),
user=subscriber_user.make(),
).user
),
True,
True,
),
],
ids=["anonymous", "public", "subscriber", "admin", "club board"],
)
@pytest.mark.django_db
def test_club_main_page_displayed_elements(
client: Client, user_factory, can_view_tabs, can_view_edit_button
):
"""Test that the appropriate elements are displayed depending on user status."""
club = baker.make(Club)
club.page.revisions.all().delete()
if user := user_factory(club):
client.force_login(user)
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(id="club-page")
assert detail_html.find_all("markdown") == []
tabs = soup.find(class_="tool_bar")
assert tabs is not None if can_view_tabs else tabs is None
edit_btn = soup.find(
"a",
href=reverse("core:page_edit", kwargs={"page_name": club.page.get_full_name()}),
)
assert edit_btn is not None if can_view_edit_button else edit_btn is None
@pytest.mark.django_db
def test_page_revision(client: Client):
"""Test that accessing a previous revision of the club page works."""
club = baker.make(Club)
revisions = baker.make(
PageRev, page=club.page, _quantity=3, content=iter(["foo", "bar", "baz"])
+2 -20
View File
@@ -29,8 +29,8 @@ from club.views import (
ClubCreateView,
ClubEditView,
ClubListView,
ClubMailingView,
ClubMembersView,
ClubNewsView,
ClubOldMembersView,
ClubPageEditView,
ClubPageHistView,
@@ -43,9 +43,6 @@ from club.views import (
ClubSellingView,
ClubToolsView,
ClubView,
MailingAutoGenerationView,
MailingDeleteView,
MailingSubscriptionDeleteView,
MembershipDeleteView,
MembershipSetOldView,
PosterCreateView,
@@ -96,22 +93,6 @@ urlpatterns = [
"<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv"
),
path("<int:club_id>/tools/", ClubToolsView.as_view(), name="tools"),
path("<int:club_id>/mailing/", ClubMailingView.as_view(), name="mailing"),
path(
"<int:mailing_id>/mailing/generate/",
MailingAutoGenerationView.as_view(),
name="mailing_generate",
),
path(
"<int:mailing_id>/mailing/delete/",
MailingDeleteView.as_view(),
name="mailing_delete",
),
path(
"<int:mailing_subscription_id>/mailing/delete/subscription/",
MailingSubscriptionDeleteView.as_view(),
name="mailing_subscription_delete",
),
path(
"membership/<int:membership_id>/set_old/",
MembershipSetOldView.as_view(),
@@ -136,4 +117,5 @@ urlpatterns = [
PosterDeleteView.as_view(),
name="poster_delete",
),
path("<int:club_id>/news/", ClubNewsView.as_view(), name="news_list"),
]
+58 -217
View File
@@ -34,9 +34,9 @@ from django.contrib.auth.mixins import (
UserPassesTestMixin,
)
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
from django.core.paginator import InvalidPage, Paginator
from django.db.models import F, Prefetch, Q, Sum
from django.db.models.expressions import OuterRef, Subquery
from django.db.models.functions import Length
from django.http import Http404, StreamingHttpResponse
from django.shortcuts import get_object_or_404, redirect
@@ -59,7 +59,6 @@ from club.forms import (
ClubRoleFormSet,
ClubSearchForm,
JoinClubForm,
MailingForm,
SellingsForm,
)
from club.models import (
@@ -67,11 +66,9 @@ from club.models import (
ClubLink,
ClubRole,
LinkType,
Mailing,
MailingSubscription,
Membership,
)
from com.models import Poster
from com.models import NewsDate, Poster
from com.views import (
PosterCreateBaseView,
PosterDeleteBaseView,
@@ -80,6 +77,7 @@ from com.views import (
)
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
from core.models import Page, PageRev
from core.utils import get_semester_code
from core.views import BasePageEditView, DetailFormView, UseFragmentsMixin
from core.views.mixins import (
AllowFragment,
@@ -106,42 +104,40 @@ class ClubTabsMixin(TabedViewMixin):
return self.object.get_display_name()
def get_list_of_tabs(self):
if not self.request.user.has_perm("club.view_club"):
# The user will still be able to see the club main page,
# but nothing else.
# It's useless to display a tab with only one item, so return nothing.
return []
tab_list = [
{
"url": reverse("club:club_view", kwargs={"club_id": self.object.id}),
"slug": "infos",
"name": _("Infos"),
}
},
{
"url": reverse("club:club_members", kwargs={"club_id": self.object.id}),
"slug": "members",
"name": _("Members"),
},
{
"url": reverse(
"club:club_old_members", kwargs={"club_id": self.object.id}
),
"slug": "elderlies",
"name": _("Old members"),
},
{
"url": reverse("club:club_hist", kwargs={"club_id": self.object.id}),
"slug": "history",
"name": _("History"),
},
{
"url": reverse("club:news_list", kwargs={"club_id": self.object.id}),
"slug": "events",
"name": _("Events"),
},
]
if self.request.user.has_perm("club.view_club"):
tab_list.extend(
[
{
"url": reverse(
"club:club_members", kwargs={"club_id": self.object.id}
),
"slug": "members",
"name": _("Members"),
},
{
"url": reverse(
"club:club_old_members", kwargs={"club_id": self.object.id}
),
"slug": "elderlies",
"name": _("Old members"),
},
]
)
if self.object.page:
tab_list.append(
{
"url": reverse(
"club:club_hist", kwargs={"club_id": self.object.id}
),
"slug": "history",
"name": _("History"),
}
)
if self.request.user.can_edit(self.object):
tab_list.extend(
[
@@ -159,21 +155,6 @@ class ClubTabsMixin(TabedViewMixin):
"slug": "edit",
"name": _("Edit"),
},
]
)
if self.object.page and self.request.user.can_edit(self.object.page):
tab_list.append(
{
"url": reverse(
"core:page_edit",
kwargs={"page_name": self.object.page.get_full_name()},
),
"slug": "page_edit",
"name": _("Edit club page"),
}
)
tab_list.extend(
[
{
"url": reverse(
"club:club_sellings", kwargs={"club_id": self.object.id}
@@ -181,13 +162,6 @@ class ClubTabsMixin(TabedViewMixin):
"slug": "sellings",
"name": _("Sellings"),
},
{
"url": reverse(
"club:mailing", kwargs={"club_id": self.object.id}
),
"slug": "mailing",
"name": _("Mailing list"),
},
{
"url": reverse(
"club:poster_list", kwargs={"club_id": self.object.id}
@@ -742,166 +716,6 @@ class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user_id})
class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView):
"""A list of mailing for a given club."""
model = Club
form_class = MailingForm
pk_url_kwarg = "club_id"
template_name = "club/mailing.jinja"
current_tab = "mailing"
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["club_id"] = self.object.id
kwargs["user_id"] = self.request.user.id
kwargs["mailings"] = self.object.mailings.all()
return kwargs
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
mailings = list(self.object.mailings.all())
kwargs["club"] = self.object
kwargs["user"] = self.request.user
kwargs["mailings"] = mailings
kwargs["mailings_moderated"] = [m for m in mailings if m.is_moderated]
kwargs["mailings_not_moderated"] = [m for m in mailings if not m.is_moderated]
kwargs["form_actions"] = {
"NEW_MALING": self.form_class.ACTION_NEW_MAILING,
"NEW_SUBSCRIPTION": self.form_class.ACTION_NEW_SUBSCRIPTION,
"REMOVE_SUBSCRIPTION": self.form_class.ACTION_REMOVE_SUBSCRIPTION,
}
return kwargs
def add_new_mailing(self, cleaned_data) -> ValidationError | None:
"""Create a new mailing list from the form."""
mailing = Mailing(
club=self.object,
email=cleaned_data["mailing_email"],
moderator=self.request.user,
is_moderated=False,
)
try:
mailing.clean()
except ValidationError as validation_error:
return validation_error
mailing.save()
return None
def add_new_subscription(self, cleaned_data) -> ValidationError | None:
"""Add mailing subscriptions for each user given and/or for the specified email in form."""
users_to_save = []
for user in cleaned_data["subscription_users"]:
sub = MailingSubscription(
mailing=cleaned_data["subscription_mailing"], user=user
)
try:
sub.clean()
except ValidationError as validation_error:
return validation_error
sub.save()
users_to_save.append(sub)
if cleaned_data["subscription_email"]:
sub = MailingSubscription(
mailing=cleaned_data["subscription_mailing"],
email=cleaned_data["subscription_email"],
)
try:
sub.clean()
except ValidationError as validation_error:
return validation_error
sub.save()
# Save users after we are sure there is no error
for user in users_to_save:
user.save()
return None
def remove_subscription(self, cleaned_data):
"""Remove specified users from a mailing list."""
fields = [
val for key, val in cleaned_data.items() if key.startswith("removal_")
]
for field in fields:
for sub in field:
sub.delete()
def form_valid(self, form):
resp = super().form_valid(form)
cleaned_data = form.clean()
error = None
if cleaned_data["action"] == self.form_class.ACTION_NEW_MAILING:
error = self.add_new_mailing(cleaned_data)
if cleaned_data["action"] == self.form_class.ACTION_NEW_SUBSCRIPTION:
error = self.add_new_subscription(cleaned_data)
if cleaned_data["action"] == self.form_class.ACTION_REMOVE_SUBSCRIPTION:
self.remove_subscription(cleaned_data)
if error:
form.add_error(NON_FIELD_ERRORS, error)
return self.form_invalid(form)
return resp
def get_success_url(self, **kwargs):
return reverse("club:mailing", kwargs={"club_id": self.object.id})
class MailingDeleteView(CanEditMixin, DeleteView):
model = Mailing
template_name = "core/delete_confirm.jinja"
pk_url_kwarg = "mailing_id"
redirect_page = None
def dispatch(self, request, *args, **kwargs):
self.club_id = self.get_object().club.id
return super().dispatch(request, *args, **kwargs)
def get_success_url(self, **kwargs):
if self.redirect_page:
return reverse_lazy(self.redirect_page)
else:
return reverse_lazy("club:mailing", kwargs={"club_id": self.club_id})
class MailingSubscriptionDeleteView(CanEditMixin, DeleteView):
model = MailingSubscription
template_name = "core/delete_confirm.jinja"
pk_url_kwarg = "mailing_subscription_id"
def dispatch(self, request, *args, **kwargs):
self.club_id = self.get_object().mailing.club.id
return super().dispatch(request, *args, **kwargs)
def get_success_url(self, **kwargs):
return reverse_lazy("club:mailing", kwargs={"club_id": self.club_id})
class MailingAutoGenerationView(View):
def dispatch(self, request, *args, **kwargs):
self.mailing = get_object_or_404(Mailing, pk=kwargs["mailing_id"])
if not request.user.can_edit(self.mailing):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
club = self.mailing.club
self.mailing.subscriptions.all().delete()
members = club.members.ongoing().filter(role__is_board=True)
for member in members.all():
MailingSubscription(user=member.user, mailing=self.mailing).save()
return redirect("club:mailing", club_id=club.id)
class PosterListView(
PermissionOrClubBoardRequiredMixin, ClubTabsMixin, PosterListBaseView
):
@@ -957,3 +771,30 @@ class PosterDeleteView(ClubTabsMixin, PosterDeleteBaseView):
def get_success_url(self):
return reverse_lazy("club:poster_list", kwargs={"club_id": self.club.id})
class ClubNewsView(PermissionRequiredMixin, ClubTabsMixin, DetailView):
pk_url_kwarg = "club_id"
model = Club
permission_required = "club.view_club"
template_name = "club/club_news_list.jinja"
current_tab = "events"
def get_context_data(self, **kwargs: Any):
events = list(
self.object.news.viewable_by(self.request.user)
.annotate(
first_date=Subquery(
NewsDate.objects.filter(news_id=OuterRef("pk"))
.order_by("start_date")
.values("start_date")[:1]
),
)
.prefetch_related("dates")
.order_by("-first_date")
)
return super().get_context_data(**kwargs) | {
"events": itertools.groupby(
events, key=lambda event: get_semester_code(event.first_date.date())
),
}
+1 -4
View File
@@ -259,10 +259,7 @@ class Weekmail(models.Model):
return f"Weekmail {self.id} (sent: {self.sent}) - {self.title}"
def send(self):
"""Send the weekmail to all users with the receive weekmail option opt-in.
Also send the weekmail to the mailing list in settings.SITH_COM_EMAIL.
"""
"""Send the weekmail to all users with the receive weekmail option opt-in."""
dest = [
i[0]
for i in Preferences.objects.filter(receive_weekmail=True).values_list(
-1
View File
@@ -5,7 +5,6 @@
margin-top: 20px;
padding: 0.4em;
width: 80%;
background: $white-color;
h4 {
margin-top: 1em;
+8 -8
View File
@@ -32,7 +32,7 @@
h3 {
--box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 3px 7px 2px;
background: lighten($second-color, 5%);
background: var(--bg-yellow);
box-shadow: var(--box-shadow);
padding: .75rem;
margin: 0 0 0.5em 0;
@@ -85,7 +85,7 @@
#links_content {
overflow: auto;
box-shadow: $shadow-color 1px 1px 1px;
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
min-height: 20em;
padding: 1em;
@@ -111,7 +111,7 @@
}
#birthdays_content {
box-shadow: $shadow-color 1px 1px 1px;
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
padding: 1em;
ul.birthdays_year {
@@ -123,7 +123,7 @@
padding: 0.5em;
&:nth-child(even) {
background: $secondary-neutral-light-color;
background: var(--bg-off-white);
}
}
@@ -141,7 +141,7 @@
/* EVENTS TODAY AND NEXT FEW DAYS */
.news_events_group {
box-shadow: $shadow-color 1px 1px 1px;
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
margin-left: 0;
margin-bottom: 0.5em;
@@ -153,8 +153,8 @@
display: table-cell;
padding: 0.6em;
vertical-align: middle;
background: $primary-neutral-dark-color;
color: $white-color;
background: var(--deepblue);
color: var(--text-white);
text-transform: uppercase;
text-align: center;
font-weight: bold;
@@ -180,7 +180,7 @@
}
.news_event:nth-of-type(even) {
background: $primary-neutral-light-color;
background: var(--bg-white);
}
.news_event {
-45
View File
@@ -1,45 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans %}Mailing lists administration{% endtrans %}
{% endblock %}
{% macro display_mailings(list) %}
<table>
<tr>
<th>{% trans %}Email{% endtrans %}</th>
<th>{% trans %}Club{%endtrans%}</th>
<th>{% trans %}Actions{% endtrans %}</th>
</tr>
{% for mailing in list %}
<tr>
<td>{{ mailing.email_full }}</td>
<td><a href="{{ url('club:mailing', club_id=mailing.club.id) }}">{{ mailing.club }}</a></td>
<td>
<a href="{{ url('com:mailing_delete', mailing_id=mailing.id) }}">{% trans %}Delete{% endtrans %}</a> - {% if not mailing.is_moderated %}<a href="{{ url('com:mailing_moderate', mailing_id=mailing.id) }}">{% trans %}Moderate{% endtrans %}</a>{% else %}{% trans user=mailing.moderator %}Moderated by {{ user }}{% endtrans %}{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% endmacro %}
{% block content %}
<h1>{% trans %}This page lists all mailing lists{% endtrans %}</h1>
{% if has_unmoderated %}
<h2>{% trans %}Not moderated mailing lists{% endtrans %}</h2>
{{ display_mailings(unmoderated) }}
{% endif %}
<h2>{% trans %}Moderated mailing lists{% endtrans %}</h2>
{% if has_moderated %}
{{ display_mailings(moderated) }}
{% else %}
<p>{% trans %}No mailing list existing{% endtrans %}</p>
{% endif %}
{% endblock %}
+12 -6
View File
@@ -39,12 +39,18 @@
<a href="{{ news.club.get_absolute_url() }}">{{ news.club }}</a>
</div>
<h4>{{ news.title }}</h4>
<p class="date">
<time datetime="{{ date.start_date.isoformat(timespec="seconds") }}">{{ date.start_date|localtime|date(DATETIME_FORMAT) }}
{{ date.start_date|localtime|time(DATETIME_FORMAT) }}</time> -
<time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">{{ date.end_date|localtime|date(DATETIME_FORMAT) }}
{{ date.end_date|localtime|time(DATETIME_FORMAT) }}</time>
</p>
{% if date %}
<p class="date">
<time datetime="{{ date.start_date.isoformat(timespec="seconds") }}">
{{ date.start_date|localtime|date(DATETIME_FORMAT) }}
{{ date.start_date|localtime|time(DATETIME_FORMAT) }}
</time> -
<time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">
{{ date.end_date|localtime|date(DATETIME_FORMAT) }}
{{ date.end_date|localtime|time(DATETIME_FORMAT) }}
</time>
</p>
{% endif %}
<div class="news_content">
<div><em>{{ news.summary|markdown }}</em></div>
<br/>
+35
View File
@@ -26,6 +26,7 @@ from django.utils import html
from django.utils.timezone import now
from django.utils.translation import gettext as _
from model_bakery import baker
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club, ClubRole, Membership
@@ -362,3 +363,37 @@ def test_moderate_poster(client: Client, referer: str | None):
poster.refresh_from_db()
assert poster.is_moderated
assert poster.moderator == user
class TestNewsDetail(TestCase):
@classmethod
def setUpTestData(cls) -> None:
cls.news = baker.make(News, is_published=True)
cls.user = subscriber_user.make()
cls.url = reverse("com:news_detail", kwargs={"news_id": cls.news.id})
def test_page_ok(self):
self.client.force_login(self.user)
res = self.client.get(self.url)
assert res.status_code == 200
def test_displayed_date(self):
self.client.force_login(self.user)
n = now()
date_recipe = Recipe(NewsDate, news=self.news)
date_recipe.make(
start_date=n - timedelta(days=1, hours=6), end_date=n - timedelta(days=1)
)
date_recipe.make(
start_date=n - timedelta(hours=6), end_date=n + timedelta(hours=1)
)
expected = date_recipe.make( # we expect the next happening date.
start_date=n + timedelta(days=1), end_date=n + timedelta(days=1, hours=1)
)
date_recipe.make(
start_date=n + timedelta(days=2), end_date=n + timedelta(days=2, hours=1)
)
self.client.force_login(self.user)
res = self.client.get(self.url)
assert res.status_code == 200
assert res.context_data["date"] == expected
-14
View File
@@ -15,12 +15,9 @@
from django.urls import path
from club.views import MailingDeleteView
from com.views import (
AlertMsgEditView,
InfoMsgEditView,
MailingListAdminView,
MailingModerateView,
NewsAdminListView,
NewsCreateView,
NewsDeleteView,
@@ -82,17 +79,6 @@ urlpatterns = [
"news/<int:news_id>/moderate/", NewsModerateView.as_view(), name="news_moderate"
),
path("news/<int:news_id>/", NewsDetailView.as_view(), name="news_detail"),
path("mailings/", MailingListAdminView.as_view(), name="mailing_admin"),
path(
"mailings/<int:mailing_id>/moderate/",
MailingModerateView.as_view(),
name="mailing_moderate",
),
path(
"mailings/<int:mailing_id>/delete/",
MailingDeleteView.as_view(redirect_page="com:mailing_admin"),
name="mailing_delete",
),
path("poster/", PosterListView.as_view(), name="poster_list"),
path("poster/create/", PosterCreateView.as_view(), name="poster_create"),
path("poster/<int:poster_id>/edit/", PosterEditView.as_view(), name="poster_edit"),
+6 -42
View File
@@ -30,11 +30,9 @@ from urllib.parse import urlparse
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import (
PermissionRequiredMixin,
)
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.syndication.views import Feed
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.exceptions import ValidationError
from django.db.models import Exists, Max, OuterRef, Value
from django.forms.models import modelform_factory
from django.http import HttpResponseRedirect
@@ -46,7 +44,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, TemplateView, View
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.models import Club, Mailing, Membership
from club.models import Club, Membership
from com.forms import NewsDateForm, NewsForm, PosterForm
from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
@@ -86,11 +84,6 @@ class ComTabsMixin(TabedViewMixin):
"slug": "alert",
"name": _("Alert message"),
},
{
"url": reverse("com:mailing_admin"),
"slug": "mailings",
"name": _("Mailing lists administration"),
},
{
"url": reverse("com:poster_list"),
"slug": "posters",
@@ -301,7 +294,9 @@ class NewsDetailView(CanViewMixin, DetailView):
queryset = News.objects.select_related("club", "author", "moderator")
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"date": self.object.dates.first()}
return super().get_context_data(**kwargs) | {
"date": self.object.dates.filter(start_date__gt=now()).first()
}
class NewsFeed(Feed):
@@ -531,37 +526,6 @@ class WeekmailArticleDeleteView(CanEditPropMixin, DeleteView):
pk_url_kwarg = "article_id"
class MailingListAdminView(ComTabsMixin, ListView):
template_name = "com/mailing_admin.jinja"
model = Mailing
current_tab = "mailings"
def dispatch(self, request, *args, **kwargs):
if not (request.user.is_com_admin or request.user.is_root):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["moderated"] = self.get_queryset().filter(is_moderated=True).all()
kwargs["unmoderated"] = self.get_queryset().filter(is_moderated=False).all()
kwargs["has_moderated"] = len(kwargs["moderated"]) > 0
kwargs["has_unmoderated"] = len(kwargs["unmoderated"]) > 0
return kwargs
class MailingModerateView(View):
def get(self, request, *args, **kwargs):
mailing = get_object_or_404(Mailing, pk=kwargs["mailing_id"])
if mailing.can_moderate(request.user):
mailing.is_moderated = True
mailing.moderator = request.user
mailing.save()
return redirect("com:mailing_admin")
raise PermissionDenied
class PosterListBaseView(ListView):
"""List communication posters."""
-16
View File
@@ -1,18 +1,15 @@
from typing import Annotated, Literal
from annotated_types import Ge, Le, MinLen
from django.conf import settings
from django.db.models import F
from ninja import File, Query
from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import PermissionDenied
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from api.auth import ApiKeyAuth
from api.permissions import CanAccessLookup, CanView, HasPerm
from club.models import Mailing
from core.models import Group, QuickUploadImage, SithFile, User
from core.schemas import (
FamilyGodfatherSchema,
@@ -48,19 +45,6 @@ class UploadController(ControllerBase):
return image
@api_controller("/mailings")
class MailingListController(ControllerBase):
@route.get("", response=str)
def fetch_mailing_lists(self, key: str):
if key != settings.SITH_MAILING_FETCH_KEY:
raise PermissionDenied
mailings = Mailing.objects.filter(
is_moderated=True, club__is_active=True
).prefetch_related("subscriptions")
data = "\n".join(m.fetch_format() for m in mailings)
return data
@api_controller("/user")
class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema])
+11 -8
View File
@@ -1,19 +1,16 @@
class FourDigitYearConverter:
regex = "[0-9]{4}"
from django.urls.converters import IntConverter, StringConverter
def to_python(self, value):
return int(value)
class FourDigitYearConverter(IntConverter):
regex = "[0-9]{4}"
def to_url(self, value):
return str(value).zfill(4)
class TwoDigitMonthConverter:
class TwoDigitMonthConverter(IntConverter):
regex = "[0-9]{2}"
def to_python(self, value):
return int(value)
def to_url(self, value):
return str(value).zfill(2)
@@ -28,3 +25,9 @@ class BooleanStringConverter:
def to_url(self, value):
return str(value)
class ResultConverter(StringConverter):
"""Converter whose regex match either "success" or "failure"."""
regex = "(success|failure)"
+64 -46
View File
@@ -20,6 +20,7 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
import itertools
from datetime import date, datetime, timedelta
from io import StringIO
from pathlib import Path
@@ -120,11 +121,6 @@ class Command(BaseCommand):
)
self.profiles_root = SithFile.objects.create(name="profiles", owner=root)
home_root = SithFile.objects.create(name="users", owner=root)
# Page needed for club creation
p = Page(name=settings.SITH_CLUB_ROOT_PAGE)
p.save(force_lock=True)
club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create(
name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID
@@ -256,6 +252,7 @@ class Command(BaseCommand):
date_of_birth="1942-06-12",
password="plop",
)
User.objects.all().update(cgu_approved_at=now())
User.groups.through.objects.bulk_create(
[
User.groups.through(group=groups.counter_admin, user=counter),
@@ -279,34 +276,7 @@ class Command(BaseCommand):
]
)
# Adding syntax help page
syntax_page = Page(name="Aide_sur_la_syntaxe")
syntax_page.save(force_lock=True)
PageRev.objects.create(
page=syntax_page,
title="Aide sur la syntaxe",
author=skia,
content=(self.ROOT_PATH / "core" / "fixtures" / "SYNTAX.md").read_text(),
)
services_page = Page(name="Services")
services_page.save(force_lock=True)
PageRev.objects.create(
page=services_page,
title="Services",
author=skia,
content="- [Eboutic](/eboutic)\n- Matmat\n- SAS\n- Weekmail\n- Forum",
)
index_page = Page(name="Index")
index_page.save(force_lock=True)
PageRev.objects.create(
page=index_page,
title="Wiki index",
author=root,
content="Welcome to the wiki page!",
)
groups.public.viewable_page.set([syntax_page, services_page, index_page])
self._create_pages(groups)
self._create_subscription(root)
self._create_subscription(skia)
@@ -577,6 +547,48 @@ class Command(BaseCommand):
]
)
def _create_pages(self, groups: PopulatedGroups):
pages = Page.objects.bulk_create(
[Page(name=s, _full_name=s) for s in settings.SITH_CGU_PAGE.split("/")]
)
for parent, son in itertools.pairwise(pages):
son.parent = parent
son.save(force_lock=True)
cgu_page = pages[-1]
syntax_page = Page(name="Aide_sur_la_syntaxe")
syntax_page.save(force_lock=True)
services_page = Page(name="Services")
services_page.save(force_lock=True)
index_page = Page(name="Index")
index_page.save(force_lock=True)
page_revs = [
PageRev(page=cgu_page, title="Règlement informatique", content=""),
PageRev(
page=syntax_page,
title="Aide sur la syntaxe",
content=(
self.ROOT_PATH / "core" / "fixtures" / "SYNTAX.md"
).read_text(),
),
PageRev(
page=services_page,
title="Services",
content="- [Eboutic](/eboutic)\n- Matmat\n- SAS\n- Weekmail\n- Forum",
),
PageRev(
page=index_page, title="Wiki index", content="Welcome to the wiki page!"
),
]
for rev in page_revs:
rev.author_id = settings.SITH_ROOT_USER_ID
rev.revision = 1
PageRev.objects.bulk_create(page_revs)
groups.public.viewable_page.set(
[syntax_page, services_page, index_page, cgu_page]
)
def _create_products(self, groups: PopulatedGroups, clubs: PopulatedClubs):
beers_type, cotis_type, refill_type, verre_type = (
ProductType.objects.bulk_create(
@@ -737,6 +749,10 @@ class Command(BaseCommand):
s.save()
def _create_clubs(self) -> PopulatedClubs:
# Page needed for club creation
p = Page(name=settings.SITH_CLUB_ROOT_PAGE)
p.save(force_lock=True)
ae = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
)
@@ -848,7 +864,11 @@ class Command(BaseCommand):
subscribers = Group.objects.create(name="Cotisants")
subscribers.permissions.add(
*list(perms.filter(codename__in=["add_news", "add_uecomment"]))
*list(
perms.filter(
codename__in=["add_news", "add_uecomment", "add_self_timetable"]
)
)
)
old_subscribers = Group.objects.create(name="Anciens cotisants")
old_subscribers.permissions.add(
@@ -877,18 +897,16 @@ class Command(BaseCommand):
accounting_admin.permissions.add(
*list(
perms.filter(
Q(
codename__in=[
"view_customer",
"view_product",
"change_product",
"add_product",
"view_producttype",
"change_producttype",
"add_producttype",
"delete_selling",
]
)
codename__in=[
"view_customer",
"view_product",
"change_product",
"add_product",
"view_producttype",
"change_producttype",
"add_producttype",
"delete_selling",
]
).values_list("pk", flat=True)
)
)
+27 -8
View File
@@ -1,3 +1,4 @@
import math
import random
from datetime import date, timedelta
from datetime import timezone as tz
@@ -35,12 +36,17 @@ class Command(BaseCommand):
super().__init__(*args, **kwargs)
self.faker = Faker("fr_FR")
def add_arguments(self, parser):
parser.add_argument(
"-n", "--nb-users", help="Number of users to create", type=int, default=600
)
def handle(self, *args, **options):
if not settings.DEBUG:
raise Exception("Never call this command in prod. Never.")
self.stdout.write("Creating users...")
users = self.create_users()
users = self.create_users(options["nb_users"])
self.create_bans(random.sample(users, k=len(users) // 200)) # 0.5% of users
subscribers = random.sample(users, k=int(0.8 * len(users)))
self.stdout.write("Creating subscriptions...")
@@ -80,7 +86,7 @@ class Command(BaseCommand):
self.stdout.write("Creating products...")
self.create_products()
self.stdout.write("Creating sales and refills...")
sellers = random.sample(list(User.objects.all()), 100)
sellers = random.sample(users, len(users) // 10)
self.create_sales(sellers)
self.stdout.write("Creating permanences...")
self.create_permanences(sellers)
@@ -89,7 +95,7 @@ class Command(BaseCommand):
self.stdout.write("Done")
def create_users(self) -> list[User]:
def create_users(self, nb_users: int = 600) -> list[User]:
# Create a single password hash for all users to make it faster.
# It's insecure as hell, but it's ok since it's only for dev purposes.
password = make_password("plop")
@@ -108,7 +114,7 @@ class Command(BaseCommand):
address=self.faker.address(),
password=password,
)
for _ in range(600)
for _ in range(nb_users)
]
# there may a duplicate or two
# Not a problem, we will just have 599 users instead of 600
@@ -138,10 +144,22 @@ class Command(BaseCommand):
)
def create_subscriptions(self, users: list[User]):
subscription_types = [
"un-semestre",
"deux-semestres",
"cursus-tronc-commun",
"cursus-branche",
]
def prepare_subscription(_user: User, start_date: date) -> Subscription:
payment_method = random.choice(settings.SITH_SUBSCRIPTION_PAYMENT_METHOD)[0]
duration = random.randint(1, 4)
s = Subscription(member=_user, payment_method=payment_method)
subscription_type = random.choice(subscription_types)
s = Subscription(
member=_user,
payment_method=payment_method,
subscription_type=subscription_type,
)
duration = settings.SITH_SUBSCRIPTIONS[subscription_type]["duration"]
s.subscription_start = s.compute_start(d=start_date, duration=duration)
s.subscription_end = s.compute_end(duration)
return s
@@ -415,8 +433,9 @@ class Command(BaseCommand):
Permanency.objects.bulk_create(perms)
def create_forums(self):
forumers = random.sample(list(User.objects.all()), 100)
most_actives = random.sample(forumers, 10)
users = list(User.objects.all())
forumers = random.sample(users, math.ceil(len(users) / 10))
most_actives = random.sample(forumers, math.ceil(len(forumers) / 6))
categories = list(Forum.objects.filter(is_category=True))
new_forums = [
Forum(name=self.faker.text(20), parent=random.choice(categories))
@@ -0,0 +1,15 @@
# Generated by Django 5.2.17 on 2026-09-25 12:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("core", "0050_alter_sithfile_moderator")]
operations = [
migrations.AddField(
model_name="user",
name="cgu_approved_at",
field=models.DateTimeField(null=True, verbose_name="ToS approved at"),
),
]
@@ -0,0 +1,22 @@
# Generated by Django 5.2.17 on 2026-09-24 14:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("core", "0051_user_cgu_approved_at")]
operations = [
migrations.AddField(
model_name="preferences",
name="show_my_timetable",
field=models.BooleanField(
default=True,
help_text=(
"Allow subscribers to see your timetable and "
"to compare it to theirs (if you have filled it)"
),
verbose_name="show your timetable to others",
),
),
]
+20 -4
View File
@@ -44,6 +44,7 @@ from django.core.files.base import ContentFile
from django.core.mail import send_mail
from django.db import models, transaction
from django.db.models import Exists, F, OuterRef, Q
from django.db.models.aggregates import Max
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
@@ -291,6 +292,7 @@ class User(AbstractUser):
),
blank=True,
)
cgu_approved_at = models.DateTimeField(_("ToS approved at"), null=True, blank=False)
godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
objects = CustomUserManager()
@@ -418,6 +420,14 @@ class User(AbstractUser):
)
return age
@cached_property
def approved_current_cgu(self) -> bool:
qs = PageRev.objects.filter(page___full_name=settings.SITH_CGU_PAGE)
return (
self.cgu_approved_at is not None
and self.cgu_approved_at > qs.aggregate(date=Max("date"))["date"]
)
def make_home(self):
if self.home is None:
home_root = SithFile.objects.filter(parent=None, name="users").first()
@@ -789,6 +799,14 @@ class Preferences(models.Model):
),
default=False,
)
show_my_timetable = models.BooleanField(
_("show your timetable to others"),
help_text=_(
"Allow subscribers to see your timetable and "
"to compare it to theirs (if you have filled it)"
),
default=True,
)
notify_on_click = models.BooleanField(
_("get a notification for every click"), default=False
)
@@ -1233,9 +1251,8 @@ class Page(models.Model):
raise NotLocked("The page is not locked and thus can not be saved")
self.full_clean()
if not self.id:
super().save(
*args, **kwargs
) # Save a first time to correctly set _full_name
# Save a first time to correctly set _full_name
super().save(*args, **kwargs)
# This reset the _full_name just before saving to maintain a coherent field quicker for queries than the
# recursive method
# It also update all the children to maintain correct names
@@ -1254,7 +1271,6 @@ class Page(models.Model):
return Page.objects.filter(_full_name=name).first()
def clean(self):
"""Cleans up only the name for the moment, but this can be used to make any treatment before saving the object."""
if "/" in self.name:
self.name = self.name.split("/")[-1]
if (
@@ -60,6 +60,7 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
});
easymde.codemirror.replaceSelection("\n");
},
previewClass: ["editor-preview", "markdown"], // default class + our own markdown class
previewRender: (plainText) => markdown(plainText),
forceSync: true, // Avoid validation error on generic create view
imageTexts: {
-123
View File
@@ -1,123 +0,0 @@
/*--------------------------------RESET--------------------------------*/
/*--------------------------------RESET--------------------------------*/
html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
dfn,
em,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
table,
caption,
tbody,
tfoot,
thead,
tr,
th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
header,
hgroup,
menu,
nav,
output,
ruby,
section,
summary,
time,
mark,
audio,
video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section {
display: block;
}
body {
line-height: 1;
}
ol,
ul {
/* list-style: none;*/
}
blockquote,
q {
quotes: none;
}
blockquote:before,
blockquote:after,
q:before,
q:after {
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
+25 -29
View File
@@ -1,41 +1,37 @@
$first-color: hsl(220, 100%, 50%);
$second-color: hsl(48, 100%, 67%);
$primary-color: hsl(219.9, 53.7%, 50%);
$secondary-color: hsl(204, 64%, 44%);
$primary-color-text: hsl(0, 0%, 100%);
$secondary-color-text: hsla(0, 0%, 0%, 0.87);
/**
* Sith theme variables
*/
:root {
--bg-yellow: #ffe270;
--bg-white: #f9fafb;
--bg-off-white: #f0f0f0;
--bg-darkened-off-white: #e3e3e3;
--bg-lightgrey: #d4d4d4;
$primary-light-color: hsl(219.8, 46.4%, 64.9%);
$primary-dark-color: hsl(203, 75%, 40%);
--text-white: #f9fafb;
--text-black: #2b2b2b;
--text-red: #eb2f06;
$secondary-light-color: hsl(40, 68%, 65%);
$secondary-dark-color: hsl(40, 68%, 35%);
$primary-neutral-color: hsl(219.6, 20.8%, 50%);
$primary-neutral-light-color: hsl(0, 0%, 94%);
$primary-neutral-dark-color: hsl(210, 29%, 29%);
$secondary-neutral-color: hsl(204, 64%, 44%);
$secondary-neutral-light-color: hsl(0, 0%, 91%);
$secondary-neutral-dark-color: hsl(40, 57.6%, 17%);
$white-color: hsl(219.6, 20.8%, 98%);
$black-color: hsl(0, 0%, 17%);
$red-text-color: #eb2f06;
$hovered-red-text-color: #ff4d4d;
--blue: #3b69c4;
--lightblue: #1a78b3;
// darkened-deepblue is 10% darker than deepblue
// This is explicitely declared, because the `darken` scss function
// isn't called at compile-time somehow.
--deepblue: #354a5f;
--darkened-deepblue: #23313e;
}
/**
* Brand colours.
* Those are officially defined by their respective companies,
* and so are more appropriate as scss variables.
*/
$faceblue: hsl(221, 44%, 41%);
$twitblue: hsl(206, 82%, 63%);
$discordblurple: #7289da;
$instagradient: radial-gradient(circle at 30% 107%, #fdf497 0%, #fdf497 5%, #fd5949 45%, #d6249f 60%, #285AEB 90%);
$githubblack: rgb(22, 22, 20);
$shadow-color: rgb(223, 223, 223);
$background-button-color: hsl(0, 0%, 95%);
$deepblue: #354a5f;
@mixin shadow {
box-shadow: rgba(60, 64, 67, 0.3) 0 1px 3px 0,
rgba(60, 64, 67, 0.15) 0 4px 8px 3px;
+3 -3
View File
@@ -17,7 +17,7 @@
}
.card {
background-color: $primary-neutral-light-color;
background-color: var(--bg-off-white);
border-radius: 5px;
position: relative;
box-sizing: border-box;
@@ -30,12 +30,12 @@
gap: 20px;
&:disabled {
background-color: darken($primary-neutral-light-color, 5%);
background-color: var(--bg-darkened-off-white);
opacity: 65%;
}
&.clickable:not(:disabled):hover {
background-color: darken($primary-neutral-light-color, 5%);
background-color: var(--bg-darkened-off-white);
}
&.selected {
+8 -4
View File
@@ -11,7 +11,7 @@ ui-tab-group {
.tab-headers {
display: flex;
flex-flow: row wrap;
background-color: $primary-neutral-light-color;
background-color: var(--bg-off-white);
padding: 3px 12px 12px;
column-gap: 20px;
border-top-left-radius: 5px;
@@ -25,23 +25,27 @@ ui-tab-group {
background-color: unset;
position: relative;
&:hover {
background-color: var(--bg-lightgrey);
}
&:after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
border-bottom: 4px solid darken($primary-neutral-light-color, 10%);
border-bottom: 4px solid var(--bg-lightgrey);
border-radius: 2px;
transition: all 0.2s ease-in-out;
}
&:hover:after {
border-bottom-color: darken($primary-neutral-light-color, 20%);
filter: brightness(85%);
}
&.active:after {
border-bottom-color: $primary-dark-color;
border-bottom-color: var(--lightblue);
}
}
}
+17 -34
View File
@@ -2,92 +2,75 @@
@import "devices";
footer.bottom-links {
>section>a {
> section > a {
text-align: center;
}
text-align: center;
hr {
border: none;
}
@media (max-width: $small-devices) {
margin-top: 0.6em;
padding: 1.25em;
background-color: $primary-neutral-dark-color;
background-color: var(--deepblue);
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25em;
>section {
> section {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 0.8em;
}
a {
color: $white-color;
width: auto;
color: var(--text-white);
&:hover {
color: $white-color;
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
text-shadow: 0.5px 0.5px 0.5px var(--bg-darkened-off-white);
}
}
.fa-github {
color: $white-color;
color: var(--text-white);
}
hr {
width: 100%;
height: 0px;
border: none;
border-top: 0.5px solid $white-color;
border-top: var(--bg-white) 1px solid;
}
}
@media (min-width: $small-devices) {
width: 90%;
margin: 2em auto;
font-size: 90%;
text-align: center;
vertical-align: middle;
section:first-of-type {
margin: 0.6em 0;
color: $white-color;
border-radius: 5px;
display: flex;
flex-wrap: wrap;
align-items: center;
background-color: $primary-neutral-dark-color;
box-shadow: $shadow-color 0 0 15px;
background-color: var(--deepblue);
a {
color: $white-color;
width: auto;
padding: 0.8em;
flex: 1;
font-weight: bold;
color: var(--text-white);
&:hover {
color: $white-color;
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
text-shadow: 0.5px 0.5px 0.5px var(--bg-darkened-off-white);
}
}
}
.fa-github {
color: $githubblack;
}
hr {
border: none;
height: 5px;
}
}
}
+11 -14
View File
@@ -16,7 +16,6 @@
input[type="file"] {
border: none;
text-decoration: none;
background-color: $background-button-color;
padding: 0.4em;
margin: 0.1em;
font-size: 1.2em;
@@ -24,7 +23,7 @@
color: black;
&:not(.link-like):not(:disabled):hover {
background: hsl(0, 0%, 83%);
background: var(--bg-lightgrey);
}
}
@@ -53,7 +52,7 @@
.ts-control {
border: none;
text-decoration: none;
background-color: $background-button-color;
//background-color: $background-button-color;
padding: 0.4em;
margin: 0.1em;
font-size: 1.2em;
@@ -64,7 +63,7 @@
textarea {
border: none;
text-decoration: none;
background-color: $background-button-color;
//background-color: $background-button-color;
padding: 7px;
font-size: 1.2em;
border-radius: 5px;
@@ -75,7 +74,7 @@
border: none;
text-decoration: none;
font-size: 1.2em;
background-color: $background-button-color;
//background-color: $background-button-color;
padding: 10px;
border-radius: 5px;
cursor: pointer;
@@ -83,14 +82,14 @@
a:not(.button) {
text-decoration: none;
color: $primary-dark-color;
color: var(--lightblue);
&:hover {
color: $primary-light-color;
color: var(--blue);
}
&:active {
color: $primary-color;
color: var(--blue);
}
}
}
@@ -161,6 +160,7 @@ form {
fieldset {
margin-bottom: 1rem;
border: 0;
}
.row > label {
@@ -762,16 +762,13 @@ form {
// a submit input that should look like a regular <a>
input[type="submit"], button {
&.link-like {
color: $primary-dark-color;
color: var(--lightblue);
&:hover {
color: $primary-light-color;
color: var(--blue);
}
&.link-red {
color: $red-text-color;
&:hover {
color: $hovered-red-text-color;
}
color: var(--text-red);
}
font-weight: normal;
font-size: 100%;
+4 -4
View File
@@ -7,7 +7,7 @@ $background-color-hovered: #283747;
.header {
box-sizing: border-box;
background-color: $deepblue;
background-color: var(--deepblue);
box-shadow: 3px 3px 3px 0 #dfdfdf;
border-radius: 0;
width: 100%;
@@ -96,7 +96,7 @@ $background-color-hovered: #283747;
border-radius: 0;
margin: 0;
box-sizing: border-box;
background-color: $deepblue;
background-color: var(--deepblue);
width: 45px;
height: 25px;
padding: 0;
@@ -212,7 +212,7 @@ $background-color-hovered: #283747;
background-position: center center;
background-size: cover;
background-repeat: no-repeat;
background-color: $deepblue;
background-color: var(--deepblue);
}
>.options {
@@ -298,7 +298,7 @@ $background-color-hovered: #283747;
width: 10px;
height: 10px;
padding: 5px;
background-color: $red-text-color;
background-color: var(--text-red);
border-radius: 50%;
position: absolute;
top: -50%;
+11 -5
View File
@@ -1,7 +1,4 @@
.markdown {
--bg-lightgrey: rgb(240, 241, 245);
--border-lightgrey: rgb(215, 216, 220);
h1,
h2,
h3,
@@ -28,11 +25,20 @@
font-size: 14px;
tab-size: 4;
border-radius: 4px;
border: 1px solid var(--border-lightgrey);
background-color: var(--bg-lightgrey);
border: 1px solid var(--bg-lightgrey);
background-color: var(--bg-off-white);
padding: 1px 2px;
}
/**
* Some restyling rules for the cases where our own md css
* conflicts with the easymde editor preview css
*/
&.editor-preview {
pre {
background: unset;
}
}
pre code {
// code that is not inlined
margin: 15px;
+1 -1
View File
@@ -3,7 +3,7 @@
$desktop-size: 500px;
nav.navbar {
background-color: $primary-dark-color;
background-color: var(--lightblue);
margin: 1em;
color: white;
border-radius: 0.6em;
+4 -10
View File
@@ -6,27 +6,21 @@
margin: 30px;
button {
background-color: $secondary-neutral-light-color;
background-color: var(--bg-off-white);
min-width: 37px;
&:hover {
background-color: darken($secondary-neutral-light-color, 10%);
}
&:disabled {
color: #fff;
background-color: darken($secondary-neutral-light-color, 5%);
background-color: var(--bg-lightgrey);
}
&.active,
&.active:hover {
background-color: $primary-neutral-color;
background-color: var(--deepblue);
color: white;
border-radius: 50%;
}
&:first-of-type,
&:last-of-type {
.pagination-arrow {
// previous and next buttons
&:disabled {
// Hide the arrows when they are disabled, without
+65 -101
View File
@@ -2,12 +2,21 @@
@import "forms";
@import "devices";
/*----- RESET -----*/
* {
margin: 0;
padding: 0;
}
/*--------------------------------GENERAL------------------------------*/
body {
position: relative;
width: 100%;
font-family: sans-serif;
background-color: var(--bg-white);
line-height: 1;
}
[aria-busy] {
@@ -57,7 +66,7 @@ body {
.collapse-header {
color: white;
background-color: $deepblue;
background-color: var(--deepblue);
padding: 5px 10px;
display: flex;
align-items: center;
@@ -109,6 +118,12 @@ body {
display: none !important;
}
hr {
display: block;
margin: .5em auto;
color: gray;
}
/*--------------------------------HEADER-------------------------------*/
#info_boxes {
@@ -141,7 +156,7 @@ body {
}
#info_box {
background: $primary-neutral-light-color;
background: var(--bg-off-white);
&:before {
content: "\f05a";
@@ -150,11 +165,11 @@ body {
}
#alert_box {
background: $second-color;
background: var(--bg-yellow);
&:before {
content: "\f06a";
color: $white-color;
color: var(--text-white);
}
}
}
@@ -189,10 +204,10 @@ body {
border-radius: 5px;
&.btn-blue {
background-color: $deepblue;
background-color: var(--deepblue);
&:not(:disabled):hover {
background-color: darken($deepblue, 10%);
background-color: var(--darkened-deepblue);
}
&:disabled {
@@ -215,7 +230,7 @@ body {
&.btn-green {
$bg-color: rgba(0, 210, 83, 0.4);
background-color: $bg-color;
color: $black-color;
color: var(--text-black);
&:not(:disabled):hover {
background-color: darken($bg-color, 15%);
@@ -272,8 +287,8 @@ body {
/*--------------------------------CONTENT------------------------------*/
#content {
padding: 1.5em 3%;
box-shadow: $shadow-color 0 5px 10px;
background: $white-color;
box-shadow: var(--bg-darkened-off-white) 0 5px 10px;
background: var(--bg-white);
overflow: auto;
}
@@ -343,7 +358,7 @@ body {
display: flex;
flex-wrap: wrap;
float: right;
background: $primary-neutral-light-color;
background: var(--bg-off-white);
padding: 5px;
border-radius: 6px;
text-align: center;
@@ -351,7 +366,7 @@ body {
a {
padding: 7px;
display: inline-block;
color: $black-color;
color: var(--text-black);
font-weight: bold;
margin: 2px;
flex: 1;
@@ -359,14 +374,14 @@ body {
white-space: nowrap;
&.selected_tab {
background: $primary-color;
color: $white-color;
background: var(--blue);
color: var(--text-white);
border-radius: 6px;
}
&:hover {
background: $primary-color;
color: $white-color;
background: var(--blue);
color: var(--text-white);
border-radius: 6px;
}
}
@@ -398,28 +413,33 @@ body {
}
}
/* Fontawesome icons */
.fa-brands, .fa-link {
color: black;
}
}
.fa-facebook {
color: $faceblue;
}
/* Fontawesome icons */
.fa-brands, .fa-link {
color: black;
}
.fa-discord {
color: $discordblurple;
}
.fa-facebook {
color: $faceblue;
}
.fa-square-instagram::before, .fa-instagram::before {
background: $instagradient;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.fa-discord {
color: $discordblurple;
}
.fa-bluesky, .fa-square-bluesky {
color: #0f73ff;
}
.fa-square-instagram::before, .fa-instagram::before {
background: $instagradient;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.fa-bluesky, .fa-square-bluesky {
color: #0f73ff;
}
.fa-github {
color: $githubblack;
}
@media screen and (max-width: $small-devices) {
@@ -481,16 +501,6 @@ dt {
margin-top: 25px;
}
.edit-bar {
display: block;
margin: 4px;
a {
display: inline-block;
margin: 4px;
}
}
.important {
font-size: 1.2em;
font-weight: bold;
@@ -503,7 +513,6 @@ table {
border-collapse: collapse;
border-spacing: 0;
border-radius: 5px;
-moz-border-radius: 5px;
overflow: hidden;
box-shadow: rgba(60, 64, 67, 0.3) 0 1px 3px 0,
rgba(60, 64, 67, 0.15) 0 4px 8px 3px;
@@ -515,25 +524,21 @@ table {
}
}
th {
padding: 4px;
}
td,
th {
vertical-align: middle;
text-align: center;
padding: 5px 10px;
>input[type="checkbox"] {
> input[type="checkbox"] {
padding: unset;
}
>ul {
> ul {
margin-top: 0;
}
>input[type="checkbox"] {
> input[type="checkbox"] {
padding: unset;
}
}
@@ -543,10 +548,6 @@ td {
border-collapse: collapse;
overflow: hidden;
text-overflow: ellipsis;
>ul {
margin-top: 0;
}
}
th,
@@ -556,23 +557,23 @@ thead td {
}
thead {
background-color: $deepblue;
background-color: var(--deepblue);
color: white;
}
tbody>tr {
tbody > tr {
&:nth-child(even):not(.highlight) {
background: $primary-neutral-light-color;
background: var(--bg-off-white);
}
&.clickable:hover {
cursor: pointer;
background: $secondary-neutral-light-color;
background: var(--bg-lightgrey);
width: 100%;
}
&.highlight {
color: $primary-dark-color;
color: var(--lightblue);
font-style: italic;
}
}
@@ -598,7 +599,7 @@ em {
}
.highlight {
background: $second-color;
background: var(--bg-yellow);
font-weight: bold;
}
@@ -700,15 +701,15 @@ u,
height: 18em;
overflow: hidden;
border: 1px solid black;
box-shadow: $shadow-color 1px 1px 1px;
box-shadow: var(--bg-darkened-off-white) 1px 1px 1px;
&:hover {
box-shadow: 1px 1px 5px $second-color;
box-shadow: 1px 1px 5px var(--bg-yellow);
}
}
.matmat_user a {
color: $primary-neutral-dark-color;
color: var(--deepblue);
height: 100%;
width: 100%;
margin: 0;
@@ -723,7 +724,6 @@ u,
display: block;
margin: 10px;
padding: 10px;
background: $white-color;
}
textarea {
@@ -731,43 +731,7 @@ textarea {
margin-top: 10px;
}
.ui-dialog .ui-dialog-buttonpane {
bottom: 0;
position: absolute;
width: 97%;
}
#cash_summary_form label,
.inline {
display: inline;
}
.form_button {
width: 150px;
height: 120px;
padding: 2px;
display: inline-block;
font-size: 0.8em;
span {
width: 70px;
float: right;
}
img {
max-width: 50px;
max-height: 50px;
float: left;
}
strong {
font-weight: bold;
font-size: 1.2em;
}
button {
vertical-align: middle;
}
}
+11 -47
View File
@@ -21,7 +21,6 @@ body {
flex-direction: column;
padding: 10px;
box-shadow: none;
background-color: white;
margin: 0;
.alert {
@@ -37,8 +36,7 @@ body {
margin: 0;
}
>div,
>form {
form {
box-sizing: border-box;
display: flex;
flex-direction: column;
@@ -49,67 +47,33 @@ body {
max-width: 500px;
margin-top: 20px;
>p,
>div {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
input[type="submit"] {
width: 100%;
margin: 0;
>label {
width: 100%;
@media (min-width: 500px) {
width: 300px;
}
}
max-width: 300px;
margin-top: 1em;
}
>input,
>p>input,
>div>input {
box-sizing: border-box;
width: 100%;
max-width: 500px;
@media (min-width: 500px) {
max-width: 300px;
}
}
>.errorlist {
.errorlist {
color: red;
text-align: center;
margin: 10px 0 0 0;
list-style-type: none;
}
>.required>.helptext {
text-align: center;
font-style: italic;
}
>.required:last-of-type {
box-sizing: border-box;
div, fieldset {
max-width: 300px;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
}
.captcha {
box-sizing: border-box;
>label {
width: 100%;
fieldset {
margin-bottom: unset
}
>img {
width: 70px;
object-fit: contain;
}
>input {
width: 200px;
}
}
}
}
+1 -1
View File
@@ -71,7 +71,7 @@ main {
border-radius: 50%;
justify-content: center;
align-items: center;
background-color: $primary-neutral-light-color;
background-color: var(--bg-white);
> span {
font-size: small;
+1 -1
View File
@@ -62,7 +62,7 @@
}
&:enabled>.fa {
background-color: #354a5f;
background-color: var(--deepblue);
color: white;
}
-1
View File
@@ -23,7 +23,6 @@
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% endblock %}
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link rel="stylesheet" href="{{ static('core/base.css') }}">
<link rel="stylesheet" href="{{ static('core/style.scss') }}">
<link rel="stylesheet" href="{{ static('core/tooltips.scss') }}">
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
+39
View File
@@ -0,0 +1,39 @@
{% extends "core/base.jinja" %}
{%- block additional_css -%}
<link rel="stylesheet" href="{{ static('user/login.scss') }}">
{%- endblock -%}
{% block title %}
{% trans %}Login{% endtrans %}
{% endblock %}
{% block info_boxes %}
{% endblock %}
{% block nav %}
{% endblock %}
{% block content %}
<h1 class="title">{% trans %}Terms of Service{% endtrans %}</h1>
<form method="post" id="login-form">
{% csrf_token %}
<div class="alert alert-yellow">
{% trans trimmed %}
To continue using our services,
please read and approve the AE website's terms of service
{% endtrans %}
</div>
<div class="form-group">
{{ form.cgu_approved_at.errors }}
{{ form.cgu_approved_at }}
{{ form.cgu_approved_at.label_tag() }}
</div>
<input type="hidden" name="next" value="{{ next }}">
<input type="submit" class="btn btn-blue">
</form>
{% endblock %}
@@ -29,5 +29,13 @@
</span>
{{ form.show_my_stats.errors }}
</fieldset>
<fieldset class="form-group">
{{ form.show_my_timetable }}
{{ form.show_my_timetable.label_tag() }}
<span class="helptext">
{{ form.show_my_timetable.help_text }}
</span>
{{ form.show_my_timetable.errors }}
</fieldset>
<input type="submit" class="btn btn-blue" value="{% trans %}Save{% endtrans %}">
</form>
+8 -19
View File
@@ -35,28 +35,17 @@
{% csrf_token %}
<div>
<label for="{{ form.username.name }}">{{ form.username.label }}</label>
{{ form.username }}
{{ form.username.errors }}
</div>
<div>
<label for="{{ form.password.name }}">{{ form.password.label }}</label>
{{ form.password }}
{{ form.password.errors }}
</div>
{{ form }}
<input type="hidden" name="next" value="{{ next }}">
<input type="submit" value="{% trans %}Login{% endtrans %}">
<input type="submit" class="btn btn-blue" value="{% trans %}Login{% endtrans %}">
{# Assumes you setup the password_reset view in your URLconf #}
<p>
<a href="{{ url('core:password_reset') }}">{% trans %}Lost password?{% endtrans %}</a>
&nbsp;&nbsp;
<a href="{{ url('core:register') }}">{% trans %}Create account{% endtrans %}</a>
</p>
<div>
<a href="{{ url("core:password_reset") }}">{% trans %}Lost password?{% endtrans %}</a>
</div>
<div>
<a href="{{ url("core:register") }}">{% trans %}Create account{% endtrans %}</a>
</div>
</form>
{% endblock %}
+10 -4
View File
@@ -102,6 +102,7 @@
@click.prevent="{{ page }}--"
:disabled="{{ page }} <= 1"
@keyup.right.window="{{ page }} = Math.min({{ nb_pages }}, {{ page }} + 1)"
class="pagination-arrow"
>
<i class="fa fa-caret-left"></i>
</button>
@@ -112,6 +113,7 @@
@click.prevent="{{ page }}++"
:disabled="{{ page }} >= {{ nb_pages }}"
@keyup.left.window="{{ page }} = Math.max(1, {{ page }} - 1)"
class="pagination-arrow"
>
<i class="fa fa-caret-right"></i>
</button>
@@ -163,12 +165,14 @@
href="?{{ querystring(request, page=current_page.previous_page_number()) }}"
{%- endif -%}
>
<button>
<button class="pagination-arrow">
<i class="fa fa-caret-left"></i>
</button>
</a>
{% else %}
<button disabled="disabled"><i class="fa fa-caret-left"></i></button>
<button disabled="disabled" class="pagination-arrow">
<i class="fa fa-caret-left"></i>
</button>
{% endif %}
{% for i in paginator.get_elided_page_range(current_page.number) %}
{% if i == current_page.number %}
@@ -201,12 +205,14 @@
{%- else -%}
href="?{{querystring(request, page=current_page.next_page_number())}}"
{%- endif -%}
><button>
><button class="pagination-arrow">
<i class="fa fa-caret-right"></i>
</button>
</a>
{% else %}
<button disabled="disabled"><i class="fa fa-caret-right"></i></button>
<button disabled="disabled" class="pagination-arrow">
<i class="fa fa-caret-right"></i>
</button>
{% endif %}
</nav>
{% endmacro %}
+12 -2
View File
@@ -18,7 +18,17 @@
<form action="{{ url('core:register') }}" method="post">
{% csrf_token %}
{% render_honeypot_field %}
{{ form.as_p() }}
<input type="submit" value="{% trans %}Register{% endtrans %}" />
{% for field in form %}
{% if field.name not in ["cgu_approved_at", "captcha"] %}
<div>{{ field.as_field_group() }}</div>
{% endif %}
{% endfor %}
<div class="captcha">{{ form.captcha.as_field_group() }}</div>
<div class="form-group">
{{ form.cgu_approved_at.errors }}
{{ form.cgu_approved_at }}
{{ form.cgu_approved_at.label_tag() }}
</div>
<input type="submit" class="btn btn-blue" value="{% trans %}Register{% endtrans %}" />
</form>
{% endblock %}
-10
View File
@@ -79,15 +79,5 @@
{% endfor %}
</tbody>
</table>
{% if
profile.mailing_subscriptions.exists()
and (profile.id == user.id or user.is_root or user.is_com_admin)
%}
<h4>{% trans %}Subscribed mailing lists{% endtrans %}</h4>
{% for sub in profile.mailing_subscriptions.all() %}
<p>{{ sub.mailing.email }} <a href="{{ url('club:mailing_subscription_delete', mailing_subscription_id=sub.id) }}">{% trans %}Unsubscribe{% endtrans %}</a></p>
{% endfor %}
{% endif %}
{% endblock %}
+2 -2
View File
@@ -31,10 +31,10 @@
{% for sum in purchase_sums %}
<div>
<span>{{ sum["counter__name"] }}</span>
<span>{{ sum["total"] }} €</span>
<span>{{ "%.2f"|format(sum["total"]) }} €</span>
</div>
{% endfor %}
<div><b>Total : </b><b>{{ total_purchases }} €</b></div>
<div><b>Total : </b><b>{{ "%.2f"|format(total_purchases) }} €</b></div>
</div>
</div>
</div>
-1
View File
@@ -133,7 +133,6 @@
<li><a href="{{ url('com:alert_edit') }}">{% trans %}Edit alert message{% endtrans %}</a></li>
<li><a href="{{ url('com:info_edit') }}">{% trans %}Edit information message{% endtrans %}</a></li>
<li><a href="{{ url('core:file_moderation') }}">{% trans %}Moderate files{% endtrans %}</a></li>
<li><a href="{{ url('com:mailing_admin') }}">{% trans %}Mailing lists administration{% endtrans %}</a></li>
<li><a href="{{ url('com:poster_list') }}">{% trans %}Posters{% endtrans %}</a></li>
<li><a href="{{ url('com:screen_list') }}">{% trans %}Screens{% endtrans %}</a></li>
{% endif %}
+13
View File
@@ -0,0 +1,13 @@
import contextlib
import os
import pytest
from django.core.management import call_command
@pytest.mark.django_db
def test_populate_more(settings):
"""Just check that populate more doesn't crash"""
settings.DEBUG = True
with open(os.devnull, "w") as devnull, contextlib.redirect_stdout(devnull):
call_command("populate_more", "--nb-users", "50")
+38 -2
View File
@@ -27,6 +27,7 @@ from django.core.exceptions import ValidationError
from django.core.mail import EmailMessage
from django.test import Client, RequestFactory, TestCase
from django.urls import reverse
from django.utils.timezone import now
from django.views.generic import View
from django.views.generic.base import ContextMixin
from model_bakery import baker
@@ -55,6 +56,7 @@ class TestUserRegistration:
"password2": "plop",
"captcha_0": "dummy-value",
"captcha_1": "PASSED",
"cgu_approved_at": now(),
}
@pytest.fixture()
@@ -92,6 +94,10 @@ class TestUserRegistration:
({"first_name": ""}, "Ce champ est obligatoire."),
({"last_name": ""}, "Ce champ est obligatoire."),
({"captcha_1": "WRONG_CAPTCHA"}, "CAPTCHA invalide"),
(
{"cgu_approved_at": False},
"Vous devez approuver les conditions générales d'utilisation",
),
],
)
def test_register_user_form_fail(
@@ -150,7 +156,7 @@ class TestUserRegistration:
class TestUserLogin:
@pytest.fixture()
def user(self) -> User:
return baker.make(User, password=make_password("plop"))
return baker.make(User, password=make_password("plop"), cgu_approved_at=now())
@pytest.mark.parametrize(
"identifier_getter",
@@ -191,10 +197,40 @@ class TestUserLogin:
reverse("core:login"),
{"username": identifier_getter(user), "password": "plop"},
)
assertRedirects(response, reverse("core:index"))
assertRedirects(response, settings.LOGIN_REDIRECT_URL)
assert response.wsgi_request.user == user
@pytest.mark.django_db
class TestCGU:
def test_cgu_approval(self, client: Client):
user = baker.make(User, password=make_password("plop"), cgu_approved_at=None)
user_url = user.get_absolute_url()
res = client.post(
reverse("core:login"),
{"username": user.username, "password": "plop", "next": user_url},
)
assertRedirects(res, reverse("core:approve_cgu", query={"next": user_url}))
res = client.post(
reverse("core:approve_cgu"), {"cgu_approved_at": now(), "next": user_url}
)
assertRedirects(res, user_url)
user.refresh_from_db()
assert user.cgu_approved_at is not None
def test_access_cgu_when_already_approved(self, client: Client):
url = reverse("core:approve_cgu")
res = client.get(url)
assertRedirects(res, reverse("core:login"))
client.force_login(baker.make(User, cgu_approved_at=now()))
res = client.get(url)
assertRedirects(res, settings.LOGIN_REDIRECT_URL)
res = client.post(url, {"cgu_approved_at": now()})
assertRedirects(res, settings.LOGIN_REDIRECT_URL)
@pytest.mark.parametrize(
("md", "html"),
[
+8
View File
@@ -31,6 +31,7 @@ from core.converters import (
TwoDigitMonthConverter,
)
from core.views import (
CGUApprovalView,
FileDeleteView,
FileEditPropView,
FileEditView,
@@ -83,6 +84,7 @@ from core.views import (
notification,
send_file,
)
from timetable.views import UserTimetableView
register_converter(FourDigitYearConverter, "yyyy")
register_converter(TwoDigitMonthConverter, "mm")
@@ -125,6 +127,7 @@ urlpatterns = [
name="password_reset_complete",
),
path("register/", UserCreationView.as_view(), name="register"),
path("cgu/", CGUApprovalView.as_view(), name="approve_cgu"),
# Group handling
path("group/", GroupListView.as_view(), name="group_list"),
path("group/new/", GroupCreateView.as_view(), name="group_new"),
@@ -182,6 +185,11 @@ urlpatterns = [
name="user_account_detail",
),
path("user/<int:user_id>/stats/", UserStatsView.as_view(), name="user_stats"),
path(
"user/<int:user_id>/edt/",
UserTimetableView.as_view(),
name="user_timetable",
),
path(
"user/<int:user_id>/gift/create/",
GiftCreateView.as_view(),
+39
View File
@@ -12,12 +12,15 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from __future__ import annotations
import hmac
from datetime import date, timedelta
# Image utils
from io import BytesIO
from typing import TYPE_CHECKING, Final
from urllib.parse import urlencode
import PIL
from django.conf import settings
@@ -25,6 +28,15 @@ from django.core.files.base import ContentFile
from django.utils.timezone import localdate
from PIL.Image import Image, Resampling
if TYPE_CHECKING:
from _hashlib import HASH
from collections.abc import Buffer, Mapping, Sequence
from typing import Any, Callable, Final
from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest
if TYPE_CHECKING:
from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest
@@ -190,3 +202,30 @@ def get_client_ip(request: HttpRequest) -> str | None:
return ip
return None
def hmac_hexdigest(
key: str | bytes,
data: Mapping[str, Any] | Sequence[tuple[str, Any]],
digest: str | Callable[[Buffer], HASH] = "sha512",
) -> str:
"""Return the hexdigest of the signature of the given data.
Args:
key: the HMAC key used for the signature
data: the data to sign
digest: a PEP247 hashing algorithm (by default, sha512)
Examples:
```python
data = {
"foo": 5,
"bar": "somevalue",
}
hmac_key = secrets.token_hex(64)
signature = hmac_hexdigest(hmac_key, data, "sha256")
```
"""
if isinstance(key, str):
key = key.encode()
return hmac.digest(key, urlencode(data).encode(), digest).hex()
+12 -10
View File
@@ -21,7 +21,7 @@ from wsgiref.util import FileWrapper
from django import forms
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.core.exceptions import PermissionDenied, ValidationError
from django.db.models import Exists, OuterRef
from django.forms.models import modelform_factory
from django.http import Http404, HttpRequest, HttpResponse
@@ -233,6 +233,12 @@ class FileEditPropForm(forms.ModelForm):
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
def clean_parent(self):
parent = self.cleaned_data["parent"]
if not self.instance.owner.can_edit(parent):
raise ValidationError(_("You cannot move this file there"))
return parent
class FileEditPropView(CanEditPropMixin, UpdateView):
model = SithFile
@@ -253,10 +259,7 @@ class FileEditPropView(CanEditPropMixin, UpdateView):
return ret
def get_success_url(self):
return reverse(
"core:file_detail",
kwargs={"file_id": self.object.id},
)
return reverse("core:file_detail", kwargs={"file_id": self.object.id})
class FileView(CanViewMixin, DetailView, FormMixin):
@@ -269,7 +272,7 @@ class FileView(CanViewMixin, DetailView, FormMixin):
form_class = AddFilesForm
@staticmethod
def handle_clipboard(request, obj):
def handle_clipboard(request, obj: SithFile):
"""Handle the clipboard in the view.
This method can fail, since it does not catch the exceptions coming from
@@ -283,10 +286,9 @@ class FileView(CanViewMixin, DetailView, FormMixin):
where you want to paste the clipboard
"""
if "delete" in request.POST:
for f_id in request.POST.getlist("file_list"):
file = SithFile.objects.filter(id=f_id).first()
if file:
file.delete()
file_ids = request.POST.getlist("file_list")
for file in obj.children.filter(id__in=file_ids):
file.delete()
if "clear" in request.POST:
request.session["clipboard"] = []
if "cut" in request.POST:
+73 -7
View File
@@ -30,9 +30,7 @@ from django import forms
from django.conf import settings
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
from django.contrib.auth.models import Permission
from django.contrib.staticfiles.management.commands.collectstatic import (
staticfiles_storage,
)
from django.contrib.staticfiles.storage import staticfiles_storage
from django.core.exceptions import ValidationError
from django.db import transaction
from django.forms import (
@@ -42,6 +40,9 @@ from django.forms import (
TextInput,
Widget,
)
from django.urls import reverse
from django.utils.functional import lazy
from django.utils.safestring import mark_safe
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget
@@ -108,6 +109,43 @@ class FutureDateTimeField(forms.DateTimeField):
return {"min": widget.format_value(now())}
class CGUApprovalField(forms.BooleanField):
"""Form field with a checkbox to approve the CGUs.
The checkbox must be checked to be valid.
If valid, then the value of the field is the current timestamp.
"""
default_error_messages = {"required": _("You must approve the terms of service.")}
__label = None
def __init__(self, *, label_suffix: str | None = "", **kwargs):
# Because the core app of the sith is so huge,
# and because we require a url from the latter,
# putting the reverse into the __init__ will result in it
# being evaluated at server startup time (even with reverse_lazy).
# This will result in a circular import.
# Thus, we must keep the label in its own property and force it to be lazy.
kwargs["label"] = lazy(self.get_label, str)
kwargs["required"] = True
super().__init__(label_suffix=label_suffix, **kwargs)
def to_python(self, value):
return now() if super().to_python(value) else None
def get_label(self):
if not self.__label:
url = reverse("core:page", kwargs={"page_name": settings.SITH_CGU_PAGE})
self.__label = mark_safe(
_(
"I have read and I approve the "
'<a href="%(url)s" target="_blank">Terms of Service</a>'
)
% {"url": url}
)
return self.__label
# Forms
@@ -146,8 +184,28 @@ class RegisteringForm(UserCreationForm):
class Meta:
model = User
fields = ("first_name", "last_name", "email")
field_classes = {"email": AntiSpamEmailField}
fields = ("first_name", "last_name", "email", "cgu_approved_at")
field_classes = {
"email": AntiSpamEmailField,
"cgu_approved_at": CGUApprovalField,
}
class CGUApprovalForm(forms.ModelForm):
class Meta:
model = User
fields = ["cgu_approved_at"]
field_classes = {"cgu_approved_at": CGUApprovalField}
def __init__(self, *args, instance: User | None = None, **kwargs):
if instance:
# If this form is displayed,
# then we want the user to explicitly check the button.
# So we pretend cgu were never approved (even if they were).
# If we didn't do that, the button would be initially checked,
# even if the approval was done before the last CGU version.
instance.cgu_approved_at = False
super().__init__(*args, instance=instance, **kwargs)
class UserProfileForm(forms.ModelForm):
@@ -278,10 +336,14 @@ class UserVisibilityForm(forms.ModelForm):
__preferences_fields = forms.fields_for_model(
Preferences,
["show_my_stats"],
widgets={"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"})},
["show_my_stats", "show_my_timetable"],
widgets={
"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"}),
"show_my_timetable": forms.CheckboxInput(attrs={"class": "switch"}),
},
)
show_my_stats = __preferences_fields["show_my_stats"]
show_my_timetable = __preferences_fields["show_my_timetable"]
def __init__(
self, *args, initial: dict | None = None, instance: User | None = None, **kwargs
@@ -289,12 +351,16 @@ class UserVisibilityForm(forms.ModelForm):
if instance:
initial = initial or {}
initial["show_my_stats"] = instance.preferences.show_my_stats
initial["show_my_timetable"] = instance.preferences.show_my_timetable
super().__init__(*args, initial=initial, instance=instance, **kwargs)
def save(self, commit=True) -> User: # noqa: FBT002
instance = super().save(commit=commit)
if commit:
instance.preferences.show_my_stats = self.cleaned_data["show_my_stats"]
instance.preferences.show_my_timetable = self.cleaned_data[
"show_my_timetable"
]
instance.preferences.save()
return instance
+16 -2
View File
@@ -1,6 +1,14 @@
import copy
import inspect
from typing import TYPE_CHECKING, Any, ClassVar, LiteralString, Protocol, Unpack
from typing import (
TYPE_CHECKING,
Any,
ClassVar,
LiteralString,
Protocol,
TypedDict,
Unpack,
)
from django.core.exceptions import ImproperlyConfigured
from django.template.loader import render_to_string
@@ -12,11 +20,17 @@ if TYPE_CHECKING:
from django.utils.safestring import SafeString
class TabItem(TypedDict):
url: str
slug: str
name: str
class TabedViewMixin(View):
"""Basic functions for displaying tabs in the template."""
current_tab: ClassVar[str | None] = None
list_of_tabs: ClassVar[list | None] = None
list_of_tabs: ClassVar[list[TabItem] | None] = None
tabs_title: ClassVar[str | None] = None
def get_tabs_title(self):
+35 -6
View File
@@ -27,12 +27,12 @@ from datetime import timedelta
# This file contains all the views that concern the user model
from operator import itemgetter
from smtplib import SMTPException
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from django.contrib import messages
from django.contrib.auth import login, views
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm
from django.contrib.auth.forms import SetPasswordForm
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import PermissionDenied
@@ -60,6 +60,7 @@ from honeypot.decorators import check_honeypot
from core.auth.mixins import CanEditMixin, CanEditPropMixin, CanViewMixin
from core.models import Gift, Preferences, User
from core.views.forms import (
CGUApprovalForm,
GiftForm,
LoginForm,
RegisteringForm,
@@ -71,6 +72,7 @@ from core.views.forms import (
from core.views.mixins import FragmentMixin, TabedViewMixin, UseFragmentsMixin
from counter.models import Refilling, Selling
from eboutic.models import Invoice
from sith import settings
from trombi.views import UserTrombiForm
if TYPE_CHECKING:
@@ -82,9 +84,16 @@ class SithLoginView(views.LoginView):
template_name = "core/login.jinja"
authentication_form = LoginForm
form_class = PasswordChangeForm
redirect_authenticated_user = True
def get_success_url(self) -> str:
redirect_to = self.get_redirect_url()
default_url = self.get_default_redirect_url()
if not self.request.user.approved_current_cgu:
query = {"next": redirect_to} if redirect_to else {}
return reverse("core:approve_cgu", query=query)
return redirect_to or default_url
class SithPasswordChangeView(views.PasswordChangeView):
"""Allows a user to change its password."""
@@ -188,6 +197,27 @@ class UserCreationView(FormView):
return super().form_valid(form)
class CGUApprovalView(views.RedirectURLMixin, UpdateView):
form_class = CGUApprovalForm
next_page = settings.LOGIN_REDIRECT_URL
template_name = "core/cgu_approve.jinja"
def dispatch(self, request, *args, **kwargs):
if self.request.user.is_anonymous:
return redirect("core:login")
if self.request.user.approved_current_cgu:
return redirect(self.get_success_url())
return super().dispatch(request, *args, **kwargs)
def get_object(self, *args, **kwargs):
return self.request.user
def get_context_data(self, **kwargs) -> dict[str, Any]:
return super().get_context_data(**kwargs) | {
self.redirect_field_name: self.get_redirect_url()
}
class UserMeRedirect(LoginRequiredMixin, RedirectView):
def get_redirect_url(self, *args, **kwargs):
if remaining := kwargs.get("remaining_path"):
@@ -383,7 +413,7 @@ class UserStatsView(UserTabsMixin, UserPassesTestMixin, DetailView):
kwargs = super().get_context_data(**kwargs)
kwargs["perm_time"] = list(
self.object.permanencies.filter(end__isnull=False, counter__type="BAR")
self.object.permanencies.filter(end__isnull=False)
.values("counter", "counter__name")
.annotate(total=Sum(F("end") - F("start"), default=timedelta(seconds=0)))
.order_by("-total")
@@ -392,8 +422,7 @@ class UserStatsView(UserTabsMixin, UserPassesTestMixin, DetailView):
[perm["total"] for perm in kwargs["perm_time"]], start=timedelta(seconds=0)
)
kwargs["purchase_sums"] = list(
self.object.customer.buyings.filter(counter__type="BAR")
.values("counter", "counter__name")
self.object.customer.buyings.values("counter", "counter__name")
.annotate(total=Sum(F("unit_price") * F("quantity")))
.order_by("-total")
)
+7 -1
View File
@@ -130,8 +130,14 @@ class GetUserForm(forms.Form):
elif cleaned_data["id"]:
customer = Customer.objects.filter(user=cleaned_data["id"]).first()
if customer is None or not customer.can_buy:
if customer is None:
raise forms.ValidationError(_("User not found"))
if not customer.can_buy:
raise forms.ValidationError(_("This user cannot buy items on counters."))
if customer.user.is_banned_counter:
raise forms.ValidationError(
_("This user is banned from AE places. Take him/her out if you can.")
)
cleaned_data["user_id"] = customer.user_id
cleaned_data["user"] = customer.user
return cleaned_data
-11
View File
@@ -647,13 +647,6 @@ class Counter(models.Model):
or user in self.sellers.all()
)
def gen_token(self) -> None:
"""Generate a new token for this counter."""
self.token = "".join(
random.choice(string.ascii_letters + string.digits) for _ in range(30)
)
self.save()
@cached_property
def barmen_list(self) -> list[User]:
"""Returns the barman list as list of User."""
@@ -661,10 +654,6 @@ class Counter(models.Model):
p.user for p in self.permanencies.filter(end=None).select_related("user")
]
def get_random_barman(self) -> User:
"""Return a random user being currently a barman."""
return random.choice(self.barmen_list)
def update_activity(self) -> None:
"""Update the barman activity to prevent timeout."""
self.permanencies.filter(end=None).update(activity=timezone.now())
@@ -71,7 +71,7 @@
padding: 0.5em;
margin: 0.2em;
height: 100%;
background: $secondary-neutral-light-color;
background: var(--bg-off-white);
img {
max-width: 70%;
+26 -14
View File
@@ -83,8 +83,17 @@
{% endmacro %}
{% block content %}
<div class="alert alert-yellow">
<div class="alert-main">
{%- trans trimmed -%}
You can see product details.
However, you cannot edit it, thus you won't be able
to submit this form.
{%- endtrans -%}
</div>
</div>
{% if object %}
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
<h2>{{ object }}</h2>
<p><i>{% trans %}Creation date{% endtrans %} : {{ object.created_at|date }}</i></p>
<p><i>{% trans %}Last update{% endtrans %} : {{ object.updated_at|date }}</i></p>
{% else %}
@@ -127,19 +136,17 @@
{{ form.price_formset.management_form }}
<div x-ref="formContainer">
{%- for form in form.price_formset.forms -%}
<div>
{{ price_form(form) }}
</div>
<div>{{ price_form(form) }}</div>
{%- endfor -%}
</div>
<template x-ref="formTemplate">
<div>
{{ price_form(form.price_formset.empty_form) }}
</div>
<div>{{ price_form(form.price_formset.empty_form) }}</div>
</template>
<button class="btn btn-grey" @click.prevent="addForm()">
<i class="fa fa-plus"></i> {% trans %}Add a price{% endtrans %}
</button>
{% if user.has_perm("counter.change_product") %}
<button class="btn btn-grey" @click.prevent="addForm()">
<i class="fa fa-plus"></i> {% trans %}Add a price{% endtrans %}
</button>
{% endif %}
</div>
<br />
@@ -165,11 +172,16 @@
<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>
{% if user.has_perm("counter.change_product") %}
<button @click.prevent="addForm()" class="btn btn-grey">
<i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %}
</button>
{% endif %}
</div>
<div class="row gap margin-bottom">{{ form.archived.as_field_group() }}</div>
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
{% if user.has_perm("counter.change_product") %}
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
{% endif %}
</form>
{% endblock %}
+5 -3
View File
@@ -80,9 +80,11 @@
<h3 class="margin-bottom">{% trans %}Product list{% endtrans %}</h3>
<div class="row margin-bottom">
<a href="{{ url('counter:new_product') }}" class="btn btn-blue">
{% trans %}New product{% endtrans %} <i class="fa fa-plus"></i>
</a>
{% if user.has_perm("counter.add_product") %}
<a href="{{ url('counter:new_product') }}" class="btn btn-blue">
{% trans %}New product{% endtrans %} <i class="fa fa-plus"></i>
</a>
{% endif %}
<button
class="btn btn-blue"
@click="downloadCsv()"
@@ -13,12 +13,12 @@
{% endblock %}
{% block content %}
<p>
<a href="{{ url('counter:new_product_type') }}" class="btn btn-blue">
{% if user.has_perm("counter.add_producttype") %}
<a href="{{ url('counter:new_product_type') }}" class="btn btn-blue margin-bottom">
{% trans %}New product type{% endtrans %}
<i class="fa fa-plus"></i>
</a>
</p>
{% endif %}
{% if product_types %}
<aside>
<p>
+5 -3
View File
@@ -21,7 +21,8 @@ import pytest
from bs4 import BeautifulSoup
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib.auth.models import Permission, make_password
from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import Permission
from django.contrib.messages import DEFAULT_LEVELS, get_messages
from django.shortcuts import resolve_url
from django.test import Client, TestCase
@@ -65,8 +66,9 @@ class TestFullClickBase(TestCase):
@classmethod
def setUpTestData(cls):
cls.customer = subscriber_user.make()
cls.barmen = subscriber_user.make(password=make_password("plop"))
cls.board_admin = board_user.make(password=make_password("plop"))
password = make_password("plop")
cls.barmen = subscriber_user.make(password=password)
cls.board_admin = board_user.make(password=password)
cls.club_admin = subscriber_user.make()
cls.root = baker.make(User, is_superuser=True)
cls.subscriber = subscriber_user.make()
+6 -2
View File
@@ -6,8 +6,10 @@ from django.urls import resolve
if TYPE_CHECKING:
from django.http import HttpRequest
from counter.models import Counter
def is_logged_in_counter(request: HttpRequest) -> bool:
def is_logged_in_counter(request: HttpRequest, counter: Counter | None = None) -> bool:
"""Check if the request is sent from a device logged to a counter.
The request must also be sent within the frame of a counter's activity.
@@ -30,4 +32,6 @@ def is_logged_in_counter(request: HttpRequest) -> bool:
if not referer_ok and request.resolver_match.app_name != "counter":
return False
return bool(request.barmen)
if counter is None:
return bool(request.barmen)
return bool(request.barmen) and request.barmen.issubset(set(counter.barmen_list))
+61 -58
View File
@@ -48,7 +48,7 @@ from counter.models import (
Selling,
)
from counter.utils import is_logged_in_counter
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
from counter.views.mixins import CounterAdminTabsMixin
class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView):
@@ -84,17 +84,18 @@ class CounterEditView(
return reverse_lazy("counter:admin", kwargs={"counter_id": self.object.id})
class CounterEditPropView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
"""Edit a counter's main informations (for the counter's admin)."""
class CounterEditPropView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
"""Edit a counter's main infos."""
model = Counter
form_class = modelform_factory(Counter, fields=["name", "club", "type"])
pk_url_kwarg = "counter_id"
template_name = "core/edit.jinja"
current_tab = "counters"
permission_required = "counter.change_counter"
class CounterCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
class CounterCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
"""Create a counter (for the admins)."""
model = Counter
@@ -105,9 +106,10 @@ class CounterCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
)
template_name = "core/create.jinja"
current_tab = "counters"
permission_required = "counter.add_counter"
class CounterDeleteView(CounterAdminTabsMixin, CounterAdminMixin, DeleteView):
class CounterDeleteView(CounterAdminTabsMixin, PermissionRequiredMixin, DeleteView):
"""Delete a counter (for the admins)."""
model = Counter
@@ -115,30 +117,33 @@ class CounterDeleteView(CounterAdminTabsMixin, CounterAdminMixin, DeleteView):
template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("counter:admin_list")
current_tab = "counters"
permission_required = "counter.delete_counter"
# Product management
class ProductTypeListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
class ProductTypeListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
"""A list view for the admins."""
model = ProductType
template_name = "counter/product_type_list.jinja"
current_tab = "product_types"
context_object_name = "product_types"
permission_required = "counter.view_producttype"
class ProductTypeCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
class ProductTypeCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
"""A create view for the admins."""
model = ProductType
fields = ["name", "description", "comment", "icon"]
template_name = "core/create.jinja"
current_tab = "products"
permission_required = "counter.add_producttype"
class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
class ProductTypeEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
"""An edit view for the admins."""
model = ProductType
@@ -146,23 +151,26 @@ class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
fields = ["name", "description", "comment", "icon"]
pk_url_kwarg = "type_id"
current_tab = "products"
permission_required = "counter.change_producttype"
class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
class ProductListView(CounterAdminTabsMixin, PermissionRequiredMixin, TemplateView):
current_tab = "products"
template_name = "counter/product_list.jinja"
permission_required = "counter.view_product"
class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
class ProductCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
"""A create view for the admins."""
model = Product
form_class = ProductForm
template_name = "counter/product_form.jinja"
current_tab = "products"
permission_required = "counter.add_product"
class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
class ProductEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
"""An edit view for the admins."""
model = Product
@@ -170,6 +178,13 @@ class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
pk_url_kwarg = "product_id"
template_name = "counter/product_form.jinja"
current_tab = "products"
permission_required = "counter.change_product"
def has_permission(self):
return self.request.user.has_perm("counter.change_product") or (
self.request.method == "GET"
and self.request.user.has_perm("counter.view_product")
)
class ProductFormulaListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
@@ -291,54 +306,41 @@ class ReturnableProductDeleteView(
}
class RefillingDeleteView(DeleteView):
class CounterTransactionDeletionView(DeleteView):
template_name = "core/delete_confirm.jinja"
def get_queryset(self):
return super().get_queryset().select_related("counter", "customer")
def dispatch(self, request, *args, **kwargs):
self.object: Selling | Refilling = self.get_object()
if timezone.now() - self.object.date <= timedelta(
minutes=settings.SITH_LAST_OPERATIONS_LIMIT
) and is_logged_in_counter(request, counter=self.object.counter):
self.success_url = reverse(
"counter:details", kwargs={"counter_id": self.object.counter_id}
)
return super().dispatch(request, *args, **kwargs)
elif self.object.is_owned_by(request.user):
self.success_url = reverse(
"core:user_account", kwargs={"user_id": self.object.customer.user_id}
)
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied
class RefillingDeleteView(CounterTransactionDeletionView):
"""Delete a refilling (for the admins)."""
model = Refilling
pk_url_kwarg = "refilling_id"
template_name = "core/delete_confirm.jinja"
def dispatch(self, request, *args, **kwargs):
"""We have here a very particular right handling, we can't inherit from CanEditPropMixin."""
self.object = self.get_object()
if timezone.now() - self.object.date <= timedelta(
minutes=settings.SITH_LAST_OPERATIONS_LIMIT
) and is_logged_in_counter(request):
self.success_url = reverse(
"counter:details", kwargs={"counter_id": self.object.counter.id}
)
return super().dispatch(request, *args, **kwargs)
elif self.object.is_owned_by(request.user):
self.success_url = reverse(
"core:user_account", kwargs={"user_id": self.object.customer.user.id}
)
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied
class SellingDeleteView(DeleteView):
class SellingDeleteView(CounterTransactionDeletionView):
"""Delete a selling (for the admins)."""
model = Selling
pk_url_kwarg = "selling_id"
template_name = "core/delete_confirm.jinja"
def dispatch(self, request, *args, **kwargs):
"""We have here a very particular right handling, we can't inherit from CanEditPropMixin."""
self.object = self.get_object()
if timezone.now() - self.object.date <= timedelta(
minutes=settings.SITH_LAST_OPERATIONS_LIMIT
) and is_logged_in_counter(request):
self.success_url = reverse(
"counter:details", kwargs={"counter_id": self.object.counter.id}
)
return super().dispatch(request, *args, **kwargs)
elif self.object.is_owned_by(request.user):
self.success_url = reverse(
"core:user_account", kwargs={"user_id": self.object.customer.user.id}
)
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied
class CounterStatView(PermissionRequiredMixin, DetailView):
@@ -376,30 +378,31 @@ class CounterStatView(PermissionRequiredMixin, DetailView):
return kwargs
class CounterRefillingListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
class CounterRefillingListView(
CounterAdminTabsMixin, PermissionRequiredMixin, ListView
):
"""List of refillings on a counter."""
model = Refilling
template_name = "counter/refilling_list.jinja"
current_tab = "counters"
paginate_by = 30
permission_required = "counter.view_refilling"
def dispatch(self, request, *args, **kwargs):
self.counter = get_object_or_404(Counter, pk=kwargs["counter_id"])
self.queryset = Refilling.objects.filter(counter__id=self.counter.id)
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
self.counter = get_object_or_404(Counter, pk=self.kwargs["counter_id"])
return Refilling.objects.filter(counter=self.counter).order_by("-date")
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["counter"] = self.counter
return kwargs
return super().get_context_data(**kwargs) | {"counter": self.counter}
class RefoundAccountView(UserPassesTestMixin, FormView):
class RefoundAccountView(UserPassesTestMixin, SuccessMessageMixin, FormView):
"""Create a selling with the same amount as the current user money."""
template_name = "counter/refound_account.jinja"
form_class = CloseCustomerAccountForm
success_message = _("%(user)s's AE account has been emptied")
def test_func(self):
return self.request.user.is_root or self.request.user.is_in_group(
+3 -7
View File
@@ -242,15 +242,11 @@ class RefillingCreateView(FragmentMixin, CreateView):
if not self.customer.can_buy:
raise Http404
if not is_logged_in_counter(request):
raise PermissionDenied
self.counter: Counter = get_object_or_404(Counter, id=self.kwargs["counter_id"])
if not (
request.barmen
and request.barmen.issubset(self.counter.barmen_list)
and self.counter.can_refill
if (
not is_logged_in_counter(request, counter=self.counter)
or not self.counter.can_refill
):
raise PermissionDenied
+8 -5
View File
@@ -12,7 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.http import Http404, HttpResponse
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
@@ -21,28 +21,30 @@ from django.views.generic.edit import CreateView, UpdateView
from core.auth.mixins import CanViewMixin
from counter.forms import EticketForm
from counter.models import Eticket, Selling
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
from counter.views.mixins import CounterAdminTabsMixin
class EticketListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
class EticketListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
"""A list view for the admins."""
model = Eticket
template_name = "counter/eticket_list.jinja"
ordering = ["id"]
current_tab = "etickets"
permission_required = "counter.view_eticket"
class EticketCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
class EticketCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
"""Create an eticket."""
model = Eticket
template_name = "core/create.jinja"
form_class = EticketForm
current_tab = "etickets"
permission_required = "counter.add_eticket"
class EticketEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
class EticketEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
"""Edit an eticket."""
model = Eticket
@@ -50,6 +52,7 @@ class EticketEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
form_class = EticketForm
pk_url_kwarg = "eticket_id"
current_tab = "etickets"
permission_required = "counter.change_eticket"
class EticketPDFView(CanViewMixin, DetailView):
+8 -9
View File
@@ -16,9 +16,9 @@ from datetime import timedelta
from typing import TYPE_CHECKING
from django.conf import settings
from django.contrib.auth.mixins import UserPassesTestMixin
from django.core.exceptions import PermissionDenied
from django.db.models import F
from django.http import HttpRequest, HttpResponseRedirect
from django.shortcuts import redirect
from django.urls import reverse
from django.utils import timezone
@@ -36,6 +36,7 @@ from counter.utils import is_logged_in_counter
from counter.views.mixins import CounterTabsMixin
if TYPE_CHECKING:
from django.http import HttpRequest, HttpResponseRedirect
from django.utils.safestring import SafeString
@@ -155,7 +156,7 @@ class CounterMain(
return super().form_valid(form)
class CounterLastOperationsView(CounterTabsMixin, CanViewMixin, DetailView):
class CounterLastOperationsView(CounterTabsMixin, UserPassesTestMixin, DetailView):
"""Provide the last operations to allow barmen to delete them."""
model = Counter
@@ -163,14 +164,12 @@ class CounterLastOperationsView(CounterTabsMixin, CanViewMixin, DetailView):
template_name = "counter/last_ops.jinja"
current_tab = "last_ops"
def dispatch(self, request, *args, **kwargs):
"""We have here again a very particular right handling."""
def test_func(self):
self.object = self.get_object()
if is_logged_in_counter(request) and self.object.barmen_list:
return super().dispatch(request, *args, **kwargs)
return HttpResponseRedirect(
reverse("counter:details", kwargs={"counter_id": self.object.id})
+ "?bad_location"
return is_logged_in_counter(
self.request, counter=self.object
) or self.request.user.has_perms(
["counter.view_selling", "counter.view_refilling"]
)
def get_context_data(self, **kwargs):
+73 -69
View File
@@ -13,41 +13,16 @@
#
#
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic.base import View
from core.views.mixins import TabedViewMixin
from counter.utils import is_logged_in_counter
class CounterAdminMixin(View):
"""Protect counter admin section."""
edit_group = [settings.SITH_GROUP_COUNTER_ADMIN_ID]
edit_club = []
def _test_group(self, user):
return any(user.is_in_group(pk=grp_id) for grp_id in self.edit_group)
def _test_club(self, user):
return any(c.can_be_edited_by(user) for c in self.edit_club)
def dispatch(self, request, *args, **kwargs):
if not (
request.user.is_root
or self._test_group(request.user)
or self._test_club(request.user)
):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class CounterTabsMixin(TabedViewMixin):
def get_tabs_title(self):
return self.object
return self.object.name
def get_list_of_tabs(self):
if self.object.type != "BAR":
@@ -71,7 +46,11 @@ class CounterTabsMixin(TabedViewMixin):
"name": _("Cash summary"),
}
)
if is_logged_in_counter(self.request):
if is_logged_in_counter(
self.request, counter=self.object
) or self.request.user.has_perms(
["counter.view_selling", "counter.view_refilling"]
):
tab_list.append(
{
"url": reverse(
@@ -89,45 +68,70 @@ class CounterTabsMixin(TabedViewMixin):
class CounterAdminTabsMixin(TabedViewMixin):
tabs_title = _("Counter administration")
list_of_tabs = [
{
"url": reverse_lazy("counter:admin_list"),
"slug": "counters",
"name": _("Counters"),
},
{
"url": reverse_lazy("counter:product_list"),
"slug": "products",
"name": _("Products"),
},
{
"url": reverse_lazy("counter:product_formula_list"),
"slug": "formulas",
"name": _("Formulas"),
},
{
"url": reverse_lazy("counter:product_type_list"),
"slug": "product_types",
"name": _("Product types"),
},
{
"url": reverse_lazy("counter:returnable_list"),
"slug": "returnable_products",
"name": _("Returnable products"),
},
{
"url": reverse_lazy("counter:cash_summary_list"),
"slug": "cash_summary",
"name": _("Cash register summaries"),
},
{
"url": reverse_lazy("counter:invoices_call"),
"slug": "invoices_call",
"name": _("Invoices call"),
},
{
"url": reverse_lazy("counter:eticket_list"),
"slug": "etickets",
"name": _("Etickets"),
},
]
def get_list_of_tabs(self):
user = self.request.user
res = [
{
"url": reverse_lazy("counter:admin_list"),
"slug": "counters",
"name": _("Counters"),
}
]
if user.has_perm("counter.view_product"):
res.append(
{
"url": reverse_lazy("counter:product_list"),
"slug": "products",
"name": _("Products"),
}
)
if user.has_perm("counter.view_productformula"):
res.append(
{
"url": reverse_lazy("counter:product_formula_list"),
"slug": "formulas",
"name": _("Formulas"),
}
)
if user.has_perm("counter.view_producttype"):
res.append(
{
"url": reverse_lazy("counter:product_type_list"),
"slug": "product_types",
"name": _("Product types"),
}
)
if user.has_perm("counter.view_returnableproduct"):
res.append(
{
"url": reverse_lazy("counter:returnable_list"),
"slug": "returnable_products",
"name": _("Returnable products"),
}
)
if user.has_perm("counter.view_cashregistersummary"):
res.append(
{
"url": reverse_lazy("counter:cash_summary_list"),
"slug": "cash_summary",
"name": _("Cash register summaries"),
}
)
if user.has_perm("counter.view_invoicecall"):
res.append(
{
"url": reverse_lazy("counter:invoices_call"),
"slug": "invoices_call",
"name": _("Invoices call"),
}
)
if user.has_perm("counter.view_eticket"):
res.append(
{
"url": reverse_lazy("counter:eticket_list"),
"slug": "etickets",
"name": _("Etickets"),
}
)
return res
+5 -7
View File
@@ -48,7 +48,7 @@ des scripts complets mais qui restent lisibles,
```
Ok, c'est de la triche, on installe un package externe.
Mais bon, ça marche, et l'équivalent pur bash
serait carrément plus moche.
serait un pur enfer.
=== "nu"
@@ -59,20 +59,18 @@ des scripts complets mais qui restent lisibles,
Nombre de lignes total :
```nu
ls **/*.py | insert linecount { get name | open | lines | length } | math sum
ls **/*.py | par-each { open $in.name | lines | length } | math sum
```
Vous pouvez aussi exlure les lignes vides
et les les lignes de commentaire :
```nu
ls **/*.py |
insert linecount {
get name |
open |
par-each {
open $in.name |
lines |
each { str trim } |
filter { |l| not ($l | str starts-with "#") } | # commentaires
filter { |l| ($l | str length) > 0 } | # lignes vides
where { |l| ($l | str length) > 0 and not ($l | str starts-with "#") } |
length
} |
math sum
+1
View File
@@ -0,0 +1 @@
::: api.schemas
+1
View File
@@ -0,0 +1 @@
::: api.views
+378
View File
@@ -0,0 +1,378 @@
Le site AE offre des mécanismes permettant aux applications tierces
de récupérer les informations sur un utilisateur du site AE.
De cette manière, il devient possible de synchroniser les informations
qu possède l'application tierce sur l'utilisateur, directement depuis
le site AE.
## Fonctionnement général
Pour authentifier vos utilisateurs, vous aurez besoin d'un serveur web
et d'un client d'API (celui auquel est liée votre
[clef d'API](./connect.md#obtenir-une-clef-dapi)).
Deux informations vous sont nécessaires, en plus de votre clef d'API :
- l'id du client : vous pouvez l'obtenir soit en le demandant à l'équipe info,
soit en appelant la route `GET /api/client/me` avec votre clef d'API
renseignée dans le header [X-APIKey](./connect.md#x-apikey)
- la clef HMAC du client : vous devez la demander à l'équipe info.
Ces deux éléments ont une fonction différente : l'id du client permet
de dire au serveur quelle est l'application qui s'adresse à lui,
tandis que la [clef HMAC](https://fr.wikipedia.org/wiki/HMAC)
servira à créer une signature unique permettant de s'assurer
que les données transmises n'ont pas été falsifiées.
Grâce à ces informations, vous allez pouvoir fournir le contexte nécessaire
au site AE pour qu'il authentifie vos utilisateurs.
En effet, la démarche d'authentification s'effectue presque entièrement
sur le site : le travail de l'application tierce consiste uniquement
à fournir à l'utilisateur une url avec les bons paramètres, puis
à recevoir la réponse du serveur si tout s'est bien passé.
Comme un dessin vaut parfois mieux que mille mots,
voici les diagrammes décrivant le processus.
L'un montre l'entièreté de la démarche ;
l'autre dans un souci de simplicité, ne montre que ce qui est visible
directement par l'application tierce.
=== "Intégralité du processus"
```mermaid
sequenceDiagram
actor User
participant App
User->>+App: Authentifie-moi, stp
App-->>-User: url de connexion<br/>avec signature
User->>+Sith: GET url
opt Utilisateur non-connecté
Sith->>+User: Formulaire de connexion
User-->>-Sith: Connexion
end
Sith->>Sith: vérification de la signature
Sith->>+User: Formulaire<br/>des conditions<br/>d'utilisation
User-->>-Sith: Validation
Sith->>+App: URL de retour<br/>avec données utilisateur
App->>App: Traitement des <br/>données utilisateur
App-->>-Sith: 204 OK, No content
Sith-->>-User: Message de succès
App--)User: Message de succès
```
=== "Point de vue de l'application tierce"
```mermaid
sequenceDiagram
actor User
participant App
User->>+App: Authentifie-moi, stp
App-->>-User: url de connexion<br/>avec signature
opt
Sith->>+App: URL de retour<br/>avec données utilisateur
App->>App: Traitement des <br/>données utilisateur
App-->>-Sith: 204 OK, No content
App--)User: Message de succès
end
```
## Données attendues
### URL de connexion
L'URL de connexion que vous allez fournir à l'utilisateur doit
être `https://ae.utbm.fr/api-link/auth/`
et doit contenir les données décrites dans
[`ThirdPartyAuthParamsSchema`][api.schemas.ThirdPartyAuthParamsSchema] :
- `client_id` (integer) : l'id de votre client, que vous pouvez obtenir
de la manière décrite plus haut
- `third_party_app`(string) : le nom de la plateforme pour laquelle
l'authentification va être réalisée (si votre application est un bot
discord, mettez la valeur "discord")
- `privacy_link`(URL) : l'URL vers la page de politique de confidentialité
qui s'appliquera dans le cadre de l'application
(s'il s'agit d'un bot discord, donnez le lien vers celles de Discord)
- `username`(string) : le pseudonyme que l'utilisateur possède sur
votre application
- `callback_url`(URL) : l'URL que le site AE appellera si l'authentification
réussit
- `signature`(string) : la signature des données de la requête.
Il s'agit d'une signature par clef HMAC dont le fonctionnement
est détaillé plus bas.
Ces données doivent être url-encodées et passées dans les paramètres GET.
!!!warning "URL de retour"
Les URLs fournies doivent être des URLs HTTP valides.
En outre, elles doivent obligatoirement inclure la barre oblique finale.
=== "URL correcte ✔️"
`https://exemple.ae.utbm.fr/foo/`
=== "URL incorrecte ❌"
`https://exemple.ae.utbm.fr/foo`
!!!tip
Inclure l'id de votre utilisateur dans l'URL de retour
peut être un bon moyen de l'identifier lors du callback.
Par exemple : `GET /callback/{int:user_id}/`.
???Example
Supposons que votre client d'API soit utilisé dans le cadre d'un bot Discord,
avec les données suivantes :
- l'id du client est 15
- sa clef HMAC est "beb99dd53"
(c'est pour l'exemple, une vraie clef sera beaucoup plus longue)
- le pseudonyme discord de votre utilisateur est Brian
- son id sur discord est 123456789
- votre route de callback est `GET /callback/{int:user_id}/`,
accessible au domaine `https://bot.ae.utbm.fr`
Alors les paramètres de votre URL seront :
| Paramètre | valeur |
|-----------------|-----------------------------------------------------------------------|
| client_id | 15 |
| third_party_app | discord |
| privacy_link | `https://discord.com/privacy` |
| username | Brian |
| callback_url | `https://bot.ae.utbm.fr/callback/123456789/` |
| signature | 1a383c51060be64f07772aa42e07<br/>18ae096b8f21f2cdb4061c0834a416d12101 |
Et l'url fournie à l'utilisateur sera :
`https://ae.utbm.fr/api-link/auth/?client_id=15&third_party_app=discord
&privacy_link=https%3A%2F%2Fdiscord.com%2Fprivacy&username=Brian
&callback_url=https%3A%2F%2Fbot.ae.utbm.fr%2Fcallback%2F123456789%2F
&signature=1a383c51060be64f07772aa42e0718ae096b8f21f2cdb4061c0834a416d12101`
### Données de retour
Si l'authentification réussit, le site AE enverra une requête HTTP POST
à l'URL de retour fournie dans l'URL de connexion.
Le corps de la requête de callback et au format JSON
et contient deux paires clef-valeur :
- `user` : les données utilisateur, telles que décrites
par [UserProfileSchema][core.schemas.UserProfileSchema]
- `signature` : la signature des données utilisateur
???Example
En reprenant les mêmes paramètres que dans l'exemple précédent,
le site AE pourra renvoyer à l'application la requête suivante :
```http
POST https://bot.ae.utbm.fr/callback/123456789/
content-type: application/json
body: {
"user": {
"id": 144131,
"nick_name": "inzekitchen",
"first_name": "Brian",
...
},
"signature": "f16955bab6b805f6e1abbb98a86dfee53fed0bf812aa6513ca46cfd461b70020"
}
```
L'application doit répondre avec un des codes HTTP suivants :
| Code | Raison |
|------|--------------------------------------------------------------------------------|
| 204 | Tout s'est bien passé |
| 403 | Les données de retour ne sont <br>pas signées ou sont mal signées |
| 404 | L'URL de retour ne permet pas <br>d'identifier un utilisateur de l'application |
!!!note "Code d'erreur par défaut"
Si l'appel de la route fait face à plusieurs problèmes en même temps
(par exemple, l'URL ne permet pas de retrouver votre utilisateur,
et en plus les données sont mal signées),
le 403 prime et doit être retourné par défaut.
## Signature des données
Les données de l'URL de connexion doivent être signées,
et la signature de l'URL de retour doit être vérifiée.
Dans le deux cas, la signature est le digest HMAC-SHA512
des données url-encodées, en utilisant la clef HMAC du client d'API.
L'ordre dans lequel ces données sont placées dans l'encodage URL
doit être strictement le même que celui donné plus haut.
???Example "Signature de l'URL de connexion"
En reprenant le même exemple que les fois précédentes,
l'url-encodage des données est :
`client_id=15&third_party_app=discord
&privacy_link=https%3A%2F%2Fdiscord.com%2Fprivacy%2F&username=Brian
&callback_url=https%3A%2F%2Fbot.ae.utbm.fr%2Fcallback%2F123456789%2F`
Notez que la signature n'est pas (encore) dedans.
Cette dernière peut-être obtenue avec le code suivant :
=== ":simple-python: Python"
Dépendances :
- `environs` (>=14.1)
```python
import hmac
from urllib.parse import urlencode
from environs import Env
env = Env()
env.read_env()
key = env.str("HMAC_KEY").encode()
data = {
"client_id": 15,
"third_party_app": "discord",
"privacy_link": "https://discord.com/privacy/",
"username": "Brian",
"callback_url": "https://bot.ae.utbm.fr/callback/123456789/",
}
urlencoded = urlencode(data)
data["signature"] = hmac.digest(key, urlencoded.encode(), "sha512").hex()
# URL a fournir à l'utilisateur pour son authentification
user_url = f"https://ae.ubtm.fr/api-link/auth/?{urlencode(data)}"
```
=== ":simple-rust: Rust"
Dépendances :
- `hmac` (>=0.12.1)
- `url` (>=2.5.7, features `serde`)
- `serde` (>=1.0.228, features `derive`)
- `serde_urlencoded` (>=0.7.1)
- `sha2` (>=0.10.9)
- `dotenvy` (>= 0.15)
```rust
use hmac::{Mac, SimpleHmac};
use serde::Serialize;
use sha2::Sha512;
use url::Url;
#[derive(Serialize, Debug)]
struct UrlData<'a> {
client_id: u32,
third_party_app: &'a str,
privacy_link: Url,
username: &'a str,
callback_url: Url,
}
impl<'a> UrlData<'a> {
pub fn signature(&self, key: &[u8]) -> CtOutput<SimpleHmac<Sha512>> {
let urlencoded = serde_urlencoded::to_string(self).unwrap();
SimpleHmac::<Sha512>::new_from_slice(key)
.unwrap()
.chain_update(urlencoded.as_bytes())
.finalize()
}
}
impl Into<Url> for UrlData<'_> {
fn into(self) -> Url {
let key = std::env::var("HMAC_KEY").unwrap();
let mut url = Url::parse("http://ae.utbm.fr/api-link/auth/").unwrap();
url.set_query(Some(
format!(
"{}&signature={:x}",
serde_urlencoded::to_string(&self).unwrap(),
self.signature(key.as_bytes()).into_bytes()
)
.as_str(),
));
url
}
}
fn main() {
dotenvy::dotenv().expect("Couldn't load env");
let data = UrlData {
client_id: 1,
third_party_app: "discord",
privacy_link: "https://discord.com/privacy/".parse().unwrap(),
username: "Brian",
callback_url: "https://bot.ae.utbm.fr/callback/123456789/"
.parse()
.unwrap(),
};
let url: Url = data.into();
println!("{:?}", url);
}
```
???Example "Vérification de la signature de la réponse"
Les données utilisateur peuvent ressembler à :
```json
{
"user": {
"display_name": "Matthieu Vincent",
"profile_url": "/user/380/",
"profile_pict": "/static/core/img/unknown.jpg",
"id": 380,
"nick_name": None,
"first_name": "Matthieu",
"last_name": "Vincent",
},
"signature": "3802a280fbb01bd9fetc."
}
```
Vous pouvez vérifier la signature ainsi :
```python
import hmac
from urllib.parse import urlencode
from environs import Env
env = Env()
env.read_env()
def is_signature_valid(user_data: dict, signature: str) -> bool:
key = env.str("HMAC_KEY").encode()
urlencoded = urlencode(user_data)
return hmac.compare_digest(
hmac.digest(key, urlencoded.encode(), "sha512").hex(),
signature,
)
post_data = <récupération des données POST>
print(
"signature valide :",
is_signature_valid(post_data["user"], post_data["signature"])
)
```
!!!Warning
Vous devez impérativement vérifier la signature
des données de la requête de callback !
Ne pas vérifier la signature permet à n'importe quel acteur
tierce malveillant de vous appeler sur votre callback.
Ce serait une faille de sécurité majeure de votre côté.
Si l'équipe informatique se rend compte que vous ne le faites pas,
elle se réserve le droit de suspendre votre application,
immédiatement et sans préavis.
+4 -4
View File
@@ -112,7 +112,7 @@ cf. [HTTP persistant connection (wikipedia)](https://en.wikipedia.org/wiki/HTTP_
Voici quelques exemples :
=== "Python (requests)"
=== ":simple-python: Python (requests)"
Dépendances :
@@ -132,7 +132,7 @@ Voici quelques exemples :
print(response.json())
```
=== "Python (aiohttp)"
=== ":simple-python: Python (aiohttp)"
Dépendances :
@@ -159,7 +159,7 @@ Voici quelques exemples :
asyncio.run(main())
```
=== "Javascript (axios)"
=== ":simple-javascript: Javascript (axios)"
Dépendances :
@@ -179,7 +179,7 @@ Voici quelques exemples :
console.log(await instance.get("club/1").json());
```
=== "Rust (reqwest)"
=== ":simple-rust: Rust (reqwest)"
Dépendances :
+35 -11
View File
@@ -18,7 +18,8 @@ il n'est pas possible de développer le site sur ce système d'exploitation.
Heureusement, il existe une alternative qui ne requiert pas de désinstaller votre
OS ni de mettre un dual boot sur votre ordinateur : `WSL`.
- **Prérequis:** vous devez être sur Windows 10 version 2004 ou ultérieure (build 19041 & versions ultérieures) ou Windows 11.
- **Prérequis:** vous devez être sur Windows 10 version 2004 ou ultérieure (build 19041 & versions ultérieures) ou
Windows 11.
- **Plus d'info:** [docs.microsoft.com](https://docs.microsoft.com/fr-fr/windows/wsl/install)
```shell
@@ -43,7 +44,7 @@ il suffit d'utiliser la commande suivante :
cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
```
!!!note
!!!success
À ce stade, si vous avez réussi votre installation de `WSL` ou bien qu'il
était déjà installé, vous pouvez effectuer la mise en place du projet en suivant
@@ -101,10 +102,10 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
ln -s /usr/bin/valkey-server /usr/bin/redis-server
```
=== "macOS"
Pour installer les dépendances, il est fortement recommandé d'installer le gestionnaire de paquets `homebrew <https://brew.sh/index_fr>`_.
Pour installer les dépendances, il est fortement recommandé
d'installer le gestionnaire de paquets `homebrew <https://brew.sh/index_fr>`_.
Il est également nécessaire d'avoir installé xcode
```bash
@@ -116,12 +117,15 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
!!!note
Si vous rencontrez des erreurs lors de votre configuration, n'hésitez pas à vérifier l'état de votre installation homebrew avec :code:`brew doctor`
Si vous rencontrez des erreurs lors de votre configuration,
n'hésitez pas à vérifier l'état de votre installation homebrew
avec :code:`brew doctor`
!!!note
Python ne fait pas parti des dépendances puisqu'il est automatiquement
installé par uv. Il est cependant parfois nécessaire d'installer les headers Python nécessaire à la compilation de certains paquets.
installé par uv. Il est cependant parfois nécessaire d'installer
les headers Python nécessaire à la compilation de certains paquets.
## Finaliser l'installation
@@ -145,7 +149,6 @@ uv run ./manage.py install_xapian
de texte à l'écran.
C'est normal, il ne faut pas avoir peur.
La modification de la configuration passe par la modification de variables
d'environnement.
Ces variables sont stockées dans un fichier `.env`.
@@ -183,11 +186,32 @@ Il suffit ensuite d'utiliser cette commande :
uv run ./manage.py runserver
```
!!!note
Le serveur est alors accessible à l'adresse
[http://localhost:8000](http://localhost:8000)
ou bien [http://127.0.0.1:8000/](http://127.0.0.1:8000/).
Le serveur est alors accessible à l'adresse
[http://localhost:8000](http://localhost:8000)
ou bien [http://127.0.0.1:8000/](http://127.0.0.1:8000/).
Pour vous connecter, vous pouvez utiliser un des comptes utilisateur
créés durant l'installation :
| username | description |
|------------------|----------------------------------------------------------------------------|
| `root` | super-utilisateur<br/>il peut tout faire et tout voir |
| `subscriber` | simple cotisant |
| `old_subscriber` | ancien cotisant |
| `public` | non-cotisant |
| `krophil` | cotisant<br/>barman Foyer<br/>président du Troll Penché |
| `sli` | cotisant<br/>VP Troll |
| `skia` | cotisant<br/>barman MDE<br/>membre du bureau AE<br/>ancien membre du Troll |
| `comunity` | cotisant<br/>membre du bureau PdF<br/>admin communication |
| `counter` | cotisant<br/>admin comptoirs |
| `comptable` | cotisant<br/>admin comptabilité |
| `rbatsbak` | cotisant<br/>membre du Troll |
En plus de ces dernier, quelques centaines d'utilisateurs
supplémentaires sont générés procéduralement,
afin de rajouter un peu de volume de données.
Tous les utilisateurs ont le mot de passe `plop`.
!!!note
-37
View File
@@ -1,37 +0,0 @@
#
# Copyright 2022
# - Maréchal <thgirod@hotmail.com
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
class PaymentResultConverter:
"""Converter used for url mapping of the `eboutic.views.payment_result` view.
It's meant to build an url that can match
either `/eboutic/pay/success/` or `/eboutic/pay/failure/`
but nothing else.
"""
regex = "(success|failure)"
def to_python(self, value):
return str(value)
def to_url(self, value):
return str(value)
+1 -1
View File
@@ -67,7 +67,7 @@
.fa-plus,
.fa-minus {
cursor: pointer;
background-color: #354a5f;
background-color: var(--deepblue);
color: white;
border-radius: 50%;
padding: 5px;
+2 -2
View File
@@ -24,7 +24,7 @@
from django.urls import path, register_converter
from eboutic.converters import PaymentResultConverter
from core.converters import ResultConverter
from eboutic.views import (
BillingInfoFormFragment,
EbouticCheckout,
@@ -34,7 +34,7 @@ from eboutic.views import (
payment_result,
)
register_converter(PaymentResultConverter, "res")
register_converter(ResultConverter, "res")
urlpatterns = [
# Subscription views
+1 -1
View File
@@ -288,7 +288,7 @@ $min_col_width: 100px;
border: none;
color: black;
text-decoration: none;
background-color: $primary-neutral-light-color;
background-color: var(--bg-white);
padding: 0.4em;
margin: 0.1em;
font-size: 1.18em;
+9 -8
View File
@@ -13,7 +13,8 @@
}
.topic {
border: solid $primary-neutral-color 1px;
border: solid var(--darkened-deepblue) 1px;
border-radius: 5px;
padding: 1px;
margin: 1px;
@@ -23,7 +24,7 @@
}
a {
color: $black-color;
color: var(--text-black);
}
a:hover {
@@ -56,7 +57,7 @@
}
.forum {
background: $primary-neutral-light-color;
background: var(--bg-white);
padding: 1px;
margin: 1px;
@@ -66,7 +67,7 @@
}
a {
color: $black-color;
color: var(--text-black);
}
a:hover {
text-decoration: underline;
@@ -91,7 +92,7 @@
.category {
margin-top: 5px;
background: $secondary-color;
background: var(--deepblue);
color: white;
border-radius: 10px 10px 0 0;
.title {
@@ -105,12 +106,12 @@
display: flex;
flex-direction: column;
gap: 10px;
background: $secondary-neutral-light-color;
background: var(--bg-off-white);
border-radius: 5px;
border: 1px darken($secondary-neutral-light-color, 10%) solid;
border: 1px var(--bg-lightgrey) solid;
&:nth-child(odd) {
background: $primary-neutral-light-color;
background: var(--bg-white);
}
.message-header {
+5 -2
View File
@@ -346,13 +346,16 @@ class ForumTopicDetailView(CanViewMixin, DetailView):
return kwargs
class ForumMessageView(SingleObjectMixin, RedirectView):
class ForumMessageView(SingleObjectMixin, UserPassesTestMixin, RedirectView):
model = ForumMessage
pk_url_kwarg = "message_id"
permanent = False
def get_redirect_url(self, *args, **kwargs):
def test_func(self) -> bool:
self.object = self.get_object()
return self.request.user.can_view(self.object)
def get_redirect_url(self, *args, **kwargs):
return self.object.get_url()
+354 -253
View File
@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-14 11:48+0200\n"
"POT-Creation-Date: 2026-10-27 23:26+0100\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -35,6 +35,10 @@ msgstr ""
"True si gardé à jour par le biais d'un fournisseur externe de domains "
"toxics, False sinon"
#: api/admin.py
msgid "Reset HMAC key"
msgstr "Réinitialiser la clef HMAC"
#: api/admin.py
#, python-format
msgid ""
@@ -48,6 +52,23 @@ msgstr ""
msgid "Revoke selected API keys"
msgstr "Révoquer les clefs d'API sélectionnées"
#: api/forms.py
msgid "I have read and I accept the terms and conditions of use"
msgstr "J'ai lu et j'accepte les conditions générales d'utilisation."
#: api/forms.py
msgid "You must approve the terms and conditions of use."
msgstr "Vous devez approuver les conditions générales d'utilisation."
#: api/forms.py
msgid "You must confirm that this is your username."
msgstr "Vous devez confirmer que c'est bien votre nom d'utilisateur."
#: api/forms.py
#, python-format
msgid "I confirm that %(username)s is my username on %(app)s"
msgstr "Je confirme que %(username)s est mon nom d'utilisateur sur %(app)s"
#: api/models.py club/models.py com/models.py counter/models.py forum/models.py
msgid "name"
msgstr "nom"
@@ -68,6 +89,10 @@ msgstr "permissions du client"
msgid "Specific permissions for this api client."
msgstr "Permissions spécifiques pour ce client d'API"
#: api/models.py
msgid "HMAC Key"
msgstr "Clef HMAC"
#: api/models.py
msgid "api client"
msgstr "client d'api"
@@ -97,51 +122,81 @@ msgstr "clef d'api"
msgid "api keys"
msgstr "clefs d'api"
#: club/forms.py
msgid "Users to add"
msgstr "Utilisateurs à ajouter"
#: api/templates/api/third_party/auth.jinja
msgid "Confidentiality"
msgstr "Confidentialité"
#: club/forms.py core/views/group.py
msgid "Search users to add (one or more)."
msgstr "Recherche les utilisateurs à ajouter (un ou plus)."
#: 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."
#: club/forms.py
msgid "New Mailing"
msgstr "Nouvelle mailing liste"
#: 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."
#: club/forms.py
msgid "Subscribe"
msgstr "S'abonner"
#: api/templates/api/third_party/auth.jinja
msgid "Confirmation of identity"
msgstr "Confirmation d'identité"
#: club/forms.py
msgid "Remove"
msgstr "Retirer"
#: api/views.py
msgid "The data provided for authentication is incorrect"
msgstr "Les données fournies pour l'authentification sont incorrectes."
#: club/forms.py counter/forms.py pedagogy/templates/pedagogy/moderation.jinja
msgid "Action"
msgstr "Action"
#: api/views.py
msgid ""
"The signature is incorrect. We cannot ensure the provenance of the request."
msgstr ""
"La signature est incorrecte. Nous ne pouvons pas garantir l'authenticité de "
"la requête."
#: club/forms.py club/tests/test_mailing.py
msgid "This field is required"
msgstr "Ce champ est obligatoire"
#: 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."
#: club/forms.py club/tests/test_mailing.py
msgid "One of the selected users doesn't have an email address"
msgstr "Un des utilisateurs sélectionnés n'a pas d'adresse email"
#: 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."
#: club/forms.py
msgid "An action is required"
msgstr "Une action est requise"
#: club/forms.py club/tests/test_mailing.py
msgid "You must specify at least an user or an email address"
msgstr "vous devez spécifier au moins un utilisateur ou une adresse email"
#: 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 counter/forms.py
msgid "Begin date"
msgstr "Date de début"
#: club/forms.py com/forms.py counter/forms.py subscription/forms.py
#: club/forms.py com/forms.py counter/forms.py
msgid "End date"
msgstr "Date de fin"
@@ -299,7 +354,8 @@ msgstr ""
"de la présidence."
#: club/models.py core/models.py counter/models.py eboutic/models.py
#: election/models.py pedagogy/models.py sas/models.py trombi/models.py
#: election/models.py pedagogy/models.py sas/models.py timetable/models.py
#: trombi/models.py
msgid "user"
msgstr "utilisateur"
@@ -319,60 +375,6 @@ msgstr "rôle"
msgid "past member"
msgstr "ancien membre"
#: club/models.py 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"
#: club/models.py
msgid "Email address"
msgstr "Adresse email"
#: club/models.py
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
msgid "is moderated"
msgstr "est modéré"
#: club/models.py com/models.py
msgid "moderator"
msgstr "modérateur"
#: club/models.py
msgid "This mailing list already exists."
msgstr "Cette liste de diffusion existe déjà."
#: club/models.py club/templates/club/mailing.jinja
msgid "Mailing"
msgstr "Liste de diffusion"
#: 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
msgid "User"
msgstr "Utilisateur"
#: club/models.py
msgid "At least user or email is required"
msgstr "Au moins un utilisateur ou un email est nécessaire"
#: club/models.py club/tests/test_mailing.py
msgid "This email is already suscribed in this mailing"
msgstr "Cet email est déjà abonné à cette mailing"
#: club/models.py
msgid "Unregistered user"
msgstr "Utilisateur non enregistré"
#: club/models.py
msgid "The base url that links with this type must respect"
msgstr "L'url de base que tous les liens de ce type doivent respecter"
@@ -422,6 +424,22 @@ msgstr "Url dupliquée"
msgid "This link doesn't match with the url base of its type."
msgstr "Ce lien ne correspond pas à l'url de base de son type."
#: club/templates/club/club_detail.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/base.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
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: pedagogy/templates/pedagogy/guide.jinja sas/templates/sas/album.jinja
#: trombi/templates/trombi/detail.jinja
#: trombi/templates/trombi/edit_profile.jinja
msgid "Edit"
msgstr "Éditer"
#: club/templates/club/club_detail.jinja com/templates/com/news_list.jinja
msgid "Links"
msgstr "Liens"
@@ -459,6 +477,13 @@ msgstr "Membres du club"
msgid "Manage roles"
msgstr "Gérer les rôles"
#: club/templates/club/club_members.jinja
#: club/templates/club/club_old_members.jinja
#: counter/templates/counter/cash_summary_list.jinja
#: counter/templates/counter/stats.jinja
msgid "User"
msgstr "Utilisateur"
#: club/templates/club/club_members.jinja
#: club/templates/club/club_old_members.jinja
#: core/templates/core/user_clubs.jinja
@@ -469,6 +494,7 @@ msgid "Role"
msgstr "Rôle"
#: club/templates/club/club_members.jinja
#: club/templates/club/club_news_list.jinja
#: club/templates/club/club_old_members.jinja
#: core/templates/core/group_list.jinja core/templates/core/user_clubs.jinja
msgid "Description"
@@ -483,6 +509,34 @@ msgstr "Depuis"
msgid "There are no members in this club."
msgstr "Il n'y a pas de membres dans ce club."
#: club/templates/club/club_news_list.jinja
#: com/templates/com/news_admin_list.jinja com/templates/com/news_edit.jinja
#: com/templates/com/news_list.jinja core/templates/core/user_tools.jinja
msgid "Create news"
msgstr "Créer une nouvelle"
#: club/templates/club/club_news_list.jinja
#, python-format
msgid "Dates (%(nb_occurrences)s occurrences)"
msgstr "Dates (%(nb_occurrences)s occurrences)"
#: club/templates/club/club_news_list.jinja
msgid "Permalink"
msgstr "Permalien"
#: club/templates/club/club_news_list.jinja
#: com/templates/com/news_admin_list.jinja
msgid "Summary"
msgstr "Résumé"
#: club/templates/club/club_news_list.jinja
msgid "No summary"
msgstr "Pas de Résumé"
#: club/templates/club/club_news_list.jinja
msgid "No detailed description"
msgstr "Pas de description détaillée"
#: club/templates/club/club_old_members.jinja
msgid "Club old members"
msgstr "Anciens membres du club"
@@ -722,8 +776,7 @@ msgstr "Total"
msgid "Payment method"
msgstr "Méthode de paiement"
#: club/templates/club/club_sellings.jinja club/templates/club/mailing.jinja
#: com/templates/com/macros.jinja com/templates/com/mailing_admin.jinja
#: club/templates/club/club_sellings.jinja com/templates/com/macros.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
@@ -849,60 +902,6 @@ msgstr "Ajouter"
msgid "Join"
msgstr "Rejoindre"
#: club/templates/club/mailing.jinja
msgid "Mailing lists"
msgstr "Mailing listes"
#: club/templates/club/mailing.jinja
msgid ""
"Remember : mailing lists need to be moderated, if your new created list is "
"not shown wait until moderation takes action"
msgstr ""
"Rappelez vous : les mailing listes doivent être modérées, si votre liste "
"nouvellement créée n'est pas affichée, attendez jusqu'à ce qu'un modérateur "
"prenne une décision"
#: club/templates/club/mailing.jinja
msgid "Mailing lists waiting for moderation"
msgstr "Listes de diffusions en attente de modération"
#: club/templates/club/mailing.jinja
msgid "Generate mailing list"
msgstr "Générer la liste de diffusion"
#: club/templates/club/mailing.jinja com/templates/com/mailing_admin.jinja
msgid "Email"
msgstr "Email"
#: club/templates/club/mailing.jinja
msgid "Remove from mailing list"
msgstr "Supprimer de la liste de diffusion"
#: club/templates/club/mailing.jinja
msgid "There is no subscriber for this mailing list"
msgstr "Il n'y a pas d'abonnés dans cette liste de diffusion"
#: club/templates/club/mailing.jinja
msgid "No mailing list existing for this club"
msgstr "Aucune mailing liste n'existe pour ce club"
#: club/templates/club/mailing.jinja
#: subscription/templates/subscription/subscription.jinja
msgid "New member"
msgstr "Nouveau membre"
#: club/templates/club/mailing.jinja
msgid "Add to mailing list"
msgstr "Ajouter à la mailing liste"
#: club/templates/club/mailing.jinja
msgid "New mailing"
msgstr "Nouvelle liste de diffusion"
#: club/templates/club/mailing.jinja
msgid "Create mailing list"
msgstr "Créer une liste de diffusion"
#: club/templates/club/pagerev_edit.jinja core/templates/core/page/edit.jinja
msgid "Edit page"
msgstr "Éditer la page"
@@ -919,39 +918,20 @@ msgstr "Anciens membres"
msgid "History"
msgstr "Historique"
#: club/views.py com/templates/com/news_admin_list.jinja
#: core/templates/core/base/navbar.jinja
msgid "Events"
msgstr "Événements"
#: club/views.py core/templates/core/base/header.jinja core/views/user.py
#: sas/templates/sas/picture.jinja trombi/views.py
msgid "Tools"
msgstr "Outils"
#: 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/base.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
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: pedagogy/templates/pedagogy/guide.jinja sas/templates/sas/album.jinja
#: trombi/templates/trombi/detail.jinja
#: trombi/templates/trombi/edit_profile.jinja
msgid "Edit"
msgstr "Éditer"
#: club/views.py
msgid "Edit club page"
msgstr "Éditer la page de club"
#: club/views.py
msgid "Sellings"
msgstr "Vente"
#: club/views.py
msgid "Mailing list"
msgstr "Listes de diffusion"
#: club/views.py
msgid "You are now a member of this club."
msgstr "Vous êtes maintenant membre de ce club."
@@ -998,7 +978,7 @@ msgstr "Prix d'achat"
msgid "Format: 16:9 | Resolution: 1920x1080"
msgstr "Format : 16:9 | Résolution : 1920x1080"
#: com/forms.py subscription/forms.py
#: com/forms.py
msgid "Start date"
msgstr "Date de début"
@@ -1082,6 +1062,10 @@ msgstr "auteur"
msgid "is published"
msgstr "est publié"
#: com/models.py
msgid "moderator"
msgstr "modérateur"
#: com/models.py
msgid "news"
msgstr "nouvelle"
@@ -1142,6 +1126,10 @@ msgstr "fichier"
msgid "display time"
msgstr "temps d'affichage"
#: com/models.py core/models.py
msgid "is moderated"
msgstr "est modéré"
#: com/models.py
msgid "Begin date should be before end date"
msgstr "La date de début doit être avant celle de fin"
@@ -1177,46 +1165,6 @@ msgstr "Nouvelle publiée"
msgid "News deleted"
msgstr "Nouvelle supprimée"
#: com/templates/com/mailing_admin.jinja com/views.py
#: core/templates/core/user_tools.jinja
msgid "Mailing lists administration"
msgstr "Administration des mailing listes"
#: 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"
#: com/templates/com/mailing_admin.jinja com/templates/com/poster_list.jinja
#: core/templates/core/file_detail.jinja
#: core/templates/core/file_moderation.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: sas/templates/sas/moderation.jinja sas/templates/sas/picture.jinja
msgid "Moderate"
msgstr "Modérer"
#: com/templates/com/mailing_admin.jinja
#, python-format
msgid "Moderated by %(user)s"
msgstr "Modéré par %(user)s"
#: com/templates/com/mailing_admin.jinja
msgid "This page lists all mailing lists"
msgstr "Cette page liste toutes les listes de diffusion"
#: com/templates/com/mailing_admin.jinja
msgid "Not moderated mailing lists"
msgstr "Listes de diffusion non modérées"
#: com/templates/com/mailing_admin.jinja
msgid "Moderated mailing lists"
msgstr "Modérer les listes de diffusion"
#: com/templates/com/mailing_admin.jinja
msgid "No mailing list existing"
msgstr "Aucune liste de diffusion existante"
#: com/templates/com/news_admin_list.jinja
msgid "News admin"
msgstr "Administration des nouvelles"
@@ -1226,11 +1174,6 @@ msgstr "Administration des nouvelles"
msgid "News"
msgstr "Nouvelles"
#: com/templates/com/news_admin_list.jinja com/templates/com/news_edit.jinja
#: com/templates/com/news_list.jinja core/templates/core/user_tools.jinja
msgid "Create news"
msgstr "Créer une nouvelle"
#: com/templates/com/news_admin_list.jinja
msgid "Weeklies"
msgstr "Événements hebdomadaires"
@@ -1245,9 +1188,14 @@ msgstr "Événements hebdomadaires affichées"
msgid "Title"
msgstr "Titre"
#: com/templates/com/news_admin_list.jinja
msgid "Summary"
msgstr "Résumé"
#: 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"
#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja
#: forum/templates/forum/forum.jinja sas/models.py
@@ -1263,6 +1211,11 @@ msgstr "Modérateur"
msgid "Dates"
msgstr "Dates"
#: com/templates/com/news_admin_list.jinja com/templates/com/weekmail.jinja
#: counter/templates/counter/refilling_list.jinja
msgid "Actions"
msgstr "Actions"
#: com/templates/com/news_admin_list.jinja core/templates/core/file.jinja
#: core/templates/core/page/base.jinja
msgid "View"
@@ -1276,11 +1229,6 @@ msgstr "Dépublier"
msgid "Weeklies to moderate"
msgstr "Nouvelles hebdomadaires à modérer"
#: com/templates/com/news_admin_list.jinja
#: core/templates/core/base/navbar.jinja
msgid "Events"
msgstr "Événements"
#: com/templates/com/news_admin_list.jinja
msgid "Displayed events"
msgstr "Événements affichés"
@@ -1423,6 +1371,13 @@ msgstr "Créer"
msgid "Click to expand"
msgstr "Cliquez pour agrandir"
#: com/templates/com/poster_list.jinja core/templates/core/file_detail.jinja
#: core/templates/core/file_moderation.jinja
#: pedagogy/templates/pedagogy/fragments/ue_detail/comments.jinja
#: sas/templates/sas/moderation.jinja sas/templates/sas/picture.jinja
msgid "Moderate"
msgstr "Modérer"
#: com/templates/com/poster_list.jinja
msgid "No posters"
msgstr "Aucune affiche"
@@ -1778,7 +1733,7 @@ msgstr "département"
msgid "dpt option"
msgstr "Filière"
#: core/models.py pedagogy/models.py
#: core/models.py pedagogy/models.py timetable/models.py
msgid "semester"
msgstr "semestre"
@@ -1837,6 +1792,10 @@ msgstr ""
"Même si ce profil est caché, les utilisateurs sur cette liste pourront "
"toujours le voir."
#: core/models.py
msgid "ToS approved at"
msgstr "CGU approuvées le"
#: core/models.py
msgid "A user with that username already exists"
msgstr "Un utilisateur de ce nom d'utilisateur existe déjà"
@@ -1899,6 +1858,18 @@ msgstr ""
"Autorise les cotisants (ou les personnes whitelistées, si votre profil est "
"caché) à accéder aux statistiques de votre compte AE"
#: core/models.py
msgid "show your timetable to others"
msgstr "montrez votre emploi du temps aux autres"
#: core/models.py
msgid ""
"Allow subscribers to see your timetable and to compare it to theirs (if you "
"have filled it)"
msgstr ""
"Permet aux cotisants de voir votre emploi du temps et de le comparer au leur "
"(si vous l'avez enregistré)"
#: core/models.py
msgid "get a notification for every click"
msgstr "avoir une notification pour chaque click"
@@ -2113,7 +2084,8 @@ msgstr "R&D"
msgid "Site created by the IT Department of the AE"
msgstr "Site réalisé par le Pôle Informatique de l'AE"
#: core/templates/core/base/header.jinja core/templates/core/login.jinja
#: core/templates/core/base/header.jinja core/templates/core/cgu_approve.jinja
#: core/templates/core/login.jinja
#: core/templates/core/password_reset_complete.jinja
msgid "Login"
msgstr "Connexion"
@@ -2211,6 +2183,18 @@ msgstr "FAQ"
msgid "Wiki"
msgstr "Wiki"
#: core/templates/core/cgu_approve.jinja
msgid "Terms of Service"
msgstr "Conditions générales d'utilisation"
#: core/templates/core/cgu_approve.jinja
msgid ""
"To continue using our services, please read and approve the AE website's "
"terms of service"
msgstr ""
"Pour continuer à utiliser nos services, veuillez lire et approuver les "
"conditions générales d'utilisation du site AE."
#: core/templates/core/create.jinja
#, python-format
msgid "Create %(name)s"
@@ -2716,14 +2700,6 @@ msgstr "Clubs actuels : "
msgid "Old club(s) :"
msgstr "Anciens clubs :"
#: core/templates/core/user_clubs.jinja
msgid "Subscribed mailing lists"
msgstr "Mailing listes abonnées"
#: core/templates/core/user_clubs.jinja
msgid "Unsubscribe"
msgstr "Se désabonner"
#: core/templates/core/user_detail.jinja
#, python-format
msgid "%(user_name)s's profile"
@@ -3134,10 +3110,27 @@ msgstr "Erreur d'envoi du fichier %(file_name)s : %(msg)s"
msgid "Apply rights recursively"
msgstr "Appliquer les droits récursivement"
#: core/views/files.py
msgid "You cannot move this file there"
msgstr "Vous ne pouvez pas déplacer ce fichier là"
#: core/views/forms.py
msgid "Ensure this timestamp is set in the future"
msgstr "Assurez-vous que cet horodatage est dans le futur"
#: core/views/forms.py
msgid "You must approve the terms of service."
msgstr "Vous devez approuver les conditions générales d'utilisation"
#: core/views/forms.py
#, python-format
msgid ""
"I have read and I approve the <a href=\"%(url)s\" target=\"_blank\">Terms of "
"Service</a>"
msgstr ""
"J'ai lu et j'approuve les <a href=\"%(url)s\" target=\"_blank\">Conditions "
"Générales d'utilisation</a>"
#: core/views/forms.py
msgid "Username, email, or account number"
msgstr "Nom d'utilisateur, email, ou numéro de compte AE"
@@ -3192,6 +3185,10 @@ msgstr "%s est déjà votre fillot/fillote"
msgid "Users to add to group"
msgstr "Utilisateurs à ajouter au groupe"
#: core/views/group.py
msgid "Search users to add (one or more)."
msgstr "Recherche les utilisateurs à ajouter (un ou plus)."
#: core/views/group.py
msgid "Users to remove from group"
msgstr "Utilisateurs à retirer du groupe"
@@ -3234,6 +3231,14 @@ msgstr "Cet UID est invalide"
msgid "User not found"
msgstr "Utilisateur non trouvé"
#: counter/forms.py
msgid "This user cannot buy items on counters."
msgstr "Cet utilisateur ne peut pas faire d'achats sur les comptoirs."
#: counter/forms.py
msgid "This user is banned from AE places. Take him/her out if you can."
msgstr "Cette personne est ban comptoir. Faites-la sortir si vous le pouvez."
#: counter/forms.py
msgid "You are not a barman of this counter."
msgstr "Vous n'êtes pas barman sur ce comptoir."
@@ -3291,6 +3296,10 @@ msgstr ""
"temps, mais les utilisateurs suivants ont été définis comme les deux : "
"%(users)s"
#: counter/forms.py pedagogy/templates/pedagogy/moderation.jinja
msgid "Action"
msgstr "Action"
#: counter/forms.py
msgid "Date and time of action"
msgstr "Date et heure de l'action"
@@ -3977,7 +3986,6 @@ msgstr "Nouvelle formule"
#: counter/templates/counter/fragments/create_student_card.jinja
#: counter/templates/counter/invoices_call.jinja
#: sas/templates/sas/picture.jinja
#: subscription/templates/subscription/stats.jinja
msgid "Go"
msgstr "Valider"
@@ -4112,9 +4120,12 @@ msgid "Remove price"
msgstr "Retirer le prix"
#: counter/templates/counter/product_form.jinja
#, python-format
msgid "Edit product %(name)s"
msgstr "Édition du produit %(name)s"
msgid ""
"You can see product details. However, you cannot edit it, thus you won't be "
"able to submit this form."
msgstr ""
"Vous pouvez voir ce produit. Cependant, vous ne pouvez pas l'éditer ; vous "
"ne serez donc pas en mesure de soumettre le formulaire."
#: counter/templates/counter/product_form.jinja
msgid "Creation date"
@@ -4286,6 +4297,11 @@ msgstr ""
msgid "returnable product : %(returnable)s -> %(returned)s"
msgstr "produit consigné : %(returnable)s -> %(returned)s"
#: counter/views/admin.py
#, python-format
msgid "%(user)s's AE account has been emptied"
msgstr "Le compte de %(user)s a été vidé"
#: counter/views/cash.py
msgid "10 cents"
msgstr "10 centimes"
@@ -5073,7 +5089,7 @@ msgstr "heures THE"
msgid "hours TE"
msgstr "heures TE"
#: pedagogy/models.py
#: pedagogy/models.py timetable/models.py
msgid "ue"
msgstr "UE"
@@ -5241,6 +5257,22 @@ msgstr "Catégorie"
msgid "Credits"
msgstr "Crédits"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Lectures"
msgstr "Cours magistraux"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Tutorials"
msgstr "Travaux dirigés"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Practicals"
msgstr "Travaux pratiques"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Work without supervision"
msgstr "Travail hors encadrement"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Availability"
msgstr "Disponibilité"
@@ -5719,10 +5751,6 @@ msgstr "Cursus alternant (-20%)"
msgid "A new poster needs to be moderated"
msgstr "Une nouvelle affiche a besoin d'être modérée"
#: sith/settings.py
msgid "A new mailing list needs to be moderated"
msgstr "Une nouvelle mailing list a besoin d'être modérée"
#: sith/settings.py
msgid "A new pedagogy comment has been signaled for moderation"
msgstr ""
@@ -5844,21 +5872,61 @@ msgid "Create another subscription"
msgstr "Créer une nouvelle cotisation"
#: subscription/templates/subscription/stats.jinja
msgid "Total subscriptions"
msgstr "Cotisations totales"
#: subscription/templates/subscription/stats.jinja
msgid "Subscriptions by type"
msgstr "Cotisations par type"
msgid "All subscriptions"
msgstr "Toutes les cotisations"
#: subscription/templates/subscription/subscription.jinja
msgid "Existing member"
msgstr "Membre existant"
#: subscription/templates/subscription/subscription.jinja
msgid "New member"
msgstr "Nouveau membre"
#: subscription/views.py
msgid "the groups that can create subscriptions"
msgstr "les groupes pouvant créer des cotisations"
#: timetable/models.py
msgid "timetable"
msgstr "emploi du temps"
#: timetable/models.py
msgid "timetables"
msgstr "emplois du temps"
#: timetable/models.py
msgid "weekday"
msgstr "jour de la semaine"
#: timetable/models.py
msgid "start at"
msgstr "début"
#: timetable/models.py
msgid "end at"
msgstr "fin"
#: timetable/models.py
msgid "course type"
msgstr "type de cours"
#: timetable/models.py
msgid "room"
msgstr "salle"
#: timetable/models.py
msgid "week group"
msgstr "groupe de semaine"
#: timetable/models.py
msgid "timetable slot"
msgstr "créneau d'emploi du temps"
#: timetable/models.py
msgid "timetable slots"
msgstr "créneaux d'emploi du temps"
#: timetable/templates/timetable/generator.jinja
msgid "Timetable generator"
msgstr "Générateur d'emploi du temps"
@@ -5889,8 +5957,45 @@ msgid "Generate"
msgstr "Générer"
#: timetable/templates/timetable/generator.jinja
msgid "Save to PNG"
msgstr "Sauver en PNG"
msgid "Save on your profile"
msgstr "Enregistrer sur votre profil"
#: timetable/templates/timetable/generator.jinja
msgid "Make this timetable public"
msgstr "Rendre cet emploi du temps publique"
#: timetable/templates/timetable/generator.jinja
#, python-format
msgid ""
"You can change this at any time in <a href=\"%(url)s\">your preferences</a>"
msgstr ""
"Vous pouvez changer ceci n'importe quand dans <a href=\"%(url)s\">vos "
"préférences</a>"
#: timetable/templates/timetable/generator.jinja
msgid "Save timetable"
msgstr "Enregistrer l'emploi du temps"
#: timetable/templates/timetable/generator.jinja
#: trombi/templates/trombi/detail.jinja
msgid "Export"
msgstr "Export"
#: timetable/templates/timetable/generator.jinja
msgid "Export to PNG"
msgstr "Exporter en PNG"
#: timetable/views.py
msgid "Timetables"
msgstr "Emplois du temps"
#: timetable/views.py
msgid "Generator"
msgstr "Générateur"
#: timetable/views.py
msgid "My timetables"
msgstr "Mes emplois du temps"
#: trombi/models.py
msgid "subscription deadline"
@@ -6005,10 +6110,6 @@ msgstr "Fin des inscriptions : "
msgid "Comment deadline: "
msgstr "Fin des commentaires : "
#: trombi/templates/trombi/detail.jinja
msgid "Export"
msgstr "Exporter"
#: trombi/templates/trombi/detail.jinja
msgid "Add user"
msgstr "Ajouter une personne"
+3
View File
@@ -71,6 +71,7 @@ nav:
- API:
- Développement: tutorial/api/dev.md
- Connexion à l'API: tutorial/api/connect.md
- Liaison avec le compte AE: tutorial/api/account-link.md
- Markdown AE: tutorial/markdown.md
- Etransactions: tutorial/etransaction.md
- How-to:
@@ -95,6 +96,8 @@ nav:
- reference/api/hashers.md
- reference/api/models.md
- reference/api/perms.md
- reference/api/schemas.md
- reference/api/views.md
- club:
- reference/club/models.md
- reference/club/views.md
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff. Show more