11 Commits

Author SHA1 Message Date
imperosol
627f0ae08d doc: third-party auth 2025-11-09 15:33:00 +01:00
imperosol
c826f227ef translation: third-party authentication 2025-11-09 15:32:58 +01:00
imperosol
4ce307074c write tests 2025-11-09 15:32:48 +01:00
imperosol
246fb74cee third-party authentication views 2025-11-09 15:32:48 +01:00
imperosol
1194f383a7 add CGU/EULA to populate command 2025-11-09 15:32:48 +01:00
imperosol
6cab1f506e test populate_more command 2025-11-09 15:32:48 +01:00
imperosol
a46cf2e592 hmac_hexdigest util function 2025-11-09 15:32:48 +01:00
imperosol
5f02319156 compact notifications jinja
C'est pas la branche la plus appropriée, mais ça gène mon debug de devoir scroller plus longtemps dans l'html des réponses à cause de la place que prennent les notifs.
2025-11-09 15:32:47 +01:00
imperosol
114f6e4386 add hmac_key to ApiClient 2025-11-09 15:32:47 +01:00
imperosol
da67ef5fec move ResultConverter to core app 2025-11-09 15:32:47 +01:00
imperosol
2a79bf4978 feat: api route to get api client infos 2025-11-09 15:32:47 +01:00
121 changed files with 2178 additions and 1531 deletions

View File

@@ -17,6 +17,15 @@ class ApiClientAdmin(admin.ModelAdmin):
"owner__nick_name", "owner__nick_name",
) )
autocomplete_fields = ("owner", "groups", "client_permissions") 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) @admin.register(ApiKey)

16
api/api.py Normal file
View File

@@ -0,0 +1,16 @@
from ninja_extra import ControllerBase, api_controller, route
from api.auth import ApiKeyAuth
from api.schemas import ApiClientSchema
@api_controller("/client")
class ApiClientController(ControllerBase):
@route.get(
"/me",
auth=[ApiKeyAuth()],
response=ApiClientSchema,
url_name="api-client-infos",
)
def get_client_info(self):
return self.context.request.auth

View File

@@ -6,8 +6,6 @@ from api.models import ApiClient, ApiKey
class ApiKeyAuth(APIKeyHeader): class ApiKeyAuth(APIKeyHeader):
"""Authentication through client api keys."""
param_name = "X-APIKey" param_name = "X-APIKey"
def authenticate(self, request: HttpRequest, key: str | None) -> ApiClient | None: def authenticate(self, request: HttpRequest, key: str | None) -> ApiClient | None:

35
api/forms.py Normal file
View File

@@ -0,0 +1,35 @@
from django import forms
from django.forms import HiddenInput
from django.utils.translation import gettext_lazy as _
class ThirdPartyAuthForm(forms.Form):
"""Form to complete to authenticate on the sith from a third-party app.
For the form to be valid, the user approve the EULA (french: CGU)
and give its username from the third-party app.
"""
cgu_accepted = forms.BooleanField(
required=True,
label=_("I have read and I accept the terms and conditions of use"),
error_messages={
"required": _("You must approve the terms and conditions of use.")
},
)
is_username_valid = forms.BooleanField(
required=True,
error_messages={"required": _("You must confirm that this is your username.")},
)
client_id = forms.IntegerField(widget=HiddenInput())
third_party_app = forms.CharField(widget=HiddenInput())
privacy_link = forms.URLField(widget=HiddenInput())
username = forms.CharField(widget=HiddenInput())
callback_url = forms.URLField(widget=HiddenInput())
signature = forms.CharField(widget=HiddenInput())
def __init__(self, *args, label_suffix: str = "", initial, **kwargs):
super().__init__(*args, label_suffix=label_suffix, initial=initial, **kwargs)
self.fields["is_username_valid"].label = _(
"I confirm that %(username)s is my username on %(app)s"
) % {"username": initial.get("username"), "app": initial.get("third_party_app")}

View File

@@ -8,7 +8,7 @@ from django.utils.crypto import constant_time_compare
class Sha512ApiKeyHasher(BasePasswordHasher): class Sha512ApiKeyHasher(BasePasswordHasher):
""" """
An API key hasher using the sha512 algorithm. An API key hasher using the sha256 algorithm.
This hasher shouldn't be used in Django's `PASSWORD_HASHERS` setting. This hasher shouldn't be used in Django's `PASSWORD_HASHERS` setting.
It is insecure for use in hashing passwords, but is safe for hashing It is insecure for use in hashing passwords, but is safe for hashing

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.3 on 2025-10-26 10:15
from django.db import migrations, models
import api.models
class Migration(migrations.Migration):
dependencies = [("api", "0001_initial")]
operations = [
migrations.AddField(
model_name="apiclient",
name="hmac_key",
field=models.CharField(
default=api.models.get_hmac_key, max_length=128, verbose_name="HMAC Key"
),
),
]

View File

@@ -1,13 +1,20 @@
import secrets
from typing import Iterable from typing import Iterable
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.db import models 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 gettext_lazy as _
from django.utils.translation import pgettext_lazy from django.utils.translation import pgettext_lazy
from core.models import Group, User from core.models import Group, User
def get_hmac_key():
return secrets.token_hex(64)
class ApiClient(models.Model): class ApiClient(models.Model):
name = models.CharField(_("name"), max_length=64) name = models.CharField(_("name"), max_length=64)
owner = models.ForeignKey( owner = models.ForeignKey(
@@ -26,11 +33,10 @@ class ApiClient(models.Model):
help_text=_("Specific permissions for this api client."), help_text=_("Specific permissions for this api client."),
related_name="clients", related_name="clients",
) )
hmac_key = models.CharField(_("HMAC Key"), max_length=128, default=get_hmac_key)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
_perm_cache: set[str] | None = None
class Meta: class Meta:
verbose_name = _("api client") verbose_name = _("api client")
verbose_name_plural = _("api clients") verbose_name_plural = _("api clients")
@@ -38,33 +44,38 @@ class ApiClient(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
@cached_property
def all_permissions(self) -> set[str]:
permissions = (
Permission.objects.filter(
Q(group__group__in=self.groups.all()) | Q(clients=self)
)
.values_list("content_type__app_label", "codename")
.order_by()
)
return {f"{content_type}.{name}" for content_type, name in permissions}
def has_perm(self, perm: str): def has_perm(self, perm: str):
"""Return True if the client has the specified permission.""" """Return True if the client has the specified permission."""
return perm in self.all_permissions
if self._perm_cache is None: def has_perms(self, perm_list: Iterable[str]) -> bool:
group_permissions = ( """Return True if the client has each of the specified permissions."""
Permission.objects.filter(group__group__in=self.groups.all())
.values_list("content_type__app_label", "codename")
.order_by()
)
client_permissions = self.client_permissions.values_list(
"content_type__app_label", "codename"
).order_by()
self._perm_cache = {
f"{content_type}.{name}"
for content_type, name in (*group_permissions, *client_permissions)
}
return perm in self._perm_cache
def has_perms(self, perm_list):
"""
Return True if the client has each of the specified permissions. If
object is passed, check if the client has all required perms for it.
"""
if not isinstance(perm_list, Iterable) or isinstance(perm_list, str): if not isinstance(perm_list, Iterable) or isinstance(perm_list, str):
raise ValueError("perm_list must be an iterable of permissions.") raise ValueError("perm_list must be an iterable of permissions.")
return all(self.has_perm(perm) for perm in perm_list) 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): class ApiKey(models.Model):
PREFIX_LENGTH = 5 PREFIX_LENGTH = 5

23
api/schemas.py Normal file
View File

@@ -0,0 +1,23 @@
from ninja import ModelSchema, Schema
from pydantic import Field, HttpUrl
from api.models import ApiClient
from core.schemas import SimpleUserSchema
class ApiClientSchema(ModelSchema):
class Meta:
model = ApiClient
fields = ["id", "name"]
owner: SimpleUserSchema
permissions: list[str] = Field(alias="all_permissions")
class ThirdPartyAuthParamsSchema(Schema):
client_id: int
third_party_app: str
privacy_link: HttpUrl
username: str
callback_url: HttpUrl
signature: str

View File

@@ -0,0 +1,32 @@
{% extends "core/base.jinja" %}
{% block content %}
<form method="post">
{% csrf_token %}
<h3>{% trans %}Confidentiality{% endtrans %}</h3>
<p>
{% trans trimmed app=third_party_app %}
By ticking this box and clicking on the send button, you
acknowledge and agree to provide {{ app }} with your
first name, last name, nickname and any other information
that was the third party app was explicitly authorized to fetch
and that it must have acknowledged to you, in a complete and accurate manner.
{% endtrans %}
</p>
<p class="margin-bottom">
{% trans trimmed app=third_party_app, privacy_link=third_party_cgu, sith_cgu_link=sith_cgu %}
The privacy policies of <a href="{{ privacy_link }}">{{ app }}</a>
and of <a href="{{ sith_cgu_link }}">the Students' Association</a>
applies as soon as the form is submitted.
{% endtrans %}
</p>
<div class="row">{{ form.cgu_accepted }} {{ form.cgu_accepted.label_tag() }}</div>
<br>
<h3 class="margin-bottom">{% trans %}Confirmation of identity{% endtrans %}</h3>
<div class="row margin-bottom">
{{ form.is_username_valid }} {{ form.is_username_valid.label_tag() }}
</div>
{% for field in form.hidden_fields() %}{{ field }}{% endfor %}
<input type="submit" class="btn btn-blue">
</form>
{% endblock %}

24
api/tests/test_admin.py Normal file
View File

@@ -0,0 +1,24 @@
import pytest
from django.contrib.admin import AdminSite
from django.http import HttpRequest
from model_bakery import baker
from pytest_django.asserts import assertNumQueries
from api.admin import ApiClientAdmin
from api.models import ApiClient
@pytest.mark.django_db
def test_reset_hmac_action():
client_admin = ApiClientAdmin(ApiClient, AdminSite())
api_clients = baker.make(ApiClient, _quantity=4, _bulk_create=True)
old_hmac_keys = [c.hmac_key for c in api_clients]
with assertNumQueries(2):
qs = ApiClient.objects.filter(id__in=[c.id for c in api_clients[2:4]])
client_admin.reset_hmac_key(HttpRequest(), qs)
for c in api_clients:
c.refresh_from_db()
assert api_clients[0].hmac_key == old_hmac_keys[0]
assert api_clients[1].hmac_key == old_hmac_keys[1]
assert api_clients[2].hmac_key != old_hmac_keys[2]
assert api_clients[3].hmac_key != old_hmac_keys[3]

View File

@@ -0,0 +1,18 @@
import pytest
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from api.hashers import generate_key
from api.models import ApiClient, ApiKey
from api.schemas import ApiClientSchema
@pytest.mark.django_db
def test_api_client_controller(client: Client):
key, hashed = generate_key()
api_client = baker.make(ApiClient)
baker.make(ApiKey, client=api_client, hashed_key=hashed)
res = client.get(reverse("api:api-client-infos"), headers={"X-APIKey": key})
assert res.status_code == 200
assert res.json() == ApiClientSchema.from_orm(api_client).model_dump()

59
api/tests/test_client.py Normal file
View File

@@ -0,0 +1,59 @@
import pytest
from django.contrib.auth.models import Permission
from django.test import TestCase
from model_bakery import baker
from api.models import ApiClient
from core.models import Group
class TestClientPermissions(TestCase):
@classmethod
def setUpTestData(cls):
cls.api_client = baker.make(ApiClient)
cls.perms = baker.make(Permission, _quantity=10, _bulk_create=True)
cls.api_client.groups.set(
[
baker.make(Group, permissions=cls.perms[0:3]),
baker.make(Group, permissions=cls.perms[3:5]),
]
)
cls.api_client.client_permissions.set(
[cls.perms[3], cls.perms[5], cls.perms[6], cls.perms[7]]
)
def test_all_permissions(self):
assert self.api_client.all_permissions == {
f"{p.content_type.app_label}.{p.codename}" for p in self.perms[0:8]
}
def test_has_perm(self):
assert self.api_client.has_perm(
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}"
)
assert not self.api_client.has_perm(
f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}"
)
def test_has_perms(self):
assert self.api_client.has_perms(
[
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}",
f"{self.perms[2].content_type.app_label}.{self.perms[2].codename}",
]
)
assert not self.api_client.has_perms(
[
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}",
f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}",
],
)
@pytest.mark.django_db
def test_reset_hmac_key():
client = baker.make(ApiClient)
original_key = client.hmac_key
client.reset_hmac(commit=True)
assert len(client.hmac_key) == len(original_key)
assert client.hmac_key != original_key

View File

@@ -1,48 +0,0 @@
import pytest
from django.test import Client
from django.urls import path
from model_bakery import baker
from ninja import NinjaAPI
from ninja.security import SessionAuth
from api.auth import ApiKeyAuth
from api.hashers import generate_key
from api.models import ApiClient, ApiKey
api = NinjaAPI()
@api.post("", auth=[ApiKeyAuth(), SessionAuth()])
def post_method(*args, **kwargs) -> None:
"""Dummy POST route authenticated by either api key or session cookie."""
pass
urlpatterns = [path("", api.urls)]
@pytest.mark.django_db
@pytest.mark.urls(__name__)
@pytest.mark.parametrize("user_logged_in", [False, True])
def test_csrf_token(user_logged_in):
"""Test that CSRF check happens only when no api key is used."""
client = Client(enforce_csrf_checks=True)
key, hashed = generate_key()
api_client = baker.make(ApiClient)
baker.make(ApiKey, client=api_client, hashed_key=hashed)
if user_logged_in:
client.force_login(api_client.owner)
response = client.post("")
assert response.status_code == 403
assert response.json()["detail"] == "CSRF check Failed"
# if using a valid API key, CSRF check should not occur
response = client.post("", headers={"X-APIKey": key})
assert response.status_code == 200
# if using a wrong API key, ApiKeyAuth should fail,
# leading to a fallback into SessionAuth and a CSRF check
response = client.post("", headers={"X-APIKey": generate_key()[0]})
assert response.status_code == 403
assert response.json()["detail"] == "CSRF check Failed"

View File

@@ -0,0 +1,114 @@
from unittest import mock
from unittest.mock import Mock
from django.db.models import Max
from django.test import TestCase
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from api.models import ApiClient, get_hmac_key
from core.baker_recipes import subscriber_user
from core.schemas import UserProfileSchema
from core.utils import hmac_hexdigest
def mocked_post(*, ok: bool):
class MockedResponse(Mock):
@property
def ok(self):
return ok
def mocked():
return MockedResponse()
return mocked
class TestThirdPartyAuth(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = subscriber_user.make()
cls.api_client = baker.make(ApiClient)
def setUp(self):
self.query = {
"client_id": self.api_client.id,
"third_party_app": "app",
"privacy_link": "https://foobar.fr/",
"username": "bibou",
"callback_url": "https://callback.fr/",
}
self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query)
self.callback_data = {
"user": UserProfileSchema.from_orm(self.user).model_dump()
}
self.callback_data["signature"] = hmac_hexdigest(
self.api_client.hmac_key, self.callback_data["user"]
)
def test_auth_ok(self):
self.client.force_login(self.user)
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert res.status_code == 200
with mock.patch("requests.post", new_callable=mocked_post(ok=True)) as mocked:
res = self.client.post(
reverse("api-link:third-party-auth"),
data={"cgu_accepted": True, "is_username_valid": True, **self.query},
)
mocked.assert_called_once_with(
self.query["callback_url"], data=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"], data=self.callback_data
)
assertRedirects(
res,
reverse("api-link:third-party-auth-result", kwargs={"result": "failure"}),
)
def test_wrong_signature(self):
"""Test that a 403 is raised if the signature of the query is wrong."""
self.client.force_login(subscriber_user.make())
new_key = get_hmac_key()
del self.query["signature"]
self.query["signature"] = hmac_hexdigest(new_key, self.query)
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert res.status_code == 403
def test_cgu_not_accepted(self):
self.client.force_login(self.user)
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert res.status_code == 200
res = self.client.post(reverse("api-link:third-party-auth"), data=self.query)
assert res.status_code == 200 # no redirect means invalid form
res = self.client.post(
reverse("api-link:third-party-auth"),
data={"cgu_accepted": False, "is_username_valid": False, **self.query},
)
assert res.status_code == 200
def test_invalid_client(self):
self.query["client_id"] = ApiClient.objects.aggregate(res=Max("id"))["res"] + 1
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert res.status_code == 403
def test_missing_parameter(self):
"""Test that a 403 is raised if there is a missing parameter."""
del self.query["username"]
self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query)
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert res.status_code == 403

View File

@@ -1,11 +1,25 @@
from ninja.security import SessionAuth from django.urls import path, register_converter
from ninja_extra import NinjaExtraAPI from ninja_extra import NinjaExtraAPI
from api.views import ThirdPartyAuthResultView, ThirdPartyAuthView
from core.converters import ResultConverter
api = NinjaExtraAPI( api = NinjaExtraAPI(
title="PICON", title="PICON",
description="Portail Interactif de Communication avec les Outils Numériques", description="Portail Interactif de Communication avec les Outils Numériques",
version="0.2.0", version="0.2.0",
urls_namespace="api", urls_namespace="api",
auth=[SessionAuth()], csrf=True,
) )
api.auto_discover_controllers() api.auto_discover_controllers()
register_converter(ResultConverter, "res")
urlpatterns = [
path("auth/", ThirdPartyAuthView.as_view(), name="third-party-auth"),
path(
"auth/<res:result>/",
ThirdPartyAuthResultView.as_view(),
name="third-party-auth-result",
),
]

119
api/views.py Normal file
View File

@@ -0,0 +1,119 @@
import hmac
from urllib.parse import unquote
import pydantic
import requests
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import FormView, TemplateView
from ninja_extra.shortcuts import get_object_or_none
from api.forms import ThirdPartyAuthForm
from api.models import ApiClient
from api.schemas import ThirdPartyAuthParamsSchema
from core.models import SithFile
from core.schemas import UserProfileSchema
from core.utils import hmac_hexdigest
class ThirdPartyAuthView(LoginRequiredMixin, FormView):
form_class = ThirdPartyAuthForm
template_name = "api/third_party/auth.jinja"
success_url = reverse_lazy("core:index")
def parse_params(self) -> ThirdPartyAuthParamsSchema:
"""Parse and check the authentication parameters.
Raises:
PermissionDenied: if the verification failed.
"""
# This is here rather than in ThirdPartyAuthForm because
# the given parameters and their signature are checked during both
# POST (for obvious reasons) and GET (in order not to make
# the user fill a form just to get an error he won't understand)
params = self.request.GET or self.request.POST
params = {key: unquote(val) for key, val in params.items()}
try:
params = ThirdPartyAuthParamsSchema(**params)
except pydantic.ValidationError as e:
raise PermissionDenied("Wrong data format") from e
client: ApiClient = get_object_or_none(ApiClient, id=params.client_id)
if not client:
raise PermissionDenied
if not hmac.compare_digest(
hmac_hexdigest(client.hmac_key, params.model_dump(exclude={"signature"})),
params.signature,
):
raise PermissionDenied("Bad signature")
return params
def dispatch(self, request, *args, **kwargs):
self.params = self.parse_params()
return super().dispatch(request, *args, **kwargs)
def get(self, *args, **kwargs):
messages.warning(
self.request,
_(
"You are going to link your AE account and your %(app)s account. "
"Continue only if this page was opened from %(app)s."
)
% {"app": self.params.third_party_app},
)
return super().get(*args, **kwargs)
def get_initial(self):
return self.params.model_dump()
def form_valid(self, form):
client = ApiClient.objects.get(id=form.cleaned_data["client_id"])
user = UserProfileSchema.from_orm(self.request.user).model_dump()
data = {"user": user, "signature": hmac_hexdigest(client.hmac_key, user)}
response = requests.post(form.cleaned_data["callback_url"], data=data)
self.success_url = reverse(
"api-link:third-party-auth-result",
kwargs={"result": "success" if response.ok else "failure"},
)
return super().form_valid(form)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"third_party_app": self.params.third_party_app,
"third_party_cgu": self.params.privacy_link,
"sith_cgu": SithFile.objects.get(id=settings.SITH_CGU_FILE_ID),
}
class ThirdPartyAuthResultView(LoginRequiredMixin, TemplateView):
"""View that the user will see if its authentication on sith was successful.
This can show either a success or a failure message :
- success : everything is good, the user is successfully authenticated
and can close the page
- failure : the authentication has been processed on the sith side,
but the request to the callback url received an error.
In such a case, there is nothing much we can do but to advice
the user to contact the developers of the third-party app.
"""
template_name = "core/base.jinja"
success_message = _(
"You have been successfully authenticated. You can now close this page."
)
error_message = _(
"Your authentication on the AE website was successful, "
"but an error happened during the interaction "
"with the third-party application. "
"Please contact the managers of the latter."
)
def get(self, request, *args, **kwargs):
if self.kwargs.get("result") == "success":
messages.success(request, self.success_message)
else:
messages.error(request, self.error_message)
return super().get(request, *args, **kwargs)

View File

@@ -16,7 +16,7 @@ class ClubController(ControllerBase):
@route.get( @route.get(
"/search", "/search",
response=PaginatedResponseSchema[SimpleClubSchema], response=PaginatedResponseSchema[SimpleClubSchema],
auth=[ApiKeyAuth(), SessionAuth()], auth=[SessionAuth(), ApiKeyAuth()],
permissions=[CanAccessLookup], permissions=[CanAccessLookup],
url_name="search_club", url_name="search_club",
) )
@@ -27,7 +27,7 @@ class ClubController(ControllerBase):
@route.get( @route.get(
"/{int:club_id}", "/{int:club_id}",
response=ClubSchema, response=ClubSchema,
auth=[ApiKeyAuth(), SessionAuth()], auth=[SessionAuth(), ApiKeyAuth()],
permissions=[HasPerm("club.view_club")], permissions=[HasPerm("club.view_club")],
url_name="fetch_club", url_name="fetch_club",
) )

