mirror of
https://github.com/ae-utbm/sith.git
synced 2026-04-20 18:28:22 +00:00
Compare commits
47 Commits
discord-au
...
club-role-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b3bac9f93 | ||
|
|
25c797fff8 | ||
|
|
eac730e7a9 | ||
|
|
b02ec4e812 | ||
|
|
0bf86583a9 | ||
|
|
3e9ed4e1ef | ||
|
|
a8904c51d7 | ||
|
|
636cb3f263 | ||
|
|
9b862cfefc | ||
|
|
937f1e2535 | ||
|
|
619136bbb9 | ||
|
|
0e1981408e | ||
|
|
1d43703b33 | ||
|
|
55f4306527 | ||
|
|
8799928c55 | ||
|
|
a4d4b3459a | ||
|
|
e39615ce74 | ||
|
|
d708815ef9 | ||
|
|
0e119f8b08 | ||
| 1d525ca6d4 | |||
|
4dea60ac66
|
|||
| 30df859039 | |||
|
9c3f846f98
|
|||
| 64ebe30f5e | |||
|
744ea20c33
|
|||
|
3e0a1c7334
|
|||
|
ef036c135c
|
|||
| 366d6c7f03 | |||
| 2151bbf4c7 | |||
|
64b3acff07
|
|||
|
6ef8b6b159
|
|||
|
39dee782cc
|
|||
|
|
bda65f39af | ||
|
|
dfe884484d | ||
|
a0cce91bd5
|
|||
|
|
0f00c91b59 | ||
|
|
b5d8db0187 | ||
|
|
26178f10c5 | ||
|
|
842ae5615d | ||
|
|
5cd748e313 | ||
|
|
2944804074 | ||
| fcce34fde5 | |||
|
a213bc058d
|
|||
|
71bfdf68b7
|
|||
|
2a9bb46e97
|
|||
|
efdf71d69e
|
|||
|
3bc4f1300e
|
@@ -17,15 +17,6 @@ class ApiClientAdmin(admin.ModelAdmin):
|
||||
"owner__nick_name",
|
||||
)
|
||||
autocomplete_fields = ("owner", "groups", "client_permissions")
|
||||
readonly_fields = ("hmac_key",)
|
||||
actions = ("reset_hmac_key",)
|
||||
|
||||
@admin.action(permissions=["change"], description=_("Reset HMAC key"))
|
||||
def reset_hmac_key(self, _request: HttpRequest, queryset: QuerySet[ApiClient]):
|
||||
objs = list(queryset)
|
||||
for obj in objs:
|
||||
obj.reset_hmac(commit=False)
|
||||
ApiClient.objects.bulk_update(objs, fields=["hmac_key"])
|
||||
|
||||
|
||||
@admin.register(ApiKey)
|
||||
|
||||
16
api/api.py
16
api/api.py
@@ -1,16 +0,0 @@
|
||||
from ninja_extra import ControllerBase, api_controller, route
|
||||
|
||||
from api.auth import ApiKeyAuth
|
||||
from api.schemas import ApiClientSchema
|
||||
|
||||
|
||||
@api_controller("/client")
|
||||
class ApiClientController(ControllerBase):
|
||||
@route.get(
|
||||
"/me",
|
||||
auth=[ApiKeyAuth()],
|
||||
response=ApiClientSchema,
|
||||
url_name="api-client-infos",
|
||||
)
|
||||
def get_client_info(self):
|
||||
return self.context.request.auth
|
||||
35
api/forms.py
35
api/forms.py
@@ -1,35 +0,0 @@
|
||||
from django import forms
|
||||
from django.forms import HiddenInput
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class ThirdPartyAuthForm(forms.Form):
|
||||
"""Form to complete to authenticate on the sith from a third-party app.
|
||||
|
||||
For the form to be valid, the user approve the EULA (french: CGU)
|
||||
and give its username from the third-party app.
|
||||
"""
|
||||
|
||||
cgu_accepted = forms.BooleanField(
|
||||
required=True,
|
||||
label=_("I have read and I accept the terms and conditions of use"),
|
||||
error_messages={
|
||||
"required": _("You must approve the terms and conditions of use.")
|
||||
},
|
||||
)
|
||||
is_username_valid = forms.BooleanField(
|
||||
required=True,
|
||||
error_messages={"required": _("You must confirm that this is your username.")},
|
||||
)
|
||||
client_id = forms.IntegerField(widget=HiddenInput())
|
||||
third_party_app = forms.CharField(widget=HiddenInput())
|
||||
privacy_link = forms.URLField(widget=HiddenInput())
|
||||
username = forms.CharField(widget=HiddenInput())
|
||||
callback_url = forms.URLField(widget=HiddenInput())
|
||||
signature = forms.CharField(widget=HiddenInput())
|
||||
|
||||
def __init__(self, *args, label_suffix: str = "", initial, **kwargs):
|
||||
super().__init__(*args, label_suffix=label_suffix, initial=initial, **kwargs)
|
||||
self.fields["is_username_valid"].label = _(
|
||||
"I confirm that %(username)s is my username on %(app)s"
|
||||
) % {"username": initial.get("username"), "app": initial.get("third_party_app")}
|
||||
@@ -1,19 +0,0 @@
|
||||
# Generated by Django 5.2.3 on 2025-10-26 10:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import api.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("api", "0001_initial")]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="apiclient",
|
||||
name="hmac_key",
|
||||
field=models.CharField(
|
||||
default=api.models.get_hmac_key, max_length=128, verbose_name="HMAC Key"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,20 +1,13 @@
|
||||
import secrets
|
||||
from typing import Iterable
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import pgettext_lazy
|
||||
|
||||
from core.models import Group, User
|
||||
|
||||
|
||||
def get_hmac_key():
|
||||
return secrets.token_hex(64)
|
||||
|
||||
|
||||
class ApiClient(models.Model):
|
||||
name = models.CharField(_("name"), max_length=64)
|
||||
owner = models.ForeignKey(
|
||||
@@ -33,10 +26,11 @@ class ApiClient(models.Model):
|
||||
help_text=_("Specific permissions for this api client."),
|
||||
related_name="clients",
|
||||
)
|
||||
hmac_key = models.CharField(_("HMAC Key"), max_length=128, default=get_hmac_key)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
_perm_cache: set[str] | None = None
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("api client")
|
||||
verbose_name_plural = _("api clients")
|
||||
@@ -44,38 +38,33 @@ class ApiClient(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@cached_property
|
||||
def all_permissions(self) -> set[str]:
|
||||
permissions = (
|
||||
Permission.objects.filter(
|
||||
Q(group__group__in=self.groups.all()) | Q(clients=self)
|
||||
)
|
||||
def has_perm(self, perm: str):
|
||||
"""Return True if the client has the specified permission."""
|
||||
|
||||
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()
|
||||
)
|
||||
return {f"{content_type}.{name}" for content_type, name in permissions}
|
||||
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_perm(self, perm: str):
|
||||
"""Return True if the client has the specified permission."""
|
||||
return perm in self.all_permissions
|
||||
|
||||
def has_perms(self, perm_list: Iterable[str]) -> bool:
|
||||
"""Return True if the client has each of the specified permissions."""
|
||||
def has_perms(self, perm_list):
|
||||
"""
|
||||
Return True if the client has each of the specified permissions. If
|
||||
object is passed, check if the client has all required perms for it.
|
||||
"""
|
||||
if not isinstance(perm_list, Iterable) or isinstance(perm_list, str):
|
||||
raise ValueError("perm_list must be an iterable of permissions.")
|
||||
return all(self.has_perm(perm) for perm in perm_list)
|
||||
|
||||
def reset_hmac(self, *, commit: bool = True) -> str:
|
||||
"""Reset and return the HMAC key for this client.
|
||||
|
||||
Args:
|
||||
commit: if True (the default), persist the new hmac in db.
|
||||
"""
|
||||
self.hmac_key = get_hmac_key()
|
||||
if commit:
|
||||
self.save()
|
||||
return self.hmac_key
|
||||
|
||||
|
||||
class ApiKey(models.Model):
|
||||
PREFIX_LENGTH = 5
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
from ninja import ModelSchema, Schema
|
||||
from pydantic import Field, HttpUrl
|
||||
|
||||
from api.models import ApiClient
|
||||
from core.schemas import SimpleUserSchema
|
||||
|
||||
|
||||
class ApiClientSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = ApiClient
|
||||
fields = ["id", "name"]
|
||||
|
||||
owner: SimpleUserSchema
|
||||
permissions: list[str] = Field(alias="all_permissions")
|
||||
|
||||
|
||||
class ThirdPartyAuthParamsSchema(Schema):
|
||||
client_id: int
|
||||
third_party_app: str
|
||||
privacy_link: HttpUrl
|
||||
username: str
|
||||
callback_url: HttpUrl
|
||||
signature: str
|
||||
32
api/templates/api/third_party/auth.jinja
vendored
32
api/templates/api/third_party/auth.jinja
vendored
@@ -1,32 +0,0 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block content %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<h3>{% trans %}Confidentiality{% endtrans %}</h3>
|
||||
<p>
|
||||
{% trans trimmed app=third_party_app %}
|
||||
By ticking this box and clicking on the send button, you
|
||||
acknowledge and agree to provide {{ app }} with your
|
||||
first name, last name, nickname and any other information
|
||||
that was the third party app was explicitly authorized to fetch
|
||||
and that it must have acknowledged to you, in a complete and accurate manner.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
<p class="margin-bottom">
|
||||
{% trans trimmed app=third_party_app, privacy_link=third_party_cgu, sith_cgu_link=sith_cgu %}
|
||||
The privacy policies of <a href="{{ privacy_link }}">{{ app }}</a>
|
||||
and of <a href="{{ sith_cgu_link }}">the Students' Association</a>
|
||||
applies as soon as the form is submitted.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
<div class="row">{{ form.cgu_accepted }} {{ form.cgu_accepted.label_tag() }}</div>
|
||||
<br>
|
||||
<h3 class="margin-bottom">{% trans %}Confirmation of identity{% endtrans %}</h3>
|
||||
<div class="row margin-bottom">
|
||||
{{ form.is_username_valid }} {{ form.is_username_valid.label_tag() }}
|
||||
</div>
|
||||
{% for field in form.hidden_fields() %}{{ field }}{% endfor %}
|
||||
<input type="submit" class="btn btn-blue">
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -1,24 +0,0 @@
|
||||
import pytest
|
||||
from django.contrib.admin import AdminSite
|
||||
from django.http import HttpRequest
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertNumQueries
|
||||
|
||||
from api.admin import ApiClientAdmin
|
||||
from api.models import ApiClient
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reset_hmac_action():
|
||||
client_admin = ApiClientAdmin(ApiClient, AdminSite())
|
||||
api_clients = baker.make(ApiClient, _quantity=4, _bulk_create=True)
|
||||
old_hmac_keys = [c.hmac_key for c in api_clients]
|
||||
with assertNumQueries(2):
|
||||
qs = ApiClient.objects.filter(id__in=[c.id for c in api_clients[2:4]])
|
||||
client_admin.reset_hmac_key(HttpRequest(), qs)
|
||||
for c in api_clients:
|
||||
c.refresh_from_db()
|
||||
assert api_clients[0].hmac_key == old_hmac_keys[0]
|
||||
assert api_clients[1].hmac_key == old_hmac_keys[1]
|
||||
assert api_clients[2].hmac_key != old_hmac_keys[2]
|
||||
assert api_clients[3].hmac_key != old_hmac_keys[3]
|
||||
@@ -1,18 +0,0 @@
|
||||
import pytest
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
|
||||
from api.hashers import generate_key
|
||||
from api.models import ApiClient, ApiKey
|
||||
from api.schemas import ApiClientSchema
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_api_client_controller(client: Client):
|
||||
key, hashed = generate_key()
|
||||
api_client = baker.make(ApiClient)
|
||||
baker.make(ApiKey, client=api_client, hashed_key=hashed)
|
||||
res = client.get(reverse("api:api-client-infos"), headers={"X-APIKey": key})
|
||||
assert res.status_code == 200
|
||||
assert res.json() == ApiClientSchema.from_orm(api_client).model_dump()
|
||||
@@ -1,59 +0,0 @@
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import TestCase
|
||||
from model_bakery import baker
|
||||
|
||||
from api.models import ApiClient
|
||||
from core.models import Group
|
||||
|
||||
|
||||
class TestClientPermissions(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.api_client = baker.make(ApiClient)
|
||||
cls.perms = baker.make(Permission, _quantity=10, _bulk_create=True)
|
||||
cls.api_client.groups.set(
|
||||
[
|
||||
baker.make(Group, permissions=cls.perms[0:3]),
|
||||
baker.make(Group, permissions=cls.perms[3:5]),
|
||||
]
|
||||
)
|
||||
cls.api_client.client_permissions.set(
|
||||
[cls.perms[3], cls.perms[5], cls.perms[6], cls.perms[7]]
|
||||
)
|
||||
|
||||
def test_all_permissions(self):
|
||||
assert self.api_client.all_permissions == {
|
||||
f"{p.content_type.app_label}.{p.codename}" for p in self.perms[0:8]
|
||||
}
|
||||
|
||||
def test_has_perm(self):
|
||||
assert self.api_client.has_perm(
|
||||
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}"
|
||||
)
|
||||
assert not self.api_client.has_perm(
|
||||
f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}"
|
||||
)
|
||||
|
||||
def test_has_perms(self):
|
||||
assert self.api_client.has_perms(
|
||||
[
|
||||
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}",
|
||||
f"{self.perms[2].content_type.app_label}.{self.perms[2].codename}",
|
||||
]
|
||||
)
|
||||
assert not self.api_client.has_perms(
|
||||
[
|
||||
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}",
|
||||
f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reset_hmac_key():
|
||||
client = baker.make(ApiClient)
|
||||
original_key = client.hmac_key
|
||||
client.reset_hmac(commit=True)
|
||||
assert len(client.hmac_key) == len(original_key)
|
||||
assert client.hmac_key != original_key
|
||||
@@ -1,114 +0,0 @@
|
||||
from unittest import mock
|
||||
from unittest.mock import Mock
|
||||
|
||||
from django.db.models import Max
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from api.models import ApiClient, get_hmac_key
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.schemas import UserProfileSchema
|
||||
from core.utils import hmac_hexdigest
|
||||
|
||||
|
||||
def mocked_post(*, ok: bool):
|
||||
class MockedResponse(Mock):
|
||||
@property
|
||||
def ok(self):
|
||||
return ok
|
||||
|
||||
def mocked():
|
||||
return MockedResponse()
|
||||
|
||||
return mocked
|
||||
|
||||
|
||||
class TestThirdPartyAuth(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = subscriber_user.make()
|
||||
cls.api_client = baker.make(ApiClient)
|
||||
|
||||
def setUp(self):
|
||||
self.query = {
|
||||
"client_id": self.api_client.id,
|
||||
"third_party_app": "app",
|
||||
"privacy_link": "https://foobar.fr/",
|
||||
"username": "bibou",
|
||||
"callback_url": "https://callback.fr/",
|
||||
}
|
||||
self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query)
|
||||
self.callback_data = {
|
||||
"user": UserProfileSchema.from_orm(self.user).model_dump()
|
||||
}
|
||||
self.callback_data["signature"] = hmac_hexdigest(
|
||||
self.api_client.hmac_key, self.callback_data["user"]
|
||||
)
|
||||
|
||||
def test_auth_ok(self):
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
|
||||
assert res.status_code == 200
|
||||
with mock.patch("requests.post", new_callable=mocked_post(ok=True)) as mocked:
|
||||
res = self.client.post(
|
||||
reverse("api-link:third-party-auth"),
|
||||
data={"cgu_accepted": True, "is_username_valid": True, **self.query},
|
||||
)
|
||||
mocked.assert_called_once_with(
|
||||
self.query["callback_url"], json=self.callback_data
|
||||
)
|
||||
assertRedirects(
|
||||
res,
|
||||
reverse("api-link:third-party-auth-result", kwargs={"result": "success"}),
|
||||
)
|
||||
|
||||
def test_callback_error(self):
|
||||
"""Test that the user see the failure page if the callback request failed."""
|
||||
self.client.force_login(self.user)
|
||||
with mock.patch("requests.post", new_callable=mocked_post(ok=False)) as mocked:
|
||||
res = self.client.post(
|
||||
reverse("api-link:third-party-auth"),
|
||||
data={"cgu_accepted": True, "is_username_valid": True, **self.query},
|
||||
)
|
||||
mocked.assert_called_once_with(
|
||||
self.query["callback_url"], json=self.callback_data
|
||||
)
|
||||
assertRedirects(
|
||||
res,
|
||||
reverse("api-link:third-party-auth-result", kwargs={"result": "failure"}),
|
||||
)
|
||||
|
||||
def test_wrong_signature(self):
|
||||
"""Test that a 403 is raised if the signature of the query is wrong."""
|
||||
self.client.force_login(subscriber_user.make())
|
||||
new_key = get_hmac_key()
|
||||
del self.query["signature"]
|
||||
self.query["signature"] = hmac_hexdigest(new_key, self.query)
|
||||
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
|
||||
assert res.status_code == 403
|
||||
|
||||
def test_cgu_not_accepted(self):
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
|
||||
assert res.status_code == 200
|
||||
res = self.client.post(reverse("api-link:third-party-auth"), data=self.query)
|
||||
assert res.status_code == 200 # no redirect means invalid form
|
||||
res = self.client.post(
|
||||
reverse("api-link:third-party-auth"),
|
||||
data={"cgu_accepted": False, "is_username_valid": False, **self.query},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
def test_invalid_client(self):
|
||||
self.query["client_id"] = ApiClient.objects.aggregate(res=Max("id"))["res"] + 1
|
||||
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
|
||||
assert res.status_code == 403
|
||||
|
||||
def test_missing_parameter(self):
|
||||
"""Test that a 403 is raised if there is a missing parameter."""
|
||||
del self.query["username"]
|
||||
self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query)
|
||||
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
|
||||
assert res.status_code == 403
|
||||
15
api/urls.py
15
api/urls.py
@@ -1,10 +1,6 @@
|
||||
from django.urls import path, register_converter
|
||||
from ninja.security import SessionAuth
|
||||
from ninja_extra import NinjaExtraAPI
|
||||
|
||||
from api.views import ThirdPartyAuthResultView, ThirdPartyAuthView
|
||||
from core.converters import ResultConverter
|
||||
|
||||
api = NinjaExtraAPI(
|
||||
title="PICON",
|
||||
description="Portail Interactif de Communication avec les Outils Numériques",
|
||||
@@ -13,14 +9,3 @@ api = NinjaExtraAPI(
|
||||
auth=[SessionAuth()],
|
||||
)
|
||||
api.auto_discover_controllers()
|
||||
|
||||
register_converter(ResultConverter, "res")
|
||||
|
||||
urlpatterns = [
|
||||
path("auth/", ThirdPartyAuthView.as_view(), name="third-party-auth"),
|
||||
path(
|
||||
"auth/<res:result>/",
|
||||
ThirdPartyAuthResultView.as_view(),
|
||||
name="third-party-auth-result",
|
||||
),
|
||||
]
|
||||
|
||||
119
api/views.py
119
api/views.py
@@ -1,119 +0,0 @@
|
||||
import hmac
|
||||
from urllib.parse import unquote
|
||||
|
||||
import pydantic
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import FormView, TemplateView
|
||||
from ninja_extra.shortcuts import get_object_or_none
|
||||
|
||||
from api.forms import ThirdPartyAuthForm
|
||||
from api.models import ApiClient
|
||||
from api.schemas import ThirdPartyAuthParamsSchema
|
||||
from core.models import SithFile
|
||||
from core.schemas import UserProfileSchema
|
||||
from core.utils import hmac_hexdigest
|
||||
|
||||
|
||||
class ThirdPartyAuthView(LoginRequiredMixin, FormView):
|
||||
form_class = ThirdPartyAuthForm
|
||||
template_name = "api/third_party/auth.jinja"
|
||||
success_url = reverse_lazy("core:index")
|
||||
|
||||
def parse_params(self) -> ThirdPartyAuthParamsSchema:
|
||||
"""Parse and check the authentication parameters.
|
||||
|
||||
Raises:
|
||||
PermissionDenied: if the verification failed.
|
||||
"""
|
||||
# This is here rather than in ThirdPartyAuthForm because
|
||||
# the given parameters and their signature are checked during both
|
||||
# POST (for obvious reasons) and GET (in order not to make
|
||||
# the user fill a form just to get an error he won't understand)
|
||||
params = self.request.GET or self.request.POST
|
||||
params = {key: unquote(val) for key, val in params.items()}
|
||||
try:
|
||||
params = ThirdPartyAuthParamsSchema(**params)
|
||||
except pydantic.ValidationError as e:
|
||||
raise PermissionDenied("Wrong data format") from e
|
||||
client: ApiClient = get_object_or_none(ApiClient, id=params.client_id)
|
||||
if not client:
|
||||
raise PermissionDenied
|
||||
if not hmac.compare_digest(
|
||||
hmac_hexdigest(client.hmac_key, params.model_dump(exclude={"signature"})),
|
||||
params.signature,
|
||||
):
|
||||
raise PermissionDenied("Bad signature")
|
||||
return params
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.params = self.parse_params()
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
messages.warning(
|
||||
self.request,
|
||||
_(
|
||||
"You are going to link your AE account and your %(app)s account. "
|
||||
"Continue only if this page was opened from %(app)s."
|
||||
)
|
||||
% {"app": self.params.third_party_app},
|
||||
)
|
||||
return super().get(*args, **kwargs)
|
||||
|
||||
def get_initial(self):
|
||||
return self.params.model_dump()
|
||||
|
||||
def form_valid(self, form):
|
||||
client = ApiClient.objects.get(id=form.cleaned_data["client_id"])
|
||||
user = UserProfileSchema.from_orm(self.request.user).model_dump()
|
||||
data = {"user": user, "signature": hmac_hexdigest(client.hmac_key, user)}
|
||||
response = requests.post(form.cleaned_data["callback_url"], json=data)
|
||||
self.success_url = reverse(
|
||||
"api-link:third-party-auth-result",
|
||||
kwargs={"result": "success" if response.ok else "failure"},
|
||||
)
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(**kwargs) | {
|
||||
"third_party_app": self.params.third_party_app,
|
||||
"third_party_cgu": self.params.privacy_link,
|
||||
"sith_cgu": SithFile.objects.get(id=settings.SITH_CGU_FILE_ID),
|
||||
}
|
||||
|
||||
|
||||
class ThirdPartyAuthResultView(LoginRequiredMixin, TemplateView):
|
||||
"""View that the user will see if its authentication on sith was successful.
|
||||
|
||||
This can show either a success or a failure message :
|
||||
- success : everything is good, the user is successfully authenticated
|
||||
and can close the page
|
||||
- failure : the authentication has been processed on the sith side,
|
||||
but the request to the callback url received an error.
|
||||
In such a case, there is nothing much we can do but to advice
|
||||
the user to contact the developers of the third-party app.
|
||||
"""
|
||||
|
||||
template_name = "core/base.jinja"
|
||||
success_message = _(
|
||||
"You have been successfully authenticated. You can now close this page."
|
||||
)
|
||||
error_message = _(
|
||||
"Your authentication on the AE website was successful, "
|
||||
"but an error happened during the interaction "
|
||||
"with the third-party application. "
|
||||
"Please contact the managers of the latter."
|
||||
)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
if self.kwargs.get("result") == "success":
|
||||
messages.success(request, self.success_message)
|
||||
else:
|
||||
messages.error(request, self.error_message)
|
||||
return super().get(request, *args, **kwargs)
|
||||
@@ -13,8 +13,10 @@
|
||||
#
|
||||
#
|
||||
from django.contrib import admin
|
||||
from django.forms.models import ModelForm
|
||||
from django.http import HttpRequest
|
||||
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
|
||||
|
||||
@admin.register(Club)
|
||||
@@ -29,6 +31,31 @@ class ClubAdmin(admin.ModelAdmin):
|
||||
"page",
|
||||
)
|
||||
|
||||
def save_model(
|
||||
self,
|
||||
request: HttpRequest,
|
||||
obj: Club,
|
||||
form: ModelForm,
|
||||
change: bool, # noqa: FBT001
|
||||
):
|
||||
super().save_model(request, obj, form, change)
|
||||
if not change:
|
||||
obj.create_default_roles()
|
||||
|
||||
|
||||
@admin.register(ClubRole)
|
||||
class ClubRoleAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "club", "is_board", "is_presidency")
|
||||
search_fields = ("name",)
|
||||
autocomplete_fields = ("club",)
|
||||
list_select_related = ("club",)
|
||||
list_filter = (
|
||||
"is_board",
|
||||
"is_presidency",
|
||||
("club", admin.RelatedOnlyFieldListFilter),
|
||||
)
|
||||
show_facets = admin.ModelAdmin.show_facets.ALWAYS
|
||||
|
||||
|
||||
@admin.register(Membership)
|
||||
class MembershipAdmin(admin.ModelAdmin):
|
||||
|
||||
11
club/api.py
11
club/api.py
@@ -6,7 +6,7 @@ from ninja_extra.pagination import PageNumberPaginationExtra
|
||||
from ninja_extra.schemas import PaginatedResponseSchema
|
||||
|
||||
from api.auth import ApiKeyAuth
|
||||
from api.permissions import CanAccessLookup, CanView, HasPerm
|
||||
from api.permissions import CanView, HasPerm
|
||||
from club.models import Club, Membership
|
||||
from club.schemas import (
|
||||
ClubSchema,
|
||||
@@ -22,13 +22,11 @@ class ClubController(ControllerBase):
|
||||
@route.get(
|
||||
"/search",
|
||||
response=PaginatedResponseSchema[SimpleClubSchema],
|
||||
auth=[ApiKeyAuth(), SessionAuth()],
|
||||
permissions=[CanAccessLookup],
|
||||
url_name="search_club",
|
||||
)
|
||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||
def search_club(self, filters: Query[ClubSearchFilterSchema]):
|
||||
return filters.filter(Club.objects.all())
|
||||
return filters.filter(Club.objects.order_by("name")).values()
|
||||
|
||||
@route.get(
|
||||
"/{int:club_id}",
|
||||
@@ -39,7 +37,8 @@ class ClubController(ControllerBase):
|
||||
)
|
||||
def fetch_club(self, club_id: int):
|
||||
prefetch = Prefetch(
|
||||
"members", queryset=Membership.objects.ongoing().select_related("user")
|
||||
"members",
|
||||
queryset=Membership.objects.ongoing().select_related("user", "role"),
|
||||
)
|
||||
return self.get_object_or_exception(
|
||||
Club.objects.prefetch_related(prefetch), id=club_id
|
||||
@@ -61,5 +60,5 @@ class UserClubController(ControllerBase):
|
||||
return (
|
||||
Membership.objects.ongoing()
|
||||
.filter(user=user)
|
||||
.select_related("club", "user")
|
||||
.select_related("club", "user", "role")
|
||||
)
|
||||
|
||||
130
club/forms.py
130
club/forms.py
@@ -23,13 +23,12 @@
|
||||
#
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.db.models import Exists, OuterRef, Q, QuerySet
|
||||
from django.db.models.functions import Lower
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from club.models import Club, Mailing, MailingSubscription, Membership
|
||||
from club.models import Club, ClubRole, Mailing, MailingSubscription, Membership
|
||||
from core.models import User
|
||||
from core.views.forms import SelectDateTime
|
||||
from core.views.widgets.ajax_select import (
|
||||
@@ -215,9 +214,7 @@ class ClubOldMemberForm(forms.Form):
|
||||
|
||||
def __init__(self, *args, user: User, club: Club, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["members_old"].queryset = (
|
||||
Membership.objects.ongoing().filter(club=club).editable_by(user)
|
||||
)
|
||||
self.fields["members_old"].queryset = club.members.ongoing().editable_by(user)
|
||||
|
||||
|
||||
class ClubMemberForm(forms.ModelForm):
|
||||
@@ -235,19 +232,14 @@ class ClubMemberForm(forms.ModelForm):
|
||||
self.request_user = request_user
|
||||
self.request_user_membership = self.club.get_membership_for(self.request_user)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["role"].required = True
|
||||
self.fields["role"].choices = [
|
||||
(value, name)
|
||||
for value, name in settings.SITH_CLUB_ROLES.items()
|
||||
if value <= self.max_available_role
|
||||
]
|
||||
self.fields["role"].queryset = self.available_roles
|
||||
self.instance.club = club
|
||||
|
||||
@property
|
||||
def max_available_role(self):
|
||||
"""The greatest role that will be obtainable with this form."""
|
||||
def available_roles(self) -> QuerySet[ClubRole]:
|
||||
"""The roles that will be obtainable with this form."""
|
||||
# this is unreachable, because it will be overridden by subclasses
|
||||
return -1 # pragma: no cover
|
||||
return ClubRole.objects.none() # pragma: no cover
|
||||
|
||||
|
||||
class ClubAddMemberForm(ClubMemberForm):
|
||||
@@ -258,21 +250,22 @@ class ClubAddMemberForm(ClubMemberForm):
|
||||
widgets = {"user": AutoCompleteSelectUser}
|
||||
|
||||
@cached_property
|
||||
def max_available_role(self):
|
||||
"""The greatest role that will be obtainable with this form.
|
||||
def available_roles(self):
|
||||
"""The roles that will be obtainable with this form.
|
||||
|
||||
Admins and the club president can attribute any role.
|
||||
Board members can attribute roles lower than their own.
|
||||
Other users cannot attribute roles with this form
|
||||
"""
|
||||
qs = self.club.roles.filter(is_active=True)
|
||||
if self.request_user.has_perm("club.add_membership"):
|
||||
return settings.SITH_CLUB_ROLES_ID["President"]
|
||||
return qs.all()
|
||||
membership = self.request_user_membership
|
||||
if membership is None or membership.role <= settings.SITH_MAXIMUM_FREE_ROLE:
|
||||
return -1
|
||||
if membership.role == settings.SITH_CLUB_ROLES_ID["President"]:
|
||||
return membership.role
|
||||
return membership.role - 1
|
||||
if membership is None or not membership.role.is_board:
|
||||
return ClubRole.objects.none()
|
||||
if membership.role.is_presidency:
|
||||
return qs.all()
|
||||
return qs.above_instance(membership.role)
|
||||
|
||||
def clean_user(self):
|
||||
"""Check that the user is not trying to add a user already in the club.
|
||||
@@ -296,13 +289,11 @@ class JoinClubForm(ClubMemberForm):
|
||||
|
||||
def __init__(self, *args, club: Club, request_user: User, **kwargs):
|
||||
super().__init__(*args, club=club, request_user=request_user, **kwargs)
|
||||
# this form doesn't manage the user who will join the club,
|
||||
# so we must set this here to avoid errors
|
||||
self.instance.user = self.request_user
|
||||
|
||||
@cached_property
|
||||
def max_available_role(self):
|
||||
return settings.SITH_MAXIMUM_FREE_ROLE
|
||||
def available_roles(self):
|
||||
return self.club.roles.filter(is_board=False, is_active=True)
|
||||
|
||||
def clean(self):
|
||||
"""Check that the user is subscribed and isn't already in the club."""
|
||||
@@ -315,3 +306,88 @@ class JoinClubForm(ClubMemberForm):
|
||||
_("You are already a member of this club"), code="invalid"
|
||||
)
|
||||
return super().clean()
|
||||
|
||||
|
||||
class ClubSearchForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Club
|
||||
fields = ["name"]
|
||||
widgets = {"name": forms.SearchInput(attrs={"autocomplete": "off"})}
|
||||
|
||||
club_status = forms.NullBooleanField(
|
||||
label=_("Club status"),
|
||||
widget=forms.RadioSelect(
|
||||
choices=[(True, _("Active")), (False, _("Inactive")), ("", _("All clubs"))],
|
||||
),
|
||||
initial=True,
|
||||
)
|
||||
|
||||
def __init__(self, *args, data: dict | None = None, **kwargs):
|
||||
super().__init__(*args, data=data, **kwargs)
|
||||
if data is not None and "club_status" not in data:
|
||||
# if the key is missing, it is considered as None,
|
||||
# even though we want the default True value to be applied in such a case
|
||||
# so we enforce it.
|
||||
self.fields["club_status"].value = True
|
||||
self.fields["name"].required = False
|
||||
|
||||
|
||||
class ClubRoleForm(forms.ModelForm):
|
||||
error_css_class = "error"
|
||||
required_css_class = "required"
|
||||
|
||||
class Meta:
|
||||
model = ClubRole
|
||||
fields = ["name", "description", "is_presidency", "is_board", "is_active"]
|
||||
widgets = {
|
||||
"is_presidency": forms.HiddenInput(),
|
||||
"is_board": forms.HiddenInput(),
|
||||
"is_active": forms.CheckboxInput(attrs={"class": "switch"}),
|
||||
}
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
if "ORDER" in cleaned_data:
|
||||
self.instance.order = cleaned_data["ORDER"]
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class ClubRoleCreateForm(forms.ModelForm):
|
||||
"""Form to create a club role.
|
||||
|
||||
Notes:
|
||||
For UX purposes, users are not meant to fill `is_presidency`
|
||||
and `is_board`, so those values are required by the form constructor
|
||||
in order to initialize the instance properly.
|
||||
"""
|
||||
|
||||
error_css_class = "error"
|
||||
required_css_class = "required"
|
||||
|
||||
class Meta:
|
||||
model = ClubRole
|
||||
fields = ["name", "description"]
|
||||
|
||||
def __init__(
|
||||
self, *args, club: Club, is_presidency: bool, is_board: bool, **kwargs
|
||||
):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.instance.club = club
|
||||
self.instance.is_presidency = is_presidency
|
||||
self.instance.is_board = is_board
|
||||
|
||||
|
||||
class ClubRoleBaseFormSet(forms.BaseInlineFormSet):
|
||||
ordering_widget = forms.HiddenInput()
|
||||
|
||||
|
||||
ClubRoleFormSet = forms.inlineformset_factory(
|
||||
Club,
|
||||
ClubRole,
|
||||
ClubRoleForm,
|
||||
ClubRoleBaseFormSet,
|
||||
can_delete=False,
|
||||
can_order=True,
|
||||
edit_only=True,
|
||||
extra=0,
|
||||
)
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.db.models.functions.datetime
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
from django.db.migrations.state import StateApps
|
||||
from django.db.models import Q
|
||||
from django.utils.timezone import localdate
|
||||
|
||||
# Before the club role rework, the maximum free role
|
||||
# was the hardcoded highest non-board role
|
||||
MAXIMUM_FREE_ROLE = 1
|
||||
|
||||
|
||||
def migrate_meta_groups(apps: StateApps, schema_editor):
|
||||
"""Attach the existing meta groups to the clubs.
|
||||
@@ -46,10 +49,7 @@ def migrate_meta_groups(apps: StateApps, schema_editor):
|
||||
).select_related("user")
|
||||
club.members_group.users.set([m.user for m in memberships])
|
||||
club.board_group.users.set(
|
||||
[
|
||||
m.user
|
||||
for m in memberships.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
|
||||
]
|
||||
[m.user for m in memberships.filter(role__gt=MAXIMUM_FREE_ROLE)]
|
||||
)
|
||||
|
||||
|
||||
|
||||
161
club/migrations/0015_clubrole_alter_membership_role.py
Normal file
161
club/migrations/0015_clubrole_alter_membership_role.py
Normal file
@@ -0,0 +1,161 @@
|
||||
# Generated by Django 5.2.3 on 2025-06-21 21:59
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
from django.db.migrations.state import StateApps
|
||||
from django.db.models import Case, When
|
||||
|
||||
PRESIDENCY_ROLES = [10, 9]
|
||||
MAXIMUM_FREE_ROLE = 1
|
||||
SITH_CLUB_ROLES = {
|
||||
10: "Président⸱e",
|
||||
9: "Vice-Président⸱e",
|
||||
7: "Trésorier⸱e",
|
||||
5: "Responsable communication",
|
||||
4: "Secrétaire",
|
||||
3: "Responsable info",
|
||||
2: "Membre du bureau",
|
||||
1: "Membre actif⸱ve",
|
||||
0: "Curieux⸱euse",
|
||||
}
|
||||
|
||||
|
||||
def migrate_roles(apps: StateApps, schema_editor):
|
||||
ClubRole = apps.get_model("club", "ClubRole")
|
||||
Membership = apps.get_model("club", "Membership")
|
||||
|
||||
updates = []
|
||||
for club_id, role in Membership.objects.values_list("club", "role").distinct():
|
||||
new_role = ClubRole.objects.create(
|
||||
name=SITH_CLUB_ROLES[role],
|
||||
is_board=role > MAXIMUM_FREE_ROLE,
|
||||
is_presidency=role in PRESIDENCY_ROLES,
|
||||
club_id=club_id,
|
||||
order=max(SITH_CLUB_ROLES) - role,
|
||||
)
|
||||
updates.append(When(role=role, then=new_role.id))
|
||||
# all updates must happen at the same time
|
||||
# otherwise, the 10 first created ClubRole would be
|
||||
# re-modified after their initial creation, and it would
|
||||
# result in an incoherent state.
|
||||
# To avoid that, all updates are wrapped in a single giant Case(When) statement
|
||||
# cf. https://docs.djangoproject.com/fr/stable/ref/models/conditional-expressions/#conditional-update
|
||||
Membership.objects.update(role=Case(*updates))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more"),
|
||||
("core", "0047_alter_notification_date_alter_notification_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="club",
|
||||
name="page",
|
||||
field=models.OneToOneField(
|
||||
blank=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="club",
|
||||
to="core.page",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ClubRole",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"order",
|
||||
models.PositiveIntegerField(
|
||||
db_index=True, editable=False, verbose_name="order"
|
||||
),
|
||||
),
|
||||
(
|
||||
"club",
|
||||
models.ForeignKey(
|
||||
help_text="The club with which this role is associated",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="roles",
|
||||
to="club.club",
|
||||
verbose_name="club",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=50, verbose_name="name")),
|
||||
(
|
||||
"description",
|
||||
models.TextField(
|
||||
default="", blank=True, verbose_name="description"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_board",
|
||||
models.BooleanField(default=False, verbose_name="Board role"),
|
||||
),
|
||||
(
|
||||
"is_presidency",
|
||||
models.BooleanField(default=False, verbose_name="Presidency role"),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text=(
|
||||
"If the role is inactive, people joining the club "
|
||||
"won't be able to get it."
|
||||
),
|
||||
verbose_name="is active",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ("order",),
|
||||
"verbose_name": "club role",
|
||||
"verbose_name_plural": "club roles",
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="club",
|
||||
name="board_group",
|
||||
field=models.OneToOneField(
|
||||
editable=False,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="club_board",
|
||||
to="core.group",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="club",
|
||||
name="members_group",
|
||||
field=models.OneToOneField(
|
||||
editable=False,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="club",
|
||||
to="core.group",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="clubrole",
|
||||
constraint=models.CheckConstraint(
|
||||
condition=models.Q(
|
||||
("is_presidency", False), ("is_board", True), _connector="OR"
|
||||
),
|
||||
name="clubrole_presidency_implies_board",
|
||||
violation_error_message=(
|
||||
"A role cannot be in the presidency while not being in the board"
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_roles, migrations.RunPython.noop),
|
||||
# because Postgres migrations run in a single transaction,
|
||||
# we cannot change the actual values of Membership.role
|
||||
# and apply the FOREIGN KEY constraint in the same migration.
|
||||
# The constraint is created in the next migration
|
||||
]
|
||||
25
club/migrations/0016_clubrole_alter_membership_role.py
Normal file
25
club/migrations/0016_clubrole_alter_membership_role.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.3 on 2025-09-27 09:57
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("club", "0015_clubrole_alter_membership_role")]
|
||||
|
||||
operations = [
|
||||
# because Postgres migrations run in a single transaction,
|
||||
# we cannot change the actual values of Membership.role
|
||||
# and apply the FOREIGN KEY constraint in the same migration.
|
||||
# The data migration was made in the previous migration.
|
||||
migrations.AlterField(
|
||||
model_name="membership",
|
||||
name="role",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="members",
|
||||
to="club.clubrole",
|
||||
verbose_name="role",
|
||||
),
|
||||
),
|
||||
]
|
||||
191
club/models.py
191
club/models.py
@@ -28,15 +28,15 @@ 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.db import models, transaction
|
||||
from django.db.models import Exists, F, OuterRef, Q, Value
|
||||
from django.db.models.functions import Greatest
|
||||
from django.db import ProgrammingError, models, transaction
|
||||
from django.db.models import Exists, F, OuterRef, Q
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.text import slugify
|
||||
from django.utils.timezone import localdate
|
||||
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
|
||||
@@ -89,13 +89,13 @@ class Club(models.Model):
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
page = models.OneToOneField(
|
||||
Page, related_name="club", blank=True, on_delete=models.CASCADE
|
||||
Page, related_name="club", blank=True, on_delete=models.PROTECT
|
||||
)
|
||||
members_group = models.OneToOneField(
|
||||
Group, related_name="club", on_delete=models.PROTECT
|
||||
Group, related_name="club", on_delete=models.PROTECT, editable=False
|
||||
)
|
||||
board_group = models.OneToOneField(
|
||||
Group, related_name="club_board", on_delete=models.PROTECT
|
||||
Group, related_name="club_board", on_delete=models.PROTECT, editable=False
|
||||
)
|
||||
|
||||
objects = ClubQuerySet.as_manager()
|
||||
@@ -138,9 +138,7 @@ class Club(models.Model):
|
||||
@cached_property
|
||||
def president(self) -> Membership | None:
|
||||
"""Fetch the membership of the current president of this club."""
|
||||
return self.members.filter(
|
||||
role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
|
||||
).first()
|
||||
return self.members.filter(end_date=None).order_by("role__order").first()
|
||||
|
||||
def check_loop(self):
|
||||
"""Raise a validation error when a loop is found within the parent list."""
|
||||
@@ -185,6 +183,40 @@ class Club(models.Model):
|
||||
self.page.parent = self.parent.page
|
||||
self.page.save(force_lock=True)
|
||||
|
||||
def create_default_roles(self):
|
||||
"""Create some roles that should exist by default for this club.
|
||||
|
||||
The created roles are : president, treasurer, active member and curious.
|
||||
|
||||
Warnings:
|
||||
When calling this method, no club must exist yet for this club.
|
||||
"""
|
||||
if self.roles.exists():
|
||||
raise ProgrammingError(
|
||||
"Default roles can be created only for clubs "
|
||||
"that don't have associated roles yet"
|
||||
)
|
||||
# The names are written in French, because there is no gettext involved
|
||||
# for strings stored in database, and the majority of users are french.
|
||||
roles = [
|
||||
ClubRole(name="Président⸱e", is_board=True, is_presidency=True),
|
||||
ClubRole(name="Trésorier⸱e", is_board=True, is_presidency=False),
|
||||
ClubRole(name="Membre actif⸱ve", is_board=False, is_presidency=False),
|
||||
ClubRole(
|
||||
name="Curieux⸱euse",
|
||||
description=(
|
||||
"Les gens qui suivent l'activité "
|
||||
"du club sans forcément y participer"
|
||||
),
|
||||
is_board=False,
|
||||
is_presidency=False,
|
||||
),
|
||||
]
|
||||
for i, role in enumerate(roles):
|
||||
role.club = self
|
||||
role.order = i
|
||||
ClubRole.objects.bulk_create(roles)
|
||||
|
||||
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
|
||||
self.board_group.delete()
|
||||
self.members_group.delete()
|
||||
@@ -206,9 +238,20 @@ class Club(models.Model):
|
||||
"""Method to see if that object can be edited by the given user."""
|
||||
return 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"""
|
||||
return user.is_authenticated and (
|
||||
user.has_perm("club.change_clubrole")
|
||||
or self.members.ongoing()
|
||||
.filter(user=user, role__is_presidency=True)
|
||||
.exists()
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def current_members(self) -> list[Membership]:
|
||||
return list(self.members.ongoing().select_related("user").order_by("-role"))
|
||||
return list(
|
||||
self.members.ongoing().select_related("user", "role").order_by("-role")
|
||||
)
|
||||
|
||||
def get_membership_for(self, user: User) -> Membership | None:
|
||||
"""Return the current membership of the given user."""
|
||||
@@ -220,6 +263,95 @@ class Club(models.Model):
|
||||
return user.is_in_group(pk=self.board_group_id)
|
||||
|
||||
|
||||
class ClubRole(OrderedModel):
|
||||
club = models.ForeignKey(
|
||||
Club,
|
||||
verbose_name=_("club"),
|
||||
help_text=_("The club with which this role is associated"),
|
||||
related_name="roles",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
name = models.CharField(_("name"), max_length=50)
|
||||
description = models.TextField(_("description"), blank=True, default="")
|
||||
is_board = models.BooleanField(_("Board role"), default=False)
|
||||
is_presidency = models.BooleanField(_("Presidency role"), default=False)
|
||||
is_active = models.BooleanField(
|
||||
_("is active"),
|
||||
default=True,
|
||||
help_text=_(
|
||||
"If the role is inactive, people joining the club won't be able to get it."
|
||||
),
|
||||
)
|
||||
|
||||
order_with_respect_to = "club"
|
||||
|
||||
class Meta(OrderedModel.Meta):
|
||||
verbose_name = _("club role")
|
||||
verbose_name_plural = _("club roles")
|
||||
constraints = [
|
||||
# presidency IMPLIES board <=> NOT presidency OR board
|
||||
# cf. MT1 :)
|
||||
models.CheckConstraint(
|
||||
condition=Q(is_presidency=False) | Q(is_board=True),
|
||||
name="clubrole_presidency_implies_board",
|
||||
violation_error_message=_(
|
||||
"A role cannot be in the presidency while not being in the board"
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_display_name(self):
|
||||
return f"{self.name} - {self.club.name}"
|
||||
|
||||
def clean(self):
|
||||
errors = []
|
||||
roles = list(self.club.roles.all())
|
||||
if (
|
||||
self.is_board
|
||||
and self.order
|
||||
and any(r.order < self.order and not r.is_board for r in roles)
|
||||
):
|
||||
errors.append(
|
||||
ValidationError(
|
||||
_("Role %(role)s cannot be placed below a member role")
|
||||
% {"role": self.name}
|
||||
)
|
||||
)
|
||||
if (
|
||||
self.is_presidency
|
||||
and self.order
|
||||
and any(r.order < self.order and not r.is_presidency for r in roles)
|
||||
):
|
||||
errors.append(
|
||||
ValidationError(
|
||||
_("Role %(role)s cannot be placed below a non-presidency role")
|
||||
% {"role": self.name}
|
||||
)
|
||||
)
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
return super().clean()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
auto_order = self.order is None and self.is_board
|
||||
if not auto_order:
|
||||
super().save(*args, **kwargs)
|
||||
return
|
||||
# get the role that should be placed after the role we are dealing with.
|
||||
# So, if this is role is presidency, get the first board role ;
|
||||
# if it is a board role, get the first member role ;
|
||||
# and if it is a member role, get nothing (OrderedModel.save will
|
||||
# automatically put it in the last position anyway)
|
||||
filters = {"is_board": self.is_presidency, "is_presidency": False}
|
||||
next_role = self.club.roles.filter(**filters).order_by("order").first()
|
||||
super().save(*args, **kwargs)
|
||||
if next_role:
|
||||
self.above(next_role)
|
||||
|
||||
|
||||
class MembershipQuerySet(models.QuerySet):
|
||||
def ongoing(self) -> Self:
|
||||
"""Filter all memberships which are not finished yet."""
|
||||
@@ -232,9 +364,10 @@ class MembershipQuerySet(models.QuerySet):
|
||||
are included, even if there are no more members.
|
||||
|
||||
If you want to get the users who are currently in the board,
|
||||
mind combining this with the `ongoing` queryset method
|
||||
mind combining this with the [MembershipQuerySet.ongoing][]
|
||||
queryset method
|
||||
"""
|
||||
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
|
||||
return self.filter(role__is_board=True)
|
||||
|
||||
def editable_by(self, user: User) -> Self:
|
||||
"""Filter Memberships that this user can edit.
|
||||
@@ -257,21 +390,16 @@ class MembershipQuerySet(models.QuerySet):
|
||||
"""
|
||||
if user.has_perm("club.change_membership"):
|
||||
return self.all()
|
||||
return self.filter(
|
||||
return self.ongoing().filter(
|
||||
Q(user=user)
|
||||
| Exists(
|
||||
Membership.objects.filter(
|
||||
Q(
|
||||
role__gt=Greatest(
|
||||
OuterRef("role"), Value(settings.SITH_MAXIMUM_FREE_ROLE)
|
||||
)
|
||||
),
|
||||
Membership.objects.ongoing().filter(
|
||||
user=user,
|
||||
end_date=None,
|
||||
club=OuterRef("club"),
|
||||
role__is_board=True,
|
||||
role__order__lt=OuterRef("role__order"),
|
||||
)
|
||||
)
|
||||
),
|
||||
end_date=None,
|
||||
)
|
||||
|
||||
def update(self, **kwargs) -> int:
|
||||
@@ -341,10 +469,11 @@ class Membership(models.Model):
|
||||
)
|
||||
start_date = models.DateField(_("start date"), default=timezone.now)
|
||||
end_date = models.DateField(_("end date"), null=True, blank=True)
|
||||
role = models.IntegerField(
|
||||
_("role"),
|
||||
choices=sorted(settings.SITH_CLUB_ROLES.items()),
|
||||
default=sorted(settings.SITH_CLUB_ROLES.items())[0][0],
|
||||
role = models.ForeignKey(
|
||||
ClubRole,
|
||||
verbose_name=_("role"),
|
||||
related_name="members",
|
||||
on_delete=models.PROTECT,
|
||||
)
|
||||
description = models.CharField(
|
||||
_("description"), max_length=128, null=False, blank=True
|
||||
@@ -362,7 +491,7 @@ class Membership(models.Model):
|
||||
def __str__(self):
|
||||
return (
|
||||
f"{self.club.name} - {self.user.username} "
|
||||
f"- {settings.SITH_CLUB_ROLES[self.role]} "
|
||||
f"- {self.role.name} "
|
||||
f"- {str(_('past member')) if self.end_date is not None else ''}"
|
||||
)
|
||||
|
||||
@@ -391,7 +520,11 @@ class Membership(models.Model):
|
||||
if user.is_root or user.is_board_member:
|
||||
return True
|
||||
membership = self.club.get_membership_for(user)
|
||||
return membership is not None and membership.role >= self.role
|
||||
if not membership:
|
||||
return False
|
||||
return membership.user_id == user.id or (
|
||||
membership.is_board and membership.role.order < self.role.order
|
||||
)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
self._remove_club_groups([self])
|
||||
@@ -467,7 +600,7 @@ class Membership(models.Model):
|
||||
group_id=membership.club.members_group_id,
|
||||
)
|
||||
)
|
||||
if membership.role > settings.SITH_MAXIMUM_FREE_ROLE:
|
||||
if membership.role.is_board:
|
||||
club_groups.append(
|
||||
User.groups.through(
|
||||
user_id=membership.user_id,
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Annotated
|
||||
from django.db.models import Q
|
||||
from ninja import FilterLookup, FilterSchema, ModelSchema
|
||||
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.schemas import NonEmptyStr, SimpleUserSchema
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class ClubProfileSchema(ModelSchema):
|
||||
|
||||
class Meta:
|
||||
model = Club
|
||||
fields = ["id", "name", "logo"]
|
||||
fields = ["id", "name", "logo", "is_active", "short_description"]
|
||||
|
||||
url: str
|
||||
|
||||
@@ -39,14 +39,21 @@ class ClubProfileSchema(ModelSchema):
|
||||
return obj.get_absolute_url()
|
||||
|
||||
|
||||
class ClubRoleSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = ClubRole
|
||||
fields = ["id", "name", "is_presidency", "is_board"]
|
||||
|
||||
|
||||
class ClubMemberSchema(ModelSchema):
|
||||
"""A schema to represent all memberships in a club."""
|
||||
|
||||
class Meta:
|
||||
model = Membership
|
||||
fields = ["start_date", "end_date", "role", "description"]
|
||||
fields = ["start_date", "end_date", "description"]
|
||||
|
||||
user: SimpleUserSchema
|
||||
role: ClubRoleSchema
|
||||
|
||||
|
||||
class ClubSchema(ModelSchema):
|
||||
@@ -62,6 +69,7 @@ class UserMembershipSchema(ModelSchema):
|
||||
|
||||
class Meta:
|
||||
model = Membership
|
||||
fields = ["id", "start_date", "role", "description"]
|
||||
fields = ["id", "start_date", "description"]
|
||||
|
||||
club: SimpleClubSchema
|
||||
role: ClubRoleSchema
|
||||
|
||||
61
club/static/bundled/club/role-list-index.ts
Normal file
61
club/static/bundled/club/role-list-index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { AlpineComponent } from "alpinejs";
|
||||
|
||||
interface RoleGroupData {
|
||||
isBoard: boolean;
|
||||
isPresidency: boolean;
|
||||
roleId: number;
|
||||
}
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("clubRoleList", (config: { userRoleId: number | null }) => ({
|
||||
confirmOnSubmit: false,
|
||||
|
||||
/**
|
||||
* Edit relevant item data after it has been moved by x-sort
|
||||
*/
|
||||
reorder(item: AlpineComponent<RoleGroupData>, conf: RoleGroupData) {
|
||||
item.isBoard = conf.isBoard;
|
||||
item.isPresidency = conf.isPresidency;
|
||||
// if the user has moved its own role outside the presidency,
|
||||
// submitting the form will require a confirmation
|
||||
this.confirmOnSubmit = config.userRoleId === item.roleId && !item.isPresidency;
|
||||
this.resetOrder();
|
||||
},
|
||||
/**
|
||||
* Reset the value of the ORDER input of all items in the list.
|
||||
* This is to be called after any reordering operation, in order to make sure
|
||||
* that the order that will be saved is coherent with what is displayed.
|
||||
*/
|
||||
resetOrder() {
|
||||
// When moving items with x-sort, the only information we truly have is
|
||||
// the end position in the target group, not the previous position nor
|
||||
// the position in the global list.
|
||||
// To overcome this, we loop through an enumeration of all inputs
|
||||
// that are in the form `roles-X-ORDER` and sequentially set the value of the field.
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>(
|
||||
"input[name^='roles'][name$='ORDER']",
|
||||
);
|
||||
for (const [i, elem] of inputs.entries()) {
|
||||
elem.value = (i + 1).toString();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* If the user moved its role out of the presidency, ask a confirmation
|
||||
* before submitting the form
|
||||
*/
|
||||
confirmSubmission(event: SubmitEvent) {
|
||||
if (
|
||||
this.confirmOnSubmit &&
|
||||
!confirm(
|
||||
gettext(
|
||||
"You're going to remove your own role from the presidency. " +
|
||||
"You may lock yourself out of this page. Do you want to continue ? ",
|
||||
),
|
||||
)
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
}));
|
||||
});
|
||||
47
club/static/club/list.scss
Normal file
47
club/static/club/list.scss
Normal file
@@ -0,0 +1,47 @@
|
||||
#club-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2em;
|
||||
padding: 2em;
|
||||
|
||||
.card {
|
||||
display: block;
|
||||
background-color: unset;
|
||||
|
||||
.club-image {
|
||||
float: left;
|
||||
margin-right: 2rem;
|
||||
margin-bottom: .5rem;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 10%;
|
||||
background-color: rgba(173, 173, 173, 0.2);
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
i.club-image {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: block;
|
||||
text-align: justify;
|
||||
|
||||
h4 {
|
||||
margin-top: 0;
|
||||
margin-right: .5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
club/static/club/roles.scss
Normal file
7
club/static/club/roles.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
.fa-grip-vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
margin-right: .5em;
|
||||
}
|
||||
@@ -1,52 +1,76 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
{% if is_fragment %}
|
||||
{% extends "core/base_fragment.jinja" %}
|
||||
|
||||
{% block title -%}
|
||||
{% trans %}Club list{% endtrans %}
|
||||
{%- endblock %}
|
||||
|
||||
{% block description -%}
|
||||
{# Don't display tabs and errors #}
|
||||
{% block tabs %}
|
||||
{% endblock %}
|
||||
{% block errors %}
|
||||
{% endblock %}
|
||||
{% else %}
|
||||
{% extends "core/base.jinja" %}
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static("club/list.scss") }}">
|
||||
{% endblock %}
|
||||
{% block description -%}
|
||||
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
|
||||
{%- endblock %}
|
||||
{%- endblock %}
|
||||
{% block title -%}
|
||||
{% trans %}Club list{% endtrans %}
|
||||
{%- endblock %}
|
||||
{% endif %}
|
||||
|
||||
{% macro display_club(club) -%}
|
||||
|
||||
{% if club.is_active or user.is_root %}
|
||||
|
||||
<li><a href="{{ url('club:club_view', club_id=club.id) }}">{{ club.name }}</a>
|
||||
|
||||
{% if not club.is_active %}
|
||||
({% trans %}inactive{% endtrans %})
|
||||
{% endif %}
|
||||
|
||||
{% if club.president %} - <a href="{{ url('core:user_profile', user_id=club.president.user.id) }}">{{ club.president.user }}</a>{% endif %}
|
||||
{% if club.short_description %}<p>{{ club.short_description|markdown }}</p>{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{%- if club.children.all()|length != 0 %}
|
||||
<ul>
|
||||
{%- for c in club.children.order_by('name').prefetch_related("children") %}
|
||||
{{ display_club(c) }}
|
||||
{%- endfor %}
|
||||
</ul>
|
||||
{%- endif -%}
|
||||
</li>
|
||||
{%- endmacro %}
|
||||
{% from "core/macros.jinja" import paginate_htmx %}
|
||||
|
||||
{% block content %}
|
||||
{% if user.is_root %}
|
||||
<p><a href="{{ url('club:club_new') }}">{% trans %}New club{% endtrans %}</a></p>
|
||||
{% endif %}
|
||||
{% if club_list %}
|
||||
<main>
|
||||
<h3>{% trans %}Filters{% endtrans %}</h3>
|
||||
<form
|
||||
id="club-list-filters"
|
||||
hx-get="{{ url("club:club_list") }}"
|
||||
hx-target="#content"
|
||||
hx-swap="outerHtml"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<div class="row gap-4x">
|
||||
{{ form }}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-blue margin-bottom">
|
||||
<i class="fa fa-magnifying-glass"></i>{% trans %}Search{% endtrans %}
|
||||
</button>
|
||||
</form>
|
||||
<h3>{% trans %}Club list{% endtrans %}</h3>
|
||||
<ul>
|
||||
{%- for club in club_list %}
|
||||
{{ display_club(club) }}
|
||||
{%- endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{% trans %}There is no club in this website.{% endtrans %}
|
||||
{% if user.has_perm("club.add_club") %}
|
||||
<br>
|
||||
<a href="{{ url('club:club_new') }}" class="btn btn-blue">
|
||||
<i class="fa fa-plus"></i> {% trans %}New club{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<section class="aria-busy-grow" id="club-list">
|
||||
{% for club in object_list %}
|
||||
<div class="card">
|
||||
{% set club_url = club.get_absolute_url() %}
|
||||
<a href="{{ club_url }}">
|
||||
{% if club.logo %}
|
||||
<img class="club-image" src="{{ club.logo.url }}" alt="logo {{ club.name }}">
|
||||
{% else %}
|
||||
<i class="fa-regular fa-image fa-4x club-image"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
<div class="content">
|
||||
<a href="{{ club_url }}">
|
||||
<h4>
|
||||
{{ club.name }} {% if not club.is_active %}({% trans %}inactive{% endtrans %}){% endif %}
|
||||
</h4>
|
||||
</a>
|
||||
{{ club.short_description|markdown }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% if is_paginated %}
|
||||
{{ paginate_htmx(request, page_obj, paginator) }}
|
||||
{% endif %}
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
{% from 'core/macros.jinja' import user_profile_link, select_all_checkbox %}
|
||||
|
||||
{% block additional_js %}
|
||||
<script type="module" src="{{ static("bundled/core/components/ajax-select-index.ts") }}"></script>
|
||||
{% endblock %}
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
|
||||
<link rel="stylesheet" href="{{ static("club/members.scss") }}">
|
||||
{% endblock %}
|
||||
|
||||
@@ -16,6 +12,15 @@
|
||||
|
||||
<h2>{% trans %}Club members{% endtrans %}</h2>
|
||||
|
||||
{% if club.can_roles_be_edited_by(user) %}
|
||||
<a
|
||||
href="{{ url("club:club_roles", club_id=object.id) }}"
|
||||
class="btn btn-blue margin-bottom"
|
||||
>
|
||||
{% trans %}Manage roles{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if add_member_fragment %}
|
||||
<br />
|
||||
{{ add_member_fragment }}
|
||||
@@ -45,7 +50,7 @@
|
||||
{% for m in members %}
|
||||
<tr>
|
||||
<td>{{ user_profile_link(m.user) }}</td>
|
||||
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
|
||||
<td>{{ m.role.name }}</td>
|
||||
<td>{{ m.description }}</td>
|
||||
<td>{{ m.start_date }}</td>
|
||||
{%- if can_end_membership -%}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{% for member in old_members %}
|
||||
<tr>
|
||||
<td>{{ user_profile_link(member.user) }}</td>
|
||||
<td>{{ settings.SITH_CLUB_ROLES[member.role] }}</td>
|
||||
<td>{{ member.role.name }}</td>
|
||||
<td>{{ member.description }}</td>
|
||||
<td>{{ member.start_date }}</td>
|
||||
<td>{{ member.end_date }}</td>
|
||||
|
||||
165
club/templates/club/club_roles.jinja
Normal file
165
club/templates/club/club_roles.jinja
Normal file
@@ -0,0 +1,165 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block additional_js %}
|
||||
<script type="module" src="{{ static("bundled/club/role-list-index.ts") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static("club/roles.scss") }}">
|
||||
{% endblock %}
|
||||
|
||||
{% macro display_subform(subform) %}
|
||||
<div
|
||||
class="row"
|
||||
x-data="{
|
||||
isPresidency: {{ subform.is_presidency.value()|lower }},
|
||||
isBoard: {{ subform.is_board.value()|lower }},
|
||||
roleId: {{ subform.id.value() }},
|
||||
}"
|
||||
x-sort:item="$data"
|
||||
>
|
||||
{# hidden fields #}
|
||||
{{ subform.ORDER }}
|
||||
{{ subform.id }}
|
||||
{{ subform.club }}
|
||||
{{ subform.is_presidency|add_attr("x-model=isPresidency") }}
|
||||
{{ subform.is_board|add_attr("x-model=isBoard") }}
|
||||
<i class="fa fa-grip-vertical" x-sort:handle></i>
|
||||
<details class="accordion grow" {% if subform.errors %}open{% endif %}>
|
||||
<summary>
|
||||
{{ subform.name.value() }}
|
||||
{% if not subform.instance.is_active -%}
|
||||
({% trans %}inactive{% endtrans %})
|
||||
{%- endif %}
|
||||
</summary>
|
||||
<div class="accordion-content">
|
||||
{{ subform.non_field_errors() }}
|
||||
<div class="form-group">
|
||||
{{ subform.name.as_field_group() }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ subform.description.as_field_group() }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div>
|
||||
{{ subform.is_active }}
|
||||
{{ subform.is_active.label_tag() }}
|
||||
</div>
|
||||
<span class="helptext">
|
||||
{{ subform.is_active.help_text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<p>
|
||||
{% trans trimmed %}
|
||||
Roles give rights on the club.
|
||||
Higher roles grant more rights, and the members having them are displayed higher
|
||||
in the club members list.
|
||||
{% endtrans %}
|
||||
</p>
|
||||
<p>
|
||||
{% trans trimmed %}
|
||||
On this page, you can edit their name and description, as well as their order.
|
||||
You can also drag roles from a category to another
|
||||
(e.g. a board role can be made into a presidency role).
|
||||
{% endtrans %}
|
||||
</p>
|
||||
<form
|
||||
method="post"
|
||||
x-data="clubRoleList({ userRoleId: {{ user_role or "null" }} })"
|
||||
@submit="confirmSubmission"
|
||||
>
|
||||
{% csrf_token %}
|
||||
{{ form.management_form }}
|
||||
{{ form.non_form_errors() }}
|
||||
<h3>{% trans %}Presidency{% endtrans %}</h3>
|
||||
<a class="btn btn-blue margin-bottom" href="{{ url("club:new_role_president", club_id=club.id) }}">
|
||||
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
|
||||
</a>
|
||||
<details class="clickable margin-bottom">
|
||||
<summary>{% trans %}Help{% endtrans %}</summary>
|
||||
{# The style we use for markdown rendering is quite nice for what we want to display,
|
||||
so we are just gonna reuse it. #}
|
||||
<div class="markdown">
|
||||
<p>{% trans %}Users with a presidency role can :{% endtrans %}</p>
|
||||
<ul>
|
||||
<li>{% trans %}create new club roles and edit existing ones{% endtrans %}</li>
|
||||
<li>{% trans %}manage the club counters{% endtrans %}</li>
|
||||
<li>{% trans %}add new members with any active role and end any membership{% endtrans %}</li>
|
||||
</ul>
|
||||
<p>{% trans %}They also have all the rights of the club board.{% endtrans %}</p>
|
||||
</div>
|
||||
</details>
|
||||
<div
|
||||
x-sort="reorder($item, { isBoard: true, isPresidency: true })"
|
||||
x-sort:group="roles"
|
||||
>
|
||||
{% for subform in form %}
|
||||
{% if subform.is_presidency.value() %}
|
||||
{{ display_subform(subform) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<h3>{% trans %}Board{% endtrans %}</h3>
|
||||
<a class="btn btn-blue margin-bottom" href="{{ url("club:new_role_board", club_id=club.id) }}">
|
||||
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
|
||||
</a>
|
||||
<details class="clickable margin-bottom">
|
||||
<summary>{% trans %}Help{% endtrans %}</summary>
|
||||
<div class="markdown">
|
||||
<p>
|
||||
{% trans trimmed %}
|
||||
Board members can do most administrative actions in the club, including :
|
||||
{% endtrans %}
|
||||
</p>
|
||||
<ul>
|
||||
<li>{% trans %}manage the club posters{% endtrans %}</li>
|
||||
<li>{% trans %}create news for the club{% endtrans %}</li>
|
||||
<li>{% trans %}click users on the club's counters{% endtrans %}</li>
|
||||
<li>
|
||||
{% trans trimmed %}
|
||||
add new members and end active memberships
|
||||
for roles that are lower than their own.
|
||||
{% endtrans %}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
<div
|
||||
x-sort="reorder($item, { isBoard: true, isPresidency: false })"
|
||||
x-sort:group="roles"
|
||||
>
|
||||
{% for subform in form %}
|
||||
{% if subform.is_board.value() and not subform.is_presidency.value() %}
|
||||
{{ display_subform(subform) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<h3>{% trans %}Members{% endtrans %}</h3>
|
||||
<a class="btn btn-blue margin-bottom" href="{{ url("club:new_role_member", club_id=club.id) }}">
|
||||
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
|
||||
</a>
|
||||
<details class="clickable margin-bottom">
|
||||
<summary>{% trans %}Help{% endtrans %}</summary>
|
||||
<div class="markdown">
|
||||
<p>{% trans %}Simple members cannot perform administrative actions.{% endtrans %}</p>
|
||||
</div>
|
||||
</details>
|
||||
<div
|
||||
x-sort="reorder($item, { isBoard: false, isPresidency: false })"
|
||||
x-sort:group="roles"
|
||||
>
|
||||
{% for subform in form %}
|
||||
{% if not subform.is_board.value() %}
|
||||
{{ display_subform(subform) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
@@ -5,8 +5,19 @@
|
||||
<div>
|
||||
<h4>{% trans %}Communication:{% endtrans %}</h4>
|
||||
<ul>
|
||||
<li> <a href="{{ url('com:news_new') }}?club={{ object.id }}">{% trans %}Create a news{% endtrans %}</a></li>
|
||||
<li> <a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">{% trans %}Post in the Weekmail{% endtrans %}</a></li>
|
||||
<li>
|
||||
<a href="{{ url('com:news_new') }}?club={{ object.id }}">
|
||||
{% trans %}Create a news{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">
|
||||
{% 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 %}
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.utils.timezone import now
|
||||
from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||
from core.models import User
|
||||
|
||||
@@ -43,6 +43,11 @@ class TestClub(TestCase):
|
||||
|
||||
cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID)
|
||||
cls.club = baker.make(Club)
|
||||
cls.president_role = baker.make(
|
||||
ClubRole, club=cls.club, is_board=True, is_presidency=True, order=0
|
||||
)
|
||||
cls.board_role = baker.make(ClubRole, club=cls.club, is_board=True, order=1)
|
||||
cls.member_role = baker.make(ClubRole, club=cls.club, order=2)
|
||||
cls.new_members_url = reverse(
|
||||
"club:club_new_members", kwargs={"club_id": cls.club.id}
|
||||
)
|
||||
@@ -51,12 +56,17 @@ class TestClub(TestCase):
|
||||
yesterday = now() - timedelta(days=1)
|
||||
membership_recipe = Recipe(Membership, club=cls.club)
|
||||
membership_recipe.make(
|
||||
user=cls.simple_board_member, start_date=a_month_ago, role=3
|
||||
user=cls.simple_board_member, start_date=a_month_ago, role=cls.board_role
|
||||
)
|
||||
membership_recipe.make(user=cls.richard, role=cls.member_role)
|
||||
membership_recipe.make(
|
||||
user=cls.president, start_date=a_month_ago, role=cls.president_role
|
||||
)
|
||||
membership_recipe.make(user=cls.richard, role=1)
|
||||
membership_recipe.make(user=cls.president, start_date=a_month_ago, role=10)
|
||||
membership_recipe.make( # sli was a member but isn't anymore
|
||||
user=cls.sli, start_date=a_month_ago, end_date=yesterday, role=2
|
||||
user=cls.sli,
|
||||
start_date=a_month_ago,
|
||||
end_date=yesterday,
|
||||
role=cls.board_role,
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.db import ProgrammingError
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import localdate
|
||||
from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import User
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -16,12 +22,106 @@ def test_club_queryset_having_board_member():
|
||||
membership_recipe = Recipe(
|
||||
Membership, user=user, start_date=localdate() - timedelta(days=3)
|
||||
)
|
||||
membership_recipe.make(club=clubs[0], role=1)
|
||||
membership_recipe.make(club=clubs[1], role=3)
|
||||
membership_recipe.make(club=clubs[2], role=7)
|
||||
membership_recipe.make(
|
||||
club=clubs[3], role=3, end_date=localdate() - timedelta(days=1)
|
||||
club=clubs[0], role=baker.make(ClubRole, club=clubs[0], is_board=False)
|
||||
)
|
||||
membership_recipe.make(
|
||||
club=clubs[1], role=baker.make(ClubRole, club=clubs[1], is_board=True)
|
||||
)
|
||||
membership_recipe.make(
|
||||
club=clubs[2], role=baker.make(ClubRole, club=clubs[2], is_board=True)
|
||||
)
|
||||
membership_recipe.make(
|
||||
club=clubs[3],
|
||||
role=baker.make(ClubRole, club=clubs[3], is_board=True),
|
||||
end_date=localdate() - timedelta(days=1),
|
||||
)
|
||||
|
||||
club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)
|
||||
assert set(club_ids) == {clubs[1].id, clubs[2].id}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("nb_additional_clubs", [10, 30])
|
||||
@pytest.mark.parametrize("is_fragment", [True, False])
|
||||
@pytest.mark.django_db
|
||||
def test_club_list(client: Client, nb_additional_clubs: int, is_fragment):
|
||||
client.force_login(baker.make(User))
|
||||
baker.make(Club, _quantity=nb_additional_clubs)
|
||||
headers = {"HX-Request": True} if is_fragment else {}
|
||||
res = client.get(reverse("club:club_list"), headers=headers)
|
||||
assert res.status_code == 200
|
||||
|
||||
|
||||
def assert_club_created(club_name: str):
|
||||
club = Club.objects.last()
|
||||
assert club.name == club_name
|
||||
assert club.board_group.name == f"{club_name} - Bureau"
|
||||
assert club.members_group.name == f"{club_name} - Membres"
|
||||
# default roles should be added on club creation,
|
||||
# whether the creation happens on the admin site or on the user site
|
||||
assert list(club.roles.values("name", "is_presidency", "is_board")) == [
|
||||
{"name": "Président⸱e", "is_presidency": True, "is_board": True},
|
||||
{"name": "Trésorier⸱e", "is_presidency": False, "is_board": True},
|
||||
{"name": "Membre actif⸱ve", "is_presidency": False, "is_board": False},
|
||||
{"name": "Curieux⸱euse", "is_presidency": False, "is_board": False},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_view(admin_client: Client):
|
||||
"""Test that the club creation view works well"""
|
||||
res = admin_client.get(reverse("club:club_new"))
|
||||
assert res.status_code == 200
|
||||
res = admin_client.post(
|
||||
reverse("club:club_new"),
|
||||
data={"name": "foo", "parent": settings.SITH_MAIN_CLUB_ID},
|
||||
)
|
||||
club = Club.objects.last()
|
||||
assertRedirects(res, club.get_absolute_url())
|
||||
assert_club_created("foo")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_default_roles_for_club_with_roles_fails():
|
||||
"""Test that an Error is raised if trying to create
|
||||
default roles for a club that already has roles.
|
||||
"""
|
||||
club = baker.make(Club)
|
||||
baker.make(ClubRole, club=club)
|
||||
with pytest.raises(ProgrammingError):
|
||||
club.create_default_roles()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAdminInterface:
|
||||
def test_create(self, admin_client: Client):
|
||||
"""Test the creation of a club via the admin interface."""
|
||||
res = admin_client.post(
|
||||
reverse("admin:club_club_add"),
|
||||
data={
|
||||
"name": "foo",
|
||||
"parent": settings.SITH_MAIN_CLUB_ID,
|
||||
"address": "Rome",
|
||||
},
|
||||
)
|
||||
assertRedirects(res, reverse("admin:club_club_changelist"))
|
||||
assert_club_created("foo")
|
||||
|
||||
def test_change(self, admin_client: Client):
|
||||
"""Test the edition of a club via the admin interface."""
|
||||
club = baker.make(Club)
|
||||
res = admin_client.post(
|
||||
reverse("admin:club_club_change", kwargs={"object_id": club.id}),
|
||||
data={
|
||||
"name": "foo",
|
||||
"page": club.page_id,
|
||||
"home": club.home_id,
|
||||
"address": club.address,
|
||||
},
|
||||
)
|
||||
assertRedirects(res, reverse("admin:club_club_changelist"))
|
||||
club.refresh_from_db()
|
||||
assert club.name == "foo"
|
||||
# Club roles shouldn't be modified when editing the club on the admin interface
|
||||
# This club had no roles beforehand, therefore it shouldn't have roles now.
|
||||
assert not club.roles.exists()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
@@ -8,7 +9,7 @@ from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
from pytest_django.asserts import assertNumQueries
|
||||
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import Group, Page, User
|
||||
|
||||
@@ -26,8 +27,10 @@ class TestClubSearch(TestCase):
|
||||
"id", flat=True
|
||||
)
|
||||
)
|
||||
Page.objects.exclude(club=None).delete()
|
||||
Membership.objects.all().delete()
|
||||
ClubRole.objects.all().delete()
|
||||
Club.objects.all().delete()
|
||||
Page.objects.exclude(name=settings.SITH_CLUB_ROOT_PAGE).delete()
|
||||
Group.objects.filter(id__in=groups).delete()
|
||||
|
||||
cls.clubs = baker.make(
|
||||
|
||||
254
club/tests/test_clubrole.py
Normal file
254
club/tests/test_clubrole.py
Normal file
@@ -0,0 +1,254 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker, seq
|
||||
from model_bakery.recipe import Recipe
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from club.forms import ClubRoleFormSet
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import AnonymousUser, User
|
||||
|
||||
|
||||
def make_club():
|
||||
# unittest-style tests cannot use fixture, so we create a function
|
||||
# that will be callable either by a pytest fixture or inside
|
||||
# a TestCase.setUpTestData method.
|
||||
club = baker.make(Club)
|
||||
recipe = Recipe(ClubRole, club=club, name=seq("role "))
|
||||
recipe.make(
|
||||
is_board=iter([True, True, False]),
|
||||
is_presidency=iter([True, False, False]),
|
||||
order=iter([1, 2, 3]),
|
||||
_quantity=3,
|
||||
_bulk_create=True,
|
||||
)
|
||||
return club
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def club(db):
|
||||
"""A club with a presidency role, a board role and a member role"""
|
||||
return make_club()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_auto(club):
|
||||
"""Test that newly created roles are put in the right place."""
|
||||
roles = list(club.roles.all())
|
||||
# create new roles one by one (like they will be in prod)
|
||||
# each new role should be placed at the end of its category
|
||||
recipe = Recipe(ClubRole, club=club, name=seq("new role "))
|
||||
role_a = recipe.make(is_board=True, is_presidency=True, order=None)
|
||||
role_b = recipe.make(is_board=True, is_presidency=False, order=None)
|
||||
role_c = recipe.make(is_board=False, is_presidency=False, order=None)
|
||||
assert list(club.roles.order_by("order")) == [
|
||||
roles[0],
|
||||
role_a,
|
||||
roles[1],
|
||||
role_b,
|
||||
roles[2],
|
||||
role_c,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
("user_factory", "is_allowed"),
|
||||
[
|
||||
(
|
||||
lambda club: baker.make(
|
||||
User,
|
||||
user_permissions=[Permission.objects.get(codename="change_clubrole")],
|
||||
),
|
||||
True,
|
||||
),
|
||||
( # user with presidency roles can edit the club roles
|
||||
lambda club: subscriber_user.make(
|
||||
memberships=[
|
||||
baker.make(
|
||||
Membership,
|
||||
club=club,
|
||||
role=club.roles.filter(is_presidency=True).first(),
|
||||
)
|
||||
]
|
||||
),
|
||||
True,
|
||||
),
|
||||
( # user in the board but not in the presidency cannot edit roles
|
||||
lambda club: subscriber_user.make(
|
||||
memberships=[
|
||||
baker.make(
|
||||
Membership,
|
||||
club=club,
|
||||
role=club.roles.filter(
|
||||
is_presidency=False, is_board=True
|
||||
).first(),
|
||||
)
|
||||
]
|
||||
),
|
||||
False,
|
||||
),
|
||||
(lambda _: AnonymousUser(), False),
|
||||
],
|
||||
)
|
||||
def test_can_roles_be_edited_by(
|
||||
club: Club, user_factory: Callable[[Club], User], is_allowed
|
||||
):
|
||||
"""Test that `Club.can_roles_be_edited_by` return the right value"""
|
||||
user = user_factory(club)
|
||||
assert club.can_roles_be_edited_by(user) == is_allowed
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
["route", "is_presidency", "is_board"],
|
||||
[
|
||||
("club:new_role_president", True, True),
|
||||
("club:new_role_board", False, True),
|
||||
("club:new_role_member", False, False),
|
||||
],
|
||||
)
|
||||
def test_create_role_view(client: Client, route: str, is_presidency, is_board):
|
||||
"""Test that the role creation views work."""
|
||||
club = baker.make(Club)
|
||||
role = baker.make(ClubRole, club=club, is_presidency=True, is_board=True)
|
||||
user = subscriber_user.make()
|
||||
baker.make(Membership, club=club, role=role, user=user, end_date=None)
|
||||
url = reverse(route, kwargs={"club_id": club.id})
|
||||
client.force_login(user)
|
||||
|
||||
res = client.get(url)
|
||||
assert res.status_code == 200
|
||||
|
||||
res = client.post(url, data={"name": "foo"})
|
||||
assertRedirects(res, reverse("club:club_roles", kwargs={"club_id": club.id}))
|
||||
new_role = club.roles.last()
|
||||
assert new_role.name == "foo"
|
||||
assert new_role.is_presidency == is_presidency
|
||||
assert new_role.is_board == is_board
|
||||
|
||||
|
||||
class TestClubRoleUpdate(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.club = make_club()
|
||||
cls.roles = list(cls.club.roles.all())
|
||||
cls.user = subscriber_user.make()
|
||||
baker.make(
|
||||
Membership, club=cls.club, role=cls.roles[0], user=cls.user, end_date=None
|
||||
)
|
||||
cls.url = reverse("club:club_roles", kwargs={"club_id": cls.club.id})
|
||||
|
||||
def setUp(self):
|
||||
self.payload = {
|
||||
"roles-TOTAL_FORMS": 3,
|
||||
"roles-INITIAL_FORMS": 3,
|
||||
"roles-MIN_NUM_FORMS": 0,
|
||||
"roles-MAX_NUM_FORMS": 1000,
|
||||
"roles-0-ORDER": self.roles[0].order,
|
||||
"roles-0-id": self.roles[0].id,
|
||||
"roles-0-club": self.club.id,
|
||||
"roles-0-is_presidency": True,
|
||||
"roles-0-is_board": True,
|
||||
"roles-0-name": self.roles[0].name,
|
||||
"roles-0-description": self.roles[0].description,
|
||||
"roles-0-is_active": True,
|
||||
"roles-1-ORDER": self.roles[1].order,
|
||||
"roles-1-id": self.roles[1].id,
|
||||
"roles-1-club": self.club.id,
|
||||
"roles-1-is_presidency": False,
|
||||
"roles-1-is_board": True,
|
||||
"roles-1-name": self.roles[1].name,
|
||||
"roles-1-description": self.roles[1].description,
|
||||
"roles-1-is_active": True,
|
||||
"roles-2-ORDER": self.roles[2].order,
|
||||
"roles-2-id": self.roles[2].id,
|
||||
"roles-2-club": self.club.id,
|
||||
"roles-2-is_presidency": False,
|
||||
"roles-2-is_board": False,
|
||||
"roles-2-name": self.roles[2].name,
|
||||
"roles-2-description": self.roles[2].description,
|
||||
"roles-2-is_active": True,
|
||||
}
|
||||
|
||||
def test_view_ok(self):
|
||||
"""Basic test to check that the view works."""
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.get(self.url)
|
||||
assert res.status_code == 200
|
||||
self.payload["roles-2-name"] = "foo"
|
||||
res = self.client.post(self.url, data=self.payload)
|
||||
assertRedirects(res, self.url)
|
||||
self.roles[2].refresh_from_db()
|
||||
assert self.roles[2].name == "foo"
|
||||
|
||||
def test_incoherent_order(self):
|
||||
"""Test that placing a member role over a board role fails."""
|
||||
self.payload["roles-0-ORDER"] = 4
|
||||
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
|
||||
assert not formset.is_valid()
|
||||
assert formset.errors == [
|
||||
{
|
||||
"__all__": [
|
||||
f"Le rôle {self.roles[0].name} ne peut pas "
|
||||
"être placé en-dessous d'un rôle de membre.",
|
||||
f"Le rôle {self.roles[0].name} ne peut pas être placé "
|
||||
"en-dessous d'un rôle qui n'est pas de la présidence.",
|
||||
]
|
||||
},
|
||||
{},
|
||||
{},
|
||||
]
|
||||
|
||||
def test_change_order_ok(self):
|
||||
"""Test that changing order the intended way works"""
|
||||
self.payload["roles-1-ORDER"] = 3
|
||||
self.payload["roles-1-is_board"] = False
|
||||
self.payload["roles-2-ORDER"] = 2
|
||||
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
|
||||
assert formset.is_valid()
|
||||
formset.save()
|
||||
assert list(self.club.roles.order_by("order")) == [
|
||||
self.roles[0],
|
||||
self.roles[2],
|
||||
self.roles[1],
|
||||
]
|
||||
self.roles[1].refresh_from_db()
|
||||
assert not self.roles[1].is_board
|
||||
|
||||
def test_non_board_presidency_is_forbidden(self):
|
||||
"""Test that a role cannot be in the presidency without being in the board."""
|
||||
self.payload["roles-0-is_board"] = False
|
||||
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
|
||||
assert not formset.is_valid()
|
||||
assert formset.errors == [
|
||||
{
|
||||
"__all__": [
|
||||
"Un rôle ne peut pas appartenir à la présidence sans être dans le bureau",
|
||||
]
|
||||
},
|
||||
{},
|
||||
{},
|
||||
]
|
||||
|
||||
def test_president_moves_itself_out_of_the_presidency(self):
|
||||
"""Test that if the user moves its own role out of the presidency,
|
||||
then it's redirected to another page and loses access to the update page."""
|
||||
self.payload["roles-0-is_presidency"] = False
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.post(self.url, data=self.payload)
|
||||
assertRedirects(
|
||||
res, reverse("club:club_members", kwargs={"club_id": self.club.id})
|
||||
)
|
||||
# When the user clicked that button, it still had the right to update roles,
|
||||
# so the modification should be applied
|
||||
self.roles[0].refresh_from_db()
|
||||
assert self.roles[0].is_presidency is False
|
||||
|
||||
res = self.client.get(self.url)
|
||||
assert res.status_code == 403
|
||||
@@ -4,7 +4,7 @@ from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.baker_recipes import subscriber_user
|
||||
|
||||
|
||||
@@ -12,7 +12,12 @@ from core.baker_recipes import subscriber_user
|
||||
def test_club_board_member_cannot_edit_club_properties(client: Client):
|
||||
user = subscriber_user.make()
|
||||
club = baker.make(Club, name="old name", is_active=True, address="old address")
|
||||
baker.make(Membership, club=club, user=user, role=7)
|
||||
baker.make(
|
||||
Membership,
|
||||
club=club,
|
||||
user=user,
|
||||
role=baker.make(ClubRole, club=club, is_board=True),
|
||||
)
|
||||
client.force_login(user)
|
||||
res = client.post(
|
||||
reverse("club:club_edit", kwargs={"club_id": club.id}),
|
||||
@@ -32,7 +37,12 @@ def test_edit_club_page_doesnt_crash(client: Client):
|
||||
"""crash test for club:club_edit"""
|
||||
club = baker.make(Club)
|
||||
user = subscriber_user.make()
|
||||
baker.make(Membership, club=club, user=user, role=3)
|
||||
baker.make(
|
||||
Membership,
|
||||
club=club,
|
||||
user=user,
|
||||
role=baker.make(ClubRole, club=club, is_board=True),
|
||||
)
|
||||
client.force_login(user)
|
||||
res = client.get(reverse("club:club_edit", kwargs={"club_id": club.id}))
|
||||
assert res.status_code == 200
|
||||
|
||||
@@ -3,9 +3,10 @@ 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, Mailing, Membership
|
||||
from club.models import Club, ClubRole, Mailing, Membership
|
||||
from core.models import User
|
||||
|
||||
|
||||
@@ -25,7 +26,7 @@ class TestMailingForm(TestCase):
|
||||
user=cls.rbatsbak,
|
||||
club=cls.club,
|
||||
start_date=timezone.now(),
|
||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||
role=baker.make(ClubRole, club=cls.club, is_board=True),
|
||||
).save()
|
||||
|
||||
def test_mailing_list_add_no_moderation(self):
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import itertools
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Max
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import localdate, localtime, now
|
||||
from model_bakery import baker
|
||||
from model_bakery import baker, seq
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from club.forms import ClubAddMemberForm, JoinClubForm
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from club.tests.base import TestClub
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import AnonymousUser, User
|
||||
@@ -75,17 +75,22 @@ class TestMembershipQuerySet(TestClub):
|
||||
def test_update_change_club_groups(self):
|
||||
"""Test that `update` set the user groups accordingly."""
|
||||
user = baker.make(User)
|
||||
membership = baker.make(Membership, end_date=None, user=user, role=5)
|
||||
board_role, member_role = baker.make(
|
||||
ClubRole, is_board=iter([True, False]), _quantity=2, _bulk_create=True
|
||||
)
|
||||
membership = baker.make(
|
||||
Membership, end_date=None, user=user, role=board_role, club=board_role.club
|
||||
)
|
||||
members_group = membership.club.members_group
|
||||
board_group = membership.club.board_group
|
||||
assert user.groups.contains(members_group)
|
||||
assert user.groups.contains(board_group)
|
||||
|
||||
user.memberships.update(role=1) # from board to simple member
|
||||
user.memberships.update(role=member_role) # from board to simple member
|
||||
assert user.groups.contains(members_group)
|
||||
assert not user.groups.contains(board_group)
|
||||
|
||||
user.memberships.update(role=5) # from member to board
|
||||
user.memberships.update(role=board_role) # from member to board
|
||||
assert user.groups.contains(members_group)
|
||||
assert user.groups.contains(board_group)
|
||||
|
||||
@@ -96,7 +101,17 @@ class TestMembershipQuerySet(TestClub):
|
||||
def test_delete_remove_from_groups(self):
|
||||
"""Test that `delete` removes from club groups"""
|
||||
user = baker.make(User)
|
||||
memberships = baker.make(Membership, role=iter([1, 5]), user=user, _quantity=2)
|
||||
club = baker.make(Club)
|
||||
roles = baker.make(
|
||||
ClubRole,
|
||||
is_board=iter([False, True]),
|
||||
club=club,
|
||||
_quantity=2,
|
||||
_bulk_create=True,
|
||||
)
|
||||
memberships = baker.make(
|
||||
Membership, club=club, role=iter(roles), user=user, _quantity=2
|
||||
)
|
||||
club_groups = {
|
||||
memberships[0].club.members_group,
|
||||
memberships[1].club.members_group,
|
||||
@@ -112,13 +127,20 @@ class TestMembershipEditableBy(TestCase):
|
||||
def setUpTestData(cls):
|
||||
Membership.objects.all().delete()
|
||||
cls.club_a, cls.club_b = baker.make(Club, _quantity=2)
|
||||
roles = baker.make(
|
||||
ClubRole,
|
||||
is_presidency=itertools.cycle([True, False, False, False]),
|
||||
is_board=itertools.cycle([True, True, True, False]),
|
||||
order=itertools.cycle(range(4)),
|
||||
club=iter(
|
||||
[*itertools.repeat(cls.club_a, 4), *itertools.repeat(cls.club_b, 4)]
|
||||
),
|
||||
_quantity=8,
|
||||
_bulk_create=True,
|
||||
)
|
||||
cls.memberships = [
|
||||
*baker.make(
|
||||
Membership, role=iter([7, 3, 3, 1]), club=cls.club_a, _quantity=4
|
||||
),
|
||||
*baker.make(
|
||||
Membership, role=iter([7, 3, 3, 1]), club=cls.club_b, _quantity=4
|
||||
),
|
||||
*baker.make(Membership, role=iter(roles[:4]), club=cls.club_a, _quantity=4),
|
||||
*baker.make(Membership, role=iter(roles[4:]), club=cls.club_b, _quantity=4),
|
||||
]
|
||||
|
||||
def test_admin_user(self):
|
||||
@@ -140,7 +162,7 @@ class TestMembershipEditableBy(TestCase):
|
||||
|
||||
|
||||
class TestMembership(TestClub):
|
||||
def assert_membership_started_today(self, user: User, role: int):
|
||||
def assert_membership_started_today(self, user: User, role: ClubRole):
|
||||
"""Assert that the given membership is active and started today."""
|
||||
membership = user.memberships.ongoing().filter(club=self.club).first()
|
||||
assert membership is not None
|
||||
@@ -189,21 +211,27 @@ class TestMembership(TestClub):
|
||||
"Marquer comme ancien",
|
||||
]
|
||||
rows = table.find("tbody").find_all("tr")
|
||||
memberships = self.club.members.ongoing().order_by("-role")
|
||||
for row, membership in zip(
|
||||
rows, memberships.select_related("user"), strict=False
|
||||
):
|
||||
memberships = (
|
||||
self.club.members.ongoing()
|
||||
.order_by("role__order")
|
||||
.select_related("user", "role")
|
||||
)
|
||||
user_role = ClubRole.objects.get(members__user=self.simple_board_member)
|
||||
for row, membership in zip(rows, memberships, strict=False):
|
||||
user = membership.user
|
||||
user_url = reverse("core:user_profile", args=[user.id])
|
||||
cols = row.find_all("td")
|
||||
user_link = cols[0].find("a")
|
||||
assert user_link.attrs["href"] == user_url
|
||||
assert user_link.text == user.get_display_name()
|
||||
assert cols[1].text == settings.SITH_CLUB_ROLES[membership.role]
|
||||
assert cols[1].text == membership.role.name
|
||||
assert cols[2].text == membership.description
|
||||
assert cols[3].text == str(membership.start_date)
|
||||
|
||||
if membership.role < 3 or membership.user_id == self.simple_board_member.id:
|
||||
if (
|
||||
membership.role.order > user_role.order
|
||||
or membership.user_id == self.simple_board_member.id
|
||||
):
|
||||
# 3 is the role of simple_board_member
|
||||
form_input = cols[4].find("input")
|
||||
expected_attrs = {
|
||||
@@ -219,14 +247,15 @@ class TestMembership(TestClub):
|
||||
"""Test that root users can add members to clubs"""
|
||||
self.client.force_login(self.root)
|
||||
response = self.client.post(
|
||||
self.new_members_url, {"user": self.subscriber.id, "role": 3}
|
||||
self.new_members_url,
|
||||
{"user": self.subscriber.id, "role": self.board_role.id},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers.get("HX-Redirect", "") == reverse(
|
||||
"club:club_members", kwargs={"club_id": self.club.id}
|
||||
)
|
||||
self.subscriber.refresh_from_db()
|
||||
self.assert_membership_started_today(self.subscriber, role=3)
|
||||
self.assert_membership_started_today(self.subscriber, role=self.board_role)
|
||||
|
||||
def test_add_unauthorized_members(self):
|
||||
"""Test that users who are not currently subscribed
|
||||
@@ -234,7 +263,7 @@ class TestMembership(TestClub):
|
||||
"""
|
||||
for user in self.public, self.old_subscriber:
|
||||
form = ClubAddMemberForm(
|
||||
data={"user": user.id, "role": 1},
|
||||
data={"user": user.id, "role": self.member_role},
|
||||
request_user=self.root,
|
||||
club=self.club,
|
||||
)
|
||||
@@ -255,7 +284,7 @@ class TestMembership(TestClub):
|
||||
nb_memberships = self.simple_board_member.memberships.count()
|
||||
self.client.post(
|
||||
self.members_url,
|
||||
{"users": self.simple_board_member.id, "role": current_membership.role + 1},
|
||||
{"users": self.simple_board_member.id, "role": self.member_role},
|
||||
)
|
||||
self.simple_board_member.refresh_from_db()
|
||||
assert nb_memberships == self.simple_board_member.memberships.count()
|
||||
@@ -274,7 +303,7 @@ class TestMembership(TestClub):
|
||||
max_id = User.objects.aggregate(id=Max("id"))["id"]
|
||||
for members in [max_id + 1], [max_id + 1, self.subscriber.id]:
|
||||
form = ClubAddMemberForm(
|
||||
data={"user": members, "role": 1},
|
||||
data={"user": members, "role": self.member_role},
|
||||
request_user=self.root,
|
||||
club=self.club,
|
||||
)
|
||||
@@ -288,44 +317,6 @@ class TestMembership(TestClub):
|
||||
self.club.refresh_from_db()
|
||||
assert self.club.members.count() == nb_memberships
|
||||
|
||||
def test_president_add_members(self):
|
||||
"""Test that the president of the club can add members."""
|
||||
president = self.club.members.get(role=10).user
|
||||
nb_club_membership = self.club.members.count()
|
||||
nb_subscriber_memberships = self.subscriber.memberships.count()
|
||||
self.client.force_login(president)
|
||||
response = self.client.post(
|
||||
self.new_members_url, {"user": self.subscriber.id, "role": 9}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers.get("HX-Redirect", "") == reverse(
|
||||
"club:club_members", kwargs={"club_id": self.club.id}
|
||||
)
|
||||
self.club.refresh_from_db()
|
||||
self.subscriber.refresh_from_db()
|
||||
assert self.club.members.count() == nb_club_membership + 1
|
||||
assert self.subscriber.memberships.count() == nb_subscriber_memberships + 1
|
||||
self.assert_membership_started_today(self.subscriber, role=9)
|
||||
|
||||
def test_add_member_greater_role(self):
|
||||
"""Test that a member of the club member cannot create
|
||||
a membership with a greater role than its own.
|
||||
"""
|
||||
form = ClubAddMemberForm(
|
||||
data={"user": self.subscriber.id, "role": 10},
|
||||
request_user=self.simple_board_member,
|
||||
club=self.club,
|
||||
)
|
||||
nb_memberships = self.club.members.count()
|
||||
|
||||
assert not form.is_valid()
|
||||
assert form.errors == {
|
||||
"role": ["Sélectionnez un choix valide. 10 n\u2019en fait pas partie."]
|
||||
}
|
||||
self.club.refresh_from_db()
|
||||
assert nb_memberships == self.club.members.count()
|
||||
assert not self.subscriber.memberships.filter(club=self.club).exists()
|
||||
|
||||
def test_add_member_without_role(self):
|
||||
"""Test that trying to add members without specifying their role fails."""
|
||||
form = ClubAddMemberForm(
|
||||
@@ -336,8 +327,9 @@ class TestMembership(TestClub):
|
||||
assert form.errors == {"role": ["Ce champ est obligatoire."]}
|
||||
|
||||
def test_add_member_already_there(self):
|
||||
role = ClubRole.objects.get(members__user=self.simple_board_member)
|
||||
form = ClubAddMemberForm(
|
||||
data={"user": self.simple_board_member, "role": 3},
|
||||
data={"user": self.simple_board_member, "role": role.id},
|
||||
request_user=self.root,
|
||||
club=self.club,
|
||||
)
|
||||
@@ -348,22 +340,27 @@ class TestMembership(TestClub):
|
||||
|
||||
def test_add_other_member_forbidden(self):
|
||||
non_member = subscriber_user.make()
|
||||
simple_member = baker.make(Membership, club=self.club, role=1).user
|
||||
simple_member = baker.make(
|
||||
Membership, club=self.club, role=self.member_role
|
||||
).user
|
||||
for user in non_member, simple_member:
|
||||
form = ClubAddMemberForm(
|
||||
data={"user": subscriber_user.make(), "role": 1},
|
||||
data={"user": subscriber_user.make(), "role": self.member_role.id},
|
||||
request_user=user,
|
||||
club=self.club,
|
||||
)
|
||||
assert not form.is_valid()
|
||||
assert form.errors == {
|
||||
"role": ["Sélectionnez un choix valide. 1 n\u2019en fait pas partie."]
|
||||
"role": [
|
||||
"Sélectionnez un choix valide. "
|
||||
"Ce choix ne fait pas partie de ceux disponibles."
|
||||
]
|
||||
}
|
||||
|
||||
def test_simple_members_dont_see_form_anymore(self):
|
||||
"""Test that simple club members don't see the form to add members"""
|
||||
user = subscriber_user.make()
|
||||
baker.make(Membership, club=self.club, user=user, role=1)
|
||||
baker.make(Membership, club=self.club, user=user, role=self.member_role)
|
||||
self.client.force_login(user)
|
||||
res = self.client.get(self.members_url)
|
||||
assert res.status_code == 200
|
||||
@@ -382,9 +379,10 @@ class TestMembership(TestClub):
|
||||
"""Test that board members of the club can end memberships
|
||||
of users with lower roles.
|
||||
"""
|
||||
# reminder : simple_board_member has role 3
|
||||
self.client.force_login(self.simple_board_member)
|
||||
membership = baker.make(Membership, club=self.club, role=2, end_date=None)
|
||||
role = baker.make(ClubRole, club=self.club, is_board=True)
|
||||
role.below(self.board_role)
|
||||
membership = baker.make(Membership, club=self.club, role=role)
|
||||
response = self.client.post(self.members_url, {"members_old": [membership.id]})
|
||||
self.assertRedirects(response, self.members_url)
|
||||
self.club.refresh_from_db()
|
||||
@@ -394,7 +392,9 @@ class TestMembership(TestClub):
|
||||
"""Test that board members of the club cannot end memberships
|
||||
of users with higher roles.
|
||||
"""
|
||||
membership = self.president.memberships.filter(club=self.club).first()
|
||||
membership = self.president.memberships.filter(
|
||||
club=self.club, end_date=None
|
||||
).first()
|
||||
self.client.force_login(self.simple_board_member)
|
||||
self.client.post(self.members_url, {"members_old": [membership.id]})
|
||||
self.club.refresh_from_db()
|
||||
@@ -436,7 +436,9 @@ class TestMembership(TestClub):
|
||||
def test_remove_from_club_group(self):
|
||||
"""Test that when a membership ends, the user is removed from club groups."""
|
||||
user = baker.make(User)
|
||||
baker.make(Membership, user=user, club=self.club, end_date=None, role=3)
|
||||
baker.make(
|
||||
Membership, user=user, club=self.club, end_date=None, role=self.board_role
|
||||
)
|
||||
assert user.groups.contains(self.club.members_group)
|
||||
assert user.groups.contains(self.club.board_group)
|
||||
user.memberships.update(end_date=localdate())
|
||||
@@ -447,18 +449,20 @@ class TestMembership(TestClub):
|
||||
"""Test that when a membership begins, the user is added to the club group."""
|
||||
assert not self.subscriber.groups.contains(self.club.members_group)
|
||||
assert not self.subscriber.groups.contains(self.club.board_group)
|
||||
baker.make(Membership, club=self.club, user=self.subscriber, role=3)
|
||||
baker.make(
|
||||
Membership, club=self.club, user=self.subscriber, role=self.board_role
|
||||
)
|
||||
assert self.subscriber.groups.contains(self.club.members_group)
|
||||
assert self.subscriber.groups.contains(self.club.board_group)
|
||||
|
||||
def test_change_position_in_club(self):
|
||||
"""Test that when moving from board to members, club group change"""
|
||||
membership = baker.make(
|
||||
Membership, club=self.club, user=self.subscriber, role=3
|
||||
Membership, club=self.club, user=self.subscriber, role=self.board_role
|
||||
)
|
||||
assert self.subscriber.groups.contains(self.club.members_group)
|
||||
assert self.subscriber.groups.contains(self.club.board_group)
|
||||
membership.role = 1
|
||||
membership.role = self.member_role
|
||||
membership.save()
|
||||
assert self.subscriber.groups.contains(self.club.members_group)
|
||||
assert not self.subscriber.groups.contains(self.club.board_group)
|
||||
@@ -471,7 +475,11 @@ class TestMembership(TestClub):
|
||||
|
||||
# make sli a board member
|
||||
self.sli.memberships.all().delete()
|
||||
Membership(club=self.ae, user=self.sli, role=3).save()
|
||||
Membership(
|
||||
club=self.ae,
|
||||
user=self.sli,
|
||||
role=baker.make(ClubRole, club=self.ae, is_board=True),
|
||||
).save()
|
||||
assert self.club.is_owned_by(self.sli)
|
||||
|
||||
def test_change_club_name(self):
|
||||
@@ -497,7 +505,7 @@ class TestMembership(TestClub):
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_membership_set_old(client: Client):
|
||||
membership = baker.make(Membership, end_date=None, user=(subscriber_user.make()))
|
||||
membership = baker.make(Membership, end_date=None, user=subscriber_user.make())
|
||||
client.force_login(membership.user)
|
||||
response = client.post(
|
||||
reverse("club:membership_set_old", kwargs={"membership_id": membership.id})
|
||||
@@ -524,6 +532,50 @@ def test_membership_delete(client: Client):
|
||||
assert not Membership.objects.filter(id=membership.id).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAddMemberForm(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.club = baker.make(Club)
|
||||
cls.roles = baker.make(
|
||||
ClubRole,
|
||||
club=cls.club,
|
||||
is_board=iter([True, True, True, True, False, False]),
|
||||
is_presidency=iter([True, True, False, False, False, False]),
|
||||
order=seq(0),
|
||||
_quantity=6,
|
||||
_bulk_create=True,
|
||||
)
|
||||
cls.roles[-1].is_active = False
|
||||
cls.roles[-1].save()
|
||||
|
||||
def test_admin(self):
|
||||
"""Test that admin users can give any active role."""
|
||||
user = baker.make(
|
||||
User, user_permissions=[Permission.objects.get(codename="add_membership")]
|
||||
)
|
||||
form = ClubAddMemberForm(request_user=user, club=self.club)
|
||||
assert list(form.fields["role"].queryset) == self.roles[:-1]
|
||||
|
||||
def test_president(self):
|
||||
"""Test that someone with a presidency role can give any active role."""
|
||||
user = baker.make(Membership, club=self.club, role=self.roles[0]).user
|
||||
form = ClubAddMemberForm(request_user=user, club=self.club)
|
||||
assert list(form.fields["role"].queryset) == self.roles[:-1]
|
||||
|
||||
def test_board_member(self):
|
||||
"""Test that someone with a board role can give lower active role."""
|
||||
user = baker.make(Membership, club=self.club, role=self.roles[2]).user
|
||||
form = ClubAddMemberForm(request_user=user, club=self.club)
|
||||
assert list(form.fields["role"].queryset) == self.roles[3:-1]
|
||||
|
||||
def test_simple_member(self):
|
||||
"""Test that someone with a non-board role cannot give roles."""
|
||||
user = baker.make(Membership, club=self.club, role=self.roles[4]).user
|
||||
form = ClubAddMemberForm(request_user=user, club=self.club)
|
||||
assert list(form.fields["role"].queryset) == []
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestJoinClub:
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -531,55 +583,64 @@ class TestJoinClub:
|
||||
cache.clear()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("user_factory", "role", "errors"),
|
||||
("user_factory", "board_role", "errors"),
|
||||
[
|
||||
(
|
||||
subscriber_user.make,
|
||||
2,
|
||||
True,
|
||||
{
|
||||
"role": [
|
||||
"Sélectionnez un choix valide. 2 n\u2019en fait pas partie."
|
||||
"Sélectionnez un choix valide. "
|
||||
"Ce choix ne fait pas partie de ceux disponibles."
|
||||
]
|
||||
},
|
||||
),
|
||||
(
|
||||
lambda: baker.make(User),
|
||||
1,
|
||||
False,
|
||||
{"__all__": ["Vous devez être cotisant pour faire partie d'un club"]},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_join_club_errors(
|
||||
self, user_factory: Callable[[], User], role: int, errors: dict
|
||||
self, user_factory: Callable[[], User], board_role, errors: dict
|
||||
):
|
||||
club = baker.make(Club)
|
||||
user = user_factory()
|
||||
form = JoinClubForm(club=club, request_user=user, data={"role": role})
|
||||
role = baker.make(ClubRole, club=club, is_board=board_role)
|
||||
form = JoinClubForm(club=club, request_user=user, data={"role": role.id})
|
||||
assert not form.is_valid()
|
||||
assert form.errors == errors
|
||||
|
||||
def test_user_already_in_club(self):
|
||||
club = baker.make(Club)
|
||||
user = subscriber_user.make()
|
||||
baker.make(Membership, user=user, club=club)
|
||||
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
|
||||
role = baker.make(ClubRole, is_board=False)
|
||||
baker.make(Membership, user=user, club=role.club)
|
||||
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
|
||||
assert not form.is_valid()
|
||||
assert form.errors == {"__all__": ["Vous êtes déjà membre de ce club."]}
|
||||
|
||||
def test_ok(self):
|
||||
club = baker.make(Club)
|
||||
user = subscriber_user.make()
|
||||
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
|
||||
role = baker.make(ClubRole, is_board=False)
|
||||
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
|
||||
assert form.is_valid()
|
||||
form.save()
|
||||
assert Membership.objects.ongoing().filter(user=user, club=club).exists()
|
||||
assert Membership.objects.ongoing().filter(user=user, club=role.club).exists()
|
||||
|
||||
|
||||
class TestOldMembersView(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
club = baker.make(Club)
|
||||
roles = [1, 1, 1, 2, 2, 4, 4, 5, 7, 9, 10]
|
||||
roles = baker.make(
|
||||
ClubRole,
|
||||
club=club,
|
||||
is_board=itertools.cycle([True, True, False]),
|
||||
order=seq(0),
|
||||
_quantity=10,
|
||||
_bulk_create=True,
|
||||
)
|
||||
cls.memberships = baker.make(
|
||||
Membership,
|
||||
role=iter(roles),
|
||||
@@ -604,3 +665,11 @@ class TestOldMembersView(TestCase):
|
||||
self.client.force_login(baker.make(User))
|
||||
res = self.client.get(self.url)
|
||||
assert res.status_code == 403
|
||||
|
||||
def test_context_data(self):
|
||||
# mark a membership as not ended, to make sure it is excluded from the result
|
||||
self.memberships[0].end_date = None
|
||||
self.memberships[0].save()
|
||||
self.client.force_login(subscriber_user.make())
|
||||
res = self.client.get(self.url)
|
||||
assert list(res.context_data.get("old_members")) == self.memberships[1:]
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertHTMLEqual, assertRedirects
|
||||
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.markdown import markdown
|
||||
from core.models import PageRev, User
|
||||
@@ -59,7 +59,12 @@ def test_page_revision(client: Client):
|
||||
def test_edit_page(client: Client):
|
||||
club = baker.make(Club)
|
||||
user = subscriber_user.make()
|
||||
baker.make(Membership, user=user, club=club, role=3)
|
||||
baker.make(
|
||||
Membership,
|
||||
user=user,
|
||||
club=club,
|
||||
role=baker.make(ClubRole, club=club, is_board=True),
|
||||
)
|
||||
client.force_login(user)
|
||||
url = reverse("club:club_edit_page", kwargs={"club_id": club.id})
|
||||
content = "# foo\nLorem ipsum dolor sit amet"
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.utils.timezone import localdate
|
||||
from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from club.schemas import UserMembershipSchema
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import Page
|
||||
@@ -19,7 +19,10 @@ class TestFetchClub(TestCase):
|
||||
pages = baker.make(Page, _quantity=3, _bulk_create=True)
|
||||
clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True)
|
||||
recipe = Recipe(
|
||||
Membership, user=cls.user, start_date=localdate() - timedelta(days=2)
|
||||
Membership,
|
||||
user=cls.user,
|
||||
start_date=localdate() - timedelta(days=2),
|
||||
role=baker.make(ClubRole),
|
||||
)
|
||||
cls.members = Membership.objects.bulk_create(
|
||||
[
|
||||
|
||||
20
club/urls.py
20
club/urls.py
@@ -35,6 +35,10 @@ from club.views import (
|
||||
ClubPageEditView,
|
||||
ClubPageHistView,
|
||||
ClubRevView,
|
||||
ClubRoleBoardCreateView,
|
||||
ClubRoleMemberCreateView,
|
||||
ClubRolePresidencyCreateView,
|
||||
ClubRoleUpdateView,
|
||||
ClubSellingCSVView,
|
||||
ClubSellingView,
|
||||
ClubToolsView,
|
||||
@@ -71,6 +75,22 @@ urlpatterns = [
|
||||
ClubOldMembersView.as_view(),
|
||||
name="club_old_members",
|
||||
),
|
||||
path("<int:club_id>/role/", ClubRoleUpdateView.as_view(), name="club_roles"),
|
||||
path(
|
||||
"<int:club_id>/role/new/president/",
|
||||
ClubRolePresidencyCreateView.as_view(),
|
||||
name="new_role_president",
|
||||
),
|
||||
path(
|
||||
"<int:club_id>/role/new/board/",
|
||||
ClubRoleBoardCreateView.as_view(),
|
||||
name="new_role_board",
|
||||
),
|
||||
path(
|
||||
"<int:club_id>/role/new/member/",
|
||||
ClubRoleMemberCreateView.as_view(),
|
||||
name="new_role_member",
|
||||
),
|
||||
path("<int:club_id>/sellings/", ClubSellingView.as_view(), name="club_sellings"),
|
||||
path(
|
||||
"<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv"
|
||||
|
||||
203
club/views.py
203
club/views.py
@@ -28,8 +28,11 @@ import csv
|
||||
import itertools
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
|
||||
from django.contrib.auth.mixins import (
|
||||
LoginRequiredMixin,
|
||||
PermissionRequiredMixin,
|
||||
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
|
||||
@@ -44,18 +47,26 @@ from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView, View
|
||||
from django.views.generic.detail import SingleObjectMixin
|
||||
from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
||||
from django.views.generic.edit import (
|
||||
CreateView,
|
||||
DeleteView,
|
||||
FormMixin,
|
||||
UpdateView,
|
||||
)
|
||||
|
||||
from club.forms import (
|
||||
ClubAddMemberForm,
|
||||
ClubAdminEditForm,
|
||||
ClubEditForm,
|
||||
ClubOldMemberForm,
|
||||
ClubRoleCreateForm,
|
||||
ClubRoleFormSet,
|
||||
ClubSearchForm,
|
||||
JoinClubForm,
|
||||
MailingForm,
|
||||
SellingsForm,
|
||||
)
|
||||
from club.models import Club, Mailing, MailingSubscription, Membership
|
||||
from club.models import Club, ClubRole, Mailing, MailingSubscription, Membership
|
||||
from com.models import Poster
|
||||
from com.views import (
|
||||
PosterCreateBaseView,
|
||||
@@ -66,7 +77,12 @@ from com.views import (
|
||||
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
|
||||
from core.models import Page, PageRev
|
||||
from core.views import BasePageEditView, DetailFormView, UseFragmentsMixin
|
||||
from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin
|
||||
from core.views.mixins import (
|
||||
AllowFragment,
|
||||
FragmentMixin,
|
||||
FragmentRenderer,
|
||||
TabedViewMixin,
|
||||
)
|
||||
from counter.models import Selling
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -180,15 +196,41 @@ class ClubTabsMixin(TabedViewMixin):
|
||||
return tab_list
|
||||
|
||||
|
||||
class ClubListView(ListView):
|
||||
"""List the Clubs."""
|
||||
class ClubListView(AllowFragment, FormMixin, ListView):
|
||||
"""List the clubs of the AE, with a form to perform basic search.
|
||||
|
||||
Notes:
|
||||
This view is fully public, because we want to advertise as much as possible
|
||||
the cultural life of the AE.
|
||||
In accordance with that matter, searching and listing the clubs is done
|
||||
entirely server-side (no AlpineJS involved) ;
|
||||
this is done this way in order to be sure the page is the most accessible
|
||||
and SEO-friendly possible, even if it makes the UX slightly less smooth.
|
||||
"""
|
||||
|
||||
model = Club
|
||||
template_name = "club/club_list.jinja"
|
||||
queryset = (
|
||||
Club.objects.filter(parent=None).order_by("name").prefetch_related("children")
|
||||
)
|
||||
context_object_name = "club_list"
|
||||
form_class = ClubSearchForm
|
||||
queryset = Club.objects.order_by("name")
|
||||
paginate_by = 20
|
||||
|
||||
def get_form_kwargs(self):
|
||||
res = super().get_form_kwargs()
|
||||
if self.request.method == "GET":
|
||||
res |= {"data": self.request.GET, "initial": self.request.GET}
|
||||
return res
|
||||
|
||||
def get_queryset(self):
|
||||
form: ClubSearchForm = self.get_form()
|
||||
qs = self.queryset
|
||||
if not form.is_bound:
|
||||
return qs.filter(is_active=True)
|
||||
if not form.is_valid():
|
||||
return qs.none()
|
||||
if name := form.cleaned_data.get("name"):
|
||||
qs = qs.filter(name__icontains=name)
|
||||
if (is_active := form.cleaned_data.get("club_status")) is not None:
|
||||
qs = qs.filter(is_active=is_active)
|
||||
return qs
|
||||
|
||||
|
||||
class ClubView(ClubTabsMixin, DetailView):
|
||||
@@ -318,7 +360,7 @@ class ClubMembersView(
|
||||
membership = self.object.get_membership_for(self.request.user)
|
||||
if (
|
||||
membership
|
||||
and membership.role <= settings.SITH_MAXIMUM_FREE_ROLE
|
||||
and not membership.role.is_board
|
||||
and not self.request.user.has_perm("club.add_membership")
|
||||
):
|
||||
# Simple club members won't see the form anymore.
|
||||
@@ -343,8 +385,8 @@ class ClubMembersView(
|
||||
kwargs["members"] = list(
|
||||
self.object.members.ongoing()
|
||||
.annotate(is_editable=Q(id__in=editable))
|
||||
.order_by("-role")
|
||||
.select_related("user")
|
||||
.order_by("role__order")
|
||||
.select_related("user", "role")
|
||||
)
|
||||
kwargs["can_end_membership"] = len(editable) > 0
|
||||
return kwargs
|
||||
@@ -372,12 +414,130 @@ class ClubOldMembersView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
|
||||
return super().get_context_data(**kwargs) | {
|
||||
"old_members": (
|
||||
self.object.members.exclude(end_date=None)
|
||||
.order_by("-role", "description", "-end_date")
|
||||
.select_related("user")
|
||||
.order_by("role__order", "description", "-end_date")
|
||||
.select_related("user", "role")
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
class ClubRoleUpdateView(
|
||||
ClubTabsMixin, UserPassesTestMixin, SuccessMessageMixin, UpdateView
|
||||
):
|
||||
form_class = ClubRoleFormSet
|
||||
model = Club
|
||||
template_name = "club/club_roles.jinja"
|
||||
pk_url_kwarg = "club_id"
|
||||
current_tab = "members"
|
||||
success_message = _("Club roles updated")
|
||||
|
||||
@cached_property
|
||||
def club(self) -> Club:
|
||||
return self.get_object()
|
||||
|
||||
def test_func(self):
|
||||
return self.club.can_roles_be_edited_by(self.request.user)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {"form_kwargs": {"label_suffix": ""}}
|
||||
|
||||
def get_success_url(self):
|
||||
# if the user lost the right to view the role update page
|
||||
# (because it moved its own role out of the presidency),
|
||||
# redirect to the club member page, else stay on the same page.
|
||||
if self.club.can_roles_be_edited_by(self.request.user):
|
||||
return self.request.path
|
||||
return reverse("club:club_members", kwargs={"club_id": self.club.id})
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(**kwargs) | {
|
||||
"user_role": ClubRole.objects.filter(
|
||||
club=self.object,
|
||||
members__user=self.request.user,
|
||||
members__end_date=None,
|
||||
)
|
||||
.values_list("id", flat=True)
|
||||
.first()
|
||||
}
|
||||
|
||||
|
||||
class ClubRoleBaseCreateView(UserPassesTestMixin, SuccessMessageMixin, CreateView):
|
||||
"""View to create a new Club Role, using [][club.forms.ClubRoleCreateForm].
|
||||
|
||||
This view isn't meant to be called directly, but rather subclassed for each
|
||||
type of role that can exist :
|
||||
|
||||
- `[ClubRolePresidencyCreateView][club.views.ClubRolePresidencyCreateView]`
|
||||
to create a presidency role
|
||||
- `[ClubRoleBoardCreateView][club.views.ClubRoleBoardCreateView]`
|
||||
to create a board role
|
||||
- `[ClubRoleMemberCreateView][club.views.ClubRoleMemberCreateView]`
|
||||
to create a member role
|
||||
|
||||
Each subclass have to override the following variables :
|
||||
|
||||
- `is_presidency` and `is_board`, indicating what type of role
|
||||
the view creates.
|
||||
- `role_description`, which is the title of the page, indication
|
||||
the user what kind of role is being created.
|
||||
|
||||
This way, we are making sure the correct type of role will
|
||||
be created, without bothering the user with the implementation details.
|
||||
"""
|
||||
|
||||
form_class = ClubRoleCreateForm
|
||||
model = ClubRole
|
||||
template_name = "core/create.jinja"
|
||||
success_message = _("Role %(name)s created")
|
||||
role_description = ""
|
||||
is_presidency: bool
|
||||
is_board: bool
|
||||
|
||||
@cached_property
|
||||
def club(self):
|
||||
return get_object_or_404(Club, id=self.kwargs["club_id"])
|
||||
|
||||
def test_func(self):
|
||||
return self.request.user.is_authenticated and (
|
||||
self.request.user.has_perm("club.add_clubrole")
|
||||
or self.club.members.filter(
|
||||
user=self.request.user, role__is_presidency=True
|
||||
).exists()
|
||||
)
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {
|
||||
"club": self.club,
|
||||
"is_presidency": self.is_presidency,
|
||||
"is_board": self.is_board,
|
||||
}
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(**kwargs) | {
|
||||
"object_name": self.role_description
|
||||
}
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("club:club_roles", kwargs={"club_id": self.club.id})
|
||||
|
||||
|
||||
class ClubRolePresidencyCreateView(ClubRoleBaseCreateView):
|
||||
is_presidency = True
|
||||
is_board = True
|
||||
role_description = _("club role \u2013 presidency")
|
||||
|
||||
|
||||
class ClubRoleBoardCreateView(ClubRoleBaseCreateView):
|
||||
is_presidency = False
|
||||
is_board = True
|
||||
role_description = _("club role \u2013 board")
|
||||
|
||||
|
||||
class ClubRoleMemberCreateView(ClubRoleBaseCreateView):
|
||||
is_presidency = False
|
||||
is_board = False
|
||||
role_description = _("club role \u2013 member")
|
||||
|
||||
|
||||
class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
|
||||
"""Sales of a club."""
|
||||
|
||||
@@ -544,6 +704,11 @@ class ClubCreateView(PermissionRequiredMixin, CreateView):
|
||||
template_name = "core/create.jinja"
|
||||
permission_required = "club.add_club"
|
||||
|
||||
def form_valid(self, form):
|
||||
res = super().form_valid(form)
|
||||
self.object.create_default_roles()
|
||||
return res
|
||||
|
||||
|
||||
class MembershipSetOldView(CanEditMixin, SingleObjectMixin, View):
|
||||
"""Set a membership as being old."""
|
||||
@@ -724,9 +889,7 @@ class MailingAutoGenerationView(View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
club = self.mailing.club
|
||||
self.mailing.subscriptions.all().delete()
|
||||
members = club.members.filter(
|
||||
role__gte=settings.SITH_CLUB_ROLES_ID["Board member"]
|
||||
).exclude(end_date__lte=timezone.now())
|
||||
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)
|
||||
|
||||
@@ -28,7 +28,7 @@ from django.utils.translation import gettext as _
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertNumQueries, assertRedirects
|
||||
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import AnonymousUser, Group, User
|
||||
@@ -214,7 +214,8 @@ class TestNewsCreation(TestCase):
|
||||
def setUpTestData(cls):
|
||||
cls.club = baker.make(Club)
|
||||
cls.user = subscriber_user.make()
|
||||
baker.make(Membership, user=cls.user, club=cls.club, role=5)
|
||||
role = baker.make(ClubRole, club=cls.club, is_board=True)
|
||||
baker.make(Membership, user=cls.user, club=cls.club, role=role)
|
||||
|
||||
def setUp(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
@@ -503,7 +503,7 @@ class WeekmailArticleCreateView(CreateView):
|
||||
self.object = form.instance
|
||||
form.is_valid() # Valid a first time to populate club field
|
||||
m = form.instance.club.get_membership_for(request.user)
|
||||
if m is None or m.role <= settings.SITH_MAXIMUM_FREE_ROLE:
|
||||
if m is None or not m.role.is_board:
|
||||
form.add_error(
|
||||
"club",
|
||||
ValidationError(
|
||||
|
||||
@@ -4,9 +4,9 @@ from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
from django.utils.timezone import localdate, now
|
||||
from model_bakery import seq
|
||||
from model_bakery.recipe import Recipe, related
|
||||
from model_bakery.recipe import Recipe, foreign_key, related
|
||||
|
||||
from club.models import Membership
|
||||
from club.models import ClubRole, Membership
|
||||
from core.models import Group, User
|
||||
from subscription.models import Subscription
|
||||
|
||||
@@ -52,7 +52,9 @@ ae_board_membership = Recipe(
|
||||
Membership,
|
||||
start_date=now() - timedelta(days=30),
|
||||
club_id=settings.SITH_MAIN_CLUB_ID,
|
||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||
role=foreign_key(
|
||||
Recipe(ClubRole, club_id=settings.SITH_MAIN_CLUB_ID, is_board=True)
|
||||
),
|
||||
)
|
||||
|
||||
board_user = Recipe(
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
from django.urls.converters import IntConverter, StringConverter
|
||||
|
||||
|
||||
class FourDigitYearConverter(IntConverter):
|
||||
class FourDigitYearConverter:
|
||||
regex = "[0-9]{4}"
|
||||
|
||||
def to_python(self, value):
|
||||
return int(value)
|
||||
|
||||
def to_url(self, value):
|
||||
return str(value).zfill(4)
|
||||
|
||||
|
||||
class TwoDigitMonthConverter(IntConverter):
|
||||
class TwoDigitMonthConverter:
|
||||
regex = "[0-9]{2}"
|
||||
|
||||
def to_python(self, value):
|
||||
return int(value)
|
||||
|
||||
def to_url(self, value):
|
||||
return str(value).zfill(2)
|
||||
|
||||
@@ -25,9 +28,3 @@ class BooleanStringConverter:
|
||||
|
||||
def to_url(self, value):
|
||||
return str(value)
|
||||
|
||||
|
||||
class ResultConverter(StringConverter):
|
||||
"""Converter whose regex match either "success" or "failure"."""
|
||||
|
||||
regex = "(success|failure)"
|
||||
|
||||
@@ -39,12 +39,16 @@ class Command(BaseCommand):
|
||||
return None
|
||||
return xapian.version_string()
|
||||
|
||||
def _desired_version(self) -> str:
|
||||
def _desired_version(self) -> tuple[str, str, str]:
|
||||
with open(
|
||||
Path(__file__).parent.parent.parent.parent / "pyproject.toml", "rb"
|
||||
) as f:
|
||||
pyproject = tomli.load(f)
|
||||
return pyproject["tool"]["xapian"]["version"]
|
||||
return (
|
||||
pyproject["tool"]["xapian"]["version"],
|
||||
pyproject["tool"]["xapian"]["core-sha256"],
|
||||
pyproject["tool"]["xapian"]["bindings-sha256"],
|
||||
)
|
||||
|
||||
def handle(self, *args, force: bool, **options):
|
||||
if not os.environ.get("VIRTUAL_ENV", None):
|
||||
@@ -53,7 +57,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
return
|
||||
|
||||
desired = self._desired_version()
|
||||
desired, core_checksum, bindings_checksum = self._desired_version()
|
||||
if desired == self._current_version():
|
||||
if not force:
|
||||
self.stdout.write(
|
||||
@@ -65,7 +69,12 @@ class Command(BaseCommand):
|
||||
f"Installing xapian version {desired} at {os.environ['VIRTUAL_ENV']}"
|
||||
)
|
||||
subprocess.run(
|
||||
[str(Path(__file__).parent / "install_xapian.sh"), desired],
|
||||
[
|
||||
str(Path(__file__).parent / "install_xapian.sh"),
|
||||
desired,
|
||||
core_checksum,
|
||||
bindings_checksum,
|
||||
],
|
||||
env=dict(os.environ),
|
||||
check=True,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Originates from https://gist.github.com/jorgecarleitao/ab6246c86c936b9c55fd
|
||||
# first argument of the script is Xapian version (e.g. 1.2.19)
|
||||
# second argument of the script is core sha256
|
||||
# second argument of the script is binding sha256
|
||||
VERSION="$1"
|
||||
CORE_SHA256="$2"
|
||||
BINDINGS_SHA256="$3"
|
||||
|
||||
# Cleanup env vars for auto discovery mechanism
|
||||
unset CPATH
|
||||
@@ -21,9 +25,15 @@ BINDINGS=xapian-bindings-$VERSION
|
||||
|
||||
# download
|
||||
echo "Downloading source..."
|
||||
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${CORE}.tar.xz"
|
||||
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${CORE}.tar.xz" || exit 1
|
||||
|
||||
echo "${CORE_SHA256} ${CORE}.tar.xz" | sha256sum -c - || exit 1
|
||||
|
||||
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${BINDINGS}.tar.xz"
|
||||
|
||||
echo "${BINDINGS_SHA256} ${BINDINGS}.tar.xz" | sha256sum -c - || exit 1
|
||||
|
||||
|
||||
# extract
|
||||
echo "Extracting source..."
|
||||
tar xf "${CORE}.tar.xz"
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
# 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
|
||||
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
#
|
||||
#
|
||||
@@ -28,7 +28,6 @@ from typing import ClassVar, NamedTuple
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
@@ -37,7 +36,7 @@ from django.utils import timezone
|
||||
from django.utils.timezone import localdate
|
||||
from PIL import Image
|
||||
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from com.ics_calendar import IcsCalendar
|
||||
from com.models import News, NewsDate, Sith, Weekmail
|
||||
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
|
||||
@@ -63,6 +62,13 @@ class PopulatedGroups(NamedTuple):
|
||||
campus_admin: Group
|
||||
|
||||
|
||||
class PopulatedClubs(NamedTuple):
|
||||
ae: Club
|
||||
troll: Club
|
||||
pdf: Club
|
||||
refound: Club
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
|
||||
SAS_FIXTURE_PATH: ClassVar[Path] = (
|
||||
@@ -105,47 +111,23 @@ class Command(BaseCommand):
|
||||
)
|
||||
self.profiles_root = SithFile.objects.create(name="profiles", owner=root)
|
||||
home_root = SithFile.objects.create(name="users", owner=root)
|
||||
club_root = SithFile.objects.create(name="clubs", owner=root)
|
||||
sas = SithFile.objects.create(name="SAS", owner=root)
|
||||
SithFile.objects.create(
|
||||
name="CGU",
|
||||
is_folder=False,
|
||||
file=ContentFile(
|
||||
content="Conditions générales d'utilisation", name="cgu.txt"
|
||||
),
|
||||
owner=root,
|
||||
)
|
||||
|
||||
# Page needed for club creation
|
||||
p = Page(name=settings.SITH_CLUB_ROOT_PAGE)
|
||||
p.save(force_lock=True)
|
||||
|
||||
main_club = Club.objects.create(
|
||||
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
|
||||
)
|
||||
main_club.board_group.permissions.add(
|
||||
*Permission.objects.filter(
|
||||
codename__in=[
|
||||
"view_subscription",
|
||||
"add_subscription",
|
||||
"view_hidden_user",
|
||||
]
|
||||
)
|
||||
)
|
||||
bar_club = Club.objects.create(
|
||||
id=settings.SITH_PDF_CLUB_ID,
|
||||
name="PdF",
|
||||
address="6 Boulevard Anatole France, 90000 Belfort",
|
||||
)
|
||||
club_root = SithFile.objects.create(name="clubs", owner=root)
|
||||
sas = SithFile.objects.create(name="SAS", owner=root, is_in_sas=True)
|
||||
clubs = self._create_clubs()
|
||||
|
||||
self.reset_index("club")
|
||||
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
|
||||
Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR").save()
|
||||
Counter(id=bar_id, name=bar_name, club=clubs.pdf, type="BAR").save()
|
||||
self.reset_index("counter")
|
||||
counters = [
|
||||
Counter(name="Eboutic", club=main_club, type="EBOUTIC"),
|
||||
Counter(name="AE", club=main_club, type="OFFICE"),
|
||||
Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
|
||||
Counter(name="Eboutic", club=clubs.ae, type="EBOUTIC"),
|
||||
Counter(name="AE", club=clubs.ae, type="OFFICE"),
|
||||
Counter(name="Vidage comptes AE", club=clubs.ae, type="OFFICE"),
|
||||
]
|
||||
Counter.objects.bulk_create(counters)
|
||||
bar_groups = []
|
||||
@@ -328,54 +310,41 @@ class Command(BaseCommand):
|
||||
self._create_subscription(tutu)
|
||||
StudentCard(uid="9A89B82018B0A0", customer=sli.customer).save()
|
||||
|
||||
# Clubs
|
||||
Club.objects.create(
|
||||
name="Bibo'UT", address="46 de la Boustifaille", parent=main_club
|
||||
Membership.objects.create(
|
||||
user=skia, club=clubs.ae, role=clubs.ae.roles.get(name="Respo Info")
|
||||
)
|
||||
guyut = Club.objects.create(
|
||||
name="Guy'UT", address="42 de la Boustifaille", parent=main_club
|
||||
)
|
||||
Club.objects.create(name="Woenzel'UT", address="Woenzel", parent=guyut)
|
||||
troll = Club.objects.create(
|
||||
name="Troll Penché", address="Terre Du Milieu", parent=main_club
|
||||
)
|
||||
refound = Club.objects.create(
|
||||
name="Carte AE", address="Jamais imprimée", parent=main_club
|
||||
)
|
||||
|
||||
Membership.objects.create(user=skia, club=main_club, role=3)
|
||||
Membership.objects.create(
|
||||
user=comunity,
|
||||
club=bar_club,
|
||||
club=clubs.pdf,
|
||||
start_date=localdate(),
|
||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||
role=clubs.pdf.roles.get(name="Membre du bureau"),
|
||||
)
|
||||
Membership.objects.create(
|
||||
user=sli,
|
||||
club=troll,
|
||||
role=9,
|
||||
club=clubs.troll,
|
||||
role=clubs.troll.roles.get(name="Vice-Président⸱e"),
|
||||
description="Padawan Troll",
|
||||
start_date=localdate() - timedelta(days=17),
|
||||
)
|
||||
Membership.objects.create(
|
||||
user=krophil,
|
||||
club=troll,
|
||||
role=10,
|
||||
club=clubs.troll,
|
||||
role=clubs.troll.roles.get(name="Président⸱e"),
|
||||
description="Maitre Troll",
|
||||
start_date=localdate() - timedelta(days=200),
|
||||
)
|
||||
Membership.objects.create(
|
||||
user=skia,
|
||||
club=troll,
|
||||
role=2,
|
||||
club=clubs.troll,
|
||||
role=clubs.troll.roles.get(name="Membre du bureau"),
|
||||
description="Grand Ancien Troll",
|
||||
start_date=localdate() - timedelta(days=400),
|
||||
end_date=localdate() - timedelta(days=86),
|
||||
)
|
||||
Membership.objects.create(
|
||||
user=richard,
|
||||
club=troll,
|
||||
role=2,
|
||||
club=clubs.troll,
|
||||
role=clubs.troll.roles.get(name="Membre du bureau"),
|
||||
description="",
|
||||
start_date=localdate() - timedelta(days=200),
|
||||
end_date=localdate() - timedelta(days=100),
|
||||
@@ -392,7 +361,7 @@ class Command(BaseCommand):
|
||||
purchase_price="15",
|
||||
selling_price="15",
|
||||
special_selling_price="15",
|
||||
club=main_club,
|
||||
club=clubs.ae,
|
||||
)
|
||||
cotis2 = Product.objects.create(
|
||||
name="Cotis 2 semestres",
|
||||
@@ -401,7 +370,7 @@ class Command(BaseCommand):
|
||||
purchase_price="28",
|
||||
selling_price="28",
|
||||
special_selling_price="28",
|
||||
club=main_club,
|
||||
club=clubs.ae,
|
||||
)
|
||||
refill = Product.objects.create(
|
||||
name="Rechargement 15 €",
|
||||
@@ -410,7 +379,7 @@ class Command(BaseCommand):
|
||||
purchase_price="15",
|
||||
selling_price="15",
|
||||
special_selling_price="15",
|
||||
club=main_club,
|
||||
club=clubs.ae,
|
||||
)
|
||||
barb = Product.objects.create(
|
||||
name="Barbar",
|
||||
@@ -419,7 +388,7 @@ class Command(BaseCommand):
|
||||
purchase_price="1.50",
|
||||
selling_price="1.7",
|
||||
special_selling_price="1.6",
|
||||
club=main_club,
|
||||
club=clubs.ae,
|
||||
limit_age=18,
|
||||
)
|
||||
cble = Product.objects.create(
|
||||
@@ -429,7 +398,7 @@ class Command(BaseCommand):
|
||||
purchase_price="1.50",
|
||||
selling_price="1.7",
|
||||
special_selling_price="1.6",
|
||||
club=main_club,
|
||||
club=clubs.ae,
|
||||
limit_age=18,
|
||||
)
|
||||
cons = Product.objects.create(
|
||||
@@ -439,7 +408,7 @@ class Command(BaseCommand):
|
||||
purchase_price="1",
|
||||
selling_price="1",
|
||||
special_selling_price="1",
|
||||
club=main_club,
|
||||
club=clubs.ae,
|
||||
)
|
||||
dcons = Product.objects.create(
|
||||
name="Déconsigne Eco-cup",
|
||||
@@ -448,7 +417,7 @@ class Command(BaseCommand):
|
||||
purchase_price="-1",
|
||||
selling_price="-1",
|
||||
special_selling_price="-1",
|
||||
club=main_club,
|
||||
club=clubs.ae,
|
||||
)
|
||||
cors = Product.objects.create(
|
||||
name="Corsendonk",
|
||||
@@ -457,7 +426,7 @@ class Command(BaseCommand):
|
||||
purchase_price="1.50",
|
||||
selling_price="1.7",
|
||||
special_selling_price="1.6",
|
||||
club=main_club,
|
||||
club=clubs.ae,
|
||||
limit_age=18,
|
||||
)
|
||||
carolus = Product.objects.create(
|
||||
@@ -467,7 +436,7 @@ class Command(BaseCommand):
|
||||
purchase_price="1.50",
|
||||
selling_price="1.7",
|
||||
special_selling_price="1.6",
|
||||
club=main_club,
|
||||
club=clubs.ae,
|
||||
limit_age=18,
|
||||
)
|
||||
Product.objects.create(
|
||||
@@ -476,7 +445,7 @@ class Command(BaseCommand):
|
||||
purchase_price="0",
|
||||
selling_price="0",
|
||||
special_selling_price="0",
|
||||
club=refound,
|
||||
club=clubs.refound,
|
||||
)
|
||||
groups.subscribers.products.add(
|
||||
cotis, cotis2, refill, barb, cble, cors, carolus
|
||||
@@ -489,7 +458,7 @@ class Command(BaseCommand):
|
||||
eboutic = Counter.objects.get(name="Eboutic")
|
||||
eboutic.products.add(barb, cotis, cotis2, refill)
|
||||
|
||||
Counter.objects.create(name="Carte AE", club=refound, type="OFFICE")
|
||||
Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE")
|
||||
|
||||
ReturnableProduct.objects.create(
|
||||
product=cons, returned_product=dcons, max_return=3
|
||||
@@ -513,7 +482,7 @@ class Command(BaseCommand):
|
||||
end_date="7942-06-12 10:28:45+01",
|
||||
)
|
||||
el.view_groups.add(groups.public)
|
||||
el.edit_groups.add(main_club.board_group)
|
||||
el.edit_groups.add(clubs.ae.board_group)
|
||||
el.candidature_groups.add(groups.subscribers)
|
||||
el.vote_groups.add(groups.subscribers)
|
||||
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
|
||||
@@ -586,7 +555,7 @@ class Command(BaseCommand):
|
||||
title="Apero barman",
|
||||
summary="Viens boire un coup avec les barmans",
|
||||
content="Glou glou glou glou glou glou glou",
|
||||
club=bar_club,
|
||||
club=clubs.pdf,
|
||||
author=subscriber,
|
||||
is_published=True,
|
||||
moderator=skia,
|
||||
@@ -604,7 +573,7 @@ class Command(BaseCommand):
|
||||
content=(
|
||||
"Viens donc t'enjailler avec les autres barmans aux frais du BdF! \\o/"
|
||||
),
|
||||
club=bar_club,
|
||||
club=clubs.pdf,
|
||||
author=subscriber,
|
||||
is_published=True,
|
||||
moderator=skia,
|
||||
@@ -620,7 +589,7 @@ class Command(BaseCommand):
|
||||
title="Repas fromager",
|
||||
summary="Wien manger du l'bon fromeug'",
|
||||
content="Fô viendre mangey d'la bonne fondue!",
|
||||
club=bar_club,
|
||||
club=clubs.pdf,
|
||||
author=subscriber,
|
||||
is_published=True,
|
||||
moderator=skia,
|
||||
@@ -636,7 +605,7 @@ class Command(BaseCommand):
|
||||
title="SdF",
|
||||
summary="Enjoy la fin des finaux!",
|
||||
content="Viens faire la fête avec tout plein de gens!",
|
||||
club=bar_club,
|
||||
club=clubs.pdf,
|
||||
author=subscriber,
|
||||
is_published=True,
|
||||
moderator=skia,
|
||||
@@ -654,7 +623,7 @@ class Command(BaseCommand):
|
||||
summary="Viens jouer!",
|
||||
content="Rejoins la fine équipe du Troll Penché et viens "
|
||||
"t'amuser le Vendredi soir!",
|
||||
club=troll,
|
||||
club=clubs.troll,
|
||||
author=subscriber,
|
||||
is_published=True,
|
||||
moderator=skia,
|
||||
@@ -791,6 +760,57 @@ class Command(BaseCommand):
|
||||
)
|
||||
s.save()
|
||||
|
||||
def _create_clubs(self) -> PopulatedClubs:
|
||||
ae = Club.objects.create(
|
||||
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
|
||||
)
|
||||
ae.board_group.permissions.add(
|
||||
*Permission.objects.filter(
|
||||
codename__in=[
|
||||
"view_subscription",
|
||||
"add_subscription",
|
||||
"add_membership",
|
||||
"view_hidden_user",
|
||||
]
|
||||
)
|
||||
)
|
||||
pdf = Club.objects.create(
|
||||
id=settings.SITH_PDF_CLUB_ID,
|
||||
name="PdF",
|
||||
address="6 Boulevard Anatole France, 90000 Belfort",
|
||||
)
|
||||
troll = Club.objects.create(
|
||||
name="Troll Penché", address="Terre Du Milieu", parent=ae
|
||||
)
|
||||
refound = Club.objects.create(
|
||||
name="Carte AE", address="Jamais imprimée", parent=ae
|
||||
)
|
||||
roles = []
|
||||
presidency_roles = ["Président⸱e", "Vice-Président⸱e"]
|
||||
board_roles = [
|
||||
"Trésorier⸱e",
|
||||
"Secrétaire",
|
||||
"Respo Info",
|
||||
"Respo Com",
|
||||
"Membre du bureau",
|
||||
]
|
||||
simple_roles = ["Membre actif⸱ve", "Curieux⸱euse"]
|
||||
for club in ae, pdf, troll, refound:
|
||||
for i, role in enumerate(presidency_roles):
|
||||
roles.append(
|
||||
ClubRole(
|
||||
club=club, order=i, name=role, is_presidency=True, is_board=True
|
||||
)
|
||||
)
|
||||
for i, role in enumerate(board_roles, start=len(presidency_roles)):
|
||||
roles.append(ClubRole(club=club, order=i, name=role, is_board=True))
|
||||
for i, role in enumerate(
|
||||
simple_roles, start=len(presidency_roles) + len(board_roles)
|
||||
):
|
||||
roles.append(ClubRole(club=club, order=i, name=role))
|
||||
ClubRole.objects.bulk_create(roles)
|
||||
return PopulatedClubs(ae=ae, troll=troll, pdf=pdf, refound=refound)
|
||||
|
||||
def _create_groups(self) -> PopulatedGroups:
|
||||
perms = Permission.objects.all()
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import math
|
||||
import random
|
||||
from datetime import date, timedelta
|
||||
from datetime import timezone as tz
|
||||
@@ -12,7 +11,7 @@ from django.db.models import Count, Exists, Min, OuterRef, Subquery
|
||||
from django.utils.timezone import localdate, make_aware, now
|
||||
from faker import Faker
|
||||
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.models import Group, User, UserBan
|
||||
from counter.models import (
|
||||
Counter,
|
||||
@@ -35,17 +34,12 @@ class Command(BaseCommand):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.faker = Faker("fr_FR")
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"-n", "--nb-users", help="Number of users to create", type=int, default=600
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if not settings.DEBUG:
|
||||
raise Exception("Never call this command in prod. Never.")
|
||||
|
||||
self.stdout.write("Creating users...")
|
||||
users = self.create_users(options["nb_users"])
|
||||
users = self.create_users()
|
||||
self.create_bans(random.sample(users, k=len(users) // 200)) # 0.5% of users
|
||||
subscribers = random.sample(users, k=int(0.8 * len(users)))
|
||||
self.stdout.write("Creating subscriptions...")
|
||||
@@ -85,7 +79,7 @@ class Command(BaseCommand):
|
||||
self.stdout.write("Creating products...")
|
||||
self.create_products()
|
||||
self.stdout.write("Creating sales and refills...")
|
||||
sellers = random.sample(users, len(users) // 10)
|
||||
sellers = random.sample(list(User.objects.all()), 100)
|
||||
self.create_sales(sellers)
|
||||
self.stdout.write("Creating permanences...")
|
||||
self.create_permanences(sellers)
|
||||
@@ -94,7 +88,7 @@ class Command(BaseCommand):
|
||||
|
||||
self.stdout.write("Done")
|
||||
|
||||
def create_users(self, nb_users: int = 600) -> list[User]:
|
||||
def create_users(self) -> list[User]:
|
||||
# Create a single password hash for all users to make it faster.
|
||||
# It's insecure as hell, but it's ok since it's only for dev purposes.
|
||||
password = make_password("plop")
|
||||
@@ -113,7 +107,7 @@ class Command(BaseCommand):
|
||||
address=self.faker.address(),
|
||||
password=password,
|
||||
)
|
||||
for _ in range(nb_users)
|
||||
for _ in range(600)
|
||||
]
|
||||
# there may a duplicate or two
|
||||
# Not a problem, we will just have 599 users instead of 600
|
||||
@@ -178,20 +172,25 @@ class Command(BaseCommand):
|
||||
Customer.objects.bulk_create(customers, ignore_conflicts=True)
|
||||
|
||||
def make_club(self, club: Club, members: list[User], old_members: list[User]):
|
||||
def zip_roles(users: list[User]) -> Iterator[tuple[User, int]]:
|
||||
roles = iter(sorted(settings.SITH_CLUB_ROLES.keys(), reverse=True))
|
||||
roles: list[ClubRole] = list(club.roles.all())
|
||||
|
||||
def zip_roles(users: list[User]) -> Iterator[tuple[User, ClubRole]]:
|
||||
important_roles = [r for r in roles if r.is_board]
|
||||
important_roles.sort(key=lambda r: r.order)
|
||||
simple_board_role = important_roles.pop()
|
||||
member_roles = [r for r in roles if not r.is_board]
|
||||
user_idx = 0
|
||||
while (role := next(roles)) > 2:
|
||||
for _role in important_roles:
|
||||
# one member for each major role
|
||||
yield users[user_idx], role
|
||||
yield users[user_idx], _role
|
||||
user_idx += 1
|
||||
for _ in range(int(0.3 * (len(users) - user_idx))):
|
||||
# 30% of the remaining in the board
|
||||
yield users[user_idx], 2
|
||||
yield users[user_idx], simple_board_role
|
||||
user_idx += 1
|
||||
for remaining in users[user_idx + 1 :]:
|
||||
# everything else is a simple member
|
||||
yield remaining, 1
|
||||
yield remaining, random.choices(member_roles, weights=(0.8, 0.2))[0]
|
||||
|
||||
memberships = []
|
||||
old_members = old_members.copy()
|
||||
@@ -203,19 +202,14 @@ class Command(BaseCommand):
|
||||
start_date=start,
|
||||
end_date=self.faker.past_date(start),
|
||||
user=old,
|
||||
role=random.choice(list(settings.SITH_CLUB_ROLES.keys())),
|
||||
role=random.choice(roles),
|
||||
club=club,
|
||||
)
|
||||
)
|
||||
for member, role in zip_roles(members):
|
||||
start = self.faker.past_date("-1y")
|
||||
memberships.append(
|
||||
Membership(
|
||||
start_date=start,
|
||||
user=member,
|
||||
role=role,
|
||||
club=club,
|
||||
)
|
||||
Membership(start_date=start, user=member, role=role, club=club)
|
||||
)
|
||||
memberships = Membership.objects.bulk_create(memberships)
|
||||
Membership._add_club_groups(memberships)
|
||||
@@ -416,9 +410,8 @@ class Command(BaseCommand):
|
||||
Permanency.objects.bulk_create(perms)
|
||||
|
||||
def create_forums(self):
|
||||
users = list(User.objects.all())
|
||||
forumers = random.sample(users, math.ceil(len(users) / 10))
|
||||
most_actives = random.sample(forumers, math.ceil(len(forumers) / 6))
|
||||
forumers = random.sample(list(User.objects.all()), 100)
|
||||
most_actives = random.sample(forumers, 10)
|
||||
categories = list(Forum.objects.filter(is_category=True))
|
||||
new_forums = [
|
||||
Forum(name=self.faker.text(20), parent=random.choice(categories))
|
||||
|
||||
@@ -1,18 +1,136 @@
|
||||
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
|
||||
import {
|
||||
type InheritedHtmlElement,
|
||||
inheritHtmlElement,
|
||||
registerComponent,
|
||||
} from "#core:utils/web-components.ts";
|
||||
|
||||
/**
|
||||
* ElementOnce web components
|
||||
*
|
||||
* Those elements ensures that their content is always included only once on a document
|
||||
* They are compatible with elements that are not managed with our Web Components
|
||||
**/
|
||||
export interface ElementOnce<K extends keyof HTMLElementTagNameMap>
|
||||
extends InheritedHtmlElement<K> {
|
||||
getElementQuerySelector(): string;
|
||||
refresh(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an abstract class for ElementOnce types Web Components
|
||||
**/
|
||||
export function elementOnce<K extends keyof HTMLElementTagNameMap>(tagName: K) {
|
||||
abstract class ElementOnceImpl
|
||||
extends inheritHtmlElement(tagName)
|
||||
implements ElementOnce<K>
|
||||
{
|
||||
abstract getElementQuerySelector(): string;
|
||||
|
||||
clearNode() {
|
||||
while (this.firstChild) {
|
||||
this.removeChild(this.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.clearNode();
|
||||
if (document.querySelectorAll(this.getElementQuerySelector()).length === 0) {
|
||||
this.appendChild(this.node);
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback(false);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
// The MutationObserver can't see web components being removed
|
||||
// It also can't see if something is removed inside after the component gets deleted
|
||||
// We need to manually clear the containing node to trigger the observer
|
||||
this.clearNode();
|
||||
}
|
||||
}
|
||||
return ElementOnceImpl;
|
||||
}
|
||||
|
||||
// Set of ElementOnce type components to refresh with the observer
|
||||
const registeredComponents: Set<string> = new Set();
|
||||
|
||||
/**
|
||||
* Helper to register ElementOnce types Web Components
|
||||
* It's a wrapper around registerComponent that registers that component on
|
||||
* a MutationObserver that activates a refresh on them when elements are removed
|
||||
*
|
||||
* You are not supposed to unregister an element
|
||||
**/
|
||||
export function registerElementOnce(name: string, options?: ElementDefinitionOptions) {
|
||||
registeredComponents.add(name);
|
||||
return registerComponent(name, options);
|
||||
}
|
||||
|
||||
// Refresh all ElementOnce components on the document based on the tag name of the removed element
|
||||
const refreshElement = <
|
||||
T extends keyof HTMLElementTagNameMap,
|
||||
K extends keyof HTMLElementTagNameMap,
|
||||
>(
|
||||
components: HTMLCollectionOf<ElementOnce<T>>,
|
||||
removedTagName: K,
|
||||
) => {
|
||||
for (const element of components) {
|
||||
// We can't guess if an element is compatible before we get one
|
||||
// We exit the function completely if it's not compatible
|
||||
if (element.inheritedTagName.toUpperCase() !== removedTagName.toUpperCase()) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.refresh();
|
||||
}
|
||||
};
|
||||
|
||||
// Since we need to pause the observer, we make an helper to start it with consistent arguments
|
||||
const startObserver = (observer: MutationObserver) => {
|
||||
observer.observe(document, {
|
||||
// We want to also listen for elements contained in the header (eg: link)
|
||||
subtree: true,
|
||||
childList: true,
|
||||
});
|
||||
};
|
||||
|
||||
// Refresh ElementOnce components when changes happens
|
||||
const observer = new MutationObserver((mutations: MutationRecord[]) => {
|
||||
// To avoid infinite recursion, we need to pause the observer while manipulation nodes
|
||||
observer.disconnect();
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.removedNodes) {
|
||||
if (node.nodeType !== node.ELEMENT_NODE) {
|
||||
continue;
|
||||
}
|
||||
for (const registered of registeredComponents) {
|
||||
refreshElement(
|
||||
document.getElementsByTagName(registered) as HTMLCollectionOf<
|
||||
ElementOnce<"html"> // The specific tag doesn't really matter
|
||||
>,
|
||||
(node as HTMLElement).tagName as keyof HTMLElementTagNameMap,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// We then resume the observer
|
||||
startObserver(observer);
|
||||
});
|
||||
|
||||
startObserver(observer);
|
||||
|
||||
/**
|
||||
* Web component used to import css files only once
|
||||
* If called multiple times or the file was already imported, it does nothing
|
||||
**/
|
||||
@registerComponent("link-once")
|
||||
export class LinkOnce extends inheritHtmlElement("link") {
|
||||
connectedCallback() {
|
||||
super.connectedCallback(false);
|
||||
@registerElementOnce("link-once")
|
||||
export class LinkOnce extends elementOnce("link") {
|
||||
getElementQuerySelector(): string {
|
||||
// We get href from node.attributes instead of node.href to avoid getting the domain part
|
||||
const href = this.node.attributes.getNamedItem("href").nodeValue;
|
||||
if (document.querySelectorAll(`link[href='${href}']`).length === 0) {
|
||||
this.appendChild(this.node);
|
||||
}
|
||||
return `link[href='${this.node.attributes.getNamedItem("href").nodeValue}']`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,14 +138,10 @@ export class LinkOnce extends inheritHtmlElement("link") {
|
||||
* Web component used to import javascript files only once
|
||||
* If called multiple times or the file was already imported, it does nothing
|
||||
**/
|
||||
@registerComponent("script-once")
|
||||
@registerElementOnce("script-once")
|
||||
export class ScriptOnce extends inheritHtmlElement("script") {
|
||||
connectedCallback() {
|
||||
super.connectedCallback(false);
|
||||
// We get src from node.attributes instead of node.src to avoid getting the domain part
|
||||
const src = this.node.attributes.getNamedItem("src").nodeValue;
|
||||
if (document.querySelectorAll(`script[src='${src}']`).length === 0) {
|
||||
this.appendChild(this.node);
|
||||
}
|
||||
getElementQuerySelector(): string {
|
||||
// We get href from node.attributes instead of node.src to avoid getting the domain part
|
||||
return `script[src='${this.node.attributes.getNamedItem("src").nodeValue}']`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,17 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio
|
||||
* The technique is to:
|
||||
* create a new web component
|
||||
* create the desired type inside
|
||||
* pass all attributes to the child component
|
||||
* move all attributes to the child component
|
||||
* store is at as `node` inside the parent
|
||||
*
|
||||
* Since we can't use the generic type to instantiate the node, we create a generator function
|
||||
**/
|
||||
export interface InheritedHtmlElement<K extends keyof HTMLElementTagNameMap>
|
||||
extends HTMLElement {
|
||||
readonly inheritedTagName: K;
|
||||
node: HTMLElementTagNameMap[K];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generator function that creates an InheritedHtmlElement compatible class
|
||||
*
|
||||
* ```js
|
||||
* class MyClass extends inheritHtmlElement("select") {
|
||||
@@ -35,11 +42,15 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio
|
||||
* ```
|
||||
**/
|
||||
export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagName: K) {
|
||||
return class Inherited extends HTMLElement {
|
||||
protected node: HTMLElementTagNameMap[K];
|
||||
return class InheritedHtmlElementImpl
|
||||
extends HTMLElement
|
||||
implements InheritedHtmlElement<K>
|
||||
{
|
||||
readonly inheritedTagName = tagName;
|
||||
node: HTMLElementTagNameMap[K];
|
||||
|
||||
connectedCallback(autoAddNode?: boolean) {
|
||||
this.node = document.createElement(tagName);
|
||||
this.node = document.createElement(this.inheritedTagName);
|
||||
const attributes: Attr[] = []; // We need to make a copy to delete while iterating
|
||||
for (const attr of this.attributes) {
|
||||
if (attr.name in this.node) {
|
||||
@@ -47,6 +58,10 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
|
||||
}
|
||||
}
|
||||
|
||||
// We move compatible attributes to the child element
|
||||
// This avoids weird inconsistencies between attributes
|
||||
// when we manipulate the dom in the future
|
||||
// This is especially important when using attribute based reactivity
|
||||
for (const attr of attributes) {
|
||||
this.removeAttributeNode(attr);
|
||||
this.node.setAttributeNode(attr);
|
||||
|
||||
@@ -53,7 +53,7 @@ details.accordion>.accordion-content {
|
||||
opacity: 0;
|
||||
|
||||
@supports (max-height: calc-size(max-content, size)) {
|
||||
max-height: 0px;
|
||||
max-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,11 +71,12 @@ details.accordion>.accordion-content {
|
||||
}
|
||||
}
|
||||
|
||||
// ::details-content isn't available on firefox yet
|
||||
// ::details-content is available on firefox only since september 2025
|
||||
// (and wasn't available when this code was initially written)
|
||||
// we use .accordion-content as a workaround
|
||||
// But we need to use ::details-content for chrome because it's
|
||||
// not working correctly otherwise
|
||||
// it only happen in chrome, not safari or firefox
|
||||
// it only happens in chrome, not safari or firefox
|
||||
// Note: `selector` is not supported by scss so we comment it out to
|
||||
// avoid compiling it and sending it straight to the css
|
||||
// This is a trick that comes from here :
|
||||
|
||||
@@ -141,6 +141,7 @@ form {
|
||||
display: block;
|
||||
margin: calc(var(--nf-input-size) * 1.5) auto 10px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
|
||||
.fields-centered {
|
||||
padding: 10px 10px 0;
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
<details name="navbar" class="menu">
|
||||
<summary class="head">{% trans %}Associations & Clubs{% endtrans %}</summary>
|
||||
<ul class="content">
|
||||
<li><a href="{{ url('core:page', page_name='ae') }}">{% trans %}AE{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('core:page', page_name='clubs') }}">{% trans %}AE's clubs{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('core:page', page_name='utbm-associations') }}">{% trans %}Others UTBM's Associations{% endtrans %}</a></li>
|
||||
<li><a href="{{ url("core:page", page_name="ae") }}">{% trans %}AE{% endtrans %}</a></li>
|
||||
<li><a href="{{ url("club:club_list") }}">{% trans %}AE's clubs{% endtrans %}</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
<details name="navbar" class="menu">
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{# if the template context has the `object_name` variable,
|
||||
then this one will be used in the page title,
|
||||
instead of the result of `str(object)` #}
|
||||
{% if not object_name %}
|
||||
{% set object_name=form.instance.__class__._meta.verbose_name %}
|
||||
{% endif %}
|
||||
|
||||
{% block title %}
|
||||
{% trans name=form.instance.__class__._meta.verbose_name %}Create {{ name }}{% endtrans %}
|
||||
{% trans name=object_name %}Create {{ name }}{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>{% trans name=form.instance.__class__._meta.verbose_name %}Create {{ name }}{% endtrans %}</h2>
|
||||
<h2>{% trans name=object_name %}Create {{ name }}{% endtrans %}</h2>
|
||||
<form action="" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p() }}
|
||||
|
||||
@@ -48,6 +48,6 @@
|
||||
>{% trans %}Delete{% endtrans %}</button></p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{{ paginate_htmx(page_obj, paginator) }}
|
||||
{{ paginate_htmx(request, page_obj, paginator) }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -118,20 +118,21 @@
|
||||
</nav>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro paginate_jinja(current_page, paginator) %}
|
||||
{% macro paginate_jinja(request, current_page, paginator) %}
|
||||
{# Add pagination buttons for pages without Alpine.
|
||||
|
||||
This must be coupled with a view that handles pagination
|
||||
with the Django Paginator object.
|
||||
|
||||
Parameters:
|
||||
request (django.http.request.HttpRequest): the current django request
|
||||
current_page (django.core.paginator.Page): the current page object
|
||||
paginator (django.core.paginator.Paginator): the paginator object
|
||||
#}
|
||||
{{ paginate_server_side(current_page, paginator, False) }}
|
||||
{{ paginate_server_side(request, current_page, paginator, "") }}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro paginate_htmx(current_page, paginator) %}
|
||||
{% macro paginate_htmx(request, current_page, paginator, htmx_target="#content") %}
|
||||
{# Add pagination buttons for pages without Alpine but supporting fragments.
|
||||
|
||||
This must be coupled with a view that handles pagination
|
||||
@@ -140,24 +141,26 @@
|
||||
The replaced fragment will be #content so make sure you are calling this macro inside your content block.
|
||||
|
||||
Parameters:
|
||||
request (django.http.request.HttpRequest): the current django request
|
||||
current_page (django.core.paginator.Page): the current page object
|
||||
paginator (django.core.paginator.Paginator): the paginator object
|
||||
htmx_target (string): htmx target selector (default '#content')
|
||||
#}
|
||||
{{ paginate_server_side(current_page, paginator, True) }}
|
||||
{{ paginate_server_side(request, current_page, paginator, htmx_target) }}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro paginate_server_side(current_page, paginator, use_htmx) %}
|
||||
{% macro paginate_server_side(request, current_page, paginator, htmx_target) %}
|
||||
<nav class="pagination">
|
||||
{% if current_page.has_previous() %}
|
||||
<a
|
||||
{% if use_htmx -%}
|
||||
hx-get="?{{ querystring(page=current_page.previous_page_number()) }}"
|
||||
{% if htmx_target -%}
|
||||
hx-get="?{{ querystring(request, page=current_page.previous_page_number()) }}"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#content"
|
||||
hx-target="{{ htmx_target }}"
|
||||
hx-push-url="true"
|
||||
hx-trigger="click, keyup[key=='ArrowLeft'] from:body"
|
||||
{%- else -%}
|
||||
href="?{{ querystring(page=current_page.previous_page_number()) }}"
|
||||
href="?{{ querystring(request, page=current_page.previous_page_number()) }}"
|
||||
{%- endif -%}
|
||||
>
|
||||
<button>
|
||||
@@ -174,13 +177,13 @@
|
||||
<strong>{{ paginator.ELLIPSIS }}</strong>
|
||||
{% else %}
|
||||
<a
|
||||
{% if use_htmx -%}
|
||||
hx-get="?{{ querystring(page=i) }}"
|
||||
{% if htmx_target -%}
|
||||
hx-get="?{{ querystring(request, page=i) }}"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#content"
|
||||
hx-target="{{ htmx_target }}"
|
||||
hx-push-url="true"
|
||||
{%- else -%}
|
||||
href="?{{ querystring(page=i) }}"
|
||||
href="?{{ querystring(request, page=i) }}"
|
||||
{%- endif -%}
|
||||
>
|
||||
<button>{{ i }}</button>
|
||||
@@ -189,14 +192,14 @@
|
||||
{% endfor %}
|
||||
{% if current_page.has_next() %}
|
||||
<a
|
||||
{% if use_htmx -%}
|
||||
hx-get="?{{querystring(page=current_page.next_page_number())}}"
|
||||
{% if htmx_target -%}
|
||||
hx-get="?{{querystring(request, page=current_page.next_page_number())}}"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#content"
|
||||
hx-target="{{ htmx_target }}"
|
||||
hx-push-url="true"
|
||||
hx-trigger="click, keyup[key=='ArrowRight'] from:body"
|
||||
{%- else -%}
|
||||
href="?{{querystring(page=current_page.next_page_number())}}"
|
||||
href="?{{querystring(request, page=current_page.next_page_number())}}"
|
||||
{%- endif -%}
|
||||
><button>
|
||||
<i class="fa fa-caret-right"></i>
|
||||
@@ -247,15 +250,8 @@
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
{% macro querystring() %}
|
||||
{%- for key, values in request.GET.lists() -%}
|
||||
{%- if key not in kwargs -%}
|
||||
{%- for value in values -%}
|
||||
{{ key }}={{ value }}&
|
||||
{%- endfor -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- for key, value in kwargs.items() -%}
|
||||
{{ key }}={{ value }}&
|
||||
{%- endfor -%}
|
||||
{% macro querystring(request) %}
|
||||
{%- set qs = request.GET.copy() -%}
|
||||
{%- do qs.update(kwargs) -%}
|
||||
{{- qs | urlencode -}}
|
||||
{% endmacro %}
|
||||
@@ -23,10 +23,10 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in profile.memberships.filter(end_date=None).all() %}
|
||||
{% for m in profile.memberships.ongoing().select_related("role") %}
|
||||
<tr>
|
||||
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
|
||||
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
|
||||
<td>{{ m.role.name }}</td>
|
||||
<td>{{ m.description }}</td>
|
||||
<td>{{ m.start_date }}</td>
|
||||
{% if m.can_be_edited_by(user) %}
|
||||
@@ -65,10 +65,10 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in profile.memberships.exclude(end_date=None).all() %}
|
||||
{% for m in profile.memberships.exclude(end_date=None).select_related("role") %}
|
||||
<tr>
|
||||
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
|
||||
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
|
||||
<td>{{ m.role.name }}</td>
|
||||
<td>{{ m.description }}</td>
|
||||
<td>{{ m.start_date }}</td>
|
||||
<td>{{ m.end_date }}</td>
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{%- block additional_js -%}
|
||||
<script type="module" src="{{ static("bundled/core/components/ajax-select-index.ts") }}"></script>
|
||||
{%- endblock -%}
|
||||
|
||||
{%- block additional_css -%}
|
||||
<link rel="stylesheet" href="{{ static('user/user_preferences.scss') }}">
|
||||
{# importing ajax-select-index is necessary for it to be applied after HTMX reload #}
|
||||
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
|
||||
<link rel="stylesheet" href="{{ static("core/components/ajax-select.scss") }}">
|
||||
{%- endblock -%}
|
||||
|
||||
{% block title %}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import contextlib
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_populate_more(settings):
|
||||
"""Just check that populate more doesn't crash"""
|
||||
settings.DEBUG = True
|
||||
with open(os.devnull, "w") as devnull, contextlib.redirect_stdout(devnull):
|
||||
call_command("populate_more", "--nb-users", "50")
|
||||
@@ -11,7 +11,7 @@ from django.utils.timezone import now
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertHTMLEqual, assertRedirects
|
||||
|
||||
from club.models import Club
|
||||
from club.models import Club, Membership
|
||||
from core.baker_recipes import board_user, subscriber_user
|
||||
from core.markdown import markdown
|
||||
from core.models import AnonymousUser, Page, PageRev, User
|
||||
@@ -122,6 +122,9 @@ def test_page_revision_club_redirection(client: Client):
|
||||
@pytest.mark.django_db
|
||||
def test_viewable_by():
|
||||
# remove existing pages to prevent side effect
|
||||
# club pages are protected, so we must delete clubs first
|
||||
Membership.objects.all().delete()
|
||||
Club.objects.all().delete()
|
||||
Page.objects.all().delete()
|
||||
view_groups = [
|
||||
[settings.SITH_GROUP_PUBLIC_ID],
|
||||
|
||||
@@ -12,32 +12,22 @@
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
from datetime import date, timedelta
|
||||
|
||||
# Image utils
|
||||
from io import BytesIO
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlencode
|
||||
from typing import Final
|
||||
|
||||
import PIL
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.http import HttpRequest
|
||||
from django.utils.timezone import localdate
|
||||
from PIL import ExifTags
|
||||
from PIL.Image import Image, Resampling
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from _hashlib import HASH
|
||||
from collections.abc import Buffer, Mapping, Sequence
|
||||
from typing import Any, Callable, Final
|
||||
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.http import HttpRequest
|
||||
|
||||
|
||||
RED_PIXEL_PNG: Final[bytes] = (
|
||||
b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52"
|
||||
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53"
|
||||
@@ -215,30 +205,3 @@ def get_client_ip(request: HttpRequest) -> str | None:
|
||||
return ip
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def hmac_hexdigest(
|
||||
key: str | bytes,
|
||||
data: Mapping[str, Any] | Sequence[tuple[str, Any]],
|
||||
digest: str | Callable[[Buffer], HASH] = "sha512",
|
||||
) -> str:
|
||||
"""Return the hexdigest of the signature of the given data.
|
||||
|
||||
Args:
|
||||
key: the HMAC key used for the signature
|
||||
data: the data to sign
|
||||
digest: a PEP247 hashing algorithm (by default, sha512)
|
||||
|
||||
Examples:
|
||||
```python
|
||||
data = {
|
||||
"foo": 5,
|
||||
"bar": "somevalue",
|
||||
}
|
||||
hmac_key = secrets.token_hex(64)
|
||||
signature = hmac_hexdigest(hmac_key, data, "sha256")
|
||||
```
|
||||
"""
|
||||
if isinstance(key, str):
|
||||
key = key.encode()
|
||||
return hmac.digest(key, urlencode(data).encode(), digest).hex()
|
||||
|
||||
@@ -583,7 +583,7 @@ class Counter(models.Model):
|
||||
if user.is_anonymous:
|
||||
return False
|
||||
mem = self.club.get_membership_for(user)
|
||||
if mem and mem.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
|
||||
if mem and mem.role.is_presidency:
|
||||
return True
|
||||
return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</table>
|
||||
<br>
|
||||
{% if is_paginated %}
|
||||
{{ paginate_jinja(page_obj, paginator) }}
|
||||
{{ paginate_jinja(request, page_obj, paginator) }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% trans %}There is no cash register summary in this website.{% endtrans %}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
{%- endfor %}
|
||||
</table>
|
||||
{% if is_paginated %}
|
||||
{{ paginate_jinja(page_obj, paginator) }}
|
||||
{{ paginate_jinja(request, page_obj, paginator) }}
|
||||
{% endif %}
|
||||
{%- endblock %}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from club.models import Membership
|
||||
from club.models import ClubRole, Membership
|
||||
from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user
|
||||
from core.models import BanGroup, User
|
||||
from counter.baker_recipes import product_recipe, sale_recipe
|
||||
@@ -88,7 +88,7 @@ class TestFullClickBase(TestCase):
|
||||
Membership,
|
||||
start_date=now() - timedelta(days=30),
|
||||
club=cls.club_counter.club,
|
||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||
role=baker.make(ClubRole, club=cls.club_counter.club, is_board=True),
|
||||
user=cls.club_admin,
|
||||
)
|
||||
|
||||
@@ -782,7 +782,13 @@ class TestClubCounterClickAccess(TestCase):
|
||||
"counter:click",
|
||||
kwargs={"counter_id": cls.counter.id, "user_id": cls.customer.id},
|
||||
)
|
||||
|
||||
cls.board_role, cls.member_role = baker.make(
|
||||
ClubRole,
|
||||
club=cls.counter.club,
|
||||
is_board=iter([True, False]),
|
||||
_quantity=2,
|
||||
_bulk_create=True,
|
||||
)
|
||||
cls.user = subscriber_user.make()
|
||||
|
||||
def setUp(self):
|
||||
@@ -797,13 +803,17 @@ class TestClubCounterClickAccess(TestCase):
|
||||
res = self.client.get(self.click_url)
|
||||
assert res.status_code == 403
|
||||
# being a member of the club, without being in the board, isn't enough
|
||||
baker.make(Membership, club=self.counter.club, user=self.user, role=1)
|
||||
baker.make(
|
||||
Membership, club=self.counter.club, user=self.user, role=self.member_role
|
||||
)
|
||||
res = self.client.get(self.click_url)
|
||||
assert res.status_code == 403
|
||||
|
||||
def test_board_member(self):
|
||||
"""By default, board members should be able to click on office counters"""
|
||||
baker.make(Membership, club=self.counter.club, user=self.user, role=3)
|
||||
baker.make(
|
||||
Membership, club=self.counter.club, user=self.user, role=self.board_role
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.get(self.click_url)
|
||||
assert res.status_code == 200
|
||||
@@ -818,7 +828,9 @@ class TestClubCounterClickAccess(TestCase):
|
||||
def test_both_barman_and_board_member(self):
|
||||
"""If the user is barman and board member, he should be authorized as well."""
|
||||
self.counter.sellers.add(self.user)
|
||||
baker.make(Membership, club=self.counter.club, user=self.user, role=3)
|
||||
baker.make(
|
||||
Membership, club=self.counter.club, user=self.user, role=self.board_role
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.get(self.click_url)
|
||||
assert res.status_code == 200
|
||||
|
||||
@@ -3,14 +3,13 @@ import string
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.base_user import make_password
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from model_bakery import baker
|
||||
|
||||
from club.models import Membership
|
||||
from club.models import ClubRole, Membership
|
||||
from core.baker_recipes import board_user, subscriber_user
|
||||
from core.models import User
|
||||
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
|
||||
@@ -42,11 +41,12 @@ class TestStudentCard(TestCase):
|
||||
cls.counter.sellers.add(cls.barmen)
|
||||
|
||||
cls.club_counter = baker.make(Counter)
|
||||
role = baker.make(ClubRole, club=cls.club_counter.club, is_board=True)
|
||||
baker.make(
|
||||
Membership,
|
||||
start_date=now() - timedelta(days=30),
|
||||
club=cls.club_counter.club,
|
||||
role=settings.SITH_CLUB_ROLES_ID["Board member"],
|
||||
role=role,
|
||||
user=cls.club_admin,
|
||||
)
|
||||
|
||||
|
||||
73
docs/howto/xapian.md
Normal file
73
docs/howto/xapian.md
Normal file
@@ -0,0 +1,73 @@
|
||||
## Pourquoi Xapian
|
||||
|
||||
Xapian permet de faire de la recherche fulltext.
|
||||
C'est une librairie écrite en C++ avec des bindings Python
|
||||
qu'on utilise avec la dépendance `django-haystack` via `xapian-haystack`.
|
||||
|
||||
Elle a les avantages suivants:
|
||||
|
||||
* C'est très rapide et ça correspond très bien à notre échelle
|
||||
* C'est performant
|
||||
* Pas besoin de service supplémentaire, c'est une librairie qui utilise des fichiers, comme sqlite
|
||||
|
||||
Mais elle a un défaut majeur: on ne peut pas « juste » la `pip install`,
|
||||
il faut installer une librairie système et des bindings et ça a toujours été
|
||||
l'étape la plus frustrante et buggée de notre process d'installation. C'est
|
||||
aussi la seule raison qui fait que le projet n'es pas compatible windows.
|
||||
|
||||
## Mettre à jour Xapian
|
||||
|
||||
Pour installer xapian le plus simplement possible, on le compile depuis les
|
||||
sources via la commande `./manage.py install_xapian` comme indiqué dans la
|
||||
documentation d'installation.
|
||||
|
||||
La version de xapian est contrôlée par le `pyproject.toml` dans la section
|
||||
`[tool.xapian]`.
|
||||
|
||||
Cette section ressemble à ceci:
|
||||
|
||||
```toml
|
||||
[tool.xapian]
|
||||
version = "x.y.z"
|
||||
core-sha256 = "abcdefghijklmnopqrstuvwyz0123456789"
|
||||
bindings-sha256 = "abcdefghijklmnopqrstuvwyz0123456789"
|
||||
```
|
||||
|
||||
Comme on peut le voir, il y a 3 variables différentes, une variable de version,
|
||||
qui sert à choisir la version à télécharger, et deux variables sha256.
|
||||
|
||||
Ces variables sha256 permettent de protéger des attaques par supply chain, un
|
||||
peu comme uv et npm font avec leurs respectifs `uv.lock` et `package-lock.json`
|
||||
. Elles permettent de vérifier que les fichiers téléchargés n'ont pas été
|
||||
altérés entre la configuration du fichier et l'installation par l'utilisateur
|
||||
et/ou le déploiement.
|
||||
|
||||
L'installation de xapian passe par deux fichiers, `xapian-core` et
|
||||
`xapian-bindings` disponibles sur [https://xapian.org/download](https://xapian.org/download).
|
||||
|
||||
Lorsque le script d'installation télécharge les fichiers, il vérifie leur
|
||||
signature sha256 contre celles contenues dans ces deux variables. Si la
|
||||
signature n'est pas la même, une erreur est levée, protégant l'utilisateur
|
||||
d'une potentielle attaque.
|
||||
|
||||
Pour mettre à jour, il faut donc changer la version ET modifier la signature !
|
||||
|
||||
Pour récupérer ces signatures, il suffit de télécharger soi-même les archives
|
||||
du logiciel sur ce site, utiliser la commande `sha256sum` dessus et, enfin,
|
||||
reporter la valeur sortie par cette commande.
|
||||
|
||||
Pour ce qui est de la correspondance, `core-sha256` correspond à la signature
|
||||
de `xapian-core` et `bindings-sha256` de `xapian-bindings`.
|
||||
|
||||
Voici un bout de script qui peut faciliter une mise à jour:
|
||||
|
||||
```bash
|
||||
VERSION="x.y.z" # À modifier avec la bonne version
|
||||
curl -O "https://oligarchy.co.uk/xapian/${VERSION}/xapian-core-${VERSION}.tar.xz"
|
||||
sha256sum xapian-core-${VERSION}.tar.xz # Affiche la signature pour `core-sha256`
|
||||
rm -f xapian-core-${VERSION}
|
||||
|
||||
curl -O "https://oligarchy.co.uk/xapian/${VERSION}/xapian-bindings-${VERSION}.tar.xz"
|
||||
sha256sum xapian-bindings-${VERSION}.tar.xz # Affiche la signature pour `bindingse-sha256`
|
||||
rm -f xapian-bindings-${VERSION}.tar.xz
|
||||
```
|
||||
@@ -1 +0,0 @@
|
||||
::: api.schemas
|
||||
@@ -1 +0,0 @@
|
||||
::: api.views
|
||||
@@ -1,368 +0,0 @@
|
||||
Le site AE offre des mécanismes permettant aux applications tierces
|
||||
de récupérer les informations sur un utilisateur du site AE.
|
||||
De cette manière, il devient possible de synchroniser les informations
|
||||
qu possède l'application tierce sur l'utilisateur, directement depuis
|
||||
le site AE.
|
||||
|
||||
## Fonctionnement général
|
||||
|
||||
Pour authentifier vos utilisateurs, vous aurez besoin d'un serveur web
|
||||
et d'un client d'API (celui auquel est liée votre
|
||||
[clef d'API](./connect.md#obtenir-une-clef-dapi)).
|
||||
Deux informations vous sont nécessaires, en plus de votre clef d'API :
|
||||
|
||||
- l'id du client : vous pouvez l'obtenir soit en le demandant à l'équipe info,
|
||||
soit en appelant la route `GET /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.
|
||||
|
||||
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 !
|
||||
|
||||
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.
|
||||
@@ -112,7 +112,7 @@ cf. [HTTP persistant connection (wikipedia)](https://en.wikipedia.org/wiki/HTTP_
|
||||
|
||||
Voici quelques exemples :
|
||||
|
||||
=== ":simple-python: Python (requests)"
|
||||
=== "Python (requests)"
|
||||
|
||||
Dépendances :
|
||||
|
||||
@@ -132,7 +132,7 @@ Voici quelques exemples :
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
=== ":simple-python: Python (aiohttp)"
|
||||
=== "Python (aiohttp)"
|
||||
|
||||
Dépendances :
|
||||
|
||||
@@ -158,7 +158,7 @@ Voici quelques exemples :
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
=== ":simple-javascript: Javascript (axios)"
|
||||
=== "Javascript (axios)"
|
||||
|
||||
Dépendances :
|
||||
|
||||
@@ -178,7 +178,7 @@ Voici quelques exemples :
|
||||
console.log(await instance.get("club/1").json());
|
||||
```
|
||||
|
||||
=== ":simple-rust: Rust (reqwest)"
|
||||
=== "Rust (reqwest)"
|
||||
|
||||
Dépendances :
|
||||
|
||||
|
||||
@@ -56,6 +56,12 @@ Commencez par installer les dépendances système :
|
||||
sudo pacman -S postgresql nginx
|
||||
```
|
||||
|
||||
=== "Fedora/RHEL/AlmaLinux/Rocky"
|
||||
|
||||
```bash
|
||||
sudo dnf install postgresql libpq-devel nginx
|
||||
```
|
||||
|
||||
=== "macOS"
|
||||
|
||||
```bash
|
||||
@@ -100,9 +106,11 @@ PROCFILE_SERVICE=
|
||||
vous devez ouvrir une autre fenêtre de votre terminal
|
||||
et lancer la commande `npm run serve`
|
||||
|
||||
## Configurer Redis en service externe
|
||||
## Configurer Redis/Valkey en service externe
|
||||
|
||||
Redis est installé comme dépendance mais pas lancé par défaut.
|
||||
Redis est installé comme dépendance mais n'es pas lancé par défaut.
|
||||
|
||||
Si vous avez installé Valkey parce que Redis n'es pas disponible, remplacez juste `redis` par `valkey`.
|
||||
|
||||
En mode développement, le sith se charge de le démarrer mais
|
||||
pas en production !
|
||||
|
||||
@@ -79,6 +79,29 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
|
||||
sudo pacman -S uv gcc git gettext pkgconf npm valkey
|
||||
```
|
||||
|
||||
=== "Fedora"
|
||||
```bash
|
||||
sudo dnf update
|
||||
sudo dnf install epel-release
|
||||
sudo dnf install python-devel uv git gettext pkgconf npm redis @c-development @development-tools
|
||||
```
|
||||
|
||||
=== "RHEL/AlmaLinux/Rocky"
|
||||
```bash
|
||||
dnf update
|
||||
dnf install epel-release
|
||||
dnf install python-devel uv git gettext pkgconf npm valkey
|
||||
dnf group install "Development Tools"
|
||||
```
|
||||
|
||||
La couche de compatibilitée valkey/redis est un package Fedora.
|
||||
Il est nécessaire de faire un alias nous même:
|
||||
|
||||
```bash
|
||||
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>`_.
|
||||
@@ -98,7 +121,7 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
|
||||
!!!note
|
||||
|
||||
Python ne fait pas parti des dépendances puisqu'il est automatiquement
|
||||
installé par uv.
|
||||
installé par uv. Il est cependant parfois nécessaire d'installer les headers Python nécessaire à la compilation de certains paquets.
|
||||
|
||||
## Finaliser l'installation
|
||||
|
||||
|
||||
37
eboutic/converters.py
Normal file
37
eboutic/converters.py
Normal file
@@ -0,0 +1,37 @@
|
||||
#
|
||||
# Copyright 2022
|
||||
# - Maréchal <thgirod@hotmail.com
|
||||
#
|
||||
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
|
||||
# http://ae.utbm.fr.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify it under
|
||||
# the terms of the GNU General Public License a published by the Free Software
|
||||
# Foundation; either version 3 of the License, or (at your option) any later
|
||||
# version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||||
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License along with
|
||||
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
|
||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||
|
||||
|
||||
class PaymentResultConverter:
|
||||
"""Converter used for url mapping of the `eboutic.views.payment_result` view.
|
||||
|
||||
It's meant to build an url that can match
|
||||
either `/eboutic/pay/success/` or `/eboutic/pay/failure/`
|
||||
but nothing else.
|
||||
"""
|
||||
|
||||
regex = "(success|failure)"
|
||||
|
||||
def to_python(self, value):
|
||||
return str(value)
|
||||
|
||||
def to_url(self, value):
|
||||
return str(value)
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
from django.urls import path, register_converter
|
||||
|
||||
from core.converters import ResultConverter
|
||||
from eboutic.converters import PaymentResultConverter
|
||||
from eboutic.views import (
|
||||
BillingInfoFormFragment,
|
||||
EbouticCheckout,
|
||||
@@ -35,7 +35,7 @@ from eboutic.views import (
|
||||
payment_result,
|
||||
)
|
||||
|
||||
register_converter(ResultConverter, "res")
|
||||
register_converter(PaymentResultConverter, "res")
|
||||
|
||||
urlpatterns = [
|
||||
# Subscription views
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
</section>
|
||||
{%- endfor %}
|
||||
{% if is_paginated %}
|
||||
{{ paginate_jinja(page_obj, paginator) }}
|
||||
{{ paginate_jinja(request, page_obj, paginator) }}
|
||||
{% endif %}
|
||||
{%- endblock %}
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ class Forum(models.Model):
|
||||
Forum._club_memberships[self.id] = {}
|
||||
Forum._club_memberships[self.id][user.id] = m
|
||||
if m:
|
||||
return m.role > settings.SITH_MAXIMUM_FREE_ROLE
|
||||
return m.role.is_board
|
||||
return False
|
||||
|
||||
def check_loop(self):
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
</p>
|
||||
|
||||
{{ display_search_bar(request) }}
|
||||
{{ paginate_jinja(msgs, msgs.paginator) }}
|
||||
{{ paginate_jinja(request, msgs, msgs.paginator) }}
|
||||
|
||||
<main class="message-list">
|
||||
{% for m in msgs %}
|
||||
@@ -44,7 +44,9 @@
|
||||
|
||||
<p><a class="ib button" href="{{ url('forum:new_message', topic_id=topic.id) }}">{% trans %}Reply{% endtrans %}</a></p>
|
||||
|
||||
{{ paginate_jinja(msgs, msgs.paginator) }}
|
||||
{% if is_paginated %}
|
||||
{{ paginate_jinja(request, msgs, msgs.paginator) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -29,8 +29,9 @@ from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from model_bakery import baker
|
||||
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.models import Group, Page, SithFile, User
|
||||
from core.utils import RED_PIXEL_PNG
|
||||
from sas.models import Album, PeoplePictureRelation, Picture
|
||||
@@ -217,11 +218,19 @@ class Command(BaseCommand):
|
||||
"The `make_clubs()` method must be called before `make_club_memberships()`"
|
||||
)
|
||||
memberships = []
|
||||
roles = {
|
||||
r.club_id: r.id
|
||||
for r in baker.make(
|
||||
ClubRole,
|
||||
club=iter(self.clubs),
|
||||
_quantity=len(self.clubs),
|
||||
_bulk_create=True,
|
||||
)
|
||||
}
|
||||
for i in range(1, 11): # users can be in up to 20 clubs
|
||||
self.logger.info(f"Club membership, pass {i}")
|
||||
for uid in range(
|
||||
i, self.NB_USERS, i
|
||||
): # Pass #1 will make sure every user is at least in one club
|
||||
for uid in range(i, self.NB_USERS, i):
|
||||
# Pass #1 will make sure every user is at least in one club
|
||||
user = self.users[uid]
|
||||
club = self.clubs[(uid + i**2) % self.NB_CLUBS]
|
||||
|
||||
@@ -236,7 +245,7 @@ class Command(BaseCommand):
|
||||
Membership(
|
||||
user=user,
|
||||
club=club,
|
||||
role=(uid + i) % 10 + 1, # spread the different roles
|
||||
role_id=roles[club.id],
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
)
|
||||
@@ -259,7 +268,7 @@ class Command(BaseCommand):
|
||||
Membership(
|
||||
user=user,
|
||||
club=club,
|
||||
role=((uid // 10) + i) % 10 + 1, # spread the different roles
|
||||
role_id=roles[club.id],
|
||||
start_date=start,
|
||||
end_date=end,
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-03-23 22:21+0100\n"
|
||||
"POT-Creation-Date: 2026-04-18 11:38+0200\n"
|
||||
"PO-Revision-Date: 2016-07-18\n"
|
||||
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||
@@ -35,10 +35,6 @@ msgstr ""
|
||||
"True si gardé à jour par le biais d'un fournisseur externe de domains "
|
||||
"toxics, False sinon"
|
||||
|
||||
#: api/admin.py
|
||||
msgid "Reset HMAC key"
|
||||
msgstr "Réinitialiser la clef HMAC"
|
||||
|
||||
#: api/admin.py
|
||||
#, python-format
|
||||
msgid ""
|
||||
@@ -52,23 +48,6 @@ msgstr ""
|
||||
msgid "Revoke selected API keys"
|
||||
msgstr "Révoquer les clefs d'API sélectionnées"
|
||||
|
||||
#: api/forms.py
|
||||
msgid "I have read and I accept the terms and conditions of use"
|
||||
msgstr "J'ai lu et j'accepte les conditions générales d'utilisation."
|
||||
|
||||
#: api/forms.py
|
||||
msgid "You must approve the terms and conditions of use."
|
||||
msgstr "Vous devez approuver les conditions générales d'utilisation."
|
||||
|
||||
#: api/forms.py
|
||||
msgid "You must confirm that this is your username."
|
||||
msgstr "Vous devez confirmer que c'est bien votre nom d'utilisateur."
|
||||
|
||||
#: api/forms.py
|
||||
#, python-format
|
||||
msgid "I confirm that %(username)s is my username on %(app)s"
|
||||
msgstr "Je confirme que %(username)s est mon nom d'utilisateur sur %(app)s"
|
||||
|
||||
#: api/models.py club/models.py com/models.py counter/models.py forum/models.py
|
||||
msgid "name"
|
||||
msgstr "nom"
|
||||
@@ -89,10 +68,6 @@ msgstr "permissions du client"
|
||||
msgid "Specific permissions for this api client."
|
||||
msgstr "Permissions spécifiques pour ce client d'API"
|
||||
|
||||
#: api/models.py
|
||||
msgid "HMAC Key"
|
||||
msgstr "Clef HMAC"
|
||||
|
||||
#: api/models.py
|
||||
msgid "api client"
|
||||
msgstr "client d'api"
|
||||
@@ -122,63 +97,6 @@ msgstr "clef d'api"
|
||||
msgid "api keys"
|
||||
msgstr "clefs d'api"
|
||||
|
||||
#: api/templates/api/third_party/auth.jinja
|
||||
msgid "Confidentiality"
|
||||
msgstr "Confidentialité"
|
||||
|
||||
#: api/templates/api/third_party/auth.jinja
|
||||
#, python-format
|
||||
msgid ""
|
||||
"By ticking this box and clicking on the send button, you acknowledge and "
|
||||
"agree to provide %(app)s with your first name, last name, nickname and any "
|
||||
"other information that was the third party app was explicitly authorized to "
|
||||
"fetch and that it must have acknowledged to you, in a complete and accurate "
|
||||
"manner."
|
||||
msgstr ""
|
||||
"En cochant cette case et en cliquant sur le bouton « Envoyer », vous "
|
||||
"reconnaissez et acceptez de fournir à %(app)s votre prénom, nom, pseudonyme "
|
||||
"et toute autre information que l'application tierce a été explicitement "
|
||||
"autorisée à récupérer et qu'elle doit vous avoir communiqué de manière "
|
||||
"complète et exacte."
|
||||
|
||||
#: api/templates/api/third_party/auth.jinja
|
||||
#, python-format
|
||||
msgid ""
|
||||
"The privacy policies of <a href=\"%(privacy_link)s\">%(app)s</a> and of <a "
|
||||
"href=\"%(sith_cgu_link)s\">the Students' Association</a> applies as soon as "
|
||||
"the form is submitted."
|
||||
msgstr ""
|
||||
"Les politiques de confidentialité de <a href=\"%(privacy_link)s\">%(app)s</a> et de <a "
|
||||
"href=\"%(sith_cgu_link)s\">l'Association des Etudiants</a> s'appliquent dès la soumission "
|
||||
"du formulaire."
|
||||
|
||||
#: api/templates/api/third_party/auth.jinja
|
||||
msgid "Confirmation of identity"
|
||||
msgstr "Confirmation d'identité"
|
||||
|
||||
#: api/views.py
|
||||
#, python-format
|
||||
msgid ""
|
||||
"You are going to link your AE account and your %(app)s account. Continue "
|
||||
"only if this page was opened from %(app)s."
|
||||
msgstr ""
|
||||
"Vous allez lier votre compte AE et votre compte %(app)s. Poursuivez "
|
||||
"uniquement si cette page a été ouverte depuis %(app)s."
|
||||
|
||||
#: api/views.py
|
||||
msgid "You have been successfully authenticated. You can now close this page."
|
||||
msgstr "Vous avez été authentifié avec succès. Vous pouvez maintenant fermer cette page."
|
||||
|
||||
#: api/views.py
|
||||
msgid ""
|
||||
"Your authentication on the AE website was successful, but an error happened "
|
||||
"during the interaction with the third-party application. Please contact the "
|
||||
"managers of the latter."
|
||||
msgstr ""
|
||||
"Votre authentification sur le site AE a fonctionné, mais une erreur est arrivée "
|
||||
"durant l'interaction avec l'application tierce. Veuillez contacter les responsables "
|
||||
"de cette dernière."
|
||||
|
||||
#: club/forms.py
|
||||
msgid "Users to add"
|
||||
msgstr "Utilisateurs à ajouter"
|
||||
@@ -263,6 +181,22 @@ msgstr "Vous devez être cotisant pour faire partie d'un club"
|
||||
msgid "You are already a member of this club"
|
||||
msgstr "Vous êtes déjà membre de ce club."
|
||||
|
||||
#: club/forms.py
|
||||
msgid "Club status"
|
||||
msgstr "État du club"
|
||||
|
||||
#: club/forms.py
|
||||
msgid "Active"
|
||||
msgstr "Actif"
|
||||
|
||||
#: club/forms.py
|
||||
msgid "Inactive"
|
||||
msgstr "Inactif"
|
||||
|
||||
#: club/forms.py
|
||||
msgid "All clubs"
|
||||
msgstr "Tous les clubs"
|
||||
|
||||
#: club/models.py
|
||||
msgid "slug name"
|
||||
msgstr "nom slug"
|
||||
@@ -299,15 +233,65 @@ msgstr "home"
|
||||
msgid "You can not make loops in clubs"
|
||||
msgstr "Vous ne pouvez pas faire de boucles dans les clubs"
|
||||
|
||||
#: club/models.py com/models.py counter/models.py trombi/models.py
|
||||
msgid "club"
|
||||
msgstr "club"
|
||||
|
||||
#: club/models.py
|
||||
msgid "The club with which this role is associated"
|
||||
msgstr "Le club auquel ce rôle est attaché."
|
||||
|
||||
#: club/models.py core/models.py counter/models.py election/models.py
|
||||
#: forum/models.py
|
||||
msgid "description"
|
||||
msgstr "description"
|
||||
|
||||
#: club/models.py
|
||||
msgid "Board role"
|
||||
msgstr "Rôle du bureau"
|
||||
|
||||
#: club/models.py
|
||||
msgid "Presidency role"
|
||||
msgstr "Rôle de la présidence"
|
||||
|
||||
#: club/models.py
|
||||
msgid ""
|
||||
"If the role is inactive, people joining the club won't be able to get it."
|
||||
msgstr ""
|
||||
"Si ce rôle est inactif, il ne pourra pas être attribué aux gens qui "
|
||||
"rejoignent le club."
|
||||
|
||||
#: club/models.py
|
||||
msgid "club role"
|
||||
msgstr "rôle de club"
|
||||
|
||||
#: club/models.py
|
||||
msgid "club roles"
|
||||
msgstr "rôles de club"
|
||||
|
||||
#: club/models.py
|
||||
msgid "A role cannot be in the presidency while not being in the board"
|
||||
msgstr ""
|
||||
"Un rôle ne peut pas appartenir à la présidence sans être dans le bureau"
|
||||
|
||||
#: club/models.py
|
||||
#, python-format
|
||||
msgid "Role %(role)s cannot be placed below a member role"
|
||||
msgstr ""
|
||||
"Le rôle %(role)s ne peut pas être placé en-dessous d'un rôle de membre."
|
||||
|
||||
#: club/models.py
|
||||
#, python-format
|
||||
msgid "Role %(role)s cannot be placed below a non-presidency role"
|
||||
msgstr ""
|
||||
"Le rôle %(role)s ne peut pas être placé en-dessous d'un rôle qui n'est pas "
|
||||
"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
|
||||
msgid "user"
|
||||
msgstr "utilisateur"
|
||||
|
||||
#: club/models.py com/models.py counter/models.py trombi/models.py
|
||||
msgid "club"
|
||||
msgstr "club"
|
||||
|
||||
#: club/models.py counter/models.py election/models.py
|
||||
msgid "start date"
|
||||
msgstr "date de début"
|
||||
@@ -320,11 +304,6 @@ msgstr "date de fin"
|
||||
msgid "role"
|
||||
msgstr "rôle"
|
||||
|
||||
#: club/models.py core/models.py counter/models.py election/models.py
|
||||
#: forum/models.py
|
||||
msgid "description"
|
||||
msgstr "description"
|
||||
|
||||
#: club/models.py
|
||||
msgid "past member"
|
||||
msgstr "ancien membre"
|
||||
@@ -383,30 +362,39 @@ msgstr "Cet email est déjà abonné à cette mailing"
|
||||
msgid "Unregistered user"
|
||||
msgstr "Utilisateur non enregistré"
|
||||
|
||||
#: club/templates/club/club_list.jinja
|
||||
msgid "Club list"
|
||||
msgstr "Liste des clubs"
|
||||
|
||||
#: club/templates/club/club_list.jinja
|
||||
msgid "The list of all clubs existing at UTBM."
|
||||
msgstr "La liste de tous les clubs existants à l'UTBM"
|
||||
|
||||
#: club/templates/club/club_list.jinja
|
||||
msgid "inactive"
|
||||
msgstr "inactif"
|
||||
msgid "Club list"
|
||||
msgstr "Liste des clubs"
|
||||
|
||||
#: club/templates/club/club_list.jinja
|
||||
msgid "Filters"
|
||||
msgstr "Filtres"
|
||||
|
||||
#: club/templates/club/club_list.jinja core/templates/core/base/header.jinja
|
||||
#: forum/templates/forum/macros.jinja matmat/templates/matmat/search_form.jinja
|
||||
msgid "Search"
|
||||
msgstr "Recherche"
|
||||
|
||||
#: club/templates/club/club_list.jinja core/templates/core/user_tools.jinja
|
||||
msgid "New club"
|
||||
msgstr "Nouveau club"
|
||||
|
||||
#: club/templates/club/club_list.jinja
|
||||
msgid "There is no club in this website."
|
||||
msgstr "Il n'y a pas de club dans ce site web."
|
||||
#: club/templates/club/club_list.jinja club/templates/club/club_roles.jinja
|
||||
msgid "inactive"
|
||||
msgstr "inactif"
|
||||
|
||||
#: club/templates/club/club_members.jinja
|
||||
msgid "Club members"
|
||||
msgstr "Membres du club"
|
||||
|
||||
#: club/templates/club/club_members.jinja
|
||||
msgid "Manage roles"
|
||||
msgstr "Gérer les rôles"
|
||||
|
||||
#: club/templates/club/club_members.jinja
|
||||
#: club/templates/club/club_old_members.jinja
|
||||
#: core/templates/core/user_clubs.jinja
|
||||
@@ -445,6 +433,120 @@ msgstr "Du"
|
||||
msgid "To"
|
||||
msgstr "Au"
|
||||
|
||||
#: club/templates/club/club_roles.jinja
|
||||
msgid ""
|
||||
"Roles give rights on the club. Higher roles grant more rights, and the "
|
||||
"members having them are displayed higher in the club members list."
|
||||
msgstr ""
|
||||
"Les rôles donnent des droits sur le club. Les rôles plus élevés donnent plus "
|
||||
"de droit, et les membres qui les possèdent sont affichés plus haut dans la "
|
||||
"liste des membres."
|
||||
|
||||
#: club/templates/club/club_roles.jinja
|
||||
msgid ""
|
||||
"On this page, you can edit their name and description, as well as their "
|
||||
"order. You can also drag roles from a category to another (e.g. a board role "
|
||||
"can be made into a presidency role)."
|
||||
msgstr ""
|
||||
"Sur cette page, vous pouvez éditer leur nom et leur description, ainsi que "
|
||||
"leur ordre. Vous pouvez également déplacer des rôles d'une catégorie à "
|
||||
"l'autre (par exemple, un rôle du bureau peut devenir un rôle de présidence)."
|
||||
|
||||
#: club/templates/club/club_roles.jinja
|
||||
msgid "Presidency"
|
||||
msgstr "Présidence"
|
||||
|
||||
#: club/templates/club/club_roles.jinja
|
||||
msgid "add role"
|
||||
msgstr "ajouter un rôle"
|
||||
|
||||
#: club/templates/club/club_roles.jinja core/templates/core/base/navbar.jinja
|
||||
msgid "Help"
|
||||
msgstr "Aide"
|
||||
|
||||
#: club/templates/club/club_roles.jinja
|
||||
msgid "Users with a presidency role can :"
|
||||
msgstr "Les utilisateurs avec un rôle de présidence peuvent :"
|
||||
|
||||
#: club/templates/club/club_roles.jinja
|
||||
msgid "create new club roles and edit existing ones"
|
||||
msgstr "créer de nouveaux rôles et modifier ceux qui existent"
|
||||
|
||||
#: club/templates/club/club_roles.jinja
|
||||
msgid "manage the club counters"
|
||||
msgstr "gérer les comptoirs du club"
|
||||
|
||||
#: club/templates/club/club_roles.jinja
|
||||
msgid "add new members with any active role and end any membership"
|
||||
msgstr ""
|
||||
"ajouter de nouveaux membres avec n'importe quel rôle et mettre fin à "
|
||||
"n'importe quelle adhésion au club."
|
||||
|
||||
#: club/templates/club/club_roles.jinja
|
||||
msgid "They also have all the rights of the club board."
|
||||
msgstr "Ils possèdent également tous les droits du bureau."
|
||||
|
||||
#: club/templates/club/club_roles.jinja
|
||||
msgid "Board"
|
||||
msgstr "Bureau"
|
||||
|
||||
#: club/templates/club/club_roles.jinja
|
||||
msgid ""
|
||||
"Board members can do most administrative actions in the club, including :"
|
||||
msgstr ""
|
||||
"Les membres du bureau peuvent effectuer la plupart des actions "
|
||||
"administratives dans le club, incluant :"
|
||||
|
||||
#: club/templates/club/club_roles.jinja
|
||||
msgid "manage the club posters"
|
||||
msgstr "gérer les affiches du club"
|
||||
|
||||
#: club/templates/club/club_roles.jinja
|
||||
msgid "create news for the club"
|
||||
msgstr "créer des nouvelles pour le club"
|
||||
|
||||
#: club/templates/club/club_roles.jinja
|
||||
msgid "click users on the club's counters"
|
||||
msgstr "cliquer des utilisateurs sur les comptoirs du club"
|
||||
|
||||
#: club/templates/club/club_roles.jinja
|
||||
msgid ""
|
||||
"add new members and end active memberships for roles that are lower than "
|
||||
"their own."
|
||||
msgstr ""
|
||||
"ajouter de nouveaux membres et mettre fin à des adhésions en cours, pour des "
|
||||
"rôles plus bas que le leur."
|
||||
|
||||
#: club/templates/club/club_roles.jinja club/views.py
|
||||
msgid "Members"
|
||||
msgstr "Membres"
|
||||
|
||||
#: club/templates/club/club_roles.jinja
|
||||
msgid "Simple members cannot perform administrative actions."
|
||||
msgstr ""
|
||||
"Les simples membres ne peuvent pas effectuer d'actions administratives."
|
||||
|
||||
#: club/templates/club/club_roles.jinja club/templates/club/edit_club.jinja
|
||||
#: club/templates/club/pagerev_edit.jinja com/templates/com/news_edit.jinja
|
||||
#: com/templates/com/poster_edit.jinja com/templates/com/screen_edit.jinja
|
||||
#: com/templates/com/weekmail.jinja core/templates/core/create.jinja
|
||||
#: core/templates/core/edit.jinja core/templates/core/file_edit.jinja
|
||||
#: core/templates/core/fragment/user_visibility.jinja
|
||||
#: core/templates/core/page/edit.jinja core/templates/core/page/prop.jinja
|
||||
#: core/templates/core/user_godfathers.jinja
|
||||
#: core/templates/core/user_godfathers_tree.jinja
|
||||
#: core/templates/core/user_preferences.jinja
|
||||
#: counter/templates/counter/cash_register_summary.jinja
|
||||
#: counter/templates/counter/invoices_call.jinja
|
||||
#: counter/templates/counter/product_form.jinja
|
||||
#: forum/templates/forum/reply.jinja
|
||||
#: subscription/templates/subscription/fragments/creation_form.jinja
|
||||
#: trombi/templates/trombi/comment.jinja
|
||||
#: trombi/templates/trombi/edit_profile.jinja
|
||||
#: trombi/templates/trombi/user_tools.jinja
|
||||
msgid "Save"
|
||||
msgstr "Sauver"
|
||||
|
||||
#: club/templates/club/club_sellings.jinja
|
||||
msgid "Previous"
|
||||
msgstr "Précédent"
|
||||
@@ -629,27 +731,6 @@ msgstr ""
|
||||
"Les champs de formulaire suivants sont liées à la description basique d'un "
|
||||
"club. Tous les membres du bureau du club peuvent voir et modifier ceux-ci."
|
||||
|
||||
#: club/templates/club/edit_club.jinja club/templates/club/pagerev_edit.jinja
|
||||
#: com/templates/com/news_edit.jinja com/templates/com/poster_edit.jinja
|
||||
#: com/templates/com/screen_edit.jinja com/templates/com/weekmail.jinja
|
||||
#: core/templates/core/create.jinja core/templates/core/edit.jinja
|
||||
#: core/templates/core/file_edit.jinja
|
||||
#: core/templates/core/fragment/user_visibility.jinja
|
||||
#: core/templates/core/page/edit.jinja core/templates/core/page/prop.jinja
|
||||
#: core/templates/core/user_godfathers.jinja
|
||||
#: core/templates/core/user_godfathers_tree.jinja
|
||||
#: core/templates/core/user_preferences.jinja
|
||||
#: counter/templates/counter/cash_register_summary.jinja
|
||||
#: counter/templates/counter/invoices_call.jinja
|
||||
#: counter/templates/counter/product_form.jinja
|
||||
#: forum/templates/forum/reply.jinja
|
||||
#: subscription/templates/subscription/fragments/creation_form.jinja
|
||||
#: trombi/templates/trombi/comment.jinja
|
||||
#: trombi/templates/trombi/edit_profile.jinja
|
||||
#: trombi/templates/trombi/user_tools.jinja
|
||||
msgid "Save"
|
||||
msgstr "Sauver"
|
||||
|
||||
#: club/templates/club/fragments/add_member.jinja
|
||||
msgid "Add a new member"
|
||||
msgstr "Ajouter un nouveau membre"
|
||||
@@ -730,10 +811,6 @@ msgstr "Éditer la page"
|
||||
msgid "Infos"
|
||||
msgstr "Infos"
|
||||
|
||||
#: club/views.py
|
||||
msgid "Members"
|
||||
msgstr "Membres"
|
||||
|
||||
#: club/views.py
|
||||
msgid "Old members"
|
||||
msgstr "Anciens membres"
|
||||
@@ -783,6 +860,27 @@ msgstr "Vous êtes maintenant membre de ce club."
|
||||
msgid "%(user)s has been added to club."
|
||||
msgstr "%(user)s a été ajouté au club."
|
||||
|
||||
#: club/views.py
|
||||
msgid "Club roles updated"
|
||||
msgstr "Rôles de club mis à jour"
|
||||
|
||||
#: club/views.py
|
||||
#, python-format
|
||||
msgid "Role %(name)s created"
|
||||
msgstr "Rôle %(name)s créé"
|
||||
|
||||
#: club/views.py
|
||||
msgid "club role – presidency"
|
||||
msgstr "rôle de club – présidence"
|
||||
|
||||
#: club/views.py
|
||||
msgid "club role – board"
|
||||
msgstr "rôle de club – bureau"
|
||||
|
||||
#: club/views.py
|
||||
msgid "club role – member"
|
||||
msgstr "rôle de club – membre"
|
||||
|
||||
#: club/views.py
|
||||
msgid "Benefit"
|
||||
msgstr "Bénéfice"
|
||||
@@ -1925,11 +2023,6 @@ msgstr "Connexion"
|
||||
msgid "Register"
|
||||
msgstr "Inscription"
|
||||
|
||||
#: core/templates/core/base/header.jinja forum/templates/forum/macros.jinja
|
||||
#: matmat/templates/matmat/search_form.jinja
|
||||
msgid "Search"
|
||||
msgstr "Recherche"
|
||||
|
||||
#: core/templates/core/base/header.jinja
|
||||
msgid "Logout"
|
||||
msgstr "Déconnexion"
|
||||
@@ -1963,10 +2056,6 @@ msgstr "L'AE"
|
||||
msgid "AE's clubs"
|
||||
msgstr "Les clubs de L'AE"
|
||||
|
||||
#: core/templates/core/base/navbar.jinja
|
||||
msgid "Others UTBM's Associations"
|
||||
msgstr "Les autres associations de l'UTBM"
|
||||
|
||||
#: core/templates/core/base/navbar.jinja
|
||||
msgid "Big event"
|
||||
msgstr "Grandes Activités"
|
||||
@@ -2015,10 +2104,6 @@ msgstr "Partenaires"
|
||||
msgid "Subscriber benefits"
|
||||
msgstr "Les avantages cotisants"
|
||||
|
||||
#: core/templates/core/base/navbar.jinja
|
||||
msgid "Help"
|
||||
msgstr "Aide"
|
||||
|
||||
#: core/templates/core/base/navbar.jinja
|
||||
msgid "FAQ"
|
||||
msgstr "FAQ"
|
||||
@@ -4278,6 +4363,47 @@ msgstr ""
|
||||
msgid "this page"
|
||||
msgstr "cette page"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
msgid "Eurockéennes 2025 partnership"
|
||||
msgstr "Partenariat Eurockéennes 2025"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
msgid ""
|
||||
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
|
||||
"according to its own privacy policy. By clicking the accept button you "
|
||||
"consent to their terms of services."
|
||||
msgstr ""
|
||||
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
|
||||
"collecter des informations utilisateur conformément à sa propre politique de "
|
||||
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
|
||||
"leurs termes de service."
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
msgid "Privacy policy"
|
||||
msgstr "Politique de confidentialité"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
#: trombi/templates/trombi/comment_moderation.jinja
|
||||
msgid "Accept"
|
||||
msgstr "Accepter"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
msgid ""
|
||||
"You must be subscribed to benefit from the partnership with the Eurockéennes."
|
||||
msgstr ""
|
||||
"Vous devez être cotisant pour bénéficier du partenariat avec les "
|
||||
"Eurockéennes."
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
#, python-format
|
||||
msgid ""
|
||||
"This partnership offers a discount of up to 33%% on tickets for Friday, "
|
||||
"Saturday and Sunday, as well as the 3-day package from Friday to Sunday."
|
||||
msgstr ""
|
||||
"Ce partenariat permet de profiter d'une réduction jusqu'à 33%% sur les "
|
||||
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
|
||||
"du vendredi au dimanche."
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
msgid "There are no items available for sale"
|
||||
msgstr "Aucun article n'est disponible à la vente"
|
||||
@@ -5432,42 +5558,6 @@ msgstr "Cursus alternant (-20%)"
|
||||
msgid "One year for free(CA offer)"
|
||||
msgstr "Une année offerte (Offre CA)"
|
||||
|
||||
#: sith/settings.py
|
||||
msgid "President"
|
||||
msgstr "Président⸱e"
|
||||
|
||||
#: sith/settings.py
|
||||
msgid "Vice-President"
|
||||
msgstr "Vice-Président⸱e"
|
||||
|
||||
#: sith/settings.py
|
||||
msgid "Treasurer"
|
||||
msgstr "Trésorier⸱e"
|
||||
|
||||
#: sith/settings.py
|
||||
msgid "Communication supervisor"
|
||||
msgstr "Responsable communication"
|
||||
|
||||
#: sith/settings.py
|
||||
msgid "Secretary"
|
||||
msgstr "Secrétaire"
|
||||
|
||||
#: sith/settings.py
|
||||
msgid "IT supervisor"
|
||||
msgstr "Responsable info"
|
||||
|
||||
#: sith/settings.py
|
||||
msgid "Board member"
|
||||
msgstr "Membre du bureau"
|
||||
|
||||
#: sith/settings.py
|
||||
msgid "Active member"
|
||||
msgstr "Membre actif⸱ve"
|
||||
|
||||
#: sith/settings.py
|
||||
msgid "Curious"
|
||||
msgstr "Curieux⸱euse"
|
||||
|
||||
#: sith/settings.py
|
||||
msgid "A new poster needs to be moderated"
|
||||
msgstr "Une nouvelle affiche a besoin d'être modérée"
|
||||
@@ -5704,10 +5794,6 @@ msgstr "fin"
|
||||
msgid "Moderate Trombi comments"
|
||||
msgstr "Modérer les commentaires du Trombi"
|
||||
|
||||
#: trombi/templates/trombi/comment_moderation.jinja
|
||||
msgid "Accept"
|
||||
msgstr "Accepter"
|
||||
|
||||
#: trombi/templates/trombi/comment_moderation.jinja
|
||||
msgid "Reject"
|
||||
msgstr "Refuser"
|
||||
@@ -5949,39 +6035,3 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
|
||||
#, python-format
|
||||
msgid "Maximum characters: %(max_length)s"
|
||||
msgstr "Nombre de caractères max: %(max_length)s"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
msgid "Eurockéennes 2025 partnership"
|
||||
msgstr "Partenariat Eurockéennes 2025"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
msgid ""
|
||||
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
|
||||
"according to its own privacy policy. By clicking the accept button you "
|
||||
"consent to their terms of services."
|
||||
msgstr ""
|
||||
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
|
||||
"collecter des informations utilisateur conformément à sa propre politique de "
|
||||
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
|
||||
"leurs termes de service."
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
msgid "Privacy policy"
|
||||
msgstr "Politique de confidentialité"
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
msgid ""
|
||||
"You must be subscribed to benefit from the partnership with the Eurockéennes."
|
||||
msgstr ""
|
||||
"Vous devez être cotisant pour bénéficier du partenariat avec les "
|
||||
"Eurockéennes."
|
||||
|
||||
#: eboutic/templates/eboutic/eboutic_main.jinja
|
||||
#, python-format
|
||||
msgid ""
|
||||
"This partnership offers a discount of up to 33%% on tickets for Friday, "
|
||||
"Saturday and Sunday, as well as the 3-day package from Friday to Sunday."
|
||||
msgstr ""
|
||||
"Ce partenariat permet de profiter d'une réduction jusqu'à 33%% sur les "
|
||||
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
|
||||
"du vendredi au dimanche."
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-26 15:45+0100\n"
|
||||
"POT-Creation-Date: 2026-04-17 22:42+0200\n"
|
||||
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
|
||||
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||
@@ -17,6 +17,14 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
|
||||
#: club/static/bundled/club/role-list-index.ts
|
||||
msgid ""
|
||||
"You're going to remove your own role from the presidency. You may lock "
|
||||
"yourself out of this page. Do you want to continue ? "
|
||||
msgstr ""
|
||||
"Vous vous apprêtez à retirer votre propre rôle de la présidence. Vous risquez "
|
||||
"de perdre l'accès à cette page. Voulez-vous continuer ?"
|
||||
|
||||
#: com/static/bundled/com/components/ics-calendar-index.ts
|
||||
msgid "More info"
|
||||
msgstr "Plus d'informations"
|
||||
@@ -271,4 +279,5 @@ msgstr "Il n'a pas été possible de supprimer l'image"
|
||||
msgid ""
|
||||
"Wrong timetable format. Make sure you copied if from your student folder."
|
||||
msgstr ""
|
||||
"Mauvais format d'emploi du temps. Assurez-vous que vous l'avez copié depuis votre dossier étudiants."
|
||||
"Mauvais format d'emploi du temps. Assurez-vous que vous l'avez copié depuis "
|
||||
"votre dossier étudiant."
|
||||
|
||||
@@ -69,7 +69,6 @@ nav:
|
||||
- API:
|
||||
- Développement: tutorial/api/dev.md
|
||||
- Connexion à l'API: tutorial/api/connect.md
|
||||
- Liaison avec le compte AE: tutorial/api/account-link.md
|
||||
- Etransactions: tutorial/etransaction.md
|
||||
- How-to:
|
||||
- L'ORM de Django: howto/querysets.md
|
||||
@@ -81,6 +80,7 @@ nav:
|
||||
- Ajouter un logo de promo: howto/logo.md
|
||||
- Ajouter une cotisation: howto/subscriptions.md
|
||||
- Modifier le weekmail: howto/weekmail.md
|
||||
- Mettre à jour xapian: howto/xapian.md
|
||||
- Terminal: howto/terminal.md
|
||||
- Direnv: howto/direnv.md
|
||||
- Reference:
|
||||
@@ -92,8 +92,6 @@ nav:
|
||||
- reference/api/hashers.md
|
||||
- reference/api/models.md
|
||||
- reference/api/perms.md
|
||||
- reference/api/schemas.md
|
||||
- reference/api/views.md
|
||||
- club:
|
||||
- reference/club/models.md
|
||||
- reference/club/views.md
|
||||
|
||||
@@ -92,7 +92,11 @@ docs = [
|
||||
default-groups = ["dev", "tests", "docs"]
|
||||
|
||||
[tool.xapian]
|
||||
version = "1.4.29"
|
||||
version = "1.4.31"
|
||||
# Those hashes are here to protect against supply chains attacks
|
||||
# See `https://ae-utbm.github.io/sith/howto/xapian/` for more information
|
||||
core-sha256 = "fecf609ea2efdc8a64be369715aac733336a11f7480a6545244964ae6bc80811"
|
||||
bindings-sha256 = "a38cc7ba4188cc0bd27dc7369f03906772047087a1c54f1b93355d5e9103c304"
|
||||
|
||||
[tool.ruff]
|
||||
output-format = "concise" # makes ruff error logs easier to read
|
||||
|
||||
@@ -28,5 +28,7 @@
|
||||
</table>
|
||||
|
||||
<br>
|
||||
{{ paginate_jinja(page_obj, paginator) }}
|
||||
{% if is_paginated %}
|
||||
{{ paginate_jinja(request, page_obj, paginator) }}
|
||||
{% endif %}
|
||||
{% endblock content %}
|
||||
@@ -50,13 +50,15 @@ class AlbumEditForm(forms.ModelForm):
|
||||
model = Album
|
||||
fields = ["name", "date", "file", "parent", "edit_groups"]
|
||||
widgets = {
|
||||
"parent": AutoCompleteSelectAlbum,
|
||||
"edit_groups": AutoCompleteSelectMultipleGroup,
|
||||
}
|
||||
|
||||
name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name"))
|
||||
date = forms.DateField(label=_("Date"), widget=SelectDate, required=True)
|
||||
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
|
||||
parent = forms.ModelChoiceField(
|
||||
Album.objects.all(), required=True, widget=AutoCompleteSelectAlbum
|
||||
)
|
||||
|
||||
|
||||
class PictureModerationRequestForm(forms.ModelForm):
|
||||
|
||||
@@ -205,7 +205,13 @@ class AlbumQuerySet(models.QuerySet):
|
||||
|
||||
class SASAlbumManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(is_in_sas=True, is_folder=True)
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(
|
||||
Q(id=settings.SITH_SAS_ROOT_DIR_ID) | Q(is_in_sas=True, is_folder=True)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Album(SasFile):
|
||||
|
||||
@@ -20,12 +20,14 @@ from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertHTMLEqual, assertInHTML, assertRedirects
|
||||
|
||||
from core.baker_recipes import old_subscriber_user, subscriber_user
|
||||
from core.models import Group, User
|
||||
from sas.baker_recipes import picture_recipe
|
||||
from sas.forms import AlbumEditForm
|
||||
from sas.models import Album, Picture
|
||||
|
||||
# Create your tests here.
|
||||
@@ -133,6 +135,171 @@ class TestAlbumUpload:
|
||||
assert not album.children.exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAlbumEdit:
|
||||
@pytest.fixture
|
||||
def sas_root(self) -> Album:
|
||||
return Album.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)
|
||||
|
||||
@pytest.fixture
|
||||
def album(self) -> Album:
|
||||
return baker.make(
|
||||
Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID, is_moderated=True
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user",
|
||||
[
|
||||
None,
|
||||
lambda: baker.make(User),
|
||||
subscriber_user.make,
|
||||
],
|
||||
)
|
||||
def test_permission_denied(
|
||||
self,
|
||||
client: Client,
|
||||
album: Album,
|
||||
user: Callable[[], User] | None,
|
||||
):
|
||||
if user:
|
||||
client.force_login(user())
|
||||
|
||||
response = client.get(reverse("sas:album_edit", kwargs={"album_id": album.pk}))
|
||||
assert response.status_code == 403
|
||||
response = client.post(reverse("sas:album_edit", kwargs={"album_id": album.pk}))
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_sas_root_read_only(self, client: Client, sas_root: Album):
|
||||
moderator = baker.make(
|
||||
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
|
||||
)
|
||||
client.force_login(moderator)
|
||||
response = client.get(
|
||||
reverse("sas:album_edit", kwargs={"album_id": sas_root.pk})
|
||||
)
|
||||
assert response.status_code == 404
|
||||
response = client.post(
|
||||
reverse("sas:album_edit", kwargs={"album_id": sas_root.pk})
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("excluded", "is_valid"),
|
||||
[
|
||||
("name", False),
|
||||
("date", False),
|
||||
("file", True),
|
||||
("parent", False),
|
||||
("edit_groups", True),
|
||||
("recursive", True),
|
||||
],
|
||||
)
|
||||
def test_form_required(self, album: Album, excluded: str, is_valid: bool): # noqa: FBT001
|
||||
data = {
|
||||
"name": album.name[: Album.NAME_MAX_LENGTH],
|
||||
"parent": baker.make(Album, parent=album.parent, is_moderated=True).pk,
|
||||
"date": timezone.now().strftime("%Y-%m-%d"),
|
||||
"file": "/random/path",
|
||||
"edit_groups": [settings.SITH_GROUP_SAS_ADMIN_ID],
|
||||
"recursive": False,
|
||||
}
|
||||
del data[excluded]
|
||||
assert AlbumEditForm(data=data).is_valid() == is_valid
|
||||
|
||||
def test_form_album_name(self, album: Album):
|
||||
data = {
|
||||
"name": album.name[: Album.NAME_MAX_LENGTH],
|
||||
"parent": album.pk,
|
||||
"date": timezone.now().strftime("%Y-%m-%d"),
|
||||
}
|
||||
assert AlbumEditForm(data=data).is_valid()
|
||||
|
||||
data["name"] = album.name[: Album.NAME_MAX_LENGTH + 1]
|
||||
assert not AlbumEditForm(data=data).is_valid()
|
||||
|
||||
def test_update_recursive_parent(self, client: Client, album: Album):
|
||||
client.force_login(baker.make(User, is_superuser=True))
|
||||
|
||||
payload = {
|
||||
"name": album.name[: Album.NAME_MAX_LENGTH],
|
||||
"parent": album.pk,
|
||||
"date": timezone.now().strftime("%Y-%m-%d"),
|
||||
}
|
||||
response = client.post(
|
||||
reverse(
|
||||
"sas:album_edit",
|
||||
kwargs={"album_id": album.pk},
|
||||
),
|
||||
payload,
|
||||
)
|
||||
assertInHTML(
|
||||
"<li>Boucle dans l'arborescence des dossiers</li>",
|
||||
response.text,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user",
|
||||
[
|
||||
lambda: baker.make(User, is_superuser=True),
|
||||
lambda: baker.make(
|
||||
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_update(
|
||||
self,
|
||||
client: Client,
|
||||
album: Album,
|
||||
sas_root: Album,
|
||||
user: Callable[[], User],
|
||||
):
|
||||
client.force_login(user())
|
||||
|
||||
# Prepare a good payload
|
||||
expected_redirect = reverse("sas:album", kwargs={"album_id": album.pk})
|
||||
expected_date = timezone.now()
|
||||
payload = {
|
||||
"name": album.name[: Album.NAME_MAX_LENGTH],
|
||||
"parent": baker.make(Album, parent=sas_root, is_moderated=True).pk,
|
||||
"date": expected_date.strftime("%Y-%m-%d"),
|
||||
"recursive": False,
|
||||
}
|
||||
|
||||
# Test successful update
|
||||
response = client.post(
|
||||
reverse(
|
||||
"sas:album_edit",
|
||||
kwargs={"album_id": album.pk},
|
||||
),
|
||||
payload,
|
||||
)
|
||||
assertRedirects(response, expected_redirect)
|
||||
updated_album = Album.objects.get(id=album.pk)
|
||||
assert updated_album.name == payload["name"]
|
||||
assert updated_album.parent.id == payload["parent"]
|
||||
assert timezone.localdate(updated_album.date) == timezone.localdate(
|
||||
expected_date
|
||||
)
|
||||
|
||||
# Test root album can be used as parent
|
||||
payload["parent"] = sas_root.pk
|
||||
response = client.post(
|
||||
reverse(
|
||||
"sas:album_edit",
|
||||
kwargs={"album_id": album.pk},
|
||||
),
|
||||
payload,
|
||||
)
|
||||
assertRedirects(response, expected_redirect)
|
||||
updated_album = Album.objects.get(id=album.pk)
|
||||
assert updated_album.name == payload["name"]
|
||||
assert updated_album.parent.id == payload["parent"]
|
||||
assert timezone.localdate(updated_album.date) == timezone.localdate(
|
||||
expected_date
|
||||
)
|
||||
|
||||
|
||||
class TestSasModeration(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
@@ -37,7 +37,7 @@ from sas.forms import (
|
||||
PictureModerationRequestForm,
|
||||
PictureUploadForm,
|
||||
)
|
||||
from sas.models import Album, PeoplePictureRelation, Picture
|
||||
from sas.models import Album, AlbumQuerySet, PeoplePictureRelation, Picture
|
||||
|
||||
|
||||
class AlbumCreateFragment(FragmentMixin, CreateView):
|
||||
@@ -266,6 +266,9 @@ class AlbumEditView(CanEditMixin, UpdateView):
|
||||
template_name = "core/edit.jinja"
|
||||
pk_url_kwarg = "album_id"
|
||||
|
||||
def get_queryset(self) -> AlbumQuerySet:
|
||||
return super().get_queryset().exclude(id=settings.SITH_SAS_ROOT_DIR_ID)
|
||||
|
||||
def form_valid(self, form):
|
||||
ret = super().form_valid(form)
|
||||
if form.cleaned_data["recursive"]:
|
||||
|
||||
@@ -404,8 +404,6 @@ SITH_FORUM_PAGE_LENGTH = 30
|
||||
SITH_SAS_ROOT_DIR_ID = env.int("SITH_SAS_ROOT_DIR_ID", default=4)
|
||||
SITH_SAS_IMAGES_PER_PAGE = 60
|
||||
|
||||
SITH_CGU_FILE_ID = env.int("SITH_CGU_FILE_ID", default=5)
|
||||
|
||||
SITH_PROFILE_DEPARTMENTS = [
|
||||
("TC", _("TC")),
|
||||
("IMSI", _("IMSI")),
|
||||
@@ -576,35 +574,6 @@ SITH_SUBSCRIPTIONS = {
|
||||
# To be completed....
|
||||
}
|
||||
|
||||
SITH_CLUB_ROLES_ID = {
|
||||
"President": 10,
|
||||
"Vice-President": 9,
|
||||
"Treasurer": 7,
|
||||
"Communication supervisor": 5,
|
||||
"Secretary": 4,
|
||||
"IT supervisor": 3,
|
||||
"Board member": 2,
|
||||
"Active member": 1,
|
||||
"Curious": 0,
|
||||
}
|
||||
|
||||
SITH_CLUB_ROLES = {
|
||||
10: _("President"),
|
||||
9: _("Vice-President"),
|
||||
7: _("Treasurer"),
|
||||
5: _("Communication supervisor"),
|
||||
4: _("Secretary"),
|
||||
3: _("IT supervisor"),
|
||||
2: _("Board member"),
|
||||
1: _("Active member"),
|
||||
0: _("Curious"),
|
||||
}
|
||||
|
||||
# This corresponds to the maximum role a user can freely subscribe to
|
||||
# In this case, SITH_MAXIMUM_FREE_ROLE=1 means that a user can
|
||||
# set himself as "Membre actif" or "Curieux", but not higher
|
||||
SITH_MAXIMUM_FREE_ROLE = 1
|
||||
|
||||
# Minutes to timeout the logged barmen
|
||||
SITH_BARMAN_TIMEOUT = 30
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ urlpatterns = [
|
||||
path("", include(("core.urls", "core"), namespace="core")),
|
||||
path("sitemap.xml", cache_page(86400)(sitemap), {"sitemaps": sitemaps}),
|
||||
path("api/", api.urls),
|
||||
path("api-link/", include(("api.urls", "api-link"), namespace="api-link")),
|
||||
path("rootplace/", include(("rootplace.urls", "rootplace"), namespace="rootplace")),
|
||||
path(
|
||||
"subscription/",
|
||||
|
||||
@@ -6,14 +6,8 @@
|
||||
{% trans %}New subscription{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{# The following statics are bundled with our autocomplete select.
|
||||
However, if one tries to swap a form by another, then the urls in script-once
|
||||
and link-once disappear.
|
||||
So we give them here.
|
||||
If the aforementioned bug is resolved, you can remove this. #}
|
||||
{% block additional_js %}
|
||||
<script type="module" src="{{ static('bundled/core/components/tabs-index.ts') }}"></script>
|
||||
<script type="module" src="{{ static("bundled/core/components/ajax-select-index.ts") }}"></script>
|
||||
<script
|
||||
type="module"
|
||||
src="{{ static("bundled/subscription/creation-form-existing-user-index.ts") }}"
|
||||
@@ -21,8 +15,6 @@
|
||||
{% endblock %}
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static("core/components/tabs.scss") }}">
|
||||
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
|
||||
<link rel="stylesheet" href="{{ static("core/components/ajax-select.scss") }}">
|
||||
<link rel="stylesheet" href="{{ static("subscription/css/subscription.scss") }}">
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertRedirects
|
||||
|
||||
from club.models import Club, Membership
|
||||
from club.models import Club, ClubRole, Membership
|
||||
from core.baker_recipes import subscriber_user
|
||||
from core.models import User
|
||||
|
||||
@@ -15,7 +15,8 @@ class TestSubscriptionPermission(TestCase):
|
||||
cls.user: User = subscriber_user.make()
|
||||
cls.admin = baker.make(User, is_superuser=True)
|
||||
cls.club = baker.make(Club)
|
||||
baker.make(Membership, user=cls.user, club=cls.club, role=7)
|
||||
role = baker.make(ClubRole, club=cls.club, is_board=True)
|
||||
baker.make(Membership, user=cls.user, club=cls.club, role=role)
|
||||
|
||||
def test_give_permission(self):
|
||||
self.client.force_login(self.admin)
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
|
||||
from datetime import date
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
@@ -152,10 +151,12 @@ class TrombiUser(models.Model):
|
||||
|
||||
def make_memberships(self):
|
||||
self.memberships.all().delete()
|
||||
for m in self.user.memberships.filter(
|
||||
role__gt=settings.SITH_MAXIMUM_FREE_ROLE
|
||||
).order_by("end_date"):
|
||||
role = str(settings.SITH_CLUB_ROLES[m.role])
|
||||
for m in (
|
||||
self.user.memberships.filter(role__is_board=True)
|
||||
.select_related("role")
|
||||
.order_by("end_date")
|
||||
):
|
||||
role = m.role.name
|
||||
if m.description:
|
||||
role += " (%s)" % m.description
|
||||
end_date = get_semester_code(m.end_date) if m.end_date else ""
|
||||
|
||||
5
uv.lock
generated
5
uv.lock
generated
@@ -815,11 +815,10 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "griffelib"
|
||||
version = "2.0.2"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user