Compare commits

..

79 Commits

Author SHA1 Message Date
imperosol a4a5757835 add club links to club list page 2026-05-13 10:49:56 +02:00
imperosol 3a1f4388fd fix: incorrect initial value for ClubSearchForm.club_status 2026-05-13 10:49:56 +02:00
imperosol 3ff61e3835 add og tags to club list page 2026-05-13 10:49:56 +02:00
imperosol 2de1f9f937 add translations 2026-05-13 10:49:56 +02:00
imperosol f5eac164ec display club links on club main page 2026-05-13 10:49:56 +02:00
imperosol 2b0c36c085 feat: club link management in club edit view 2026-05-13 10:49:56 +02:00
imperosol 74a7f4ffc9 generate club links in populate 2026-05-13 10:49:56 +02:00
imperosol b9b0c00b74 feat: add links to response of GET /api/club/{club_id} 2026-05-13 10:49:56 +02:00
imperosol 59847b3973 feat: ClubLink model 2026-05-13 10:49:56 +02:00
imperosol b7275bd843 improve main page style 2026-05-12 11:07:04 +02:00
thomas girod f0ac35370e Merge pull request #1323 from ae-utbm/club-role
Dynamic club roles
2026-05-12 11:04:09 +02:00
imperosol 54d261142d create default club roles on club creation 2026-05-12 09:49:40 +02:00
imperosol 8876c64f54 add forgotten check 2026-05-12 09:49:40 +02:00
imperosol a40c43204a exclude inactive roles from attributable roles 2026-05-12 09:45:02 +02:00
imperosol 44e8ab4fb9 put roles at the right place when they are created 2026-05-12 09:45:02 +02:00
imperosol c9caa3f324 add translations 2026-05-12 09:45:00 +02:00
imperosol aaaaeb204f remove settings.SITH_MAXIMUM_FREE_ROLE 2026-05-12 09:44:28 +02:00
imperosol fa190f6909 adapt club members pages to new club roles framework 2026-05-12 09:44:28 +02:00
imperosol 898ce48cc8 adapt tests to new club roles framework 2026-05-12 09:44:28 +02:00
imperosol 74d8a374b9 change on_delete constraint for club pages 2026-05-12 09:44:28 +02:00
imperosol 11d9e912e1 adapt populate and populate_more 2026-05-12 09:44:28 +02:00
imperosol 5cf54729ae add ClubRole model 2026-05-12 09:44:28 +02:00
Titouan 387e5fe0a3 Merge pull request #1372 from ae-utbm/update_subscription
delete unused subscriptions
2026-05-10 22:22:04 +02:00
TitouanDor c371a5cf7a modif django.po 2026-05-10 20:27:45 +02:00
TitouanDor 07eae232d0 delete unused sub 2026-05-10 20:27:24 +02:00
klmp200 e4fa9c3c4a Merge pull request #1378 from ae-utbm/fix-subscriptions
Fix subscription form
2026-05-10 14:13:37 +02:00
klmp200 5551fdc953 Apply review comments 2026-05-10 13:39:20 +02:00
klmp200 2d86ba67f4 Fix subscription form 2026-05-10 13:37:40 +02:00
thomas girod deb83ec08f Merge pull request #1380 from ae-utbm/fragment-subscription
refactor: use `FragmentMixin` for subscription fragments
2026-05-10 13:17:41 +02:00
imperosol 94cda48508 fix: shortcut SubscriptionNewUserForm.clean if there are errors 2026-05-10 10:27:12 +02:00
imperosol 7bd2f1da96 refactor: use FragmentMixin for subscription fragments 2026-05-08 13:47:34 +02:00
klmp200 e2d2eb7470 Merge pull request #1377 from ae-utbm/fix-component-nesting
Fix component nesting bug
2026-05-08 13:01:09 +02:00
thomas girod a18f316088 Merge pull request #1362 from ae-utbm/improve-style
Small style improvement for main page
2026-05-08 11:07:52 +02:00
thomas girod fee1bbc5a5 Merge pull request #1366 from ae-utbm/update-ci
update CI
2026-05-08 09:06:48 +02:00
klmp200 cb1a330983 Fix component nesting bug 2026-05-07 13:37:37 +02:00
klmp200 1cfeeefa56 Merge pull request #1376 from ae-utbm/fix-link-once
Fix crashes on *-once elements when called at bad timings
2026-05-06 23:23:02 +02:00
thomas girod 0308d1c887 Merge pull request #1374 from ae-utbm/fix-duplicated-price
fix: duplicated prices on counters
2026-05-06 23:22:35 +02:00
thomas girod 9abd16c8f6 Merge pull request #1375 from ae-utbm/fix-eboutic-js
fix: `this.$refs.basketManagementForm.getElementById is not a function`
2026-05-06 23:22:06 +02:00
klmp200 0405ef424d Fix crashes on *-once elements when called at bad timings 2026-05-06 23:19:35 +02:00
imperosol b79b7cbcf5 fix: this.$refs.basketManagementForm.getElementById is not a function 2026-05-06 23:06:06 +02:00
imperosol 2c259de22c fix: duplicated prices on counters 2026-05-06 23:05:14 +02:00
thomas girod 38f2b6aa7b Merge pull request #1367 from ae-utbm/rolldown-inject
refactor: use rolldown builtin `inject`
2026-05-06 18:21:34 +02:00
thomas girod 37345f1bc4 Merge pull request #1360 from ae-utbm/image_rotation
properly rotate SAS pictures
2026-05-06 18:21:23 +02:00
thomas girod 151d17aca1 Merge pull request #1373 from ae-utbm/localsorage-pictures
fix: `QuotaExceededError` on user pictures load
2026-05-06 13:51:00 +02:00
imperosol d123e5e35b fix: QuotaExceededError on user pictures load 2026-05-06 13:37:55 +02:00
imperosol 00f7afb937 add translations 2026-05-02 17:59:06 +02:00
imperosol 2dbf4cff05 add og tags to eboutic page 2026-05-02 17:30:11 +02:00
imperosol f88c061b02 scss-ify eboutic.css 2026-05-02 17:29:04 +02:00
imperosol 4bd248f827 add transition to user whitelist input 2026-05-02 17:28:47 +02:00
imperosol 2aa6eed2fc improve main page style 2026-05-02 17:28:46 +02:00
imperosol 7fec05820c test: Picture.generate_thumbnails 2026-05-02 17:25:14 +02:00
imperosol 381f1ac829 refactor: use rolldown builtin inject 2026-05-02 17:23:35 +02:00
imperosol 22e6c09c36 remove dead code 2026-05-01 23:20:25 +02:00
imperosol 399a3813f0 feat: rotate pictures with API+AlpineJS 2026-05-01 23:20:15 +02:00
imperosol 441a016025 refactor Picture.generate_thumbnails 2026-05-01 23:15:11 +02:00
imperosol 060dde78e7 add update date to SithFile model 2026-05-01 19:18:38 +02:00
klmp200 f19b3056ef Fix notifications on messages containing quotes 2026-05-01 18:59:58 +02:00
thomas girod 200f71a762 Merge pull request #1359 from ae-utbm/album-thumbnail
Automatically resize album thumbnail
2026-05-01 18:58:27 +02:00
imperosol facfa1be89 test thumbnail management of AlbumEditForm 2026-05-01 18:56:12 +02:00
imperosol 4b9a953a20 Automatically resize album thumbnail 2026-05-01 18:56:12 +02:00
imperosol 5c17337595 update CI 2026-04-30 19:02:19 +02:00
thomas girod da621f6d7e Merge pull request #1364 from ae-utbm/update-dep
Update dep
2026-04-30 10:23:53 +02:00
imperosol 6b2b4d347f resolve warnings 2026-04-30 10:15:13 +02:00
imperosol 26483825a1 update JS dependencies 2026-04-29 19:14:51 +02:00
imperosol 9ef592849c update python dependencies 2026-04-29 19:10:02 +02:00
thomas girod c329fb1d0e Merge pull request #1309 from ae-utbm/price
Product prices
2026-04-29 18:49:57 +02:00
imperosol ef611ce1a7 unify eboutic and regular counter price selection 2026-04-29 12:59:01 +02:00
imperosol fb23d24db7 directly work on group ids
add tests
2026-04-29 12:53:20 +02:00
imperosol 16ca6c3ead fix existing tests 2026-04-29 12:53:20 +02:00
imperosol f8f85c2778 add translations 2026-04-29 12:53:20 +02:00
imperosol cf40e941d4 adapt formulas to new price system 2026-04-29 12:53:20 +02:00
imperosol adeadeaf70 adapt products export to new price system 2026-04-29 12:53:20 +02:00
imperosol 40fce1609d Price formset in the Product edit page 2026-04-29 12:53:20 +02:00
imperosol 0f02d55318 use new price system in populate 2026-04-29 12:53:20 +02:00
imperosol 965309d76c remove Product.selling_price and Product.special_selling_price 2026-04-29 12:53:20 +02:00
imperosol 26c6ab4a9f use new price system in counters 2026-04-29 12:53:20 +02:00
imperosol 2613ede59d use new price system in the eboutic 2026-04-29 12:53:20 +02:00
imperosol 6634bd987d refactor InvoiceItem and BasketItem models 2026-04-29 12:53:20 +02:00
imperosol 1c6d7f435a Price model 2026-04-29 12:53:20 +02:00
147 changed files with 4923 additions and 4108 deletions
+16 -22
View File
@@ -12,7 +12,7 @@ runs:
steps:
- name: Install apt packages
if: ${{ inputs.full == 'true' }}
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
with:
packages: gettext
version: 1.0 # increment to reset cache
@@ -23,26 +23,29 @@ runs:
with:
redis-version: "7.x"
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
version: "0.5.14"
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: "Set up Python"
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- name: Restore cached virtualenv
uses: actions/cache/restore@v4
- name: Install uv
uses: astral-sh/setup-uv@v8.1.0
with:
version: "0.11.8"
enable-cache: false
cache-dependency-glob: "uv.lock"
- name: Restore cached virtualenv
uses: actions/cache@v5
with:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
path: .venv
key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
restore-keys: |
uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
uv-${{ runner.os }}
- name: Install dependencies
run: uv sync
run: uv sync --locked
shell: bash
- name: Install Xapian
@@ -50,15 +53,6 @@ runs:
run: uv run ./manage.py install_xapian
shell: bash
# compiling xapian accounts for almost the entirety of the virtualenv setup,
# so we save the virtual environment only on workflows where it has been installed
- name: Save cached virtualenv
if: ${{ inputs.full == 'true' }}
uses: actions/cache/save@v4
with:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
path: .venv
- name: Compile gettext messages
if: ${{ inputs.full == 'true' }}
run: uv run ./manage.py compilemessages
+4 -4
View File
@@ -18,8 +18,8 @@ jobs:
name: Launch pre-commits checks (ruff)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version-file: ".python-version"
- uses: pre-commit/action@v3.0.1
@@ -35,7 +35,7 @@ jobs:
pytest-mark: [not slow]
steps:
- name: Check out repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- uses: ./.github/actions/setup_project
with:
full: true
@@ -49,7 +49,7 @@ jobs:
uv run coverage report
uv run coverage html
- name: Archive code coverage results
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: coverage-report-${{ matrix.pytest-mark }}
path: coverage_report
+1 -3
View File
@@ -14,7 +14,7 @@ jobs:
steps:
- name: SSH Remote Commands
uses: appleboy/ssh-action@v1.1.0
uses: appleboy/ssh-action@v1.2.5
with:
# Proxy
proxy_host : ${{secrets.PROXY_HOST}}
@@ -29,8 +29,6 @@ jobs:
username : ${{secrets.USER}}
key: ${{secrets.KEY}}
script_stop: true
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
script: |
cd ${{secrets.SITH_PATH}}
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: ./.github/actions/setup_project
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@v3
- uses: actions/cache@v5
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
+1 -3
View File
@@ -13,7 +13,7 @@ jobs:
steps:
- name: SSH Remote Commands
uses: appleboy/ssh-action@v1.1.0
uses: appleboy/ssh-action@v1.2.5
with:
# Proxy
proxy_host : ${{secrets.PROXY_HOST}}
@@ -28,8 +28,6 @@ jobs:
username : ${{secrets.USER}}
key: ${{secrets.KEY}}
script_stop: true
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
script: |
cd ${{secrets.SITH_PATH}}
-9
View File
@@ -17,15 +17,6 @@ class ApiClientAdmin(admin.ModelAdmin):
"owner__nick_name",
)
autocomplete_fields = ("owner", "groups", "client_permissions")
readonly_fields = ("hmac_key",)
actions = ("reset_hmac_key",)
@admin.action(permissions=["change"], description=_("Reset HMAC key"))
def reset_hmac_key(self, _request: HttpRequest, queryset: QuerySet[ApiClient]):
objs = list(queryset)
for obj in objs:
obj.reset_hmac(commit=False)
ApiClient.objects.bulk_update(objs, fields=["hmac_key"])
@admin.register(ApiKey)
-16
View File
@@ -1,16 +0,0 @@
from ninja_extra import ControllerBase, api_controller, route
from api.auth import ApiKeyAuth
from api.schemas import ApiClientSchema
@api_controller("/client")
class ApiClientController(ControllerBase):
@route.get(
"/me",
auth=[ApiKeyAuth()],
response=ApiClientSchema,
url_name="api-client-infos",
)
def get_client_info(self):
return self.context.request.auth
-35
View File
@@ -1,35 +0,0 @@
from django import forms
from django.forms import HiddenInput
from django.utils.translation import gettext_lazy as _
class ThirdPartyAuthForm(forms.Form):
"""Form to complete to authenticate on the sith from a third-party app.
For the form to be valid, the user approve the EULA (french: CGU)
and give its username from the third-party app.
"""
cgu_accepted = forms.BooleanField(
required=True,
label=_("I have read and I accept the terms and conditions of use"),
error_messages={
"required": _("You must approve the terms and conditions of use.")
},
)
is_username_valid = forms.BooleanField(
required=True,
error_messages={"required": _("You must confirm that this is your username.")},
)
client_id = forms.IntegerField(widget=HiddenInput())
third_party_app = forms.CharField(widget=HiddenInput())
privacy_link = forms.URLField(widget=HiddenInput())
username = forms.CharField(widget=HiddenInput())
callback_url = forms.URLField(widget=HiddenInput())
signature = forms.CharField(widget=HiddenInput())
def __init__(self, *args, label_suffix: str = "", initial, **kwargs):
super().__init__(*args, label_suffix=label_suffix, initial=initial, **kwargs)
self.fields["is_username_valid"].label = _(
"I confirm that %(username)s is my username on %(app)s"
) % {"username": initial.get("username"), "app": initial.get("third_party_app")}
-19
View File
@@ -1,19 +0,0 @@
# Generated by Django 5.2.3 on 2025-10-26 10:15
from django.db import migrations, models
import api.models
class Migration(migrations.Migration):
dependencies = [("api", "0001_initial")]
operations = [
migrations.AddField(
model_name="apiclient",
name="hmac_key",
field=models.CharField(
default=api.models.get_hmac_key, max_length=128, verbose_name="HMAC Key"
),
),
]
+21 -32
View File
@@ -1,20 +1,13 @@
import secrets
from typing import Iterable
from django.contrib.auth.models import Permission
from django.db import models
from django.db.models import Q
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.utils.translation import pgettext_lazy
from core.models import Group, User
def get_hmac_key():
return secrets.token_hex(64)
class ApiClient(models.Model):
name = models.CharField(_("name"), max_length=64)
owner = models.ForeignKey(
@@ -33,10 +26,11 @@ class ApiClient(models.Model):
help_text=_("Specific permissions for this api client."),
related_name="clients",
)
hmac_key = models.CharField(_("HMAC Key"), max_length=128, default=get_hmac_key)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
_perm_cache: set[str] | None = None
class Meta:
verbose_name = _("api client")
verbose_name_plural = _("api clients")
@@ -44,38 +38,33 @@ class ApiClient(models.Model):
def __str__(self):
return self.name
@cached_property
def all_permissions(self) -> set[str]:
permissions = (
Permission.objects.filter(
Q(group__group__in=self.groups.all()) | Q(clients=self)
)
def has_perm(self, perm: str):
"""Return True if the client has the specified permission."""
if self._perm_cache is None:
group_permissions = (
Permission.objects.filter(group__group__in=self.groups.all())
.values_list("content_type__app_label", "codename")
.order_by()
)
return {f"{content_type}.{name}" for content_type, name in permissions}
client_permissions = self.client_permissions.values_list(
"content_type__app_label", "codename"
).order_by()
self._perm_cache = {
f"{content_type}.{name}"
for content_type, name in (*group_permissions, *client_permissions)
}
return perm in self._perm_cache
def has_perm(self, perm: str):
"""Return True if the client has the specified permission."""
return perm in self.all_permissions
def has_perms(self, perm_list: Iterable[str]) -> bool:
"""Return True if the client has each of the specified permissions."""
def has_perms(self, perm_list):
"""
Return True if the client has each of the specified permissions. If
object is passed, check if the client has all required perms for it.
"""
if not isinstance(perm_list, Iterable) or isinstance(perm_list, str):
raise ValueError("perm_list must be an iterable of permissions.")
return all(self.has_perm(perm) for perm in perm_list)
def reset_hmac(self, *, commit: bool = True) -> str:
"""Reset and return the HMAC key for this client.
Args:
commit: if True (the default), persist the new hmac in db.
"""
self.hmac_key = get_hmac_key()
if commit:
self.save()
return self.hmac_key
class ApiKey(models.Model):
PREFIX_LENGTH = 5
-23
View File
@@ -1,23 +0,0 @@
from ninja import ModelSchema, Schema
from pydantic import Field, HttpUrl
from api.models import ApiClient
from core.schemas import SimpleUserSchema
class ApiClientSchema(ModelSchema):
class Meta:
model = ApiClient
fields = ["id", "name"]
owner: SimpleUserSchema
permissions: list[str] = Field(alias="all_permissions")
class ThirdPartyAuthParamsSchema(Schema):
client_id: int
third_party_app: str
privacy_link: HttpUrl
username: str
callback_url: HttpUrl
signature: str
-32
View File
@@ -1,32 +0,0 @@
{% extends "core/base.jinja" %}
{% block content %}
<form method="post">
{% csrf_token %}
<h3>{% trans %}Confidentiality{% endtrans %}</h3>
<p>
{% trans trimmed app=third_party_app %}
By ticking this box and clicking on the send button, you
acknowledge and agree to provide {{ app }} with your
first name, last name, nickname and any other information
that was the third party app was explicitly authorized to fetch
and that it must have acknowledged to you, in a complete and accurate manner.
{% endtrans %}
</p>
<p class="margin-bottom">
{% trans trimmed app=third_party_app, privacy_link=third_party_cgu, sith_cgu_link=sith_cgu %}
The privacy policies of <a href="{{ privacy_link }}">{{ app }}</a>
and of <a href="{{ sith_cgu_link }}">the Students' Association</a>
applies as soon as the form is submitted.
{% endtrans %}
</p>
<div class="row">{{ form.cgu_accepted }} {{ form.cgu_accepted.label_tag() }}</div>
<br>
<h3 class="margin-bottom">{% trans %}Confirmation of identity{% endtrans %}</h3>
<div class="row margin-bottom">
{{ form.is_username_valid }} {{ form.is_username_valid.label_tag() }}
</div>
{% for field in form.hidden_fields() %}{{ field }}{% endfor %}
<input type="submit" class="btn btn-blue">
</form>
{% endblock %}
-24
View File
@@ -1,24 +0,0 @@
import pytest
from django.contrib.admin import AdminSite
from django.http import HttpRequest
from model_bakery import baker
from pytest_django.asserts import assertNumQueries
from api.admin import ApiClientAdmin
from api.models import ApiClient
@pytest.mark.django_db
def test_reset_hmac_action():
client_admin = ApiClientAdmin(ApiClient, AdminSite())
api_clients = baker.make(ApiClient, _quantity=4, _bulk_create=True)
old_hmac_keys = [c.hmac_key for c in api_clients]
with assertNumQueries(2):
qs = ApiClient.objects.filter(id__in=[c.id for c in api_clients[2:4]])
client_admin.reset_hmac_key(HttpRequest(), qs)
for c in api_clients:
c.refresh_from_db()
assert api_clients[0].hmac_key == old_hmac_keys[0]
assert api_clients[1].hmac_key == old_hmac_keys[1]
assert api_clients[2].hmac_key != old_hmac_keys[2]
assert api_clients[3].hmac_key != old_hmac_keys[3]
-18
View File
@@ -1,18 +0,0 @@
import pytest
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from api.hashers import generate_key
from api.models import ApiClient, ApiKey
from api.schemas import ApiClientSchema
@pytest.mark.django_db
def test_api_client_controller(client: Client):
key, hashed = generate_key()
api_client = baker.make(ApiClient)
baker.make(ApiKey, client=api_client, hashed_key=hashed)
res = client.get(reverse("api:api-client-infos"), headers={"X-APIKey": key})
assert res.status_code == 200
assert res.json() == ApiClientSchema.from_orm(api_client).model_dump()
-59
View File
@@ -1,59 +0,0 @@
import pytest
from django.contrib.auth.models import Permission
from django.test import TestCase
from model_bakery import baker
from api.models import ApiClient
from core.models import Group
class TestClientPermissions(TestCase):
@classmethod
def setUpTestData(cls):
cls.api_client = baker.make(ApiClient)
cls.perms = baker.make(Permission, _quantity=10, _bulk_create=True)
cls.api_client.groups.set(
[
baker.make(Group, permissions=cls.perms[0:3]),
baker.make(Group, permissions=cls.perms[3:5]),
]
)
cls.api_client.client_permissions.set(
[cls.perms[3], cls.perms[5], cls.perms[6], cls.perms[7]]
)
def test_all_permissions(self):
assert self.api_client.all_permissions == {
f"{p.content_type.app_label}.{p.codename}" for p in self.perms[0:8]
}
def test_has_perm(self):
assert self.api_client.has_perm(
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}"
)
assert not self.api_client.has_perm(
f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}"
)
def test_has_perms(self):
assert self.api_client.has_perms(
[
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}",
f"{self.perms[2].content_type.app_label}.{self.perms[2].codename}",
]
)
assert not self.api_client.has_perms(
[
f"{self.perms[1].content_type.app_label}.{self.perms[1].codename}",
f"{self.perms[9].content_type.app_label}.{self.perms[9].codename}",
],
)
@pytest.mark.django_db
def test_reset_hmac_key():
client = baker.make(ApiClient)
original_key = client.hmac_key
client.reset_hmac(commit=True)
assert len(client.hmac_key) == len(original_key)
assert client.hmac_key != original_key
-134
View File
@@ -1,134 +0,0 @@
from unittest import mock
from unittest.mock import Mock
from django.contrib.messages import Message, get_messages
from django.db.models import Max
from django.test import TestCase
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from api.models import ApiClient, get_hmac_key
from core.baker_recipes import subscriber_user
from core.schemas import UserProfileSchema
from core.utils import hmac_hexdigest
def mocked_post(*, ok: bool):
class MockedResponse(Mock):
@property
def ok(self):
return ok
def mocked():
return MockedResponse()
return mocked
class TestThirdPartyAuth(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = subscriber_user.make()
cls.api_client = baker.make(ApiClient)
def setUp(self):
self.query = {
"client_id": self.api_client.id,
"third_party_app": "app",
"privacy_link": "https://foobar.fr/",
"username": "bibou",
"callback_url": "https://callback.fr/",
}
self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query)
self.callback_data = {
"user": UserProfileSchema.from_orm(self.user).model_dump()
}
self.callback_data["signature"] = hmac_hexdigest(
self.api_client.hmac_key, self.callback_data["user"]
)
def test_auth_ok(self):
self.client.force_login(self.user)
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert res.status_code == 200
with mock.patch("requests.post", new_callable=mocked_post(ok=True)) as mocked:
res = self.client.post(
reverse("api-link:third-party-auth"),
data={"cgu_accepted": True, "is_username_valid": True, **self.query},
)
mocked.assert_called_once_with(
self.query["callback_url"], json=self.callback_data
)
assertRedirects(
res,
reverse("api-link:third-party-auth-result", kwargs={"result": "success"}),
)
def test_callback_error(self):
"""Test that the user see the failure page if the callback request failed."""
self.client.force_login(self.user)
with mock.patch("requests.post", new_callable=mocked_post(ok=False)) as mocked:
res = self.client.post(
reverse("api-link:third-party-auth"),
data={"cgu_accepted": True, "is_username_valid": True, **self.query},
)
mocked.assert_called_once_with(
self.query["callback_url"], json=self.callback_data
)
assertRedirects(
res,
reverse("api-link:third-party-auth-result", kwargs={"result": "failure"}),
)
def test_wrong_signature(self):
"""Test that a 403 is raised if the signature of the query is wrong."""
self.client.force_login(subscriber_user.make())
new_key = get_hmac_key()
del self.query["signature"]
self.query["signature"] = hmac_hexdigest(new_key, self.query)
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert list(get_messages(res.wsgi_request)) == [
Message(
level=40,
message=(
"La signature est incorrecte. "
"Nous ne pouvons pas garantir l'authenticité de la requête."
),
)
]
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.client.force_login(self.user)
self.query["client_id"] = ApiClient.objects.aggregate(res=Max("id"))["res"] + 1
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert list(get_messages(res.wsgi_request)) == [
Message(
level=40,
message="Les données fournies pour l'authentification sont incorrectes.",
)
]
def test_missing_parameter(self):
self.client.force_login(self.user)
del self.query["username"]
self.query["signature"] = hmac_hexdigest(self.api_client.hmac_key, self.query)
res = self.client.get(reverse("api-link:third-party-auth", query=self.query))
assert list(get_messages(res.wsgi_request)) == [
Message(
level=40,
message="Les données fournies pour l'authentification sont incorrectes.",
)
]
-15
View File
@@ -1,10 +1,6 @@
from django.urls import path, register_converter
from ninja.security import SessionAuth
from ninja_extra import NinjaExtraAPI
from api.views import ThirdPartyAuthResultView, ThirdPartyAuthView
from core.converters import ResultConverter
api = NinjaExtraAPI(
title="PICON",
description="Portail Interactif de Communication avec les Outils Numériques",
@@ -13,14 +9,3 @@ api = NinjaExtraAPI(
auth=[SessionAuth()],
)
api.auto_discover_controllers()
register_converter(ResultConverter, "res")
urlpatterns = [
path("auth/", ThirdPartyAuthView.as_view(), name="third-party-auth"),
path(
"auth/<res:result>/",
ThirdPartyAuthResultView.as_view(),
name="third-party-auth-result",
),
]
-146
View File
@@ -1,146 +0,0 @@
import hmac
from urllib.parse import unquote
import pydantic
import requests
import sentry_sdk
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import AccessMixin, LoginRequiredMixin
from django.shortcuts import render
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import FormView, TemplateView
from ninja_extra.shortcuts import get_object_or_none
from api.forms import ThirdPartyAuthForm
from api.models import ApiClient
from api.schemas import ThirdPartyAuthParamsSchema
from core.models import SithFile
from core.schemas import UserProfileSchema
from core.utils import hmac_hexdigest
class ThirdPartyAuthView(AccessMixin, FormView):
form_class = ThirdPartyAuthForm
template_name = "api/third_party/auth.jinja"
success_url = reverse_lazy("core:index")
def parse_params(self) -> ThirdPartyAuthParamsSchema | None:
"""Parse and check the authentication parameters.
If parsing fails, messages will be created using the django message
infrastructure.
Returns:
The parses parameters, or None if the parsing failed.
"""
# This is here rather than in ThirdPartyAuthForm because
# the given parameters and their signature are checked during both
# POST (for obvious reasons) and GET (in order not to make
# the user fill a form just to get an error he won't understand)
params = self.request.GET or self.request.POST
params = {key: unquote(val) for key, val in params.items()}
try:
params = ThirdPartyAuthParamsSchema(**params)
except pydantic.ValidationError:
messages.error(
self.request, _("The data provided for authentication is incorrect")
)
return None
client: ApiClient = get_object_or_none(ApiClient, id=params.client_id)
if not client:
messages.error(
self.request, _("The data provided for authentication is incorrect")
)
return None
if not hmac.compare_digest(
hmac_hexdigest(client.hmac_key, params.model_dump(exclude={"signature"})),
params.signature,
):
messages.error(
self.request,
_(
"The signature is incorrect. "
"We cannot ensure the provenance of the request."
),
)
return None
return params
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
self.params = self.parse_params()
if not self.params:
# if parameters parsing failed, shortcut the operation and display
# an empty page with just the error messages.
return render(request, "core/base.jinja")
return super().dispatch(request, *args, **kwargs)
def get(self, *args, **kwargs):
messages.warning(
self.request,
_(
"You are going to link your AE account and your %(app)s account. "
"Continue only if this page was opened from %(app)s."
)
% {"app": self.params.third_party_app},
)
return super().get(*args, **kwargs)
def get_initial(self):
return self.params.model_dump()
def form_valid(self, form):
client = ApiClient.objects.get(id=form.cleaned_data["client_id"])
user = UserProfileSchema.from_orm(self.request.user).model_dump()
data = {"user": user, "signature": hmac_hexdigest(client.hmac_key, user)}
try:
ok = requests.post(form.cleaned_data["callback_url"], json=data).ok
except requests.RequestException as e:
sentry_sdk.capture_exception(e)
ok = False
self.success_url = reverse(
"api-link:third-party-auth-result",
kwargs={"result": "success" if ok else "failure"},
)
return super().form_valid(form)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"third_party_app": self.params.third_party_app,
"third_party_cgu": self.params.privacy_link,
"sith_cgu": 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)
+43 -1
View File
@@ -13,8 +13,10 @@
#
#
from django.contrib import admin
from django.forms.models import ModelForm
from django.http import HttpRequest
from club.models import Club, Membership
from club.models import Club, ClubLink, ClubRole, LinkType, Membership
@admin.register(Club)
@@ -29,6 +31,31 @@ class ClubAdmin(admin.ModelAdmin):
"page",
)
def save_model(
self,
request: HttpRequest,
obj: Club,
form: ModelForm,
change: bool, # noqa: FBT001
):
super().save_model(request, obj, form, change)
if not change:
obj.create_default_roles()
@admin.register(ClubRole)
class ClubRoleAdmin(admin.ModelAdmin):
list_display = ("name", "club", "is_board", "is_presidency")
search_fields = ("name",)
autocomplete_fields = ("club",)
list_select_related = ("club",)
list_filter = (
"is_board",
"is_presidency",
("club", admin.RelatedOnlyFieldListFilter),
)
show_facets = admin.ModelAdmin.show_facets.ALWAYS
@admin.register(Membership)
class MembershipAdmin(admin.ModelAdmin):
@@ -40,3 +67,18 @@ class MembershipAdmin(admin.ModelAdmin):
"club__name",
)
autocomplete_fields = ("user",)
@admin.register(LinkType)
class LinkTypeAdmin(admin.ModelAdmin):
list_display = ("name", "url_base", "icon")
search_fields = ("name",)
@admin.register(ClubLink)
class ClubLinkAdmin(admin.ModelAdmin):
list_display = ("link_type", "club", "url")
list_select_related = ("link_type", "club")
autocomplete_fields = ("link_type", "club")
search_fields = ("link_type__name", "url")
list_filter = ("link_type", ("club", admin.RelatedOnlyFieldListFilter))
+4 -3
View File
@@ -37,10 +37,11 @@ class ClubController(ControllerBase):
)
def fetch_club(self, club_id: int):
prefetch = Prefetch(
"members", queryset=Membership.objects.ongoing().select_related("user")
"members",
queryset=Membership.objects.ongoing().select_related("user", "role"),
)
return self.get_object_or_exception(
Club.objects.prefetch_related(prefetch), id=club_id
Club.objects.prefetch_related(prefetch, "links"), id=club_id
)
@@ -59,5 +60,5 @@ class UserClubController(ControllerBase):
return (
Membership.objects.ongoing()
.filter(user=user)
.select_related("club", "user")
.select_related("club", "user", "role")
)
+59 -27
View File
@@ -23,13 +23,19 @@
#
from django import forms
from django.conf import settings
from django.db.models import Exists, OuterRef, Q
from django.db.models import Exists, OuterRef, Q, QuerySet
from django.db.models.functions import Lower
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from club.models import Club, Mailing, MailingSubscription, Membership
from club.models import (
Club,
ClubLink,
ClubRole,
Mailing,
MailingSubscription,
Membership,
)
from core.models import User
from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import (
@@ -40,6 +46,26 @@ from counter.models import Counter, Selling
from counter.schemas import SaleFilterSchema
class ClubLinkForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
class Meta:
model = ClubLink
fields = ["url", "name", "link_type"]
widgets = {
"url": forms.URLInput(
{"pattern": "https://.*", "placeholder": "https://monlien.com"}
),
"link_type": forms.HiddenInput(),
}
ClubLinkFormSet = forms.inlineformset_factory(
Club, ClubLink, ClubLinkForm, extra=0, can_delete_extra=False
)
class ClubEditForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
@@ -49,6 +75,20 @@ class ClubEditForm(forms.ModelForm):
fields = ["address", "logo", "short_description"]
widgets = {"short_description": forms.Textarea()}
def __init__(self, *args, prefix: str | None = None, instance=None, **kwargs):
super().__init__(*args, prefix=prefix, instance=instance, **kwargs)
self.link_formset = ClubLinkFormSet(
*args, instance=self.instance, prefix="link", **kwargs
)
def is_valid(self):
return super().is_valid() and self.link_formset.is_valid()
def save(self, commit=True): # noqa: FBT002
res = super().save(commit=commit)
self.link_formset.save(commit=commit)
return res
class ClubAdminEditForm(ClubEditForm):
admin_fields = ["name", "parent", "is_active"]
@@ -215,9 +255,7 @@ class ClubOldMemberForm(forms.Form):
def __init__(self, *args, user: User, club: Club, **kwargs):
super().__init__(*args, **kwargs)
self.fields["members_old"].queryset = (
Membership.objects.ongoing().filter(club=club).editable_by(user)
)
self.fields["members_old"].queryset = club.members.ongoing().editable_by(user)
class ClubMemberForm(forms.ModelForm):
@@ -235,19 +273,14 @@ class ClubMemberForm(forms.ModelForm):
self.request_user = request_user
self.request_user_membership = self.club.get_membership_for(self.request_user)
super().__init__(*args, **kwargs)
self.fields["role"].required = True
self.fields["role"].choices = [
(value, name)
for value, name in settings.SITH_CLUB_ROLES.items()
if value <= self.max_available_role
]
self.fields["role"].queryset = self.available_roles
self.instance.club = club
@property
def max_available_role(self):
"""The greatest role that will be obtainable with this form."""
def available_roles(self) -> QuerySet[ClubRole]:
"""The roles that will be obtainable with this form."""
# this is unreachable, because it will be overridden by subclasses
return -1 # pragma: no cover
return ClubRole.objects.none() # pragma: no cover
class ClubAddMemberForm(ClubMemberForm):
@@ -258,21 +291,22 @@ class ClubAddMemberForm(ClubMemberForm):
widgets = {"user": AutoCompleteSelectUser}
@cached_property
def max_available_role(self):
"""The greatest role that will be obtainable with this form.
def available_roles(self):
"""The roles that will be obtainable with this form.
Admins and the club president can attribute any role.
Board members can attribute roles lower than their own.
Other users cannot attribute roles with this form
"""
qs = self.club.roles.filter(is_active=True)
if self.request_user.has_perm("club.add_membership"):
return settings.SITH_CLUB_ROLES_ID["President"]
return qs.all()
membership = self.request_user_membership
if membership is None or membership.role <= settings.SITH_MAXIMUM_FREE_ROLE:
return -1
if membership.role == settings.SITH_CLUB_ROLES_ID["President"]:
return membership.role
return membership.role - 1
if membership is None or not membership.role.is_board:
return ClubRole.objects.none()
if membership.role.is_presidency:
return qs.all()
return qs.above_instance(membership.role)
def clean_user(self):
"""Check that the user is not trying to add a user already in the club.
@@ -296,13 +330,11 @@ class JoinClubForm(ClubMemberForm):
def __init__(self, *args, club: Club, request_user: User, **kwargs):
super().__init__(*args, club=club, request_user=request_user, **kwargs)
# this form doesn't manage the user who will join the club,
# so we must set this here to avoid errors
self.instance.user = self.request_user
@cached_property
def max_available_role(self):
return settings.SITH_MAXIMUM_FREE_ROLE
def available_roles(self):
return self.club.roles.filter(is_board=False, is_active=True)
def clean(self):
"""Check that the user is subscribed and isn't already in the club."""
@@ -2,12 +2,15 @@
import django.db.models.deletion
import django.db.models.functions.datetime
from django.conf import settings
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Q
from django.utils.timezone import localdate
# Before the club role rework, the maximum free role
# was the hardcoded highest non-board role
MAXIMUM_FREE_ROLE = 1
def migrate_meta_groups(apps: StateApps, schema_editor):
"""Attach the existing meta groups to the clubs.
@@ -46,10 +49,7 @@ def migrate_meta_groups(apps: StateApps, schema_editor):
).select_related("user")
club.members_group.users.set([m.user for m in memberships])
club.board_group.users.set(
[
m.user
for m in memberships.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
]
[m.user for m in memberships.filter(role__gt=MAXIMUM_FREE_ROLE)]
)
@@ -0,0 +1,158 @@
# Generated by Django 5.2.3 on 2025-06-21 21:59
import django.db.models.deletion
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Case, When
PRESIDENCY_ROLES = [10, 9]
MAXIMUM_FREE_ROLE = 1
SITH_CLUB_ROLES = {
10: "Président⸱e",
9: "Vice-Président⸱e",
7: "Trésorier⸱e",
5: "Responsable communication",
4: "Secrétaire",
3: "Responsable info",
2: "Membre du bureau",
1: "Membre actif⸱ve",
0: "Curieux⸱euse",
}
def migrate_roles(apps: StateApps, schema_editor):
ClubRole = apps.get_model("club", "ClubRole")
Membership = apps.get_model("club", "Membership")
updates = []
for club_id, role in Membership.objects.values_list("club", "role").distinct():
new_role = ClubRole.objects.create(
name=SITH_CLUB_ROLES[role],
is_board=role > MAXIMUM_FREE_ROLE,
is_presidency=role in PRESIDENCY_ROLES,
club_id=club_id,
order=max(SITH_CLUB_ROLES) - role,
)
updates.append(When(role=role, then=new_role.id))
# all updates must happen at the same time
# otherwise, the 10 first created ClubRole would be
# re-modified after their initial creation, and it would
# result in an incoherent state.
# To avoid that, all updates are wrapped in a single giant Case(When) statement
# cf. https://docs.djangoproject.com/fr/stable/ref/models/conditional-expressions/#conditional-update
Membership.objects.update(role=Case(*updates))
class Migration(migrations.Migration):
dependencies = [
("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more"),
("core", "0047_alter_notification_date_alter_notification_type"),
]
operations = [
migrations.AlterField(
model_name="club",
name="page",
field=models.OneToOneField(
blank=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="club",
to="core.page",
),
),
migrations.CreateModel(
name="ClubRole",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"order",
models.PositiveIntegerField(
db_index=True, editable=False, verbose_name="order"
),
),
(
"club",
models.ForeignKey(
help_text="The club with which this role is associated",
on_delete=django.db.models.deletion.CASCADE,
related_name="roles",
to="club.club",
verbose_name="club",
),
),
("name", models.CharField(max_length=50, verbose_name="name")),
(
"description",
models.TextField(
default="", blank=True, verbose_name="description"
),
),
(
"is_board",
models.BooleanField(default=False, verbose_name="Board role"),
),
(
"is_presidency",
models.BooleanField(default=False, verbose_name="Presidency role"),
),
(
"is_active",
models.BooleanField(
default=True,
help_text=(
"If the role is inactive, people joining the club "
"won't be able to get it."
),
verbose_name="is active",
),
),
],
options={
"ordering": ("order",),
"verbose_name": "club role",
"verbose_name_plural": "club roles",
},
),
migrations.AlterField(
model_name="club",
name="board_group",
field=models.OneToOneField(
editable=False,
on_delete=django.db.models.deletion.PROTECT,
related_name="club_board",
to="core.group",
),
),
migrations.AlterField(
model_name="club",
name="members_group",
field=models.OneToOneField(
editable=False,
on_delete=django.db.models.deletion.PROTECT,
related_name="club",
to="core.group",
),
),
migrations.AddConstraint(
model_name="clubrole",
constraint=models.CheckConstraint(
condition=models.Q(
("is_presidency", False), ("is_board", True), _connector="OR"
),
name="clubrole_presidency_implies_board",
),
),
migrations.RunPython(migrate_roles, migrations.RunPython.noop),
# because Postgres migrations run in a single transaction,
# we cannot change the actual values of Membership.role
# and apply the FOREIGN KEY constraint in the same migration.
# The constraint is created in the next migration
]
@@ -0,0 +1,25 @@
# Generated by Django 5.2.3 on 2025-09-27 09:57
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("club", "0015_clubrole_alter_membership_role")]
operations = [
# because Postgres migrations run in a single transaction,
# we cannot change the actual values of Membership.role
# and apply the FOREIGN KEY constraint in the same migration.
# The data migration was made in the previous migration.
migrations.AlterField(
model_name="membership",
name="role",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="members",
to="club.clubrole",
verbose_name="role",
),
),
]
+99
View File
@@ -0,0 +1,99 @@
# Generated by Django 5.2.12 on 2026-04-27 07:39
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("club", "0016_clubrole_alter_membership_role")]
operations = [
migrations.CreateModel(
name="LinkType",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"name",
models.CharField(max_length=40, unique=True, verbose_name="name"),
),
(
"url_base",
models.URLField(
help_text=(
"L'url de base que tous les "
"liens de ce type doivent respecter "
"(par exemple `https://www.instagram.com`)"
),
unique=True,
verbose_name="url base",
),
),
(
"icon",
models.CharField(
help_text=(
"The fontawesome class to use "
"(e.g. `fa-brands fa-instagram`)"
),
max_length=40,
verbose_name="icon",
),
),
],
options={"verbose_name": "link type", "verbose_name_plural": "link types"},
),
migrations.CreateModel(
name="ClubLink",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"name",
models.CharField(blank=True, max_length=40, verbose_name="name"),
),
("url", models.URLField(verbose_name="link url")),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="updated at"),
),
(
"club",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="links",
to="club.club",
verbose_name="club",
),
),
(
"link_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="links",
to="club.linktype",
verbose_name="link type",
),
),
],
options={"verbose_name": "club link", "verbose_name_plural": "club links"},
),
]
+234 -29
View File
@@ -28,15 +28,15 @@ from typing import Iterable, Self
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email
from django.db import models, transaction
from django.db.models import Exists, F, OuterRef, Q, Value
from django.db.models.functions import Greatest
from django.db import ProgrammingError, models, transaction
from django.db.models import Exists, F, OuterRef, Q
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.text import slugify
from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _
from ordered_model.models import OrderedModel
from core.fields import ResizedImageField
from core.models import Group, Notification, Page, SithFile, User
@@ -89,13 +89,13 @@ class Club(models.Model):
on_delete=models.SET_NULL,
)
page = models.OneToOneField(
Page, related_name="club", blank=True, on_delete=models.CASCADE
Page, related_name="club", blank=True, on_delete=models.PROTECT
)
members_group = models.OneToOneField(
Group, related_name="club", on_delete=models.PROTECT
Group, related_name="club", on_delete=models.PROTECT, editable=False
)
board_group = models.OneToOneField(
Group, related_name="club_board", on_delete=models.PROTECT
Group, related_name="club_board", on_delete=models.PROTECT, editable=False
)
objects = ClubQuerySet.as_manager()
@@ -138,9 +138,7 @@ class Club(models.Model):
@cached_property
def president(self) -> Membership | None:
"""Fetch the membership of the current president of this club."""
return self.members.filter(
role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
).first()
return self.members.filter(end_date=None).order_by("role__order").first()
def check_loop(self):
"""Raise a validation error when a loop is found within the parent list."""
@@ -185,6 +183,40 @@ class Club(models.Model):
self.page.parent = self.parent.page
self.page.save(force_lock=True)
def create_default_roles(self):
"""Create some roles that should exist by default for this club.
The created roles are : president, treasurer, active member and curious.
Warnings:
When calling this method, no club must exist yet for this club.
"""
if self.roles.exists():
raise ProgrammingError(
"Default roles can be created only for clubs "
"that don't have associated roles yet"
)
# The names are written in French, because there is no gettext involved
# for strings stored in database, and the majority of users are french.
roles = [
ClubRole(name="Président⸱e", is_board=True, is_presidency=True),
ClubRole(name="Trésorier⸱e", is_board=True, is_presidency=False),
ClubRole(name="Membre actif⸱ve", is_board=False, is_presidency=False),
ClubRole(
name="Curieux⸱euse",
description=(
"Les gens qui suivent l'activité "
"du club sans forcément y participer"
),
is_board=False,
is_presidency=False,
),
]
for i, role in enumerate(roles):
role.club = self
role.order = i
ClubRole.objects.bulk_create(roles)
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
self.board_group.delete()
self.members_group.delete()
@@ -208,7 +240,9 @@ class Club(models.Model):
@cached_property
def current_members(self) -> list[Membership]:
return list(self.members.ongoing().select_related("user").order_by("-role"))
return list(
self.members.ongoing().select_related("user", "role").order_by("-role")
)
def get_membership_for(self, user: User) -> Membership | None:
"""Return the current membership of the given user."""
@@ -220,6 +254,105 @@ class Club(models.Model):
return user.is_in_group(pk=self.board_group_id)
class ClubRole(OrderedModel):
club = models.ForeignKey(
Club,
verbose_name=_("club"),
help_text=_("The club with which this role is associated"),
related_name="roles",
on_delete=models.CASCADE,
)
name = models.CharField(_("name"), max_length=50)
description = models.TextField(_("description"), blank=True, default="")
is_board = models.BooleanField(_("Board role"), default=False)
is_presidency = models.BooleanField(_("Presidency role"), default=False)
is_active = models.BooleanField(
_("is active"),
default=True,
help_text=_(
"If the role is inactive, people joining the club won't be able to get it."
),
)
order_with_respect_to = "club"
class Meta(OrderedModel.Meta):
verbose_name = _("club role")
verbose_name_plural = _("club roles")
constraints = [
# presidency IMPLIES board <=> NOT presidency OR board
# cf. MT1 :)
models.CheckConstraint(
condition=Q(is_presidency=False) | Q(is_board=True),
name="clubrole_presidency_implies_board",
)
]
def __str__(self):
return self.name
def get_display_name(self):
return f"{self.name} - {self.club.name}"
def get_absolute_url(self):
return reverse("club:club_roles", kwargs={"club_id": self.club_id})
def clean(self):
errors = []
if self.is_presidency and not self.is_board:
errors.append(
ValidationError(
_(
"Role %(name)s was declared as a presidency role "
"without being a board role"
)
% {"name": self.name}
)
)
roles = list(self.club.roles.all())
if (
self.is_board
and self.order
and any(r.order < self.order and not r.is_board for r in roles)
):
errors.append(
ValidationError(
_("Role %(role)s cannot be placed below a member role")
% {"role": self.name}
)
)
if (
self.is_presidency
and self.order
and any(r.order < self.order and not r.is_presidency for r in roles)
):
errors.append(
ValidationError(
_("Role %(role)s cannot be placed below a non-presidency role")
% {"role": self.name}
)
)
if errors:
raise ValidationError(errors)
return super().clean()
def save(self, *args, **kwargs):
auto_order = self.order is None and self.is_board
if not auto_order:
super().save(*args, **kwargs)
return
# get the role that should be placed after the role we are dealing with.
# So, if this is role is presidency, get the first board role ;
# if it is a board role, get the first member role ;
# and if it is a member role, get nothing (OrderedModel.save will
# automatically put it in the last position anyway)
filters = {"is_board": self.is_presidency, "is_presidency": False}
next_role = self.club.roles.filter(**filters).order_by("order").first()
super().save(*args, **kwargs)
if next_role:
self.above(next_role)
class MembershipQuerySet(models.QuerySet):
def ongoing(self) -> Self:
"""Filter all memberships which are not finished yet."""
@@ -232,9 +365,10 @@ class MembershipQuerySet(models.QuerySet):
are included, even if there are no more members.
If you want to get the users who are currently in the board,
mind combining this with the `ongoing` queryset method
mind combining this with the [MembershipQuerySet.ongoing][]
queryset method
"""
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
return self.filter(role__is_board=True)
def editable_by(self, user: User) -> Self:
"""Filter Memberships that this user can edit.
@@ -257,21 +391,16 @@ class MembershipQuerySet(models.QuerySet):
"""
if user.has_perm("club.change_membership"):
return self.all()
return self.filter(
return self.ongoing().filter(
Q(user=user)
| Exists(
Membership.objects.filter(
Q(
role__gt=Greatest(
OuterRef("role"), Value(settings.SITH_MAXIMUM_FREE_ROLE)
)
),
Membership.objects.ongoing().filter(
user=user,
end_date=None,
club=OuterRef("club"),
role__is_board=True,
role__order__lt=OuterRef("role__order"),
)
)
),
end_date=None,
)
def update(self, **kwargs) -> int:
@@ -341,10 +470,11 @@ class Membership(models.Model):
)
start_date = models.DateField(_("start date"), default=timezone.now)
end_date = models.DateField(_("end date"), null=True, blank=True)
role = models.IntegerField(
_("role"),
choices=sorted(settings.SITH_CLUB_ROLES.items()),
default=sorted(settings.SITH_CLUB_ROLES.items())[0][0],
role = models.ForeignKey(
ClubRole,
verbose_name=_("role"),
related_name="members",
on_delete=models.PROTECT,
)
description = models.CharField(
_("description"), max_length=128, null=False, blank=True
@@ -362,7 +492,7 @@ class Membership(models.Model):
def __str__(self):
return (
f"{self.club.name} - {self.user.username} "
f"- {settings.SITH_CLUB_ROLES[self.role]} "
f"- {self.role.name} "
f"- {str(_('past member')) if self.end_date is not None else ''}"
)
@@ -391,7 +521,11 @@ class Membership(models.Model):
if user.is_root or user.is_board_member:
return True
membership = self.club.get_membership_for(user)
return membership is not None and membership.role >= self.role
if not membership:
return False
return membership.user_id == user.id or (
membership.is_board and membership.role.order < self.role.order
)
def delete(self, *args, **kwargs):
self._remove_club_groups([self])
@@ -467,7 +601,7 @@ class Membership(models.Model):
group_id=membership.club.members_group_id,
)
)
if membership.role > settings.SITH_MAXIMUM_FREE_ROLE:
if membership.role.is_board:
club_groups.append(
User.groups.through(
user_id=membership.user_id,
@@ -640,3 +774,74 @@ class MailingSubscription(models.Model):
def fetch_format(self):
return self.get_email + " "
class LinkType(models.Model):
"""A link type, in order to group links and give them icons.
Notes:
Among all club links, there is a special one, with an empty base url
and a default link icon.
It is use as a fallback item when no actual link type can be found.
Danger:
LinkType.icon is content that will be raw-rendered in the template.
It is NOT safe to allow users to give it.
The edition of this field must be reserved to trusted admins.
"""
name = models.CharField(_("name"), max_length=40, unique=True)
url_base = models.URLField(
"url base",
unique=True,
help_text=_(
"The base url that links with this type must respect (e.g. `%(url)s`)"
)
% {"url": "https://www.instagram.com"},
)
icon = models.CharField(
_("icon"),
max_length=40,
help_text=_("The fontawesome class to use (e.g. `fa-brands fa-instagram`)"),
)
class Meta:
verbose_name = _("link type")
verbose_name_plural = _("link types")
def __str__(self):
return self.name
class ClubLink(models.Model):
link_type = models.ForeignKey(
LinkType,
verbose_name=_("link type"),
on_delete=models.CASCADE,
related_name="links",
)
name = models.CharField(_("name"), max_length=40, blank=True)
url = models.URLField(_("link url"))
club = models.ForeignKey(
Club, verbose_name=_("club"), on_delete=models.CASCADE, related_name="links"
)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
class Meta:
verbose_name = _("club link")
verbose_name_plural = _("club links")
def __str__(self):
return self.url
def save(self, **kwargs):
if not self.name:
self.name = self.link_type.name
return super().save(**kwargs)
def clean(self):
if not self.url.startswith(self.link_type.url_base):
raise ValidationError(
_("This link doesn't match with the url base of its type.")
)
+17 -3
View File
@@ -2,8 +2,9 @@ from typing import Annotated
from django.db.models import Q
from ninja import FilterLookup, FilterSchema, ModelSchema
from pydantic import HttpUrl
from club.models import Club, Membership
from club.models import Club, ClubRole, Membership
from core.schemas import NonEmptyStr, SimpleUserSchema
@@ -39,14 +40,21 @@ class ClubProfileSchema(ModelSchema):
return obj.get_absolute_url()
class ClubRoleSchema(ModelSchema):
class Meta:
model = ClubRole
fields = ["id", "name", "is_presidency", "is_board"]
class ClubMemberSchema(ModelSchema):
"""A schema to represent all memberships in a club."""
class Meta:
model = Membership
fields = ["start_date", "end_date", "role", "description"]
fields = ["start_date", "end_date", "description"]
user: SimpleUserSchema
role: ClubRoleSchema
class ClubSchema(ModelSchema):
@@ -55,6 +63,11 @@ class ClubSchema(ModelSchema):
fields = ["id", "name", "logo", "is_active", "short_description", "address"]
members: list[ClubMemberSchema]
links: list[HttpUrl]
@staticmethod
def resolve_links(obj: Club):
return [link.url for link in obj.links.all()]
class UserMembershipSchema(ModelSchema):
@@ -62,6 +75,7 @@ class UserMembershipSchema(ModelSchema):
class Meta:
model = Membership
fields = ["id", "start_date", "role", "description"]
fields = ["id", "start_date", "description"]
club: SimpleClubSchema
role: ClubRoleSchema
+66
View File
@@ -0,0 +1,66 @@
#club-detail {
img.club-logo {
display: block;
max-height: 200px;
max-width: 200px;
}
#club-attributes {
ul {
list-style: none;
margin-left: 0;
display: flex;
flex-direction: column;
gap: .75rem;
li i {
margin-right: .5rem;
}
}
}
&:not(.has-links) {
#club-attributes {
float: right;
margin: 1em 0 1em 2em;
@media screen and (max-width: 650px) {
margin-left: 1em;
}
@media screen and (max-width: 400px) {
float: unset;
img.club-logo {
margin: auto;
}
}
}
}
&.has-links {
display: flex;
flex-direction: row-reverse;
gap: 2em;
@media screen and (max-width: 650px) {
flex-direction: column;
gap: 1em;
}
#club-attributes {
display: flex;
flex-direction: column;
gap: 1em;
min-width: 200px;
@media screen and (max-width: 650px) {
margin-top: 1em;
flex-direction: row-reverse;
justify-content: flex-end;
h4 {
margin: 0;
}
img.club-logo {
margin-left: auto;
}
}
}
}
}
+31 -4
View File
@@ -21,17 +21,44 @@
{% endif %}
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("club/detail.scss") }}">
{% endblock %}
{% block content %}
<div id="club_detail">
<h3>{{ club.name }}</h3>
<div id="club-detail" {% if links %}class="has-links"{% endif %}>
<div id="club-attributes">
{% if club.logo %}
<div class="club_logo"><img src="{{ club.logo.url }}" alt="{{ club.name }}"></div>
<img
class="club-logo"
src="{{ club.logo.url }}"
alt="{{ club.name }}"
width="{{ club.logo.width }}"
height="{{ club.logo.height }}"
>
{% endif %}
{% if links %}
<div id="club-links">
<h4>{% trans %}Links{% endtrans %}</h4>
<ul>
{% for link in links %}
<li>
<a href="{{ link.url }}" rel="noopener" target="_blank">
<i class="{{ link.link_type.icon }} fa-xl"></i>{{ link.name }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
<div id="club-page">
{% if page_revision %}
{{ page_revision|markdown }}
{% else %}
<h3>{{ club.name }}</h3>
{% endif %}
</div>
</div>
{% endblock %}
+19
View File
@@ -1,6 +1,13 @@
{% if is_fragment %}
{% extends "core/base_fragment.jinja" %}
{% block metatags %}
<meta property="og:url" content="{{ request.build_absolute_uri() }}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Liste des clubs et assos" />
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% endblock %}
{# Don't display tabs and errors #}
{% block tabs %}
{% endblock %}
@@ -62,6 +69,18 @@
{{ club.name }} {% if not club.is_active %}({% trans %}inactive{% endtrans %}){% endif %}
</h4>
</a>
{% set links = club.links.all() %}
{% if links %}
<br>
<div class="row gap-2x">
{% for link in club.links.all() %}
<a href="{{ link.url }}">
<i class="{{ link.link_type.icon }} fa-xl"></i>
<strong>{{ link.name }}</strong>
</a>
{% endfor %}
</div>
{% endif %}
{{ club.short_description|markdown }}
</div>
</div>
+1 -1
View File
@@ -41,7 +41,7 @@
{% for m in members %}
<tr>
<td>{{ user_profile_link(m.user) }}</td>
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
<td>{{ m.role.name }}</td>
<td>{{ m.description }}</td>
<td>{{ m.start_date }}</td>
{%- if can_end_membership -%}
+1 -1
View File
@@ -17,7 +17,7 @@
{% for member in old_members %}
<tr>
<td>{{ user_profile_link(member.user) }}</td>
<td>{{ settings.SITH_CLUB_ROLES[member.role] }}</td>
<td>{{ member.role.name }}</td>
<td>{{ member.description }}</td>
<td>{{ member.start_date }}</td>
<td>{{ member.end_date }}</td>
+95 -4
View File
@@ -1,9 +1,60 @@
{% extends "core/base.jinja" %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/core/dynamic-formset-index.ts") }}"></script>
{% endblock %}
{% block title %}
{% trans name=object %}Edit {{ name }}{% endtrans %}
{% endblock %}
{% macro link_form(form) %}
<fieldset
{# set url in x-init rather than in x-data,
in order to trigger the $watch on initial load #}
x-data="{ url: '', linkType: { icon: '', id: 0 } }"
x-init="() => {
$watch('url', (u) => linkType = linkTypes.find((t) => u.startsWith(t.url)));
url = '{{ form.url.value() or "" }}';
}"
>
{{ form.non_field_errors() }}
<div class="form-group row gap-2x">
<div>
{{ form.url.label_tag() }}
{{ form.url.errors }}
<span>
{# we change the icon when the user change it and leave the input,
or when it is pasted from the clipboard #}
{{ form.url|add_attr("x-model.change=url,@paste.prevent=url = $event.clipboardData.getData('text')") }}
<i :class="linkType.icon || 'fa fa-link'"></i>
</span>
</div>
<div>{{ form.name.as_field_group() }}</div>
</div>
{%- if form.DELETE -%}
<div class="form-group row gap">
{{ form.DELETE.as_field_group() }}
</div>
{%- else -%}
<br>
<button
class="btn btn-grey"
@click.prevent="removeForm($event.target.closest('fieldset'))"
>
<i class="fa fa-minus"></i> {% trans %}Remove link{% endtrans %}
</button>
{%- endif -%}
{{ form.link_type|add_attr(":value=linkType.id") }}
{%- for field in form.hidden_fields() -%}
{%- if field != form.link_type -%}
{{ field }}
{%- endif -%}
{%- endfor -%}
</fieldset>
{% endmacro %}
{% block content %}
<h2>{% trans name=object %}Edit {{ name }}{% endtrans %}</h2>
@@ -17,7 +68,7 @@
and explicitly separate them from the non-admin ones,
with some help text.
Non-admin users will only see the regular form fields,
so they don't need thoses explanations #}
so they don't need those explanations #}
<h3>{% trans %}Club properties{% endtrans %}</h3>
<p class="helptext">
{% trans trimmed %}
@@ -25,7 +76,7 @@
Only admin users can see and edit them.
{% endtrans %}
</p>
<fieldset class="required margin-bottom">
<fieldset class="margin-bottom">
{% for field_name in form.admin_fields %}
{% set field = form[field_name] %}
<div class="form-group">
@@ -36,11 +87,13 @@
{# Remove the the admin fields from the form.
The remaining non-admin fields will be rendered
at once with a simple {{ form.as_p() }} #}
{% set _ = form.fields.pop(field_name) %}
{% do form.fields.pop(field_name) %}
{% endfor %}
</fieldset>
{% endif %}
<h3>{% trans %}Club informations{% endtrans %}</h3>
{% if form.admin_fields %}
<p class="helptext">
{% trans trimmed %}
The following form fields are linked to the basic description of a club.
@@ -48,7 +101,45 @@
{% endtrans %}
</p>
{% endif %}
<fieldset class="margin-bottom">
{{ form.as_p() }}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</fieldset>
<h3>{% trans %}Club links{% endtrans %}</h3>
<div x-data="dynamicFormSet({ prefix: '{{ form.link_formset.prefix }}' })" class="margin-bottom">
{{ form.link_formset.management_form }}
<div x-ref="formContainer">
{%- for f in form.link_formset.forms -%}
{{ link_form(f) }}
{%- endfor -%}
</div>
<template x-ref="formTemplate">
{{ link_form(form.link_formset.empty_form) }}
</template>
<p>
<i>{% trans trimmed %}
Note: if the icon of one of your links doesn't exist yet,
you can ask the info team to add it.
{% endtrans %}</i>
</p>
<br>
<button @click.prevent="addForm()" class="btn btn-grey">
<i class="fa fa-plus"></i>{% trans %}Add link{% endtrans %}
</button>
</div>
<hr>
<button type="submit" class="btn btn-blue">
<i class="fa fa-check"></i>{% trans %}Save{% endtrans %}
</button>
</form>
{% endblock content %}
{% block script %}
<script>
const linkTypes = [
{%- for t in link_types -%}
{ id: {{ t.id }}, url: '{{ t.url_base }}', icon: '{{ t.icon }}' },
{%- endfor -%}
];
</script>
{% endblock %}
+15 -5
View File
@@ -8,7 +8,7 @@ from django.utils.timezone import now
from model_bakery import baker
from model_bakery.recipe import Recipe
from club.models import Club, Membership
from club.models import Club, ClubRole, Membership
from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import User
@@ -43,6 +43,11 @@ class TestClub(TestCase):
cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID)
cls.club = baker.make(Club)
cls.president_role = baker.make(
ClubRole, club=cls.club, is_board=True, is_presidency=True, order=0
)
cls.board_role = baker.make(ClubRole, club=cls.club, is_board=True, order=1)
cls.member_role = baker.make(ClubRole, club=cls.club, order=2)
cls.new_members_url = reverse(
"club:club_new_members", kwargs={"club_id": cls.club.id}
)
@@ -51,12 +56,17 @@ class TestClub(TestCase):
yesterday = now() - timedelta(days=1)
membership_recipe = Recipe(Membership, club=cls.club)
membership_recipe.make(
user=cls.simple_board_member, start_date=a_month_ago, role=3
user=cls.simple_board_member, start_date=a_month_ago, role=cls.board_role
)
membership_recipe.make(user=cls.richard, role=cls.member_role)
membership_recipe.make(
user=cls.president, start_date=a_month_ago, role=cls.president_role
)
membership_recipe.make(user=cls.richard, role=1)
membership_recipe.make(user=cls.president, start_date=a_month_ago, role=10)
membership_recipe.make( # sli was a member but isn't anymore
user=cls.sli, start_date=a_month_ago, end_date=yesterday, role=2
user=cls.sli,
start_date=a_month_ago,
end_date=yesterday,
role=cls.board_role,
)
def setUp(self):
+91 -5
View File
@@ -1,13 +1,16 @@
from datetime import timedelta
import pytest
from django.conf import settings
from django.db import ProgrammingError
from django.test import Client
from django.urls import reverse
from django.utils.timezone import localdate
from model_bakery import baker
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects
from club.models import Club, Membership
from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
from core.models import User
@@ -19,11 +22,19 @@ def test_club_queryset_having_board_member():
membership_recipe = Recipe(
Membership, user=user, start_date=localdate() - timedelta(days=3)
)
membership_recipe.make(club=clubs[0], role=1)
membership_recipe.make(club=clubs[1], role=3)
membership_recipe.make(club=clubs[2], role=7)
membership_recipe.make(
club=clubs[3], role=3, end_date=localdate() - timedelta(days=1)
club=clubs[0], role=baker.make(ClubRole, club=clubs[0], is_board=False)
)
membership_recipe.make(
club=clubs[1], role=baker.make(ClubRole, club=clubs[1], is_board=True)
)
membership_recipe.make(
club=clubs[2], role=baker.make(ClubRole, club=clubs[2], is_board=True)
)
membership_recipe.make(
club=clubs[3],
role=baker.make(ClubRole, club=clubs[3], is_board=True),
end_date=localdate() - timedelta(days=1),
)
club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)
@@ -39,3 +50,78 @@ def test_club_list(client: Client, nb_additional_clubs: int, is_fragment):
headers = {"HX-Request": True} if is_fragment else {}
res = client.get(reverse("club:club_list"), headers=headers)
assert res.status_code == 200
def assert_club_created(club_name: str):
club = Club.objects.last()
assert club.name == club_name
assert club.board_group.name == f"{club_name} - Bureau"
assert club.members_group.name == f"{club_name} - Membres"
# default roles should be added on club creation,
# whether the creation happens on the admin site or on the user site
assert list(club.roles.values("name", "is_presidency", "is_board")) == [
{"name": "Président⸱e", "is_presidency": True, "is_board": True},
{"name": "Trésorier⸱e", "is_presidency": False, "is_board": True},
{"name": "Membre actif⸱ve", "is_presidency": False, "is_board": False},
{"name": "Curieux⸱euse", "is_presidency": False, "is_board": False},
]
@pytest.mark.django_db
def test_create_view(admin_client: Client):
"""Test that the club creation view works well"""
res = admin_client.get(reverse("club:club_new"))
assert res.status_code == 200
res = admin_client.post(
reverse("club:club_new"),
data={"name": "foo", "parent": settings.SITH_MAIN_CLUB_ID},
)
club = Club.objects.last()
assertRedirects(res, club.get_absolute_url())
assert_club_created("foo")
@pytest.mark.django_db
def test_default_roles_for_club_with_roles_fails():
"""Test that an Error is raised if trying to create
default roles for a club that already has roles.
"""
club = baker.make(Club)
baker.make(ClubRole, club=club)
with pytest.raises(ProgrammingError):
club.create_default_roles()
@pytest.mark.django_db
class TestAdminInterface:
def test_create(self, admin_client: Client):
"""Test the creation of a club via the admin interface."""
res = admin_client.post(
reverse("admin:club_club_add"),
data={
"name": "foo",
"parent": settings.SITH_MAIN_CLUB_ID,
"address": "Rome",
},
)
assertRedirects(res, reverse("admin:club_club_changelist"))
assert_club_created("foo")
def test_change(self, admin_client: Client):
"""Test the edition of a club via the admin interface."""
club = baker.make(Club)
res = admin_client.post(
reverse("admin:club_club_change", kwargs={"object_id": club.id}),
data={
"name": "foo",
"page": club.page_id,
"home": club.home_id,
"address": club.address,
},
)
assertRedirects(res, reverse("admin:club_club_changelist"))
club.refresh_from_db()
assert club.name == "foo"
# Club roles shouldn't be modified when editing the club on the admin interface
# This club had no roles beforehand, therefore it shouldn't have roles now.
assert not club.roles.exists()
+7 -4
View File
@@ -1,6 +1,7 @@
from datetime import date, timedelta
import pytest
from django.conf import settings
from django.contrib.auth.models import Permission
from django.test import Client, TestCase
from django.urls import reverse
@@ -8,7 +9,7 @@ from model_bakery import baker
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertNumQueries
from club.models import Club, Membership
from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
from core.models import Group, Page, User
@@ -26,8 +27,10 @@ class TestClubSearch(TestCase):
"id", flat=True
)
)
Page.objects.exclude(club=None).delete()
Membership.objects.all().delete()
ClubRole.objects.all().delete()
Club.objects.all().delete()
Page.objects.exclude(name=settings.SITH_CLUB_ROOT_PAGE).delete()
Group.objects.filter(id__in=groups).delete()
cls.clubs = baker.make(
@@ -85,8 +88,8 @@ class TestFetchClub:
def test_fetch_club_nb_queries(self, client: Client, club: Club):
user = subscriber_user.make()
client.force_login(user)
with assertNumQueries(6):
with assertNumQueries(7):
# - 4 queries for authentication
# - 2 queries for the actual data
# - 3 queries for the actual data
res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id}))
assert res.status_code == 200
+33
View File
@@ -0,0 +1,33 @@
import pytest
from model_bakery import baker, seq
from model_bakery.recipe import Recipe
from club.models import Club, ClubRole
@pytest.mark.django_db
def test_order_auto():
"""Test that newly created roles are put in the right place."""
club = baker.make(Club)
recipe = Recipe(ClubRole, club=club, name=seq("role "))
# bulk create initial roles (1 presidency, 1 board, 1 member)
roles = recipe.make(
is_board=iter([True, True, False]),
is_presidency=iter([True, False, False]),
order=iter([1, 2, 3]),
_quantity=3,
_bulk_create=True,
)
# then create the remaining roles one by one (like they will be in prod)
# each new role should be placed at the end of its category
role_a = recipe.make(is_board=True, is_presidency=True, order=None)
role_b = recipe.make(is_board=True, is_presidency=False, order=None)
role_c = recipe.make(is_board=False, is_presidency=False, order=None)
assert list(club.roles.order_by("order")) == [
roles[0],
role_a,
roles[1],
role_b,
roles[2],
role_c,
]
+20 -4
View File
@@ -4,7 +4,7 @@ from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from club.models import Club, Membership
from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
@@ -12,11 +12,22 @@ from core.baker_recipes import subscriber_user
def test_club_board_member_cannot_edit_club_properties(client: Client):
user = subscriber_user.make()
club = baker.make(Club, name="old name", is_active=True, address="old address")
baker.make(Membership, club=club, user=user, role=7)
baker.make(
Membership,
club=club,
user=user,
role=baker.make(ClubRole, club=club, is_board=True),
)
client.force_login(user)
res = client.post(
reverse("club:club_edit", kwargs={"club_id": club.id}),
{"name": "new name", "is_active": False, "address": "new address"},
{
"name": "new name",
"is_active": False,
"address": "new address",
"link-TOTAL_FORMS": 0,
"link-INITIAL_FORMS": 0,
},
)
# The request should success,
# but admin-only fields shouldn't be taken into account
@@ -32,7 +43,12 @@ def test_edit_club_page_doesnt_crash(client: Client):
"""crash test for club:club_edit"""
club = baker.make(Club)
user = subscriber_user.make()
baker.make(Membership, club=club, user=user, role=3)
baker.make(
Membership,
club=club,
user=user,
role=baker.make(ClubRole, club=club, is_board=True),
)
client.force_login(user)
res = client.get(reverse("club:club_edit", kwargs={"club_id": club.id}))
assert res.status_code == 200
+3 -2
View File
@@ -3,9 +3,10 @@ from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
from model_bakery import baker
from club.forms import MailingForm
from club.models import Club, Mailing, Membership
from club.models import Club, ClubRole, Mailing, Membership
from core.models import User
@@ -25,7 +26,7 @@ class TestMailingForm(TestCase):
user=cls.rbatsbak,
club=cls.club,
start_date=timezone.now(),
role=settings.SITH_CLUB_ROLES_ID["Board member"],
role=baker.make(ClubRole, club=cls.club, is_board=True),
).save()
def test_mailing_list_add_no_moderation(self):
+159 -90
View File
@@ -1,20 +1,20 @@
import itertools
from collections.abc import Callable
from datetime import timedelta
import pytest
from bs4 import BeautifulSoup
from django.conf import settings
from django.contrib.auth.models import Permission
from django.core.cache import cache
from django.db.models import Max
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import localdate, localtime, now
from model_bakery import baker
from model_bakery import baker, seq
from pytest_django.asserts import assertRedirects
from club.forms import ClubAddMemberForm, JoinClubForm
from club.models import Club, Membership
from club.models import Club, ClubRole, Membership
from club.tests.base import TestClub
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, User
@@ -75,17 +75,22 @@ class TestMembershipQuerySet(TestClub):
def test_update_change_club_groups(self):
"""Test that `update` set the user groups accordingly."""
user = baker.make(User)
membership = baker.make(Membership, end_date=None, user=user, role=5)
board_role, member_role = baker.make(
ClubRole, is_board=iter([True, False]), _quantity=2, _bulk_create=True
)
membership = baker.make(
Membership, end_date=None, user=user, role=board_role, club=board_role.club
)
members_group = membership.club.members_group
board_group = membership.club.board_group
assert user.groups.contains(members_group)
assert user.groups.contains(board_group)
user.memberships.update(role=1) # from board to simple member
user.memberships.update(role=member_role) # from board to simple member
assert user.groups.contains(members_group)
assert not user.groups.contains(board_group)
user.memberships.update(role=5) # from member to board
user.memberships.update(role=board_role) # from member to board
assert user.groups.contains(members_group)
assert user.groups.contains(board_group)
@@ -96,7 +101,17 @@ class TestMembershipQuerySet(TestClub):
def test_delete_remove_from_groups(self):
"""Test that `delete` removes from club groups"""
user = baker.make(User)
memberships = baker.make(Membership, role=iter([1, 5]), user=user, _quantity=2)
club = baker.make(Club)
roles = baker.make(
ClubRole,
is_board=iter([False, True]),
club=club,
_quantity=2,
_bulk_create=True,
)
memberships = baker.make(
Membership, club=club, role=iter(roles), user=user, _quantity=2
)
club_groups = {
memberships[0].club.members_group,
memberships[1].club.members_group,
@@ -112,13 +127,20 @@ class TestMembershipEditableBy(TestCase):
def setUpTestData(cls):
Membership.objects.all().delete()
cls.club_a, cls.club_b = baker.make(Club, _quantity=2)
roles = baker.make(
ClubRole,
is_presidency=itertools.cycle([True, False, False, False]),
is_board=itertools.cycle([True, True, True, False]),
order=itertools.cycle(range(4)),
club=iter(
[*itertools.repeat(cls.club_a, 4), *itertools.repeat(cls.club_b, 4)]
),
_quantity=8,
_bulk_create=True,
)
cls.memberships = [
*baker.make(
Membership, role=iter([7, 3, 3, 1]), club=cls.club_a, _quantity=4
),
*baker.make(
Membership, role=iter([7, 3, 3, 1]), club=cls.club_b, _quantity=4
),
*baker.make(Membership, role=iter(roles[:4]), club=cls.club_a, _quantity=4),
*baker.make(Membership, role=iter(roles[4:]), club=cls.club_b, _quantity=4),
]
def test_admin_user(self):
@@ -140,7 +162,7 @@ class TestMembershipEditableBy(TestCase):
class TestMembership(TestClub):
def assert_membership_started_today(self, user: User, role: int):
def assert_membership_started_today(self, user: User, role: ClubRole):
"""Assert that the given membership is active and started today."""
membership = user.memberships.ongoing().filter(club=self.club).first()
assert membership is not None
@@ -189,21 +211,27 @@ class TestMembership(TestClub):
"Marquer comme ancien",
]
rows = table.find("tbody").find_all("tr")
memberships = self.club.members.ongoing().order_by("-role")
for row, membership in zip(
rows, memberships.select_related("user"), strict=False
):
memberships = (
self.club.members.ongoing()
.order_by("role__order")
.select_related("user", "role")
)
user_role = ClubRole.objects.get(members__user=self.simple_board_member)
for row, membership in zip(rows, memberships, strict=False):
user = membership.user
user_url = reverse("core:user_profile", args=[user.id])
cols = row.find_all("td")
user_link = cols[0].find("a")
assert user_link.attrs["href"] == user_url
assert user_link.text == user.get_display_name()
assert cols[1].text == settings.SITH_CLUB_ROLES[membership.role]
assert cols[1].text == membership.role.name
assert cols[2].text == membership.description
assert cols[3].text == str(membership.start_date)
if membership.role < 3 or membership.user_id == self.simple_board_member.id:
if (
membership.role.order > user_role.order
or membership.user_id == self.simple_board_member.id
):
# 3 is the role of simple_board_member
form_input = cols[4].find("input")
expected_attrs = {
@@ -219,14 +247,15 @@ class TestMembership(TestClub):
"""Test that root users can add members to clubs"""
self.client.force_login(self.root)
response = self.client.post(
self.new_members_url, {"user": self.subscriber.id, "role": 3}
self.new_members_url,
{"user": self.subscriber.id, "role": self.board_role.id},
)
assert response.status_code == 200
assert response.headers.get("HX-Redirect", "") == reverse(
"club:club_members", kwargs={"club_id": self.club.id}
)
self.subscriber.refresh_from_db()
self.assert_membership_started_today(self.subscriber, role=3)
self.assert_membership_started_today(self.subscriber, role=self.board_role)
def test_add_unauthorized_members(self):
"""Test that users who are not currently subscribed
@@ -234,7 +263,7 @@ class TestMembership(TestClub):
"""
for user in self.public, self.old_subscriber:
form = ClubAddMemberForm(
data={"user": user.id, "role": 1},
data={"user": user.id, "role": self.member_role},
request_user=self.root,
club=self.club,
)
@@ -255,7 +284,7 @@ class TestMembership(TestClub):
nb_memberships = self.simple_board_member.memberships.count()
self.client.post(
self.members_url,
{"users": self.simple_board_member.id, "role": current_membership.role + 1},
{"users": self.simple_board_member.id, "role": self.member_role},
)
self.simple_board_member.refresh_from_db()
assert nb_memberships == self.simple_board_member.memberships.count()
@@ -274,7 +303,7 @@ class TestMembership(TestClub):
max_id = User.objects.aggregate(id=Max("id"))["id"]
for members in [max_id + 1], [max_id + 1, self.subscriber.id]:
form = ClubAddMemberForm(
data={"user": members, "role": 1},
data={"user": members, "role": self.member_role},
request_user=self.root,
club=self.club,
)
@@ -288,44 +317,6 @@ class TestMembership(TestClub):
self.club.refresh_from_db()
assert self.club.members.count() == nb_memberships
def test_president_add_members(self):
"""Test that the president of the club can add members."""
president = self.club.members.get(role=10).user
nb_club_membership = self.club.members.count()
nb_subscriber_memberships = self.subscriber.memberships.count()
self.client.force_login(president)
response = self.client.post(
self.new_members_url, {"user": self.subscriber.id, "role": 9}
)
assert response.status_code == 200
assert response.headers.get("HX-Redirect", "") == reverse(
"club:club_members", kwargs={"club_id": self.club.id}
)
self.club.refresh_from_db()
self.subscriber.refresh_from_db()
assert self.club.members.count() == nb_club_membership + 1
assert self.subscriber.memberships.count() == nb_subscriber_memberships + 1
self.assert_membership_started_today(self.subscriber, role=9)
def test_add_member_greater_role(self):
"""Test that a member of the club member cannot create
a membership with a greater role than its own.
"""
form = ClubAddMemberForm(
data={"user": self.subscriber.id, "role": 10},
request_user=self.simple_board_member,
club=self.club,
)
nb_memberships = self.club.members.count()
assert not form.is_valid()
assert form.errors == {
"role": ["Sélectionnez un choix valide. 10 n\u2019en fait pas partie."]
}
self.club.refresh_from_db()
assert nb_memberships == self.club.members.count()
assert not self.subscriber.memberships.filter(club=self.club).exists()
def test_add_member_without_role(self):
"""Test that trying to add members without specifying their role fails."""
form = ClubAddMemberForm(
@@ -336,8 +327,9 @@ class TestMembership(TestClub):
assert form.errors == {"role": ["Ce champ est obligatoire."]}
def test_add_member_already_there(self):
role = ClubRole.objects.get(members__user=self.simple_board_member)
form = ClubAddMemberForm(
data={"user": self.simple_board_member, "role": 3},
data={"user": self.simple_board_member, "role": role.id},
request_user=self.root,
club=self.club,
)
@@ -348,22 +340,27 @@ class TestMembership(TestClub):
def test_add_other_member_forbidden(self):
non_member = subscriber_user.make()
simple_member = baker.make(Membership, club=self.club, role=1).user
simple_member = baker.make(
Membership, club=self.club, role=self.member_role
).user
for user in non_member, simple_member:
form = ClubAddMemberForm(
data={"user": subscriber_user.make(), "role": 1},
data={"user": subscriber_user.make(), "role": self.member_role.id},
request_user=user,
club=self.club,
)
assert not form.is_valid()
assert form.errors == {
"role": ["Sélectionnez un choix valide. 1 n\u2019en fait pas partie."]
"role": [
"Sélectionnez un choix valide. "
"Ce choix ne fait pas partie de ceux disponibles."
]
}
def test_simple_members_dont_see_form_anymore(self):
"""Test that simple club members don't see the form to add members"""
user = subscriber_user.make()
baker.make(Membership, club=self.club, user=user, role=1)
baker.make(Membership, club=self.club, user=user, role=self.member_role)
self.client.force_login(user)
res = self.client.get(self.members_url)
assert res.status_code == 200
@@ -382,9 +379,10 @@ class TestMembership(TestClub):
"""Test that board members of the club can end memberships
of users with lower roles.
"""
# reminder : simple_board_member has role 3
self.client.force_login(self.simple_board_member)
membership = baker.make(Membership, club=self.club, role=2, end_date=None)
role = baker.make(ClubRole, club=self.club, is_board=True)
role.below(self.board_role)
membership = baker.make(Membership, club=self.club, role=role)
response = self.client.post(self.members_url, {"members_old": [membership.id]})
self.assertRedirects(response, self.members_url)
self.club.refresh_from_db()
@@ -394,7 +392,9 @@ class TestMembership(TestClub):
"""Test that board members of the club cannot end memberships
of users with higher roles.
"""
membership = self.president.memberships.filter(club=self.club).first()
membership = self.president.memberships.filter(
club=self.club, end_date=None
).first()
self.client.force_login(self.simple_board_member)
self.client.post(self.members_url, {"members_old": [membership.id]})
self.club.refresh_from_db()
@@ -436,7 +436,9 @@ class TestMembership(TestClub):
def test_remove_from_club_group(self):
"""Test that when a membership ends, the user is removed from club groups."""
user = baker.make(User)
baker.make(Membership, user=user, club=self.club, end_date=None, role=3)
baker.make(
Membership, user=user, club=self.club, end_date=None, role=self.board_role
)
assert user.groups.contains(self.club.members_group)
assert user.groups.contains(self.club.board_group)
user.memberships.update(end_date=localdate())
@@ -447,18 +449,20 @@ class TestMembership(TestClub):
"""Test that when a membership begins, the user is added to the club group."""
assert not self.subscriber.groups.contains(self.club.members_group)
assert not self.subscriber.groups.contains(self.club.board_group)
baker.make(Membership, club=self.club, user=self.subscriber, role=3)
baker.make(
Membership, club=self.club, user=self.subscriber, role=self.board_role
)
assert self.subscriber.groups.contains(self.club.members_group)
assert self.subscriber.groups.contains(self.club.board_group)
def test_change_position_in_club(self):
"""Test that when moving from board to members, club group change"""
membership = baker.make(
Membership, club=self.club, user=self.subscriber, role=3
Membership, club=self.club, user=self.subscriber, role=self.board_role
)
assert self.subscriber.groups.contains(self.club.members_group)
assert self.subscriber.groups.contains(self.club.board_group)
membership.role = 1
membership.role = self.member_role
membership.save()
assert self.subscriber.groups.contains(self.club.members_group)
assert not self.subscriber.groups.contains(self.club.board_group)
@@ -471,7 +475,11 @@ class TestMembership(TestClub):
# make sli a board member
self.sli.memberships.all().delete()
Membership(club=self.ae, user=self.sli, role=3).save()
Membership(
club=self.ae,
user=self.sli,
role=baker.make(ClubRole, club=self.ae, is_board=True),
).save()
assert self.club.is_owned_by(self.sli)
def test_change_club_name(self):
@@ -497,7 +505,7 @@ class TestMembership(TestClub):
@pytest.mark.django_db
def test_membership_set_old(client: Client):
membership = baker.make(Membership, end_date=None, user=(subscriber_user.make()))
membership = baker.make(Membership, end_date=None, user=subscriber_user.make())
client.force_login(membership.user)
response = client.post(
reverse("club:membership_set_old", kwargs={"membership_id": membership.id})
@@ -524,6 +532,50 @@ def test_membership_delete(client: Client):
assert not Membership.objects.filter(id=membership.id).exists()
@pytest.mark.django_db
class TestAddMemberForm(TestCase):
@classmethod
def setUpTestData(cls):
cls.club = baker.make(Club)
cls.roles = baker.make(
ClubRole,
club=cls.club,
is_board=iter([True, True, True, True, False, False]),
is_presidency=iter([True, True, False, False, False, False]),
order=seq(0),
_quantity=6,
_bulk_create=True,
)
cls.roles[-1].is_active = False
cls.roles[-1].save()
def test_admin(self):
"""Test that admin users can give any active role."""
user = baker.make(
User, user_permissions=[Permission.objects.get(codename="add_membership")]
)
form = ClubAddMemberForm(request_user=user, club=self.club)
assert list(form.fields["role"].queryset) == self.roles[:-1]
def test_president(self):
"""Test that someone with a presidency role can give any active role."""
user = baker.make(Membership, club=self.club, role=self.roles[0]).user
form = ClubAddMemberForm(request_user=user, club=self.club)
assert list(form.fields["role"].queryset) == self.roles[:-1]
def test_board_member(self):
"""Test that someone with a board role can give lower active role."""
user = baker.make(Membership, club=self.club, role=self.roles[2]).user
form = ClubAddMemberForm(request_user=user, club=self.club)
assert list(form.fields["role"].queryset) == self.roles[3:-1]
def test_simple_member(self):
"""Test that someone with a non-board role cannot give roles."""
user = baker.make(Membership, club=self.club, role=self.roles[4]).user
form = ClubAddMemberForm(request_user=user, club=self.club)
assert list(form.fields["role"].queryset) == []
@pytest.mark.django_db
class TestJoinClub:
@pytest.fixture(autouse=True)
@@ -531,55 +583,64 @@ class TestJoinClub:
cache.clear()
@pytest.mark.parametrize(
("user_factory", "role", "errors"),
("user_factory", "board_role", "errors"),
[
(
subscriber_user.make,
2,
True,
{
"role": [
"Sélectionnez un choix valide. 2 n\u2019en fait pas partie."
"Sélectionnez un choix valide. "
"Ce choix ne fait pas partie de ceux disponibles."
]
},
),
(
lambda: baker.make(User),
1,
False,
{"__all__": ["Vous devez être cotisant pour faire partie d'un club"]},
),
],
)
def test_join_club_errors(
self, user_factory: Callable[[], User], role: int, errors: dict
self, user_factory: Callable[[], User], board_role, errors: dict
):
club = baker.make(Club)
user = user_factory()
form = JoinClubForm(club=club, request_user=user, data={"role": role})
role = baker.make(ClubRole, club=club, is_board=board_role)
form = JoinClubForm(club=club, request_user=user, data={"role": role.id})
assert not form.is_valid()
assert form.errors == errors
def test_user_already_in_club(self):
club = baker.make(Club)
user = subscriber_user.make()
baker.make(Membership, user=user, club=club)
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
role = baker.make(ClubRole, is_board=False)
baker.make(Membership, user=user, club=role.club)
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
assert not form.is_valid()
assert form.errors == {"__all__": ["Vous êtes déjà membre de ce club."]}
def test_ok(self):
club = baker.make(Club)
user = subscriber_user.make()
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
role = baker.make(ClubRole, is_board=False)
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
assert form.is_valid()
form.save()
assert Membership.objects.ongoing().filter(user=user, club=club).exists()
assert Membership.objects.ongoing().filter(user=user, club=role.club).exists()
class TestOldMembersView(TestCase):
@classmethod
def setUpTestData(cls):
club = baker.make(Club)
roles = [1, 1, 1, 2, 2, 4, 4, 5, 7, 9, 10]
roles = baker.make(
ClubRole,
club=club,
is_board=itertools.cycle([True, True, False]),
order=seq(0),
_quantity=10,
_bulk_create=True,
)
cls.memberships = baker.make(
Membership,
role=iter(roles),
@@ -604,3 +665,11 @@ class TestOldMembersView(TestCase):
self.client.force_login(baker.make(User))
res = self.client.get(self.url)
assert res.status_code == 403
def test_context_data(self):
# mark a membership as not ended, to make sure it is excluded from the result
self.memberships[0].end_date = None
self.memberships[0].save()
self.client.force_login(subscriber_user.make())
res = self.client.get(self.url)
assert list(res.context_data.get("old_members")) == self.memberships[1:]
+9 -4
View File
@@ -5,7 +5,7 @@ from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects
from club.models import Club, Membership
from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
from core.markdown import markdown
from core.models import PageRev, User
@@ -21,7 +21,7 @@ def test_page_display_on_club_main_page(client: Client):
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(id="club_detail").find(class_="markdown")
detail_html = soup.find(id="club-page").find(class_="markdown")
assertHTMLEqual(detail_html.decode_contents(), markdown(content))
@@ -34,7 +34,7 @@ def test_club_main_page_without_content(client: Client):
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(id="club_detail")
detail_html = soup.find(id="club-page")
assert detail_html.find_all("markdown") == []
@@ -59,7 +59,12 @@ def test_page_revision(client: Client):
def test_edit_page(client: Client):
club = baker.make(Club)
user = subscriber_user.make()
baker.make(Membership, user=user, club=club, role=3)
baker.make(
Membership,
user=user,
club=club,
role=baker.make(ClubRole, club=club, is_board=True),
)
client.force_login(user)
url = reverse("club:club_edit_page", kwargs={"club_id": club.id})
content = "# foo\nLorem ipsum dolor sit amet"
+5 -2
View File
@@ -6,7 +6,7 @@ from django.utils.timezone import localdate
from model_bakery import baker
from model_bakery.recipe import Recipe
from club.models import Club, Membership
from club.models import Club, ClubRole, Membership
from club.schemas import UserMembershipSchema
from core.baker_recipes import subscriber_user
from core.models import Page
@@ -19,7 +19,10 @@ class TestFetchClub(TestCase):
pages = baker.make(Page, _quantity=3, _bulk_create=True)
clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True)
recipe = Recipe(
Membership, user=cls.user, start_date=localdate() - timedelta(days=2)
Membership,
user=cls.user,
start_date=localdate() - timedelta(days=2),
role=baker.make(ClubRole),
)
cls.members = Membership.objects.bulk_create(
[
+34 -16
View File
@@ -28,12 +28,12 @@ import csv
import itertools
from typing import TYPE_CHECKING, Any
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
from django.core.paginator import InvalidPage, Paginator
from django.db.models import F, Q, Sum
from django.db.models import F, Prefetch, Q, Sum
from django.db.models.functions import Length
from django.http import Http404, StreamingHttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
@@ -61,7 +61,14 @@ from club.forms import (
MailingForm,
SellingsForm,
)
from club.models import Club, Mailing, MailingSubscription, Membership
from club.models import (
Club,
ClubLink,
LinkType,
Mailing,
MailingSubscription,
Membership,
)
from com.models import Poster
from com.views import (
PosterCreateBaseView,
@@ -205,20 +212,22 @@ class ClubListView(AllowFragment, FormMixin, ListView):
template_name = "club/club_list.jinja"
form_class = ClubSearchForm
queryset = Club.objects.order_by("name")
queryset = Club.objects.prefetch_related(
Prefetch("links", queryset=ClubLink.objects.select_related("link_type"))
).order_by("name")
paginate_by = 20
def get_form_kwargs(self):
res = super().get_form_kwargs()
if self.request.method == "GET":
res |= {"data": self.request.GET, "initial": self.request.GET}
# if request.GET is empty, the form will interpret club_status as None,
# even though we want it to be initially True,
# so we force a defaut True value.
res["data"] = {"club_status": True} | self.request.GET.dict()
return res
def get_queryset(self):
form: ClubSearchForm = self.get_form()
qs = self.queryset
if not form.is_bound:
return qs.filter(is_active=True)
if not form.is_valid():
return qs.none()
if name := form.cleaned_data.get("name"):
@@ -244,6 +253,7 @@ class ClubView(ClubTabsMixin, DetailView):
.values_list("content", flat=True)
.first()
)
kwargs["links"] = list(self.object.links.select_related("link_type").all())
return kwargs
@@ -355,7 +365,7 @@ class ClubMembersView(
membership = self.object.get_membership_for(self.request.user)
if (
membership
and membership.role <= settings.SITH_MAXIMUM_FREE_ROLE
and not membership.role.is_board
and not self.request.user.has_perm("club.add_membership")
):
# Simple club members won't see the form anymore.
@@ -380,8 +390,8 @@ class ClubMembersView(
kwargs["members"] = list(
self.object.members.ongoing()
.annotate(is_editable=Q(id__in=editable))
.order_by("-role")
.select_related("user")
.order_by("role__order")
.select_related("user", "role")
)
kwargs["can_end_membership"] = len(editable) > 0
return kwargs
@@ -409,8 +419,8 @@ class ClubOldMembersView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
return super().get_context_data(**kwargs) | {
"old_members": (
self.object.members.exclude(end_date=None)
.order_by("-role", "description", "-end_date")
.select_related("user")
.order_by("role__order", "description", "-end_date")
.select_related("user", "role")
)
}
@@ -571,6 +581,11 @@ class ClubEditView(ClubTabsMixin, CanEditMixin, UpdateView):
return ClubAdminEditForm
return ClubEditForm
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"link_types": list(LinkType.objects.order_by(Length("url_base").desc()))
}
class ClubCreateView(PermissionRequiredMixin, CreateView):
"""Create a club (for the Sith admin)."""
@@ -581,6 +596,11 @@ class ClubCreateView(PermissionRequiredMixin, CreateView):
template_name = "core/create.jinja"
permission_required = "club.add_club"
def form_valid(self, form):
res = super().form_valid(form)
self.object.create_default_roles()
return res
class MembershipSetOldView(CanEditMixin, SingleObjectMixin, View):
"""Set a membership as being old."""
@@ -761,9 +781,7 @@ class MailingAutoGenerationView(View):
def get(self, request, *args, **kwargs):
club = self.mailing.club
self.mailing.subscriptions.all().delete()
members = club.members.filter(
role__gte=settings.SITH_CLUB_ROLES_ID["Board member"]
).exclude(end_date__lte=timezone.now())
members = club.members.ongoing().filter(role__is_board=True)
for member in members.all():
MailingSubscription(user=member.user, mailing=self.mailing).save()
return redirect("club:mailing", club_id=club.id)
+25 -31
View File
@@ -3,6 +3,7 @@
#news {
display: flex;
gap: 1em;
@media (max-width: 800px) {
flex-direction: column;
@@ -15,9 +16,13 @@
#right_column {
flex: 20%;
margin: 3.2px;
display: inline-block;
vertical-align: top;
@media screen and (min-width: 800px) {
max-width: 20%;
min-width: 200px;
}
}
#left_column {
@@ -26,12 +31,14 @@
}
h3 {
background: $second-color;
box-shadow: $shadow-color 1px 1px 1px;
padding: 0.4em;
--box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 3px 7px 2px;
background: lighten($second-color, 5%);
box-shadow: var(--box-shadow);
padding: .75rem;
margin: 0 0 0.5em 0;
text-transform: uppercase;
font-size: 17px;
border-radius: 10px;
&:not(:first-of-type) {
margin: 2em 0 1em 0;
@@ -39,12 +46,11 @@
.feed {
float: right;
color: #f26522;
color: #e25512;
}
}
@media screen and (max-width: $small-devices) {
@media screen and (max-width: 800px) {
#left_column,
#right_column {
flex: 100%;
@@ -57,6 +63,7 @@
max-height: 600px;
overflow-y: scroll;
overflow-x: clip;
margin-top: 1em;
#load-more-news-button {
text-align: center;
@@ -73,18 +80,14 @@
display: block;
width: 100%;
background: white;
font-size: 70%;
margin-bottom: 1em;
h3 {
margin-bottom: 0;
}
#links_content {
font-size: 85%;
overflow: auto;
box-shadow: $shadow-color 1px 1px 1px;
min-height: 20em;
padding-bottom: 1em;
padding: 1em;
h4 {
margin-left: 5px;
@@ -96,24 +99,10 @@
li {
margin: 10px;
.fa-facebook {
color: $faceblue;
}
.fa-discord {
color: $discordblurple;
}
.fa-square-instagram::before {
background: $instagradient;
background-clip: text;
-webkit-text-fill-color: transparent;
}
i {
width: 25px;
text-align: center;
margin-right: .5rem;
}
}
}
@@ -121,6 +110,8 @@
}
#birthdays_content {
box-shadow: $shadow-color 1px 1px 1px;
padding: 1em;
ul.birthdays_year {
margin: 0;
list-style-type: none;
@@ -135,8 +126,7 @@
}
ul {
margin: 0;
margin-left: 1em;
margin: .5em 0 0 1em;
list-style-type: square;
list-style-position: inside;
font-weight: normal;
@@ -150,9 +140,13 @@
/* EVENTS TODAY AND NEXT FEW DAYS */
.news_events_group {
box-shadow: $shadow-color 1px 1px 1px;
margin-left: 1em;
margin-left: 0;
margin-bottom: 0.5em;
@media screen and (max-width: $small-devices) {
margin-left: 3px;
}
.news_events_group_date {
display: table-cell;
padding: 0.6em;
+1 -1
View File
@@ -23,7 +23,7 @@
<a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
</h3>
{% if user.is_authenticated and (user.is_com_admin or user.memberships.board().ongoing().exists()) %}
<a class="btn btn-blue margin-bottom" href="{{ url("com:news_new") }}">
<a class="btn btn-blue" href="{{ url("com:news_new") }}">
<i class="fa fa-plus"></i>
{% trans %}Create news{% endtrans %}
</a>
+2 -1
View File
@@ -7,7 +7,7 @@ from model_bakery import baker
from com.models import News, NewsDate
from core.baker_recipes import subscriber_user
from core.models import Group, Notification, User
from core.models import Group, Notification, SithFile, User
@pytest.mark.django_db
@@ -18,6 +18,7 @@ def test_notification_created():
past_news = baker.make(News, is_published=False)
baker.make(NewsDate, news=past_news, start_date=now() - timedelta(days=1))
com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
SithFile.objects.filter(owner__in=com_admin_group.users.all()).delete()
com_admin_group.users.all().delete()
Notification.objects.all().delete()
com_admin = baker.make(User, groups=[com_admin_group])
+3 -2
View File
@@ -28,7 +28,7 @@ from django.utils.translation import gettext as _
from model_bakery import baker
from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club, Membership
from club.models import Club, ClubRole, Membership
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, Group, User
@@ -214,7 +214,8 @@ class TestNewsCreation(TestCase):
def setUpTestData(cls):
cls.club = baker.make(Club)
cls.user = subscriber_user.make()
baker.make(Membership, user=cls.user, club=cls.club, role=5)
role = baker.make(ClubRole, club=cls.club, is_board=True)
baker.make(Membership, user=cls.user, club=cls.club, role=role)
def setUp(self):
self.client.force_login(self.user)
+1 -1
View File
@@ -503,7 +503,7 @@ class WeekmailArticleCreateView(CreateView):
self.object = form.instance
form.is_valid() # Valid a first time to populate club field
m = form.instance.club.get_membership_for(request.user)
if m is None or m.role <= settings.SITH_MAXIMUM_FREE_ROLE:
if m is None or not m.role.is_board:
form.add_error(
"club",
ValidationError(
+1 -1
View File
@@ -123,7 +123,7 @@ class GroupController(ControllerBase):
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_group(self, search: Annotated[str, MinLen(1)]):
return Group.objects.filter(name__icontains=search).values()
return Group.objects.filter(name__icontains=search).order_by("name").values()
DepthValue = Annotated[int, Ge(0), Le(10)]
+5 -3
View File
@@ -4,9 +4,9 @@ from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.utils.timezone import localdate, now
from model_bakery import seq
from model_bakery.recipe import Recipe, related
from model_bakery.recipe import Recipe, foreign_key, related
from club.models import Membership
from club.models import ClubRole, Membership
from core.models import Group, User
from subscription.models import Subscription
@@ -52,7 +52,9 @@ ae_board_membership = Recipe(
Membership,
start_date=now() - timedelta(days=30),
club_id=settings.SITH_MAIN_CLUB_ID,
role=settings.SITH_CLUB_ROLES_ID["Board member"],
role=foreign_key(
Recipe(ClubRole, club_id=settings.SITH_MAIN_CLUB_ID, is_board=True)
),
)
board_user = Recipe(
+8 -11
View File
@@ -1,16 +1,19 @@
from django.urls.converters import IntConverter, StringConverter
class FourDigitYearConverter(IntConverter):
class FourDigitYearConverter:
regex = "[0-9]{4}"
def to_python(self, value):
return int(value)
def to_url(self, value):
return str(value).zfill(4)
class TwoDigitMonthConverter(IntConverter):
class TwoDigitMonthConverter:
regex = "[0-9]{2}"
def to_python(self, value):
return int(value)
def to_url(self, value):
return str(value).zfill(2)
@@ -25,9 +28,3 @@ class BooleanStringConverter:
def to_url(self, value):
return str(value)
class ResultConverter(StringConverter):
"""Converter whose regex match either "success" or "failure"."""
regex = "(success|failure)"
+269 -182
View File
@@ -28,7 +28,6 @@ from typing import ClassVar, NamedTuple
from django.conf import settings
from django.contrib.auth.models import Permission
from django.contrib.sites.models import Site
from django.core.files.base import ContentFile
from django.core.management import call_command
from django.core.management.base import BaseCommand
from django.db import connection
@@ -37,12 +36,19 @@ from django.utils import timezone
from django.utils.timezone import localdate
from PIL import Image
from club.models import Club, Membership
from club.models import Club, ClubLink, ClubRole, LinkType, Membership
from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate, Sith, Weekmail
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
from core.utils import resize_image
from counter.models import Counter, Product, ProductType, ReturnableProduct, StudentCard
from counter.models import (
Counter,
Price,
Product,
ProductType,
ReturnableProduct,
StudentCard,
)
from election.models import Candidature, Election, ElectionList, Role
from forum.models import Forum
from pedagogy.models import UE
@@ -63,6 +69,13 @@ class PopulatedGroups(NamedTuple):
campus_admin: Group
class PopulatedClubs(NamedTuple):
ae: Club
troll: Club
pdf: Club
refound: Club
class Command(BaseCommand):
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
SAS_FIXTURE_PATH: ClassVar[Path] = (
@@ -105,49 +118,25 @@ class Command(BaseCommand):
)
self.profiles_root = SithFile.objects.create(name="profiles", owner=root)
home_root = SithFile.objects.create(name="users", owner=root)
club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create(
name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID
)
SithFile.objects.create(
name="CGU",
is_folder=False,
file=ContentFile(
content="Conditions générales d'utilisation", name="cgu.txt"
),
owner=root,
)
# Page needed for club creation
p = Page(name=settings.SITH_CLUB_ROOT_PAGE)
p.save(force_lock=True)
main_club = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
)
main_club.board_group.permissions.add(
*Permission.objects.filter(
codename__in=[
"view_subscription",
"add_subscription",
"view_hidden_user",
]
)
)
bar_club = Club.objects.create(
id=settings.SITH_PDF_CLUB_ID,
name="PdF",
address="6 Boulevard Anatole France, 90000 Belfort",
club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create(
name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID
)
clubs = self._create_clubs()
self.reset_index("club")
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR").save()
Counter(id=bar_id, name=bar_name, club=clubs.pdf, type="BAR").save()
self.reset_index("counter")
counters = [
Counter(name="Eboutic", club=main_club, type="EBOUTIC"),
Counter(name="AE", club=main_club, type="OFFICE"),
Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
Counter(name="Eboutic", club=clubs.ae, type="EBOUTIC"),
Counter(name="AE", club=clubs.ae, type="OFFICE"),
Counter(name="Vidage comptes AE", club=clubs.ae, type="OFFICE"),
]
Counter.objects.bulk_create(counters)
bar_groups = []
@@ -330,178 +319,55 @@ class Command(BaseCommand):
self._create_subscription(tutu)
StudentCard(uid="9A89B82018B0A0", customer=sli.customer).save()
# Clubs
Club.objects.create(
name="Bibo'UT", address="46 de la Boustifaille", parent=main_club
Membership.objects.create(
user=skia, club=clubs.ae, role=clubs.ae.roles.get(name="Respo Info")
)
guyut = Club.objects.create(
name="Guy'UT", address="42 de la Boustifaille", parent=main_club
)
Club.objects.create(name="Woenzel'UT", address="Woenzel", parent=guyut)
troll = Club.objects.create(
name="Troll Penché", address="Terre Du Milieu", parent=main_club
)
refound = Club.objects.create(
name="Carte AE", address="Jamais imprimée", parent=main_club
)
Membership.objects.create(user=skia, club=main_club, role=3)
Membership.objects.create(
user=comunity,
club=bar_club,
club=clubs.pdf,
start_date=localdate(),
role=settings.SITH_CLUB_ROLES_ID["Board member"],
role=clubs.pdf.roles.get(name="Membre du bureau"),
)
Membership.objects.create(
user=sli,
club=troll,
role=9,
club=clubs.troll,
role=clubs.troll.roles.get(name="Vice-Président⸱e"),
description="Padawan Troll",
start_date=localdate() - timedelta(days=17),
)
Membership.objects.create(
user=krophil,
club=troll,
role=10,
club=clubs.troll,
role=clubs.troll.roles.get(name="Président⸱e"),
description="Maitre Troll",
start_date=localdate() - timedelta(days=200),
)
Membership.objects.create(
user=skia,
club=troll,
role=2,
club=clubs.troll,
role=clubs.troll.roles.get(name="Membre du bureau"),
description="Grand Ancien Troll",
start_date=localdate() - timedelta(days=400),
end_date=localdate() - timedelta(days=86),
)
Membership.objects.create(
user=richard,
club=troll,
role=2,
club=clubs.troll,
role=clubs.troll.roles.get(name="Membre du bureau"),
description="",
start_date=localdate() - timedelta(days=200),
end_date=localdate() - timedelta(days=100),
)
p = ProductType.objects.create(name="Bières bouteilles")
c = ProductType.objects.create(name="Cotisations")
r = ProductType.objects.create(name="Rechargements")
verre = ProductType.objects.create(name="Verre")
cotis = Product.objects.create(
name="Cotis 1 semestre",
code="1SCOTIZ",
product_type=c,
purchase_price="15",
selling_price="15",
special_selling_price="15",
club=main_club,
)
cotis2 = Product.objects.create(
name="Cotis 2 semestres",
code="2SCOTIZ",
product_type=c,
purchase_price="28",
selling_price="28",
special_selling_price="28",
club=main_club,
)
refill = Product.objects.create(
name="Rechargement 15 €",
code="15REFILL",
product_type=r,
purchase_price="15",
selling_price="15",
special_selling_price="15",
club=main_club,
)
barb = Product.objects.create(
name="Barbar",
code="BARB",
product_type=p,
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=main_club,
limit_age=18,
)
cble = Product.objects.create(
name="Chimay Bleue",
code="CBLE",
product_type=p,
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=main_club,
limit_age=18,
)
cons = Product.objects.create(
name="Consigne Eco-cup",
code="CONS",
product_type=verre,
purchase_price="1",
selling_price="1",
special_selling_price="1",
club=main_club,
)
dcons = Product.objects.create(
name="Déconsigne Eco-cup",
code="DECO",
product_type=verre,
purchase_price="-1",
selling_price="-1",
special_selling_price="-1",
club=main_club,
)
cors = Product.objects.create(
name="Corsendonk",
code="CORS",
product_type=p,
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=main_club,
limit_age=18,
)
carolus = Product.objects.create(
name="Carolus",
code="CARO",
product_type=p,
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=main_club,
limit_age=18,
)
Product.objects.create(
name="remboursement",
code="REMBOURS",
purchase_price="0",
selling_price="0",
special_selling_price="0",
club=refound,
)
groups.subscribers.products.add(
cotis, cotis2, refill, barb, cble, cors, carolus
)
groups.old_subscribers.products.add(cotis, cotis2)
self._create_products(groups, clubs)
mde = Counter.objects.get(name="MDE")
mde.products.add(barb, cble, cons, dcons)
eboutic = Counter.objects.get(name="Eboutic")
eboutic.products.add(barb, cotis, cotis2, refill)
Counter.objects.create(name="Carte AE", club=refound, type="OFFICE")
ReturnableProduct.objects.create(
product=cons, returned_product=dcons, max_return=3
)
Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE")
# Add barman to counter
Counter.sellers.through.objects.bulk_create(
[
Counter.sellers.through(counter_id=2, user=krophil),
Counter.sellers.through(counter=mde, user=skia),
Counter.sellers.through(counter_id=1, user=skia), # MDE
Counter.sellers.through(counter_id=2, user=krophil), # Foyer
]
)
@@ -515,7 +381,7 @@ class Command(BaseCommand):
end_date="7942-06-12 10:28:45+01",
)
el.view_groups.add(groups.public)
el.edit_groups.add(main_club.board_group)
el.edit_groups.add(clubs.ae.board_group)
el.candidature_groups.add(groups.subscribers)
el.vote_groups.add(groups.subscribers)
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
@@ -588,7 +454,7 @@ class Command(BaseCommand):
title="Apero barman",
summary="Viens boire un coup avec les barmans",
content="Glou glou glou glou glou glou glou",
club=bar_club,
club=clubs.pdf,
author=subscriber,
is_published=True,
moderator=skia,
@@ -606,7 +472,7 @@ class Command(BaseCommand):
content=(
"Viens donc t'enjailler avec les autres barmans aux frais du BdF! \\o/"
),
club=bar_club,
club=clubs.pdf,
author=subscriber,
is_published=True,
moderator=skia,
@@ -622,7 +488,7 @@ class Command(BaseCommand):
title="Repas fromager",
summary="Wien manger du l'bon fromeug'",
content="Fô viendre mangey d'la bonne fondue!",
club=bar_club,
club=clubs.pdf,
author=subscriber,
is_published=True,
moderator=skia,
@@ -638,7 +504,7 @@ class Command(BaseCommand):
title="SdF",
summary="Enjoy la fin des finaux!",
content="Viens faire la fête avec tout plein de gens!",
club=bar_club,
club=clubs.pdf,
author=subscriber,
is_published=True,
moderator=skia,
@@ -656,7 +522,7 @@ class Command(BaseCommand):
summary="Viens jouer!",
content="Rejoins la fine équipe du Troll Penché et viens "
"t'amuser le Vendredi soir!",
club=troll,
club=clubs.troll,
author=subscriber,
is_published=True,
moderator=skia,
@@ -734,8 +600,7 @@ class Command(BaseCommand):
)
pict.file.name = p.name
pict.full_clean()
pict.generate_thumbnails()
pict.save()
pict.generate_thumbnails(save=True)
img_skia = Picture.objects.get(name="skia.jpg")
img_sli = Picture.objects.get(name="sli.jpg")
@@ -757,6 +622,129 @@ class Command(BaseCommand):
]
)
def _create_products(self, groups: PopulatedGroups, clubs: PopulatedClubs):
beers_type, cotis_type, refill_type, verre_type = (
ProductType.objects.bulk_create(
[
ProductType(name="Bières bouteilles"),
ProductType(name="Cotisations"),
ProductType(name="Rechargements"),
ProductType(name="Verre"),
]
)
)
cotis = Product.objects.create(
name="Cotis 1 semestre",
code="1SCOTIZ",
product_type=cotis_type,
purchase_price=15,
club=clubs.ae,
)
cotis2 = Product.objects.create(
name="Cotis 2 semestres",
code="2SCOTIZ",
product_type=cotis_type,
purchase_price="28",
club=clubs.ae,
)
refill = Product.objects.create(
name="Rechargement 15 €",
code="15REFILL",
product_type=refill_type,
purchase_price=15,
club=clubs.ae,
)
barb = Product.objects.create(
name="Barbar",
code="BARB",
product_type=beers_type,
purchase_price="1.50",
club=clubs.pdf,
limit_age=18,
)
cble = Product.objects.create(
name="Chimay Bleue",
code="CBLE",
product_type=beers_type,
purchase_price="1.50",
club=clubs.pdf,
limit_age=18,
)
cons = Product.objects.create(
name="Consigne Eco-cup",
code="CONS",
product_type=verre_type,
purchase_price="1",
club=clubs.pdf,
)
dcons = Product.objects.create(
name="Déconsigne Eco-cup",
code="DECO",
product_type=verre_type,
purchase_price="-1",
club=clubs.pdf,
)
cors = Product.objects.create(
name="Corsendonk",
code="CORS",
product_type=beers_type,
purchase_price="1.50",
club=clubs.pdf,
limit_age=18,
)
carolus = Product.objects.create(
name="Carolus",
code="CARO",
product_type=beers_type,
purchase_price="1.50",
club=clubs.pdf,
limit_age=18,
)
Product.objects.create(
name="remboursement",
code="REMBOURS",
purchase_price=0,
club=clubs.refound,
)
ReturnableProduct.objects.create(
product=cons, returned_product=dcons, max_return=3
)
mde = Counter.objects.get(name="MDE")
mde.products.add(barb, cble, cons, dcons)
eboutic = Counter.objects.get(name="Eboutic")
eboutic.products.add(barb, cotis, cotis2, refill)
cotis, cotis2, refill, barb, cble, cors, carolus, cons, dcons = (
Price.objects.bulk_create(
[
Price(product=cotis, amount=15),
Price(product=cotis2, amount=28),
Price(product=refill, amount=15),
Price(product=barb, amount=1.7),
Price(product=cble, amount=1.7),
Price(product=cors, amount=1.7),
Price(product=carolus, amount=1.7),
Price(product=cons, amount=1),
Price(product=dcons, amount=-1),
]
)
)
Price.groups.through.objects.bulk_create(
[
Price.groups.through(price=cotis, group=groups.subscribers),
Price.groups.through(price=cotis2, group=groups.subscribers),
Price.groups.through(price=refill, group=groups.subscribers),
Price.groups.through(price=barb, group=groups.subscribers),
Price.groups.through(price=cble, group=groups.subscribers),
Price.groups.through(price=cors, group=groups.subscribers),
Price.groups.through(price=carolus, group=groups.subscribers),
Price.groups.through(price=cotis, group=groups.old_subscribers),
Price.groups.through(price=cotis2, group=groups.old_subscribers),
Price.groups.through(price=cons, group=groups.old_subscribers),
Price.groups.through(price=dcons, group=groups.old_subscribers),
]
)
def _create_profile_pict(self, user: User):
path = self.SAS_FIXTURE_PATH / "Family" / f"{user.username}.jpg"
file = resize_image(Image.open(path), 400, "WEBP")
@@ -793,6 +781,105 @@ class Command(BaseCommand):
)
s.save()
def _create_clubs(self) -> PopulatedClubs:
ae = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
)
ae.board_group.permissions.add(
*Permission.objects.filter(
codename__in=[
"view_subscription",
"add_subscription",
"add_membership",
"view_hidden_user",
]
)
)
pdf = Club.objects.create(
id=settings.SITH_PDF_CLUB_ID,
name="PdF",
address="6 Boulevard Anatole France, 90000 Belfort",
)
troll = Club.objects.create(
name="Troll Penché", address="Terre Du Milieu", parent=ae
)
refound = Club.objects.create(
name="Carte AE", address="Jamais imprimée", parent=ae
)
roles = []
presidency_roles = ["Président⸱e", "Vice-Président⸱e"]
board_roles = [
"Trésorier⸱e",
"Secrétaire",
"Respo Info",
"Respo Com",
"Membre du bureau",
]
simple_roles = ["Membre actif⸱ve", "Curieux⸱euse"]
for club in ae, pdf, troll, refound:
for i, role in enumerate(presidency_roles):
roles.append(
ClubRole(
club=club, order=i, name=role, is_presidency=True, is_board=True
)
)
for i, role in enumerate(board_roles, start=len(presidency_roles)):
roles.append(ClubRole(club=club, order=i, name=role, is_board=True))
for i, role in enumerate(
simple_roles, start=len(presidency_roles) + len(board_roles)
):
roles.append(ClubRole(club=club, order=i, name=role))
ClubRole.objects.bulk_create(roles)
insta, fb, discord, _ = LinkType.objects.bulk_create(
[
LinkType(
name="instagram",
icon="fa-brands fa-square-instagram",
url_base="https://www.instagram.com",
),
LinkType(
name="facebook",
icon="fa-brands fa-facebook",
url_base="https://www.facebook.com",
),
LinkType(
name="discord",
icon="fa-brands fa-discord",
url_base="https://discord.gg",
),
LinkType(name="generic", icon="fa fa-link", url_base=""),
]
)
ClubLink.objects.bulk_create(
[
ClubLink(
name="insta AE",
url="https://www.instagram.com/ae_utbm/",
club=ae,
link_type=insta,
),
ClubLink(
name="insta activités AE",
url="https://www.instagram.com/activites_ae/",
club=ae,
link_type=insta,
),
ClubLink(
name="facebook AE",
url="https://www.facebook.com/ae_utbm",
club=ae,
link_type=fb,
),
ClubLink(
name="Discord",
url="https://discord.gg/QvTm3XJrHR",
club=ae,
link_type=discord,
),
]
)
return PopulatedClubs(ae=ae, troll=troll, pdf=pdf, refound=refound)
def _create_groups(self) -> PopulatedGroups:
perms = Permission.objects.all()
+42 -44
View File
@@ -1,4 +1,3 @@
import math
import random
from datetime import date, timedelta
from datetime import timezone as tz
@@ -12,12 +11,13 @@ from django.db.models import Count, Exists, Min, OuterRef, Subquery
from django.utils.timezone import localdate, make_aware, now
from faker import Faker
from club.models import Club, Membership
from club.models import Club, ClubRole, Membership
from core.models import Group, User, UserBan
from counter.models import (
Counter,
Customer,
Permanency,
Price,
Product,
ProductType,
Refilling,
@@ -35,17 +35,12 @@ class Command(BaseCommand):
super().__init__(*args, **kwargs)
self.faker = Faker("fr_FR")
def add_arguments(self, parser):
parser.add_argument(
"-n", "--nb-users", help="Number of users to create", type=int, default=600
)
def handle(self, *args, **options):
if not settings.DEBUG:
raise Exception("Never call this command in prod. Never.")
self.stdout.write("Creating users...")
users = self.create_users(options["nb_users"])
users = self.create_users()
self.create_bans(random.sample(users, k=len(users) // 200)) # 0.5% of users
subscribers = random.sample(users, k=int(0.8 * len(users)))
self.stdout.write("Creating subscriptions...")
@@ -85,7 +80,7 @@ class Command(BaseCommand):
self.stdout.write("Creating products...")
self.create_products()
self.stdout.write("Creating sales and refills...")
sellers = random.sample(users, len(users) // 10)
sellers = random.sample(list(User.objects.all()), 100)
self.create_sales(sellers)
self.stdout.write("Creating permanences...")
self.create_permanences(sellers)
@@ -94,7 +89,7 @@ class Command(BaseCommand):
self.stdout.write("Done")
def create_users(self, nb_users: int = 600) -> list[User]:
def create_users(self) -> list[User]:
# Create a single password hash for all users to make it faster.
# It's insecure as hell, but it's ok since it's only for dev purposes.
password = make_password("plop")
@@ -113,7 +108,7 @@ class Command(BaseCommand):
address=self.faker.address(),
password=password,
)
for _ in range(nb_users)
for _ in range(600)
]
# there may a duplicate or two
# Not a problem, we will just have 599 users instead of 600
@@ -178,20 +173,25 @@ class Command(BaseCommand):
Customer.objects.bulk_create(customers, ignore_conflicts=True)
def make_club(self, club: Club, members: list[User], old_members: list[User]):
def zip_roles(users: list[User]) -> Iterator[tuple[User, int]]:
roles = iter(sorted(settings.SITH_CLUB_ROLES.keys(), reverse=True))
roles: list[ClubRole] = list(club.roles.all())
def zip_roles(users: list[User]) -> Iterator[tuple[User, ClubRole]]:
important_roles = [r for r in roles if r.is_board]
important_roles.sort(key=lambda r: r.order)
simple_board_role = important_roles.pop()
member_roles = [r for r in roles if not r.is_board]
user_idx = 0
while (role := next(roles)) > 2:
for _role in important_roles:
# one member for each major role
yield users[user_idx], role
yield users[user_idx], _role
user_idx += 1
for _ in range(int(0.3 * (len(users) - user_idx))):
# 30% of the remaining in the board
yield users[user_idx], 2
yield users[user_idx], simple_board_role
user_idx += 1
for remaining in users[user_idx + 1 :]:
# everything else is a simple member
yield remaining, 1
yield remaining, random.choices(member_roles, weights=(0.8, 0.2))[0]
memberships = []
old_members = old_members.copy()
@@ -203,19 +203,14 @@ class Command(BaseCommand):
start_date=start,
end_date=self.faker.past_date(start),
user=old,
role=random.choice(list(settings.SITH_CLUB_ROLES.keys())),
role=random.choice(roles),
club=club,
)
)
for member, role in zip_roles(members):
start = self.faker.past_date("-1y")
memberships.append(
Membership(
start_date=start,
user=member,
role=role,
club=club,
)
Membership(start_date=start, user=member, role=role, club=club)
)
memberships = Membership.objects.bulk_create(memberships)
Membership._add_club_groups(memberships)
@@ -284,6 +279,7 @@ class Command(BaseCommand):
# 2/3 of the products are owned by AE
clubs = [ae, ae, ae, ae, ae, ae, *other_clubs]
products = []
prices = []
buying_groups = []
selling_places = []
for _ in range(200):
@@ -294,25 +290,28 @@ class Command(BaseCommand):
product_type=random.choice(categories),
code="".join(self.faker.random_letters(length=random.randint(4, 8))),
purchase_price=price,
selling_price=price,
special_selling_price=price - min(0.5, price),
club=random.choice(clubs),
limit_age=0 if random.random() > 0.2 else 18,
archived=bool(random.random() > 0.7),
archived=self.faker.boolean(60),
)
products.append(product)
# there will be products without buying groups
# but there are also such products in the real database
buying_groups.extend(
Product.buying_groups.through(product=product, group=group)
for group in random.sample(groups, k=random.randint(0, 3))
for i in range(random.randint(0, 3)):
product_price = Price(
amount=price, product=product, is_always_shown=self.faker.boolean()
)
# prices for non-subscribers will be higher than for subscribers
price *= 1.2
prices.append(product_price)
buying_groups.append(
Price.groups.through(price=product_price, group=groups[i])
)
selling_places.extend(
Counter.products.through(counter=counter, product=product)
for counter in random.sample(counters, random.randint(0, 4))
)
Product.objects.bulk_create(products)
Product.buying_groups.through.objects.bulk_create(buying_groups)
Price.objects.bulk_create(prices)
Price.groups.through.objects.bulk_create(buying_groups)
Counter.products.through.objects.bulk_create(selling_places)
def create_sales(self, sellers: list[User]):
@@ -326,7 +325,7 @@ class Command(BaseCommand):
)
)
)
products = list(Product.objects.all())
prices = list(Price.objects.select_related("product").all())
counters = list(
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette"])
)
@@ -336,14 +335,14 @@ class Command(BaseCommand):
# the longer the customer has existed, the higher the mean of nb_products
mu = 5 + (now().year - customer.since.year) * 2
nb_sales = max(0, int(random.normalvariate(mu=mu, sigma=mu * 5)))
favoured_products = random.sample(products, k=(random.randint(1, 5)))
favoured_prices = random.sample(prices, k=(random.randint(1, 5)))
favoured_counter = random.choice(counters)
this_customer_sales = []
for _ in range(nb_sales):
product = (
random.choice(favoured_products)
price = (
random.choice(favoured_prices)
if random.random() > 0.7
else random.choice(products)
else random.choice(prices)
)
counter = (
favoured_counter
@@ -352,11 +351,11 @@ class Command(BaseCommand):
)
this_customer_sales.append(
Selling(
product=product,
product=price.product,
counter=counter,
club_id=product.club_id,
club_id=price.product.club_id,
quantity=random.randint(1, 5),
unit_price=product.selling_price,
unit_price=price.amount,
seller=random.choice(sellers),
customer=customer,
date=make_aware(
@@ -416,9 +415,8 @@ class Command(BaseCommand):
Permanency.objects.bulk_create(perms)
def create_forums(self):
users = list(User.objects.all())
forumers = random.sample(users, math.ceil(len(users) / 10))
most_actives = random.sample(forumers, math.ceil(len(forumers) / 6))
forumers = random.sample(list(User.objects.all()), 100)
most_actives = random.sample(forumers, 10)
categories = list(Forum.objects.filter(is_category=True))
new_forums = [
Forum(name=self.faker.text(20), parent=random.choice(categories))
@@ -0,0 +1,47 @@
# Generated by Django 5.2.12 on 2026-05-01 08:59
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import F
def set_updated_at(apps: StateApps, schema_editor):
SithFile = apps.get_model("core", "SithFile")
SithFile.objects.update(updated_at=F("date"))
class Migration(migrations.Migration):
dependencies = [("core", "0049_user_whitelisted_users")]
operations = [
migrations.AlterField(
model_name="sithfile",
name="moderator",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="moderated_files",
to=settings.AUTH_USER_MODEL,
verbose_name="owner",
),
),
migrations.AlterField(
model_name="sithfile",
name="owner",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="owned_files",
to=settings.AUTH_USER_MODEL,
verbose_name="owner",
),
),
migrations.AddField(
model_name="sithfile",
name="updated_at",
field=models.DateTimeField(auto_now=True, verbose_name="updated at"),
),
migrations.RunPython(set_updated_at, reverse_code=migrations.RunPython.noop),
]
+3 -2
View File
@@ -853,7 +853,7 @@ class SithFile(models.Model):
User,
related_name="owned_files",
verbose_name=_("owner"),
on_delete=models.CASCADE,
on_delete=models.PROTECT,
)
edit_groups = models.ManyToManyField(
Group, related_name="editable_files", verbose_name=_("edit group"), blank=True
@@ -865,6 +865,7 @@ class SithFile(models.Model):
mime_type = models.CharField(_("mime type"), max_length=30)
size = models.IntegerField(_("size"), default=0)
date = models.DateTimeField(_("date"), default=timezone.now)
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
is_moderated = models.BooleanField(_("is moderated"), default=False)
moderator = models.ForeignKey(
User,
@@ -872,7 +873,7 @@ class SithFile(models.Model):
verbose_name=_("owner"),
null=True,
blank=True,
on_delete=models.CASCADE,
on_delete=models.SET_NULL,
)
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
is_in_sas = models.BooleanField(
@@ -28,7 +28,7 @@ export class Tab extends HTMLElement {
return html`
<button
role="tab"
?aria-selected=${this.active}
?aria-selected="${this.active}"
class="tab-header clickable ${this.active ? "active" : ""}"
@click="${() => this.setActive(true)}"
>
@@ -40,7 +40,7 @@ export class Tab extends HTMLElement {
return html`
<section
class="tab-section"
?hidden=${!this.active}
?hidden="${!this.active}"
>
${unsafeHTML(this.getContentHtml())}
</section>
@@ -47,9 +47,18 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
implements InheritedHtmlElement<K>
{
readonly inheritedTagName = tagName;
private readonly initializedAttribute = "component-initialized";
node: HTMLElementTagNameMap[K];
connectedCallback(autoAddNode?: boolean) {
// When nesting inherited elements, we might trigger the wrapping twice
// To avoid this, we tag a created element as initialized
// We then skip the initialization step and grab the inner content as the node
if (this.hasAttribute(this.initializedAttribute)) {
this.node = this.firstChild as HTMLElementTagNameMap[K];
return;
}
this.node = document.createElement(this.inheritedTagName);
const attributes: Attr[] = []; // We need to make a copy to delete while iterating
for (const attr of this.attributes) {
@@ -58,6 +67,8 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
}
}
this.setAttribute(this.initializedAttribute, "");
// We move compatible attributes to the child element
// This avoids weird inconsistencies between attributes
// when we manipulate the dom in the future
+1
View File
@@ -141,6 +141,7 @@ form {
display: block;
margin: calc(var(--nf-input-size) * 1.5) auto 10px;
line-height: 1;
white-space: nowrap;
.fields-centered {
padding: 10px 10px 0;
+23 -14
View File
@@ -271,7 +271,7 @@ body {
/*--------------------------------CONTENT------------------------------*/
#content {
padding: 1em 1%;
padding: 1.5em 3%;
box-shadow: $shadow-color 0 5px 10px;
background: $white-color;
overflow: auto;
@@ -398,6 +398,28 @@ body {
}
}
/* Fontawesome icons */
.fa-brands, .fa-link {
color: black;
}
.fa-facebook {
color: $faceblue;
}
.fa-discord {
color: $discordblurple;
}
.fa-square-instagram::before, .fa-instagram::before {
background: $instagradient;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.fa-bluesky, .fa-square-bluesky {
color: #0f73ff;
}
}
@media screen and (max-width: $small-devices) {
@@ -749,16 +771,3 @@ textarea {
vertical-align: middle;
}
}
/*--------------------------------JQuery-------------------------------*/
#club_detail {
.club_logo {
float: right;
img {
display: block;
max-height: 10em;
max-width: 10em;
}
}
}
+3 -3
View File
@@ -1,13 +1,13 @@
<div id="quick-notifications"
x-data="{
x-data='{
messages: [
{%- for message in messages -%}
{%- if not message.extra_tags -%}
{ tag: '{{ message.tags }}', text: '{{ message }}' },
{ tag: {{ message.tags|string|tojson }}, text: {{ message|string|tojson }} },
{%- endif -%}
{%- endfor -%}
]
}"
}'
@quick-notification-add="(e) => messages.push(e?.detail)"
@quick-notification-delete="messages = []">
<template x-for="(message, index) in messages">
+2 -1
View File
@@ -33,7 +33,8 @@
<a href="{{ url("core:file_detail", file_id=f.id) }}">{{ f.name }}</a><br/>
{% trans %}Full name: {% endtrans %}{{ f.get_parent_path()+'/'+f.name }}<br/>
{% trans %}Owner: {% endtrans %}{{ f.owner.get_display_name() }}<br/>
{% trans %}Date: {% endtrans %}{{ f.date|date(DATE_FORMAT) }} {{ f.date|time(TIME_FORMAT) }}<br/>
{% trans %}Date: {% endtrans %}
{{ f.date|date(DATE_FORMAT) }} {{ f.date|time(TIME_FORMAT) }}<br/>
</p>
<p><button
hx-get="{{ url('core:file_moderate', file_id=f.id) }}"
@@ -18,7 +18,7 @@
<span class="helptext">{{ form.is_viewable.help_text }}</span>
{{ form.is_viewable.errors }}
</fieldset>
<fieldset class="form-group" x-show="!isViewable">
<fieldset class="form-group" x-show="!isViewable" x-transition x-cloak>
{{ form.whitelisted_users.as_field_group() }}
</fieldset>
<fieldset class="form-group">
+4 -4
View File
@@ -23,10 +23,10 @@
</tr>
</thead>
<tbody>
{% for m in profile.memberships.filter(end_date=None).all() %}
{% for m in profile.memberships.ongoing().select_related("role") %}
<tr>
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
<td>{{ m.role.name }}</td>
<td>{{ m.description }}</td>
<td>{{ m.start_date }}</td>
{% if m.can_be_edited_by(user) %}
@@ -65,10 +65,10 @@
</tr>
</thead>
<tbody>
{% for m in profile.memberships.exclude(end_date=None).all() %}
{% for m in profile.memberships.exclude(end_date=None).select_related("role") %}
<tr>
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
<td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
<td>{{ m.role.name }}</td>
<td>{{ m.description }}</td>
<td>{{ m.start_date }}</td>
<td>{{ m.end_date }}</td>
@@ -3,7 +3,7 @@
<script-once type="module" src="{{ js }}"></script-once>
{% endfor %}
{% for css in statics.css %}
<link-once rel="stylesheet" type="text/css" href="{{ css }}" defer></link-once>
<link-once rel="stylesheet" type="text/css" href="{{ css }}"></link-once>
{% endfor %}
<{{ component }} name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %}>
+1 -1
View File
@@ -103,7 +103,7 @@ def add_attr(field: BoundField, attr: str):
if "=" not in d:
attrs["class"] = d
else:
key, val = d.split("=")
key, val = d.split("=", maxsplit=1)
attrs[key] = val
return field.as_widget(attrs=attrs)
-13
View File
@@ -1,13 +0,0 @@
import contextlib
import os
import pytest
from django.core.management import call_command
@pytest.mark.django_db
def test_populate_more(settings):
"""Just check that populate more doesn't crash"""
settings.DEBUG = True
with open(os.devnull, "w") as devnull, contextlib.redirect_stdout(devnull):
call_command("populate_more", "--nb-users", "50")
+4 -1
View File
@@ -11,7 +11,7 @@ from django.utils.timezone import now
from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects
from club.models import Club
from club.models import Club, Membership
from core.baker_recipes import board_user, subscriber_user
from core.markdown import markdown
from core.models import AnonymousUser, Page, PageRev, User
@@ -122,6 +122,9 @@ def test_page_revision_club_redirection(client: Client):
@pytest.mark.django_db
def test_viewable_by():
# remove existing pages to prevent side effect
# club pages are protected, so we must delete clubs first
Membership.objects.all().delete()
Club.objects.all().delete()
Page.objects.all().delete()
view_groups = [
[settings.SITH_GROUP_PUBLIC_ID],
+5 -4
View File
@@ -21,7 +21,7 @@ from core.baker_recipes import (
subscriber_user,
very_old_subscriber_user,
)
from core.models import AnonymousUser, Group, User
from core.models import AnonymousUser, Group, SithFile, User
from core.views import UserTabsMixin
from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Permanency, Refilling, Selling
@@ -34,6 +34,7 @@ class TestSearchUsers(TestCase):
def setUpTestData(cls):
# News.author has on_delete=PROTECT, so news must be deleted beforehand
News.objects.all().delete()
SithFile.objects.all().delete()
User.objects.all().delete()
user_recipe = Recipe(
User,
@@ -213,9 +214,9 @@ def test_user_invoice_with_multiple_items():
"""Test that annotate_total() works when invoices contain multiple items."""
user: User = subscriber_user.make()
item_recipe = Recipe(InvoiceItem, invoice=foreign_key(Recipe(Invoice, user=user)))
item_recipe.make(_quantity=3, quantity=1, product_unit_price=5)
item_recipe.make(_quantity=1, quantity=1, product_unit_price=5)
item_recipe.make(_quantity=2, quantity=1, product_unit_price=iter([5, 8]))
item_recipe.make(_quantity=3, quantity=1, unit_price=5)
item_recipe.make(_quantity=1, quantity=1, unit_price=5)
item_recipe.make(_quantity=2, quantity=1, unit_price=iter([5, 8]))
res = list(
Invoice.objects.filter(user=user)
.annotate_total()
+3 -57
View File
@@ -12,31 +12,20 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from __future__ import annotations
import hmac
from datetime import date, timedelta
# Image utils
from io import BytesIO
from typing import TYPE_CHECKING
from urllib.parse import urlencode
from typing import Final
import PIL
from django.conf import settings
from django.core.files.base import ContentFile
from django.utils.timezone import localdate
from PIL import ExifTags
from PIL.Image import Image, Resampling
if TYPE_CHECKING:
from _hashlib import HASH
from collections.abc import Buffer, Mapping, Sequence
from typing import Any, Callable, Final
from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest
from django.utils.timezone import localdate
from PIL.Image import Image, Resampling
RED_PIXEL_PNG: Final[bytes] = (
b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52"
@@ -188,22 +177,6 @@ def resize_image_explicit(
return ContentFile(content.getvalue())
def exif_auto_rotate(image):
for orientation in ExifTags.TAGS:
if ExifTags.TAGS[orientation] == "Orientation":
break
exif = dict(image._getexif().items())
if exif[orientation] == 3:
image = image.rotate(180, expand=True)
elif exif[orientation] == 6:
image = image.rotate(270, expand=True)
elif exif[orientation] == 8:
image = image.rotate(90, expand=True)
return image
def get_client_ip(request: HttpRequest) -> str | None:
headers = (
"X_FORWARDED_FOR", # Common header for proxies
@@ -215,30 +188,3 @@ def get_client_ip(request: HttpRequest) -> str | None:
return ip
return None
def hmac_hexdigest(
key: str | bytes,
data: Mapping[str, Any] | Sequence[tuple[str, Any]],
digest: str | Callable[[Buffer], HASH] = "sha512",
) -> str:
"""Return the hexdigest of the signature of the given data.
Args:
key: the HMAC key used for the signature
data: the data to sign
digest: a PEP247 hashing algorithm (by default, sha512)
Examples:
```python
data = {
"foo": 5,
"bar": "somevalue",
}
hmac_key = secrets.token_hex(64)
signature = hmac_hexdigest(hmac_key, data, "sha256")
```
"""
if isinstance(key, str):
key = key.encode()
return hmac.digest(key, urlencode(data).encode(), digest).hex()
+7 -1
View File
@@ -24,6 +24,7 @@ from counter.models import (
Eticket,
InvoiceCall,
Permanency,
Price,
Product,
ProductType,
Refilling,
@@ -32,19 +33,24 @@ from counter.models import (
)
class PriceInline(admin.TabularInline):
model = Price
autocomplete_fields = ("groups",)
@admin.register(Product)
class ProductAdmin(SearchModelAdmin):
list_display = (
"name",
"code",
"product_type",
"selling_price",
"archived",
"created_at",
"updated_at",
)
list_select_related = ("product_type",)
search_fields = ("name", "code")
inlines = [PriceInline]
@admin.register(ReturnableProduct)
+2 -6
View File
@@ -101,13 +101,9 @@ class ProductController(ControllerBase):
"""Get the detailed information about the products."""
return filters.filter(
Product.objects.select_related("club")
.prefetch_related("buying_groups")
.prefetch_related("prices", "prices__groups")
.select_related("product_type")
.order_by(
F("product_type__order").asc(nulls_last=True),
"product_type",
"name",
)
.order_by(F("product_type__order").asc(nulls_last=True), "name")
)
+2 -1
View File
@@ -2,10 +2,11 @@ from model_bakery.recipe import Recipe, foreign_key
from club.models import Club
from core.models import User
from counter.models import Counter, Product, Refilling, Selling
from counter.models import Counter, Price, Product, Refilling, Selling
counter_recipe = Recipe(Counter)
product_recipe = Recipe(Product, club=foreign_key(Recipe(Club)))
price_recipe = Recipe(Price, product=foreign_key(product_recipe))
sale_recipe = Recipe(
Selling,
product=foreign_key(product_recipe),
+50 -62
View File
@@ -1,12 +1,12 @@
import json
import math
import uuid
from collections import defaultdict
from datetime import date, datetime, timezone
from dateutil.relativedelta import relativedelta
from django import forms
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator
from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet
from django.utils.timezone import now
@@ -37,6 +37,7 @@ from counter.models import (
Customer,
Eticket,
InvoiceCall,
Price,
Product,
ProductFormula,
Refilling,
@@ -374,7 +375,21 @@ ScheduledProductActionFormSet = forms.modelformset_factory(
can_delete=True,
can_delete_extra=False,
extra=0,
)
ProductPriceFormSet = forms.inlineformset_factory(
parent_model=Product,
model=Price,
fields=["amount", "label", "groups", "is_always_shown"],
widgets={
"groups": AutoCompleteSelectMultipleGroup,
"is_always_shown": forms.CheckboxInput(attrs={"class": "switch"}),
},
absolute_max=None,
can_delete_extra=False,
min_num=1,
extra=0,
)
@@ -389,10 +404,7 @@ class ProductForm(forms.ModelForm):
"description",
"product_type",
"code",
"buying_groups",
"purchase_price",
"selling_price",
"special_selling_price",
"icon",
"club",
"limit_age",
@@ -407,8 +419,8 @@ class ProductForm(forms.ModelForm):
}
widgets = {
"product_type": AutoCompleteSelect,
"buying_groups": AutoCompleteSelectMultipleGroup,
"club": AutoCompleteSelectClub,
"tray": forms.CheckboxInput(attrs={"class": "switch"}),
}
counters = forms.ModelMultipleChoiceField(
@@ -418,50 +430,40 @@ class ProductForm(forms.ModelForm):
queryset=Counter.objects.all(),
)
def __init__(self, *args, instance=None, **kwargs):
super().__init__(*args, instance=instance, **kwargs)
def __init__(self, *args, prefix: str | None = None, instance=None, **kwargs):
super().__init__(*args, prefix=prefix, instance=instance, **kwargs)
self.fields["name"].widget.attrs["autofocus"] = "autofocus"
if self.instance.id:
self.fields["counters"].initial = self.instance.counters.all()
if hasattr(self.instance, "formula"):
self.formula_init(self.instance.formula)
self.price_formset = ProductPriceFormSet(
*args, instance=self.instance, prefix="price", **kwargs
)
self.action_formset = ScheduledProductActionFormSet(
*args, product=self.instance, **kwargs
*args, product=self.instance, prefix="action", **kwargs
)
def formula_init(self, formula: ProductFormula):
"""Part of the form initialisation specific to formula products."""
self.fields["selling_price"].help_text = _(
"This product is a formula. "
"Its price cannot be greater than the price "
"of the products constituting it, which is %(price)s"
) % {"price": formula.max_selling_price}
self.fields["special_selling_price"].help_text = _(
"This product is a formula. "
"Its special price cannot be greater than the price "
"of the products constituting it, which is %(price)s"
) % {"price": formula.max_special_selling_price}
for key, price in (
("selling_price", formula.max_selling_price),
("special_selling_price", formula.max_special_selling_price),
):
self.fields[key].widget.attrs["max"] = price
self.fields[key].validators.append(MaxValueValidator(price))
def is_valid(self):
return super().is_valid() and self.action_formset.is_valid()
return (
super().is_valid()
and self.price_formset.is_valid()
and self.action_formset.is_valid()
)
def save(self, *args, **kwargs) -> Product:
product = super().save(*args, **kwargs)
product.counters.set(self.cleaned_data["counters"])
for form in self.action_formset:
# if it's a creation, the product given in the formset
# wasn't a persisted instance.
# So if we tried to persist the scheduled actions in the current state,
# So if we tried to persist the related objects in the current state,
# they would be linked to no product, thus be completely useless
# To make it work, we have to replace
# the initial product with a persisted one
for form in self.action_formset:
form.set_product(product)
self.action_formset.save()
self.price_formset.save()
return product
@@ -484,18 +486,6 @@ class ProductFormulaForm(forms.ModelForm):
"the result and a part of the formula."
),
)
prices = [p.selling_price for p in cleaned_data["products"]]
special_prices = [p.special_selling_price for p in cleaned_data["products"]]
selling_price = cleaned_data["result"].selling_price
special_selling_price = cleaned_data["result"].special_selling_price
if selling_price > sum(prices) or special_selling_price > sum(special_prices):
self.add_error(
"result",
_(
"The result cannot be more expensive "
"than the total of the other products."
),
)
return cleaned_data
@@ -546,48 +536,47 @@ class CloseCustomerAccountForm(forms.Form):
)
class BasketProductForm(forms.Form):
class BasketItemForm(forms.Form):
quantity = forms.IntegerField(min_value=1, required=True)
id = forms.IntegerField(min_value=0, required=True)
price_id = forms.IntegerField(min_value=0, required=True)
def __init__(
self,
customer: Customer,
counter: Counter,
allowed_products: dict[int, Product],
allowed_prices: dict[int, Price],
*args,
**kwargs,
):
self.customer = customer # Used by formset
self.counter = counter # Used by formset
self.allowed_products = allowed_products
self.allowed_prices = allowed_prices
super().__init__(*args, **kwargs)
def clean_id(self):
data = self.cleaned_data["id"]
def clean_price_id(self):
data = self.cleaned_data["price_id"]
# We store self.product so we can use it later on the formset validation
# We store self.price so we can use it later on the formset validation
# And also in the global clean
self.product = self.allowed_products.get(data, None)
if self.product is None:
self.price = self.allowed_prices.get(data, None)
if self.price is None:
raise forms.ValidationError(
_("The selected product isn't available for this user")
)
return data
def clean(self):
cleaned_data = super().clean()
if len(self.errors) > 0:
return
return cleaned_data
# Compute prices
cleaned_data["bonus_quantity"] = 0
if self.product.tray:
if self.price.product.tray:
cleaned_data["bonus_quantity"] = math.floor(
cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE
)
cleaned_data["total_price"] = self.product.price * (
cleaned_data["total_price"] = self.price.amount * (
cleaned_data["quantity"] - cleaned_data["bonus_quantity"]
)
@@ -611,8 +600,8 @@ class BaseBasketForm(forms.BaseFormSet):
raise forms.ValidationError(_("Submitted basket is invalid"))
def _check_product_are_unique(self):
product_ids = {form.cleaned_data["id"] for form in self.forms}
if len(product_ids) != len(self.forms):
price_ids = {form.cleaned_data["price_id"] for form in self.forms}
if len(price_ids) != len(self.forms):
raise forms.ValidationError(_("Duplicated product entries."))
def _check_enough_money(self, counter: Counter, customer: Customer):
@@ -622,10 +611,9 @@ class BaseBasketForm(forms.BaseFormSet):
def _check_recorded_products(self, customer: Customer):
"""Check for, among other things, ecocups and pitchers"""
items = {
form.cleaned_data["id"]: form.cleaned_data["quantity"]
for form in self.forms
}
items = defaultdict(int)
for form in self.forms:
items[form.price.product_id] += form.cleaned_data["quantity"]
ids = list(items.keys())
returnables = list(
ReturnableProduct.objects.filter(
@@ -651,7 +639,7 @@ class BaseBasketForm(forms.BaseFormSet):
BasketForm = forms.formset_factory(
BasketProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
BasketItemForm, formset=BaseBasketForm, absolute_max=None, min_num=1
)
+2 -1
View File
@@ -32,8 +32,9 @@ class Migration(migrations.Migration):
(
"result",
models.OneToOneField(
help_text="The formula product.",
help_text="The product got with the formula.",
on_delete=django.db.models.deletion.CASCADE,
related_name="formula",
to="counter.product",
verbose_name="result product",
),
+149
View File
@@ -0,0 +1,149 @@
# Generated by Django 5.2.11 on 2026-02-18 13:30
import django.db.models.deletion
from django.db import migrations, models
from django.db.migrations.state import StateApps
import counter.fields
def migrate_prices(apps: StateApps, schema_editor):
Product = apps.get_model("counter", "Product")
Price = apps.get_model("counter", "Price")
prices = [
Price(
amount=p.selling_price,
product=p,
created_at=p.created_at,
updated_at=p.updated_at,
)
for p in Product.objects.all()
]
Price.objects.bulk_create(prices)
groups = [
Price.groups.through(price=price, group=group)
for price in Price.objects.select_related("product").prefetch_related(
"product__buying_groups"
)
for group in price.product.buying_groups.all()
]
Price.groups.through.objects.bulk_create(groups)
class Migration(migrations.Migration):
dependencies = [
("core", "0048_alter_user_options"),
("counter", "0038_countersellers"),
]
operations = [
migrations.CreateModel(
name="Price",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"amount",
counter.fields.CurrencyField(
decimal_places=2, max_digits=12, verbose_name="amount"
),
),
(
"is_always_shown",
models.BooleanField(
default=False,
help_text=(
"If this option is enabled, "
"people will see this price and be able to pay it, "
"even if another cheaper price exists. "
"Else it will visible only if it is the cheapest available price."
),
verbose_name="always show",
),
),
(
"label",
models.CharField(
default="",
help_text=(
"A short label for easier differentiation "
"if a user can see multiple prices."
),
max_length=32,
verbose_name="label",
blank=True,
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="updated at"),
),
(
"groups",
models.ManyToManyField(
related_name="prices", to="core.group", verbose_name="groups"
),
),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="prices",
to="counter.product",
verbose_name="product",
),
),
],
options={"verbose_name": "price"},
),
migrations.AlterField(
model_name="product",
name="tray",
field=models.BooleanField(
default=False,
help_text="Buy five, get the sixth free",
verbose_name="tray price",
),
),
migrations.RunPython(migrate_prices, reverse_code=migrations.RunPython.noop),
migrations.RemoveField(model_name="product", name="selling_price"),
migrations.RemoveField(model_name="product", name="special_selling_price"),
migrations.AlterField(
model_name="product",
name="description",
field=models.TextField(blank=True, default="", verbose_name="description"),
),
migrations.AlterField(
model_name="product",
name="product_type",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="products",
to="counter.producttype",
verbose_name="product type",
),
),
migrations.AlterField(
model_name="productformula",
name="result",
field=models.OneToOneField(
help_text="The product got with the formula.",
on_delete=django.db.models.deletion.CASCADE,
related_name="formula",
to="counter.product",
verbose_name="result product",
),
),
]
+92 -88
View File
@@ -22,7 +22,7 @@ import string
from datetime import date, datetime, timedelta
from datetime import timezone as tz
from decimal import Decimal
from typing import Literal, Self
from typing import TYPE_CHECKING, Literal, Self
from dict2xml import dict2xml
from django.conf import settings
@@ -47,6 +47,9 @@ from core.utils import get_start_of_semester
from counter.fields import CurrencyField
from subscription.models import Subscription
if TYPE_CHECKING:
from collections.abc import Sequence
def get_eboutic() -> Counter:
return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
@@ -157,14 +160,7 @@ class Customer(models.Model):
@property
def can_buy(self) -> bool:
"""Check if whether this customer has the right to purchase any item.
This must be not confused with the Product.can_be_sold_to(user)
method as the present method returns an information
about a customer whereas the other tells something
about the relation between a User (not a Customer,
don't mix them) and a Product.
"""
"""Check if whether this customer has the right to purchase any item."""
subscription = self.user.subscriptions.order_by("subscription_end").last()
if subscription is None:
return False
@@ -363,13 +359,13 @@ class Product(models.Model):
QUANTITY_FOR_TRAY_PRICE = 6
name = models.CharField(_("name"), max_length=64)
description = models.TextField(_("description"), default="")
description = models.TextField(_("description"), blank=True, default="")
product_type = models.ForeignKey(
ProductType,
related_name="products",
verbose_name=_("product type"),
null=True,
blank=True,
blank=False,
on_delete=models.SET_NULL,
)
code = models.CharField(_("code"), max_length=16, blank=True)
@@ -377,11 +373,6 @@ class Product(models.Model):
_("purchase price"),
help_text=_("Initial cost of purchasing the product"),
)
selling_price = CurrencyField(_("selling price"))
special_selling_price = CurrencyField(
_("special selling price"),
help_text=_("Price for barmen during their permanence"),
)
icon = ResizedImageField(
height=70,
force_format="WEBP",
@@ -394,7 +385,9 @@ class Product(models.Model):
Club, related_name="products", verbose_name=_("club"), on_delete=models.CASCADE
)
limit_age = models.IntegerField(_("limit age"), default=0)
tray = models.BooleanField(_("tray price"), default=False)
tray = models.BooleanField(
_("tray price"), help_text=_("Buy five, get the sixth free"), default=False
)
buying_groups = models.ManyToManyField(
Group, related_name="products", verbose_name=_("buying groups"), blank=True
)
@@ -419,41 +412,77 @@ class Product(models.Model):
pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID
) or user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
def can_be_sold_to(self, user: User) -> bool:
"""Check if whether the user given in parameter has the right to buy
this product or not.
This must be not confused with the Customer.can_buy()
method as the present method returns an information
about the relation between a User and a Product,
whereas the other tells something about a Customer
(and not a user, they are not the same model).
class PriceQuerySet(models.QuerySet):
def for_user(self, user: User) -> Self:
age = user.age
if user.is_banned_alcohol:
age = min(age, 17)
return self.filter(
Q(is_always_shown=True, groups__in=user.all_groups)
| Q(
id=Subquery(
Price.objects.filter(
product_id=OuterRef("product_id"), groups__in=user.all_groups
)
.order_by("amount")
.values("id")[:1]
)
),
product__archived=False,
product__limit_age__lte=age,
).distinct()
Returns:
True if the user can buy this product else False
Warning:
This performs a db query, thus you can quickly have
a N+1 queries problem if you call it in a loop.
Hopefully, you can avoid that if you prefetch the buying_groups :
class Price(models.Model):
amount = CurrencyField(_("amount"))
product = models.ForeignKey(
Product,
verbose_name=_("product"),
related_name="prices",
on_delete=models.CASCADE,
)
groups = models.ManyToManyField(
Group, verbose_name=_("groups"), related_name="prices"
)
is_always_shown = models.BooleanField(
_("always show"),
help_text=_(
"If this option is enabled, "
"people will see this price and be able to pay it, "
"even if another cheaper price exists. "
"Else it will visible only if it is the cheapest available price."
),
default=False,
)
label = models.CharField(
_("label"),
help_text=_(
"A short label for easier differentiation "
"if a user can see multiple prices."
),
max_length=32,
default="",
blank=True,
)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
```python
user = User.objects.get(username="foobar")
products = [
p
for p in Product.objects.prefetch_related("buying_groups")
if p.can_be_sold_to(user)
]
```
"""
buying_groups = list(self.buying_groups.all())
if not buying_groups:
return True
return any(user.is_in_group(pk=group.id) for group in buying_groups)
objects = PriceQuerySet.as_manager()
class Meta:
verbose_name = _("price")
def __str__(self):
if not self.label:
return f"{self.product.name} ({self.amount}€)"
return f"{self.product.name} {self.label} ({self.amount}€)"
@property
def profit(self):
return self.selling_price - self.purchase_price
def full_label(self):
if not self.label:
return self.product.name
return f"{self.product.name} \u2013 {self.label}"
class ProductFormula(models.Model):
@@ -474,18 +503,6 @@ class ProductFormula(models.Model):
def __str__(self):
return self.result.name
@cached_property
def max_selling_price(self) -> float:
# iterating over all products is less efficient than doing
# a simple aggregation, but this method is likely to be used in
# coordination with `max_special_selling_price`,
# and Django caches the result of the `all` queryset.
return sum(p.selling_price for p in self.products.all())
@cached_property
def max_special_selling_price(self) -> float:
return sum(p.special_selling_price for p in self.products.all())
class CounterQuerySet(models.QuerySet):
def annotate_has_barman(self, user: User) -> Self:
@@ -583,7 +600,7 @@ class Counter(models.Model):
if user.is_anonymous:
return False
mem = self.club.get_membership_for(user)
if mem and mem.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
if mem and mem.role.is_presidency:
return True
return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
@@ -716,35 +733,20 @@ class Counter(models.Model):
# but they share the same primary key
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
def get_products_for(self, customer: Customer) -> list[Product]:
"""
Get all allowed products for the provided customer on this counter
Prices will be annotated
"""
products = (
self.products.filter(archived=False)
.select_related("product_type")
.prefetch_related("buying_groups")
def get_prices_for(
self, customer: Customer, *, order_by: Sequence[str] | None = None
) -> list[Price]:
qs = (
Price.objects.filter(
product__counters=self, product__product_type__isnull=False
)
# Only include age appropriate products
age = customer.user.age
if customer.user.is_banned_alcohol:
age = min(age, 17)
products = products.filter(limit_age__lte=age)
# Compute special price for customer if he is a barmen on that bar
if self.customer_is_barman(customer):
products = products.annotate(price=F("special_selling_price"))
else:
products = products.annotate(price=F("selling_price"))
return [
product
for product in products.all()
if product.can_be_sold_to(customer.user)
]
.for_user(customer.user)
.select_related("product", "product__product_type")
.prefetch_related("groups")
)
if order_by:
qs = qs.order_by(*order_by)
return list(qs)
class CounterSellers(models.Model):
@@ -1025,7 +1027,9 @@ class Selling(models.Model):
event = self.product.eticket.event_title or _("Unknown event")
subject = _("Eticket bought for the event %(event)s") % {"event": event}
message_html = _(
"You bought an eticket for the event %(event)s.\nYou can download it directly from this link %(eticket)s.\nYou can also retrieve all your e-tickets on your account page %(url)s."
"You bought an eticket for the event %(event)s.\n"
"You can download it directly from this link %(eticket)s.\n"
"You can also retrieve all your e-tickets on your account page %(url)s."
) % {
"event": event,
"url": (
+9 -4
View File
@@ -6,8 +6,8 @@ from ninja import FilterLookup, FilterSchema, ModelSchema, Schema
from pydantic import model_validator
from club.schemas import SimpleClubSchema
from core.schemas import GroupSchema, NonEmptyStr, SimpleUserSchema
from counter.models import Counter, Product, ProductType
from core.schemas import NonEmptyStr, SimpleUserSchema
from counter.models import Counter, Price, Product, ProductType
class CounterSchema(ModelSchema):
@@ -66,6 +66,12 @@ class SimpleProductSchema(ModelSchema):
fields = ["id", "name", "code"]
class ProductPriceSchema(ModelSchema):
class Meta:
model = Price
fields = ["amount", "groups"]
class ProductSchema(ModelSchema):
class Meta:
model = Product
@@ -75,13 +81,12 @@ class ProductSchema(ModelSchema):
"code",
"description",
"purchase_price",
"selling_price",
"icon",
"limit_age",
"archived",
]
buying_groups: list[GroupSchema]
prices: list[ProductPriceSchema]
club: SimpleClubSchema
product_type: SimpleProductTypeSchema | None
url: str
+4 -5
View File
@@ -1,12 +1,11 @@
import type { Product } from "#counter:counter/types.ts";
import type { CounterItem } from "#counter:counter/types";
export class BasketItem {
quantity: number;
product: Product;
quantityForTrayPrice: number;
product: CounterItem;
errors: string[];
constructor(product: Product, quantity: number) {
constructor(product: CounterItem, quantity: number) {
this.quantity = quantity;
this.product = product;
this.errors = [];
@@ -20,6 +19,6 @@ export class BasketItem {
}
sum(): number {
return (this.quantity - this.getBonusQuantity()) * this.product.price;
return (this.quantity - this.getBonusQuantity()) * this.product.price.amount;
}
}
@@ -1,11 +1,12 @@
import { AlertMessage } from "#core:utils/alert-message.ts";
import { BasketItem } from "#counter:counter/basket.ts";
import { AlertMessage } from "#core:utils/alert-message";
import { BasketItem } from "#counter:counter/basket";
import type {
CounterConfig,
CounterItem,
ErrorMessage,
ProductFormula,
} from "#counter:counter/types.ts";
import type { CounterProductSelect } from "./components/counter-product-select-index.ts";
} from "#counter:counter/types";
import type { CounterProductSelect } from "./components/counter-product-select-index";
document.addEventListener("alpine:init", () => {
Alpine.data("counter", (config: CounterConfig) => ({
@@ -63,8 +64,10 @@ document.addEventListener("alpine:init", () => {
},
checkFormulas() {
// Try to find a formula.
// A formula is found if all its elements are already in the basket
const products = new Set(
Object.keys(this.basket).map((i: string) => Number.parseInt(i, 10)),
Object.values(this.basket).map((item: BasketItem) => item.product.productId),
);
const formula: ProductFormula = config.formulas.find((f: ProductFormula) => {
return f.products.every((p: number) => products.has(p));
@@ -72,22 +75,29 @@ document.addEventListener("alpine:init", () => {
if (formula === undefined) {
return;
}
// Now that the formula is found, remove the items composing it from the basket
for (const product of formula.products) {
const key = product.toString();
const key = Object.entries(this.basket).find(
([_, i]: [string, BasketItem]) => i.product.productId === product,
)[0];
this.basket[key].quantity -= 1;
if (this.basket[key].quantity <= 0) {
this.removeFromBasket(key);
}
}
// Then add the result product of the formula to the basket
const result = Object.values(config.products)
.filter((item: CounterItem) => item.productId === formula.result)
.reduce((acc, curr) => (acc.price.amount < curr.price.amount ? acc : curr));
this.addToBasket(result.price.id, 1);
this.alertMessage.display(
interpolate(
gettext("Formula %(formula)s applied"),
{ formula: config.products[formula.result.toString()].name },
{ formula: result.name },
true,
),
{ success: true },
);
this.addToBasket(formula.result.toString(), 1);
},
getBasketSize() {
@@ -1,13 +1,9 @@
import { showSaveFilePicker } from "native-file-system-adapter";
import type TomSelect from "tom-select";
import { paginated } from "#core:utils/api.ts";
import { csv } from "#core:utils/csv.ts";
import {
getCurrentUrlParams,
History,
updateQueryString,
} from "#core:utils/history.ts";
import type { NestedKeyOf } from "#core:utils/types.ts";
import { paginated } from "#core:utils/api";
import { csv } from "#core:utils/csv";
import { getCurrentUrlParams, History, updateQueryString } from "#core:utils/history";
import type { NestedKeyOf } from "#core:utils/types";
import {
type ProductSchema,
type ProductSearchProductsDetailedData,
@@ -20,6 +16,9 @@ type GroupedProducts = Record<ProductType, ProductSchema[]>;
const defaultPageSize = 100;
const defaultPage = 1;
// biome-ignore lint/style/useNamingConvention: api is snake case
type ProductWithPriceSchema = ProductSchema & { selling_price: string };
/**
* Keys of the properties to include in the CSV.
*/
@@ -34,7 +33,7 @@ const csvColumns = [
"purchase_price",
"selling_price",
"archived",
] as NestedKeyOf<ProductSchema>[];
] as NestedKeyOf<ProductWithPriceSchema>[];
/**
* Title of the csv columns.
@@ -175,7 +174,16 @@ document.addEventListener("alpine:init", () => {
this.nbPages > 1
? await paginated(productSearchProductsDetailed, this.getQueryParams())
: Object.values<ProductSchema[]>(this.products).flat();
const content = csv.stringify(products, {
// CSV cannot represent nested data
// so we create a row for each price of each product.
const productsWithPrice: ProductWithPriceSchema[] = products.flatMap(
(product: ProductSchema) =>
product.prices.map((price) =>
// biome-ignore lint/style/useNamingConvention: API is snake_case
Object.assign(product, { selling_price: price.amount }),
),
);
const content = csv.stringify(productsWithPrice, {
columns: csvColumns,
titleRow: csvColumnTitles,
});
+10 -5
View File
@@ -2,7 +2,7 @@ export type ErrorMessage = string;
export interface InitialFormData {
/* Used to refill the form when the backend raises an error */
id?: keyof Record<string, Product>;
id?: keyof Record<string, CounterItem>;
quantity?: number;
errors?: string[];
}
@@ -15,17 +15,22 @@ export interface ProductFormula {
export interface CounterConfig {
customerBalance: number;
customerId: number;
products: Record<string, Product>;
products: Record<string, CounterItem>;
formulas: ProductFormula[];
formInitial: InitialFormData[];
cancelUrl: string;
}
export interface Product {
id: string;
interface Price {
id: number;
amount: number;
}
export interface CounterItem {
productId: number;
price: Price;
code: string;
name: string;
price: number;
hasTrayPrice: boolean;
quantityForTrayPrice: number;
}
+33 -31
View File
@@ -6,10 +6,10 @@
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" type="text/css" href="{{ static('counter/css/counter-click.scss') }}" defer></link>
<link rel="stylesheet" type="text/css" href="{{ static('bundled/core/components/ajax-select-index.css') }}" defer></link>
<link rel="stylesheet" type="text/css" href="{{ static('core/components/ajax-select.scss') }}" defer></link>
<link rel="stylesheet" type="text/css" href="{{ static('core/components/tabs.scss') }}" defer></link>
<link rel="stylesheet" href="{{ static('counter/css/counter-click.scss') }}">
<link rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}">
<link rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
<link rel="stylesheet" href="{{ static('core/components/tabs.scss') }}">
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
{% endblock %}
@@ -65,10 +65,10 @@
<option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option>
<option value="ANN">{% trans %}Cancel (ANN){% endtrans %}</option>
</optgroup>
{%- for category in categories.keys() -%}
{%- for category, prices in categories.items() -%}
<optgroup label="{{ category }}">
{%- for product in categories[category] -%}
<option value="{{ product.id }}">{{ product }}</option>
{%- for price in prices -%}
<option value="{{ price.id }}">{{ price.full_label }}</option>
{%- endfor -%}
</optgroup>
{%- endfor -%}
@@ -103,24 +103,25 @@
</div>
<ul>
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
<template x-for="(item, index) in Object.values(basket)" :key="item.product.id">
<template x-for="(item, index) in Object.values(basket)" :key="item.product.price.id">
<li>
<template x-for="error in item.errors">
<div class="alert alert-red" x-text="error">
</div>
</template>
<button @click.prevent="addToBasket(item.product.id, -1)">-</button>
<button @click.prevent="addToBasket(item.product.price.id, -1)">-</button>
<span class="quantity" x-text="item.quantity"></span>
<button @click.prevent="addToBasket(item.product.id, 1)">+</button>
<button @click.prevent="addToBasket(item.product.price.id, 1)">+</button>
<span x-text="item.product.name"></span> :
<span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })">€</span>
<span x-show="item.getBonusQuantity() > 0" x-text="`${item.getBonusQuantity()} x P`"></span>
<span x-show="item.getBonusQuantity() > 0"
x-text="`${item.getBonusQuantity()} x P`"></span>
<button
class="remove-item"
@click.prevent="removeFromBasket(item.product.id)"
@click.prevent="removeFromBasket(item.product.price.id)"
><i class="fa fa-trash-can delete-action"></i></button>
<input
@@ -133,9 +134,9 @@
>
<input
type="hidden"
:value="item.product.id"
:id="`id_form-${index}-id`"
:name="`form-${index}-id`"
:value="item.product.price.id"
:id="`id_form-${index}-price_id`"
:name="`form-${index}-price_id`"
required
readonly
>
@@ -201,30 +202,30 @@
</div>
<div id="products">
{% if not products %}
{% if not prices %}
<div class="alert alert-red">
{% trans %}No products available on this counter for this user{% endtrans %}
</div>
{% else %}
<ui-tab-group>
{% for category in categories.keys() -%}
{% for category, prices in categories.items() -%}
<ui-tab title="{{ category }}" {% if loop.index == 1 -%}active{%- endif -%}>
<h5 class="margin-bottom">{{ category }}</h5>
<div class="row gap-2x">
{% for product in categories[category] -%}
<button class="card shadow" @click="addToBasket('{{ product.id }}', 1)">
{% for price in prices -%}
<button class="card shadow" @click="addToBasket('{{ price.id }}', 1)">
<img
class="card-image"
alt="image de {{ product.name }}"
{% if product.icon %}
src="{{ product.icon.url }}"
alt="image de {{ price.full_label }}"
{% if price.product.icon %}
src="{{ price.product.icon.url }}"
{% else %}
src="{{ static('core/img/na.gif') }}"
{% endif %}
/>
<span class="card-content">
<strong class="card-title">{{ product.name }}</strong>
<p>{{ product.price }} €<br>{{ product.code }}</p>
<strong class="card-title">{{ price.full_label }}</strong>
<p>{{ price.amount }} €<br>{{ price.product.code }}</p>
</span>
</button>
{%- endfor %}
@@ -241,13 +242,14 @@
{{ super() }}
<script>
const products = {
{%- for product in products -%}
{{ product.id }}: {
id: "{{ product.id }}",
name: "{{ product.name }}",
price: {{ product.price }},
hasTrayPrice: {{ product.tray | tojson }},
quantityForTrayPrice: {{ product.QUANTITY_FOR_TRAY_PRICE }},
{%- for price in prices -%}
{{ price.id }}: {
productId: {{ price.product_id }},
price: { id: "{{ price.id }}", amount: {{ price.amount }} },
code: "{{ price.product.code }}",
name: "{{ price.full_label }}",
hasTrayPrice: {{ price.product.tray | tojson }},
quantityForTrayPrice: {{ price.product.QUANTITY_FOR_TRAY_PRICE }},
},
{%- endfor -%}
};
+1 -5
View File
@@ -49,14 +49,10 @@
<strong class="card-title">{{ formula.result.name }}</strong>
<p>
{% for p in formula.products.all() %}
<i>{{ p.code }} ({{ p.selling_price }})</i>
<i>{{ p.name }} ({{ p.code }})</i>
{% if not loop.last %}+{% endif %}
{% endfor %}
</p>
<p>
{{ formula.result.selling_price }}
({% trans %}instead of{% endtrans %} {{ formula.max_selling_price}} €)
</p>
</div>
{% if user.has_perm("counter.delete_productformula") %}
<button
+93 -2
View File
@@ -39,6 +39,49 @@
{% endmacro %}
{% macro price_form(form) %}
<fieldset>
{{ form.non_field_errors() }}
<div class="form-group row gap-2x">
<div>{{ form.amount.as_field_group() }}</div>
<div>
{{ form.label.errors }}
<label for="{{ form.label.id_for_label }}">{{ form.label.label }}</label>
{{ form.label }}
<span class="helptext">{{ form.label.help_text }}</span>
</div>
<div class="grow">{{ form.groups.as_field_group() }}</div>
</div>
<div class="form-group">
<div>
{{ form.is_always_shown.errors }}
<div class="row gap">
{{ form.is_always_shown }}
<label for="{{ form.is_always_shown.id_for_label }}">{{ form.is_always_shown.label }}</label>
</div>
<span class="helptext">{{ form.is_always_shown.help_text }}</span>
</div>
</div>
{%- if form.DELETE -%}
<div class="form-group row gap">
{{ form.DELETE.as_field_group() }}
</div>
{%- else -%}
<br>
<button
class="btn btn-grey"
@click.prevent="removeForm($event.target.closest('fieldset').parentElement)"
>
<i class="fa fa-minus"></i> {% trans %}Remove price{% endtrans %}
</button>
{%- endif -%}
{%- for field in form.hidden_fields() -%}
{{ field }}
{%- endfor -%}
</fieldset>
<hr class="margin-bottom">
{% endmacro %}
{% block content %}
{% if object %}
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
@@ -49,7 +92,54 @@
{% endif %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p() }}
{{ form.non_field_errors() }}
<fieldset class="row gap">
<div>{{ form.name.as_field_group() }}</div>
<div>{{ form.code.as_field_group() }}</div>
</fieldset>
<fieldset>
<div class="form-group">{{ form.description.as_field_group() }}</div>
</fieldset>
<fieldset class="row gap">
<div>{{ form.club.as_field_group() }}</div>
<div>{{ form.product_type.as_field_group() }}</div>
</fieldset>
<fieldset><div>{{ form.icon.as_field_group() }}</div></fieldset>
<fieldset><div>{{ form.purchase_price.as_field_group() }}</div></fieldset>
<fieldset>
<div>{{ form.limit_age.as_field_group() }}</div>
</fieldset>
<fieldset>
<div class="row gap">
{{ form.tray }}
<div>
{{ form.tray.label_tag() }}
<span class="helptext">{{ form.tray.help_text }}</span>
</div>
</div>
</fieldset>
<fieldset><div>{{ form.counters.as_field_group() }}</div></fieldset>
<h3 class="margin-bottom">{% trans %}Prices{% endtrans %}</h3>
<div x-data="dynamicFormSet({ prefix: '{{ form.price_formset.prefix }}' })">
{{ form.price_formset.management_form }}
<div x-ref="formContainer">
{%- for form in form.price_formset.forms -%}
<div>
{{ price_form(form) }}
</div>
{%- endfor -%}
</div>
<template x-ref="formTemplate">
<div>
{{ price_form(form.price_formset.empty_form) }}
</div>
</template>
<button class="btn btn-grey" @click.prevent="addForm()">
<i class="fa fa-plus"></i> {% trans %}Add a price{% endtrans %}
</button>
</div>
<br />
@@ -64,7 +154,7 @@
</em>
</p>
<div x-data="dynamicFormSet" class="margin-bottom">
<div x-data="dynamicFormSet({ prefix: '{{ form.action_formset.prefix }}' })" class="margin-bottom">
{{ form.action_formset.management_form }}
<div x-ref="formContainer">
{%- for f in form.action_formset.forms -%}
@@ -78,6 +168,7 @@
<i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %}
</button>
</div>
<div class="row gap margin-bottom">{{ form.archived.as_field_group() }}</div>
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock %}
+1 -1
View File
@@ -108,7 +108,7 @@
</template>
<span class="card-content">
<strong class="card-title" x-text="`${p.name} (${p.code})`"></strong>
<p x-text="`${p.selling_price} €`"></p>
<p x-text="`${p.prices.map((p) => p.amount).join(' ')} €`"></p>
</span>
</a>
</template>
+8 -7
View File
@@ -16,7 +16,7 @@ from counter.forms import (
ScheduledProductActionForm,
ScheduledProductActionFormSet,
)
from counter.models import Product, ScheduledProductAction
from counter.models import Product, ProductType, ScheduledProductAction
@pytest.mark.django_db
@@ -47,18 +47,19 @@ def test_create_actions_alongside_product():
form = ProductForm(
data={
"name": "foo",
"description": "bar",
"product_type": product.product_type_id,
"product_type": ProductType.objects.first(),
"club": product.club_id,
"code": "FOO",
"purchase_price": 1.0,
"selling_price": 1.0,
"special_selling_price": 1.0,
"limit_age": 0,
"form-TOTAL_FORMS": "2",
"form-INITIAL_FORMS": "0",
"form-0-task": "counter.tasks.archive_product",
"form-0-trigger_at": trigger_at,
"price-TOTAL_FORMS": "0",
"price-INITIAL_FORMS": "0",
"action-TOTAL_FORMS": "1",
"action-INITIAL_FORMS": "0",
"action-0-task": "counter.tasks.archive_product",
"action-0-trigger_at": trigger_at,
},
)
assert form.is_valid()
+96 -69
View File
@@ -20,7 +20,6 @@ import pytest
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib.auth.models import Permission, make_password
from django.core.cache import cache
from django.http import HttpResponse
from django.shortcuts import resolve_url
from django.test import Client, TestCase
@@ -32,15 +31,15 @@ from model_bakery import baker
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects
from club.models import Membership
from club.models import ClubRole, Membership
from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user
from core.models import BanGroup, User
from counter.baker_recipes import product_recipe, sale_recipe
from core.models import BanGroup, Group, User
from counter.baker_recipes import price_recipe, product_recipe, sale_recipe
from counter.models import (
Counter,
Customer,
Permanency,
Product,
ProductType,
Refilling,
ReturnableProduct,
Selling,
@@ -88,7 +87,7 @@ class TestFullClickBase(TestCase):
Membership,
start_date=now() - timedelta(days=30),
club=cls.club_counter.club,
role=settings.SITH_CLUB_ROLES_ID["Board member"],
role=baker.make(ClubRole, club=cls.club_counter.club, is_board=True),
user=cls.club_admin,
)
@@ -204,7 +203,7 @@ class TestRefilling(TestFullClickBase):
@dataclass
class BasketItem:
id: int | None = None
price_id: int | None = None
quantity: int | None = None
def to_form(self, index: int) -> dict[str, str]:
@@ -236,38 +235,59 @@ class TestCounterClick(TestFullClickBase):
cls.banned_counter_customer.ban_groups.add(
BanGroup.objects.get(pk=settings.SITH_GROUP_BANNED_COUNTER_ID)
)
subscriber_group = Group.objects.get(id=settings.SITH_GROUP_SUBSCRIBERS_ID)
old_subscriber_group = Group.objects.get(
id=settings.SITH_GROUP_OLD_SUBSCRIBERS_ID
)
_product_recipe = product_recipe.extend(product_type=baker.make(ProductType))
cls.gift = product_recipe.make(
selling_price="-1.5",
special_selling_price="-1.5",
cls.gift = price_recipe.make(
amount=-1.5, groups=[subscriber_group], product=_product_recipe.make()
)
cls.beer = product_recipe.make(
limit_age=18, selling_price=1.5, special_selling_price=1
cls.beer = price_recipe.make(
groups=[subscriber_group],
amount=1.5,
product=_product_recipe.make(limit_age=18),
)
cls.beer_tap = product_recipe.make(
limit_age=18, tray=True, selling_price=1.5, special_selling_price=1
cls.beer_tap = price_recipe.make(
groups=[subscriber_group],
amount=1.5,
product=_product_recipe.make(limit_age=18, tray=True),
)
cls.snack = product_recipe.make(
limit_age=0, selling_price=1.5, special_selling_price=1
cls.snack = price_recipe.make(
groups=[subscriber_group, old_subscriber_group],
amount=1.5,
product=_product_recipe.make(limit_age=0),
)
cls.stamps = product_recipe.make(
limit_age=0, selling_price=1.5, special_selling_price=1
cls.stamps = price_recipe.make(
groups=[subscriber_group],
amount=1.5,
product=_product_recipe.make(limit_age=0),
)
ReturnableProduct.objects.all().delete()
cls.cons = baker.make(Product, selling_price=1)
cls.dcons = baker.make(Product, selling_price=-1)
cls.cons = price_recipe.make(
amount=1, groups=[subscriber_group], product=_product_recipe.make()
)
cls.dcons = price_recipe.make(
amount=-1, groups=[subscriber_group], product=_product_recipe.make()
)
baker.make(
ReturnableProduct,
product=cls.cons,
returned_product=cls.dcons,
product=cls.cons.product,
returned_product=cls.dcons.product,
max_return=3,
)
cls.counter.products.add(
cls.gift, cls.beer, cls.beer_tap, cls.snack, cls.cons, cls.dcons
cls.gift.product,
cls.beer.product,
cls.beer_tap.product,
cls.snack.product,
cls.cons.product,
cls.dcons.product,
)
cls.other_counter.products.add(cls.snack)
cls.club_counter.products.add(cls.stamps)
cls.other_counter.products.add(cls.snack.product)
cls.club_counter.products.add(cls.stamps.product)
def login_in_bar(self, barmen: User | None = None):
used_barman = barmen if barmen is not None else self.barmen
@@ -285,10 +305,7 @@ class TestCounterClick(TestFullClickBase):
) -> HttpResponse:
used_counter = counter if counter is not None else self.counter
used_client = client if client is not None else self.client
data = {
"form-TOTAL_FORMS": str(len(basket)),
"form-INITIAL_FORMS": "0",
}
data = {"form-TOTAL_FORMS": str(len(basket)), "form-INITIAL_FORMS": "0"}
for index, item in enumerate(basket):
data.update(item.to_form(index))
return used_client.post(
@@ -331,32 +348,22 @@ class TestCounterClick(TestFullClickBase):
res = self.submit_basket(
self.customer, [BasketItem(self.beer.id, 2), BasketItem(self.snack.id, 1)]
)
assert res.status_code == 302
self.assertRedirects(res, self.counter.get_absolute_url())
assert self.updated_amount(self.customer) == Decimal("5.5")
# Test barmen special price
force_refill_user(self.barmen, 10)
assert (
self.submit_basket(self.barmen, [BasketItem(self.beer.id, 1)])
).status_code == 302
assert self.updated_amount(self.barmen) == Decimal(9)
def test_click_tray_price(self):
force_refill_user(self.customer, 20)
self.login_in_bar(self.barmen)
# Not applying tray price
res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 2)])
assert res.status_code == 302
self.assertRedirects(res, self.counter.get_absolute_url())
assert self.updated_amount(self.customer) == Decimal(17)
# Applying tray price
res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 7)])
assert res.status_code == 302
self.assertRedirects(res, self.counter.get_absolute_url())
assert self.updated_amount(self.customer) == Decimal(8)
def test_click_alcool_unauthorized(self):
@@ -477,7 +484,8 @@ class TestCounterClick(TestFullClickBase):
BasketItem(None, 1),
BasketItem(self.beer.id, None),
]:
assert self.submit_basket(self.customer, [item]).status_code == 200
res = self.submit_basket(self.customer, [item])
assert res.status_code == 200
assert self.updated_amount(self.customer) == Decimal(10)
def test_click_not_enough_money(self):
@@ -506,29 +514,30 @@ class TestCounterClick(TestFullClickBase):
res = self.submit_basket(
self.customer, [BasketItem(self.beer.id, 1), BasketItem(self.gift.id, 1)]
)
assert res.status_code == 302
self.assertRedirects(res, self.counter.get_absolute_url())
assert self.updated_amount(self.customer) == 0
def test_recordings(self):
force_refill_user(self.customer, self.cons.selling_price * 3)
force_refill_user(self.customer, self.cons.amount * 3)
self.login_in_bar(self.barmen)
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
assert res.status_code == 302
assert self.updated_amount(self.customer) == 0
assert list(
self.customer.customer.return_balances.values("returnable", "balance")
) == [{"returnable": self.cons.cons.id, "balance": 3}]
) == [{"returnable": self.cons.product.cons.id, "balance": 3}]
res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 3)])
assert res.status_code == 302
assert self.updated_amount(self.customer) == self.dcons.selling_price * -3
assert self.updated_amount(self.customer) == self.dcons.amount * -3
res = self.submit_basket(
self.customer, [BasketItem(self.dcons.id, self.dcons.dcons.max_return)]
self.customer,
[BasketItem(self.dcons.id, self.dcons.product.dcons.max_return)],
)
# from now on, the user amount should not change
expected_amount = self.dcons.selling_price * (-3 - self.dcons.dcons.max_return)
expected_amount = self.dcons.amount * (-3 - self.dcons.product.dcons.max_return)
assert res.status_code == 302
assert self.updated_amount(self.customer) == expected_amount
@@ -545,48 +554,57 @@ class TestCounterClick(TestFullClickBase):
def test_recordings_when_negative(self):
sale_recipe.make(
customer=self.customer.customer,
product=self.dcons,
unit_price=self.dcons.selling_price,
product=self.dcons.product,
unit_price=self.dcons.amount,
quantity=10,
)
self.customer.customer.update_returnable_balance()
self.login_in_bar(self.barmen)
res = self.submit_basket(self.customer, [BasketItem(self.dcons.id, 1)])
assert res.status_code == 200
assert self.updated_amount(self.customer) == self.dcons.selling_price * -10
assert self.updated_amount(self.customer) == self.dcons.amount * -10
res = self.submit_basket(self.customer, [BasketItem(self.cons.id, 3)])
assert res.status_code == 302
assert (
self.updated_amount(self.customer)
== self.dcons.selling_price * -10 - self.cons.selling_price * 3
== self.dcons.amount * -10 - self.cons.amount * 3
)
res = self.submit_basket(self.customer, [BasketItem(self.beer.id, 1)])
assert res.status_code == 302
assert (
self.updated_amount(self.customer)
== self.dcons.selling_price * -10
- self.cons.selling_price * 3
- self.beer.selling_price
== self.dcons.amount * -10 - self.cons.amount * 3 - self.beer.amount
)
def test_no_fetch_archived_product(self):
counter = baker.make(Counter)
group = baker.make(Group)
customer = baker.make(Customer)
product_recipe.make(archived=True, counters=[counter])
unarchived_products = product_recipe.make(
archived=False, counters=[counter], _quantity=3
group.users.add(customer.user)
_product_recipe = product_recipe.extend(
counters=[counter], product_type=baker.make(ProductType)
)
customer_products = counter.get_products_for(customer)
assert unarchived_products == customer_products
price_recipe.make(
_quantity=2,
product=iter(_product_recipe.make(archived=True, _quantity=2)),
groups=[group],
)
unarchived_prices = price_recipe.make(
_quantity=2,
product=iter(_product_recipe.make(archived=False, _quantity=2)),
groups=[group],
)
customer_prices = counter.get_prices_for(customer)
assert unarchived_prices == customer_prices
class TestCounterStats(TestCase):
@classmethod
def setUpTestData(cls):
cls.users = subscriber_user.make(_quantity=4)
product = product_recipe.make(selling_price=1)
product = price_recipe.make(amount=1).product
cls.counter = baker.make(
Counter, type=["BAR"], sellers=cls.users[:4], products=[product]
)
@@ -782,12 +800,15 @@ class TestClubCounterClickAccess(TestCase):
"counter:click",
kwargs={"counter_id": cls.counter.id, "user_id": cls.customer.id},
)
cls.board_role, cls.member_role = baker.make(
ClubRole,
club=cls.counter.club,
is_board=iter([True, False]),
_quantity=2,
_bulk_create=True,
)
cls.user = subscriber_user.make()
def setUp(self):
cache.clear()
def test_anonymous(self):
res = self.client.get(self.click_url)
assert res.status_code == 403
@@ -797,13 +818,17 @@ class TestClubCounterClickAccess(TestCase):
res = self.client.get(self.click_url)
assert res.status_code == 403
# being a member of the club, without being in the board, isn't enough
baker.make(Membership, club=self.counter.club, user=self.user, role=1)
baker.make(
Membership, club=self.counter.club, user=self.user, role=self.member_role
)
res = self.client.get(self.click_url)
assert res.status_code == 403
def test_board_member(self):
"""By default, board members should be able to click on office counters"""
baker.make(Membership, club=self.counter.club, user=self.user, role=3)
baker.make(
Membership, club=self.counter.club, user=self.user, role=self.board_role
)
self.client.force_login(self.user)
res = self.client.get(self.click_url)
assert res.status_code == 200
@@ -818,7 +843,9 @@ class TestClubCounterClickAccess(TestCase):
def test_both_barman_and_board_member(self):
"""If the user is barman and board member, he should be authorized as well."""
self.counter.sellers.add(self.user)
baker.make(Membership, club=self.counter.club, user=self.user, role=3)
baker.make(
Membership, club=self.counter.club, user=self.user, role=self.board_role
)
self.client.force_login(self.user)
res = self.client.get(self.click_url)
assert res.status_code == 200
+4 -4
View File
@@ -3,14 +3,13 @@ import string
from datetime import timedelta
import pytest
from django.conf import settings
from django.contrib.auth.base_user import make_password
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker
from club.models import Membership
from club.models import ClubRole, Membership
from core.baker_recipes import board_user, subscriber_user
from core.models import User
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
@@ -42,11 +41,12 @@ class TestStudentCard(TestCase):
cls.counter.sellers.add(cls.barmen)
cls.club_counter = baker.make(Counter)
role = baker.make(ClubRole, club=cls.club_counter.club, is_board=True)
baker.make(
Membership,
start_date=now() - timedelta(days=30),
club=cls.club_counter.club,
role=settings.SITH_CLUB_ROLES_ID["Board member"],
role=role,
user=cls.club_admin,
)
@@ -341,7 +341,7 @@ def test_update_balance():
def test_update_returnable_balance():
ReturnableProduct.objects.all().delete()
customer = baker.make(Customer)
products = product_recipe.make(selling_price=0, _quantity=4, _bulk_create=True)
products = product_recipe.make(_quantity=4, _bulk_create=True)
returnables = [
baker.make(
ReturnableProduct, product=products[0], returned_product=products[1]
+1 -23
View File
@@ -7,12 +7,7 @@ from counter.forms import ProductFormulaForm
class TestFormulaForm(TestCase):
@classmethod
def setUpTestData(cls):
cls.products = product_recipe.make(
selling_price=iter([1.5, 1, 1]),
special_selling_price=iter([1.4, 0.9, 0.9]),
_quantity=3,
_bulk_create=True,
)
cls.products = product_recipe.make(_quantity=3, _bulk_create=True)
def test_ok(self):
form = ProductFormulaForm(
@@ -26,23 +21,6 @@ class TestFormulaForm(TestCase):
assert formula.result == self.products[0]
assert set(formula.products.all()) == set(self.products[1:])
def test_price_invalid(self):
self.products[0].selling_price = 2.1
self.products[0].save()
form = ProductFormulaForm(
data={
"result": self.products[0].id,
"products": [self.products[1].id, self.products[2].id],
}
)
assert not form.is_valid()
assert form.errors == {
"result": [
"Le résultat ne peut pas être plus cher "
"que le total des autres produits."
]
}
def test_product_both_in_result_and_products(self):
form = ProductFormulaForm(
data={
+100 -37
View File
@@ -9,6 +9,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase
from django.urls import reverse
from model_bakery import baker
from model_bakery.recipe import Recipe
from PIL import Image
from pytest_django.asserts import assertNumQueries, assertRedirects
@@ -16,8 +17,8 @@ from club.models import Club
from core.baker_recipes import board_user, subscriber_user
from core.models import Group, User
from counter.baker_recipes import product_recipe
from counter.forms import ProductForm
from counter.models import Product, ProductFormula, ProductType
from counter.forms import ProductForm, ProductPriceFormSet
from counter.models import Price, Product, ProductType
@pytest.mark.django_db
@@ -81,11 +82,11 @@ def test_fetch_product_access(
def test_fetch_product_nb_queries(client: Client):
client.force_login(baker.make(User, is_superuser=True))
cache.clear()
with assertNumQueries(5):
with assertNumQueries(6):
# - 2 for authentication
# - 1 for pagination
# - 1 for the actual request
# - 1 to prefetch the related buying_groups
# - 2 to prefetch the related prices and groups
client.get(reverse("api:search_products_detailed"))
@@ -107,48 +108,21 @@ class TestCreateProduct(TestCase):
"selling_price": 1.0,
"special_selling_price": 1.0,
"limit_age": 0,
"form-TOTAL_FORMS": 0,
"form-INITIAL_FORMS": 0,
"price-TOTAL_FORMS": 0,
"price-INITIAL_FORMS": 0,
"action-TOTAL_FORMS": 0,
"action-INITIAL_FORMS": 0,
}
def test_form(self):
def test_form_simple(self):
form = ProductForm(data=self.data)
assert form.is_valid()
instance = form.save()
assert instance.club == self.club
assert instance.product_type == self.product_type
assert instance.name == "foo"
assert instance.selling_price == 1.0
def test_form_with_product_from_formula(self):
"""Test when the edited product is a result of a formula."""
self.client.force_login(self.counter_admin)
products = product_recipe.make(
selling_price=iter([1.5, 1, 1]),
special_selling_price=iter([1.4, 0.9, 0.9]),
_quantity=3,
_bulk_create=True,
)
baker.make(ProductFormula, result=products[0], products=products[1:])
data = self.data | {"selling_price": 1.7, "special_selling_price": 1.5}
form = ProductForm(data=data, instance=products[0])
assert form.is_valid()
# it shouldn't be possible to give a price higher than the formula's products
data = self.data | {"selling_price": 2.1, "special_selling_price": 1.9}
form = ProductForm(data=data, instance=products[0])
assert not form.is_valid()
assert form.errors == {
"selling_price": [
"Assurez-vous que cette valeur est inférieure ou égale à 2.00."
],
"special_selling_price": [
"Assurez-vous que cette valeur est inférieure ou égale à 1.80."
],
}
def test_view(self):
def test_view_simple(self):
self.client.force_login(self.counter_admin)
url = reverse("counter:new_product")
response = self.client.get(url)
@@ -159,3 +133,92 @@ class TestCreateProduct(TestCase):
assert product.name == "foo"
assert product.club == self.club
assert product.product_type == self.product_type
class TestPriceFormSet(TestCase):
@classmethod
def setUpTestData(cls):
cls.product = product_recipe.make()
cls.counter_admin = baker.make(
User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)]
)
cls.groups = baker.make(Group, _quantity=3)
def test_add_price(self):
data = {
"prices-0-amount": 2,
"prices-0-label": "foo",
"prices-0-groups": [self.groups[0].id, self.groups[1].id],
"prices-0-is_always_shown": True,
"prices-1-amount": 1.5,
"prices-1-label": "",
"prices-1-groups": [self.groups[1].id, self.groups[2].id],
"prices-1-is_always_shown": False,
"prices-TOTAL_FORMS": 2,
"prices-INITIAL_FORMS": 0,
}
form = ProductPriceFormSet(instance=self.product, data=data)
assert form.is_valid()
form.save()
prices = list(self.product.prices.order_by("amount"))
assert len(prices) == 2
assert prices[0].amount == 1.5
assert prices[0].label == ""
assert prices[0].is_always_shown is False
assert set(prices[0].groups.all()) == {self.groups[1], self.groups[2]}
assert prices[1].amount == 2
assert prices[1].label == "foo"
assert prices[1].is_always_shown is True
assert set(prices[1].groups.all()) == {self.groups[0], self.groups[1]}
def test_change_prices(self):
price_a = baker.make(
Price, product=self.product, amount=1.5, groups=self.groups[:1]
)
price_b = baker.make(
Price, product=self.product, amount=2, groups=self.groups[1:]
)
data = {
"prices-0-id": price_a.id,
"prices-0-DELETE": True,
"prices-1-id": price_b.id,
"prices-1-DELETE": False,
"prices-1-amount": 3,
"prices-1-label": "foo",
"prices-1-groups": [self.groups[1].id],
"prices-1-is_always_shown": True,
"prices-TOTAL_FORMS": 2,
"prices-INITIAL_FORMS": 2,
}
form = ProductPriceFormSet(instance=self.product, data=data)
assert form.is_valid()
form.save()
prices = list(self.product.prices.order_by("amount"))
assert len(prices) == 1
assert prices[0].amount == 3
assert prices[0].label == "foo"
assert prices[0].is_always_shown is True
assert set(prices[0].groups.all()) == {self.groups[1]}
assert not Price.objects.filter(id=price_a.id).exists()
@pytest.mark.django_db
def test_price_for_user():
groups = baker.make(Group, _quantity=4)
users = [
baker.make(User, groups=groups[:2]),
baker.make(User, groups=groups[1:3]),
baker.make(User, groups=[groups[3]]),
]
recipe = Recipe(Price, product=product_recipe.make())
prices = [
recipe.make(amount=5, groups=groups, is_always_shown=True),
recipe.make(amount=4, groups=[groups[0]], is_always_shown=True),
recipe.make(amount=3, groups=[groups[1]], is_always_shown=False),
recipe.make(amount=2, groups=[groups[3]], is_always_shown=False),
recipe.make(amount=1, groups=[groups[1]], is_always_shown=False),
]
qs = Price.objects.order_by("-amount")
assert list(qs.for_user(users[0])) == [prices[0], prices[1], prices[4]]
assert list(qs.for_user(users[1])) == [prices[0], prices[4]]
assert list(qs.for_user(users[2])) == [prices[0], prices[3]]
+20 -22
View File
@@ -73,7 +73,7 @@ class CounterClick(
kwargs["form_kwargs"] = {
"customer": self.customer,
"counter": self.object,
"allowed_products": {product.id: product for product in self.products},
"allowed_prices": {price.id: price for price in self.prices},
}
return kwargs
@@ -103,7 +103,7 @@ class CounterClick(
):
return redirect(obj) # Redirect to counter
self.products = obj.get_products_for(self.customer)
self.prices = obj.get_prices_for(self.customer)
return super().dispatch(request, *args, **kwargs)
@@ -121,32 +121,31 @@ class CounterClick(
# This is important because some items have a negative price
# Negative priced items gives money to the customer and should
# be processed first so that we don't throw a not enough money error
for form in sorted(formset, key=lambda form: form.product.price):
for form in sorted(formset, key=lambda form: form.price.amount):
self.request.session["last_basket"].append(
f"{form.cleaned_data['quantity']} x {form.product.name}"
f"{form.cleaned_data['quantity']} x {form.price.full_label}"
)
common_kwargs = {
"product": form.price.product,
"club_id": form.price.product.club_id,
"counter": self.object,
"seller": operator,
"customer": self.customer,
}
Selling(
label=form.product.name,
product=form.product,
club=form.product.club,
counter=self.object,
unit_price=form.product.price,
**common_kwargs,
label=form.price.full_label,
unit_price=form.price.amount,
quantity=form.cleaned_data["quantity"]
- form.cleaned_data["bonus_quantity"],
seller=operator,
customer=self.customer,
).save()
if form.cleaned_data["bonus_quantity"] > 0:
Selling(
label=f"{form.product.name} (Plateau)",
product=form.product,
club=form.product.club,
counter=self.object,
**common_kwargs,
label=f"{form.price.full_label} (Plateau)",
unit_price=0,
quantity=form.cleaned_data["bonus_quantity"],
seller=operator,
customer=self.customer,
).save()
self.customer.update_returnable_balance()
@@ -207,14 +206,13 @@ class CounterClick(
def get_context_data(self, **kwargs):
"""Add customer to the context."""
kwargs = super().get_context_data(**kwargs)
kwargs["products"] = self.products
kwargs["prices"] = self.prices
kwargs["formulas"] = ProductFormula.objects.filter(
result__in=self.products
result__in=[p.product_id for p in self.prices]
).prefetch_related("products")
kwargs["categories"] = defaultdict(list)
for product in kwargs["products"]:
if product.product_type:
kwargs["categories"][product.product_type].append(product)
for price in self.prices:
kwargs["categories"][price.product.product_type].append(price)
kwargs["customer"] = self.customer
kwargs["cancel_url"] = self.get_success_url()
-1
View File
@@ -1 +0,0 @@
::: api.schemas
-1
View File
@@ -1 +0,0 @@
::: api.views
-372
View File
@@ -1,372 +0,0 @@
Le site AE offre des mécanismes permettant aux applications tierces
de récupérer les informations sur un utilisateur du site AE.
De cette manière, il devient possible de synchroniser les informations
qu possède l'application tierce sur l'utilisateur, directement depuis
le site AE.
## Fonctionnement général
Pour authentifier vos utilisateurs, vous aurez besoin d'un serveur web
et d'un client d'API (celui auquel est liée votre
[clef d'API](./connect.md#obtenir-une-clef-dapi)).
Deux informations vous sont nécessaires, en plus de votre clef d'API :
- l'id du client : vous pouvez l'obtenir soit en le demandant à l'équipe info,
soit en appelant la route `GET /api/client/me` avec votre clef d'API
renseignée dans le header [X-APIKey](./connect.md#x-apikey)
- la clef HMAC du client : vous devez la demander à l'équipe info.
Grâce à ces informations, vous allez pouvoir fournir le contexte nécessaire
au site AE pour qu'il authentifie vos utilisateurs.
En effet, la démarche d'authentification s'effectue presque entièrement
sur le site : le travail de l'application tierce consiste uniquement
à fournir à l'utilisateur une url avec les bons paramètres, puis
à recevoir la réponse du serveur si tout s'est bien passé.
Comme un dessin vaut parfois mieux que mille mots,
voici les diagrammes décrivant le processus.
L'un montre l'entièreté de la démarche ;
l'autre dans un souci de simplicité, ne montre que ce qui est visible
directement par l'application tierce.
=== "Intégralité du processus"
```mermaid
sequenceDiagram
actor User
participant App
User->>+App: Authentifie-moi, stp
App-->>-User: url de connexion<br/>avec signature
User->>+Sith: GET url
opt Utilisateur non-connecté
Sith->>+User: Formulaire de connexion
User-->>-Sith: Connexion
end
Sith->>Sith: vérification de la signature
Sith->>+User: Formulaire<br/>des conditions<br/>d'utilisation
User-->>-Sith: Validation
Sith->>+App: URL de retour<br/>avec données utilisateur
App->>App: Traitement des <br/>données utilisateur
App-->>-Sith: 204 OK, No content
Sith-->>-User: Message de succès
App--)User: Message de succès
```
=== "Point de vue de l'application tierce"
```mermaid
sequenceDiagram
actor User
participant App
User->>+App: Authentifie-moi, stp
App-->>-User: url de connexion<br/>avec signature
opt
Sith->>+App: URL de retour<br/>avec données utilisateur
App->>App: Traitement des <br/>données utilisateur
App-->>-Sith: 204 OK, No content
App--)User: Message de succès
end
```
## Données attendues
### URL de connexion
L'URL de connexion que vous allez fournir à l'utilisateur doit
être `https://ae.utbm.fr/api-link/auth/`
et doit contenir les données décrites dans
[`ThirdPartyAuthParamsSchema`][api.schemas.ThirdPartyAuthParamsSchema] :
- `client_id` (integer) : l'id de votre client, que vous pouvez obtenir
de la manière décrite plus haut
- `third_party_app`(string) : le nom de la plateforme pour laquelle
l'authentification va être réalisée (si votre application est un bot
discord, mettez la valeur "discord")
- `privacy_link`(URL) : l'URL vers la page de politique de confidentialité
qui s'appliquera dans le cadre de l'application
(s'il s'agit d'un bot discord, donnez le lien vers celles de Discord)
- `username`(string) : le pseudonyme que l'utilisateur possède sur
votre application
- `callback_url`(URL) : l'URL que le site AE appellera si l'authentification
réussit
- `signature`(string) : la signature des données de la requête.
Il s'agit d'une signature par clef HMAC dont le fonctionnement
est détaillé plus bas.
Ces données doivent être url-encodées et passées dans les paramètres GET.
!!!warning "URL de retour"
Les URLs fournies doivent être des URLs HTTP valides.
En outre, elles doivent obligatoirement inclure la barre oblique finale.
=== "URL correcte ✔️"
`https://exemple.ae.utbm.fr/foo/`
=== "URL incorrecte ❌"
`https://exemple.ae.utbm.fr/foo`
!!!tip
Inclure l'id de votre utilisateur dans l'URL de retour
peut être un bon moyen de l'identifier lors du callback.
Par exemple : `GET /callback/{int:user_id}/`.
???Example
Supposons que votre client d'API soit utilisé dans le cadre d'un bot Discord,
avec les données suivantes :
- l'id du client est 15
- sa clef HMAC est "beb99dd53"
(c'est pour l'exemple, une vraie clef sera beaucoup plus longue)
- le pseudonyme discord de votre utilisateur est Brian
- son id sur discord est 123456789
- votre route de callback est `GET /callback/{int:user_id}/`,
accessible au domaine `https://bot.ae.utbm.fr`
Alors les paramètres de votre URL seront :
| Paramètre | valeur |
|-----------------|-----------------------------------------------------------------------|
| client_id | 15 |
| third_party_app | discord |
| privacy_link | `https://discord.com/privacy` |
| username | Brian |
| callback_url | `https://bot.ae.utbm.fr/callback/123456789/` |
| signature | 1a383c51060be64f07772aa42e07<br/>18ae096b8f21f2cdb4061c0834a416d12101 |
Et l'url fournie à l'utilisateur sera :
`https://ae.utbm.fr/api-link/auth/?client_id=15&third_party_app=discord
&privacy_link=https%3A%2F%2Fdiscord.com%2Fprivacy&username=Brian
&callback_url=https%3A%2F%2Fbot.ae.utbm.fr%2Fcallback%2F123456789%2F
&signature=1a383c51060be64f07772aa42e0718ae096b8f21f2cdb4061c0834a416d12101`
### Données de retour
Si l'authentification réussit, le site AE enverra une requête HTTP POST
à l'URL de retour fournie dans l'URL de connexion.
Le corps de la requête de callback et au format JSON
et contient deux paires clef-valeur :
- `user` : les données utilisateur, telles que décrites
par [UserProfileSchema][core.schemas.UserProfileSchema]
- `signature` : la signature des données utilisateur
???Example
En reprenant les mêmes paramètres que dans l'exemple précédent,
le site AE pourra renvoyer à l'application la requête suivante :
```http
POST https://bot.ae.utbm.fr/callback/123456789/
content-type: application/json
body: {
"user": {
"id": 144131,
"nick_name": "inzekitchen",
"first_name": "Brian",
...
},
"signature": "f16955bab6b805f6e1abbb98a86dfee53fed0bf812aa6513ca46cfd461b70020"
}
```
L'application doit répondre avec un des codes HTTP suivants :
| Code | Raison |
|------|--------------------------------------------------------------------------------|
| 204 | Tout s'est bien passé |
| 403 | Les données de retour ne sont <br>pas signées ou sont mal signées |
| 404 | L'URL de retour ne permet pas <br>d'identifier un utilisateur de l'application |
!!!note "Code d'erreur par défaut"
Si l'appel de la route fait face à plusieurs problèmes en même temps
(par exemple, l'URL ne permet pas de retrouver votre utilisateur,
et en plus les données sont mal signées),
le 403 prime et doit être retourné par défaut.
## Signature des données
Les données de l'URL de connexion doivent être signées,
et la signature de l'URL de retour doit être vérifiée.
Dans le deux cas, la signature est le digest HMAC-SHA512
des données url-encodées, en utilisant la clef HMAC du client d'API.
L'ordre dans lequel ces données sont placées dans l'encodage URL
doit être strictement le même que celui donné plus haut.
???Example "Signature de l'URL de connexion"
En reprenant le même exemple que les fois précédentes,
l'url-encodage des données est :
`client_id=15&third_party_app=discord
&privacy_link=https%3A%2F%2Fdiscord.com%2Fprivacy%2F&username=Brian
&callback_url=https%3A%2F%2Fbot.ae.utbm.fr%2Fcallback%2F123456789%2F`
Notez que la signature n'est pas (encore) dedans.
Cette dernière peut-être obtenue avec le code suivant :
=== ":simple-python: Python"
Dépendances :
- `environs` (>=14.1)
```python
import hmac
from urllib.parse import urlencode
from environs import Env
env = Env()
env.read_env()
key = env.str("HMAC_KEY").encode()
data = {
"client_id": 15,
"third_party_app": "discord",
"privacy_link": "https://discord.com/privacy/",
"username": "Brian",
"callback_url": "https://bot.ae.utbm.fr/callback/123456789/",
}
urlencoded = urlencode(data)
data["signature"] = hmac.digest(key, urlencoded.encode(), "sha512").hex()
# URL a fournir à l'utilisateur pour son authentification
user_url = f"https://ae.ubtm.fr/api-link/auth/?{urlencode(data)}"
```
=== ":simple-rust: Rust"
Dépendances :
- `hmac` (>=0.12.1)
- `url` (>=2.5.7, features `serde`)
- `serde` (>=1.0.228, features `derive`)
- `serde_urlencoded` (>="0.7.1)
- `sha2` (>=0.10.9)
- `dotenvy` (>= 0.15)
```rust
use hmac::{Mac, SimpleHmac};
use serde::Serialize;
use sha2::Sha512;
use url::Url;
#[derive(Serialize, Debug)]
struct UrlData<'a> {
client_id: u32,
third_party_app: &'a str,
privacy_link: Url,
username: &'a str,
callback_url: Url,
}
impl<'a> UrlData<'a> {
pub fn signature(&self, key: &[u8]) -> CtOutput<SimpleHmac<Sha512>> {
let urlencoded = serde_urlencoded::to_string(self).unwrap();
SimpleHmac::<Sha512>::new_from_slice(key)
.unwrap()
.chain_update(urlencoded.as_bytes())
.finalize()
}
}
impl Into<Url> for UrlData<'_> {
fn into(self) -> Url {
let key = std::env::var("HMAC_KEY").unwrap();
let mut url = Url::parse("http://ae.utbm.fr/api-link/auth/").unwrap();
url.set_query(Some(
format!(
"{}&signature={:x}",
serde_urlencoded::to_string(&self).unwrap(),
self.signature(key.as_bytes()).into_bytes()
)
.as_str(),
));
url
}
}
fn main() {
dotenvy::dotenv().expect("Couldn't load env");
let data = UrlData {
client_id: 1,
third_party_app: "discord",
privacy_link: "https://discord.com/privacy/".parse().unwrap(),
username: "Brian",
callback_url: "https://bot.ae.utbm.fr/callback/123456789/"
.parse()
.unwrap(),
};
let url: Url = data.into();
println!("{:?}", url);
}
```
???Example "Vérification de la signature de la réponse"
Les données utilisateur peuvent ressembler à :
```json
{
"user": {
"display_name": "Matthieu Vincent",
"profile_url": "/user/380/",
"profile_pict": "/static/core/img/unknown.jpg",
"id": 380,
"nick_name": None,
"first_name": "Matthieu",
"last_name": "Vincent",
},
"signature": "3802a280fbb01bd9fetc."
}
```
Vous pouvez vérifier la signature ainsi :
```python
import hmac
from urllib.parse import urlencode
from environs import Env
env = Env()
env.read_env()
def is_signature_valid(user_data: dict, signature: str) -> bool:
key = env.str("HMAC_KEY").encode()
urlencoded = urlencode(user_data)
return hmac.compare_digest(
hmac.digest(key, urlencoded.encode(), "sha512").hex(),
signature,
)
post_data = <récupération des données POST>
print(
"signature valide :",
is_signature_valid(post_data["user"], post_data["signature"]
)
```
!!!Warning
Vous devez impérativement vérifier la signature
des données de la requête de callback !
Ne pas vérifier la signature permet à n'importe quel acteur
tierce malveillant de vous appeler sur votre callback.
Ce serait une faille de sécurité majeure de votre côté.
Si l'équipe informatique se rend compte que vous ne le faites pas,
elle se réserve le droit de suspendre votre application,
immédiatement et sans préavis.
+4 -4
View File
@@ -112,7 +112,7 @@ cf. [HTTP persistant connection (wikipedia)](https://en.wikipedia.org/wiki/HTTP_
Voici quelques exemples :
=== ":simple-python: Python (requests)"
=== "Python (requests)"
Dépendances :
@@ -132,7 +132,7 @@ Voici quelques exemples :
print(response.json())
```
=== ":simple-python: Python (aiohttp)"
=== "Python (aiohttp)"
Dépendances :
@@ -158,7 +158,7 @@ Voici quelques exemples :
asyncio.run(main())
```
=== ":simple-javascript: Javascript (axios)"
=== "Javascript (axios)"
Dépendances :
@@ -178,7 +178,7 @@ Voici quelques exemples :
console.log(await instance.get("club/1").json());
```
=== ":simple-rust: Rust (reqwest)"
=== "Rust (reqwest)"
Dépendances :
+7 -7
View File
@@ -22,23 +22,22 @@ from eboutic.models import Basket, BasketItem, Invoice, InvoiceItem
class BasketAdmin(admin.ModelAdmin):
list_display = ("user", "date", "total")
autocomplete_fields = ("user",)
date_hierarchy = "date"
def get_queryset(self, request):
return (
super()
.get_queryset(request)
.annotate(
total=Sum(
F("items__quantity") * F("items__product_unit_price"), default=0
)
total=Sum(F("items__quantity") * F("items__unit_price"), default=0)
)
)
@admin.register(BasketItem)
class BasketItemAdmin(admin.ModelAdmin):
list_display = ("basket", "product_name", "product_unit_price", "quantity")
search_fields = ("product_name",)
list_display = ("label", "unit_price", "quantity")
search_fields = ("label",)
@admin.register(Invoice)
@@ -50,5 +49,6 @@ class InvoiceAdmin(admin.ModelAdmin):
@admin.register(InvoiceItem)
class InvoiceItemAdmin(admin.ModelAdmin):
list_display = ("invoice", "product_name", "product_unit_price", "quantity")
search_fields = ("product_name",)
list_display = ("label", "unit_price", "quantity")
search_fields = ("label",)
list_select_related = ("price",)
+37
View File
@@ -0,0 +1,37 @@
#
# Copyright 2022
# - Maréchal <thgirod@hotmail.com
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
class PaymentResultConverter:
"""Converter used for url mapping of the `eboutic.views.payment_result` view.
It's meant to build an url that can match
either `/eboutic/pay/success/` or `/eboutic/pay/failure/`
but nothing else.
"""
regex = "(success|failure)"
def to_python(self, value):
return str(value)
def to_url(self, value):
return str(value)
@@ -0,0 +1,53 @@
# Generated by Django 5.2.11 on 2026-02-22 18:13
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("counter", "0039_price"), ("eboutic", "0002_auto_20221005_2243")]
operations = [
migrations.RenameField(
model_name="basketitem", old_name="product_name", new_name="label"
),
migrations.RenameField(
model_name="basketitem",
old_name="product_unit_price",
new_name="unit_price",
),
migrations.RenameField(
model_name="basketitem", old_name="product_id", new_name="product"
),
migrations.RenameField(
model_name="invoiceitem", old_name="product_name", new_name="label"
),
migrations.RenameField(
model_name="invoiceitem",
old_name="product_unit_price",
new_name="unit_price",
),
migrations.RenameField(
model_name="invoiceitem", old_name="product_id", new_name="product"
),
migrations.RemoveField(model_name="basketitem", name="type_id"),
migrations.RemoveField(model_name="invoiceitem", name="type_id"),
migrations.AlterField(
model_name="basketitem",
name="product",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to="counter.product",
verbose_name="product",
),
),
migrations.AlterField(
model_name="invoiceitem",
name="product",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to="counter.product",
verbose_name="product",
),
),
]
+49 -90
View File
@@ -17,7 +17,7 @@ from __future__ import annotations
import hmac
from datetime import datetime
from enum import Enum
from typing import Any, Self
from typing import Self
from dict2xml import dict2xml
from django.conf import settings
@@ -30,8 +30,8 @@ from core.models import User
from counter.fields import CurrencyField
from counter.models import (
BillingInfo,
Counter,
Customer,
Price,
Product,
Refilling,
Selling,
@@ -39,22 +39,6 @@ from counter.models import (
)
def get_eboutic_products(user: User) -> list[Product]:
products = (
get_eboutic()
.products.filter(product_type__isnull=False)
.filter(archived=False, limit_age__lte=user.age)
.annotate(
order=F("product_type__order"),
category=F("product_type__name"),
category_comment=F("product_type__comment"),
price=F("selling_price"), # <-- selected price for basket validation
)
.prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to`
)
return [p for p in products if p.can_be_sold_to(user)]
class BillingInfoState(Enum):
VALID = 1
EMPTY = 2
@@ -94,21 +78,21 @@ class Basket(models.Model):
def __str__(self):
return f"{self.user}'s basket ({self.items.all().count()} items)"
def can_be_viewed_by(self, user):
def can_be_viewed_by(self, user: User):
return self.user == user
@cached_property
def contains_refilling_item(self) -> bool:
return self.items.filter(
type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
product__product_type_id=settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
).exists()
@cached_property
def total(self) -> float:
return float(
self.items.aggregate(
total=Sum(F("quantity") * F("product_unit_price"), default=0)
)["total"]
self.items.aggregate(total=Sum(F("quantity") * F("unit_price"), default=0))[
"total"
]
)
def generate_sales(
@@ -120,7 +104,8 @@ class Basket(models.Model):
Example:
```python
counter = Counter.objects.get(name="Eboutic")
sales = basket.generate_sales(counter, "SITH_ACCOUNT")
user = User.objects.get(username="bibou")
sales = basket.generate_sales(counter, user, Selling.PaymentMethod.SITH_ACCOUNT)
# here the basket is in the same state as before the method call
with transaction.atomic():
@@ -131,31 +116,23 @@ class Basket(models.Model):
# thus only the sales remain
```
"""
# I must proceed with two distinct requests instead of
# only one with a join because the AbstractBaseItem model has been
# poorly designed. If you refactor the model, please refactor this too.
items = self.items.order_by("product_id")
ids = [item.product_id for item in items]
products = Product.objects.filter(id__in=ids).order_by("id")
# items and products are sorted in the same order
sales = []
for item, product in zip(items, products, strict=False):
sales.append(
customer = Customer.get_or_create(self.user)[0]
return [
Selling(
label=product.name,
label=item.label,
counter=counter,
club=product.club,
product=product,
club_id=item.product.club_id,
product=item.product,
seller=seller,
customer=Customer.get_or_create(self.user)[0],
unit_price=item.product_unit_price,
customer=customer,
unit_price=item.unit_price,
quantity=item.quantity,
payment_method=payment_method,
)
)
return sales
for item in self.items.select_related("product")
]
def get_e_transaction_data(self) -> list[tuple[str, Any]]:
def get_e_transaction_data(self) -> list[tuple[str, str]]:
user = self.user
if not hasattr(user, "customer"):
raise Customer.DoesNotExist
@@ -201,7 +178,7 @@ class InvoiceQueryset(models.QuerySet):
def annotate_total(self) -> Self:
"""Annotate the queryset with the total amount of each invoice.
The total amount is the sum of (product_unit_price * quantity)
The total amount is the sum of (unit_price * quantity)
for all items related to the invoice.
"""
# aggregates within subqueries require a little bit of black magic,
@@ -211,7 +188,7 @@ class InvoiceQueryset(models.QuerySet):
total=Subquery(
InvoiceItem.objects.filter(invoice_id=OuterRef("pk"))
.values("invoice_id")
.annotate(total=Sum(F("product_unit_price") * F("quantity")))
.annotate(total=Sum(F("unit_price") * F("quantity")))
.values("total")
)
)
@@ -221,11 +198,7 @@ class Invoice(models.Model):
"""Invoices are generated once the payment has been validated."""
user = models.ForeignKey(
User,
related_name="invoices",
verbose_name=_("user"),
blank=False,
on_delete=models.CASCADE,
User, related_name="invoices", verbose_name=_("user"), on_delete=models.CASCADE
)
date = models.DateTimeField(_("date"), auto_now=True)
validated = models.BooleanField(_("validated"), default=False)
@@ -246,53 +219,44 @@ class Invoice(models.Model):
if self.validated:
raise DataError(_("Invoice already validated"))
customer, _created = Customer.get_or_create(user=self.user)
eboutic = Counter.objects.filter(type="EBOUTIC").first()
for i in self.items.all():
if i.type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
new = Refilling(
counter=eboutic,
customer=customer,
operator=self.user,
amount=i.product_unit_price * i.quantity,
payment_method=Refilling.PaymentMethod.CARD,
date=self.date,
kwargs = {
"counter": get_eboutic(),
"customer": customer,
"date": self.date,
"payment_method": Selling.PaymentMethod.CARD,
}
for i in self.items.select_related("product"):
if i.product.product_type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
Refilling.objects.create(
**kwargs, operator=self.user, amount=i.unit_price * i.quantity
)
new.save()
else:
product = Product.objects.filter(id=i.product_id).first()
new = Selling(
label=i.product_name,
counter=eboutic,
club=product.club,
product=product,
Selling.objects.create(
**kwargs,
label=i.label,
club_id=i.product.club_id,
product=i.product,
seller=self.user,
customer=customer,
unit_price=i.product_unit_price,
unit_price=i.unit_price,
quantity=i.quantity,
payment_method=Selling.PaymentMethod.CARD,
date=self.date,
)
new.save()
self.validated = True
self.save()
class AbstractBaseItem(models.Model):
product_id = models.IntegerField(_("product id"))
product_name = models.CharField(_("product name"), max_length=255)
type_id = models.IntegerField(_("product type id"))
product_unit_price = CurrencyField(_("unit price"))
product = models.ForeignKey(
Product, verbose_name=_("product"), on_delete=models.PROTECT
)
label = models.CharField(_("product name"), max_length=255)
unit_price = CurrencyField(_("unit price"))
quantity = models.PositiveIntegerField(_("quantity"))
class Meta:
abstract = True
def __str__(self):
return "Item: %s (%s) x%d" % (
self.product_name,
self.product_unit_price,
self.quantity,
)
return "Item: %s (%s) x%d" % (self.product.name, self.unit_price, self.quantity)
class BasketItem(AbstractBaseItem):
@@ -301,21 +265,16 @@ class BasketItem(AbstractBaseItem):
)
@classmethod
def from_product(cls, product: Product, quantity: int, basket: Basket):
def from_price(cls, price: Price, quantity: int, basket: Basket):
"""Create a BasketItem with the same characteristics as the
product passed in parameters, with the specified quantity.
Warning:
the basket field is not filled, so you must set
it yourself before saving the model.
product price passed in parameters, with the specified quantity.
"""
return cls(
basket=basket,
product_id=product.id,
product_name=product.name,
type_id=product.product_type_id,
label=price.full_label,
product_id=price.product_id,
quantity=quantity,
product_unit_price=product.selling_price,
unit_price=price.amount,
)
+17 -19
View File
@@ -1,13 +1,15 @@
export {};
interface BasketItem {
id: number;
priceId: number;
name: string;
quantity: number;
// biome-ignore lint/style/useNamingConvention: the python code is snake_case
unit_price: number;
unitPrice: number;
}
// increment the key number if the data schema of the cached basket changes
const BASKET_CACHE_KEY = "basket1";
document.addEventListener("alpine:init", () => {
Alpine.data("basket", (lastPurchaseTime?: number) => ({
basket: [] as BasketItem[],
@@ -26,28 +28,25 @@ document.addEventListener("alpine:init", () => {
this.basket = [];
}
}
// It's quite tricky to manually apply attributes to the management part
// of a formset so we dynamically apply it here
this.$refs.basketManagementForm
.querySelector("#id_form-TOTAL_FORMS")
document
.getElementById("id_form-TOTAL_FORMS")
.setAttribute(":value", "basket.length");
},
loadBasket(): BasketItem[] {
if (localStorage.basket === undefined) {
if (localStorage.getItem(BASKET_CACHE_KEY) === null) {
return [];
}
try {
return JSON.parse(localStorage.basket);
return JSON.parse(localStorage.getItem(BASKET_CACHE_KEY));
} catch (_err) {
return [];
}
},
saveBasket() {
localStorage.basket = JSON.stringify(this.basket);
localStorage.basketTimestamp = Date.now();
localStorage.setItem(BASKET_CACHE_KEY, JSON.stringify(this.basket));
localStorage.setItem("basketTimestamp", Date.now().toString());
},
/**
@@ -56,7 +55,7 @@ document.addEventListener("alpine:init", () => {
*/
getTotal() {
return this.basket.reduce(
(acc: number, item: BasketItem) => acc + item.quantity * item.unit_price,
(acc: number, item: BasketItem) => acc + item.quantity * item.unitPrice,
0,
);
},
@@ -74,7 +73,7 @@ document.addEventListener("alpine:init", () => {
* @param itemId the id of the item to remove
*/
remove(itemId: number) {
const index = this.basket.findIndex((e: BasketItem) => e.id === itemId);
const index = this.basket.findIndex((e: BasketItem) => e.priceId === itemId);
if (index < 0) {
return;
@@ -83,7 +82,7 @@ document.addEventListener("alpine:init", () => {
if (this.basket[index].quantity === 0) {
this.basket = this.basket.filter(
(e: BasketItem) => e.id !== this.basket[index].id,
(e: BasketItem) => e.priceId !== this.basket[index].id,
);
}
},
@@ -104,11 +103,10 @@ document.addEventListener("alpine:init", () => {
*/
createItem(id: number, name: string, price: number): BasketItem {
const newItem = {
id,
priceId: id,
name,
quantity: 0,
// biome-ignore lint/style/useNamingConvention: the python code is snake_case
unit_price: price,
unitPrice: price,
} as BasketItem;
this.basket.push(newItem);
@@ -125,7 +123,7 @@ document.addEventListener("alpine:init", () => {
* @param price The unit price of the product
*/
addFromCatalog(id: number, name: string, price: number) {
let item = this.basket.find((e: BasketItem) => e.id === id);
let item = this.basket.find((e: BasketItem) => e.priceId === id);
// if the item is not in the basket, we create it
// else we add + 1 to it

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