View File

@@ -37,7 +37,6 @@ from core.views.widgets.ajax_select import (
AutoCompleteSelectUser, AutoCompleteSelectUser,
) )
from counter.models import Counter, Selling from counter.models import Counter, Selling
from counter.schemas import SaleFilterSchema
class ClubEditForm(forms.ModelForm): class ClubEditForm(forms.ModelForm):
@@ -192,18 +191,6 @@ class SellingsForm(forms.Form):
required=False, required=False,
) )
def to_filter_schema(self) -> SaleFilterSchema:
products = (
*self.cleaned_data["products"],
*self.cleaned_data["archived_products"],
)
return SaleFilterSchema(
after=self.cleaned_data["begin_date"],
before=self.cleaned_data["end_date"],
counters={c.id for c in self.cleaned_data["counters"]} or None,
products={p.id for p in products} or None,
)
class ClubOldMemberForm(forms.Form): class ClubOldMemberForm(forms.Form):
members_old = forms.ModelMultipleChoiceField( members_old = forms.ModelMultipleChoiceField(

View File

@@ -9,18 +9,6 @@
{{ club.short_description }} {{ club.short_description }}
{%- endblock %} {%- endblock %}
{% block metatags %}
<meta property="og:url" content="{{ request.build_absolute_uri(club.get_absolute_url()) }}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="{{ club.name }}" />
<meta property="og:description" content="{{ club.short_description }}" />
{% if club.logo %}
<meta property="og:image" content="{{ request.build_absolute_uri(club.logo.url) }}" />
{% else %}
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% endif %}
{% endblock %}
{% block content %} {% block content %}
<div id="club_detail"> <div id="club_detail">
{% if club.logo %} {% if club.logo %}
@@ -29,7 +17,7 @@
{% if page_revision %} {% if page_revision %}
{{ page_revision|markdown }} {{ page_revision|markdown }}
{% else %} {% else %}
<h3>{{ club.name }}</h3> <h3>{% trans %}Club{% endtrans %}</h3>
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -1,8 +1,12 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from 'core/page/macros.jinja' import page_history %} {% from 'core/macros_pages.jinja' import page_history %}
{% block content %} {% block content %}
{{ page_history(club.page) }} {% if club.page %}
{{ page_history(club.page) }}
{% else %}
{% trans %}No page existing for this club{% endtrans %}
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -1,12 +1,8 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from 'core/macros_pages.jinja' import page_edit_form %}
{% block content %} {% block content %}
<h2>{% trans %}Edit page{% endtrans %}</h2> {{ page_edit_form(page, form, url('club:club_edit_page', club_id=page.club.id), csrf_token) }}
<form action="{{ url('club:club_edit_page', club_id=page.club.id) }}" method="post">
{% csrf_token %}
{{ form.as_p() }}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock %} {% endblock %}

View File

@@ -7,7 +7,7 @@ from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Max from django.db.models import Max
from django.test import Client, TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import localdate, localtime, now from django.utils.timezone import localdate, localtime, now
from model_bakery import baker from model_bakery import baker
@@ -532,35 +532,6 @@ class TestMembership(TestClub):
assert new_board == initial_board assert new_board == initial_board
@pytest.mark.django_db
def test_membership_set_old(client: Client):
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})
)
assertRedirects(
response, reverse("core:user_clubs", kwargs={"user_id": membership.user_id})
)
membership.refresh_from_db()
assert membership.end_date == localdate()
@pytest.mark.django_db
def test_membership_delete(client: Client):
user = baker.make(User, is_superuser=True)
membership = baker.make(Membership)
client.force_login(user)
url = reverse("club:membership_delete", kwargs={"membership_id": membership.id})
response = client.get(url)
assert response.status_code == 200
response = client.post(url)
assertRedirects(
response, reverse("core:user_clubs", kwargs={"user_id": membership.user_id})
)
assert not Membership.objects.filter(id=membership.id).exists()
@pytest.mark.django_db @pytest.mark.django_db
class TestJoinClub: class TestJoinClub:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)

View File

@@ -3,10 +3,9 @@ from bs4 import BeautifulSoup
from django.test import Client from django.test import Client
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects from pytest_django.asserts import assertHTMLEqual
from club.models import Club, Membership from club.models import Club
from core.baker_recipes import subscriber_user
from core.markdown import markdown from core.markdown import markdown
from core.models import PageRev, User from core.models import PageRev, User
@@ -17,6 +16,7 @@ def test_page_display_on_club_main_page(client: Client):
club = baker.make(Club) club = baker.make(Club)
content = "# foo\nLorem ipsum dolor sit amet" content = "# foo\nLorem ipsum dolor sit amet"
baker.make(PageRev, page=club.page, revision=1, content=content) baker.make(PageRev, page=club.page, revision=1, content=content)
client.force_login(baker.make(User))
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id})) res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
assert res.status_code == 200 assert res.status_code == 200
@@ -30,42 +30,10 @@ def test_club_main_page_without_content(client: Client):
"""Test the club view works, even if the club page is empty""" """Test the club view works, even if the club page is empty"""
club = baker.make(Club) club = baker.make(Club)
club.page.revisions.all().delete() club.page.revisions.all().delete()
client.force_login(baker.make(User))
res = client.get(reverse("club:club_view", kwargs={"club_id": club.id})) res = client.get(reverse("club:club_view", kwargs={"club_id": club.id}))
assert res.status_code == 200 assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml") soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(id="club_detail") detail_html = soup.find(id="club_detail")
assert detail_html.find_all("markdown") == [] assert detail_html.find_all("markdown") == []
@pytest.mark.django_db
def test_page_revision(client: Client):
club = baker.make(Club)
revisions = baker.make(
PageRev, page=club.page, _quantity=3, content=iter(["foo", "bar", "baz"])
)
client.force_login(baker.make(User))
url = reverse(
"club:club_view_rev", kwargs={"club_id": club.id, "rev_id": revisions[1].id}
)
res = client.get(url)
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(class_="markdown")
assertHTMLEqual(detail_html.decode_contents(), markdown(revisions[1].content))
@pytest.mark.django_db
def test_edit_page(client: Client):
club = baker.make(Club)
user = subscriber_user.make()
baker.make(Membership, user=user, club=club, role=3)
client.force_login(user)
url = reverse("club:club_edit_page", kwargs={"club_id": club.id})
content = "# foo\nLorem ipsum dolor sit amet"
res = client.get(url)
assert res.status_code == 200
res = client.post(url, data={"content": content})
assertRedirects(res, reverse("club:club_view", kwargs={"club_id": club.id}))
assert club.page.revisions.last().content == content

View File

@@ -1,6 +1,3 @@
import csv
import itertools
import pytest import pytest
from django.test import Client from django.test import Client
from django.urls import reverse from django.urls import reverse
@@ -10,20 +7,16 @@ from club.forms import SellingsForm
from club.models import Club from club.models import Club
from core.models import User from core.models import User
from counter.baker_recipes import product_recipe, sale_recipe from counter.baker_recipes import product_recipe, sale_recipe
from counter.models import Counter, Customer, Product, Selling from counter.models import Counter, Customer
@pytest.mark.django_db @pytest.mark.django_db
def test_sales_page_doesnt_crash(client: Client): def test_sales_page_doesnt_crash(client: Client):
"""Basic crashtest on club sales view."""
club = baker.make(Club) club = baker.make(Club)
product = baker.make(Product, club=club)
admin = baker.make(User, is_superuser=True) admin = baker.make(User, is_superuser=True)
client.force_login(admin) client.force_login(admin)
url = reverse("club:club_sellings", kwargs={"club_id": club.id}) response = client.get(reverse("club:club_sellings", kwargs={"club_id": club.id}))
assert client.get(url).status_code == 200 assert response.status_code == 200
assert client.post(url).status_code == 200
assert client.post(url, data={"products": [product.id]}).status_code == 200
@pytest.mark.django_db @pytest.mark.django_db
@@ -43,62 +36,3 @@ def test_sales_form_counter_filter():
form = SellingsForm(club) form = SellingsForm(club)
form_counters = list(form.fields["counters"].queryset) form_counters = list(form.fields["counters"].queryset)
assert form_counters == [counters[1], counters[2], counters[0]] assert form_counters == [counters[1], counters[2], counters[0]]
@pytest.mark.django_db
def test_club_sales_csv(client: Client):
client.force_login(baker.make(User, is_superuser=True))
club = baker.make(Club)
counter = baker.make(Counter, club=club)
product = product_recipe.make(club=club, counters=[counter], purchase_price=0.5)
customers = baker.make(Customer, amount=100, _quantity=2, _bulk_create=True)
sales: list[Selling] = sale_recipe.make(
club=club,
counter=counter,
quantity=2,
unit_price=1.5,
product=iter([product, product, None]),
customer=itertools.cycle(customers),
_quantity=3,
)
url = reverse("club:sellings_csv", kwargs={"club_id": club.id})
response = client.post(url, data={"counters": [counter.id]})
assert response.status_code == 200
reader = csv.reader(s.decode() for s in response.streaming_content)
data = list(reader)
sale_rows = [
[
str(s.date),
str(counter),
str(s.seller),
s.customer.user.get_display_name(),
s.label,
"2",
"1.50",
"3.00",
"Compte utilisateur",
]
for s in sales[::-1]
]
sale_rows[2].extend(["0.50", "1.00"])
sale_rows[1].extend(["0.50", "1.00"])
sale_rows[0].extend(["", ""])
assert data == [
["Quantité", "6"],
["Total", "9"],
["Bénéfice", "1"],
[
"Date",
"Comptoir",
"Barman",
"Client",
"Étiquette",
"Quantité",
"Prix unitaire",
"Total",
"Méthode de paiement",
"Prix d'achat",
"Bénéfice",
],
*sale_rows,
]

View File

@@ -22,28 +22,25 @@
# #
# #
from __future__ import annotations
import csv import csv
import itertools import itertools
from typing import TYPE_CHECKING, Any from typing import Any
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
from django.core.paginator import InvalidPage, Paginator from django.core.paginator import InvalidPage, Paginator
from django.db.models import F, Q, Sum from django.db.models import F, Q, Sum
from django.http import Http404, StreamingHttpResponse from django.http import Http404, HttpResponseRedirect, StreamingHttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.safestring import SafeString
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, View 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, UpdateView
from club.forms import ( from club.forms import (
@@ -64,14 +61,11 @@ from com.views import (
PosterListBaseView, PosterListBaseView,
) )
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
from core.models import Page, PageRev from core.models import PageRev
from core.views import BasePageEditView, DetailFormView, UseFragmentsMixin from core.views import DetailFormView, PageEditViewBase, UseFragmentsMixin
from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin
from counter.models import Selling from counter.models import Selling
if TYPE_CHECKING:
from django.utils.safestring import SafeString
class ClubTabsMixin(TabedViewMixin): class ClubTabsMixin(TabedViewMixin):
def get_tabs_title(self): def get_tabs_title(self):
@@ -81,8 +75,6 @@ class ClubTabsMixin(TabedViewMixin):
self.object = self.object.page.club self.object = self.object.page.club
elif isinstance(self.object, Poster): elif isinstance(self.object, Poster):
self.object = self.object.club self.object = self.object.club
elif hasattr(self, "club"):
self.object = self.club
return self.object.get_display_name() return self.object.get_display_name()
def get_list_of_tabs(self): def get_list_of_tabs(self):
@@ -210,7 +202,7 @@ class ClubView(ClubTabsMixin, DetailView):
return kwargs return kwargs
class ClubRevView(LoginRequiredMixin, ClubView): class ClubRevView(ClubView):
"""Display a specific page revision.""" """Display a specific page revision."""
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
@@ -224,26 +216,26 @@ class ClubRevView(LoginRequiredMixin, ClubView):
return kwargs return kwargs
class ClubPageEditView(ClubTabsMixin, BasePageEditView): class ClubPageEditView(ClubTabsMixin, PageEditViewBase):
template_name = "club/pagerev_edit.jinja" template_name = "club/pagerev_edit.jinja"
current_tab = "page_edit" current_tab = "page_edit"
@cached_property def dispatch(self, request, *args, **kwargs):
def club(self): self.club = get_object_or_404(Club, pk=kwargs["club_id"])
return get_object_or_404(Club, pk=self.kwargs["club_id"]) if not self.club.page:
raise Http404
return super().dispatch(request, *args, **kwargs)
@cached_property def get_object(self):
def page(self) -> Page: self.page = self.club.page
page = self.club.page return self._get_revision()
page.set_lock(self.request.user)
return page
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse_lazy("club:club_view", kwargs={"club_id": self.club.id}) return reverse_lazy("club:club_view", kwargs={"club_id": self.club.id})
class ClubPageHistView(ClubTabsMixin, PermissionRequiredMixin, DetailView): class ClubPageHistView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
"""Modification history of the page.""" """Modification hostory of the page."""
model = Club model = Club
pk_url_kwarg = "club_id" pk_url_kwarg = "club_id"
@@ -407,14 +399,33 @@ class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["result"] = Selling.objects.none() kwargs["result"] = Selling.objects.none()
kwargs["paginated_result"] = kwargs["result"]
kwargs["total"] = 0 kwargs["total"] = 0
kwargs["total_quantity"] = 0 kwargs["total_quantity"] = 0
kwargs["benefit"] = 0 kwargs["benefit"] = 0
form: SellingsForm = self.get_form() form = self.get_form()
if form.is_valid() and any(v for v in form.cleaned_data.values()): if form.is_valid():
filters = form.to_filter_schema() qs = Selling.objects.filter(club=self.object)
qs = filters.filter(Selling.objects.filter(club=self.object)) if not len([v for v in form.cleaned_data.values() if v is not None]):
qs = Selling.objects.none()
if form.cleaned_data["begin_date"]:
qs = qs.filter(date__gte=form.cleaned_data["begin_date"])
if form.cleaned_data["end_date"]:
qs = qs.filter(date__lte=form.cleaned_data["end_date"])
if form.cleaned_data["counters"]:
qs = qs.filter(counter__in=form.cleaned_data["counters"])
selected_products = []
if form.cleaned_data["products"]:
selected_products.extend(form.cleaned_data["products"])
if form.cleaned_data["archived_products"]:
selected_products.extend(form.cleaned_data["archived_products"])
if len(selected_products) > 0:
qs = qs.filter(product__in=selected_products)
kwargs["total"] = qs.annotate( kwargs["total"] = qs.annotate(
price=F("quantity") * F("unit_price") price=F("quantity") * F("unit_price")
).aggregate(total=Sum("price", default=0))["total"] ).aggregate(total=Sum("price", default=0))["total"]
@@ -461,15 +472,15 @@ class ClubSellingCSVView(ClubSellingView):
*row, *row,
selling.label, selling.label,
selling.quantity, selling.quantity,
selling.unit_price,
selling.quantity * selling.unit_price, selling.quantity * selling.unit_price,
selling.get_payment_method_display(), selling.get_payment_method_display(),
] ]
if selling.product: if selling.product:
row.append(selling.product.selling_price)
row.append(selling.product.purchase_price) row.append(selling.product.purchase_price)
row.append(selling.unit_price - selling.product.purchase_price) row.append(selling.product.selling_price - selling.product.purchase_price)
else: else:
row = [*row, "", ""] row = [*row, "", "", ""]
return row return row
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
@@ -490,9 +501,9 @@ class ClubSellingCSVView(ClubSellingView):
gettext("Customer"), gettext("Customer"),
gettext("Label"), gettext("Label"),
gettext("Quantity"), gettext("Quantity"),
gettext("Unit price"),
gettext("Total"), gettext("Total"),
gettext("Payment method"), gettext("Payment method"),
gettext("Selling price"),
gettext("Purchase price"), gettext("Purchase price"),
gettext("Benefit"), gettext("Benefit"),
], ],
@@ -545,17 +556,33 @@ class ClubCreateView(PermissionRequiredMixin, CreateView):
permission_required = "club.add_club" permission_required = "club.add_club"
class MembershipSetOldView(CanEditMixin, SingleObjectMixin, View): class MembershipSetOldView(CanEditMixin, DetailView):
"""Set a membership as being old.""" """Set a membership as beeing old."""
model = Membership model = Membership
pk_url_kwarg = "membership_id" pk_url_kwarg = "membership_id"
def post(self, *_args, **_kwargs): def get(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
self.object.end_date = timezone.now() self.object.end_date = timezone.now()
self.object.save() self.object.save()
return redirect("core:user_clubs", user_id=self.object.user_id) return HttpResponseRedirect(
reverse(
"club:club_members",
args=self.args,
kwargs={"club_id": self.object.club.id},
)
)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
return HttpResponseRedirect(
reverse(
"club:club_members",
args=self.args,
kwargs={"club_id": self.object.club.id},
)
)
class MembershipDeleteView(PermissionRequiredMixin, DeleteView): class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
@@ -567,7 +594,7 @@ class MembershipDeleteView(PermissionRequiredMixin, DeleteView):
permission_required = "club.delete_membership" permission_required = "club.delete_membership"
def get_success_url(self): def get_success_url(self):
return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user_id}) return reverse_lazy("core:user_clubs", kwargs={"user_id": self.object.user.id})
class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView): class ClubMailingView(ClubTabsMixin, CanEditMixin, DetailFormView):

View File

@@ -5,6 +5,7 @@ from django.utils.cache import add_never_cache_headers
from ninja import Query from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from api.permissions import HasPerm from api.permissions import HasPerm
@@ -16,13 +17,17 @@ from core.views.files import send_raw_file
@api_controller("/calendar") @api_controller("/calendar")
class CalendarController(ControllerBase): class CalendarController(ControllerBase):
@route.get("/internal.ics", auth=None, url_name="calendar_internal") @route.get("/internal.ics", url_name="calendar_internal")
def calendar_internal(self): def calendar_internal(self):
response = send_raw_file(IcsCalendar.get_internal()) response = send_raw_file(IcsCalendar.get_internal())
add_never_cache_headers(response) add_never_cache_headers(response)
return response return response
@route.get("/unpublished.ics", url_name="calendar_unpublished") @route.get(
"/unpublished.ics",
permissions=[IsAuthenticated],
url_name="calendar_unpublished",
)
def calendar_unpublished(self): def calendar_unpublished(self):
response = HttpResponse( response = HttpResponse(
IcsCalendar.get_unpublished(self.context.request.user), IcsCalendar.get_unpublished(self.context.request.user),
@@ -69,7 +74,6 @@ class NewsController(ControllerBase):
@route.get( @route.get(
"/date", "/date",
auth=None,
url_name="fetch_news_dates", url_name="fetch_news_dates",
response=PaginatedResponseSchema[NewsDateSchema], response=PaginatedResponseSchema[NewsDateSchema],
) )

View File

@@ -1,20 +1,15 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import user_profile_link, link_news_logo %} {% from 'core/macros.jinja' import user_profile_link, facebook_share, tweet, link_news_logo, gen_news_metatags %}
{% from "com/macros.jinja" import news_moderation_alert %} {% from "com/macros.jinja" import news_moderation_alert %}
{% block title %} {% block title %}
{% trans %}News{% endtrans %} - {{ object.title }} {% trans %}News{% endtrans %} -
{{ object.title }}
{% endblock %} {% endblock %}
{% block description %}{{ news.summary }}{% endblock %} {% block head %}
{{ super() }}
{% block metatags %} {{ gen_news_metatags(news) }}
<meta property="og:url" content="{{ news.get_full_url() }}" />
<meta property="og:type" content="article" />
<meta property="article:section" content="{% trans %}News{% endtrans %}" />
<meta property="og:title" content="{{ news.title }}" />
<meta property="og:description" content="{{ news.summary }}" />
<meta property="og:image" content="{{ request.build_absolute_uri(link_news_logo(news)) }}" />
{% endblock %} {% endblock %}
@@ -49,14 +44,8 @@
<div><em>{{ news.summary|markdown }}</em></div> <div><em>{{ news.summary|markdown }}</em></div>
<br/> <br/>
<div>{{ news.content|markdown }}</div> <div>{{ news.content|markdown }}</div>
<a {{ facebook_share(news) }}
rel="nofollow" {{ tweet(news) }}
target="#"
class="share_button facebook"
href="https://www.facebook.com/sharer/sharer.php?u={{ news.get_full_url() }}"
>
{% trans %}Share on Facebook{% endtrans %}
</a>
<div class="news_meta"> <div class="news_meta">
<p>{% trans %}Author: {% endtrans %}{{ user_profile_link(news.author) }}</p> <p>{% trans %}Author: {% endtrans %}{{ user_profile_link(news.author) }}</p>
{% if news.moderator %} {% if news.moderator %}

View File

@@ -1,3 +1,4 @@
from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
@@ -17,6 +18,16 @@ from core.markdown import markdown
from core.models import User from core.models import User
@dataclass
class MockResponse:
ok: bool
value: str
@property
def content(self):
return self.value.encode("utf8")
def accel_redirect_to_file(response: HttpResponse) -> Path | None: def accel_redirect_to_file(response: HttpResponse) -> Path | None:
redirect = Path(response.headers.get("X-Accel-Redirect", "")) redirect = Path(response.headers.get("X-Accel-Redirect", ""))
if not redirect.is_relative_to(Path("/") / settings.MEDIA_ROOT.stem): if not redirect.is_relative_to(Path("/") / settings.MEDIA_ROOT.stem):

View File

@@ -240,11 +240,10 @@ class NewsListView(TemplateView):
if not self.request.user.has_perm("core.view_user"): if not self.request.user.has_perm("core.view_user"):
return [] return []
return itertools.groupby( return itertools.groupby(
User.objects.viewable_by(self.request.user) User.objects.filter(
.filter(
date_of_birth__month=localdate().month, date_of_birth__month=localdate().month,
date_of_birth__day=localdate().day, date_of_birth__day=localdate().day,
is_viewable=True, is_subscriber_viewable=True,
) )
.filter(role__in=["STUDENT", "FORMER STUDENT"]) .filter(role__in=["STUDENT", "FORMER STUDENT"])
.order_by("-date_of_birth"), .order_by("-date_of_birth"),
@@ -701,7 +700,7 @@ class PosterModerateView(PermissionRequiredMixin, ComTabsMixin, View):
parsed = urlparse(referer) parsed = urlparse(referer)
if parsed.netloc == settings.SITH_URL: if parsed.netloc == settings.SITH_URL:
return redirect(parsed.path) return redirect(parsed.path)
return redirect("com:poster_list") return redirect(reverse("com:poster_list"))
class ScreenListView(PermissionRequiredMixin, ComTabsMixin, ListView): class ScreenListView(PermissionRequiredMixin, ComTabsMixin, ListView):

View File

@@ -74,19 +74,9 @@ class UserBanAdmin(admin.ModelAdmin):
autocomplete_fields = ("user", "ban_group") autocomplete_fields = ("user", "ban_group")
class GroupInline(admin.TabularInline):
model = Group.permissions.through
readonly_fields = ("group",)
extra = 0
def has_add_permission(self, request, obj):
return False
@admin.register(Permission) @admin.register(Permission)
class PermissionAdmin(admin.ModelAdmin): class PermissionAdmin(admin.ModelAdmin):
search_fields = ("codename",) search_fields = ("codename",)
inlines = (GroupInline,)
@admin.register(Page) @admin.register(Page)

View File

@@ -1,6 +1,6 @@
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
from annotated_types import Ge, Le, MinLen import annotated_types
from django.conf import settings from django.conf import settings
from django.db.models import F from django.db.models import F
from django.http import HttpResponse from django.http import HttpResponse
@@ -28,7 +28,6 @@ from core.schemas import (
UserSchema, UserSchema,
) )
from core.templatetags.renderer import markdown from core.templatetags.renderer import markdown
from counter.utils import is_logged_in_counter
@api_controller("/markdown") @api_controller("/markdown")
@@ -73,9 +72,9 @@ class MailingListController(ControllerBase):
@api_controller("/user") @api_controller("/user")
class UserController(ControllerBase): class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema]) @route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup])
def fetch_profiles(self, pks: Query[set[int]]): def fetch_profiles(self, pks: Query[set[int]]):
return User.objects.viewable_by(self.context.request.user).filter(pk__in=pks) return User.objects.filter(pk__in=pks)
@route.get("/{int:user_id}", response=UserSchema, permissions=[CanView]) @route.get("/{int:user_id}", response=UserSchema, permissions=[CanView])
def fetch_user(self, user_id: int): def fetch_user(self, user_id: int):
@@ -86,18 +85,13 @@ class UserController(ControllerBase):
"/search", "/search",
response=PaginatedResponseSchema[UserProfileSchema], response=PaginatedResponseSchema[UserProfileSchema],
url_name="search_users", url_name="search_users",
# logged in barmen aren't authenticated stricto sensu, so no auth here permissions=[CanAccessLookup],
auth=None,
) )
@paginate(PageNumberPaginationExtra, page_size=20) @paginate(PageNumberPaginationExtra, page_size=20)
def search_users(self, filters: Query[UserFilterSchema]): def search_users(self, filters: Query[UserFilterSchema]):
qs = User.objects return filters.filter(
# the logged in barmen can see all users (even the hidden one), User.objects.order_by(F("last_login").desc(nulls_last=True))
# because they have a temporary administrative function during )
# which they may have to deal with hidden users
if not is_logged_in_counter(self.context.request):
qs = qs.viewable_by(self.context.request.user)
return filters.filter(qs.order_by(F("last_login").desc(nulls_last=True)))
@api_controller("/file") @api_controller("/file")
@@ -105,11 +99,11 @@ class SithFileController(ControllerBase):
@route.get( @route.get(
"/search", "/search",
response=PaginatedResponseSchema[SithFileSchema], response=PaginatedResponseSchema[SithFileSchema],
auth=[ApiKeyAuth(), SessionAuth()], auth=[SessionAuth(), ApiKeyAuth()],
permissions=[CanAccessLookup], permissions=[CanAccessLookup],
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
def search_files(self, search: Annotated[str, MinLen(1)]): def search_files(self, search: Annotated[str, annotated_types.MinLen(1)]):
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search) return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
@@ -118,15 +112,15 @@ class GroupController(ControllerBase):
@route.get( @route.get(
"/search", "/search",
response=PaginatedResponseSchema[GroupSchema], response=PaginatedResponseSchema[GroupSchema],
auth=[ApiKeyAuth(), SessionAuth()], auth=[SessionAuth(), ApiKeyAuth()],
permissions=[CanAccessLookup], permissions=[CanAccessLookup],
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
def search_group(self, search: Annotated[str, MinLen(1)]): def search_group(self, search: Annotated[str, annotated_types.MinLen(1)]):
return Group.objects.filter(name__icontains=search).values() return Group.objects.filter(name__icontains=search).values()
DepthValue = Annotated[int, Ge(0), Le(10)] DepthValue = Annotated[int, annotated_types.Ge(0), annotated_types.Le(10)]
DEFAULT_DEPTH = 4 DEFAULT_DEPTH = 4

View File

@@ -24,6 +24,7 @@
from __future__ import annotations from __future__ import annotations
import types import types
import warnings
from typing import TYPE_CHECKING, Any, LiteralString from typing import TYPE_CHECKING, Any, LiteralString
from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
@@ -146,6 +147,45 @@ class GenericContentPermissionMixinBuilder(View):
return super().dispatch(request, *arg, **kwargs) return super().dispatch(request, *arg, **kwargs)
class CanCreateMixin(View):
"""Protect any child view that would create an object.
Raises:
PermissionDenied:
If the user has not the necessary permission
to create the object of the view.
"""
def __init_subclass__(cls, **kwargs):
warnings.warn(
f"{cls.__name__} is deprecated and should be replaced "
"by other permission verification mecanism.",
DeprecationWarning,
stacklevel=2,
)
super().__init_subclass__(**kwargs)
def __init__(self, *args, **kwargs):
warnings.warn(
f"{self.__class__.__name__} is deprecated and should be replaced "
"by other permission verification mecanism.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)
def dispatch(self, request, *arg, **kwargs):
if not request.user.is_authenticated:
raise PermissionDenied
return super().dispatch(request, *arg, **kwargs)
def form_valid(self, form):
obj = form.instance
if can_edit_prop(obj, self.request.user):
return super().form_valid(form)
raise PermissionDenied
class CanEditPropMixin(GenericContentPermissionMixinBuilder): class CanEditPropMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has owner permissions on the child view object. """Ensure the user has owner permissions on the child view object.

View File

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

View File

@@ -1,6 +1,6 @@
# #
# Copyright 2022 # Copyright 2018
# - Maréchal <thgirod@hotmail.com # - Skia <skia@libskia.so>
# #
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM, # Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr. # http://ae.utbm.fr.
@@ -18,20 +18,23 @@
# You should have received a copy of the GNU General Public License along with # 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 Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.core.management.base import BaseCommand
from core.models import SithFile
class PaymentResultConverter: class Command(BaseCommand):
"""Converter used for url mapping of the `eboutic.views.payment_result` view. help = "Recursively check the file system with respect to the DB"
It's meant to build an url that can match def add_arguments(self, parser):
either `/eboutic/pay/success/` or `/eboutic/pay/failure/` parser.add_argument(
but nothing else. "ids", metavar="ID", type=int, nargs="+", help="The file IDs to process"
""" )
regex = "(success|failure)" def handle(self, *args, **options):
files = SithFile.objects.filter(id__in=options["ids"]).all()
def to_python(self, value): for f in files:
return str(value) f._check_fs()
def to_url(self, value):
return str(value)

View File

@@ -28,6 +28,7 @@ from typing import ClassVar, NamedTuple
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.sites.models import Site 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 import call_command
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import connection from django.db import connection
@@ -104,13 +105,21 @@ class Command(BaseCommand):
) )
self.profiles_root = SithFile.objects.create(name="profiles", owner=root) self.profiles_root = SithFile.objects.create(name="profiles", owner=root)
home_root = SithFile.objects.create(name="users", 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 # Page needed for club creation
p = Page(name=settings.SITH_CLUB_ROOT_PAGE) p = Page(name=settings.SITH_CLUB_ROOT_PAGE)
p.save(force_lock=True) p.save(force_lock=True)
club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create(name="SAS", owner=root)
main_club = Club.objects.create( main_club = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort" id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
) )
@@ -150,8 +159,7 @@ class Command(BaseCommand):
Weekmail().save() Weekmail().save()
# Here we add a lot of test datas, that are not necessary for the Sith, # Here we add a lot of test datas, that are not necessary for the Sith, but that provide a basic development environment
# but that provide a basic development environment
self.now = timezone.now().replace(hour=12, second=0) self.now = timezone.now().replace(hour=12, second=0)
skia = User.objects.create_user( skia = User.objects.create_user(

View File

@@ -1,3 +1,4 @@
import math
import random import random
from datetime import date, timedelta from datetime import date, timedelta
from datetime import timezone as tz from datetime import timezone as tz
@@ -34,12 +35,17 @@ class Command(BaseCommand):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.faker = Faker("fr_FR") 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): def handle(self, *args, **options):
if not settings.DEBUG: if not settings.DEBUG:
raise Exception("Never call this command in prod. Never.") raise Exception("Never call this command in prod. Never.")
self.stdout.write("Creating users...") self.stdout.write("Creating users...")
users = self.create_users() users = self.create_users(options["nb_users"])
subscribers = random.sample(users, k=int(0.8 * len(users))) subscribers = random.sample(users, k=int(0.8 * len(users)))
self.stdout.write("Creating subscriptions...") self.stdout.write("Creating subscriptions...")
self.create_subscriptions(subscribers) self.create_subscriptions(subscribers)
@@ -78,7 +84,7 @@ class Command(BaseCommand):
self.stdout.write("Creating products...") self.stdout.write("Creating products...")
self.create_products() self.create_products()
self.stdout.write("Creating sales and refills...") self.stdout.write("Creating sales and refills...")
sellers = random.sample(list(User.objects.all()), 100) sellers = random.sample(users, len(users) // 10)
self.create_sales(sellers) self.create_sales(sellers)
self.stdout.write("Creating permanences...") self.stdout.write("Creating permanences...")
self.create_permanences(sellers) self.create_permanences(sellers)
@@ -87,7 +93,7 @@ class Command(BaseCommand):
self.stdout.write("Done") self.stdout.write("Done")
def create_users(self) -> list[User]: def create_users(self, nb_users: int = 600) -> list[User]:
password = make_password("plop") password = make_password("plop")
users = [ users = [
User( User(
@@ -104,7 +110,7 @@ class Command(BaseCommand):
address=self.faker.address(), address=self.faker.address(),
password=password, password=password,
) )
for _ in range(600) for _ in range(nb_users)
] ]
# there may a duplicate or two # there may a duplicate or two
# Not a problem, we will just have 599 users instead of 600 # Not a problem, we will just have 599 users instead of 600
@@ -350,6 +356,7 @@ class Command(BaseCommand):
date=make_aware( date=make_aware(
self.faker.date_time_between(customer.since, localdate()) self.faker.date_time_between(customer.since, localdate())
), ),
is_validated=True,
) )
) )
sales.extend(this_customer_sales) sales.extend(this_customer_sales)
@@ -388,8 +395,9 @@ class Command(BaseCommand):
Permanency.objects.bulk_create(perms) Permanency.objects.bulk_create(perms)
def create_forums(self): def create_forums(self):
forumers = random.sample(list(User.objects.all()), 100) users = list(User.objects.all())
most_actives = random.sample(forumers, 10) forumers = random.sample(users, math.ceil(len(users) / 10))
most_actives = random.sample(forumers, math.ceil(len(forumers) / 6))
categories = list(Forum.objects.filter(is_category=True)) categories = list(Forum.objects.filter(is_category=True))
new_forums = [ new_forums = [
Forum(name=self.faker.text(20), parent=random.choice(categories)) Forum(name=self.faker.text(20), parent=random.choice(categories))

View File

@@ -0,0 +1,41 @@
#
# Copyright 2018
# - Skia <skia@libskia.so>
#
# 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.
#
#
from django.core.management.base import BaseCommand
from core.models import SithFile
class Command(BaseCommand):
help = "Recursively repair the file system with respect to the DB"
def add_arguments(self, parser):
parser.add_argument(
"ids", metavar="ID", type=int, nargs="+", help="The file IDs to process"
)
def handle(self, *args, **options):
files = SithFile.objects.filter(id__in=options["ids"]).all()
for f in files:
f._repair_fs()

View File

@@ -1,33 +0,0 @@
# Generated by Django 5.2.8 on 2025-11-09 15:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("core", "0047_alter_notification_date_alter_notification_type")]
operations = [
migrations.AlterModelOptions(
name="user",
options={
"permissions": [("view_hidden_user", "Can view hidden users")],
"verbose_name": "user",
"verbose_name_plural": "users",
},
),
migrations.RenameField(
model_name="user", old_name="is_subscriber_viewable", new_name="is_viewable"
),
migrations.AlterField(
model_name="user",
name="is_viewable",
field=models.BooleanField(
default=True,
verbose_name="Profile visible by subscribers",
help_text=(
"If you disable this option, only admin users "
"will be able to see your profile."
),
),
),
]

View File

@@ -23,13 +23,14 @@
# #
from __future__ import annotations from __future__ import annotations
import difflib import logging
import os
import string import string
import unicodedata import unicodedata
from datetime import timedelta from datetime import timedelta
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Final, Self from typing import TYPE_CHECKING, Optional, Self
from uuid import uuid4 from uuid import uuid4
from django.conf import settings from django.conf import settings
@@ -55,8 +56,6 @@ from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField from phonenumber_field.modelfields import PhoneNumberField
from PIL import Image, ImageOps from PIL import Image, ImageOps
from core.utils import get_last_promo
if TYPE_CHECKING: if TYPE_CHECKING:
from django.core.files.uploadedfile import UploadedFile from django.core.files.uploadedfile import UploadedFile
from pydantic import NonNegativeInt from pydantic import NonNegativeInt
@@ -89,14 +88,57 @@ class Group(AuthGroup):
def validate_promo(value: int) -> None: def validate_promo(value: int) -> None:
last_promo = get_last_promo() start_year = settings.SITH_SCHOOL_START_YEAR
if not 0 < value <= last_promo: delta = (localdate() + timedelta(days=180)).year - start_year
if value < 0 or delta < value:
raise ValidationError( raise ValidationError(
_("%(value)s is not a valid promo (between 0 and %(end)s)"), _("%(value)s is not a valid promo (between 0 and %(end)s)"),
params={"value": value, "end": last_promo}, params={"value": value, "end": delta},
) )
def get_group(*, pk: int | None = None, name: str | None = None) -> Group | None:
"""Search for a group by its primary key or its name.
Either one of the two must be set.
The result is cached for the default duration (should be 5 minutes).
Args:
pk: The primary key of the group
name: The name of the group
Returns:
The group if it exists, else None
Raises:
ValueError: If no group matches the criteria
"""
if pk is None and name is None:
raise ValueError("Either pk or name must be set")
# replace space characters to hide warnings with memcached backend
pk_or_name: str | int = pk if pk is not None else name.replace(" ", "_")
group = cache.get(f"sith_group_{pk_or_name}")
if group == "not_found":
# Using None as a cache value is a little bit tricky,
# so we use a special string to represent None
return None
elif group is not None:
return group
# if this point is reached, the group is not in cache
if pk is not None:
group = Group.objects.filter(pk=pk).first()
else:
group = Group.objects.filter(name=name).first()
if group is not None:
name = group.name.replace(" ", "_")
cache.set_many({f"sith_group_{group.id}": group, f"sith_group_{name}": group})
else:
cache.set(f"sith_group_{pk_or_name}", "not_found")
return group
class BanGroup(AuthGroup): class BanGroup(AuthGroup):
"""An anti-group, that removes permissions instead of giving them. """An anti-group, that removes permissions instead of giving them.
@@ -138,15 +180,6 @@ class UserQuerySet(models.QuerySet):
Q(Exists(subscriptions)) | Q(Exists(refills)) | Q(Exists(purchases)) Q(Exists(subscriptions)) | Q(Exists(refills)) | Q(Exists(purchases))
) )
def viewable_by(self, user: User) -> Self:
if user.has_perm("core.view_hidden_user"):
return self
if user.has_perm("core.view_user"):
return self.filter(is_viewable=True)
if user.is_anonymous:
return self.none()
return self.filter(id=user.id)
class CustomUserManager(UserManager.from_queryset(UserQuerySet)): class CustomUserManager(UserManager.from_queryset(UserQuerySet)):
# see https://docs.djangoproject.com/fr/stable/topics/migrations/#model-managers # see https://docs.djangoproject.com/fr/stable/topics/migrations/#model-managers
@@ -282,24 +315,13 @@ class User(AbstractUser):
parent_address = models.CharField( parent_address = models.CharField(
_("parent address"), max_length=128, blank=True, default="" _("parent address"), max_length=128, blank=True, default=""
) )
is_viewable = models.BooleanField( is_subscriber_viewable = models.BooleanField(
_("Profile visible by subscribers"), _("is subscriber viewable"), default=True
help_text=_(
"If you disable this option, only admin users "
"will be able to see your profile."
),
default=True,
) )
godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True) godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
objects = CustomUserManager() objects = CustomUserManager()
class Meta(AbstractUser.Meta):
abstract = False
permissions = [
("view_hidden_user", "Can view hidden users"),
]
def __str__(self): def __str__(self):
return self.get_display_name() return self.get_display_name()
@@ -360,18 +382,19 @@ class User(AbstractUser):
Returns: Returns:
True if the user is the group, else False True if the user is the group, else False
""" """
if not pk and not name: if pk is not None:
group: Optional[Group] = get_group(pk=pk)
elif name is not None:
group: Optional[Group] = get_group(name=name)
else:
raise ValueError("You must either provide the id or the name of the group") raise ValueError("You must either provide the id or the name of the group")
group_id: int | None = ( if group is None:
pk or Group.objects.filter(name=name).values_list("id", flat=True).first()
)
if group_id is None:
return False return False
if group_id == settings.SITH_GROUP_SUBSCRIBERS_ID: if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID:
return self.is_subscribed return self.is_subscribed
if group_id == settings.SITH_GROUP_ROOT_ID: if group.id == settings.SITH_GROUP_ROOT_ID:
return self.is_root return self.is_root
return any(g.id == group_id for g in self.cached_groups) return group in self.cached_groups
@cached_property @cached_property
def cached_groups(self) -> list[Group]: def cached_groups(self) -> list[Group]:
@@ -431,6 +454,14 @@ class User(AbstractUser):
else: else:
raise ValidationError(_("A user with that username already exists")) raise ValidationError(_("A user with that username already exists"))
def get_profile(self):
return {
"last_name": self.last_name,
"first_name": self.first_name,
"nick_name": self.nick_name,
"date_of_birth": self.date_of_birth,
}
def get_short_name(self): def get_short_name(self):
"""Returns the short name for the user.""" """Returns the short name for the user."""
if self.nick_name: if self.nick_name:
@@ -573,12 +604,8 @@ class User(AbstractUser):
def can_be_edited_by(self, user): def can_be_edited_by(self, user):
return user.is_root or user.is_board_member return user.is_root or user.is_board_member
def can_be_viewed_by(self, user: User) -> bool: def can_be_viewed_by(self, user):
return ( return (user.was_subscribed and self.is_subscriber_viewable) or user.is_root
user.id == self.id
or user.has_perm("core.view_hidden_user")
or (user.has_perm("core.view_user") and self.is_viewable)
)
def get_mini_item(self): def get_mini_item(self):
return """ return """
@@ -662,8 +689,8 @@ class AnonymousUser(AuthAnonymousUser):
if pk is not None: if pk is not None:
return pk == allowed_id return pk == allowed_id
elif name is not None: elif name is not None:
group = Group.objects.get(id=allowed_id) group = get_group(name=name)
return group.name == name return group is not None and group.id == allowed_id
else: else:
raise ValueError("You must either provide the id or the name of the group") raise ValueError("You must either provide the id or the name of the group")
@@ -989,6 +1016,63 @@ class SithFile(models.Model):
self.clean() self.clean()
self.save() self.save()
def _repair_fs(self):
"""Rebuilds recursively the filesystem as it should be regarding the DB tree."""
if self.is_folder:
for c in self.children.all():
c._repair_fs()
return
elif not self._check_path_consistence():
# First get future parent path and the old file name
# Prepend "." so that we match all relative handling of Django's
# file storage
parent_path = "." + self.parent.get_full_path()
parent_full_path = settings.MEDIA_ROOT + parent_path
os.makedirs(parent_full_path, exist_ok=True)
old_path = self.file.name # Should be relative: "./users/skia/bleh.jpg"
new_path = "." + self.get_full_path()
try:
# Make this atomic, so that a FS problem rolls back the DB change
with transaction.atomic():
# Set the new filesystem path
self.file.name = new_path
self.save()
# Really move at the FS level
if os.path.exists(parent_full_path):
os.rename(
settings.MEDIA_ROOT + old_path,
settings.MEDIA_ROOT + new_path,
)
# Empty directories may remain, but that's not really a
# problem, and that can be solved with a simple shell
# command: `find . -type d -empty -delete`
except Exception as e:
logging.error(e)
def _check_path_consistence(self):
file_path = str(self.file)
file_full_path = settings.MEDIA_ROOT + file_path
db_path = ".%s" % self.get_full_path()
if not os.path.exists(file_full_path):
print("%s: WARNING: real file does not exists!" % self.id) # noqa T201
print("file path: %s" % file_path, end="") # noqa T201
print(" db path: %s" % db_path) # noqa T201
return False
if file_path != db_path:
print("%s: " % self.id, end="") # noqa T201
print("file path: %s" % file_path, end="") # noqa T201
print(" db path: %s" % db_path) # noqa T201
return False
return True
def _check_fs(self):
if self.is_folder:
for c in self.children.all():
c._check_fs()
return
else:
self._check_path_consistence()
@property @property
def is_file(self): def is_file(self):
return not self.is_folder return not self.is_folder
@@ -1345,9 +1429,6 @@ class PageRev(models.Model):
The content is in PageRev.title and PageRev.content . The content is in PageRev.title and PageRev.content .
""" """
MERGE_TIME_THRESHOLD: Final[timedelta] = timedelta(minutes=20)
MERGE_DIFF_THRESHOLD: Final[float] = 0.2
revision = models.IntegerField(_("revision")) revision = models.IntegerField(_("revision"))
title = models.CharField(_("page title"), max_length=255, blank=True) title = models.CharField(_("page title"), max_length=255, blank=True)
content = models.TextField(_("page content"), blank=True) content = models.TextField(_("page content"), blank=True)
@@ -1389,32 +1470,6 @@ class PageRev(models.Model):
def is_owned_by(self, user: User) -> bool: def is_owned_by(self, user: User) -> bool:
return any(g.id == self.page.owner_group_id for g in user.cached_groups) return any(g.id == self.page.owner_group_id for g in user.cached_groups)
def similarity_ratio(self, text: str) -> float:
"""Similarity ratio between this revision's content and the given text.
The result is a float in [0; 1], 0 meaning the contents are entirely different,
and 1 they are strictly the same.
"""
# cf. https://docs.python.org/3/library/difflib.html#difflib.SequenceMatcher.ratio
return difflib.SequenceMatcher(None, self.content, text).quick_ratio()
def should_merge(self, other: Self) -> bool:
"""Return True if `other` should be merged into `self`, else False.
It's considered the other revision should be merged into this one if :
- it was made less than 20 minutes after
- by the same author
- with a similarity ratio higher than 80%
"""
return (
not self._state.adding # cannot merge if the original rev doesn't exist
and self.author == other.author
and (other.date - self.date) < self.MERGE_TIME_THRESHOLD
and (not other._state.adding or other.revision == self.revision + 1)
and self.similarity_ratio(other.content) >= (1 - other.MERGE_DIFF_THRESHOLD)
)
def get_notification_types(): def get_notification_types():
return settings.SITH_NOTIFICATIONS return settings.SITH_NOTIFICATIONS

View File

@@ -21,8 +21,6 @@ $secondary-neutral-dark-color: hsl(40, 57.6%, 17%);
$white-color: hsl(219.6, 20.8%, 98%); $white-color: hsl(219.6, 20.8%, 98%);
$black-color: hsl(0, 0%, 17%); $black-color: hsl(0, 0%, 17%);
$red-text-color: #eb2f06;
$hovered-red-text-color: #ff4d4d;
$faceblue: hsl(221, 44%, 41%); $faceblue: hsl(221, 44%, 41%);
$twitblue: hsl(206, 82%, 63%); $twitblue: hsl(206, 82%, 63%);

View File

@@ -141,7 +141,6 @@ form {
display: block; display: block;
margin: calc(var(--nf-input-size) * 1.5) auto 10px; margin: calc(var(--nf-input-size) * 1.5) auto 10px;
line-height: 1; line-height: 1;
white-space: nowrap;
.helptext { .helptext {
margin-top: .25rem; margin-top: .25rem;
@@ -745,32 +744,4 @@ form {
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: var(--nf-input-size); background-size: var(--nf-input-size);
} }
&.no-margin {
margin:0;
}
// a submit input that should look like a regular <a>
input[type="submit"], button {
&.link-like {
color: $primary-dark-color;
&:hover {
color: $primary-light-color;
}
&.link-red {
color: $red-text-color;
&:hover {
color: $hovered-red-text-color;
}
}
font-weight: normal;
font-size: 100%;
margin: auto;
background: none;
border: none;
cursor: pointer;
padding: 0;
}
}
} }

View File

@@ -5,6 +5,9 @@ $text-color: white;
$background-color-hovered: #283747; $background-color-hovered: #283747;
$red-text-color: #eb2f06;
$hovered-red-text-color: #ff4d4d;
.header { .header {
box-sizing: border-box; box-sizing: border-box;
background-color: $deepblue; background-color: $deepblue;
@@ -248,15 +251,12 @@ $background-color-hovered: #283747;
justify-content: flex-start; justify-content: flex-start;
} }
a {
color: $text-color;
}
a, a,
button { button {
font-size: 100%; font-size: 100%;
margin: 0; margin: 0;
text-align: right; text-align: right;
color: $text-color;
margin-top: auto; margin-top: auto;
&:hover { &:hover {
@@ -268,6 +268,19 @@ $background-color-hovered: #283747;
margin: 0; margin: 0;
display: inline; display: inline;
} }
#logout-form button {
color: $red-text-color;
&:hover {
color: $hovered-red-text-color;
}
background: none;
border: none;
cursor: pointer;
padding: 0;
}
} }
} }
} }

View File

@@ -519,6 +519,7 @@ th {
td { td {
margin: 5px; margin: 5px;
border-collapse: collapse; border-collapse: collapse;
vertical-align: top;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;

View File

@@ -7,13 +7,10 @@
.profile { .profile {
&-visible { &-visible {
display: flex; display: flex;
flex-direction: column; justify-content: center;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
padding-top: 10px; padding-top: 10px;
input[type="checkbox"]+label {
max-width: unset;
}
} }
&-pictures { &-pictures {
@@ -119,19 +116,23 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--nf-input-size) 10px; gap: 10px;
justify-content: center; justify-content: center;
} }
&-field { &-field {
display: flex; display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
gap: 10px;
width: 100%; width: 100%;
max-width: 330px; max-width: 330px;
min-width: 300px; min-width: 300px;
@media (max-width: 750px) { @media (max-width: 750px) {
gap: 4px;
max-width: 100%; max-width: 100%;
} }
@@ -144,6 +145,22 @@
} }
} }
&-label {
text-align: left !important;
}
&-content {
> * {
box-sizing: border-box;
text-align: left !important;
margin: 0;
> * {
text-align: left !important;
}
}
}
textarea { textarea {
height: 7rem; height: 7rem;
} }

View File

@@ -4,22 +4,12 @@
{% block head %} {% block head %}
<title>{% block title %}Association des Étudiants de l'UTBM{% endblock %}</title> <title>{% block title %}Association des Étudiants de l'UTBM{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta <meta name="description" content="{% block description -%}
name="description" {% trans trimmed %}
content="{% block description -%} AE UTBM is a voluntary organisation run by UTBM students.
{% trans trimmed %} It organises student life at UTBM and manages its student facilities.
AE UTBM is a voluntary organisation run by UTBM students. {% endtrans %}
It organises student life at UTBM and manages its student facilities. {%- endblock %}">
{% endtrans %}
{%- endblock %}"
>
<meta property="og:site_name" content="Association des Étudiants de l'UTBM" />
{% block metatags %}
<meta property="og:url" content="{{ request.build_absolute_uri() }}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Association des Étudiants de l'UTBM" />
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% endblock %}
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}"> <link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link rel="stylesheet" href="{{ static('core/base.css') }}"> <link rel="stylesheet" href="{{ static('core/base.css') }}">
<link rel="stylesheet" href="{{ static('core/style.scss') }}"> <link rel="stylesheet" href="{{ static('core/style.scss') }}">

View File

@@ -61,9 +61,7 @@
<a href="{{ url('core:user_tools') }}">{% trans %}Tools{% endtrans %}</a> <a href="{{ url('core:user_tools') }}">{% trans %}Tools{% endtrans %}</a>
<form id="logout-form" method="post" action="{{ url("core:logout") }}"> <form id="logout-form" method="post" action="{{ url("core:logout") }}">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="link-like link-red"> <button type="submit">{% trans %}Logout{% endtrans %}</button>
{% trans %}Logout{% endtrans %}
</button>
</form> </form>
</div> </div>
</div> </div>

View File

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

View File

@@ -21,6 +21,20 @@
{% else %} {% else %}
<h2>{% trans %}Save{% endtrans %}</h2> <h2>{% trans %}Save{% endtrans %}</h2>
{% endif %} {% endif %}
{% if messages %}
<div x-data="{show_alert: true}" class="alert alert-green" x-show="show_alert" x-transition>
<span class="alert-main">
{% for message in messages %}
{% if message.level_tag == "success" %}
{{ message }}
{% endif %}
{% endfor %}
</span>
<span class="clickable" @click="show_alert = false">
<i class="fa fa-close"></i>
</span>
</div>
{% endif %}
<form action="" method="post" enctype="multipart/form-data"> <form action="" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
{{ form.as_p() }} {{ form.as_p() }}

View File

@@ -13,11 +13,30 @@
{%- endmacro %} {%- endmacro %}
{% macro link_news_logo(news) -%} {% macro link_news_logo(news) -%}
{%- if news.club.logo -%} {% if news.club.logo -%}
{{ news.club.logo.url }} {{ news.club.logo.url }}
{%- else -%} {% else -%}
{{ static("com/img/news.png") }} {{ static("com/img/news.png") }}
{%- endif -%} {% endif %}
{%- endmacro %}
{% macro gen_news_metatags(news) -%}
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="{{ settings.SITH_TWITTER }}" />
<meta name="twitter:creator" content= "{{ settings.SITH_TWITTER }}" />
<meta property="og:url" content="{{ news.get_full_url() }}" />
<meta property="og:type" content="article" />
<meta property="og:title" content="{{ news.title }}" />
<meta property="og:description" content="{{ news.summary }}" />
<meta property="og:image" content="{{ "https://%s%s" % (settings.SITH_URL, link_news_logo(news)) }}" />
{%- endmacro %}
{% macro facebook_share(news) -%}
<a rel="nofollow" target="#" class="share_button facebook" href="https://www.facebook.com/sharer/sharer.php?u={{ news.get_full_url() }}">{% trans %}Share on Facebook{% endtrans %}</a>
{%- endmacro %}
{% macro tweet(news) -%}
<a rel="nofollow" target="#" class="share_button twitter" href="https://twitter.com/intent/tweet?text={{ news.get_full_url() }}">{% trans %}Tweet{% endtrans %}</a>
{%- endmacro %} {%- endmacro %}
{% macro user_mini_profile(user) %} {% macro user_mini_profile(user) %}

View File

@@ -17,3 +17,12 @@
{%- endfor -%} {%- endfor -%}
</ul> </ul>
{% endmacro %} {% endmacro %}
{% macro page_edit_form(page, form, url, token) %}
<h2>{% trans %}Edit page{% endtrans %}</h2>
<form action="{{ url }}" method="post">
<input type="hidden" name="csrfmiddlewaretoken" value="{{ token }}">
{{ form.as_p() }}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endmacro %}

View File

@@ -0,0 +1,52 @@
{% extends "core/base.jinja" %}
{% block title %}
{% if page %}
{{ page.get_display_name() }}
{% elif page_list %}
{% trans %}Page list{% endtrans %}
{% elif new_page %}
{% trans %}Create page{% endtrans %}
{% else %}
{% trans %}Not found{% endtrans %}
{% endif %}
{% endblock %}
{%- macro print_page_name(page) -%}
{%- if page -%}
{{ print_page_name(page.parent) }} >
<a href="{{ url('core:page', page_name=page.get_full_name()) }}">{{ page.get_display_name() }}</a>
{%- endif -%}
{%- endmacro -%}
{% block content %}
{{ print_page_name(page) }}
<div class="tool_bar">
<div class="tools">
{% if page %}
{% if page.club %}
<a href="{{ url('club:club_view', club_id=page.club.id) }}">{% trans %}Return to club management{% endtrans %}</a>
{% else %}
<a href="{{ url('core:page', page.get_full_name()) }}">{% trans %}View{% endtrans %}</a>
{% endif %}
<a href="{{ url('core:page_hist', page_name=page.get_full_name()) }}">{% trans %}History{% endtrans %}</a>
{% if can_edit(page, user) %}
<a href="{{ url('core:page_edit', page_name=page.get_full_name()) }}">{% trans %}Edit{% endtrans %}</a>
{% endif %}
{% if can_edit_prop(page, user) and not page.is_club_page %}
<a href="{{ url('core:page_prop', page_name=page.get_full_name()) }}">{% trans %}Prop{% endtrans %}</a>
{% endif %}
{% endif %}
</div>
</div>
<hr>
{% if page %}
{% block page %}
{% endblock %}
{% else %}
<h2>{% trans %}Page does not exist{% endtrans %}</h2>
<p><a href="{{ url('core:page_new') }}?page={{ request.resolver_match.kwargs['page_name'] }}">
{% trans %}Create it?{% endtrans %}</a></p>
{% endif %}
{% endblock %}

View File

@@ -1,44 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{{ page.get_display_name() }}
{% endblock %}
{% block metatags %}
<meta property="og:url" content="{{ request.build_absolute_uri(page.get_absolute_url()) }}" />
<meta property="og:type" content="article" />
<meta property="article:section" content="{% trans %}Page{% endtrans %}" />
<meta property="og:title" content="{{ page.get_display_name() }}" />
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% endblock %}
{%- macro print_page_name(page) -%}
{%- if page -%}
{{ print_page_name(page.parent) }} >
<a href="{{ url('core:page', page_name=page.get_full_name()) }}">{{ page.get_display_name() }}</a>
{%- endif -%}
{%- endmacro -%}
{% block content %}
{{ print_page_name(page) }}
<div class="tool_bar">
<div class="tools">
{% if page.club %}
<a href="{{ url('club:club_view', club_id=page.club.id) }}">{% trans %}Return to club management{% endtrans %}</a>
{% else %}
<a href="{{ url('core:page', page.get_full_name()) }}">{% trans %}View{% endtrans %}</a>
{% endif %}
<a href="{{ url('core:page_hist', page_name=page.get_full_name()) }}">{% trans %}History{% endtrans %}</a>
{% if can_edit(page, user) %}
<a href="{{ url('core:page_edit', page_name=page.get_full_name()) }}">{% trans %}Edit{% endtrans %}</a>
{% endif %}
{% if can_edit_prop(page, user) and not page.is_club_page %}
<a href="{{ url('core:page_prop', page_name=page.get_full_name()) }}">{% trans %}Prop{% endtrans %}</a>
{% endif %}
</div>
</div>
<hr>
{% block page %}
{% endblock %}
{% endblock %}

View File

@@ -1,17 +0,0 @@
{% extends "core/page/base.jinja" %}
{% block page %}
{% if revision and revision.id != last_revision.id %}
<h4>
{% trans trimmed rev_id=revision.revision %}
This may not be the last update, you are seeing revision {{ rev_id }}!
{% endtrans %}
</h4>
{% endif %}
{% set current_revision = revision or last_revision %}
<h3>{{ current_revision.title }}</h3>
<div class="page_content">{{ current_revision.content|markdown }}</div>
{% endblock %}

View File

@@ -1,13 +0,0 @@
{% extends "core/page/base.jinja" %}
{% block page %}
<h2>{% trans %}Edit page{% endtrans %}</h2>
<form action="{{ url('core:page_edit', page_name=page.get_full_name()) }}" method="post">
{% csrf_token %}
{{ form.as_p() }}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock %}

View File

@@ -1,12 +0,0 @@
{% extends "core/base.jinja" %}
{% block content %}
<h2>{% trans %}Page does not exist{% endtrans %}</h2>
<p>
{# This template is rendered when a PageNotFound error is raised,
so the `exception` context variable should always have a page_name attribute #}
<a href="{{ url('core:page_new') }}?page={{ exception.page_name }}">
{% trans %}Create it?{% endtrans %}
</a>
</p>
{% endblock %}

View File

@@ -0,0 +1,17 @@
{% extends "core/page.jinja" %}
{% block page %}
{% if rev %}
<h4>{% trans rev_id=rev.revision %}This may not be the last update, you are seeing revision {{ rev_id }}!{% endtrans %}</h4>
<h3>{{ rev.title }}</h3>
<div class="page_content">{{ rev.content|markdown }}</div>
{% else %}
{% if page.revisions.last() %}
<h3>{{ page.revisions.last().title }}</h3>
<div class="page_content">{{ page.revisions.last().content|markdown }}</div>
{% endif %}
{% endif %}
{% endblock %}

View File

@@ -1,6 +1,6 @@
{% extends "core/page/base.jinja" %} {% extends "core/page.jinja" %}
{% from "core/page/macros.jinja" import page_history %} {% from "core/macros_pages.jinja" import page_history %}
{% block page %} {% block page %}
<h3>{% trans %}Page history{% endtrans %}</h3> <h3>{% trans %}Page history{% endtrans %}</h3>

View File

@@ -1,13 +1,18 @@
{% extends "core/page/base.jinja" %} {% extends "core/page.jinja" %}
{% block page %} {% block content %}
{% if page %}
{{ super() }}
{% endif %}
<h2>{% trans %}Page properties{% endtrans %}</h2> <h2>{% trans %}Page properties{% endtrans %}</h2>
<form action="" method="post"> <form action="" method="post">
{% csrf_token %} {% csrf_token %}
{{ form.as_p() }} {{ form.as_p() }}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p> <p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form> </form>
<a href="{{ url('core:page_delete', page_id=page.id)}}">{% trans %}Delete{% endtrans %}</a> {% if page %}
<a href="{{ url('core:page_delete', page_id=page.id)}}">{% trans %}Delete{% endtrans %}</a>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,9 @@
{% extends "core/page.jinja" %}
{% from 'core/macros_pages.jinja' import page_edit_form %}
{% block page %}
{{ page_edit_form(page, form, url('core:page_edit', page_name=page.get_full_name()), csrf_token) }}
{% endblock %}

View File

@@ -17,9 +17,7 @@
<td>{% trans %}Description{% endtrans %}</td> <td>{% trans %}Description{% endtrans %}</td>
<td>{% trans %}Since{% endtrans %}</td> <td>{% trans %}Since{% endtrans %}</td>
<td></td> <td></td>
{% if user.has_perm("club.delete_membership") %} <td></td>
<td></td>
{% endif %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -30,16 +28,7 @@
<td>{{ m.description }}</td> <td>{{ m.description }}</td>
<td>{{ m.start_date }}</td> <td>{{ m.start_date }}</td>
{% if m.can_be_edited_by(user) %} {% if m.can_be_edited_by(user) %}
<td> <td><a href="{{ url('club:membership_set_old', membership_id=m.id) }}">{% trans %}Mark as old{% endtrans %}</a></td>
<form
method="post"
action="{{ url('club:membership_set_old', membership_id=m.id) }}"
class="no-margin"
>
{% csrf_token %}
<input type="submit" class="link-like" value="{% trans %}Mark as old{% endtrans %}" />
</form>
</td>
{% endif %} {% endif %}
{% if user.has_perm("club.delete_membership") %} {% if user.has_perm("club.delete_membership") %}
<td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td> <td><a href="{{ url('club:membership_delete', membership_id=m.id) }}">{% trans %}Delete{% endtrans %}</a></td>
@@ -59,9 +48,7 @@
<td>{% trans %}Description{% endtrans %}</td> <td>{% trans %}Description{% endtrans %}</td>
<td>{% trans %}From{% endtrans %}</td> <td>{% trans %}From{% endtrans %}</td>
<td>{% trans %}To{% endtrans %}</td> <td>{% trans %}To{% endtrans %}</td>
{% if user.has_perm("club.delete_membership") %}
<td></td>
{% endif %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@@ -116,12 +116,12 @@
{# All fields #} {# All fields #}
<div class="profile-fields"> <div class="profile-fields">
{%- for field in form -%} {%- for field in form -%}
{%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_viewable","forum_signature"] -%} {%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_subscriber_viewable","forum_signature"] -%}
{%- continue -%} {%- continue -%}
{%- endif -%} {%- endif -%}
<div class="profile-field"> <div class="profile-field">
{{ field.label_tag() }} <div class="profile-field-label">{{ field.label }}</div>
<div class="profile-field-content"> <div class="profile-field-content">
{{ field }} {{ field }}
{%- if field.errors -%} {%- if field.errors -%}
@@ -136,7 +136,7 @@
<div class="profile-fields"> <div class="profile-fields">
{%- for field in [form.quote, form.forum_signature] -%} {%- for field in [form.quote, form.forum_signature] -%}
<div class="profile-field"> <div class="profile-field">
{{ field.label_tag() }} <div class="profile-field-label">{{ field.label }}</div>
<div class="profile-field-content"> <div class="profile-field-content">
{{ field }} {{ field }}
{%- if field.errors -%} {%- if field.errors -%}
@@ -149,13 +149,8 @@
{# Checkboxes #} {# Checkboxes #}
<div class="profile-visible"> <div class="profile-visible">
<div class="row"> {{ form.is_subscriber_viewable }}
{{ form.is_viewable }} {{ form.is_subscriber_viewable.label }}
{{ form.is_viewable.label_tag() }}
</div>
<span class="helptext">
{{ form.is_viewable.help_text }}
</span>
</div> </div>
<div class="final-actions"> <div class="final-actions">

View File

@@ -0,0 +1,13 @@
import contextlib
import os
import pytest
from django.core.management import call_command
@pytest.mark.django_db
def test_populate_more(settings):
"""Just check that populate more doesn't crash"""
settings.DEBUG = True
with open(os.devnull, "w") as devnull, contextlib.redirect_stdout(devnull):
call_command("populate_more", "--nb-users", "50")

View File

@@ -23,7 +23,6 @@ from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.core import mail from django.core import mail
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
from django.test import Client, RequestFactory, TestCase from django.test import Client, RequestFactory, TestCase
from django.urls import reverse from django.urls import reverse
@@ -36,8 +35,8 @@ from pytest_django.asserts import assertInHTML, assertRedirects
from antispam.models import ToxicDomain from antispam.models import ToxicDomain
from club.models import Club, Membership from club.models import Club, Membership
from core.markdown import markdown from core.markdown import markdown
from core.models import AnonymousUser, Group, Page, User, validate_promo from core.models import AnonymousUser, Group, Page, User
from core.utils import get_last_promo, get_semester_code, get_start_of_semester from core.utils import get_semester_code, get_start_of_semester
from core.views import AllowFragment from core.views import AllowFragment
from counter.models import Customer from counter.models import Customer
from sith import settings from sith import settings
@@ -319,8 +318,9 @@ class TestPageHandling(TestCase):
def test_access_page_not_found(self): def test_access_page_not_found(self):
"""Should not display a page correctly.""" """Should not display a page correctly."""
response = self.client.get(reverse("core:page", kwargs={"page_name": "swagg"})) response = self.client.get(reverse("core:page", kwargs={"page_name": "swagg"}))
assert response.status_code == 404 assert response.status_code == 200
assert '<a href="/page/create/?page=swagg">' in response.text html = response.text
self.assertIn('<a href="/page/create/?page=swagg">', html)
def test_create_page_markdown_safe(self): def test_create_page_markdown_safe(self):
"""Should format the markdown and escape html correctly.""" """Should format the markdown and escape html correctly."""
@@ -421,16 +421,18 @@ class TestUserIsInGroup(TestCase):
# clear the cached property `User.cached_groups` # clear the cached property `User.cached_groups`
self.public_user.__dict__.pop("cached_groups", None) self.public_user.__dict__.pop("cached_groups", None)
cache.clear()
# Test when the user is in the group # Test when the user is in the group
with self.assertNumQueries(1): with self.assertNumQueries(2):
self.public_user.is_in_group(pk=group_in.id) self.public_user.is_in_group(pk=group_in.id)
with self.assertNumQueries(0): with self.assertNumQueries(0):
self.public_user.is_in_group(pk=group_in.id) self.public_user.is_in_group(pk=group_in.id)
group_not_in = baker.make(Group) group_not_in = baker.make(Group)
self.public_user.__dict__.pop("cached_groups", None) self.public_user.__dict__.pop("cached_groups", None)
cache.clear()
# Test when the user is not in the group # Test when the user is not in the group
with self.assertNumQueries(1): with self.assertNumQueries(2):
self.public_user.is_in_group(pk=group_not_in.id) self.public_user.is_in_group(pk=group_not_in.id)
with self.assertNumQueries(0): with self.assertNumQueries(0):
self.public_user.is_in_group(pk=group_not_in.id) self.public_user.is_in_group(pk=group_not_in.id)
@@ -523,21 +525,6 @@ class TestDateUtils(TestCase):
assert get_start_of_semester() == autumn_2023 assert get_start_of_semester() == autumn_2023
@pytest.mark.parametrize(
("current_date", "promo"),
[("2020-10-01", 22), ("2025-03-01", 26), ("2000-11-11", 2)],
)
def test_get_last_promo(current_date: str, promo: int):
with freezegun.freeze_time(current_date):
assert get_last_promo() == promo
@pytest.mark.parametrize("promo", [0, 24])
def test_promo_validator(promo: int):
with freezegun.freeze_time("2021-10-01"), pytest.raises(ValidationError):
validate_promo(promo)
def test_allow_fragment_mixin(): def test_allow_fragment_mixin():
class TestAllowFragmentView(AllowFragment, ContextMixin, View): class TestAllowFragmentView(AllowFragment, ContextMixin, View):
def get(self, *args, **kwargs): def get(self, *args, **kwargs):

View File

@@ -46,7 +46,7 @@ class TestFetchFamilyApi(TestCase):
response = self.client.get( response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id]) reverse("api:family_graph", args=[self.main_user.id])
) )
assert response.status_code == 401 assert response.status_code == 403
self.client.force_login(baker.make(User)) # unsubscribed user self.client.force_login(baker.make(User)) # unsubscribed user
response = self.client.get( response = self.client.get(
@@ -55,7 +55,7 @@ class TestFetchFamilyApi(TestCase):
assert response.status_code == 403 assert response.status_code == 403
def test_fetch_family_hidden_user(self): def test_fetch_family_hidden_user(self):
self.main_user.is_viewable = False self.main_user.is_subscriber_viewable = False
self.main_user.save() self.main_user.save()
for user_to_login, error_code in [ for user_to_login, error_code in [
(self.main_user, 200), (self.main_user, 200),

View File

@@ -269,7 +269,7 @@ def test_apply_rights_recursively():
SimpleUploadedFile( SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg" "test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
), ),
401, 403,
), ),
( (
lambda: baker.make(User), lambda: baker.make(User),

View File

@@ -1,122 +1,32 @@
from datetime import timedelta
import freezegun
import pytest import pytest
from bs4 import BeautifulSoup
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.test import Client from django.test import Client
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects from pytest_django.asserts import assertRedirects
from club.models import Club
from core.baker_recipes import board_user, subscriber_user from core.baker_recipes import board_user, subscriber_user
from core.markdown import markdown from core.models import AnonymousUser, Page, User
from core.models import AnonymousUser, Page, PageRev, User from sith.settings import SITH_GROUP_OLD_SUBSCRIBERS_ID, SITH_GROUP_SUBSCRIBERS_ID
@pytest.mark.django_db @pytest.mark.django_db
class TestEditPage: def test_edit_page(client: Client):
def test_edit_page(self, client: Client): user = board_user.make()
user = board_user.make()
page = baker.prepare(Page)
page.save(force_lock=True)
page.view_groups.add(user.groups.first())
page.edit_groups.add(user.groups.first())
client.force_login(user)
url = reverse("core:page_edit", kwargs={"page_name": page._full_name})
res = client.get(url)
assert res.status_code == 200
res = client.post(url, data={"content": "Hello World"})
assertRedirects(
res, reverse("core:page", kwargs={"page_name": page._full_name})
)
revision = page.revisions.last()
assert revision.content == "Hello World"
def test_pagerev_reused(self, client):
"""Test that the previous revision is edited, if same author and small time diff"""
user = baker.make(User, is_superuser=True)
page = baker.prepare(Page)
page.save(force_lock=True)
first_rev = baker.make(
PageRev, author=user, page=page, date=now(), content="Hello World"
)
client.force_login(user)
url = reverse("core:page_edit", kwargs={"page_name": page._full_name})
client.post(url, data={"content": "Hello World!"})
assert page.revisions.count() == 1
assert page.revisions.last() == first_rev
first_rev.refresh_from_db()
assert first_rev.author == user
assert first_rev.content == "Hello World!"
def test_pagerev_not_reused(self, client):
"""Test that a new revision is created if too much time
passed since the last one.
"""
user = baker.make(User, is_superuser=True)
page = baker.prepare(Page)
page.save(force_lock=True)
first_rev = baker.make(PageRev, author=user, page=page, date=now())
client.force_login(user)
url = reverse("core:page_edit", kwargs={"page_name": page._full_name})
with freezegun.freeze_time(now() + timedelta(minutes=30)):
client.post(url, data={"content": "Hello World"})
assert page.revisions.count() == 2
assert page.revisions.last() != first_rev
@pytest.mark.django_db
def test_page_revision(client: Client):
"""Test the GET to request to a specific revision page."""
page = baker.prepare(Page) page = baker.prepare(Page)
page.save(force_lock=True) page.save(force_lock=True)
page.view_groups.add(settings.SITH_GROUP_SUBSCRIBERS_ID) page.view_groups.add(user.groups.first())
revisions = baker.make( client.force_login(user)
PageRev, page=page, _quantity=3, content=iter(["foo", "bar", "baz"])
) url = reverse("core:page_edit", kwargs={"page_name": page._full_name})
client.force_login(subscriber_user.make())
url = reverse(
"core:page_rev",
kwargs={"page_name": page._full_name, "rev": revisions[1].id},
)
res = client.get(url) res = client.get(url)
assert res.status_code == 200 assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(class_="markdown")
assertHTMLEqual(detail_html.decode_contents(), markdown(revisions[1].content))
res = client.post(url, data={"content": "Hello World"})
@pytest.mark.django_db assertRedirects(res, reverse("core:page", kwargs={"page_name": page._full_name}))
def test_page_club_redirection(client: Client): revision = page.revisions.last()
club = baker.make(Club) assert revision.content == "Hello World"
url = reverse("core:page", kwargs={"page_name": club.page._full_name})
res = client.get(url)
redirection_url = reverse("club:club_view", kwargs={"club_id": club.id})
assertRedirects(res, redirection_url)
@pytest.mark.django_db
def test_page_revision_club_redirection(client: Client):
client.force_login(subscriber_user.make())
club = baker.make(Club)
revisions = baker.make(
PageRev, page=club.page, _quantity=3, content=iter(["foo", "bar", "baz"])
)
url = reverse(
"core:page_rev",
kwargs={"page_name": club.page._full_name, "rev": revisions[1].id},
)
res = client.get(url)
redirection_url = reverse(
"club:club_view_rev", kwargs={"club_id": club.id, "rev_id": revisions[1].id}
)
assertRedirects(res, redirection_url)
@pytest.mark.django_db @pytest.mark.django_db
@@ -125,9 +35,9 @@ def test_viewable_by():
Page.objects.all().delete() Page.objects.all().delete()
view_groups = [ view_groups = [
[settings.SITH_GROUP_PUBLIC_ID], [settings.SITH_GROUP_PUBLIC_ID],
[settings.SITH_GROUP_PUBLIC_ID, settings.SITH_GROUP_SUBSCRIBERS_ID], [settings.SITH_GROUP_PUBLIC_ID, SITH_GROUP_SUBSCRIBERS_ID],
[settings.SITH_GROUP_SUBSCRIBERS_ID], [SITH_GROUP_SUBSCRIBERS_ID],
[settings.SITH_GROUP_SUBSCRIBERS_ID, settings.SITH_GROUP_OLD_SUBSCRIBERS_ID], [SITH_GROUP_SUBSCRIBERS_ID, SITH_GROUP_OLD_SUBSCRIBERS_ID],
[], [],
] ]
pages = baker.make(Page, _quantity=len(view_groups), _bulk_create=True) pages = baker.make(Page, _quantity=len(view_groups), _bulk_create=True)
@@ -146,11 +56,3 @@ def test_viewable_by():
) )
viewable = Page.objects.viewable_by(root_user).values_list("id", flat=True) viewable = Page.objects.viewable_by(root_user).values_list("id", flat=True)
assert set(viewable) == {p.id for p in pages} assert set(viewable) == {p.id for p in pages}
@pytest.mark.django_db
def test_page_list_view(client: Client):
baker.make(Page, _quantity=10, _bulk_create=True)
client.force_login(subscriber_user.make())
res = client.get(reverse("core:page_list"))
assert res.status_code == 200

View File

@@ -1,10 +1,8 @@
from datetime import timedelta from datetime import timedelta
from unittest import mock
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.contrib import auth from django.contrib import auth
from django.contrib.auth.models import Permission
from django.core.management import call_command from django.core.management import call_command
from django.test import Client, RequestFactory, TestCase from django.test import Client, RequestFactory, TestCase
from django.urls import reverse from django.urls import reverse
@@ -20,11 +18,10 @@ from core.baker_recipes import (
subscriber_user, subscriber_user,
very_old_subscriber_user, very_old_subscriber_user,
) )
from core.models import AnonymousUser, Group, User from core.models import Group, User
from core.views import UserTabsMixin from core.views import UserTabsMixin
from counter.baker_recipes import sale_recipe from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Refilling, Selling from counter.models import Counter, Customer, Refilling, Selling
from counter.utils import is_logged_in_counter
from eboutic.models import Invoice, InvoiceItem from eboutic.models import Invoice, InvoiceItem
@@ -62,9 +59,7 @@ class TestSearchUsersAPI(TestSearchUsers):
"""Test that users are ordered by last login date.""" """Test that users are ordered by last login date."""
self.client.force_login(subscriber_user.make()) self.client.force_login(subscriber_user.make())
response = self.client.get( response = self.client.get(reverse("api:search_users") + "?search=First")
reverse("api:search_users", query={"search": "First"})
)
assert response.status_code == 200 assert response.status_code == 200
assert response.json()["count"] == 11 assert response.json()["count"] == 11
# The users are ordered by last login date, so we need to reverse the list # The users are ordered by last login date, so we need to reverse the list
@@ -73,7 +68,7 @@ class TestSearchUsersAPI(TestSearchUsers):
] ]
def test_search_case_insensitive(self): def test_search_case_insensitive(self):
"""Test that the search is case-insensitive.""" """Test that the search is case insensitive."""
self.client.force_login(subscriber_user.make()) self.client.force_login(subscriber_user.make())
expected = [u.id for u in self.users[::-1]] expected = [u.id for u in self.users[::-1]]
@@ -86,19 +81,14 @@ class TestSearchUsersAPI(TestSearchUsers):
assert [r["id"] for r in response.json()["results"]] == expected assert [r["id"] for r in response.json()["results"]] == expected
def test_search_nick_name(self): def test_search_nick_name(self):
"""Test that the search can be done on the nickname.""" """Test that the search can be done on the nick name."""
# hidden users should not be in the final result,
# even when the nickname matches
self.users[10].is_viewable = False
self.users[10].save()
self.client.force_login(subscriber_user.make()) self.client.force_login(subscriber_user.make())
# this should return users with nicknames Nick11, Nick10 and Nick1 # this should return users with nicknames Nick11, Nick10 and Nick1
response = self.client.get( response = self.client.get(reverse("api:search_users") + "?search=Nick1")
reverse("api:search_users", query={"search": "Nick1"})
)
assert response.status_code == 200 assert response.status_code == 200
assert [r["id"] for r in response.json()["results"]] == [ assert [r["id"] for r in response.json()["results"]] == [
self.users[10].id,
self.users[9].id, self.users[9].id,
self.users[0].id, self.users[0].id,
] ]
@@ -110,25 +100,10 @@ class TestSearchUsersAPI(TestSearchUsers):
self.client.force_login(subscriber_user.make()) self.client.force_login(subscriber_user.make())
# this should return users with first names First1 and First10 # this should return users with first names First1 and First10
response = self.client.get(reverse("api:search_users", query={"search": "bél"})) response = self.client.get(reverse("api:search_users") + "?search=bél")
assert response.status_code == 200 assert response.status_code == 200
assert [r["id"] for r in response.json()["results"]] == [belix.id] assert [r["id"] for r in response.json()["results"]] == [belix.id]
@mock.create_autospec(is_logged_in_counter, return_value=True)
def test_search_as_barman(self):
# barmen should also see hidden users
self.users[10].is_viewable = False
self.users[10].save()
response = self.client.get(
reverse("api:search_users", query={"search": "Nick1"})
)
assert response.status_code == 200
assert [r["id"] for r in response.json()["results"]] == [
self.users[10].id,
self.users[9].id,
self.users[0].id,
]
class TestSearchUsersView(TestSearchUsers): class TestSearchUsersView(TestSearchUsers):
"""Test the search user view (`GET /search`).""" """Test the search user view (`GET /search`)."""
@@ -187,7 +162,11 @@ class TestFilterInactive(TestCase):
time_inactive = time_active - timedelta(days=3) time_inactive = time_active - timedelta(days=3)
counter, seller = baker.make(Counter), baker.make(User) counter, seller = baker.make(Counter), baker.make(User)
sale_recipe = Recipe( sale_recipe = Recipe(
Selling, counter=counter, club=counter.club, seller=seller, unit_price=0 Selling,
counter=counter,
club=counter.club,
seller=seller,
is_validated=True,
) )
cls.users = [ cls.users = [
@@ -389,38 +368,3 @@ class TestRedirectMe:
def test_promo_has_logo(promo): def test_promo_has_logo(promo):
user = baker.make(User, promo=promo) user = baker.make(User, promo=promo)
assert user.promo_has_logo() assert user.promo_has_logo()
@pytest.mark.django_db
class TestUserQuerySetViewableBy:
@pytest.fixture
def users(self) -> list[User]:
return [
baker.make(User),
subscriber_user.make(),
subscriber_user.make(is_viewable=False),
]
def test_admin_user(self, users: list[User]):
user = baker.make(
User,
user_permissions=[Permission.objects.get(codename="view_hidden_user")],
)
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert set(viewable) == set(users)
@pytest.mark.parametrize(
"user_factory", [old_subscriber_user.make, subscriber_user.make]
)
def test_subscriber(self, users: list[User], user_factory):
user = user_factory()
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert set(viewable) == {users[0], users[1]}
@pytest.mark.parametrize(
"user_factory", [lambda: baker.make(User), lambda: AnonymousUser()]
)
def test_not_subscriber(self, users: list[User], user_factory):
user = user_factory()
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert not viewable.exists()

View File

@@ -12,22 +12,32 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from __future__ import annotations
import hmac
from datetime import date, timedelta from datetime import date, timedelta
# Image utils # Image utils
from io import BytesIO from io import BytesIO
from typing import Final from typing import TYPE_CHECKING
from urllib.parse import urlencode
import PIL import PIL
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile 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 django.utils.timezone import localdate
from PIL import ExifTags from PIL import ExifTags
from PIL.Image import Image, Resampling 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] = ( RED_PIXEL_PNG: Final[bytes] = (
b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52" 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" b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53"
@@ -112,16 +122,6 @@ def get_semester_code(d: date | None = None) -> str:
return "P" + str(start.year)[-2:] return "P" + str(start.year)[-2:]
def get_last_promo() -> int:
"""Get the latest promo at the time the function is called.
For example, if called in october 2022 return 24,
if called in march 2026 return 27, etc.
"""
start_year = settings.SITH_SCHOOL_START_YEAR
return (localdate() + timedelta(days=180)).year - start_year
def is_image(file: UploadedFile): def is_image(file: UploadedFile):
try: try:
im = PIL.Image.open(file.file) im = PIL.Image.open(file.file)
@@ -205,3 +205,30 @@ def get_client_ip(request: HttpRequest) -> str | None:
return ip return ip
return None return None
def hmac_hexdigest(
key: str | bytes,
data: Mapping[str, Any] | Sequence[tuple[str, Any]],
digest: str | Callable[[Buffer], HASH] = "sha512",
) -> str:
"""Return the hexdigest of the signature of the given data.
Args:
key: the HMAC key used for the signature
data: the data to sign
digest: a PEP247 hashing algorithm (by default, sha512)
Examples:
```python
data = {
"foo": 5,
"bar": "somevalue",
}
hmac_key = secrets.token_hex(64)
signature = hmac_hexdigest(hmac_key, data, "sha256")
```
"""
if isinstance(key, str):
key = key.encode()
return hmac.digest(key, urlencode(data).encode(), digest).hex()

View File

@@ -21,10 +21,10 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
from django.http import ( from django.http import (
Http404,
HttpRequest,
HttpResponseForbidden, HttpResponseForbidden,
HttpResponseNotFound,
HttpResponseServerError, HttpResponseServerError,
) )
from django.shortcuts import render from django.shortcuts import render
@@ -33,20 +33,17 @@ from django.views.generic.edit import FormView
from sentry_sdk import last_event_id from sentry_sdk import last_event_id
from core.views.forms import LoginForm from core.views.forms import LoginForm
from core.views.page import PageNotFound
def forbidden(request: HttpRequest, exception): def forbidden(request, exception):
context = {"next": request.path, "form": LoginForm()} context = {"next": request.path, "form": LoginForm()}
return HttpResponseForbidden(render(request, "core/403.jinja", context=context)) return HttpResponseForbidden(render(request, "core/403.jinja", context=context))
def not_found(request: HttpRequest, exception: Http404): def not_found(request, exception):
if isinstance(exception, PageNotFound): return HttpResponseNotFound(
template_name = "core/page/not_found.jinja" render(request, "core/404.jinja", context={"exception": exception})
else: )
template_name = "core/404.jinja"
return render(request, template_name, context={"exception": exception}, status=404)
def internal_servor_error(request): def internal_servor_error(request):

View File

@@ -21,7 +21,6 @@
# #
# #
import re import re
from copy import copy
from datetime import date, datetime from datetime import date, datetime
from io import BytesIO from io import BytesIO
@@ -43,12 +42,13 @@ from django.forms import (
Widget, Widget,
) )
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget from phonenumber_field.widgets import RegionalPhoneNumberWidget
from PIL import Image from PIL import Image
from antispam.forms import AntiSpamEmailField from antispam.forms import AntiSpamEmailField
from core.models import Gift, Group, Page, PageRev, SithFile, User from core.models import Gift, Group, Page, SithFile, User
from core.utils import resize_image from core.utils import resize_image
from core.views.widgets.ajax_select import ( from core.views.widgets.ajax_select import (
AutoCompleteSelect, AutoCompleteSelect,
@@ -56,7 +56,6 @@ from core.views.widgets.ajax_select import (
AutoCompleteSelectMultipleGroup, AutoCompleteSelectMultipleGroup,
AutoCompleteSelectUser, AutoCompleteSelectUser,
) )
from core.views.widgets.markdown import MarkdownInput
# Widgets # Widgets
@@ -87,6 +86,30 @@ class NFCTextInput(TextInput):
return context return context
class SelectUser(TextInput):
def render(self, name, value, attrs=None, renderer=None):
if attrs:
attrs["class"] = "select_user"
else:
attrs = {"class": "select_user"}
output = (
'%(content)s<div name="%(name)s" class="choose_user_widget" title="%(title)s"></div>'
% {
"content": super().render(name, value, attrs, renderer),
"title": _("Choose user"),
"name": name,
}
)
output += (
'<span name="'
+ name
+ '" class="choose_user_button">'
+ gettext("Choose user")
+ "</span>"
)
return output
# Fields # Fields
@@ -179,7 +202,7 @@ class UserProfileForm(forms.ModelForm):
"school", "school",
"promo", "promo",
"forum_signature", "forum_signature",
"is_viewable", "is_subscriber_viewable",
] ]
widgets = { widgets = {
"date_of_birth": SelectDate, "date_of_birth": SelectDate,
@@ -188,8 +211,8 @@ class UserProfileForm(forms.ModelForm):
"quote": forms.Textarea, "quote": forms.Textarea,
} }
def __init__(self, *args, label_suffix: str = "", **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, label_suffix=label_suffix, **kwargs) super().__init__(*args, **kwargs)
# Image fields are injected here to override the file field provided by the model # Image fields are injected here to override the file field provided by the model
# This would be better if we could have a SithImage sort of model input instead of a generic SithFile # This would be better if we could have a SithImage sort of model input instead of a generic SithFile
@@ -381,42 +404,6 @@ class PageForm(forms.ModelForm):
) )
class PageRevisionForm(forms.ModelForm):
"""Form to add a new revision to a page.
Notes:
Saving this form won't always result in a new revision.
If the previous revision on the same page was made :
- less than 20 minutes ago
- by the same author
- with a similarity ratio higher than 80%
then the latter will be edited and the new revision won't be created.
"""
class Meta:
model = PageRev
fields = ["title", "content"]
widgets = {"content": MarkdownInput}
def __init__(
self, *args, author: User, page: Page, instance: PageRev | None = None, **kwargs
):
super().__init__(*args, instance=instance, **kwargs)
self.author = author
self.page = page
self.initial_obj: PageRev = copy(self.instance)
def save(self, commit=True): # noqa FBT002
revision: PageRev = self.instance
if not self.initial_obj.should_merge(self.instance):
revision.author = self.author
revision.page = self.page
revision.id = None # if id is None, Django will create a new record
return super().save(commit=commit)
class GiftForm(forms.ModelForm): class GiftForm(forms.ModelForm):
class Meta: class Meta:
model = Gift model = Gift

View File

@@ -13,39 +13,39 @@
# #
# #
from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import F, OuterRef, Subquery from django.db.models import F, OuterRef, Subquery
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
# This file contains all the views that concern the page model
from django.forms.models import modelform_factory
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import redirect
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.functional import cached_property
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from core.auth.mixins import CanEditPropMixin, CanViewMixin from core.auth.mixins import (
from core.models import Page, PageRev CanEditMixin,
from core.views.forms import PageForm, PagePropForm, PageRevisionForm CanEditPropMixin,
CanViewMixin,
)
from core.models import LockError, Page, PageRev
from core.views.forms import PageForm, PagePropForm
from core.views.widgets.markdown import MarkdownInput
class PageNotFound(Http404): class CanEditPagePropMixin(CanEditPropMixin):
"""Http404 Exception, but specifically for when the not found object is a Page.""" def dispatch(self, request, *args, **kwargs):
res = super().dispatch(request, *args, **kwargs)
def __init__(self, page_name: str): if self.object.is_club_page:
self.page_name = page_name raise Http404
return res
def get_page_or_404(full_name: str) -> Page:
"""Like Django's get_object_or_404, but for Page, and with a custom 404 exception."""
page = Page.objects.filter(_full_name=full_name).first()
if not page:
raise PageNotFound(full_name)
return page
class PageListView(ListView): class PageListView(ListView):
model = Page model = Page
template_name = "core/page/list.jinja" template_name = "core/page_list.jinja"
def get_queryset(self): def get_queryset(self):
return ( return (
@@ -64,57 +64,80 @@ class PageListView(ListView):
) )
class BasePageDetailView(CanViewMixin, DetailView): class PageView(CanViewMixin, DetailView):
model = Page model = Page
template_name = "core/page_detail.jinja"
def dispatch(self, request, *args, **kwargs):
res = super().dispatch(request, *args, **kwargs)
if self.object and self.object.need_club_redirection:
return redirect("club:club_view", club_id=self.object.club.id)
return res
def get_object(self):
self.page = Page.get_page_by_full_name(self.kwargs["page_name"])
return self.page
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if "page" not in context:
context["new_page"] = self.kwargs["page_name"]
return context
class PageHistView(CanViewMixin, DetailView):
model = Page
template_name = "core/page_hist.jinja"
slug_field = "_full_name"
slug_url_kwarg = "page_name" slug_url_kwarg = "page_name"
_cached_object: Page | None = None _cached_object: Page | None = None
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
page = self.get_object() page = self.get_object()
if page.need_club_redirection: if page.need_club_redirection:
return redirect("club:club_view", club_id=page.club.id) return redirect("club:club_hist", club_id=page.club.id)
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_object(self, *args, **kwargs): def get_object(self, *args, **kwargs):
if not self._cached_object: if not self._cached_object:
full_name = self.kwargs.get(self.slug_url_kwarg) self._cached_object = super().get_object()
self._cached_object = get_page_or_404(full_name)
return self._cached_object return self._cached_object
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"last_revision": self.object.revisions.last()
}
class PageRevView(CanViewMixin, DetailView):
class PageView(BasePageDetailView): model = Page
template_name = "core/page/detail.jinja" template_name = "core/page_detail.jinja"
class PageHistView(BasePageDetailView):
template_name = "core/page/history.jinja"
class PageRevView(BasePageDetailView):
template_name = "core/page/detail.jinja"
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
page = self.get_object() res = super().dispatch(request, *args, **kwargs)
if page.need_club_redirection: self.object = self.get_object()
if self.object is None:
return redirect("core:page_create", page_name=self.kwargs["page_name"])
if self.object.need_club_redirection:
return redirect( return redirect(
"club:club_view_rev", club_id=page.club.id, rev_id=kwargs["rev"] "club:club_view_rev", club_id=self.object.club.id, rev_id=kwargs["rev"]
) )
self.revision = get_object_or_404(page.revisions, id=self.kwargs["rev"]) return res
return super().dispatch(request, *args, **kwargs)
def get_object(self, *args, **kwargs):
self.page = Page.get_page_by_full_name(self.kwargs["page_name"])
return self.page
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"revision": self.revision} context = super().get_context_data(**kwargs)
if not self.page:
return context | {"new_page": self.kwargs["page_name"]}
context["page"] = self.page
context["rev"] = self.page.revisions.filter(id=self.kwargs["rev"]).first()
return context
class PageCreateView(PermissionRequiredMixin, CreateView): class PageCreateView(PermissionRequiredMixin, CreateView):
model = Page model = Page
form_class = PageForm form_class = PageForm
template_name = "core/create.jinja" template_name = "core/page_prop.jinja"
permission_required = "core.add_page" permission_required = "core.add_page"
def get_initial(self): def get_initial(self):
@@ -129,67 +152,88 @@ class PageCreateView(PermissionRequiredMixin, CreateView):
init["name"] = page_name[-1] init["name"] = page_name[-1]
return init return init
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["new_page"] = True
return context
def form_valid(self, form): def form_valid(self, form):
form.instance.set_lock(self.request.user) form.instance.set_lock(self.request.user)
ret = super().form_valid(form) ret = super().form_valid(form)
return ret return ret
class CanEditPagePropMixin(CanEditPropMixin):
def dispatch(self, request, *args, **kwargs):
res = super().dispatch(request, *args, **kwargs)
if self.object.is_club_page:
raise Http404
return res
class PagePropView(CanEditPagePropMixin, UpdateView): class PagePropView(CanEditPagePropMixin, UpdateView):
model = Page model = Page
form_class = PagePropForm form_class = PagePropForm
template_name = "core/page/prop.jinja" template_name = "core/page_prop.jinja"
slug_field = "_full_name"
slug_url_kwarg = "page_name"
def get_object(self, queryset=None): def get_object(self, queryset=None):
self.page = get_page_or_404(full_name=self.kwargs["page_name"]) self.page = super().get_object()
self.page.set_lock_recursive(self.request.user) try:
self.page.set_lock_recursive(self.request.user)
except LockError as e:
raise e
return self.page return self.page
class BasePageEditView(UserPassesTestMixin, UpdateView): class PageEditViewBase(CanEditMixin, UpdateView):
model = PageRev model = PageRev
form_class = PageRevisionForm form_class = modelform_factory(
template_name = "core/page/edit.jinja" model=PageRev, fields=["title", "content"], widgets={"content": MarkdownInput}
)
def test_func(self): template_name = "core/pagerev_edit.jinja"
return self.request.user.can_edit(self.page)
@cached_property
def page(self) -> Page:
page = get_page_or_404(full_name=self.kwargs["page_name"])
page.set_lock(self.request.user)
return page
def get_object(self, *args, **kwargs): def get_object(self, *args, **kwargs):
return self.page.revisions.last() self.page = Page.get_page_by_full_name(self.kwargs["page_name"])
return self._get_revision()
def _get_revision(self):
if self.page is not None:
# First edit
if self.page.revisions.all() is None:
rev = PageRev(author=self.request.user)
rev.save()
self.page.revisions.add(rev)
try:
self.page.set_lock(self.request.user)
except LockError as e:
raise e
return self.page.revisions.last()
return None
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"page": self.page} context = super().get_context_data(**kwargs)
if self.page is not None:
context["page"] = self.page
else:
context["new_page"] = self.kwargs["page_name"]
return context
def get_form_kwargs(self): def form_valid(self, form):
return super().get_form_kwargs() | { # TODO : factor that, but first make some tests
"author": self.request.user, rev = form.instance
"page": self.page, new_rev = PageRev(title=rev.title, content=rev.content)
} new_rev.author = self.request.user
new_rev.page = self.page
form.instance = new_rev
return super().form_valid(form)
class PageEditView(BasePageEditView): class PageEditView(PageEditViewBase):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
if self.page.need_club_redirection: res = super().dispatch(request, *args, **kwargs)
return redirect("club:club_edit_page", club_id=self.page.club.id) if self.object and self.object.page.need_club_redirection:
return super().dispatch(request, *args, **kwargs) return redirect("club:club_edit_page", club_id=self.object.page.club.id)
return res
class PageDeleteView(CanEditPagePropMixin, DeleteView): class PageDeleteView(CanEditPagePropMixin, DeleteView):
model = Page model = Page
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
pk_url_kwarg = "page_id" pk_url_kwarg = "page_id"
success_url = reverse_lazy("core:page_list")
def get_success_url(self, **kwargs):
return reverse_lazy("core:page_list")

View File

@@ -103,7 +103,9 @@ def password_root_change(request, user_id):
"""Allows a root user to change someone's password.""" """Allows a root user to change someone's password."""
if not request.user.is_root: if not request.user.is_root:
raise PermissionDenied raise PermissionDenied
user = get_object_or_404(User, id=user_id) user = User.objects.filter(id=user_id).first()
if not user:
raise Http404("User not found")
if request.method == "POST": if request.method == "POST":
form = views.SetPasswordForm(user=user, data=request.POST) form = views.SetPasswordForm(user=user, data=request.POST)
if form.is_valid(): if form.is_valid():

View File

@@ -64,7 +64,7 @@ class CounterController(ControllerBase):
@route.get( @route.get(
"/search", "/search",
response=PaginatedResponseSchema[SimplifiedCounterSchema], response=PaginatedResponseSchema[SimplifiedCounterSchema],
auth=[ApiKeyAuth(), SessionAuth()], auth=[SessionAuth(), ApiKeyAuth()],
permissions=[CanAccessLookup], permissions=[CanAccessLookup],
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
@@ -77,7 +77,7 @@ class ProductController(ControllerBase):
@route.get( @route.get(
"/search", "/search",
response=PaginatedResponseSchema[SimpleProductSchema], response=PaginatedResponseSchema[SimpleProductSchema],
auth=[ApiKeyAuth(), SessionAuth()], auth=[SessionAuth(), ApiKeyAuth()],
permissions=[CanAccessLookup], permissions=[CanAccessLookup],
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
@@ -117,7 +117,7 @@ class ProductTypeController(ControllerBase):
def fetch_all(self): def fetch_all(self):
return ProductType.objects.order_by("order") return ProductType.objects.order_by("order")
@route.patch("/{type_id}/move", url_name="reorder_product_type") @route.patch("/{type_id}/move")
def reorder(self, type_id: int, other_id: Query[ReorderProductTypeSchema]): def reorder(self, type_id: int, other_id: Query[ReorderProductTypeSchema]):
"""Change the order of a product type. """Change the order of a product type.

View File

@@ -24,6 +24,12 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
PAYMENT_METHOD = [
("CHECK", _("Check")),
("CASH", _("Cash")),
("CARD", _("Credit card")),
]
class CounterConfig(AppConfig): class CounterConfig(AppConfig):
name = "counter" name = "counter"

View File

@@ -136,10 +136,7 @@ class GetUserForm(forms.Form):
class RefillForm(forms.ModelForm): class RefillForm(forms.ModelForm):
allowed_refilling_methods = [ allowed_refilling_methods = ["CASH", "CARD"]
Refilling.PaymentMethod.CASH,
Refilling.PaymentMethod.CARD,
]
error_css_class = "error" error_css_class = "error"
required_css_class = "required" required_css_class = "required"
@@ -149,7 +146,7 @@ class RefillForm(forms.ModelForm):
class Meta: class Meta:
model = Refilling model = Refilling
fields = ["amount", "payment_method"] fields = ["amount", "payment_method", "bank"]
widgets = {"payment_method": forms.RadioSelect} widgets = {"payment_method": forms.RadioSelect}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -163,6 +160,9 @@ class RefillForm(forms.ModelForm):
if self.fields["payment_method"].initial not in self.allowed_refilling_methods: if self.fields["payment_method"].initial not in self.allowed_refilling_methods:
self.fields["payment_method"].initial = self.allowed_refilling_methods[0] self.fields["payment_method"].initial = self.allowed_refilling_methods[0]
if "CHECK" not in self.allowed_refilling_methods:
del self.fields["bank"]
class CounterEditForm(forms.ModelForm): class CounterEditForm(forms.ModelForm):
class Meta: class Meta:

View File

@@ -119,6 +119,7 @@ class Command(BaseCommand):
quantity=1, quantity=1,
unit_price=account.amount, unit_price=account.amount,
date=now(), date=now(),
is_validated=True,
) )
for account in accounts for account in accounts
] ]

View File

@@ -1,84 +0,0 @@
# Generated by Django 5.2.8 on 2025-11-19 17:59
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Case, When
def migrate_selling_payment_method(apps: StateApps, schema_editor):
# 0 <=> SITH_ACCOUNT is the default value, so no need to migrate it
Selling = apps.get_model("counter", "Selling")
Selling.objects.filter(payment_method_str="CARD").update(payment_method=1)
def migrate_selling_payment_method_reverse(apps: StateApps, schema_editor):
Selling = apps.get_model("counter", "Selling")
Selling.objects.filter(payment_method=1).update(payment_method_str="CARD")
def migrate_refilling_payment_method(apps: StateApps, schema_editor):
Refilling = apps.get_model("counter", "Refilling")
Refilling.objects.update(
payment_method=Case(
When(payment_method_str="CARD", then=0),
When(payment_method_str="CASH", then=1),
When(payment_method_str="CHECK", then=2),
)
)
def migrate_refilling_payment_method_reverse(apps: StateApps, schema_editor):
Refilling = apps.get_model("counter", "Refilling")
Refilling.objects.update(
payment_method_str=Case(
When(payment_method=0, then="CARD"),
When(payment_method=1, then="CASH"),
When(payment_method=2, then="CHECK"),
)
)
class Migration(migrations.Migration):
dependencies = [("counter", "0034_alter_selling_date_selling_date_month_idx")]
operations = [
migrations.RemoveField(model_name="selling", name="is_validated"),
migrations.RemoveField(model_name="refilling", name="is_validated"),
migrations.RemoveField(model_name="refilling", name="bank"),
migrations.RenameField(
model_name="selling",
old_name="payment_method",
new_name="payment_method_str",
),
migrations.AddField(
model_name="selling",
name="payment_method",
field=models.PositiveSmallIntegerField(
choices=[(0, "Sith account"), (1, "Credit card")],
default=0,
verbose_name="payment method",
),
),
migrations.RunPython(
migrate_selling_payment_method, migrate_selling_payment_method_reverse
),
migrations.RemoveField(model_name="selling", name="payment_method_str"),
migrations.RenameField(
model_name="refilling",
old_name="payment_method",
new_name="payment_method_str",
),
migrations.AddField(
model_name="refilling",
name="payment_method",
field=models.PositiveSmallIntegerField(
choices=[(0, "Credit card"), (1, "Cash"), (2, "Check")],
default=0,
verbose_name="payment method",
),
),
migrations.RunPython(
migrate_refilling_payment_method, migrate_refilling_payment_method_reverse
),
migrations.RemoveField(model_name="refilling", name="payment_method_str"),
]

View File

@@ -44,6 +44,7 @@ from club.models import Club
from core.fields import ResizedImageField from core.fields import ResizedImageField
from core.models import Group, Notification, User from core.models import Group, Notification, User
from core.utils import get_start_of_semester from core.utils import get_start_of_semester
from counter.apps import PAYMENT_METHOD
from counter.fields import CurrencyField from counter.fields import CurrencyField
from subscription.models import Subscription from subscription.models import Subscription
@@ -79,8 +80,7 @@ class CustomerQuerySet(models.QuerySet):
) )
money_out = Subquery( money_out = Subquery(
Selling.objects.filter( Selling.objects.filter(
customer=OuterRef("pk"), customer=OuterRef("pk"), payment_method="SITH_ACCOUNT"
payment_method=Selling.PaymentMethod.SITH_ACCOUNT,
) )
.values("customer_id") .values("customer_id")
.annotate(res=Sum(F("unit_price") * F("quantity"), default=0)) .annotate(res=Sum(F("unit_price") * F("quantity"), default=0))
@@ -731,11 +731,6 @@ class RefillingQuerySet(models.QuerySet):
class Refilling(models.Model): class Refilling(models.Model):
"""Handle the refilling.""" """Handle the refilling."""
class PaymentMethod(models.IntegerChoices):
CARD = 0, _("Credit card")
CASH = 1, _("Cash")
CHECK = 2, _("Check")
counter = models.ForeignKey( counter = models.ForeignKey(
Counter, related_name="refillings", blank=False, on_delete=models.CASCADE Counter, related_name="refillings", blank=False, on_delete=models.CASCADE
) )
@@ -750,9 +745,16 @@ class Refilling(models.Model):
Customer, related_name="refillings", blank=False, on_delete=models.CASCADE Customer, related_name="refillings", blank=False, on_delete=models.CASCADE
) )
date = models.DateTimeField(_("date")) date = models.DateTimeField(_("date"))
payment_method = models.PositiveSmallIntegerField( payment_method = models.CharField(
_("payment method"), choices=PaymentMethod, default=PaymentMethod.CARD _("payment method"),
max_length=255,
choices=PAYMENT_METHOD,
default="CARD",
) )
bank = models.CharField(
_("bank"), max_length=255, choices=settings.SITH_COUNTER_BANK, default="OTHER"
)
is_validated = models.BooleanField(_("is validated"), default=False)
objects = RefillingQuerySet.as_manager() objects = RefillingQuerySet.as_manager()
@@ -769,9 +771,10 @@ class Refilling(models.Model):
if not self.date: if not self.date:
self.date = timezone.now() self.date = timezone.now()
self.full_clean() self.full_clean()
if self._state.adding: if not self.is_validated:
self.customer.amount += self.amount self.customer.amount += self.amount
self.customer.save() self.customer.save()
self.is_validated = True
if self.customer.user.preferences.notify_on_refill: if self.customer.user.preferences.notify_on_refill:
Notification( Notification(
user=self.customer.user, user=self.customer.user,
@@ -811,10 +814,6 @@ class SellingQuerySet(models.QuerySet):
class Selling(models.Model): class Selling(models.Model):
"""Handle the sellings.""" """Handle the sellings."""
class PaymentMethod(models.IntegerChoices):
SITH_ACCOUNT = 0, _("Sith account")
CARD = 1, _("Credit card")
# We make sure that sellings have a way begger label than any product name is allowed to # We make sure that sellings have a way begger label than any product name is allowed to
label = models.CharField(_("label"), max_length=128) label = models.CharField(_("label"), max_length=128)
product = models.ForeignKey( product = models.ForeignKey(
@@ -851,9 +850,13 @@ class Selling(models.Model):
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
) )
date = models.DateTimeField(_("date"), db_index=True) date = models.DateTimeField(_("date"), db_index=True)
payment_method = models.PositiveSmallIntegerField( payment_method = models.CharField(
_("payment method"), choices=PaymentMethod, default=PaymentMethod.SITH_ACCOUNT _("payment method"),
max_length=255,
choices=[("SITH_ACCOUNT", _("Sith account")), ("CARD", _("Credit card"))],
default="SITH_ACCOUNT",
) )
is_validated = models.BooleanField(_("is validated"), default=False)
objects = SellingQuerySet.as_manager() objects = SellingQuerySet.as_manager()
@@ -872,12 +875,10 @@ class Selling(models.Model):
if not self.date: if not self.date:
self.date = timezone.now() self.date = timezone.now()
self.full_clean() self.full_clean()
if ( if not self.is_validated:
self._state.adding
and self.payment_method == self.PaymentMethod.SITH_ACCOUNT
):
self.customer.amount -= self.quantity * self.unit_price self.customer.amount -= self.quantity * self.unit_price
self.customer.save(allow_negative=allow_negative) self.customer.save(allow_negative=allow_negative)
self.is_validated = True
user = self.customer.user user = self.customer.user
if user.was_subscribed: if user.was_subscribed:
if ( if (
@@ -947,9 +948,7 @@ class Selling(models.Model):
def is_owned_by(self, user: User) -> bool: def is_owned_by(self, user: User) -> bool:
if user.is_anonymous: if user.is_anonymous:
return False return False
return self.payment_method != self.PaymentMethod.CARD and user.is_owner( return self.payment_method != "CARD" and user.is_owner(self.counter)
self.counter
)
def can_be_viewed_by(self, user: User) -> bool: def can_be_viewed_by(self, user: User) -> bool:
if ( if (
@@ -959,7 +958,7 @@ class Selling(models.Model):
return user == self.customer.user return user == self.customer.user
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
if self.payment_method == Selling.PaymentMethod.SITH_ACCOUNT: if self.payment_method == "SITH_ACCOUNT":
self.customer.amount += self.quantity * self.unit_price self.customer.amount += self.quantity * self.unit_price
self.customer.save() self.customer.save()
super().delete(*args, **kwargs) super().delete(*args, **kwargs)

View File

@@ -1,4 +1,3 @@
from datetime import datetime
from typing import Annotated, Self from typing import Annotated, Self
from annotated_types import MinLen from annotated_types import MinLen
@@ -101,10 +100,3 @@ class ProductFilterSchema(FilterSchema):
product_type: set[int] | None = Field(None, q="product_type__in") product_type: set[int] | None = Field(None, q="product_type__in")
club: set[int] | None = Field(None, q="club__in") club: set[int] | None = Field(None, q="club__in")
counter: set[int] | None = Field(None, q="counters__in") counter: set[int] | None = Field(None, q="counters__in")
class SaleFilterSchema(FilterSchema):
before: datetime | None = Field(None, q="date__lt")
after: datetime | None = Field(None, q="date__gt")
counters: set[int] | None = Field(None, q="counter__in")
products: set[int] | None = Field(None, q="product__in")

View File

@@ -116,6 +116,7 @@ class TestAccountDumpCommand(TestAccountDump):
operation: Selling = customer.buyings.order_by("date").last() operation: Selling = customer.buyings.order_by("date").last()
assert operation.unit_price == initial_amount assert operation.unit_price == initial_amount
assert operation.counter_id == settings.SITH_COUNTER_ACCOUNT_DUMP_ID assert operation.counter_id == settings.SITH_COUNTER_ACCOUNT_DUMP_ID
assert operation.is_validated is True
dump = customer.dumps.last() dump = customer.dumps.last()
assert dump.dump_operation == operation assert dump.dump_operation == operation

View File

@@ -53,7 +53,7 @@ def set_age(user: User, age: int):
def force_refill_user(user: User, amount: Decimal | int): def force_refill_user(user: User, amount: Decimal | int):
baker.make(Refilling, amount=amount, customer=user.customer) baker.make(Refilling, amount=amount, customer=user.customer, is_validated=False)
class TestFullClickBase(TestCase): class TestFullClickBase(TestCase):
@@ -115,10 +115,18 @@ class TestRefilling(TestFullClickBase):
) -> HttpResponse: ) -> HttpResponse:
used_client = client if client is not None else self.client used_client = client if client is not None else self.client
return used_client.post( return used_client.post(
reverse("counter:refilling_create", kwargs={"customer_id": user.pk}), reverse(
{"amount": str(amount), "payment_method": Refilling.PaymentMethod.CASH}, "counter:refilling_create",
kwargs={"customer_id": user.pk},
),
{
"amount": str(amount),
"payment_method": "CASH",
"bank": "OTHER",
},
HTTP_REFERER=reverse( HTTP_REFERER=reverse(
"counter:click", kwargs={"counter_id": counter.id, "user_id": user.pk} "counter:click",
kwargs={"counter_id": counter.id, "user_id": user.pk},
), ),
) )
@@ -141,7 +149,11 @@ class TestRefilling(TestFullClickBase):
"counter:refilling_create", "counter:refilling_create",
kwargs={"customer_id": self.customer.pk}, kwargs={"customer_id": self.customer.pk},
), ),
{"amount": "10", "payment_method": "CASH"}, {
"amount": "10",
"payment_method": "CASH",
"bank": "OTHER",
},
) )
self.client.force_login(self.club_admin) self.client.force_login(self.club_admin)

View File

@@ -298,6 +298,7 @@ def test_update_balance():
_quantity=len(customers), _quantity=len(customers),
unit_price=10, unit_price=10,
quantity=1, quantity=1,
payment_method="SITH_ACCOUNT",
_save_related=True, _save_related=True,
), ),
*sale_recipe.prepare( *sale_recipe.prepare(
@@ -305,12 +306,14 @@ def test_update_balance():
_quantity=3, _quantity=3,
unit_price=5, unit_price=5,
quantity=2, quantity=2,
payment_method="SITH_ACCOUNT",
_save_related=True, _save_related=True,
), ),
sale_recipe.prepare( sale_recipe.prepare(
customer=customers[4], customer=customers[4],
quantity=1, quantity=1,
unit_price=50, unit_price=50,
payment_method="SITH_ACCOUNT",
_save_related=True, _save_related=True,
), ),
*sale_recipe.prepare( *sale_recipe.prepare(
@@ -321,7 +324,7 @@ def test_update_balance():
_quantity=len(customers), _quantity=len(customers),
unit_price=50, unit_price=50,
quantity=1, quantity=1,
payment_method=Selling.PaymentMethod.CARD, payment_method="CARD",
_save_related=True, _save_related=True,
), ),
] ]

View File

@@ -3,9 +3,11 @@ from django.conf import settings
from django.test import Client from django.test import Client
from django.urls import reverse from django.urls import reverse
from model_bakery import baker, seq from model_bakery import baker, seq
from ninja_extra.testing import TestClient
from core.baker_recipes import board_user, subscriber_user from core.baker_recipes import board_user, subscriber_user
from core.models import Group, User from core.models import Group, User
from counter.api import ProductTypeController
from counter.models import ProductType from counter.models import ProductType
@@ -17,43 +19,24 @@ def product_types(db) -> list[ProductType]:
return baker.make(ProductType, _quantity=5, order=seq(0)) return baker.make(ProductType, _quantity=5, order=seq(0))
@pytest.fixture()
def counter_admin_client(db, client: Client) -> Client:
client.force_login(
baker.make(
User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)]
)
)
return client
@pytest.mark.django_db @pytest.mark.django_db
def test_fetch_product_types( def test_fetch_product_types(product_types: list[ProductType]):
counter_admin_client: Client, product_types: list[ProductType]
):
"""Test that the API returns the right products in the right order""" """Test that the API returns the right products in the right order"""
response = counter_admin_client.get(reverse("api:fetch_product_types")) client = TestClient(ProductTypeController)
response = client.get("")
assert response.status_code == 200 assert response.status_code == 200
assert [i["id"] for i in response.json()] == [t.id for t in product_types] assert [i["id"] for i in response.json()] == [t.id for t in product_types]
@pytest.mark.django_db @pytest.mark.django_db
def test_move_below_product_type( def test_move_below_product_type(product_types: list[ProductType]):
counter_admin_client: Client, product_types: list[ProductType]
):
"""Test that moving a product below another works""" """Test that moving a product below another works"""
response = counter_admin_client.patch( client = TestClient(ProductTypeController)
reverse( response = client.patch(
"api:reorder_product_type", f"/{product_types[-1].id}/move", query={"below": product_types[0].id}
kwargs={"type_id": product_types[-1].id},
query={"below": product_types[0].id},
),
) )
assert response.status_code == 200 assert response.status_code == 200
new_order = [ new_order = [i["id"] for i in client.get("").json()]
i["id"]
for i in counter_admin_client.get(reverse("api:fetch_product_types")).json()
]
assert new_order == [ assert new_order == [
product_types[0].id, product_types[0].id,
product_types[-1].id, product_types[-1].id,
@@ -62,22 +45,14 @@ def test_move_below_product_type(
@pytest.mark.django_db @pytest.mark.django_db
def test_move_above_product_type( def test_move_above_product_type(product_types: list[ProductType]):
counter_admin_client: Client, product_types: list[ProductType]
):
"""Test that moving a product above another works""" """Test that moving a product above another works"""
response = counter_admin_client.patch( client = TestClient(ProductTypeController)
reverse( response = client.patch(
"api:reorder_product_type", f"/{product_types[1].id}/move", query={"above": product_types[0].id}
kwargs={"type_id": product_types[1].id},
query={"above": product_types[0].id},
),
) )
assert response.status_code == 200 assert response.status_code == 200
new_order = [ new_order = [i["id"] for i in client.get("").json()]
i["id"]
for i in counter_admin_client.get(reverse("api:fetch_product_types")).json()
]
assert new_order == [ assert new_order == [
product_types[1].id, product_types[1].id,
product_types[0].id, product_types[0].id,

View File

@@ -67,13 +67,15 @@ class InvoiceCallView(
end_date = start_date + relativedelta(months=1) end_date = start_date + relativedelta(months=1)
kwargs["sum_cb"] = Refilling.objects.filter( kwargs["sum_cb"] = Refilling.objects.filter(
payment_method=Refilling.PaymentMethod.CARD, payment_method="CARD",
is_validated=True,
date__gte=start_date, date__gte=start_date,
date__lte=end_date, date__lte=end_date,
).aggregate(res=Sum("amount", default=0))["res"] ).aggregate(res=Sum("amount", default=0))["res"]
kwargs["sum_cb"] += ( kwargs["sum_cb"] += (
Selling.objects.filter( Selling.objects.filter(
payment_method=Selling.PaymentMethod.CARD, payment_method="CARD",
is_validated=True,
date__gte=start_date, date__gte=start_date,
date__lte=end_date, date__lte=end_date,
) )

View File

@@ -182,19 +182,29 @@ ainsi même que de l'héritage de templates.
si on souhaite faire des modifications côté client, si on souhaite faire des modifications côté client,
il faut utiliser du Javascript, rien ne change à ce niveau-là. il faut utiliser du Javascript, rien ne change à ce niveau-là.
### Typescript ### jQuery
[Site officiel](https://www.typescriptlang.org/) [Site officiel](https://jquery.com/)
Pour rendre le site interactif, nous n'utilisons jQuery est une bibliothèque JavaScript
pas directement Javascript, mais Typescript. libre et multiplateforme créée pour faciliter
Il s'agit d'un langage construit par-dessus Javascript, l'écriture de scripts côté client
en ajoutant un typage statique et des éléments de sucre syntaxique. dans le code HTML des pages web.
Grâce au système de type, le code est plus lisible, La première version est lancée en janvier 2006 par John Resig.
à la fois par les humains et par l'IDE, et plus fiable.
Il faut parfois se battre un peu contre le système de types de Typescript, C'est une vieille technologie et certains
mais globalement Typescript est une alternative largement préférable à Javascript. feront remarquer à juste titre que le Javascript
moderne permet d'utiliser assez simplement
la majorité de ce que fournit jQuery
sans rien avoir à installer.
Cependant, de nombreuses dépendances du projet
utilisent encore jQuery qui est toujours
très implanté aujourd'hui.
Le sucre syntaxique qu'offre cette librairie
reste très agréable à utiliser et économise
parfois beaucoup de temps.
Ça fonctionne et ça fonctionne très bien.
C'est maintenu et pratique.
### AlpineJS ### AlpineJS
@@ -260,6 +270,17 @@ sur tous les navigateurs contrairement
à un simple icône unicode qui s'affiche à un simple icône unicode qui s'affiche
lui différemment selon la plate-forme. lui différemment selon la plate-forme.
!!!note
C'est une dépendance capricieuse qui évolue très vite
et qu'il faut très souvent mettre à jour.
!!!warning
Il a été décidé de **ne pas utiliser**
de CDN puisque le site ralentissait régulièrement.
Il est préférable de fournir cette dépendance avec le site.
## Workflow ## Workflow
### Git ### Git

View File

@@ -1,4 +1,4 @@
L'ORM de Django est puissant, très puissant, non pas parce qu'il L'ORM de Django est puissant, très puissant, non par parce qu'il
est performant (après tout, ce n'est qu'une interface, le gros du boulot, est performant (après tout, ce n'est qu'une interface, le gros du boulot,
c'est la db qui le fait), mais parce qu'il permet d'écrire c'est la db qui le fait), mais parce qu'il permet d'écrire
de manière relativement simple un grand panel de requêtes. de manière relativement simple un grand panel de requêtes.

View File

@@ -51,7 +51,7 @@ Pour accéder au fichier, il faut utiliser `static` comme pour le reste mais en
Le bundler ne génère que des modules javascript. Le bundler ne génère que des modules javascript.
Ajouter `type="module"` n'est pas optionnel ! Ajouter `type="module"` n'est pas optionnel !
### Les imports au sein des fichiers javascript bundlés ### Les imports au sein des fichiers des fichiers javascript bundlés
Pour importer au sein d'un fichier js bundlé, il faut préfixer ses imports de `#app:`. Pour importer au sein d'un fichier js bundlé, il faut préfixer ses imports de `#app:`.

View File

@@ -36,4 +36,11 @@ SITH_SUBSCRIPTIONS = {
} }
``` ```
Après ça, n'oubliez pas de gérer les traductions (cf. [ici](./translation.md)) Une fois ceci fait, il faut créer une nouvelle migration :
```bash
python ./manage.py makemigrations subscription
python ./manage.py migrate
```
N'oubliez pas non plus les traductions (cf. [ici](./translation.md))

View File

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

View File

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

View File

@@ -17,6 +17,7 @@
- can_edit_prop - can_edit_prop
- can_edit - can_edit
- can_view - can_view
- CanCreateMixin
- CanEditMixin - CanEditMixin
- CanViewMixin - CanViewMixin
- CanEditPropMixin - CanEditPropMixin

View File

@@ -0,0 +1,353 @@
Le site AE offre des mécanismes permettant aux applications tierces
de récupérer les informations sur un utilisateur du site AE.
De cette manière, il devient possible de synchroniser les informations
qu possède l'application tierce sur l'utilisateur, directement depuis
le site AE.
## Fonctionnement général
Pour authentifier vos utilisateurs, vous aurez besoin d'un serveur web
et d'un client d'API (celui auquel est liée votre
[clef d'API](./connect.md#obtenir-une-clef-dapi)).
Deux informations vous sont nécessaires, en plus de votre clef d'API :
- l'id du client : vous pouvez l'obtenir soit en le demandant à l'équipe info,
soit en appelant la route `GET /client/me` avec votre clef d'API
renseignée dans le header [X-APIKey](./connect.md#x-apikey)
- la clef HMAC du client : vous devez la demander à l'équipe info.
Grâce à ces informations, vous allez pouvoir fournir le contexte nécessaire
au site AE pour qu'il authentifie vos utilisateurs.
En effet, la démarche d'authentification s'effectue presque entièrement
sur le site : le travail de l'application tierce consiste uniquement
à fournir à l'utilisateur une url avec les bons paramètres, puis
à recevoir la réponse du serveur si tout s'est bien passé.
Comme un dessin vaut parfois mieux que mille mots,
voici les diagrammes décrivant le processus.
L'un montre l'entièreté de la démarche ;
l'autre dans un souci de simplicité, ne montre que ce qui est visible
directement par l'application tierce.
=== "Intégralité du processus"
```mermaid
sequenceDiagram
actor User
participant App
User->>+App: Authentifie-moi, stp
App-->>-User: url de connexion<br/>avec signature
User->>+Sith: GET url
opt Utilisateur non-connecté
Sith->>+User: Formulaire de connexion
User-->>-Sith: Connexion
end
Sith->>Sith: vérification de la signature
Sith->>+User: Formulaire<br/>des conditions<br/>d'utilisation
User-->>-Sith: Validation
Sith->>+App: URL de retour<br/>avec données utilisateur
App->>App: Traitement des <br/>données utilisateur
App-->>-Sith: 204 OK, No content
Sith-->>-User: Message de succès
App--)User: Message de succès
```
=== "Point de vue de l'application tierce"
```mermaid
sequenceDiagram
actor User
participant App
User->>+App: Authentifie-moi, stp
App-->>-User: url de connexion<br/>avec signature
opt
Sith->>+App: URL de retour<br/>avec données utilisateur
App->>App: Traitement des <br/>données utilisateur
App-->>-Sith: 204 OK, No content
App--)User: Message de succès
end
```
## Données attendues
### URL de connexion
L'URL de connexion que vous allez fournir à l'utilisateur doit
être `https://ae.utbm.fr/api-link/auth/`
et doit contenir les données décrites dans
[`ThirdPartyAuthParamsSchema`][api.schemas.ThirdPartyAuthParamsSchema] :
- `client_id` (integer) : l'id de votre client, que vous pouvez obtenir
de la manière décrite plus haut
- `third_party_app`(string) : le nom de la plateforme pour laquelle
l'authentification va être réalisée (si votre application est un bot
discord, mettez la valeur "discord")
- `privacy_link`(URL) : l'URL vers la page de politique de confidentialité
qui s'appliquera dans le cadre de l'application
(s'il s'agit d'un bot discord, donnez le lien vers celles de Discord)
- `username`(string) : le pseudonyme que l'utilisateur possède sur
votre application
- `callback_url`(URL) : l'URL que le site AE appellera si l'authentification
réussit
- `signature`(string) : la signature des données de la requête.
Ces données doivent être url-encodées et passées dans les paramètres GET.
!!!tip "URL de retour"
Notre système n'impose aucune contrainte quant à la manière
de construire votre URL (hormis le fait que ce doit être une URL HTTPS valide),
mais il est tout de même conseillé d'utiliser l'identifiant de votre
utilisateur comme paramètre dans l'URL
(par exemple `GET /callback/{int:user_id}/`).
???Example
Supposons que votre client d'API soit utilisé dans le cadre d'un bot Discord,
avec les données suivantes :
- l'id du client est 15
- sa clef HMAC est "beb99dd53"
(c'est pour l'exemple, une vraie clef sera beaucoup plus longue)
- le pseudonyme discord de votre utilisateur est Brian
- son id sur discord est 123456789
- votre route de callback est `GET /callback/{int:user_id}/`,
accessible au domaine `https://bot.ae.utbm.fr`
Alors les paramètres de votre URL seront :
| Paramètre | valeur |
|-----------------|-----------------------------------------------------------------------|
| client_id | 15 |
| third_party_app | discord |
| privacy_link | `https://discord.com/privacy` |
| username | Brian |
| callback_url | `https://bot.ae.utbm.fr/callback/123456789/` |
| signature | 1a383c51060be64f07772aa42e07<br/>18ae096b8f21f2cdb4061c0834a416d12101 |
Et l'url fournie à l'utilisateur sera :
`https://ae.utbm.fr/api-link/auth/?client_id=15&third_party_app=discord
&privacy_link=https%3A%2F%2Fdiscord.com%2Fprivacy&username=Brian
&callback_url=https%3A%2F%2Fbot.ae.utbm.fr%2Fcallback%2F123456789%2F
&signature=1a383c51060be64f07772aa42e0718ae096b8f21f2cdb4061c0834a416d12101`
### Données de retour
Si l'authentification réussit, le site AE enverra une requête HTTP POST
à l'URL de retour fournie dans l'URL de connexion.
Le corps de la requête de callback et au format JSON
et contient deux paires clef-valeur :
- `user` : les données utilisateur, telles que décrites
par [UserProfileSchema][core.schemas.UserProfileSchema]
- `signature` : la signature des données utilisateur
???Example
En reprenant les mêmes paramètres que dans l'exemple précédent,
le site AE pourra renvoyer à l'application la requête suivante :
```http
POST https://bot.ae.utbm.fr/callback/123456789/
content-type: application/json
body: {
"user": {
"id": 144131,
"nick_name": "inzekitchen",
"first_name": "Brian",
...
},
"signature": "f16955bab6b805f6e1abbb98a86dfee53fed0bf812aa6513ca46cfd461b70020"
}
```
L'application doit répondre avec un des codes HTTP suivants :
| Code | Raison |
|------|--------------------------------------------------------------------------------|
| 204 | Tout s'est bien passé |
| 403 | Les données de retour ne sont <br>pas signées ou sont mal signées |
| 404 | L'URL de retour ne permet pas <br>d'identifier un utilisateur de l'application |
!!!note "Code d'erreur par défaut"
Si l'appel de la route fait face à plusieurs problèmes en même temps
(par exemple, l'URL ne permet pas de retrouver votre utilisateur,
et en plus les données sont mal signées),
le 403 prime et doit être retourné par défaut.
## Signature des données
Les données de l'URL de connexion doivent être signées,
et la signature de l'URL de retour doit être vérifiée.
Dans le deux cas, la signature est le digest HMAC-SHA512
des données url-encodées, en utilisant la clef HMAC du client d'API.
???Example "Signature de l'URL de connexion"
En reprenant le même exemple que les fois précédentes,
l'url-encodage des données est :
`client_id=15&third_party_app=discord
&privacy_link=https%3A%2F%2Fdiscord.com%2Fprivacy%2F&username=Brian
&callback_url=https%3A%2F%2Fbot.ae.utbm.fr%2Fcallback%2F123456789%2F`
Notez que la signature n'est pas (encore) dedans.
Cette dernière peut-être obtenue avec le code suivant :
=== ":simple-python: Python"
Dépendances :
- `environs` (>=14.1)
```python
import hmac
from urllib.parse import urlencode
from environs import Env
env = Env()
env.read_env()
key = env.str("HMAC_KEY").encode()
data = {
"client_id": 15,
"third_party_app": "discord",
"privacy_link": "https://discord.com/privacy/",
"username": "Brian",
"callback_url": "https://bot.ae.utbm.fr/callback/123456789/",
}
urlencoded = urlencode(data)
data["signature"] = hmac.digest(key, urlencoded.encode(), "sha512").hex()
# URL a fournir à l'utilisateur pour son authentification
user_url = f"https://ae.ubtm.fr/api-link/auth/?{urlencode(data)}"
```
=== ":simple-rust: Rust"
Dépendances :
- `hmac` (>=0.12.1)
- `url` (>=2.5.7, features `serde`)
- `serde` (>=1.0.228, features `derive`)
- `serde_urlencoded` (>="0.7.1)
- `sha2` (>=0.10.9)
- `dotenvy` (>= 0.15)
```rust
use hmac::{Mac, SimpleHmac};
use serde::Serialize;
use sha2::Sha512;
use url::Url;
#[derive(Serialize, Debug)]
struct UrlData<'a> {
client_id: u32,
third_party_app: &'a str,
privacy_link: Url,
username: &'a str,
callback_url: Url,
}
impl<'a> UrlData<'a> {
pub fn signature(&self, key: &[u8]) -> CtOutput<SimpleHmac<Sha512>> {
let urlencoded = serde_urlencoded::to_string(self).unwrap();
SimpleHmac::<Sha512>::new_from_slice(key)
.unwrap()
.chain_update(urlencoded.as_bytes())
.finalize()
}
}
impl Into<Url> for UrlData<'_> {
fn into(self) -> Url {
let key = std::env::var("HMAC_KEY").unwrap();
let mut url = Url::parse("http://ae.utbm.fr/api-link/auth/").unwrap();
url.set_query(Some(
format!(
"{}&signature={:x}",
serde_urlencoded::to_string(&self).unwrap(),
self.signature(key.as_bytes()).into_bytes()
)
.as_str(),
));
url
}
}
fn main() {
dotenvy::dotenv().expect("Couldn't load env");
let data = UrlData {
client_id: 1,
third_party_app: "discord",
privacy_link: "https://discord.com/privacy/".parse().unwrap(),
username: "Brian",
callback_url: "https://bot.ae.utbm.fr/callback/123456789/"
.parse()
.unwrap(),
};
let url: Url = data.into();
println!("{:?}", url);
}
```
???Example "Vérification de la signature de la réponse"
Les données utilisateur peuvent ressembler à :
```json
{
"user": {
"display_name": "Matthieu Vincent",
"profile_url": "/user/380/",
"profile_pict": "/static/core/img/unknown.jpg",
"id": 380,
"nick_name": None,
"first_name": "Matthieu",
"last_name": "Vincent",
},
"signature": "3802a280fbb01bd9fetc."
}
```
Vous pouvez vérifier la signature ainsi :
```python
import hmac
from urllib.parse import urlencode
from environs import Env
env = Env()
env.read_env()
def is_signature_valid(user_data: dict, signature: str) -> bool:
key = env.str("HMAC_KEY").encode()
urlencoded = urlencode(user_data)
return hmac.compare_digest(
hmac.digest(key, urlencoded.encode(), "sha512").hex(),
signature,
)
post_data = <récupération des données POST>
print(
"signature valide :",
is_signature_valid(post_data["user"], post_data["signature"]
)
```
!!!Warning
Vous devez impérativement vérifier la signature
des données de la requête de callback !
Si l'équipe informatique se rend compte que vous ne le faites pas,
elle se réserve le droit de suspendre votre application,
immédiatement et sans préavis.

View File

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

View File

@@ -1,3 +1,4 @@
Pour l'API, nous utilisons `django-ninja` et sa surcouche `django-ninja-extra`. Pour l'API, nous utilisons `django-ninja` et sa surcouche `django-ninja-extra`.
Ce sont des librairies relativement simples et qui présentent Ce sont des librairies relativement simples et qui présentent
l'immense avantage d'offrir des mécanismes de validation et de sérialisation l'immense avantage d'offrir des mécanismes de validation et de sérialisation
@@ -48,9 +49,8 @@ Notre API offre deux moyens d'authentification :
- par clef d'API - par clef d'API
La plus grande partie des routes de l'API utilisent la méthode par cookie de session. La plus grande partie des routes de l'API utilisent la méthode par cookie de session.
Cette dernière est donc activée par défaut.
Pour changer la méthode d'authentification, Pour placer une route d'API derrière l'une de ces méthodes (ou bien les deux),
utilisez l'attribut `auth` et les classes `SessionAuth` et utilisez l'attribut `auth` et les classes `SessionAuth` et
[`ApiKeyAuth`][api.auth.ApiKeyAuth]. [`ApiKeyAuth`][api.auth.ApiKeyAuth].
@@ -60,17 +60,13 @@ utilisez l'attribut `auth` et les classes `SessionAuth` et
@api_controller("/foo") @api_controller("/foo")
class FooController(ControllerBase): class FooController(ControllerBase):
# Cette route sera accessible uniquement avec l'authentification # Cette route sera accessible uniquement avec l'authentification
# par clef d'API # par cookie de session
@route.get("", auth=[ApiKeyAuth()]) @route.get("", auth=[SessionAuth()])
def fetch_foo(self, club_id: int): ... def fetch_foo(self, club_id: int): ...
# Celle-ci sera accessible avec les deux méthodes d'authentification # Et celle-ci sera accessible peut importe la méthode d'authentification
@route.get("/bar", auth=[ApiKeyAuth(), SessionAuth()]) @route.get("/bar", auth=[SessionAuth(), ApiKeyAuth()])
def fetch_bar(self, club_id: int): ... def fetch_bar(self, club_id: int): ...
# Et celle-ci sera accessible aussi aux utilisateurs non-connectés
@route.get("/public", auth=None)
def fetch_public(self, club_id: int): ...
``` ```
### Permissions ### Permissions
@@ -83,7 +79,9 @@ par-dessus `django-ninja`, le système de permissions de django
et notre propre système. et notre propre système.
Cette dernière est documentée [ici](../perms.md). Cette dernière est documentée [ici](../perms.md).
### Incompatibilité avec certaines permissions ### Limites des clefs d'API
#### Incompatibilité avec certaines permissions
Le système des clefs d'API est apparu très tard dans l'histoire du site Le système des clefs d'API est apparu très tard dans l'histoire du site
(en P25, 10 ans après le début du développement). (en P25, 10 ans après le début du développement).
@@ -114,33 +112,10 @@ Les principaux points de friction sont :
- `IsLoggedInCounter`, qui utilise encore un autre système - `IsLoggedInCounter`, qui utilise encore un autre système
d'authentification maison et qui n'est pas fait pour être utilisé en dehors du site. d'authentification maison et qui n'est pas fait pour être utilisé en dehors du site.
### CSRF #### Incompatibilité avec les tokens csrf
!!!info "A propos du csrf" Le [CSRF (*cross-site request forgery*)](https://fr.wikipedia.org/wiki/Cross-site_request_forgery)
est un des multiples facteurs d'attaque sur le web.
Le [CSRF (*cross-site request forgery*)](https://fr.wikipedia.org/wiki/Cross-site_request_forgery)
est un vecteur d'attaque sur le web consistant
à soumettre des données au serveur à l'insu
de l'utilisateur, en profitant de sa session.
C'est une attaque qui peut se produire lorsque l'utilisateur
est authentifié par cookie de session.
En effet, les cookies sont joints automatiquement à
toutes les requêtes ;
en l'absence de protection contre le CSRF,
un attaquant parvenant à insérer un formulaire
dans la page de l'utilisateur serait en mesure
de faire presque n'importe quoi en son nom,
et ce sans même que l'utilisateur ni les administrateurs
ne s'en rendent compte avant qu'il ne soit largement trop tard !
Sur le CSRF et les moyens de s'en prémunir, voir :
- [https://owasp.org/www-community/attacks/csrf]()
- [https://security.stackexchange.com/questions/166724/should-i-use-csrf-protection-on-rest-api-endpoints]()
- [https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html]()
Le CSRF, c'est dangereux.
Heureusement, Django vient encore une fois à notre aide, Heureusement, Django vient encore une fois à notre aide,
avec des mécanismes intégrés pour s'en protéger. avec des mécanismes intégrés pour s'en protéger.
Ceux-ci incluent notamment un système de Ceux-ci incluent notamment un système de
@@ -148,39 +123,16 @@ Ceux-ci incluent notamment un système de
à fournir dans les requêtes POST/PUT/PATCH. à fournir dans les requêtes POST/PUT/PATCH.
Ceux-ci sont bien adaptés au cycle requêtes/réponses Ceux-ci sont bien adaptés au cycle requêtes/réponses
typiques de l'expérience utilisateur sur un navigateur, typique de l'expérience utilisateur sur un navigateur,
où les requêtes POST sont toujours effectuées après une requête où les requêtes POST sont toujours effectuées après une requête
GET au cours de laquelle on a pu récupérer un token csrf. GET au cours de laquelle on a pu récupérer un token csrf.
Cependant, ils sont également gênants et moins utiles Cependant, le flux des requêtes sur une API est bien différent ;
dans le cadre d'une API REST, étant donné de ce fait, il est à attendre que les requêtes POST envoyées à l'API
que l'authentification cesse d'être implicite : par un client externe n'aient pas de token CSRF et se retrouvent
la clef d'API doit être explicitement jointe aux headers, donc bloquées.
pour chaque requête.
Pour ces raisons, la vérification CSRF ne prend place Pour ces raisons, l'accès aux requêtes POST/PUT/PATCH de l'API
que pour la vérification de l'authentification par un client externe ne marche pas.
par cookie de session.
!!!warning "L'ordre est important"
Si vous écrivez le code suivant, l'authentification par clef d'API
ne marchera plus :
```python
@api_controller("/foo")
class FooController(ControllerBase):
@route.post("/bar", auth=[SessionAuth(), ApiKeyAuth()])
def post_bar(self, club_id: int): ...
```
En effet, la vérification du cookie de session intègrera
toujours la vérification CSRF.
Or, un échec de cette dernière est traduit par django en un code HTTP 403
au lieu d'un HTTP 401.
L'authentification se retrouve alors court-circuitée,
faisant que la vérification de la clef d'API ne sera jamais appelée.
`SessionAuth` doit donc être déclaré **après** `ApiKeyAuth`.
## Créer un client et une clef d'API ## Créer un client et une clef d'API
@@ -219,3 +171,5 @@ qui en a besoin.
Dites-lui bien de garder cette clef en lieu sûr ! Dites-lui bien de garder cette clef en lieu sûr !
Si la clef est perdue, il n'y a pas moyen de la récupérer, Si la clef est perdue, il n'y a pas moyen de la récupérer,
vous devrez en recréer une. vous devrez en recréer une.

View File

@@ -157,18 +157,16 @@ que sont VsCode et Sublime Text.
Si vous avez réussi à terminer l'installation, vous n'avez donc pas de configuration Si vous avez réussi à terminer l'installation, vous n'avez donc pas de configuration
supplémentaire à effectuer. supplémentaire à effectuer.
Pour utiliser Biome, placez-vous à la racine du projet et lancez la commande suivante: Pour utiliser Biome, placez-vous à la racine du projet et lancer la commande suivante:
```bash ```bash
npx @biomejs/biome check # Pour checker le code avec le linter et le formater npx @biomejs/biome check # Pour checker le code avec le linter et le formater
npx @biomejs/biome check --write # Pour appliquer les changements npx @biomejs/biome check --write # Pour appliquer les changemnts
``` ```
Biome va alors faire son travail sur l'ensemble du projet puis vous dire Biome va alors faire son travail sur l'ensemble du projet puis vous dire
si des documents ont été reformatés (si vous avez fait `npx @biomejs/biome format --write`) si des documents ont été reformatés (si vous avez fait `npx @biomejs/biome format --write`)
ou bien s'il y a des erreurs à réparer ou bien s'il y a des erreurs à réparer (si vous avez faire `npx @biomejs/biome lint`) ou les deux (si vous avez fait `npx @biomejs/biome check --write`).
(si vous avez fait `npx @biomejs/biome lint`)
ou les deux (si vous avez fait `npx @biomejs/biome check --write`).
Appeler Biome en ligne de commandes avant de pousser votre code sur Github Appeler Biome en ligne de commandes avant de pousser votre code sur Github
est une technique qui marche très bien. est une technique qui marche très bien.

View File

@@ -30,7 +30,7 @@ opérations, telles que la validation de formulaire.
En effet, valider un formulaire demande beaucoup En effet, valider un formulaire demande beaucoup
de travail de nettoyage des données et d'affichage de travail de nettoyage des données et d'affichage
des messages d'erreur appropriés. des messages d'erreur appropriés.
Or, tout ce travail existe déjà dans Django. Or, tout ce travail existe déjà dans django.
On veut donc, dans ces cas-là, ne pas demander On veut donc, dans ces cas-là, ne pas demander
toute une page HTML au serveur, mais uniquement toute une page HTML au serveur, mais uniquement
@@ -84,7 +84,7 @@ Grâce à ça, on peut écrire des vues qui
fonctionnent dans les deux contextes. fonctionnent dans les deux contextes.
Par exemple, supposons que nous avons Par exemple, supposons que nous avons
une `UpdateView` très simple, contenant une `EditView` très simple, contenant
uniquement un formulaire. uniquement un formulaire.
On peut écrire la vue et le template de la manière On peut écrire la vue et le template de la manière
suivante : suivante :
@@ -94,10 +94,8 @@ suivante :
```python ```python
from django.views.generic import UpdateView from django.views.generic import UpdateView
from core.views import AllowFragment
class FooUpdateView(UpdateView):
class FooUpdateView(AllowFragment, UpdateView):
model = Foo model = Foo
fields = ["foo", "bar"] fields = ["foo", "bar"]
pk_url_kwarg = "foo_id" pk_url_kwarg = "foo_id"
@@ -134,7 +132,7 @@ Dans ces situations, pouvoir décomposer une vue
en plusieurs vues de fragment permet de ne plus en plusieurs vues de fragment permet de ne plus
raisonner en termes de condition, mais en termes raisonner en termes de condition, mais en termes
de composition : on n'a pas un seul template de composition : on n'a pas un seul template
qui peut changer selon les situations, on a plusieurs qui peut changer les situations, on a plusieurs
templates que l'on injecte dans un template principal. templates que l'on injecte dans un template principal.
Supposons, par exemple, que nous n'avons plus un, Supposons, par exemple, que nous n'avons plus un,
@@ -240,10 +238,10 @@ qui se comportera alors comme une vue normale.
#### La méthode `as_fragment` #### La méthode `as_fragment`
Il est à noter que l'instanciation d'un fragment Il est à noter que l'instantiation d'un fragment
se fait en deux étapes : se fait en deux étapes :
- on commence par instancier la vue en tant que renderer. - on commence par instantier la vue en tant que renderer.
- on appelle le renderer en lui-même - on appelle le renderer en lui-même
Ce qui donne la syntaxe `Fragment.as_fragment()()`. Ce qui donne la syntaxe `Fragment.as_fragment()()`.

View File

@@ -76,7 +76,7 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
```bash ```bash
sudo pacman -Syu # on s'assure que les dépôts et le système sont à jour sudo pacman -Syu # on s'assure que les dépôts et le système sont à jour
sudo pacman -S uv gcc git gettext pkgconf npm valkey sudo pacman -S uv gcc git gettext pkgconf npm redis
``` ```
=== "macOS" === "macOS"

View File

@@ -212,7 +212,7 @@ Pour les vues sous forme de fonction, il y a le décorateur
obj = self.get_object() obj = self.get_object()
obj.is_moderated = True obj.is_moderated = True
obj.save() obj.save()
return redirect("com:news_list") return redirect(reverse("com:news_list"))
``` ```
=== "Function-based view" === "Function-based view"
@@ -233,7 +233,7 @@ Pour les vues sous forme de fonction, il y a le décorateur
news = get_object_or_404(News, id=news_id) news = get_object_or_404(News, id=news_id)
news.is_moderated = True news.is_moderated = True
news.save() news.save()
return redirect("com:news_list") return redirect(reverse("com:news_list"))
``` ```
## Accès à des éléments en particulier ## Accès à des éléments en particulier
@@ -447,9 +447,10 @@ l'utilisateur recevra une liste vide d'objet.
Voici un exemple d'utilisation en reprenant l'objet Article crée précédemment : Voici un exemple d'utilisation en reprenant l'objet Article crée précédemment :
```python ```python
from django.views.generic import DetailView from django.views.generic import CreateView, DetailView
from core.auth.mixins import CanViewMixin, CanCreateMixin
from core.auth.mixins import CanViewMixin
from com.models import WeekmailArticle from com.models import WeekmailArticle
@@ -458,15 +459,48 @@ from com.models import WeekmailArticle
# d'une classe de base pour fonctionner correctement. # d'une classe de base pour fonctionner correctement.
class ArticlesDetailView(CanViewMixin, DetailView): class ArticlesDetailView(CanViewMixin, DetailView):
model = WeekmailArticle model = WeekmailArticle
# Même chose pour une vue de création de l'objet Article
class ArticlesCreateView(CanCreateMixin, CreateView):
model = WeekmailArticle
``` ```
Les mixins suivants sont implémentés : Les mixins suivants sont implémentés :
- [CanCreateMixin][core.auth.mixins.CanCreateMixin] : l'utilisateur peut-il créer l'objet ?
Ce mixin existe, mais est déprécié et ne doit plus être utilisé !
- [CanEditPropMixin][core.auth.mixins.CanEditPropMixin] : l'utilisateur peut-il éditer les propriétés de l'objet ? - [CanEditPropMixin][core.auth.mixins.CanEditPropMixin] : l'utilisateur peut-il éditer les propriétés de l'objet ?
- [CanEditMixin][core.auth.mixins.CanEditMixin] : L'utilisateur peut-il éditer l'objet ? - [CanEditMixin][core.auth.mixins.CanEditMixin] : L'utilisateur peut-il éditer l'objet ?
- [CanViewMixin][core.auth.mixins.CanViewMixin] : L'utilisateur peut-il voir l'objet ? - [CanViewMixin][core.auth.mixins.CanViewMixin] : L'utilisateur peut-il voir l'objet ?
- [FormerSubscriberMixin][core.auth.mixins.FormerSubscriberMixin] : L'utilisateur a-t-il déjà été cotisant ? - [FormerSubscriberMixin][core.auth.mixins.FormerSubscriberMixin] : L'utilisateur a-t-il déjà été cotisant ?
!!!danger "CanCreateMixin"
L'usage de `CanCreateMixin` est dangereux et ne doit en aucun cas être
étendu.
La façon dont ce mixin marche est qu'il valide le formulaire
de création et crée l'objet sans le persister en base de données, puis
vérifie les droits sur cet objet non-persisté.
Le danger de ce système vient de multiples raisons :
- Les vérifications se faisant sur un objet non persisté,
l'utilisation de mécanismes nécessitant une persistance préalable
peut mener à des comportements indésirés, voire à des erreurs.
- Les développeurs de django ayant tendance à restreindre progressivement
les actions qui peuvent être faites sur des objets non-persistés,
les mises-à-jour de django deviennent plus compliquées.
- La vérification des droits ne se fait que dans les requêtes POST,
à la toute fin de la requête.
Tout ce qui arrive avant n'est absolument pas protégé.
Toute opération (même les suppressions et les créations) qui ont
lieu avant la persistance de l'objet seront appliquées,
même sans permission.
- Si un développeur du site fait l'erreur de surcharger
la méthode `form_valid` (ce qui est plutôt courant,
lorsqu'on veut accomplir certaines actions
quand un formulaire est valide), on peut se retrouver
dans une situation où l'objet est persisté sans aucune protection.
!!!danger "Performance" !!!danger "Performance"

View File

@@ -110,9 +110,7 @@ class Basket(models.Model):
)["total"] )["total"]
) )
def generate_sales( def generate_sales(self, counter, seller: User, payment_method: str):
self, counter, seller: User, payment_method: Selling.PaymentMethod
):
"""Generate a list of sold items corresponding to the items """Generate a list of sold items corresponding to the items
of this basket WITHOUT saving them NOR deleting the basket. of this basket WITHOUT saving them NOR deleting the basket.
@@ -253,7 +251,8 @@ class Invoice(models.Model):
customer=customer, customer=customer,
operator=self.user, operator=self.user,
amount=i.product_unit_price * i.quantity, amount=i.product_unit_price * i.quantity,
payment_method=Refilling.PaymentMethod.CARD, payment_method="CARD",
bank="OTHER",
date=self.date, date=self.date,
) )
new.save() new.save()
@@ -268,7 +267,8 @@ class Invoice(models.Model):
customer=customer, customer=customer,
unit_price=i.product_unit_price, unit_price=i.product_unit_price,
quantity=i.quantity, quantity=i.quantity,
payment_method=Selling.PaymentMethod.CARD, payment_method="CARD",
is_validated=True,
date=self.date, date=self.date,
) )
new.save() new.save()

Some files were not shown because too many files have changed in this diff Show More