Compare commits

..

5 Commits

Author SHA1 Message Date
dependabot[bot] 0c2c9671d1 Bump mistune from 3.2.0 to 3.2.1
Bumps [mistune](https://github.com/lepture/mistune) from 3.2.0 to 3.2.1.
- [Release notes](https://github.com/lepture/mistune/releases)
- [Changelog](https://github.com/lepture/mistune/blob/main/docs/changes.rst)
- [Commits](https://github.com/lepture/mistune/compare/v3.2.0...v3.2.1)

---
updated-dependencies:
- dependency-name: mistune
  dependency-version: 3.2.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-08 06:31:35 +00:00
thomas girod b2ffcd3a37 Merge pull request #1365 from ae-utbm/taiste
Product prices, club list page rework and bug fixes
2026-05-01 19:14:03 +02:00
Titouan ca37996d6a Merge pull request #1332 from ae-utbm/taiste
Stats & Whitelist, Eurockéenne, fix pagination, Vite 8, delete unused settings
2026-03-29 16:35:16 +02:00
Titouan 173311c1d5 Merge pull request #1315 from ae-utbm/taiste
Product history, formula management, test election
2026-03-12 11:33:45 +01:00
thomas girod 2995823d6e Merge pull request #1293 from ae-utbm/taiste
Refactors, updates and db optimisations
2026-02-13 15:25:04 +01:00
89 changed files with 1113 additions and 1796 deletions
+21 -15
View File
@@ -12,7 +12,7 @@ runs:
steps: steps:
- name: Install apt packages - name: Install apt packages
if: ${{ inputs.full == 'true' }} if: ${{ inputs.full == 'true' }}
uses: awalsh128/cache-apt-pkgs-action@v1.6.0 uses: awalsh128/cache-apt-pkgs-action@v1.4.3
with: with:
packages: gettext packages: gettext
version: 1.0 # increment to reset cache version: 1.0 # increment to reset cache
@@ -23,29 +23,26 @@ runs:
with: with:
redis-version: "7.x" 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" - name: "Set up Python"
uses: actions/setup-python@v6 uses: actions/setup-python@v5
with: with:
python-version-file: ".python-version" python-version-file: ".python-version"
- 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 - name: Restore cached virtualenv
uses: actions/cache@v5 uses: actions/cache/restore@v4
with: with:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
path: .venv path: .venv
key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
restore-keys: |
uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
uv-${{ runner.os }}
- name: Install dependencies - name: Install dependencies
run: uv sync --locked run: uv sync
shell: bash shell: bash
- name: Install Xapian - name: Install Xapian
@@ -53,6 +50,15 @@ runs:
run: uv run ./manage.py install_xapian run: uv run ./manage.py install_xapian
shell: bash 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 - name: Compile gettext messages
if: ${{ inputs.full == 'true' }} if: ${{ inputs.full == 'true' }}
run: uv run ./manage.py compilemessages run: uv run ./manage.py compilemessages
+4 -4
View File
@@ -18,8 +18,8 @@ jobs:
name: Launch pre-commits checks (ruff) name: Launch pre-commits checks (ruff)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- uses: actions/setup-python@v6 - uses: actions/setup-python@v5
with: with:
python-version-file: ".python-version" python-version-file: ".python-version"
- uses: pre-commit/action@v3.0.1 - uses: pre-commit/action@v3.0.1
@@ -35,7 +35,7 @@ jobs:
pytest-mark: [not slow] pytest-mark: [not slow]
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@v6 uses: actions/checkout@v4
- uses: ./.github/actions/setup_project - uses: ./.github/actions/setup_project
with: with:
full: true full: true
@@ -49,7 +49,7 @@ jobs:
uv run coverage report uv run coverage report
uv run coverage html uv run coverage html
- name: Archive code coverage results - name: Archive code coverage results
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: coverage-report-${{ matrix.pytest-mark }} name: coverage-report-${{ matrix.pytest-mark }}
path: coverage_report path: coverage_report
+3 -1
View File
@@ -14,7 +14,7 @@ jobs:
steps: steps:
- name: SSH Remote Commands - name: SSH Remote Commands
uses: appleboy/ssh-action@v1.2.5 uses: appleboy/ssh-action@v1.1.0
with: with:
# Proxy # Proxy
proxy_host : ${{secrets.PROXY_HOST}} proxy_host : ${{secrets.PROXY_HOST}}
@@ -29,6 +29,8 @@ jobs:
username : ${{secrets.USER}} username : ${{secrets.USER}}
key: ${{secrets.KEY}} key: ${{secrets.KEY}}
script_stop: true
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action # See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
script: | script: |
cd ${{secrets.SITH_PATH}} cd ${{secrets.SITH_PATH}}
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- uses: ./.github/actions/setup_project - uses: ./.github/actions/setup_project
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@v5 - uses: actions/cache@v3
with: with:
key: mkdocs-material-${{ env.cache_id }} key: mkdocs-material-${{ env.cache_id }}
path: .cache path: .cache
+3 -1
View File
@@ -13,7 +13,7 @@ jobs:
steps: steps:
- name: SSH Remote Commands - name: SSH Remote Commands
uses: appleboy/ssh-action@v1.2.5 uses: appleboy/ssh-action@v1.1.0
with: with:
# Proxy # Proxy
proxy_host : ${{secrets.PROXY_HOST}} proxy_host : ${{secrets.PROXY_HOST}}
@@ -28,6 +28,8 @@ jobs:
username : ${{secrets.USER}} username : ${{secrets.USER}}
key: ${{secrets.KEY}} key: ${{secrets.KEY}}
script_stop: true
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action # See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
script: | script: |
cd ${{secrets.SITH_PATH}} cd ${{secrets.SITH_PATH}}
+1 -28
View File
@@ -13,10 +13,8 @@
# #
# #
from django.contrib import admin from django.contrib import admin
from django.forms.models import ModelForm
from django.http import HttpRequest
from club.models import Club, ClubRole, Membership from club.models import Club, Membership
@admin.register(Club) @admin.register(Club)
@@ -31,31 +29,6 @@ class ClubAdmin(admin.ModelAdmin):
"page", "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) @admin.register(Membership)
class MembershipAdmin(admin.ModelAdmin): class MembershipAdmin(admin.ModelAdmin):
+2 -3
View File
@@ -37,8 +37,7 @@ class ClubController(ControllerBase):
) )
def fetch_club(self, club_id: int): def fetch_club(self, club_id: int):
prefetch = Prefetch( prefetch = Prefetch(
"members", "members", queryset=Membership.objects.ongoing().select_related("user")
queryset=Membership.objects.ongoing().select_related("user", "role"),
) )
return self.get_object_or_exception( return self.get_object_or_exception(
Club.objects.prefetch_related(prefetch), id=club_id Club.objects.prefetch_related(prefetch), id=club_id
@@ -60,5 +59,5 @@ class UserClubController(ControllerBase):
return ( return (
Membership.objects.ongoing() Membership.objects.ongoing()
.filter(user=user) .filter(user=user)
.select_related("club", "user", "role") .select_related("club", "user")
) )
+27 -18
View File
@@ -23,12 +23,13 @@
# #
from django import forms from django import forms
from django.db.models import Exists, OuterRef, Q, QuerySet from django.conf import settings
from django.db.models import Exists, OuterRef, Q
from django.db.models.functions import Lower from django.db.models.functions import Lower
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from club.models import Club, ClubRole, Mailing, MailingSubscription, Membership from club.models import Club, Mailing, MailingSubscription, Membership
from core.models import User from core.models import User
from core.views.forms import SelectDateTime from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import ( from core.views.widgets.ajax_select import (
@@ -214,7 +215,9 @@ class ClubOldMemberForm(forms.Form):
def __init__(self, *args, user: User, club: Club, **kwargs): def __init__(self, *args, user: User, club: Club, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["members_old"].queryset = club.members.ongoing().editable_by(user) self.fields["members_old"].queryset = (
Membership.objects.ongoing().filter(club=club).editable_by(user)
)
class ClubMemberForm(forms.ModelForm): class ClubMemberForm(forms.ModelForm):
@@ -232,14 +235,19 @@ class ClubMemberForm(forms.ModelForm):
self.request_user = request_user self.request_user = request_user
self.request_user_membership = self.club.get_membership_for(self.request_user) self.request_user_membership = self.club.get_membership_for(self.request_user)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["role"].queryset = self.available_roles 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.instance.club = club self.instance.club = club
@property @property
def available_roles(self) -> QuerySet[ClubRole]: def max_available_role(self):
"""The roles that will be obtainable with this form.""" """The greatest role that will be obtainable with this form."""
# this is unreachable, because it will be overridden by subclasses # this is unreachable, because it will be overridden by subclasses
return ClubRole.objects.none() # pragma: no cover return -1 # pragma: no cover
class ClubAddMemberForm(ClubMemberForm): class ClubAddMemberForm(ClubMemberForm):
@@ -250,22 +258,21 @@ class ClubAddMemberForm(ClubMemberForm):
widgets = {"user": AutoCompleteSelectUser} widgets = {"user": AutoCompleteSelectUser}
@cached_property @cached_property
def available_roles(self): def max_available_role(self):
"""The roles that will be obtainable with this form. """The greatest role that will be obtainable with this form.
Admins and the club president can attribute any role. Admins and the club president can attribute any role.
Board members can attribute roles lower than their own. Board members can attribute roles lower than their own.
Other users cannot attribute roles with this form 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"): if self.request_user.has_perm("club.add_membership"):
return qs.all() return settings.SITH_CLUB_ROLES_ID["President"]
membership = self.request_user_membership membership = self.request_user_membership
if membership is None or not membership.role.is_board: if membership is None or membership.role <= settings.SITH_MAXIMUM_FREE_ROLE:
return ClubRole.objects.none() return -1
if membership.role.is_presidency: if membership.role == settings.SITH_CLUB_ROLES_ID["President"]:
return qs.all() return membership.role
return qs.above_instance(membership.role) return membership.role - 1
def clean_user(self): def clean_user(self):
"""Check that the user is not trying to add a user already in the club. """Check that the user is not trying to add a user already in the club.
@@ -289,11 +296,13 @@ class JoinClubForm(ClubMemberForm):
def __init__(self, *args, club: Club, request_user: User, **kwargs): def __init__(self, *args, club: Club, request_user: User, **kwargs):
super().__init__(*args, club=club, request_user=request_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 self.instance.user = self.request_user
@cached_property @cached_property
def available_roles(self): def max_available_role(self):
return self.club.roles.filter(is_board=False, is_active=True) return settings.SITH_MAXIMUM_FREE_ROLE
def clean(self): def clean(self):
"""Check that the user is subscribed and isn't already in the club.""" """Check that the user is subscribed and isn't already in the club."""
@@ -2,15 +2,12 @@
import django.db.models.deletion import django.db.models.deletion
import django.db.models.functions.datetime import django.db.models.functions.datetime
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
from django.db.migrations.state import StateApps from django.db.migrations.state import StateApps
from django.db.models import Q from django.db.models import Q
from django.utils.timezone import localdate 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): def migrate_meta_groups(apps: StateApps, schema_editor):
"""Attach the existing meta groups to the clubs. """Attach the existing meta groups to the clubs.
@@ -49,7 +46,10 @@ def migrate_meta_groups(apps: StateApps, schema_editor):
).select_related("user") ).select_related("user")
club.members_group.users.set([m.user for m in memberships]) club.members_group.users.set([m.user for m in memberships])
club.board_group.users.set( club.board_group.users.set(
[m.user for m in memberships.filter(role__gt=MAXIMUM_FREE_ROLE)] [
m.user
for m in memberships.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
]
) )
@@ -1,158 +0,0 @@
# 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
]
@@ -1,25 +0,0 @@
# 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",
),
),
]
+29 -163
View File
@@ -28,15 +28,15 @@ from typing import Iterable, Self
from django.conf import settings from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email from django.core.validators import RegexValidator, validate_email
from django.db import ProgrammingError, models, transaction from django.db import models, transaction
from django.db.models import Exists, F, OuterRef, Q from django.db.models import Exists, F, OuterRef, Q, Value
from django.db.models.functions import Greatest
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.timezone import localdate from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from ordered_model.models import OrderedModel
from core.fields import ResizedImageField from core.fields import ResizedImageField
from core.models import Group, Notification, Page, SithFile, User from core.models import Group, Notification, Page, SithFile, User
@@ -89,13 +89,13 @@ class Club(models.Model):
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
) )
page = models.OneToOneField( page = models.OneToOneField(
Page, related_name="club", blank=True, on_delete=models.PROTECT Page, related_name="club", blank=True, on_delete=models.CASCADE
) )
members_group = models.OneToOneField( members_group = models.OneToOneField(
Group, related_name="club", on_delete=models.PROTECT, editable=False Group, related_name="club", on_delete=models.PROTECT
) )
board_group = models.OneToOneField( board_group = models.OneToOneField(
Group, related_name="club_board", on_delete=models.PROTECT, editable=False Group, related_name="club_board", on_delete=models.PROTECT
) )
objects = ClubQuerySet.as_manager() objects = ClubQuerySet.as_manager()
@@ -138,7 +138,9 @@ class Club(models.Model):
@cached_property @cached_property
def president(self) -> Membership | None: def president(self) -> Membership | None:
"""Fetch the membership of the current president of this club.""" """Fetch the membership of the current president of this club."""
return self.members.filter(end_date=None).order_by("role__order").first() return self.members.filter(
role=settings.SITH_CLUB_ROLES_ID["President"], end_date=None
).first()
def check_loop(self): def check_loop(self):
"""Raise a validation error when a loop is found within the parent list.""" """Raise a validation error when a loop is found within the parent list."""
@@ -183,40 +185,6 @@ class Club(models.Model):
self.page.parent = self.parent.page self.page.parent = self.parent.page
self.page.save(force_lock=True) 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]]: def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
self.board_group.delete() self.board_group.delete()
self.members_group.delete() self.members_group.delete()
@@ -240,9 +208,7 @@ class Club(models.Model):
@cached_property @cached_property
def current_members(self) -> list[Membership]: def current_members(self) -> list[Membership]:
return list( return list(self.members.ongoing().select_related("user").order_by("-role"))
self.members.ongoing().select_related("user", "role").order_by("-role")
)
def get_membership_for(self, user: User) -> Membership | None: def get_membership_for(self, user: User) -> Membership | None:
"""Return the current membership of the given user.""" """Return the current membership of the given user."""
@@ -254,105 +220,6 @@ class Club(models.Model):
return user.is_in_group(pk=self.board_group_id) 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): class MembershipQuerySet(models.QuerySet):
def ongoing(self) -> Self: def ongoing(self) -> Self:
"""Filter all memberships which are not finished yet.""" """Filter all memberships which are not finished yet."""
@@ -365,10 +232,9 @@ class MembershipQuerySet(models.QuerySet):
are included, even if there are no more members. are included, even if there are no more members.
If you want to get the users who are currently in the board, If you want to get the users who are currently in the board,
mind combining this with the [MembershipQuerySet.ongoing][] mind combining this with the `ongoing` queryset method
queryset method
""" """
return self.filter(role__is_board=True) return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
def editable_by(self, user: User) -> Self: def editable_by(self, user: User) -> Self:
"""Filter Memberships that this user can edit. """Filter Memberships that this user can edit.
@@ -391,16 +257,21 @@ class MembershipQuerySet(models.QuerySet):
""" """
if user.has_perm("club.change_membership"): if user.has_perm("club.change_membership"):
return self.all() return self.all()
return self.ongoing().filter( return self.filter(
Q(user=user) Q(user=user)
| Exists( | Exists(
Membership.objects.ongoing().filter( Membership.objects.filter(
Q(
role__gt=Greatest(
OuterRef("role"), Value(settings.SITH_MAXIMUM_FREE_ROLE)
)
),
user=user, user=user,
end_date=None,
club=OuterRef("club"), club=OuterRef("club"),
role__is_board=True,
role__order__lt=OuterRef("role__order"),
) )
) ),
end_date=None,
) )
def update(self, **kwargs) -> int: def update(self, **kwargs) -> int:
@@ -470,11 +341,10 @@ class Membership(models.Model):
) )
start_date = models.DateField(_("start date"), default=timezone.now) start_date = models.DateField(_("start date"), default=timezone.now)
end_date = models.DateField(_("end date"), null=True, blank=True) end_date = models.DateField(_("end date"), null=True, blank=True)
role = models.ForeignKey( role = models.IntegerField(
ClubRole, _("role"),
verbose_name=_("role"), choices=sorted(settings.SITH_CLUB_ROLES.items()),
related_name="members", default=sorted(settings.SITH_CLUB_ROLES.items())[0][0],
on_delete=models.PROTECT,
) )
description = models.CharField( description = models.CharField(
_("description"), max_length=128, null=False, blank=True _("description"), max_length=128, null=False, blank=True
@@ -492,7 +362,7 @@ class Membership(models.Model):
def __str__(self): def __str__(self):
return ( return (
f"{self.club.name} - {self.user.username} " f"{self.club.name} - {self.user.username} "
f"- {self.role.name} " f"- {settings.SITH_CLUB_ROLES[self.role]} "
f"- {str(_('past member')) if self.end_date is not None else ''}" f"- {str(_('past member')) if self.end_date is not None else ''}"
) )
@@ -521,11 +391,7 @@ class Membership(models.Model):
if user.is_root or user.is_board_member: if user.is_root or user.is_board_member:
return True return True
membership = self.club.get_membership_for(user) membership = self.club.get_membership_for(user)
if not membership: return membership is not None and membership.role >= self.role
return False
return membership.user_id == user.id or (
membership.is_board and membership.role.order < self.role.order
)
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
self._remove_club_groups([self]) self._remove_club_groups([self])
@@ -601,7 +467,7 @@ class Membership(models.Model):
group_id=membership.club.members_group_id, group_id=membership.club.members_group_id,
) )
) )
if membership.role.is_board: if membership.role > settings.SITH_MAXIMUM_FREE_ROLE:
club_groups.append( club_groups.append(
User.groups.through( User.groups.through(
user_id=membership.user_id, user_id=membership.user_id,
+3 -11
View File
@@ -3,7 +3,7 @@ from typing import Annotated
from django.db.models import Q from django.db.models import Q
from ninja import FilterLookup, FilterSchema, ModelSchema from ninja import FilterLookup, FilterSchema, ModelSchema
from club.models import Club, ClubRole, Membership from club.models import Club, Membership
from core.schemas import NonEmptyStr, SimpleUserSchema from core.schemas import NonEmptyStr, SimpleUserSchema
@@ -39,21 +39,14 @@ class ClubProfileSchema(ModelSchema):
return obj.get_absolute_url() return obj.get_absolute_url()
class ClubRoleSchema(ModelSchema):
class Meta:
model = ClubRole
fields = ["id", "name", "is_presidency", "is_board"]
class ClubMemberSchema(ModelSchema): class ClubMemberSchema(ModelSchema):
"""A schema to represent all memberships in a club.""" """A schema to represent all memberships in a club."""
class Meta: class Meta:
model = Membership model = Membership
fields = ["start_date", "end_date", "description"] fields = ["start_date", "end_date", "role", "description"]
user: SimpleUserSchema user: SimpleUserSchema
role: ClubRoleSchema
class ClubSchema(ModelSchema): class ClubSchema(ModelSchema):
@@ -69,7 +62,6 @@ class UserMembershipSchema(ModelSchema):
class Meta: class Meta:
model = Membership model = Membership
fields = ["id", "start_date", "description"] fields = ["id", "start_date", "role", "description"]
club: SimpleClubSchema club: SimpleClubSchema
role: ClubRoleSchema
+2 -1
View File
@@ -26,9 +26,10 @@
{% if club.logo %} {% if club.logo %}
<div class="club_logo"><img src="{{ club.logo.url }}" alt="{{ club.name }}"></div> <div class="club_logo"><img src="{{ club.logo.url }}" alt="{{ club.name }}"></div>
{% endif %} {% endif %}
<h3>{{ club.name }}</h3>
{% if page_revision %} {% if page_revision %}
{{ page_revision|markdown }} {{ page_revision|markdown }}
{% else %}
<h3>{{ club.name }}</h3>
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
+1 -1
View File
@@ -41,7 +41,7 @@
{% for m in members %} {% for m in members %}
<tr> <tr>
<td>{{ user_profile_link(m.user) }}</td> <td>{{ user_profile_link(m.user) }}</td>
<td>{{ m.role.name }}</td> <td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
<td>{{ m.description }}</td> <td>{{ m.description }}</td>
<td>{{ m.start_date }}</td> <td>{{ m.start_date }}</td>
{%- if can_end_membership -%} {%- if can_end_membership -%}
+1 -1
View File
@@ -17,7 +17,7 @@
{% for member in old_members %} {% for member in old_members %}
<tr> <tr>
<td>{{ user_profile_link(member.user) }}</td> <td>{{ user_profile_link(member.user) }}</td>
<td>{{ member.role.name }}</td> <td>{{ settings.SITH_CLUB_ROLES[member.role] }}</td>
<td>{{ member.description }}</td> <td>{{ member.description }}</td>
<td>{{ member.start_date }}</td> <td>{{ member.start_date }}</td>
<td>{{ member.end_date }}</td> <td>{{ member.end_date }}</td>
+5 -15
View File
@@ -8,7 +8,7 @@ from django.utils.timezone import now
from model_bakery import baker from model_bakery import baker
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from club.models import Club, ClubRole, Membership from club.models import Club, Membership
from core.baker_recipes import old_subscriber_user, subscriber_user from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import User from core.models import User
@@ -43,11 +43,6 @@ class TestClub(TestCase):
cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID) cls.ae = Club.objects.get(pk=settings.SITH_MAIN_CLUB_ID)
cls.club = baker.make(Club) 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( cls.new_members_url = reverse(
"club:club_new_members", kwargs={"club_id": cls.club.id} "club:club_new_members", kwargs={"club_id": cls.club.id}
) )
@@ -56,17 +51,12 @@ class TestClub(TestCase):
yesterday = now() - timedelta(days=1) yesterday = now() - timedelta(days=1)
membership_recipe = Recipe(Membership, club=cls.club) membership_recipe = Recipe(Membership, club=cls.club)
membership_recipe.make( membership_recipe.make(
user=cls.simple_board_member, start_date=a_month_ago, role=cls.board_role user=cls.simple_board_member, start_date=a_month_ago, role=3
)
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 membership_recipe.make( # sli was a member but isn't anymore
user=cls.sli, user=cls.sli, start_date=a_month_ago, end_date=yesterday, role=2
start_date=a_month_ago,
end_date=yesterday,
role=cls.board_role,
) )
def setUp(self): def setUp(self):
+5 -91
View File
@@ -1,16 +1,13 @@
from datetime import timedelta from datetime import timedelta
import pytest import pytest
from django.conf import settings
from django.db import ProgrammingError
from django.test import Client from django.test import Client
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import localdate from django.utils.timezone import localdate
from model_bakery import baker from model_bakery import baker
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects
from club.models import Club, ClubRole, Membership from club.models import Club, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import User from core.models import User
@@ -22,19 +19,11 @@ def test_club_queryset_having_board_member():
membership_recipe = Recipe( membership_recipe = Recipe(
Membership, user=user, start_date=localdate() - timedelta(days=3) 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( membership_recipe.make(
club=clubs[0], role=baker.make(ClubRole, club=clubs[0], is_board=False) club=clubs[3], role=3, end_date=localdate() - timedelta(days=1)
)
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) club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)
@@ -50,78 +39,3 @@ def test_club_list(client: Client, nb_additional_clubs: int, is_fragment):
headers = {"HX-Request": True} if is_fragment else {} headers = {"HX-Request": True} if is_fragment else {}
res = client.get(reverse("club:club_list"), headers=headers) res = client.get(reverse("club:club_list"), headers=headers)
assert res.status_code == 200 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()
+2 -5
View File
@@ -1,7 +1,6 @@
from datetime import date, timedelta from datetime import date, timedelta
import pytest import pytest
from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
@@ -9,7 +8,7 @@ from model_bakery import baker
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from pytest_django.asserts import assertNumQueries from pytest_django.asserts import assertNumQueries
from club.models import Club, ClubRole, Membership from club.models import Club, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import Group, Page, User from core.models import Group, Page, User
@@ -27,10 +26,8 @@ class TestClubSearch(TestCase):
"id", flat=True "id", flat=True
) )
) )
Membership.objects.all().delete() Page.objects.exclude(club=None).delete()
ClubRole.objects.all().delete()
Club.objects.all().delete() Club.objects.all().delete()
Page.objects.exclude(name=settings.SITH_CLUB_ROOT_PAGE).delete()
Group.objects.filter(id__in=groups).delete() Group.objects.filter(id__in=groups).delete()
cls.clubs = baker.make( cls.clubs = baker.make(
-33
View File
@@ -1,33 +0,0 @@
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,
]
+3 -13
View File
@@ -4,7 +4,7 @@ from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
from club.models import Club, ClubRole, Membership from club.models import Club, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
@@ -12,12 +12,7 @@ from core.baker_recipes import subscriber_user
def test_club_board_member_cannot_edit_club_properties(client: Client): def test_club_board_member_cannot_edit_club_properties(client: Client):
user = subscriber_user.make() user = subscriber_user.make()
club = baker.make(Club, name="old name", is_active=True, address="old address") club = baker.make(Club, name="old name", is_active=True, address="old address")
baker.make( baker.make(Membership, club=club, user=user, role=7)
Membership,
club=club,
user=user,
role=baker.make(ClubRole, club=club, is_board=True),
)
client.force_login(user) client.force_login(user)
res = client.post( res = client.post(
reverse("club:club_edit", kwargs={"club_id": club.id}), reverse("club:club_edit", kwargs={"club_id": club.id}),
@@ -37,12 +32,7 @@ def test_edit_club_page_doesnt_crash(client: Client):
"""crash test for club:club_edit""" """crash test for club:club_edit"""
club = baker.make(Club) club = baker.make(Club)
user = subscriber_user.make() user = subscriber_user.make()
baker.make( baker.make(Membership, club=club, user=user, role=3)
Membership,
club=club,
user=user,
role=baker.make(ClubRole, club=club, is_board=True),
)
client.force_login(user) client.force_login(user)
res = client.get(reverse("club:club_edit", kwargs={"club_id": club.id})) res = client.get(reverse("club:club_edit", kwargs={"club_id": club.id}))
assert res.status_code == 200 assert res.status_code == 200
+2 -3
View File
@@ -3,10 +3,9 @@ from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from model_bakery import baker
from club.forms import MailingForm from club.forms import MailingForm
from club.models import Club, ClubRole, Mailing, Membership from club.models import Club, Mailing, Membership
from core.models import User from core.models import User
@@ -26,7 +25,7 @@ class TestMailingForm(TestCase):
user=cls.rbatsbak, user=cls.rbatsbak,
club=cls.club, club=cls.club,
start_date=timezone.now(), start_date=timezone.now(),
role=baker.make(ClubRole, club=cls.club, is_board=True), role=settings.SITH_CLUB_ROLES_ID["Board member"],
).save() ).save()
def test_mailing_list_add_no_moderation(self): def test_mailing_list_add_no_moderation(self):
+90 -159
View File
@@ -1,20 +1,20 @@
import itertools
from collections.abc import Callable from collections.abc import Callable
from datetime import timedelta from datetime import timedelta
import pytest import pytest
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.conf import settings
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Max from django.db.models import Max
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import localdate, localtime, now from django.utils.timezone import localdate, localtime, now
from model_bakery import baker, seq from model_bakery import baker
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
from club.forms import ClubAddMemberForm, JoinClubForm from club.forms import ClubAddMemberForm, JoinClubForm
from club.models import Club, ClubRole, Membership from club.models import Club, Membership
from club.tests.base import TestClub from club.tests.base import TestClub
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, User from core.models import AnonymousUser, User
@@ -75,22 +75,17 @@ class TestMembershipQuerySet(TestClub):
def test_update_change_club_groups(self): def test_update_change_club_groups(self):
"""Test that `update` set the user groups accordingly.""" """Test that `update` set the user groups accordingly."""
user = baker.make(User) user = baker.make(User)
board_role, member_role = baker.make( membership = baker.make(Membership, end_date=None, user=user, role=5)
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 members_group = membership.club.members_group
board_group = membership.club.board_group board_group = membership.club.board_group
assert user.groups.contains(members_group) assert user.groups.contains(members_group)
assert user.groups.contains(board_group) assert user.groups.contains(board_group)
user.memberships.update(role=member_role) # from board to simple member user.memberships.update(role=1) # from board to simple member
assert user.groups.contains(members_group) assert user.groups.contains(members_group)
assert not user.groups.contains(board_group) assert not user.groups.contains(board_group)
user.memberships.update(role=board_role) # from member to board user.memberships.update(role=5) # from member to board
assert user.groups.contains(members_group) assert user.groups.contains(members_group)
assert user.groups.contains(board_group) assert user.groups.contains(board_group)
@@ -101,17 +96,7 @@ class TestMembershipQuerySet(TestClub):
def test_delete_remove_from_groups(self): def test_delete_remove_from_groups(self):
"""Test that `delete` removes from club groups""" """Test that `delete` removes from club groups"""
user = baker.make(User) user = baker.make(User)
club = baker.make(Club) memberships = baker.make(Membership, role=iter([1, 5]), user=user, _quantity=2)
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 = { club_groups = {
memberships[0].club.members_group, memberships[0].club.members_group,
memberships[1].club.members_group, memberships[1].club.members_group,
@@ -127,20 +112,13 @@ class TestMembershipEditableBy(TestCase):
def setUpTestData(cls): def setUpTestData(cls):
Membership.objects.all().delete() Membership.objects.all().delete()
cls.club_a, cls.club_b = baker.make(Club, _quantity=2) 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 = [ cls.memberships = [
*baker.make(Membership, role=iter(roles[:4]), club=cls.club_a, _quantity=4), *baker.make(
*baker.make(Membership, role=iter(roles[4:]), club=cls.club_b, _quantity=4), 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
),
] ]
def test_admin_user(self): def test_admin_user(self):
@@ -162,7 +140,7 @@ class TestMembershipEditableBy(TestCase):
class TestMembership(TestClub): class TestMembership(TestClub):
def assert_membership_started_today(self, user: User, role: ClubRole): def assert_membership_started_today(self, user: User, role: int):
"""Assert that the given membership is active and started today.""" """Assert that the given membership is active and started today."""
membership = user.memberships.ongoing().filter(club=self.club).first() membership = user.memberships.ongoing().filter(club=self.club).first()
assert membership is not None assert membership is not None
@@ -211,27 +189,21 @@ class TestMembership(TestClub):
"Marquer comme ancien", "Marquer comme ancien",
] ]
rows = table.find("tbody").find_all("tr") rows = table.find("tbody").find_all("tr")
memberships = ( memberships = self.club.members.ongoing().order_by("-role")
self.club.members.ongoing() for row, membership in zip(
.order_by("role__order") rows, memberships.select_related("user"), strict=False
.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 = membership.user
user_url = reverse("core:user_profile", args=[user.id]) user_url = reverse("core:user_profile", args=[user.id])
cols = row.find_all("td") cols = row.find_all("td")
user_link = cols[0].find("a") user_link = cols[0].find("a")
assert user_link.attrs["href"] == user_url assert user_link.attrs["href"] == user_url
assert user_link.text == user.get_display_name() assert user_link.text == user.get_display_name()
assert cols[1].text == membership.role.name assert cols[1].text == settings.SITH_CLUB_ROLES[membership.role]
assert cols[2].text == membership.description assert cols[2].text == membership.description
assert cols[3].text == str(membership.start_date) assert cols[3].text == str(membership.start_date)
if ( if membership.role < 3 or membership.user_id == self.simple_board_member.id:
membership.role.order > user_role.order
or membership.user_id == self.simple_board_member.id
):
# 3 is the role of simple_board_member # 3 is the role of simple_board_member
form_input = cols[4].find("input") form_input = cols[4].find("input")
expected_attrs = { expected_attrs = {
@@ -247,15 +219,14 @@ class TestMembership(TestClub):
"""Test that root users can add members to clubs""" """Test that root users can add members to clubs"""
self.client.force_login(self.root) self.client.force_login(self.root)
response = self.client.post( response = self.client.post(
self.new_members_url, self.new_members_url, {"user": self.subscriber.id, "role": 3}
{"user": self.subscriber.id, "role": self.board_role.id},
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.headers.get("HX-Redirect", "") == reverse( assert response.headers.get("HX-Redirect", "") == reverse(
"club:club_members", kwargs={"club_id": self.club.id} "club:club_members", kwargs={"club_id": self.club.id}
) )
self.subscriber.refresh_from_db() self.subscriber.refresh_from_db()
self.assert_membership_started_today(self.subscriber, role=self.board_role) self.assert_membership_started_today(self.subscriber, role=3)
def test_add_unauthorized_members(self): def test_add_unauthorized_members(self):
"""Test that users who are not currently subscribed """Test that users who are not currently subscribed
@@ -263,7 +234,7 @@ class TestMembership(TestClub):
""" """
for user in self.public, self.old_subscriber: for user in self.public, self.old_subscriber:
form = ClubAddMemberForm( form = ClubAddMemberForm(
data={"user": user.id, "role": self.member_role}, data={"user": user.id, "role": 1},
request_user=self.root, request_user=self.root,
club=self.club, club=self.club,
) )
@@ -284,7 +255,7 @@ class TestMembership(TestClub):
nb_memberships = self.simple_board_member.memberships.count() nb_memberships = self.simple_board_member.memberships.count()
self.client.post( self.client.post(
self.members_url, self.members_url,
{"users": self.simple_board_member.id, "role": self.member_role}, {"users": self.simple_board_member.id, "role": current_membership.role + 1},
) )
self.simple_board_member.refresh_from_db() self.simple_board_member.refresh_from_db()
assert nb_memberships == self.simple_board_member.memberships.count() assert nb_memberships == self.simple_board_member.memberships.count()
@@ -303,7 +274,7 @@ class TestMembership(TestClub):
max_id = User.objects.aggregate(id=Max("id"))["id"] max_id = User.objects.aggregate(id=Max("id"))["id"]
for members in [max_id + 1], [max_id + 1, self.subscriber.id]: for members in [max_id + 1], [max_id + 1, self.subscriber.id]:
form = ClubAddMemberForm( form = ClubAddMemberForm(
data={"user": members, "role": self.member_role}, data={"user": members, "role": 1},
request_user=self.root, request_user=self.root,
club=self.club, club=self.club,
) )
@@ -317,6 +288,44 @@ class TestMembership(TestClub):
self.club.refresh_from_db() self.club.refresh_from_db()
assert self.club.members.count() == nb_memberships 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): def test_add_member_without_role(self):
"""Test that trying to add members without specifying their role fails.""" """Test that trying to add members without specifying their role fails."""
form = ClubAddMemberForm( form = ClubAddMemberForm(
@@ -327,9 +336,8 @@ class TestMembership(TestClub):
assert form.errors == {"role": ["Ce champ est obligatoire."]} assert form.errors == {"role": ["Ce champ est obligatoire."]}
def test_add_member_already_there(self): def test_add_member_already_there(self):
role = ClubRole.objects.get(members__user=self.simple_board_member)
form = ClubAddMemberForm( form = ClubAddMemberForm(
data={"user": self.simple_board_member, "role": role.id}, data={"user": self.simple_board_member, "role": 3},
request_user=self.root, request_user=self.root,
club=self.club, club=self.club,
) )
@@ -340,27 +348,22 @@ class TestMembership(TestClub):
def test_add_other_member_forbidden(self): def test_add_other_member_forbidden(self):
non_member = subscriber_user.make() non_member = subscriber_user.make()
simple_member = baker.make( simple_member = baker.make(Membership, club=self.club, role=1).user
Membership, club=self.club, role=self.member_role
).user
for user in non_member, simple_member: for user in non_member, simple_member:
form = ClubAddMemberForm( form = ClubAddMemberForm(
data={"user": subscriber_user.make(), "role": self.member_role.id}, data={"user": subscriber_user.make(), "role": 1},
request_user=user, request_user=user,
club=self.club, club=self.club,
) )
assert not form.is_valid() assert not form.is_valid()
assert form.errors == { assert form.errors == {
"role": [ "role": ["Sélectionnez un choix valide. 1 n\u2019en fait pas partie."]
"Sélectionnez un choix valide. "
"Ce choix ne fait pas partie de ceux disponibles."
]
} }
def test_simple_members_dont_see_form_anymore(self): def test_simple_members_dont_see_form_anymore(self):
"""Test that simple club members don't see the form to add members""" """Test that simple club members don't see the form to add members"""
user = subscriber_user.make() user = subscriber_user.make()
baker.make(Membership, club=self.club, user=user, role=self.member_role) baker.make(Membership, club=self.club, user=user, role=1)
self.client.force_login(user) self.client.force_login(user)
res = self.client.get(self.members_url) res = self.client.get(self.members_url)
assert res.status_code == 200 assert res.status_code == 200
@@ -379,10 +382,9 @@ class TestMembership(TestClub):
"""Test that board members of the club can end memberships """Test that board members of the club can end memberships
of users with lower roles. of users with lower roles.
""" """
# reminder : simple_board_member has role 3
self.client.force_login(self.simple_board_member) self.client.force_login(self.simple_board_member)
role = baker.make(ClubRole, club=self.club, is_board=True) membership = baker.make(Membership, club=self.club, role=2, end_date=None)
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]}) response = self.client.post(self.members_url, {"members_old": [membership.id]})
self.assertRedirects(response, self.members_url) self.assertRedirects(response, self.members_url)
self.club.refresh_from_db() self.club.refresh_from_db()
@@ -392,9 +394,7 @@ class TestMembership(TestClub):
"""Test that board members of the club cannot end memberships """Test that board members of the club cannot end memberships
of users with higher roles. of users with higher roles.
""" """
membership = self.president.memberships.filter( membership = self.president.memberships.filter(club=self.club).first()
club=self.club, end_date=None
).first()
self.client.force_login(self.simple_board_member) self.client.force_login(self.simple_board_member)
self.client.post(self.members_url, {"members_old": [membership.id]}) self.client.post(self.members_url, {"members_old": [membership.id]})
self.club.refresh_from_db() self.club.refresh_from_db()
@@ -436,9 +436,7 @@ class TestMembership(TestClub):
def test_remove_from_club_group(self): def test_remove_from_club_group(self):
"""Test that when a membership ends, the user is removed from club groups.""" """Test that when a membership ends, the user is removed from club groups."""
user = baker.make(User) user = baker.make(User)
baker.make( baker.make(Membership, user=user, club=self.club, end_date=None, role=3)
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.members_group)
assert user.groups.contains(self.club.board_group) assert user.groups.contains(self.club.board_group)
user.memberships.update(end_date=localdate()) user.memberships.update(end_date=localdate())
@@ -449,20 +447,18 @@ class TestMembership(TestClub):
"""Test that when a membership begins, the user is added to the club group.""" """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.members_group)
assert not self.subscriber.groups.contains(self.club.board_group) assert not self.subscriber.groups.contains(self.club.board_group)
baker.make( 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.members_group)
assert self.subscriber.groups.contains(self.club.board_group) assert self.subscriber.groups.contains(self.club.board_group)
def test_change_position_in_club(self): def test_change_position_in_club(self):
"""Test that when moving from board to members, club group change""" """Test that when moving from board to members, club group change"""
membership = baker.make( membership = baker.make(
Membership, club=self.club, user=self.subscriber, role=self.board_role Membership, club=self.club, user=self.subscriber, role=3
) )
assert self.subscriber.groups.contains(self.club.members_group) assert self.subscriber.groups.contains(self.club.members_group)
assert self.subscriber.groups.contains(self.club.board_group) assert self.subscriber.groups.contains(self.club.board_group)
membership.role = self.member_role membership.role = 1
membership.save() membership.save()
assert self.subscriber.groups.contains(self.club.members_group) assert self.subscriber.groups.contains(self.club.members_group)
assert not self.subscriber.groups.contains(self.club.board_group) assert not self.subscriber.groups.contains(self.club.board_group)
@@ -475,11 +471,7 @@ class TestMembership(TestClub):
# make sli a board member # make sli a board member
self.sli.memberships.all().delete() self.sli.memberships.all().delete()
Membership( Membership(club=self.ae, user=self.sli, role=3).save()
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) assert self.club.is_owned_by(self.sli)
def test_change_club_name(self): def test_change_club_name(self):
@@ -505,7 +497,7 @@ class TestMembership(TestClub):
@pytest.mark.django_db @pytest.mark.django_db
def test_membership_set_old(client: Client): 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) client.force_login(membership.user)
response = client.post( response = client.post(
reverse("club:membership_set_old", kwargs={"membership_id": membership.id}) reverse("club:membership_set_old", kwargs={"membership_id": membership.id})
@@ -532,50 +524,6 @@ def test_membership_delete(client: Client):
assert not Membership.objects.filter(id=membership.id).exists() 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 @pytest.mark.django_db
class TestJoinClub: class TestJoinClub:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -583,64 +531,55 @@ class TestJoinClub:
cache.clear() cache.clear()
@pytest.mark.parametrize( @pytest.mark.parametrize(
("user_factory", "board_role", "errors"), ("user_factory", "role", "errors"),
[ [
( (
subscriber_user.make, subscriber_user.make,
True, 2,
{ {
"role": [ "role": [
"Sélectionnez un choix valide. " "Sélectionnez un choix valide. 2 n\u2019en fait pas partie."
"Ce choix ne fait pas partie de ceux disponibles."
] ]
}, },
), ),
( (
lambda: baker.make(User), lambda: baker.make(User),
False, 1,
{"__all__": ["Vous devez être cotisant pour faire partie d'un club"]}, {"__all__": ["Vous devez être cotisant pour faire partie d'un club"]},
), ),
], ],
) )
def test_join_club_errors( def test_join_club_errors(
self, user_factory: Callable[[], User], board_role, errors: dict self, user_factory: Callable[[], User], role: int, errors: dict
): ):
club = baker.make(Club) club = baker.make(Club)
user = user_factory() user = user_factory()
role = baker.make(ClubRole, club=club, is_board=board_role) form = JoinClubForm(club=club, request_user=user, data={"role": role})
form = JoinClubForm(club=club, request_user=user, data={"role": role.id})
assert not form.is_valid() assert not form.is_valid()
assert form.errors == errors assert form.errors == errors
def test_user_already_in_club(self): def test_user_already_in_club(self):
club = baker.make(Club)
user = subscriber_user.make() user = subscriber_user.make()
role = baker.make(ClubRole, is_board=False) baker.make(Membership, user=user, club=club)
baker.make(Membership, user=user, club=role.club) form = JoinClubForm(club=club, request_user=user, data={"role": 1})
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
assert not form.is_valid() assert not form.is_valid()
assert form.errors == {"__all__": ["Vous êtes déjà membre de ce club."]} assert form.errors == {"__all__": ["Vous êtes déjà membre de ce club."]}
def test_ok(self): def test_ok(self):
club = baker.make(Club)
user = subscriber_user.make() user = subscriber_user.make()
role = baker.make(ClubRole, is_board=False) form = JoinClubForm(club=club, request_user=user, data={"role": 1})
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
assert form.is_valid() assert form.is_valid()
form.save() form.save()
assert Membership.objects.ongoing().filter(user=user, club=role.club).exists() assert Membership.objects.ongoing().filter(user=user, club=club).exists()
class TestOldMembersView(TestCase): class TestOldMembersView(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
club = baker.make(Club) club = baker.make(Club)
roles = baker.make( roles = [1, 1, 1, 2, 2, 4, 4, 5, 7, 9, 10]
ClubRole,
club=club,
is_board=itertools.cycle([True, True, False]),
order=seq(0),
_quantity=10,
_bulk_create=True,
)
cls.memberships = baker.make( cls.memberships = baker.make(
Membership, Membership,
role=iter(roles), role=iter(roles),
@@ -665,11 +604,3 @@ class TestOldMembersView(TestCase):
self.client.force_login(baker.make(User)) self.client.force_login(baker.make(User))
res = self.client.get(self.url) res = self.client.get(self.url)
assert res.status_code == 403 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:]
+2 -7
View File
@@ -5,7 +5,7 @@ from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects from pytest_django.asserts import assertHTMLEqual, assertRedirects
from club.models import Club, ClubRole, Membership from club.models import Club, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.markdown import markdown from core.markdown import markdown
from core.models import PageRev, User from core.models import PageRev, User
@@ -59,12 +59,7 @@ def test_page_revision(client: Client):
def test_edit_page(client: Client): def test_edit_page(client: Client):
club = baker.make(Club) club = baker.make(Club)
user = subscriber_user.make() user = subscriber_user.make()
baker.make( baker.make(Membership, user=user, club=club, role=3)
Membership,
user=user,
club=club,
role=baker.make(ClubRole, club=club, is_board=True),
)
client.force_login(user) client.force_login(user)
url = reverse("club:club_edit_page", kwargs={"club_id": club.id}) url = reverse("club:club_edit_page", kwargs={"club_id": club.id})
content = "# foo\nLorem ipsum dolor sit amet" content = "# foo\nLorem ipsum dolor sit amet"
+2 -5
View File
@@ -6,7 +6,7 @@ from django.utils.timezone import localdate
from model_bakery import baker from model_bakery import baker
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from club.models import Club, ClubRole, Membership from club.models import Club, Membership
from club.schemas import UserMembershipSchema from club.schemas import UserMembershipSchema
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import Page from core.models import Page
@@ -19,10 +19,7 @@ class TestFetchClub(TestCase):
pages = baker.make(Page, _quantity=3, _bulk_create=True) pages = baker.make(Page, _quantity=3, _bulk_create=True)
clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True) clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True)
recipe = Recipe( recipe = Recipe(
Membership, Membership, user=cls.user, start_date=localdate() - timedelta(days=2)
user=cls.user,
start_date=localdate() - timedelta(days=2),
role=baker.make(ClubRole),
) )
cls.members = Membership.objects.bulk_create( cls.members = Membership.objects.bulk_create(
[ [
+9 -11
View File
@@ -28,6 +28,7 @@ import csv
import itertools import itertools
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
@@ -354,7 +355,7 @@ class ClubMembersView(
membership = self.object.get_membership_for(self.request.user) membership = self.object.get_membership_for(self.request.user)
if ( if (
membership membership
and not membership.role.is_board and membership.role <= settings.SITH_MAXIMUM_FREE_ROLE
and not self.request.user.has_perm("club.add_membership") and not self.request.user.has_perm("club.add_membership")
): ):
# Simple club members won't see the form anymore. # Simple club members won't see the form anymore.
@@ -379,8 +380,8 @@ class ClubMembersView(
kwargs["members"] = list( kwargs["members"] = list(
self.object.members.ongoing() self.object.members.ongoing()
.annotate(is_editable=Q(id__in=editable)) .annotate(is_editable=Q(id__in=editable))
.order_by("role__order") .order_by("-role")
.select_related("user", "role") .select_related("user")
) )
kwargs["can_end_membership"] = len(editable) > 0 kwargs["can_end_membership"] = len(editable) > 0
return kwargs return kwargs
@@ -408,8 +409,8 @@ class ClubOldMembersView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
return super().get_context_data(**kwargs) | { return super().get_context_data(**kwargs) | {
"old_members": ( "old_members": (
self.object.members.exclude(end_date=None) self.object.members.exclude(end_date=None)
.order_by("role__order", "description", "-end_date") .order_by("-role", "description", "-end_date")
.select_related("user", "role") .select_related("user")
) )
} }
@@ -580,11 +581,6 @@ class ClubCreateView(PermissionRequiredMixin, CreateView):
template_name = "core/create.jinja" template_name = "core/create.jinja"
permission_required = "club.add_club" 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): class MembershipSetOldView(CanEditMixin, SingleObjectMixin, View):
"""Set a membership as being old.""" """Set a membership as being old."""
@@ -765,7 +761,9 @@ class MailingAutoGenerationView(View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
club = self.mailing.club club = self.mailing.club
self.mailing.subscriptions.all().delete() self.mailing.subscriptions.all().delete()
members = club.members.ongoing().filter(role__is_board=True) members = club.members.filter(
role__gte=settings.SITH_CLUB_ROLES_ID["Board member"]
).exclude(end_date__lte=timezone.now())
for member in members.all(): for member in members.all():
MailingSubscription(user=member.user, mailing=self.mailing).save() MailingSubscription(user=member.user, mailing=self.mailing).save()
return redirect("club:mailing", club_id=club.id) return redirect("club:mailing", club_id=club.id)
+13 -17
View File
@@ -3,7 +3,6 @@
#news { #news {
display: flex; display: flex;
gap: 1em;
@media (max-width: 800px) { @media (max-width: 800px) {
flex-direction: column; flex-direction: column;
@@ -27,14 +26,12 @@
} }
h3 { h3 {
--box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 3px 7px 2px; background: $second-color;
background: lighten($second-color, 5%); box-shadow: $shadow-color 1px 1px 1px;
box-shadow: var(--box-shadow); padding: 0.4em;
padding: .75rem;
margin: 0 0 0.5em 0; margin: 0 0 0.5em 0;
text-transform: uppercase; text-transform: uppercase;
font-size: 17px; font-size: 17px;
border-radius: 10px;
&:not(:first-of-type) { &:not(:first-of-type) {
margin: 2em 0 1em 0; margin: 2em 0 1em 0;
@@ -42,11 +39,12 @@
.feed { .feed {
float: right; float: right;
color: #e25512; color: #f26522;
} }
} }
@media screen and (max-width: $small-devices) { @media screen and (max-width: $small-devices) {
#left_column, #left_column,
#right_column { #right_column {
flex: 100%; flex: 100%;
@@ -59,7 +57,6 @@
max-height: 600px; max-height: 600px;
overflow-y: scroll; overflow-y: scroll;
overflow-x: clip; overflow-x: clip;
margin-top: 1em;
#load-more-news-button { #load-more-news-button {
text-align: center; text-align: center;
@@ -79,11 +76,15 @@
font-size: 70%; font-size: 70%;
margin-bottom: 1em; margin-bottom: 1em;
h3 {
margin-bottom: 0;
}
#links_content { #links_content {
overflow: auto; overflow: auto;
box-shadow: $shadow-color 1px 1px 1px; box-shadow: $shadow-color 1px 1px 1px;
min-height: 20em; min-height: 20em;
padding: 1em; padding-bottom: 1em;
h4 { h4 {
margin-left: 5px; margin-left: 5px;
@@ -120,8 +121,6 @@
} }
#birthdays_content { #birthdays_content {
box-shadow: $shadow-color 1px 1px 1px;
padding: 1em;
ul.birthdays_year { ul.birthdays_year {
margin: 0; margin: 0;
list-style-type: none; list-style-type: none;
@@ -136,7 +135,8 @@
} }
ul { ul {
margin: .5em 0 0 1em; margin: 0;
margin-left: 1em;
list-style-type: square; list-style-type: square;
list-style-position: inside; list-style-position: inside;
font-weight: normal; font-weight: normal;
@@ -150,13 +150,9 @@
/* EVENTS TODAY AND NEXT FEW DAYS */ /* EVENTS TODAY AND NEXT FEW DAYS */
.news_events_group { .news_events_group {
box-shadow: $shadow-color 1px 1px 1px; box-shadow: $shadow-color 1px 1px 1px;
margin-left: 0; margin-left: 1em;
margin-bottom: 0.5em; margin-bottom: 0.5em;
@media screen and (max-width: $small-devices) {
margin-left: 3px;
}
.news_events_group_date { .news_events_group_date {
display: table-cell; display: table-cell;
padding: 0.6em; 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> <a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
</h3> </h3>
{% if user.is_authenticated and (user.is_com_admin or user.memberships.board().ongoing().exists()) %} {% if user.is_authenticated and (user.is_com_admin or user.memberships.board().ongoing().exists()) %}
<a class="btn btn-blue" href="{{ url("com:news_new") }}"> <a class="btn btn-blue margin-bottom" href="{{ url("com:news_new") }}">
<i class="fa fa-plus"></i> <i class="fa fa-plus"></i>
{% trans %}Create news{% endtrans %} {% trans %}Create news{% endtrans %}
</a> </a>
+1 -2
View File
@@ -7,7 +7,7 @@ from model_bakery import baker
from com.models import News, NewsDate from com.models import News, NewsDate
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import Group, Notification, SithFile, User from core.models import Group, Notification, User
@pytest.mark.django_db @pytest.mark.django_db
@@ -18,7 +18,6 @@ def test_notification_created():
past_news = baker.make(News, is_published=False) past_news = baker.make(News, is_published=False)
baker.make(NewsDate, news=past_news, start_date=now() - timedelta(days=1)) 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) 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() com_admin_group.users.all().delete()
Notification.objects.all().delete() Notification.objects.all().delete()
com_admin = baker.make(User, groups=[com_admin_group]) com_admin = baker.make(User, groups=[com_admin_group])
+2 -3
View File
@@ -28,7 +28,7 @@ from django.utils.translation import gettext as _
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertNumQueries, assertRedirects from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club, ClubRole, Membership from club.models import Club, Membership
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, Group, User from core.models import AnonymousUser, Group, User
@@ -214,8 +214,7 @@ class TestNewsCreation(TestCase):
def setUpTestData(cls): def setUpTestData(cls):
cls.club = baker.make(Club) cls.club = baker.make(Club)
cls.user = subscriber_user.make() cls.user = subscriber_user.make()
role = baker.make(ClubRole, club=cls.club, is_board=True) baker.make(Membership, user=cls.user, club=cls.club, role=5)
baker.make(Membership, user=cls.user, club=cls.club, role=role)
def setUp(self): def setUp(self):
self.client.force_login(self.user) self.client.force_login(self.user)
+1 -1
View File
@@ -503,7 +503,7 @@ class WeekmailArticleCreateView(CreateView):
self.object = form.instance self.object = form.instance
form.is_valid() # Valid a first time to populate club field form.is_valid() # Valid a first time to populate club field
m = form.instance.club.get_membership_for(request.user) m = form.instance.club.get_membership_for(request.user)
if m is None or not m.role.is_board: if m is None or m.role <= settings.SITH_MAXIMUM_FREE_ROLE:
form.add_error( form.add_error(
"club", "club",
ValidationError( ValidationError(
+3 -5
View File
@@ -4,9 +4,9 @@ from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.utils.timezone import localdate, now from django.utils.timezone import localdate, now
from model_bakery import seq from model_bakery import seq
from model_bakery.recipe import Recipe, foreign_key, related from model_bakery.recipe import Recipe, related
from club.models import ClubRole, Membership from club.models import Membership
from core.models import Group, User from core.models import Group, User
from subscription.models import Subscription from subscription.models import Subscription
@@ -52,9 +52,7 @@ ae_board_membership = Recipe(
Membership, Membership,
start_date=now() - timedelta(days=30), start_date=now() - timedelta(days=30),
club_id=settings.SITH_MAIN_CLUB_ID, club_id=settings.SITH_MAIN_CLUB_ID,
role=foreign_key( role=settings.SITH_CLUB_ROLES_ID["Board member"],
Recipe(ClubRole, club_id=settings.SITH_MAIN_CLUB_ID, is_board=True)
),
) )
board_user = Recipe( board_user = Recipe(
+70 -96
View File
@@ -36,7 +36,7 @@ from django.utils import timezone
from django.utils.timezone import localdate from django.utils.timezone import localdate
from PIL import Image from PIL import Image
from club.models import Club, ClubRole, Membership from club.models import Club, Membership
from com.ics_calendar import IcsCalendar from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate, Sith, Weekmail from com.models import News, NewsDate, Sith, Weekmail
from core.models import BanGroup, Group, Page, PageRev, SithFile, User from core.models import BanGroup, Group, Page, PageRev, SithFile, User
@@ -69,13 +69,6 @@ class PopulatedGroups(NamedTuple):
campus_admin: Group campus_admin: Group
class PopulatedClubs(NamedTuple):
ae: Club
troll: Club
pdf: Club
refound: Club
class Command(BaseCommand): class Command(BaseCommand):
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
SAS_FIXTURE_PATH: ClassVar[Path] = ( SAS_FIXTURE_PATH: ClassVar[Path] = (
@@ -127,16 +120,32 @@ class Command(BaseCommand):
sas = SithFile.objects.create( sas = SithFile.objects.create(
name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID
) )
clubs = self._create_clubs() 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",
)
self.reset_index("club") self.reset_index("club")
for bar_id, bar_name in settings.SITH_COUNTER_BARS: for bar_id, bar_name in settings.SITH_COUNTER_BARS:
Counter(id=bar_id, name=bar_name, club=clubs.pdf, type="BAR").save() Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR").save()
self.reset_index("counter") self.reset_index("counter")
counters = [ counters = [
Counter(name="Eboutic", club=clubs.ae, type="EBOUTIC"), Counter(name="Eboutic", club=main_club, type="EBOUTIC"),
Counter(name="AE", club=clubs.ae, type="OFFICE"), Counter(name="AE", club=main_club, type="OFFICE"),
Counter(name="Vidage comptes AE", club=clubs.ae, type="OFFICE"), Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
] ]
Counter.objects.bulk_create(counters) Counter.objects.bulk_create(counters)
bar_groups = [] bar_groups = []
@@ -319,49 +328,62 @@ class Command(BaseCommand):
self._create_subscription(tutu) self._create_subscription(tutu)
StudentCard(uid="9A89B82018B0A0", customer=sli.customer).save() StudentCard(uid="9A89B82018B0A0", customer=sli.customer).save()
Membership.objects.create( # Clubs
user=skia, club=clubs.ae, role=clubs.ae.roles.get(name="Respo Info") Club.objects.create(
name="Bibo'UT", address="46 de la Boustifaille", parent=main_club
) )
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( Membership.objects.create(
user=comunity, user=comunity,
club=clubs.pdf, club=bar_club,
start_date=localdate(), start_date=localdate(),
role=clubs.pdf.roles.get(name="Membre du bureau"), role=settings.SITH_CLUB_ROLES_ID["Board member"],
) )
Membership.objects.create( Membership.objects.create(
user=sli, user=sli,
club=clubs.troll, club=troll,
role=clubs.troll.roles.get(name="Vice-Président⸱e"), role=9,
description="Padawan Troll", description="Padawan Troll",
start_date=localdate() - timedelta(days=17), start_date=localdate() - timedelta(days=17),
) )
Membership.objects.create( Membership.objects.create(
user=krophil, user=krophil,
club=clubs.troll, club=troll,
role=clubs.troll.roles.get(name="Président⸱e"), role=10,
description="Maitre Troll", description="Maitre Troll",
start_date=localdate() - timedelta(days=200), start_date=localdate() - timedelta(days=200),
) )
Membership.objects.create( Membership.objects.create(
user=skia, user=skia,
club=clubs.troll, club=troll,
role=clubs.troll.roles.get(name="Membre du bureau"), role=2,
description="Grand Ancien Troll", description="Grand Ancien Troll",
start_date=localdate() - timedelta(days=400), start_date=localdate() - timedelta(days=400),
end_date=localdate() - timedelta(days=86), end_date=localdate() - timedelta(days=86),
) )
Membership.objects.create( Membership.objects.create(
user=richard, user=richard,
club=clubs.troll, club=troll,
role=clubs.troll.roles.get(name="Membre du bureau"), role=2,
description="", description="",
start_date=localdate() - timedelta(days=200), start_date=localdate() - timedelta(days=200),
end_date=localdate() - timedelta(days=100), end_date=localdate() - timedelta(days=100),
) )
self._create_products(groups, clubs) self._create_products(groups, main_club, refound)
Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE") Counter.objects.create(name="Carte AE", club=refound, type="OFFICE")
# Add barman to counter # Add barman to counter
Counter.sellers.through.objects.bulk_create( Counter.sellers.through.objects.bulk_create(
@@ -381,7 +403,7 @@ class Command(BaseCommand):
end_date="7942-06-12 10:28:45+01", end_date="7942-06-12 10:28:45+01",
) )
el.view_groups.add(groups.public) el.view_groups.add(groups.public)
el.edit_groups.add(clubs.ae.board_group) el.edit_groups.add(main_club.board_group)
el.candidature_groups.add(groups.subscribers) el.candidature_groups.add(groups.subscribers)
el.vote_groups.add(groups.subscribers) el.vote_groups.add(groups.subscribers)
liste = ElectionList.objects.create(title="Candidature Libre", election=el) liste = ElectionList.objects.create(title="Candidature Libre", election=el)
@@ -454,7 +476,7 @@ class Command(BaseCommand):
title="Apero barman", title="Apero barman",
summary="Viens boire un coup avec les barmans", summary="Viens boire un coup avec les barmans",
content="Glou glou glou glou glou glou glou", content="Glou glou glou glou glou glou glou",
club=clubs.pdf, club=bar_club,
author=subscriber, author=subscriber,
is_published=True, is_published=True,
moderator=skia, moderator=skia,
@@ -472,7 +494,7 @@ class Command(BaseCommand):
content=( content=(
"Viens donc t'enjailler avec les autres barmans aux frais du BdF! \\o/" "Viens donc t'enjailler avec les autres barmans aux frais du BdF! \\o/"
), ),
club=clubs.pdf, club=bar_club,
author=subscriber, author=subscriber,
is_published=True, is_published=True,
moderator=skia, moderator=skia,
@@ -488,7 +510,7 @@ class Command(BaseCommand):
title="Repas fromager", title="Repas fromager",
summary="Wien manger du l'bon fromeug'", summary="Wien manger du l'bon fromeug'",
content="Fô viendre mangey d'la bonne fondue!", content="Fô viendre mangey d'la bonne fondue!",
club=clubs.pdf, club=bar_club,
author=subscriber, author=subscriber,
is_published=True, is_published=True,
moderator=skia, moderator=skia,
@@ -504,7 +526,7 @@ class Command(BaseCommand):
title="SdF", title="SdF",
summary="Enjoy la fin des finaux!", summary="Enjoy la fin des finaux!",
content="Viens faire la fête avec tout plein de gens!", content="Viens faire la fête avec tout plein de gens!",
club=clubs.pdf, club=bar_club,
author=subscriber, author=subscriber,
is_published=True, is_published=True,
moderator=skia, moderator=skia,
@@ -522,7 +544,7 @@ class Command(BaseCommand):
summary="Viens jouer!", summary="Viens jouer!",
content="Rejoins la fine équipe du Troll Penché et viens " content="Rejoins la fine équipe du Troll Penché et viens "
"t'amuser le Vendredi soir!", "t'amuser le Vendredi soir!",
club=clubs.troll, club=troll,
author=subscriber, author=subscriber,
is_published=True, is_published=True,
moderator=skia, moderator=skia,
@@ -600,7 +622,8 @@ class Command(BaseCommand):
) )
pict.file.name = p.name pict.file.name = p.name
pict.full_clean() pict.full_clean()
pict.generate_thumbnails(save=True) pict.generate_thumbnails()
pict.save()
img_skia = Picture.objects.get(name="skia.jpg") img_skia = Picture.objects.get(name="skia.jpg")
img_sli = Picture.objects.get(name="sli.jpg") img_sli = Picture.objects.get(name="sli.jpg")
@@ -622,7 +645,9 @@ class Command(BaseCommand):
] ]
) )
def _create_products(self, groups: PopulatedGroups, clubs: PopulatedClubs): def _create_products(
self, groups: PopulatedGroups, main_club: Club, refound_club: Club
):
beers_type, cotis_type, refill_type, verre_type = ( beers_type, cotis_type, refill_type, verre_type = (
ProductType.objects.bulk_create( ProductType.objects.bulk_create(
[ [
@@ -638,28 +663,28 @@ class Command(BaseCommand):
code="1SCOTIZ", code="1SCOTIZ",
product_type=cotis_type, product_type=cotis_type,
purchase_price=15, purchase_price=15,
club=clubs.ae, club=main_club,
) )
cotis2 = Product.objects.create( cotis2 = Product.objects.create(
name="Cotis 2 semestres", name="Cotis 2 semestres",
code="2SCOTIZ", code="2SCOTIZ",
product_type=cotis_type, product_type=cotis_type,
purchase_price="28", purchase_price="28",
club=clubs.ae, club=main_club,
) )
refill = Product.objects.create( refill = Product.objects.create(
name="Rechargement 15 €", name="Rechargement 15 €",
code="15REFILL", code="15REFILL",
product_type=refill_type, product_type=refill_type,
purchase_price=15, purchase_price=15,
club=clubs.ae, club=main_club,
) )
barb = Product.objects.create( barb = Product.objects.create(
name="Barbar", name="Barbar",
code="BARB", code="BARB",
product_type=beers_type, product_type=beers_type,
purchase_price="1.50", purchase_price="1.50",
club=clubs.pdf, club=main_club,
limit_age=18, limit_age=18,
) )
cble = Product.objects.create( cble = Product.objects.create(
@@ -667,7 +692,7 @@ class Command(BaseCommand):
code="CBLE", code="CBLE",
product_type=beers_type, product_type=beers_type,
purchase_price="1.50", purchase_price="1.50",
club=clubs.pdf, club=main_club,
limit_age=18, limit_age=18,
) )
cons = Product.objects.create( cons = Product.objects.create(
@@ -675,21 +700,21 @@ class Command(BaseCommand):
code="CONS", code="CONS",
product_type=verre_type, product_type=verre_type,
purchase_price="1", purchase_price="1",
club=clubs.pdf, club=main_club,
) )
dcons = Product.objects.create( dcons = Product.objects.create(
name="Déconsigne Eco-cup", name="Déconsigne Eco-cup",
code="DECO", code="DECO",
product_type=verre_type, product_type=verre_type,
purchase_price="-1", purchase_price="-1",
club=clubs.pdf, club=main_club,
) )
cors = Product.objects.create( cors = Product.objects.create(
name="Corsendonk", name="Corsendonk",
code="CORS", code="CORS",
product_type=beers_type, product_type=beers_type,
purchase_price="1.50", purchase_price="1.50",
club=clubs.pdf, club=main_club,
limit_age=18, limit_age=18,
) )
carolus = Product.objects.create( carolus = Product.objects.create(
@@ -697,14 +722,14 @@ class Command(BaseCommand):
code="CARO", code="CARO",
product_type=beers_type, product_type=beers_type,
purchase_price="1.50", purchase_price="1.50",
club=clubs.pdf, club=main_club,
limit_age=18, limit_age=18,
) )
Product.objects.create( Product.objects.create(
name="remboursement", name="remboursement",
code="REMBOURS", code="REMBOURS",
purchase_price=0, purchase_price=0,
club=clubs.refound, club=refound_club,
) )
ReturnableProduct.objects.create( ReturnableProduct.objects.create(
product=cons, returned_product=dcons, max_return=3 product=cons, returned_product=dcons, max_return=3
@@ -781,57 +806,6 @@ class Command(BaseCommand):
) )
s.save() s.save()
def _create_clubs(self) -> PopulatedClubs:
ae = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
)
ae.board_group.permissions.add(
*Permission.objects.filter(
codename__in=[
"view_subscription",
"add_subscription",
"add_membership",
"view_hidden_user",
]
)
)
pdf = Club.objects.create(
id=settings.SITH_PDF_CLUB_ID,
name="PdF",
address="6 Boulevard Anatole France, 90000 Belfort",
)
troll = Club.objects.create(
name="Troll Penché", address="Terre Du Milieu", parent=ae
)
refound = Club.objects.create(
name="Carte AE", address="Jamais imprimée", parent=ae
)
roles = []
presidency_roles = ["Président⸱e", "Vice-Président⸱e"]
board_roles = [
"Trésorier⸱e",
"Secrétaire",
"Respo Info",
"Respo Com",
"Membre du bureau",
]
simple_roles = ["Membre actif⸱ve", "Curieux⸱euse"]
for club in ae, pdf, troll, refound:
for i, role in enumerate(presidency_roles):
roles.append(
ClubRole(
club=club, order=i, name=role, is_presidency=True, is_board=True
)
)
for i, role in enumerate(board_roles, start=len(presidency_roles)):
roles.append(ClubRole(club=club, order=i, name=role, is_board=True))
for i, role in enumerate(
simple_roles, start=len(presidency_roles) + len(board_roles)
):
roles.append(ClubRole(club=club, order=i, name=role))
ClubRole.objects.bulk_create(roles)
return PopulatedClubs(ae=ae, troll=troll, pdf=pdf, refound=refound)
def _create_groups(self) -> PopulatedGroups: def _create_groups(self) -> PopulatedGroups:
perms = Permission.objects.all() perms = Permission.objects.all()
+14 -14
View File
@@ -11,7 +11,7 @@ from django.db.models import Count, Exists, Min, OuterRef, Subquery
from django.utils.timezone import localdate, make_aware, now from django.utils.timezone import localdate, make_aware, now
from faker import Faker from faker import Faker
from club.models import Club, ClubRole, Membership from club.models import Club, Membership
from core.models import Group, User, UserBan from core.models import Group, User, UserBan
from counter.models import ( from counter.models import (
Counter, Counter,
@@ -173,25 +173,20 @@ class Command(BaseCommand):
Customer.objects.bulk_create(customers, ignore_conflicts=True) Customer.objects.bulk_create(customers, ignore_conflicts=True)
def make_club(self, club: Club, members: list[User], old_members: list[User]): def make_club(self, club: Club, members: list[User], old_members: list[User]):
roles: list[ClubRole] = list(club.roles.all()) def zip_roles(users: list[User]) -> Iterator[tuple[User, int]]:
roles = iter(sorted(settings.SITH_CLUB_ROLES.keys(), reverse=True))
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 user_idx = 0
for _role in important_roles: while (role := next(roles)) > 2:
# one member for each major role # one member for each major role
yield users[user_idx], _role yield users[user_idx], role
user_idx += 1 user_idx += 1
for _ in range(int(0.3 * (len(users) - user_idx))): for _ in range(int(0.3 * (len(users) - user_idx))):
# 30% of the remaining in the board # 30% of the remaining in the board
yield users[user_idx], simple_board_role yield users[user_idx], 2
user_idx += 1 user_idx += 1
for remaining in users[user_idx + 1 :]: for remaining in users[user_idx + 1 :]:
# everything else is a simple member # everything else is a simple member
yield remaining, random.choices(member_roles, weights=(0.8, 0.2))[0] yield remaining, 1
memberships = [] memberships = []
old_members = old_members.copy() old_members = old_members.copy()
@@ -203,14 +198,19 @@ class Command(BaseCommand):
start_date=start, start_date=start,
end_date=self.faker.past_date(start), end_date=self.faker.past_date(start),
user=old, user=old,
role=random.choice(roles), role=random.choice(list(settings.SITH_CLUB_ROLES.keys())),
club=club, club=club,
) )
) )
for member, role in zip_roles(members): for member, role in zip_roles(members):
start = self.faker.past_date("-1y") start = self.faker.past_date("-1y")
memberships.append( 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) memberships = Membership.objects.bulk_create(memberships)
Membership._add_club_groups(memberships) Membership._add_club_groups(memberships)
@@ -1,47 +0,0 @@
# 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),
]
+2 -3
View File
@@ -853,7 +853,7 @@ class SithFile(models.Model):
User, User,
related_name="owned_files", related_name="owned_files",
verbose_name=_("owner"), verbose_name=_("owner"),
on_delete=models.PROTECT, on_delete=models.CASCADE,
) )
edit_groups = models.ManyToManyField( edit_groups = models.ManyToManyField(
Group, related_name="editable_files", verbose_name=_("edit group"), blank=True Group, related_name="editable_files", verbose_name=_("edit group"), blank=True
@@ -865,7 +865,6 @@ class SithFile(models.Model):
mime_type = models.CharField(_("mime type"), max_length=30) mime_type = models.CharField(_("mime type"), max_length=30)
size = models.IntegerField(_("size"), default=0) size = models.IntegerField(_("size"), default=0)
date = models.DateTimeField(_("date"), default=timezone.now) date = models.DateTimeField(_("date"), default=timezone.now)
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
is_moderated = models.BooleanField(_("is moderated"), default=False) is_moderated = models.BooleanField(_("is moderated"), default=False)
moderator = models.ForeignKey( moderator = models.ForeignKey(
User, User,
@@ -873,7 +872,7 @@ class SithFile(models.Model):
verbose_name=_("owner"), verbose_name=_("owner"),
null=True, null=True,
blank=True, blank=True,
on_delete=models.SET_NULL, on_delete=models.CASCADE,
) )
asked_for_removal = models.BooleanField(_("asked for removal"), default=False) asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
is_in_sas = models.BooleanField( is_in_sas = models.BooleanField(
@@ -28,7 +28,7 @@ export class Tab extends HTMLElement {
return html` return html`
<button <button
role="tab" role="tab"
?aria-selected="${this.active}" ?aria-selected=${this.active}
class="tab-header clickable ${this.active ? "active" : ""}" class="tab-header clickable ${this.active ? "active" : ""}"
@click="${() => this.setActive(true)}" @click="${() => this.setActive(true)}"
> >
@@ -40,7 +40,7 @@ export class Tab extends HTMLElement {
return html` return html`
<section <section
class="tab-section" class="tab-section"
?hidden="${!this.active}" ?hidden=${!this.active}
> >
${unsafeHTML(this.getContentHtml())} ${unsafeHTML(this.getContentHtml())}
</section> </section>
@@ -47,18 +47,9 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
implements InheritedHtmlElement<K> implements InheritedHtmlElement<K>
{ {
readonly inheritedTagName = tagName; readonly inheritedTagName = tagName;
private readonly initializedAttribute = "component-initialized";
node: HTMLElementTagNameMap[K]; node: HTMLElementTagNameMap[K];
connectedCallback(autoAddNode?: boolean) { 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); this.node = document.createElement(this.inheritedTagName);
const attributes: Attr[] = []; // We need to make a copy to delete while iterating const attributes: Attr[] = []; // We need to make a copy to delete while iterating
for (const attr of this.attributes) { for (const attr of this.attributes) {
@@ -67,8 +58,6 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
} }
} }
this.setAttribute(this.initializedAttribute, "");
// We move compatible attributes to the child element // We move compatible attributes to the child element
// This avoids weird inconsistencies between attributes // This avoids weird inconsistencies between attributes
// when we manipulate the dom in the future // when we manipulate the dom in the future
+1 -1
View File
@@ -271,7 +271,7 @@ body {
/*--------------------------------CONTENT------------------------------*/ /*--------------------------------CONTENT------------------------------*/
#content { #content {
padding: 1.5em 3%; padding: 1em 1%;
box-shadow: $shadow-color 0 5px 10px; box-shadow: $shadow-color 0 5px 10px;
background: $white-color; background: $white-color;
overflow: auto; overflow: auto;
+3 -3
View File
@@ -1,13 +1,13 @@
<div id="quick-notifications" <div id="quick-notifications"
x-data='{ x-data="{
messages: [ messages: [
{%- for message in messages -%} {%- for message in messages -%}
{%- if not message.extra_tags -%} {%- if not message.extra_tags -%}
{ tag: {{ message.tags|string|tojson }}, text: {{ message|string|tojson }} }, { tag: '{{ message.tags }}', text: '{{ message }}' },
{%- endif -%} {%- endif -%}
{%- endfor -%} {%- endfor -%}
] ]
}' }"
@quick-notification-add="(e) => messages.push(e?.detail)" @quick-notification-add="(e) => messages.push(e?.detail)"
@quick-notification-delete="messages = []"> @quick-notification-delete="messages = []">
<template x-for="(message, index) in messages"> <template x-for="(message, index) in messages">
+1 -2
View File
@@ -33,8 +33,7 @@
<a href="{{ url("core:file_detail", file_id=f.id) }}">{{ f.name }}</a><br/> <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 %}Full name: {% endtrans %}{{ f.get_parent_path()+'/'+f.name }}<br/>
{% trans %}Owner: {% endtrans %}{{ f.owner.get_display_name() }}<br/> {% trans %}Owner: {% endtrans %}{{ f.owner.get_display_name() }}<br/>
{% trans %}Date: {% endtrans %} {% trans %}Date: {% endtrans %}{{ f.date|date(DATE_FORMAT) }} {{ f.date|time(TIME_FORMAT) }}<br/>
{{ f.date|date(DATE_FORMAT) }} {{ f.date|time(TIME_FORMAT) }}<br/>
</p> </p>
<p><button <p><button
hx-get="{{ url('core:file_moderate', file_id=f.id) }}" hx-get="{{ url('core:file_moderate', file_id=f.id) }}"
@@ -18,7 +18,7 @@
<span class="helptext">{{ form.is_viewable.help_text }}</span> <span class="helptext">{{ form.is_viewable.help_text }}</span>
{{ form.is_viewable.errors }} {{ form.is_viewable.errors }}
</fieldset> </fieldset>
<fieldset class="form-group" x-show="!isViewable" x-transition x-cloak> <fieldset class="form-group" x-show="!isViewable">
{{ form.whitelisted_users.as_field_group() }} {{ form.whitelisted_users.as_field_group() }}
</fieldset> </fieldset>
<fieldset class="form-group"> <fieldset class="form-group">
+4 -4
View File
@@ -23,10 +23,10 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for m in profile.memberships.ongoing().select_related("role") %} {% for m in profile.memberships.filter(end_date=None).all() %}
<tr> <tr>
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td> <td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
<td>{{ m.role.name }}</td> <td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
<td>{{ m.description }}</td> <td>{{ m.description }}</td>
<td>{{ m.start_date }}</td> <td>{{ m.start_date }}</td>
{% if m.can_be_edited_by(user) %} {% if m.can_be_edited_by(user) %}
@@ -65,10 +65,10 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for m in profile.memberships.exclude(end_date=None).select_related("role") %} {% for m in profile.memberships.exclude(end_date=None).all() %}
<tr> <tr>
<td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td> <td><a href="{{ url('club:club_members', club_id=m.club.id) }}">{{ m.club }}</a></td>
<td>{{ m.role.name }}</td> <td>{{ settings.SITH_CLUB_ROLES[m.role] }}</td>
<td>{{ m.description }}</td> <td>{{ m.description }}</td>
<td>{{ m.start_date }}</td> <td>{{ m.start_date }}</td>
<td>{{ m.end_date }}</td> <td>{{ m.end_date }}</td>
@@ -3,7 +3,7 @@
<script-once type="module" src="{{ js }}"></script-once> <script-once type="module" src="{{ js }}"></script-once>
{% endfor %} {% endfor %}
{% for css in statics.css %} {% for css in statics.css %}
<link-once rel="stylesheet" type="text/css" href="{{ css }}"></link-once> <link-once rel="stylesheet" type="text/css" href="{{ css }}" defer></link-once>
{% endfor %} {% endfor %}
<{{ component }} name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %}> <{{ component }} name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %}>
+1 -4
View File
@@ -11,7 +11,7 @@ from django.utils.timezone import now
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects from pytest_django.asserts import assertHTMLEqual, assertRedirects
from club.models import Club, Membership from club.models import Club
from core.baker_recipes import board_user, subscriber_user from core.baker_recipes import board_user, subscriber_user
from core.markdown import markdown from core.markdown import markdown
from core.models import AnonymousUser, Page, PageRev, User from core.models import AnonymousUser, Page, PageRev, User
@@ -122,9 +122,6 @@ def test_page_revision_club_redirection(client: Client):
@pytest.mark.django_db @pytest.mark.django_db
def test_viewable_by(): def test_viewable_by():
# remove existing pages to prevent side effect # 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() Page.objects.all().delete()
view_groups = [ view_groups = [
[settings.SITH_GROUP_PUBLIC_ID], [settings.SITH_GROUP_PUBLIC_ID],
+1 -2
View File
@@ -21,7 +21,7 @@ from core.baker_recipes import (
subscriber_user, subscriber_user,
very_old_subscriber_user, very_old_subscriber_user,
) )
from core.models import AnonymousUser, Group, SithFile, User from core.models import AnonymousUser, Group, User
from core.views import UserTabsMixin from core.views import UserTabsMixin
from counter.baker_recipes import sale_recipe from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Permanency, Refilling, Selling from counter.models import Counter, Customer, Permanency, Refilling, Selling
@@ -34,7 +34,6 @@ class TestSearchUsers(TestCase):
def setUpTestData(cls): def setUpTestData(cls):
# News.author has on_delete=PROTECT, so news must be deleted beforehand # News.author has on_delete=PROTECT, so news must be deleted beforehand
News.objects.all().delete() News.objects.all().delete()
SithFile.objects.all().delete()
User.objects.all().delete() User.objects.all().delete()
user_recipe = Recipe( user_recipe = Recipe(
User, User,
+17
View File
@@ -25,6 +25,7 @@ from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest from django.http import HttpRequest
from django.utils.timezone import localdate from django.utils.timezone import localdate
from PIL import ExifTags
from PIL.Image import Image, Resampling from PIL.Image import Image, Resampling
RED_PIXEL_PNG: Final[bytes] = ( RED_PIXEL_PNG: Final[bytes] = (
@@ -177,6 +178,22 @@ def resize_image_explicit(
return ContentFile(content.getvalue()) 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: def get_client_ip(request: HttpRequest) -> str | None:
headers = ( headers = (
"X_FORWARDED_FOR", # Common header for proxies "X_FORWARDED_FOR", # Common header for proxies
+2 -2
View File
@@ -431,7 +431,7 @@ class PriceQuerySet(models.QuerySet):
), ),
product__archived=False, product__archived=False,
product__limit_age__lte=age, product__limit_age__lte=age,
).distinct() )
class Price(models.Model): class Price(models.Model):
@@ -600,7 +600,7 @@ class Counter(models.Model):
if user.is_anonymous: if user.is_anonymous:
return False return False
mem = self.club.get_membership_for(user) mem = self.club.get_membership_for(user)
if mem and mem.role.is_presidency: if mem and mem.role >= settings.SITH_CLUB_ROLES_ID["Treasurer"]:
return True return True
return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID) return user.is_in_group(pk=settings.SITH_GROUP_COUNTER_ADMIN_ID)
+6 -18
View File
@@ -31,7 +31,7 @@ from model_bakery import baker
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
from club.models import ClubRole, Membership from club.models import Membership
from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user from core.baker_recipes import board_user, subscriber_user, very_old_subscriber_user
from core.models import BanGroup, Group, User from core.models import BanGroup, Group, User
from counter.baker_recipes import price_recipe, product_recipe, sale_recipe from counter.baker_recipes import price_recipe, product_recipe, sale_recipe
@@ -87,7 +87,7 @@ class TestFullClickBase(TestCase):
Membership, Membership,
start_date=now() - timedelta(days=30), start_date=now() - timedelta(days=30),
club=cls.club_counter.club, club=cls.club_counter.club,
role=baker.make(ClubRole, club=cls.club_counter.club, is_board=True), role=settings.SITH_CLUB_ROLES_ID["Board member"],
user=cls.club_admin, user=cls.club_admin,
) )
@@ -800,13 +800,7 @@ class TestClubCounterClickAccess(TestCase):
"counter:click", "counter:click",
kwargs={"counter_id": cls.counter.id, "user_id": cls.customer.id}, 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() cls.user = subscriber_user.make()
def test_anonymous(self): def test_anonymous(self):
@@ -818,17 +812,13 @@ class TestClubCounterClickAccess(TestCase):
res = self.client.get(self.click_url) res = self.client.get(self.click_url)
assert res.status_code == 403 assert res.status_code == 403
# being a member of the club, without being in the board, isn't enough # being a member of the club, without being in the board, isn't enough
baker.make( baker.make(Membership, club=self.counter.club, user=self.user, role=1)
Membership, club=self.counter.club, user=self.user, role=self.member_role
)
res = self.client.get(self.click_url) res = self.client.get(self.click_url)
assert res.status_code == 403 assert res.status_code == 403
def test_board_member(self): def test_board_member(self):
"""By default, board members should be able to click on office counters""" """By default, board members should be able to click on office counters"""
baker.make( baker.make(Membership, club=self.counter.club, user=self.user, role=3)
Membership, club=self.counter.club, user=self.user, role=self.board_role
)
self.client.force_login(self.user) self.client.force_login(self.user)
res = self.client.get(self.click_url) res = self.client.get(self.click_url)
assert res.status_code == 200 assert res.status_code == 200
@@ -843,9 +833,7 @@ class TestClubCounterClickAccess(TestCase):
def test_both_barman_and_board_member(self): def test_both_barman_and_board_member(self):
"""If the user is barman and board member, he should be authorized as well.""" """If the user is barman and board member, he should be authorized as well."""
self.counter.sellers.add(self.user) self.counter.sellers.add(self.user)
baker.make( baker.make(Membership, club=self.counter.club, user=self.user, role=3)
Membership, club=self.counter.club, user=self.user, role=self.board_role
)
self.client.force_login(self.user) self.client.force_login(self.user)
res = self.client.get(self.click_url) res = self.client.get(self.click_url)
assert res.status_code == 200 assert res.status_code == 200
+3 -3
View File
@@ -3,13 +3,14 @@ import string
from datetime import timedelta from datetime import timedelta
import pytest import pytest
from django.conf import settings
from django.contrib.auth.base_user import make_password from django.contrib.auth.base_user import make_password
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from model_bakery import baker from model_bakery import baker
from club.models import ClubRole, Membership from club.models import Membership
from core.baker_recipes import board_user, subscriber_user from core.baker_recipes import board_user, subscriber_user
from core.models import User from core.models import User
from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe from counter.baker_recipes import product_recipe, refill_recipe, sale_recipe
@@ -41,12 +42,11 @@ class TestStudentCard(TestCase):
cls.counter.sellers.add(cls.barmen) cls.counter.sellers.add(cls.barmen)
cls.club_counter = baker.make(Counter) cls.club_counter = baker.make(Counter)
role = baker.make(ClubRole, club=cls.club_counter.club, is_board=True)
baker.make( baker.make(
Membership, Membership,
start_date=now() - timedelta(days=30), start_date=now() - timedelta(days=30),
club=cls.club_counter.club, club=cls.club_counter.club,
role=role, role=settings.SITH_CLUB_ROLES_ID["Board member"],
user=cls.club_admin, user=cls.club_admin,
) )
+3 -3
View File
@@ -219,6 +219,6 @@ def test_price_for_user():
recipe.make(amount=1, groups=[groups[1]], is_always_shown=False), recipe.make(amount=1, groups=[groups[1]], is_always_shown=False),
] ]
qs = Price.objects.order_by("-amount") qs = Price.objects.order_by("-amount")
assert list(qs.for_user(users[0])) == [prices[0], prices[1], prices[4]] assert set(qs.for_user(users[0])) == {prices[0], prices[1], prices[4]}
assert list(qs.for_user(users[1])) == [prices[0], prices[4]] assert set(qs.for_user(users[1])) == {prices[0], prices[4]}
assert list(qs.for_user(users[2])) == [prices[0], prices[3]] assert set(qs.for_user(users[2])) == {prices[0], prices[3]}
@@ -28,8 +28,11 @@ document.addEventListener("alpine:init", () => {
this.basket = []; this.basket = [];
} }
} }
document
.getElementById("id_form-TOTAL_FORMS") // It's quite tricky to manually apply attributes to the management part
// of a formset so we dynamically apply it here
this.$refs.basketManagementForm
.getElementById("#id_form-TOTAL_FORMS")
.setAttribute(":value", "basket.length"); .setAttribute(":value", "basket.length");
}, },
+163
View File
@@ -0,0 +1,163 @@
#eboutic {
display: flex;
flex-direction: row-reverse;
align-items: flex-start;
column-gap: 20px;
margin: 0 20px 20px;
}
#eboutic-title {
margin-left: 20px;
}
#eboutic h3 {
margin-left: 0;
margin-right: 0;
}
#basket {
min-width: 300px;
border-radius: 8px;
box-shadow:
rgb(60 64 67 / 30%) 0 1px 3px 0,
rgb(60 64 67 / 15%) 0 4px 8px 3px;
padding: 10px;
}
#basket h3 {
margin-top: 0;
}
@media screen and (max-width: 765px) {
#eboutic {
flex-direction: column-reverse;
align-items: center;
margin: 10px;
row-gap: 20px;
}
#eboutic-title {
margin-bottom: 20px;
margin-top: 4px;
}
#basket {
width: -webkit-fill-available;
}
}
#eboutic .item-list {
margin-left: 0;
list-style: none;
}
#eboutic .item-list li {
display: flex;
align-items: center;
margin-bottom: 10px;
}
#eboutic .item-row {
gap: 10px;
}
#eboutic .item-name {
word-break: break-word;
width: 100%;
line-height: 100%;
white-space: normal;
}
#eboutic .fa-plus,
#eboutic .fa-minus {
cursor: pointer;
background-color: #354a5f;
color: white;
border-radius: 50%;
padding: 5px;
font-size: 10px;
line-height: 10px;
width: 10px;
text-align: center;
}
#eboutic .item-quantity {
min-width: 65px;
justify-content: space-between;
align-items: center;
display: flex;
gap: 5px;
}
#eboutic .item-price {
min-width: 65px;
text-align: right;
}
/* CSS du catalogue */
#eboutic #catalog {
display: flex;
flex-grow: 1;
flex-direction: column;
row-gap: 30px;
}
#eboutic .category-header {
margin-bottom: 15px;
}
#eboutic .product-group {
display: flex;
flex-wrap: wrap;
column-gap: 15px;
row-gap: 15px;
}
#eboutic .card.selected::after {
content: "🛒";
position: absolute;
top: 5px;
right: 5px;
padding: 5px;
border-radius: 50%;
box-shadow: 0 0 12px 2px rgb(0 0 0 / 14%);
background-color: white;
width: 20px;
height: 20px;
font-size: 16px;
line-height: 20px;
}
#eboutic .catalog-buttons {
display: flex;
justify-content: center;
column-gap: 30px;
margin: 30px 0 0;
}
#eboutic input {
all: unset;
}
#eboutic .catalog-buttons button {
min-width: 60px;
}
#eboutic .catalog-buttons form {
margin: 0;
}
@media screen and (max-width: 765px) {
#eboutic #catalog {
row-gap: 15px;
width: 100%;
}
#eboutic section {
text-align: center;
}
#eboutic .product-group {
justify-content: space-around;
flex-direction: column;
}
}
-162
View File
@@ -1,162 +0,0 @@
#eboutic-title {
margin-left: 20px;
}
#eboutic {
display: flex;
flex-direction: row-reverse;
align-items: flex-start;
column-gap: 20px;
margin: 0 20px 20px;
h3 {
margin-left: 0;
margin-right: 0;
}
#basket {
--box-shadow:
rgb(60 64 67 / 30%) 0 1px 3px 0,
rgb(60 64 67 / 15%) 0 4px 8px 3px;
min-width: 300px;
border-radius: 8px;
box-shadow: var(--box-shadow);
padding: 10px;
h3 {
margin-top: 0;
}
}
@media screen and (max-width: 765px) {
flex-direction: column-reverse;
align-items: center;
margin: 10px;
row-gap: 20px;
#eboutic-title {
margin-bottom: 20px;
margin-top: 4px;
}
#basket {
width: -webkit-fill-available;
}
}
.item-list {
margin-left: 0;
list-style: none;
li {
display: flex;
align-items: center;
margin-bottom: 10px;
}
}
.item-row {
gap: 10px;
}
.item-name {
word-break: break-word;
width: 100%;
line-height: 100%;
white-space: normal;
}
.fa-plus,
.fa-minus {
cursor: pointer;
background-color: #354a5f;
color: white;
border-radius: 50%;
padding: 5px;
font-size: 10px;
line-height: 10px;
width: 10px;
text-align: center;
}
.item-quantity {
min-width: 65px;
justify-content: space-between;
align-items: center;
display: flex;
gap: 5px;
}
.item-price {
min-width: 65px;
text-align: right;
}
/* CSS du catalogue */
#catalog {
display: flex;
flex-grow: 1;
flex-direction: column;
row-gap: 30px;
}
.category-header {
margin-bottom: 15px;
}
.product-group {
display: flex;
flex-wrap: wrap;
column-gap: 15px;
row-gap: 15px;
}
.card.selected::after {
--box-shadow: 0 0 12px 2px rgb(0 0 0 / 14%);
content: "🛒";
position: absolute;
top: 5px;
right: 5px;
padding: 5px;
border-radius: 50%;
box-shadow: var(--box-shadow);
background-color: white;
width: 20px;
height: 20px;
font-size: 16px;
line-height: 20px;
}
input {
all: unset;
}
.catalog-buttons {
display: flex;
justify-content: center;
column-gap: 30px;
margin: 30px 0 0;
button {
min-width: 60px;
}
form {
margin: 0;
}
}
@media screen and (max-width: 765px) {
#catalog {
row-gap: 15px;
width: 100%;
}
section {
text-align: center;
}
.product-group {
justify-content: space-around;
flex-direction: column;
}
}
}
+1 -9
View File
@@ -8,14 +8,6 @@
{% trans %}The online shop of the association.{% endtrans %} {% trans %}The online shop of the association.{% endtrans %}
{%- endblock %} {%- endblock %}
{% block metatags %}
<meta property="og:url" content="{{ request.build_absolute_uri() }}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Eboutic" />
<meta property="og:description" content="La boutique en ligne de l'AE" />
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% endblock %}
{% block additional_js %} {% block additional_js %}
{# This script contains the code to perform requests to manipulate the {# This script contains the code to perform requests to manipulate the
user basket without having to reload the page #} user basket without having to reload the page #}
@@ -23,7 +15,7 @@
{% endblock %} {% endblock %}
{% block additional_css %} {% block additional_css %}
<link rel="stylesheet" href="{{ static("eboutic/css/eboutic.scss") }}"> <link rel="stylesheet" href="{{ static("eboutic/css/eboutic.css") }}">
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}"> <link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
{% endblock %} {% endblock %}
+1 -1
View File
@@ -183,7 +183,7 @@ class Forum(models.Model):
Forum._club_memberships[self.id] = {} Forum._club_memberships[self.id] = {}
Forum._club_memberships[self.id][user.id] = m Forum._club_memberships[self.id][user.id] = m
if m: if m:
return m.role.is_board return m.role > settings.SITH_MAXIMUM_FREE_ROLE
return False return False
def check_loop(self): def check_loop(self):
@@ -29,9 +29,8 @@ from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils import timezone from django.utils import timezone
from model_bakery import baker
from club.models import Club, ClubRole, Membership from club.models import Club, Membership
from core.models import Group, Page, SithFile, User from core.models import Group, Page, SithFile, User
from core.utils import RED_PIXEL_PNG from core.utils import RED_PIXEL_PNG
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
@@ -218,19 +217,11 @@ class Command(BaseCommand):
"The `make_clubs()` method must be called before `make_club_memberships()`" "The `make_clubs()` method must be called before `make_club_memberships()`"
) )
memberships = [] memberships = []
roles = {
r.club_id: r.id
for r in baker.make(
ClubRole,
club=iter(self.clubs),
_quantity=len(self.clubs),
_bulk_create=True,
)
}
for i in range(1, 11): # users can be in up to 20 clubs for i in range(1, 11): # users can be in up to 20 clubs
self.logger.info(f"Club membership, pass {i}") self.logger.info(f"Club membership, pass {i}")
for uid in range(i, self.NB_USERS, i): for uid in range(
# Pass #1 will make sure every user is at least in one club i, self.NB_USERS, i
): # Pass #1 will make sure every user is at least in one club
user = self.users[uid] user = self.users[uid]
club = self.clubs[(uid + i**2) % self.NB_CLUBS] club = self.clubs[(uid + i**2) % self.NB_CLUBS]
@@ -245,7 +236,7 @@ class Command(BaseCommand):
Membership( Membership(
user=user, user=user,
club=club, club=club,
role_id=roles[club.id], role=(uid + i) % 10 + 1, # spread the different roles
start_date=start, start_date=start,
end_date=end, end_date=end,
) )
@@ -268,7 +259,7 @@ class Command(BaseCommand):
Membership( Membership(
user=user, user=user,
club=club, club=club,
role_id=roles[club.id], role=((uid // 10) + i) % 10 + 1, # spread the different roles
start_date=start, start_date=start,
end_date=end, end_date=end,
) )
+146 -137
View File
@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-12 09:48+0200\n" "POT-Creation-Date: 2026-03-23 22:21+0100\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -181,22 +181,6 @@ msgstr "Vous devez être cotisant pour faire partie d'un club"
msgid "You are already a member of this club" msgid "You are already a member of this club"
msgstr "Vous êtes déjà membre de ce club." msgstr "Vous êtes déjà membre de ce club."
#: club/forms.py
msgid "Club status"
msgstr "État du club"
#: club/forms.py
msgid "Active"
msgstr "Actif"
#: club/forms.py
msgid "Inactive"
msgstr "Inactif"
#: club/forms.py
msgid "All clubs"
msgstr "Tous les clubs"
#: club/models.py #: club/models.py
msgid "slug name" msgid "slug name"
msgstr "nom slug" msgstr "nom slug"
@@ -233,67 +217,15 @@ msgstr "home"
msgid "You can not make loops in clubs" msgid "You can not make loops in clubs"
msgstr "Vous ne pouvez pas faire de boucles dans les clubs" msgstr "Vous ne pouvez pas faire de boucles dans les clubs"
#: club/models.py com/models.py counter/models.py trombi/models.py
msgid "club"
msgstr "club"
#: club/models.py
msgid "The club with which this role is associated"
msgstr "Le club auquel ce rôle est attaché."
#: club/models.py core/models.py counter/models.py election/models.py
#: forum/models.py
msgid "description"
msgstr "description"
#: club/models.py
msgid "Board role"
msgstr "Rôle du bureau"
#: club/models.py
msgid "Presidency role"
msgstr "Rôle de la présidence"
#: club/models.py
msgid ""
"If the role is inactive, people joining the club won't be able to get it."
msgstr ""
"Si ce rôle est inactif, il ne pourra pas être attribué aux gens qui "
"rejoignent le club."
#: club/models.py
msgid "club role"
msgstr "rôle de club"
#: club/models.py
msgid "club roles"
msgstr "rôles de club"
#: club/models.py
#, python-format
msgid ""
"Role %(name)s was declared as a presidency role without being a board role"
msgstr ""
"Le rôle %(name)s a été déclaré comme rôle de présidence sans être un rôle du "
"bureau."
#: club/models.py
#, python-format
msgid "Role %(role)s cannot be placed below a member role"
msgstr ""
"Le rôle %(role)s ne peut pas être placé en-dessous d'un rôle de membre."
#: club/models.py
#, python-format
msgid "Role %(role)s cannot be placed below a non-presidency role"
msgstr ""
"Le rôle %(role)s ne peut pas être placé en-dessous d'un rôle de membre."
#: club/models.py core/models.py counter/models.py eboutic/models.py #: club/models.py core/models.py counter/models.py eboutic/models.py
#: election/models.py pedagogy/models.py sas/models.py trombi/models.py #: election/models.py pedagogy/models.py sas/models.py trombi/models.py
msgid "user" msgid "user"
msgstr "utilisateur" msgstr "utilisateur"
#: club/models.py com/models.py counter/models.py trombi/models.py
msgid "club"
msgstr "club"
#: club/models.py counter/models.py election/models.py #: club/models.py counter/models.py election/models.py
msgid "start date" msgid "start date"
msgstr "date de début" msgstr "date de début"
@@ -306,6 +238,11 @@ msgstr "date de fin"
msgid "role" msgid "role"
msgstr "rôle" msgstr "rôle"
#: club/models.py core/models.py counter/models.py election/models.py
#: forum/models.py
msgid "description"
msgstr "description"
#: club/models.py #: club/models.py
msgid "past member" msgid "past member"
msgstr "ancien membre" msgstr "ancien membre"
@@ -364,22 +301,37 @@ msgstr "Cet email est déjà abonné à cette mailing"
msgid "Unregistered user" msgid "Unregistered user"
msgstr "Utilisateur non enregistré" msgstr "Utilisateur non enregistré"
#: club/templates/club/club_list.jinja
msgid "The list of all clubs existing at UTBM."
msgstr "La liste de tous les clubs existants à l'UTBM"
#: club/templates/club/club_list.jinja #: club/templates/club/club_list.jinja
msgid "Club list" msgid "Club list"
msgstr "Liste des clubs" msgstr "Liste des clubs"
#: club/templates/club/club_list.jinja
msgid "The list of all clubs existing at UTBM."
msgstr "La liste de tous les clubs existants à l'UTBM"
#: club/templates/club/club_list.jinja #: club/templates/club/club_list.jinja
msgid "Filters" msgid "Filters"
msgstr "Filtres" msgstr "Filtres"
#: club/templates/club/club_list.jinja core/templates/core/base/header.jinja #: club/templates/club/club_list.jinja
#: forum/templates/forum/macros.jinja matmat/templates/matmat/search_form.jinja msgid "Name"
msgid "Search" msgstr "Nom"
msgstr "Recherche"
#: club/templates/club/club_list.jinja
msgid "Club state"
msgstr "Etat du club"
#: club/templates/club/club_list.jinja
msgid "Active"
msgstr "Actif"
#: club/templates/club/club_list.jinja
msgid "Inactive"
msgstr "Inactif"
#: club/templates/club/club_list.jinja
msgid "All clubs"
msgstr "Tous les clubs"
#: club/templates/club/club_list.jinja core/templates/core/user_tools.jinja #: club/templates/club/club_list.jinja core/templates/core/user_tools.jinja
msgid "New club" msgid "New club"
@@ -481,7 +433,7 @@ msgstr "Bénéfice : "
#: counter/templates/counter/cash_summary_list.jinja #: counter/templates/counter/cash_summary_list.jinja
#: counter/templates/counter/last_ops.jinja #: counter/templates/counter/last_ops.jinja
#: counter/templates/counter/refilling_list.jinja #: counter/templates/counter/refilling_list.jinja
#: rootplace/templates/rootplace/logs.jinja #: rootplace/templates/rootplace/logs.jinja sas/forms.py
#: trombi/templates/trombi/user_profile.jinja #: trombi/templates/trombi/user_profile.jinja
msgid "Date" msgid "Date"
msgstr "Date" msgstr "Date"
@@ -629,8 +581,7 @@ msgstr ""
#: counter/templates/counter/invoices_call.jinja #: counter/templates/counter/invoices_call.jinja
#: counter/templates/counter/product_form.jinja #: counter/templates/counter/product_form.jinja
#: forum/templates/forum/reply.jinja #: forum/templates/forum/reply.jinja
#: subscription/templates/subscription/fragments/creation_form_existing_user.jinja #: subscription/templates/subscription/fragments/creation_form.jinja
#: subscription/templates/subscription/fragments/creation_form_new_user.jinja
#: trombi/templates/trombi/comment.jinja #: trombi/templates/trombi/comment.jinja
#: trombi/templates/trombi/edit_profile.jinja #: trombi/templates/trombi/edit_profile.jinja
#: trombi/templates/trombi/user_tools.jinja #: trombi/templates/trombi/user_tools.jinja
@@ -1741,10 +1692,6 @@ msgstr "taille"
msgid "date" msgid "date"
msgstr "date" msgstr "date"
#: core/models.py counter/models.py
msgid "updated at"
msgstr "mis à jour le"
#: core/models.py #: core/models.py
msgid "asked for removal" msgid "asked for removal"
msgstr "retrait demandé" msgstr "retrait demandé"
@@ -1916,6 +1863,11 @@ msgstr "Connexion"
msgid "Register" msgid "Register"
msgstr "Inscription" msgstr "Inscription"
#: core/templates/core/base/header.jinja forum/templates/forum/macros.jinja
#: matmat/templates/matmat/search_form.jinja
msgid "Search"
msgstr "Recherche"
#: core/templates/core/base/header.jinja #: core/templates/core/base/header.jinja
msgid "Logout" msgid "Logout"
msgstr "Déconnexion" msgstr "Déconnexion"
@@ -3243,6 +3195,10 @@ msgstr "groupe d'achat"
msgid "archived" msgid "archived"
msgstr "archivé" msgstr "archivé"
#: counter/models.py
msgid "updated at"
msgstr "mis à jour le"
#: counter/models.py eboutic/models.py #: counter/models.py eboutic/models.py
msgid "product" msgid "product"
msgstr "produit" msgstr "produit"
@@ -3872,14 +3828,14 @@ msgstr ""
"votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura " "votre cotisation. Si vous ne renouvelez pas votre cotisation, il n'y aura "
"aucune conséquence autre que le retrait de l'argent de votre compte." "aucune conséquence autre que le retrait de l'argent de votre compte."
#: counter/templates/counter/product_form.jinja
msgid "Remove this action"
msgstr "Retirer cette action"
#: counter/templates/counter/product_form.jinja #: counter/templates/counter/product_form.jinja
msgid "Remove price" msgid "Remove price"
msgstr "Retirer le prix" msgstr "Retirer le prix"
#: counter/templates/counter/product_form.jinja
msgid "Remove this action"
msgstr "Retirer cette action"
#: counter/templates/counter/product_form.jinja #: counter/templates/counter/product_form.jinja
#, python-format #, python-format
msgid "Edit product %(name)s" msgid "Edit product %(name)s"
@@ -4249,47 +4205,6 @@ msgstr ""
msgid "this page" msgid "this page"
msgstr "cette page" msgstr "cette page"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Eurockéennes 2025 partnership"
msgstr "Partenariat Eurockéennes 2025"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
"according to its own privacy policy. By clicking the accept button you "
"consent to their terms of services."
msgstr ""
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
"collecter des informations utilisateur conformément à sa propre politique de "
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
"leurs termes de service."
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Privacy policy"
msgstr "Politique de confidentialité"
#: eboutic/templates/eboutic/eboutic_main.jinja
#: trombi/templates/trombi/comment_moderation.jinja
msgid "Accept"
msgstr "Accepter"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"You must be subscribed to benefit from the partnership with the Eurockéennes."
msgstr ""
"Vous devez être cotisant pour bénéficier du partenariat avec les "
"Eurockéennes."
#: eboutic/templates/eboutic/eboutic_main.jinja
#, python-format
msgid ""
"This partnership offers a discount of up to 33%% on tickets for Friday, "
"Saturday and Sunday, as well as the 3-day package from Friday to Sunday."
msgstr ""
"Ce partenariat permet de profiter d'une réduction jusqu'à 33%% sur les "
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
"du vendredi au dimanche."
#: eboutic/templates/eboutic/eboutic_main.jinja #: eboutic/templates/eboutic/eboutic_main.jinja
msgid "There are no items available for sale" msgid "There are no items available for sale"
msgstr "Aucun article n'est disponible à la vente" msgstr "Aucun article n'est disponible à la vente"
@@ -5389,9 +5304,7 @@ msgid "Amicale/DOCEO member"
msgstr "Membre de l'Amicale/DOCEO" msgstr "Membre de l'Amicale/DOCEO"
#: sith/settings.py #: sith/settings.py
#, fuzzy msgid "UT network member"
#| msgid "UT network member"
msgid "UT network member (excluding UTC)"
msgstr "Cotisant du réseau UT" msgstr "Cotisant du réseau UT"
#: sith/settings.py #: sith/settings.py
@@ -5402,10 +5315,26 @@ msgstr "Membres du CROUS"
msgid "Sbarro/ESTA member" msgid "Sbarro/ESTA member"
msgstr "Membre de Sbarro ou de l'ESTA" msgstr "Membre de Sbarro ou de l'ESTA"
#: sith/settings.py
msgid "One semester Welcome Week"
msgstr "Un semestre Welcome Week"
#: sith/settings.py
msgid "Eurok's volunteer"
msgstr "Bénévole Eurockéennes"
#: sith/settings.py
msgid "Six weeks for free"
msgstr "6 semaines gratuites"
#: sith/settings.py #: sith/settings.py
msgid "One day" msgid "One day"
msgstr "Un jour" msgstr "Un jour"
#: sith/settings.py
msgid "GA staff member"
msgstr "Membre staff GA"
#: sith/settings.py #: sith/settings.py
msgid "One semester (-20%)" msgid "One semester (-20%)"
msgstr "Un semestre (-20%)" msgstr "Un semestre (-20%)"
@@ -5426,6 +5355,46 @@ msgstr "Cursus branche (-20%)"
msgid "Alternating cursus (-20%)" msgid "Alternating cursus (-20%)"
msgstr "Cursus alternant (-20%)" msgstr "Cursus alternant (-20%)"
#: sith/settings.py
msgid "One year for free(CA offer)"
msgstr "Une année offerte (Offre CA)"
#: sith/settings.py
msgid "President"
msgstr "Président⸱e"
#: sith/settings.py
msgid "Vice-President"
msgstr "Vice-Président⸱e"
#: sith/settings.py
msgid "Treasurer"
msgstr "Trésorier⸱e"
#: sith/settings.py
msgid "Communication supervisor"
msgstr "Responsable communication"
#: sith/settings.py
msgid "Secretary"
msgstr "Secrétaire"
#: sith/settings.py
msgid "IT supervisor"
msgstr "Responsable info"
#: sith/settings.py
msgid "Board member"
msgstr "Membre du bureau"
#: sith/settings.py
msgid "Active member"
msgstr "Membre actif⸱ve"
#: sith/settings.py
msgid "Curious"
msgstr "Curieux⸱euse"
#: sith/settings.py #: sith/settings.py
msgid "A new poster needs to be moderated" msgid "A new poster needs to be moderated"
msgstr "Une nouvelle affiche a besoin d'être modérée" msgstr "Une nouvelle affiche a besoin d'être modérée"
@@ -5512,7 +5481,7 @@ msgstr "lieu"
msgid "You can not subscribe many time for the same period" msgid "You can not subscribe many time for the same period"
msgstr "Vous ne pouvez pas cotiser plusieurs fois pour la même période" msgstr "Vous ne pouvez pas cotiser plusieurs fois pour la même période"
#: subscription/templates/subscription/fragments/creation_form_existing_user.jinja #: subscription/templates/subscription/forms/create_existing_user.jinja
msgid "" msgid ""
"If the subscription is done using the AE account, you must also click it on " "If the subscription is done using the AE account, you must also click it on "
"the AE counter." "the AE counter."
@@ -5662,6 +5631,10 @@ msgstr "fin"
msgid "Moderate Trombi comments" msgid "Moderate Trombi comments"
msgstr "Modérer les commentaires du Trombi" msgstr "Modérer les commentaires du Trombi"
#: trombi/templates/trombi/comment_moderation.jinja
msgid "Accept"
msgstr "Accepter"
#: trombi/templates/trombi/comment_moderation.jinja #: trombi/templates/trombi/comment_moderation.jinja
msgid "Reject" msgid "Reject"
msgstr "Refuser" msgstr "Refuser"
@@ -5903,3 +5876,39 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
#, python-format #, python-format
msgid "Maximum characters: %(max_length)s" msgid "Maximum characters: %(max_length)s"
msgstr "Nombre de caractères max: %(max_length)s" msgstr "Nombre de caractères max: %(max_length)s"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Eurockéennes 2025 partnership"
msgstr "Partenariat Eurockéennes 2025"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
"according to its own privacy policy. By clicking the accept button you "
"consent to their terms of services."
msgstr ""
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
"collecter des informations utilisateur conformément à sa propre politique de "
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
"leurs termes de service."
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Privacy policy"
msgstr "Politique de confidentialité"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"You must be subscribed to benefit from the partnership with the Eurockéennes."
msgstr ""
"Vous devez être cotisant pour bénéficier du partenariat avec les "
"Eurockéennes."
#: eboutic/templates/eboutic/eboutic_main.jinja
#, python-format
msgid ""
"This partnership offers a discount of up to 33%% on tickets for Friday, "
"Saturday and Sunday, as well as the 3-day package from Friday to Sunday."
msgstr ""
"Ce partenariat permet de profiter d'une réduction jusqu'à 33%% sur les "
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
"du vendredi au dimanche."
+1 -2
View File
@@ -7,14 +7,13 @@ from model_bakery import baker
from com.models import News from com.models import News
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import SithFile, User from core.models import User
class TestMatmatronch(TestCase): class TestMatmatronch(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
News.objects.all().delete() News.objects.all().delete()
SithFile.objects.all().delete()
User.objects.all().delete() User.objects.all().delete()
users = [ users = [
baker.prepare(User, promo=17), baker.prepare(User, promo=17),
+64
View File
@@ -43,6 +43,7 @@
"@babel/preset-env": "^7.29.2", "@babel/preset-env": "^7.29.2",
"@biomejs/biome": "^2.4.13", "@biomejs/biome": "^2.4.13",
"@hey-api/openapi-ts": "^0.94.5", "@hey-api/openapi-ts": "^0.94.5",
"@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.11", "@types/alpinejs": "^3.13.11",
"@types/cytoscape-cxtmenu": "^3.4.5", "@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-klay": "^3.1.5", "@types/cytoscape-klay": "^3.1.5",
@@ -2382,6 +2383,52 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rollup/plugin-inject": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/@rollup/plugin-inject/-/plugin-inject-5.0.5.tgz",
"integrity": "sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.3"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
"integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@sentry-internal/browser-utils": { "node_modules/@sentry-internal/browser-utils": {
"version": "10.51.0", "version": "10.51.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.51.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.51.0.tgz",
@@ -3331,6 +3378,13 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/esutils": { "node_modules/esutils": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -4021,6 +4075,16 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": { "node_modules/marked": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz",
+1
View File
@@ -28,6 +28,7 @@
"@babel/preset-env": "^7.29.2", "@babel/preset-env": "^7.29.2",
"@biomejs/biome": "^2.4.13", "@biomejs/biome": "^2.4.13",
"@hey-api/openapi-ts": "^0.94.5", "@hey-api/openapi-ts": "^0.94.5",
"@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.11", "@types/alpinejs": "^3.13.11",
"@types/cytoscape-cxtmenu": "^3.4.5", "@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-klay": "^3.1.5", "@types/cytoscape-klay": "^3.1.5",
+1 -1
View File
@@ -23,7 +23,7 @@ dependencies = [
"django-ninja>=1.6.2,<6.0.0", "django-ninja>=1.6.2,<6.0.0",
"django-ninja-extra>=0.31.4", "django-ninja-extra>=0.31.4",
"Pillow>=12.2.0,<13.0.0", "Pillow>=12.2.0,<13.0.0",
"mistune>=3.2.0,<4.0.0", "mistune>=3.2.1,<4.0.0",
"django-jinja<3.0.0,>=2.11.0", "django-jinja<3.0.0,>=2.11.0",
"cryptography>=47.0.0,<48.0.0", "cryptography>=47.0.0,<48.0.0",
"django-phonenumber-field>=8.4.0,<9.0.0", "django-phonenumber-field>=8.4.0,<9.0.0",
+2 -14
View File
@@ -126,8 +126,9 @@ class PicturesController(ControllerBase):
if self_moderate: if self_moderate:
new.moderator = user new.moderator = user
try: try:
new.generate_thumbnails()
new.full_clean() new.full_clean()
new.generate_thumbnails(save=True) new.save()
except ValidationError as e: except ValidationError as e:
return self.create_response({"detail": dict(e)}, status_code=409) return self.create_response({"detail": dict(e)}, status_code=409)
@@ -176,19 +177,6 @@ class PicturesController(ControllerBase):
def delete_picture(self, picture_id: int): def delete_picture(self, picture_id: int):
self.get_object_or_exception(Picture, pk=picture_id).delete() self.get_object_or_exception(Picture, pk=picture_id).delete()
@route.post(
"/{picture_id}/rotate/{direction}",
permissions=[IsSasAdmin],
response=PictureSchema,
url_name="rotate_picture",
)
def rotate_picture(self, picture_id: int, direction: Literal["left", "right"]):
"""Rotate the given picture and returns its edited data."""
angle = 90 if direction == "left" else 270
picture = self.get_object_or_exception(Picture, pk=picture_id)
picture.rotate(angle)
return picture
@route.patch( @route.patch(
"/{picture_id}/moderation", "/{picture_id}/moderation",
permissions=[IsSasAdmin], permissions=[IsSasAdmin],
+2 -13
View File
@@ -1,24 +1,13 @@
from django.conf import settings
from model_bakery import seq from model_bakery import seq
from model_bakery.recipe import Recipe, foreign_key from model_bakery.recipe import Recipe
from sas.models import Album, Picture from sas.models import Picture
album_recipe = Recipe(
Album,
is_in_sas=True,
is_folder=True,
is_moderated=True,
parent_id=settings.SITH_SAS_ROOT_DIR_ID,
name=seq("Album "),
)
picture_recipe = Recipe( picture_recipe = Recipe(
Picture, Picture,
is_in_sas=True, is_in_sas=True,
is_folder=False, is_folder=False,
is_moderated=True, is_moderated=True,
parent=foreign_key(album_recipe),
name=seq("Picture "), name=seq("Picture "),
) )
"""A SAS Picture fixture. """A SAS Picture fixture.
+68 -57
View File
@@ -15,6 +15,8 @@
from __future__ import annotations from __future__ import annotations
import contextlib
from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import ClassVar, Self from typing import ClassVar, Self
@@ -28,7 +30,7 @@ from django.utils.translation import gettext_lazy as _
from PIL import Image from PIL import Image
from core.models import Notification, SithFile, User from core.models import Notification, SithFile, User
from core.utils import resize_image from core.utils import exif_auto_rotate, resize_image
class SasFile(SithFile): class SasFile(SithFile):
@@ -90,75 +92,88 @@ class Picture(SasFile):
objects = SASPictureManager.from_queryset(PictureQuerySet)() objects = SASPictureManager.from_queryset(PictureQuerySet)()
@property
def is_vertical(self):
with open(settings.MEDIA_ROOT / self.file.name, "rb") as f:
im = Image.open(BytesIO(f.read()))
(w, h) = im.size
return (w / h) < 1
def get_download_url(self): def get_download_url(self):
return reverse( return reverse("sas:download", kwargs={"picture_id": self.id})
"sas:download",
kwargs={"picture_id": self.id},
query={"date": int(self.updated_at.timestamp())},
)
def get_download_compressed_url(self): def get_download_compressed_url(self):
return reverse( return reverse("sas:download_compressed", kwargs={"picture_id": self.id})
"sas:download_compressed",
kwargs={"picture_id": self.id},
query={"date": int(self.updated_at.timestamp())},
)
def get_download_thumb_url(self): def get_download_thumb_url(self):
return reverse( return reverse("sas:download_thumb", kwargs={"picture_id": self.id})
"sas:download_thumb",
kwargs={"picture_id": self.id},
query={"date": int(self.updated_at.timestamp())},
)
def get_absolute_url(self): def get_absolute_url(self):
return reverse("sas:picture", kwargs={"picture_id": self.id}) return reverse("sas:picture", kwargs={"picture_id": self.id})
def generate_thumbnails( def generate_thumbnails(self):
self, *, img: Image.Image | None = None, save: bool = False im = Image.open(BytesIO(self.file.read()))
): with contextlib.suppress(Exception):
"""Generate the thumbnail and the compressed version of this picture. im = exif_auto_rotate(im)
Args:
img: if given, this will be used to generate
all three images (file, compressed, thumbnail).
Else, `self.file` will be used
save: if True, save the instance in database.
"""
img = img or Image.open(self.file)
extension = self.mime_type.split("/")[-1]
previous_files = [
f.name for f in (self.file, self.thumbnail, self.compressed) if f
]
# convert the compressed image and the thumbnail into webp # convert the compressed image and the thumbnail into webp
# The original image keeps its original type, because it's not # The original image keeps its original type, because it's not
# meant to be shown on the website, but rather to keep the real image # meant to be shown on the website, but rather to keep the real image
# for less frequent cases (like downloading the pictures of a user) # for less frequent cases (like downloading the pictures of an user)
extension = self.mime_type.split("/")[-1]
# the HD version of the image doesn't need to be optimized, because : # the HD version of the image doesn't need to be optimized, because :
# - it isn't frequently queried # - it isn't frequently queried
# - optimizing large images takes a lot of time, which greatly hinders the UX # - optimizing large images takes a lot time, which greatly hinders the UX
# - photographers usually already optimize their images # - photographers usually already optimize their images
file = resize_image(im, max(im.size), extension, optimize=False)
thumb = resize_image(im, 200, "webp")
compressed = resize_image(im, 1200, "webp")
new_extension_name = str(Path(self.name).with_suffix(".webp")) new_extension_name = str(Path(self.name).with_suffix(".webp"))
file = resize_image(img, max(img.size), extension, optimize=False) self.file = file
self.file.save(self.name, file, save=False) self.file.name = self.name
thumbnail = resize_image(img, 200, "webp") self.thumbnail = thumb
self.thumbnail.save(new_extension_name, thumbnail, save=False) self.thumbnail.name = new_extension_name
compressed = resize_image(img, 1200, "webp") self.compressed = compressed
self.compressed.save(new_extension_name, compressed, save=save) self.compressed.name = new_extension_name
# once the new images have been saved, delete the previous ones.
# The deletion of old files is done after, so that if anything goes
# during the whole process, no data will be lost.
for filename in previous_files:
self.file.storage.delete(filename)
def rotate(self, degree: int | float): def rotate(self, degree):
"""Rotate this picture and update its thumbnails accordingly. for attr in ["file", "compressed", "thumbnail"]:
name = self.__getattribute__(attr).name
with open(settings.MEDIA_ROOT / name, "r+b") as file:
if file:
im = Image.open(BytesIO(file.read()))
file.seek(0)
im = im.rotate(degree, expand=True)
im.save(
fp=file,
format=self.mime_type.split("/")[-1].upper(),
quality=90,
optimize=True,
progressive=True,
)
Args: def get_next(self):
degree: the rotation angle, in degree, counter-clockwise if self.is_moderated:
""" pictures_qs = self.parent.children.filter(
img = Image.open(self.file).rotate(degree) is_moderated=True,
self.generate_thumbnails(img=img, save=True) asked_for_removal=False,
is_folder=False,
id__gt=self.id,
)
else:
pictures_qs = Picture.objects.filter(id__gt=self.id, is_moderated=False)
return pictures_qs.order_by("id").first()
def get_previous(self):
if self.is_moderated:
pictures_qs = self.parent.children.filter(
is_moderated=True,
asked_for_removal=False,
is_folder=False,
id__lt=self.id,
)
else:
pictures_qs = Picture.objects.filter(id__lt=self.id, is_moderated=False)
return pictures_qs.order_by("-id").first()
class AlbumQuerySet(models.QuerySet): class AlbumQuerySet(models.QuerySet):
@@ -224,11 +239,7 @@ class Album(SasFile):
return reverse("sas:album", kwargs={"album_id": self.id}) return reverse("sas:album", kwargs={"album_id": self.id})
def get_download_url(self): def get_download_url(self):
return reverse( return reverse("sas:album_preview", kwargs={"album_id": self.id})
"sas:album_preview",
kwargs={"album_id": self.id},
query={"date": int(self.updated_at.timestamp())},
)
def generate_thumbnail(self): def generate_thumbnail(self):
p = self.children_pictures.order_by("?").first() p = self.children_pictures.order_by("?").first()
+1 -9
View File
@@ -70,15 +70,7 @@ class PictureFilterSchema(FilterSchema):
class PictureSchema(ModelSchema): class PictureSchema(ModelSchema):
class Meta: class Meta:
model = Picture model = Picture
fields = [ fields = ["id", "name", "date", "size", "is_moderated", "asked_for_removal"]
"id",
"name",
"date",
"updated_at",
"size",
"is_moderated",
"asked_for_removal",
]
owner: UserProfileSchema owner: UserProfileSchema
sas_url: str sas_url: str
+3 -18
View File
@@ -1,4 +1,4 @@
import { paginated } from "#core:utils/api"; import { paginated } from "#core:utils/api.ts";
import { import {
type PictureSchema, type PictureSchema,
type PicturesFetchPicturesData, type PicturesFetchPicturesData,
@@ -35,23 +35,8 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: from python api // biome-ignore lint/style/useNamingConvention: from python api
query: { users_identified: [config.userId] }, query: { users_identified: [config.userId] },
} as PicturesFetchPicturesData); } as PicturesFetchPicturesData);
try { localStorage.setItem(localStorageInvalidationKey, config.nbPictures.toString());
localStorage.setItem(localStorageInvalidationKey, config.nbPictures.toString()); localStorage.setItem(localStorageKey, JSON.stringify(pictures));
localStorage.setItem(localStorageKey, JSON.stringify(pictures));
} catch {
// an exception is raised if the localstorage is entirely filled
// so just delete all cached user pictures.
// A cache hit is not worth the page breaking.
Object.keys(localStorage)
.filter(
(key) =>
key.startsWith("user") &&
(key.endsWith("Pictures") || key.endsWith("PicturesNumber")),
)
.forEach((key) => {
localStorage.removeItem(key);
});
}
return pictures; return pictures;
}, },
+11 -38
View File
@@ -1,7 +1,7 @@
import type TomSelect from "tom-select"; import type TomSelect from "tom-select";
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index"; import type { UserAjaxSelect } from "#core:core/components/ajax-select-index.ts";
import { paginated } from "#core:utils/api"; import { paginated } from "#core:utils/api.ts";
import { History } from "#core:utils/history"; import { History } from "#core:utils/history.ts";
import { import {
type IdentifiedUserSchema, type IdentifiedUserSchema,
type ModerationRequestSchema, type ModerationRequestSchema,
@@ -14,7 +14,6 @@ import {
picturesFetchPictures, picturesFetchPictures,
picturesIdentifyUsers, picturesIdentifyUsers,
picturesModeratePicture, picturesModeratePicture,
picturesRotatePicture,
type UserProfileSchema, type UserProfileSchema,
usersidentifiedDeleteRelation, usersidentifiedDeleteRelation,
} from "#openapi"; } from "#openapi";
@@ -29,32 +28,18 @@ class PictureWithIdentifications {
identificationsLoading = false; identificationsLoading = false;
moderationLoading = false; moderationLoading = false;
id: number; id: number;
compressedUrl: string = ""; // biome-ignore lint/style/useNamingConvention: api is in snake_case
thumbUrl: string = ""; compressed_url: string;
fullSizeUrl: string = "";
moderationRequests: ModerationRequestSchema[] = null; moderationRequests: ModerationRequestSchema[] = null;
constructor(picture: PictureSchema) { constructor(picture: PictureSchema) {
Object.assign(this, picture); Object.assign(this, picture);
this.compressedUrl = picture.compressed_url;
this.thumbUrl = picture.thumb_url;
this.fullSizeUrl = picture.full_size_url;
} }
static fromPicture(picture: PictureSchema): PictureWithIdentifications { static fromPicture(picture: PictureSchema): PictureWithIdentifications {
return new PictureWithIdentifications(picture); return new PictureWithIdentifications(picture);
} }
rebuildUrls(date: Date) {
const buildUrl = (url: string) => {
const base = url.split("?", 1)[0];
return `${base}?date=${date.getTime().toString()}`;
};
this.compressedUrl = buildUrl(this.compressedUrl);
this.thumbUrl = buildUrl(this.thumbUrl);
this.fullSizeUrl = buildUrl(this.fullSizeUrl);
}
/** /**
* If not already done, fetch the users identified on this picture and * If not already done, fetch the users identified on this picture and
* populate the identifications field * populate the identifications field
@@ -97,25 +82,12 @@ class PictureWithIdentifications {
this.moderationLoading = false; this.moderationLoading = false;
} }
async rotate(direction: "left" | "right") {
this.imageLoading = true;
const res = await picturesRotatePicture({
// biome-ignore lint/style/useNamingConvention: api is snake case
path: { picture_id: this.id, direction: direction },
});
// urls returned by the api include a timestamp for cache busting
this.fullSizeUrl = res.data.full_size_url;
this.compressedUrl = res.data.compressed_url;
this.thumbUrl = res.data.thumb_url;
this.imageLoading = false;
}
/** /**
* Preload the photo and the identifications * Preload the photo and the identifications
*/ */
async preload(): Promise<void> { async preload(): Promise<void> {
const img = new Image(); const img = new Image();
img.src = this.compressedUrl; img.src = this.compressed_url;
if (!img.complete) { if (!img.complete) {
this.imageLoading = true; this.imageLoading = true;
img.addEventListener("load", () => { img.addEventListener("load", () => {
@@ -168,8 +140,7 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
full_size_url: "", full_size_url: "",
owner: "", owner: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case date: new Date(),
created_at: new Date(),
identifications: [] as IdentifiedUserSchema[], identifications: [] as IdentifiedUserSchema[],
}, },
/** /**
@@ -320,8 +291,10 @@ document.addEventListener("alpine:init", () => {
async submitIdentification(): Promise<void> { async submitIdentification(): Promise<void> {
const widget: TomSelect = this.selector.widget; const widget: TomSelect = this.selector.widget;
await picturesIdentifyUsers({ await picturesIdentifyUsers({
// biome-ignore lint/style/useNamingConvention: api is in snake_case path: {
path: { picture_id: this.currentPicture.id }, // biome-ignore lint/style/useNamingConvention: api is in snake_case
picture_id: this.currentPicture.id,
},
body: widget.items.map((i: string) => Number.parseInt(i, 10)), body: widget.items.map((i: string) => Number.parseInt(i, 10)),
}); });
// refresh the identified users list // refresh the identified users list
+25 -22
View File
@@ -235,34 +235,37 @@
>.tools { >.tools {
flex: 1; flex: 1;
.btn {
background-color: $primary-neutral-light-color;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
color: black;
width: 40px;
height: 40px;
font-size: 20px;
&:hover { >div>div {
background-color: #aaa; >a.btn {
background-color: $primary-neutral-light-color;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
color: black;
width: 40px;
height: 40px;
font-size: 20px;
&:hover {
background-color: #aaa;
}
} }
}
a.text.danger { >a.text.danger {
color: red; color: red;
&:hover { &:hover {
color: darkred; color: darkred;
}
} }
}
.buttons { &.buttons {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 5px; gap: 5px;
}
} }
} }
} }
+12 -27
View File
@@ -1,9 +1,9 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{%- block additional_css -%} {%- block additional_css -%}
<link rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}"> <link defer rel="stylesheet" href="{{ static('bundled/core/components/ajax-select-index.css') }}">
<link rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}"> <link defer rel="stylesheet" href="{{ static('core/components/ajax-select.scss') }}">
<link rel="stylesheet" href="{{ static('sas/css/picture.scss') }}"> <link defer rel="stylesheet" href="{{ static('sas/css/picture.scss') }}">
{%- endblock -%} {%- endblock -%}
{%- block additional_js -%} {%- block additional_js -%}
@@ -84,7 +84,7 @@
<div class="main"> <div class="main">
<div class="photo" :aria-busy="currentPicture.imageLoading"> <div class="photo" :aria-busy="currentPicture.imageLoading">
<img <img
:src="currentPicture.compressedUrl" :src="currentPicture.compressed_url"
:alt="currentPicture.name" :alt="currentPicture.name"
id="main-picture" id="main-picture"
x-ref="mainPicture" x-ref="mainPicture"
@@ -100,7 +100,7 @@
<span <span
x-text="Intl.DateTimeFormat( x-text="Intl.DateTimeFormat(
'{{ LANGUAGE_CODE }}', {dateStyle: 'long'} '{{ LANGUAGE_CODE }}', {dateStyle: 'long'}
).format(Date.parse(currentPicture.date))" ).format(new Date(currentPicture.date))"
> >
</span> </span>
</div> </div>
@@ -115,38 +115,23 @@
<h5>{% trans %}Tools{% endtrans %}</h5> <h5>{% trans %}Tools{% endtrans %}</h5>
<div> <div>
<div> <div>
<a class="text" :href="currentPicture.fullSizeUrl"> <a class="text" :href="currentPicture.full_size_url">
{% trans %}HD version{% endtrans %} {% trans %}HD version{% endtrans %}
</a> </a>
<a class="text danger " :href="currentPicture.report_url"> <a class="text danger " :href="currentPicture.report_url">
{% trans %}Ask for removal{% endtrans %} {% trans %}Ask for removal{% endtrans %}
</a> </a>
</div> </div>
<div <div class="buttons">
class="buttons"
x-show="{{ user.has_perm("sas.change_sasfile")|tojson }} || currentPicture.owner.id === {{ user.id }}"
>
<a <a
class="btn btn-no-text" class="btn btn-no-text"
:href="currentPicture.edit_url" :href="currentPicture.edit_url"
:disabled="currentPicture.imageLoading" x-show="{{ user.has_perm("sas.change_sasfile")|tojson }} || currentPicture.owner.id === {{ user.id }}"
> >
<i class="fa-regular fa-pen-to-square edit-action"></i> <i class="fa-regular fa-pen-to-square edit-action"></i>
</a> </a>
<button <a class="btn btn-no-text" href="?rotate_left"><i class="fa-solid fa-rotate-left"></i></a>
class="btn btn-no-text" <a class="btn btn-no-text" href="?rotate_right"><i class="fa-solid fa-rotate-right"></i></a>
@click="currentPicture.rotate('left')"
:disabled="currentPicture.imageLoading"
>
<i class="fa-solid fa-rotate-left"></i>
</button>
<button
class="btn btn-no-text"
@click="currentPicture.rotate('right')"
:disabled="currentPicture.imageLoading"
>
<i class="fa-solid fa-rotate-right"></i>
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -161,7 +146,7 @@
@keyup.left.window="currentPicture = previousPicture" @keyup.left.window="currentPicture = previousPicture"
@click="currentPicture = previousPicture" @click="currentPicture = previousPicture"
> >
<img :src="previousPicture.thumbUrl" alt="{% trans %}Previous picture{% endtrans %}"/> <img :src="previousPicture.thumb_url" alt="{% trans %}Previous picture{% endtrans %}"/>
<div class="overlay">←</div> <div class="overlay">←</div>
</div> </div>
</template> </template>
@@ -172,7 +157,7 @@
@keyup.right.window="currentPicture = nextPicture" @keyup.right.window="currentPicture = nextPicture"
@click="currentPicture = nextPicture" @click="currentPicture = nextPicture"
> >
<img :src="nextPicture.thumbUrl" alt="{% trans %}Previous picture{% endtrans %}"/> <img :src="nextPicture.thumb_url" alt="{% trans %}Previous picture{% endtrans %}"/>
<div class="overlay">→</div> <div class="overlay">→</div>
</div> </div>
</template> </template>
-38
View File
@@ -1,11 +1,6 @@
from io import BytesIO
from pathlib import Path
import pytest import pytest
from django.core.files.base import ContentFile
from django.test import TestCase from django.test import TestCase
from model_bakery import baker from model_bakery import baker
from PIL import Image
from core.baker_recipes import old_subscriber_user, subscriber_user from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import User from core.models import User
@@ -72,36 +67,3 @@ def test_identifications_viewable_by_user():
assert list(picture.people.viewable_by(identifications[1].user)) == [ assert list(picture.people.viewable_by(identifications[1].user)) == [
identifications[1] identifications[1]
] ]
@pytest.mark.django_db
@pytest.mark.parametrize("save", [True, False])
@pytest.mark.parametrize("initially_saved", [True, False])
@pytest.mark.parametrize("pass_img_kwarg", [True, False])
def test_generate_thumbnail(save, initially_saved, pass_img_kwarg):
"""Test that Picture.generate_thumbnails works properly"""
image = Image.new("RGB", (2, 1))
image.putdata([(255, 0, 0), (0, 255, 0)])
buffer = BytesIO()
image.save(buffer, format="PNG")
file = ContentFile(buffer.getvalue(), "img.png")
picture: Picture = picture_recipe.prepare(
file=file,
name=file.name,
mime_type="image/png",
_save_related=True,
)
if initially_saved:
picture.save()
picture.generate_thumbnails(img=image if pass_img_kwarg else None, save=save)
storage = picture.file.storage
for f in picture.file, picture.compressed, picture.thumbnail:
# the tested picture is alone in its album,
# so there should be a single file in each folder
assert storage.exists(f.name)
_dirs, files = storage.listdir(str(Path(f.path).parent))
assert files == [Path(f.name).name]
new_img = Image.open(picture.file)
assert new_img.get_flattened_data() == image.get_flattened_data()
assert Image.open(picture.thumbnail).size == (200, 100)
assert Image.open(picture.compressed).size == (1200, 600)
+1 -70
View File
@@ -12,23 +12,19 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from typing import Callable, Literal from typing import Callable
from unittest.mock import patch
import pytest import pytest
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.files.base import ContentFile
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from PIL import Image
from pytest_django.asserts import assertHTMLEqual, assertInHTML, assertRedirects from pytest_django.asserts import assertHTMLEqual, assertInHTML, assertRedirects
from core.baker_recipes import old_subscriber_user, subscriber_user from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import Group, User from core.models import Group, User
from core.utils import RED_PIXEL_PNG
from sas.baker_recipes import picture_recipe from sas.baker_recipes import picture_recipe
from sas.models import Album, Picture from sas.models import Album, Picture
@@ -166,71 +162,6 @@ class TestAlbumUpload:
assert not album.children.exists() assert not album.children.exists()
@pytest.mark.django_db
class TestPictureRotation:
@pytest.fixture
def picture(self) -> Picture:
return picture_recipe.make(
parent_id=settings.SITH_SAS_ROOT_DIR_ID,
file=ContentFile(name="foo.png", content=RED_PIXEL_PNG),
compressed=ContentFile(name="foo.png", content=RED_PIXEL_PNG),
thumbnail=ContentFile(name="foo.png", content=RED_PIXEL_PNG),
)
@pytest.mark.parametrize(
"user",
[
None,
lambda: baker.make(User),
subscriber_user.make,
old_subscriber_user.make,
],
)
def test_permission_denied(
self, client: Client, picture: Picture, user: Callable[[], User] | None
):
if user:
client.force_login(user())
url = reverse(
"api:rotate_picture", kwargs={"picture_id": picture.id, "direction": "left"}
)
response = client.post(url)
assert response.status_code == 403 if user else 401
@pytest.mark.parametrize(
"user",
[
lambda: baker.make(User, is_superuser=True),
lambda: baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
),
],
)
@pytest.mark.parametrize(("direction", "angle"), [("left", 90), ("right", 270)])
def test_rotation(
self,
client: Client,
picture: Picture,
user: Callable[[], User],
direction: Literal["left", "right"],
angle: Literal[90, 270],
):
client.force_login(user())
url = reverse(
"api:rotate_picture",
kwargs={"picture_id": picture.id, "direction": direction},
)
with (
patch.object(Image.Image, "rotate") as mocked_rotate,
patch.object(Picture, "generate_thumbnails") as mocked_thumb,
):
response = client.post(url)
assert response.status_code == 200
mocked_rotate.assert_called_once_with(angle)
mocked_thumb.assert_called_once()
class TestSasModeration(TestCase): class TestSasModeration(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
+8
View File
@@ -97,6 +97,14 @@ class PictureView(CanViewMixin, DetailView):
pk_url_kwarg = "picture_id" pk_url_kwarg = "picture_id"
template_name = "sas/picture.jinja" template_name = "sas/picture.jinja"
def get(self, request, *args, **kwargs):
self.object = self.get_object()
if "rotate_right" in request.GET:
self.object.rotate(270)
if "rotate_left" in request.GET:
self.object.rotate(90)
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | { return super().get_context_data(**kwargs) | {
"album": Album.objects.get(children=self.object) "album": Album.objects.get(children=self.object)
+50 -8
View File
@@ -523,14 +523,22 @@ SITH_SUBSCRIPTIONS = {
"membre-honoraire": {"name": _("Honorary member"), "price": 0, "duration": 666}, "membre-honoraire": {"name": _("Honorary member"), "price": 0, "duration": 666},
"assidu": {"name": _("Assidu member"), "price": 0, "duration": 2}, "assidu": {"name": _("Assidu member"), "price": 0, "duration": 2},
"amicale/doceo": {"name": _("Amicale/DOCEO member"), "price": 0, "duration": 2}, "amicale/doceo": {"name": _("Amicale/DOCEO member"), "price": 0, "duration": 2},
"reseau-ut (hors UTC)": { "reseau-ut": {"name": _("UT network member"), "price": 0, "duration": 1},
"name": _("UT network member (excluding UTC)"), "crous": {"name": _("CROUS member"), "price": 0, "duration": 2},
"sbarro/esta": {"name": _("Sbarro/ESTA member"), "price": 15, "duration": 2},
"un-semestre-welcome": {
"name": _("One semester Welcome Week"),
"price": 0, "price": 0,
"duration": 1, "duration": 1,
}, },
"crous": {"name": _("CROUS member"), "price": 0, "duration": 2}, "benevoles-euroks": {"name": _("Eurok's volunteer"), "price": 5, "duration": 0.1},
"sbarro/esta": {"name": _("Sbarro/ESTA member"), "price": 15, "duration": 2}, "six-semaines-essai": {
"name": _("Six weeks for free"),
"price": 0,
"duration": 0.23,
},
"un-jour": {"name": _("One day"), "price": 0, "duration": 0.00555333}, "un-jour": {"name": _("One day"), "price": 0, "duration": 0.00555333},
"membre-staff-ga": {"name": _("GA staff member"), "price": 1, "duration": 0.076},
# Discount subscriptions # Discount subscriptions
"un-semestre-reduction": { "un-semestre-reduction": {
"name": _("One semester (-20%)"), "name": _("One semester (-20%)"),
@@ -557,9 +565,44 @@ SITH_SUBSCRIPTIONS = {
"price": 28, "price": 28,
"duration": 6, "duration": 6,
}, },
# CA special offer
"un-an-offert-CA": {
"name": _("One year for free(CA offer)"),
"price": 0,
"duration": 2,
},
# To be completed.... # To be completed....
} }
SITH_CLUB_ROLES_ID = {
"President": 10,
"Vice-President": 9,
"Treasurer": 7,
"Communication supervisor": 5,
"Secretary": 4,
"IT supervisor": 3,
"Board member": 2,
"Active member": 1,
"Curious": 0,
}
SITH_CLUB_ROLES = {
10: _("President"),
9: _("Vice-President"),
7: _("Treasurer"),
5: _("Communication supervisor"),
4: _("Secretary"),
3: _("IT supervisor"),
2: _("Board member"),
1: _("Active member"),
0: _("Curious"),
}
# This corresponds to the maximum role a user can freely subscribe to
# In this case, SITH_MAXIMUM_FREE_ROLE=1 means that a user can
# set himself as "Membre actif" or "Curieux", but not higher
SITH_MAXIMUM_FREE_ROLE = 1
# Minutes to timeout the logged barmen # Minutes to timeout the logged barmen
SITH_BARMAN_TIMEOUT = 30 SITH_BARMAN_TIMEOUT = 30
@@ -636,17 +679,16 @@ if DEBUG:
INSTALLED_APPS += ("debug_toolbar",) INSTALLED_APPS += ("debug_toolbar",)
MIDDLEWARE = ("debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE) MIDDLEWARE = ("debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE)
DEBUG_TOOLBAR_PANELS = [ DEBUG_TOOLBAR_PANELS = [
"debug_toolbar.panels.history.HistoryPanel", "debug_toolbar.panels.versions.VersionsPanel",
"debug_toolbar.panels.timer.TimerPanel", "debug_toolbar.panels.timer.TimerPanel",
"debug_toolbar.panels.settings.SettingsPanel", "debug_toolbar.panels.settings.SettingsPanel",
"debug_toolbar.panels.headers.HeadersPanel", "debug_toolbar.panels.headers.HeadersPanel",
"debug_toolbar.panels.request.RequestPanel", "debug_toolbar.panels.request.RequestPanel",
"debug_toolbar.panels.sql.SQLPanel", "debug_toolbar.panels.sql.SQLPanel",
"debug_toolbar.panels.templates.TemplatesPanel", "sith.toolbar_debug.TemplatesPanel",
"debug_toolbar.panels.cache.CachePanel", "debug_toolbar.panels.cache.CachePanel",
"debug_toolbar.panels.signals.SignalsPanel", "debug_toolbar.panels.signals.SignalsPanel",
"debug_toolbar.panels.community.CommunityPanel", "debug_toolbar.panels.redirects.RedirectsPanel",
"debug_toolbar.panels.profiling.ProfilingPanel",
] ]
if not TESTING: if not TESTING:
SENTRY_ENV = "development" # We can't test if it gets overridden in settings SENTRY_ENV = "development" # We can't test if it gets overridden in settings
+36
View File
@@ -0,0 +1,36 @@
#
# Copyright 2016,2017,2021
# - Sli <antoine@bartuccio.fr>
# - Skia <skia@hya.sk>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from debug_toolbar.panels.templates import TemplatesPanel as BaseTemplatesPanel
class TemplatesPanel(BaseTemplatesPanel):
def generate_stats(self, *args):
try:
template = self.templates[0]["template"]
if not hasattr(template, "engine") and hasattr(template, "backend"):
template.engine = template.backend
except IndexError: # No template
pass
return super().generate_stats(*args)
+2 -6
View File
@@ -79,6 +79,7 @@ class SubscriptionNewUserForm(SubscriptionForm):
""" """
allowed_payment_methods = ["CARD", "CASH"] allowed_payment_methods = ["CARD", "CASH"]
template_name = "subscription/forms/create_new_user.jinja"
__user_fields = forms.fields_for_model( __user_fields = forms.fields_for_model(
User, User,
@@ -120,12 +121,6 @@ class SubscriptionNewUserForm(SubscriptionForm):
email=self.cleaned_data.get("email"), email=self.cleaned_data.get("email"),
date_of_birth=self.cleaned_data.get("date_of_birth"), date_of_birth=self.cleaned_data.get("date_of_birth"),
) )
if self.errors:
# don't bother generating username, password and other data.
# The form validation failed anyway, so using a dummy User
# (just for Subscription.clean not to crash) is enough
self.instance.member = member
return super().clean()
if self.cleaned_data.get("subscription_type") in [ if self.cleaned_data.get("subscription_type") in [
"un-semestre", "un-semestre",
"deux-semestres", "deux-semestres",
@@ -158,6 +153,7 @@ class SubscriptionNewUserForm(SubscriptionForm):
class SubscriptionExistingUserForm(SubscriptionForm): class SubscriptionExistingUserForm(SubscriptionForm):
"""Form to add a subscription to an existing user.""" """Form to add a subscription to an existing user."""
template_name = "subscription/forms/create_existing_user.jinja"
required_css_class = "required" required_css_class = "required"
birthdate = forms.fields_for_model( birthdate = forms.fields_for_model(
@@ -1,42 +1,38 @@
import { userFetchUser } from "#openapi"; import { userFetchUser } from "#openapi";
Alpine.data("existing_user_subscription_form", () => ({ document.addEventListener("alpine:init", () => {
loading: false, Alpine.data("existing_user_subscription_form", () => ({
selectedUser: "", loading: false,
profileFragment: "", profileFragment: "" as string,
dateOfBirth: "",
dateOfBirthHidden: true,
init() { async init() {
this.$watch("selectedUser", async () => { const userSelect = document.getElementById("id_member") as HTMLSelectElement;
await this.loadProfile(Number.parseInt(this.selectedUser, 10)); userSelect.addEventListener("change", async () => {
}); await this.loadProfile(Number.parseInt(userSelect.value, 10));
});
await this.loadProfile(Number.parseInt(userSelect.value, 10));
},
this.$nextTick(() => { async loadProfile(userId: number) {
// Force to detect the initial value const birthdayInput = document.getElementById("id_birthdate") as HTMLInputElement;
this.selectedUser = this.$refs.userSelect.widget.getValue(); if (!Number.isInteger(userId)) {
}); this.profileFragment = "";
}, birthdayInput.hidden = true;
return;
async loadProfile(userId: number) { }
if (!Number.isInteger(userId)) { this.loading = true;
this.profileFragment = ""; const [miniProfile, userInfos] = await Promise.all([
this.dateOfBirth = ""; fetch(`/user/${userId}/mini/`),
this.dateOfBirthHidden = true; // biome-ignore lint/style/useNamingConvention: api is snake_case
return; userFetchUser({ path: { user_id: userId } }),
} ]);
this.loading = true; this.profileFragment = await miniProfile.text();
const [miniProfile, userInfos] = await Promise.all([ // If the user has no birthdate yet, show the form input
fetch(`/user/${userId}/mini/`), // to fill this info.
// biome-ignore lint/style/useNamingConvention: api is snake_case // Else keep the input hidden and change its value to the user birthdate
userFetchUser({ path: { user_id: userId } }), birthdayInput.value = userInfos.data.date_of_birth;
]); birthdayInput.hidden = userInfos.data.date_of_birth !== null;
this.profileFragment = await miniProfile.text(); this.loading = false;
// If the user has no birthdate yet, show the form input },
// to fill this info. }));
// Else keep the input hidden and change its value to the user birthdate });
this.dateOfBirth = userInfos.data.date_of_birth;
this.dateOfBirthHidden = userInfos.data.date_of_birth !== null;
this.loading = false;
},
}));
@@ -5,8 +5,7 @@
margin-top: 0; margin-top: 0;
} }
fieldset p:first-of-type, fieldset p:first-of-type, & > p:first-of-type {
&>p:first-of-type {
margin-top: 0; margin-top: 0;
} }
@@ -25,7 +24,7 @@
fieldset { fieldset {
flex: 0 1 auto; flex: 0 1 auto;
.form-group:has(input[hidden]) { p:has(input[hidden]) {
// when the input is hidden, hide the whole label+input+help text group // when the input is hidden, hide the whole label+input+help text group
display: none; display: none;
} }
@@ -0,0 +1,28 @@
{% load static %}
{% load i18n %}
<div x-data="existing_user_subscription_form" class="form-content existing-user">
<fieldset>
{{ errors }}
{% for field, errors in fields %}
<p{% with classes=field.css_classes %}{% if classes %} class="{{ classes }}"{% endif %}{% endwith %}>
{{ field.label_tag }}
{{ field }}
{% if field.help_text %}
<span class="helptext">{{ field.help_text }}</span>
{% endif %}
</p>
{% if field.name == "payment_method" %}
<i>
{% blocktranslate %}If the subscription is done using the AE account, you must also click it on the AE counter.{% endblocktranslate %}
</i>
{% endif %}
{% endfor %}
</fieldset>
<div
id="subscription-form-user-mini-profile"
x-html="profileFragment"
:aria-busy="loading"
></div>
</div>
@@ -0,0 +1 @@
{{ form.as_p }}
@@ -1,5 +1,5 @@
<form <form
hx-post="{{ url("subscription:fragment-new-user") }}" hx-post="{{ post_url }}"
hx-target="this" hx-target="this"
hx-disabled-elt="find input[type='submit']" hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML" hx-swap="outerHTML"
@@ -1,41 +0,0 @@
<form
hx-post="{{ url("subscription:fragment-existing-user") }}"
hx-target="this"
hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML"
>
{% csrf_token %}
<div x-data="existing_user_subscription_form" class="form-content existing-user">
<fieldset>
{{ form.non_field_errors() }}
<div class="form-group">
{{ form.member.label_tag() }}
{{ form.member|add_attr("x-ref=userSelect,x-model=selectedUser") }}
</div>
<div class="form-group">
{{ form.birthdate.label_tag() }}
{{ form.birthdate|add_attr(":value=dateOfBirth,:hidden=dateOfBirthHidden,:type=dateOfBirthHidden ? 'hidden' : 'date'") }}
<span class="helptext">{{ form.birthdate.help_text }}</span>
</div>
<div class="form-group">{{ form.subscription_type.as_field_group() }}</div>
<div class="form-group">
{{ form.payment_method.as_field_group() }}
<i>
{% trans trimmed %}
If the subscription is done using the AE account,
you must also click it on the AE counter.
{% endtrans %}
</i>
</div>
<div class="form-group">{{ form.location.as_field_group() }}</div>
</fieldset>
<div
id="subscription-form-user-mini-profile"
x-html="profileFragment"
:aria-busy="loading"
></div>
</div>
<input type="submit" value="{% trans %}Save{% endtrans %}">
</form>
@@ -18,14 +18,22 @@
<link rel="stylesheet" href="{{ static("subscription/css/subscription.scss") }}"> <link rel="stylesheet" href="{{ static("subscription/css/subscription.scss") }}">
{% endblock %} {% endblock %}
{% macro form_fragment(form_object, post_url) %}
{# Include the form fragment inside a with block,
in order to inject the right form in the right place #}
{% with form=form_object, post_url=post_url %}
{% include "subscription/fragments/creation_form.jinja" %}
{% endwith %}
{% endmacro %}
{% block content %} {% block content %}
<h3>{% trans %}New subscription{% endtrans %}</h3> <h3>{% trans %}New subscription{% endtrans %}</h3>
<ui-tab-group id="subscription-form"> <ui-tab-group id="subscription-form">
<ui-tab title="{% trans %}Existing member{% endtrans %}" active> <ui-tab title="{% trans %}Existing member{% endtrans %}" active>
{{ existing_user_fragment }} {{ form_fragment(existing_user_form, existing_user_post_url) }}
</ui-tab> </ui-tab>
<ui-tab title="{% trans %}New member{% endtrans %}"> <ui-tab title="{% trans %}New member{% endtrans %}">
{{ new_user_fragment }} {{ form_fragment(new_user_form, new_user_post_url) }}
</ui-tab> </ui-tab>
</ui-tab-group> </ui-tab-group>
{% endblock %} {% endblock %}
+10 -7
View File
@@ -12,6 +12,7 @@ from django.urls import reverse
from django.utils.timezone import localdate from django.utils.timezone import localdate
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
from pytest_django.fixtures import SettingsWrapper
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
from core.models import Group, User from core.models import Group, User
@@ -25,7 +26,9 @@ from subscription.models import Subscription
"user_factory", "user_factory",
[old_subscriber_user.make, lambda: baker.make(User)], [old_subscriber_user.make, lambda: baker.make(User)],
) )
def test_form_existing_user_valid(user_factory: Callable[[], User]): def test_form_existing_user_valid(
user_factory: Callable[[], User], settings: SettingsWrapper
):
"""Test `SubscriptionExistingUserForm`""" """Test `SubscriptionExistingUserForm`"""
user = user_factory() user = user_factory()
user.date_of_birth = date(year=1967, month=3, day=14) user.date_of_birth = date(year=1967, month=3, day=14)
@@ -45,7 +48,7 @@ def test_form_existing_user_valid(user_factory: Callable[[], User]):
@pytest.mark.django_db @pytest.mark.django_db
def test_form_existing_user_with_birthdate(): def test_form_existing_user_with_birthdate(settings: SettingsWrapper):
"""Test `SubscriptionExistingUserForm`""" """Test `SubscriptionExistingUserForm`"""
user = baker.make(User, date_of_birth=None) user = baker.make(User, date_of_birth=None)
data = { data = {
@@ -67,7 +70,7 @@ def test_form_existing_user_with_birthdate():
@pytest.mark.django_db @pytest.mark.django_db
def test_form_existing_user_invalid(): def test_form_existing_user_invalid(settings: SettingsWrapper):
"""Test `SubscriptionExistingUserForm`, with users that shouldn't subscribe.""" """Test `SubscriptionExistingUserForm`, with users that shouldn't subscribe."""
user = subscriber_user.make() user = subscriber_user.make()
# make sure the current subscription will end in a long time # make sure the current subscription will end in a long time
@@ -88,7 +91,7 @@ def test_form_existing_user_invalid():
@pytest.mark.django_db @pytest.mark.django_db
def test_form_new_user(): def test_form_new_user(settings: SettingsWrapper):
data = { data = {
"first_name": "John", "first_name": "John",
"last_name": "Doe", "last_name": "Doe",
@@ -118,7 +121,7 @@ def test_form_new_user():
"subscription_type", "subscription_type",
["un-semestre", "deux-semestres", "cursus-tronc-commun", "cursus-branche"], ["un-semestre", "deux-semestres", "cursus-tronc-commun", "cursus-branche"],
) )
def test_form_set_new_user_as_student(subscription_type): def test_form_set_new_user_as_student(settings: SettingsWrapper, subscription_type):
"""Test that new users have the student role by default.""" """Test that new users have the student role by default."""
data = { data = {
"first_name": "John", "first_name": "John",
@@ -162,7 +165,7 @@ def test_page_access_with_get_data(client: Client):
@pytest.mark.django_db @pytest.mark.django_db
def test_submit_form_existing_user(client: Client): def test_submit_form_existing_user(client: Client, settings: SettingsWrapper):
client.force_login( client.force_login(
baker.make( baker.make(
User, User,
@@ -193,7 +196,7 @@ def test_submit_form_existing_user(client: Client):
@pytest.mark.django_db @pytest.mark.django_db
def test_submit_form_new_user(client: Client): def test_submit_form_new_user(client: Client, settings: SettingsWrapper):
client.force_login( client.force_login(
baker.make( baker.make(
User, User,
+2 -3
View File
@@ -4,7 +4,7 @@ from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
from club.models import Club, ClubRole, Membership from club.models import Club, Membership
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import User from core.models import User
@@ -15,8 +15,7 @@ class TestSubscriptionPermission(TestCase):
cls.user: User = subscriber_user.make() cls.user: User = subscriber_user.make()
cls.admin = baker.make(User, is_superuser=True) cls.admin = baker.make(User, is_superuser=True)
cls.club = baker.make(Club) cls.club = baker.make(Club)
role = baker.make(ClubRole, club=cls.club, is_board=True) baker.make(Membership, user=cls.user, club=cls.club, role=7)
baker.make(Membership, user=cls.user, club=cls.club, role=role)
def test_give_permission(self): def test_give_permission(self):
self.client.force_login(self.admin) self.client.force_login(self.admin)
+19 -14
View File
@@ -23,7 +23,6 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, TemplateView from django.views.generic import CreateView, DetailView, TemplateView
from django.views.generic.edit import FormView from django.views.generic.edit import FormView
from core.views import FragmentMixin, UseFragmentsMixin
from core.views.group import PermissionGroupsUpdateView from core.views.group import PermissionGroupsUpdateView
from subscription.forms import ( from subscription.forms import (
SelectionDateForm, SelectionDateForm,
@@ -33,9 +32,24 @@ from subscription.forms import (
from subscription.models import Subscription from subscription.models import Subscription
class CreateSubscriptionFragment(PermissionRequiredMixin, FragmentMixin, CreateView): class NewSubscription(PermissionRequiredMixin, TemplateView):
template_name = "subscription/subscription.jinja"
permission_required = "subscription.add_subscription"
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"existing_user_form": SubscriptionExistingUserForm(
initial={"member": self.request.GET.get("member")}
),
"new_user_form": SubscriptionNewUserForm(),
"existing_user_post_url": reverse("subscription:fragment-existing-user"),
"new_user_post_url": reverse("subscription:fragment-new-user"),
}
class CreateSubscriptionFragment(PermissionRequiredMixin, CreateView):
template_name = "subscription/fragments/creation_form.jinja"
permission_required = "subscription.add_subscription" permission_required = "subscription.add_subscription"
object = None
def get_success_url(self): def get_success_url(self):
return reverse( return reverse(
@@ -47,14 +61,14 @@ class CreateSubscriptionExistingUserFragment(CreateSubscriptionFragment):
"""Create a subscription for a user who already exists.""" """Create a subscription for a user who already exists."""
form_class = SubscriptionExistingUserForm form_class = SubscriptionExistingUserForm
template_name = "subscription/fragments/creation_form_existing_user.jinja" extra_context = {"post_url": reverse_lazy("subscription:fragment-existing-user")}
class CreateSubscriptionNewUserFragment(CreateSubscriptionFragment): class CreateSubscriptionNewUserFragment(CreateSubscriptionFragment):
"""Create a subscription for a user who doesn't exist yet.""" """Create a subscription for a user who doesn't exist yet."""
form_class = SubscriptionNewUserForm form_class = SubscriptionNewUserForm
template_name = "subscription/fragments/creation_form_new_user.jinja" extra_context = {"post_url": reverse_lazy("subscription:fragment-new-user")}
def form_valid(self, form): def form_valid(self, form):
res = super().form_valid(form) res = super().form_valid(form)
@@ -69,15 +83,6 @@ class CreateSubscriptionNewUserFragment(CreateSubscriptionFragment):
return res return res
class NewSubscription(PermissionRequiredMixin, UseFragmentsMixin, TemplateView):
template_name = "subscription/subscription.jinja"
permission_required = "subscription.add_subscription"
fragments = {
"new_user_fragment": CreateSubscriptionNewUserFragment,
"existing_user_fragment": CreateSubscriptionExistingUserFragment,
}
class SubscriptionCreatedFragment(PermissionRequiredMixin, DetailView): class SubscriptionCreatedFragment(PermissionRequiredMixin, DetailView):
template_name = "subscription/fragments/creation_success.jinja" template_name = "subscription/fragments/creation_success.jinja"
permission_required = "subscription.add_subscription" permission_required = "subscription.add_subscription"
+5 -6
View File
@@ -23,6 +23,7 @@
from datetime import date from datetime import date
from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@@ -151,12 +152,10 @@ class TrombiUser(models.Model):
def make_memberships(self): def make_memberships(self):
self.memberships.all().delete() self.memberships.all().delete()
for m in ( for m in self.user.memberships.filter(
self.user.memberships.filter(role__is_board=True) role__gt=settings.SITH_MAXIMUM_FREE_ROLE
.select_related("role") ).order_by("end_date"):
.order_by("end_date") role = str(settings.SITH_CLUB_ROLES[m.role])
):
role = m.role.name
if m.description: if m.description:
role += " (%s)" % m.description role += " (%s)" % m.description
end_date = get_semester_code(m.end_date) if m.end_date else "" end_date = get_semester_code(m.end_date) if m.end_date else ""
Generated
+4 -4
View File
@@ -1267,11 +1267,11 @@ wheels = [
[[package]] [[package]]
name = "mistune" name = "mistune"
version = "3.2.0" version = "3.2.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" } sdist = { url = "https://files.pythonhosted.org/packages/ca/84/620cc3f7e3adf6f5067e10f4dbae71295d8f9e16d5d3f9ef97c40f2f592c/mistune-3.2.1.tar.gz", hash = "sha256:7c8e5501d38bac1582e067e46c8343f17d57ea1aaa735823f3aba1fd59c88a28", size = 98003, upload-time = "2026-05-03T14:33:22.312Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" }, { url = "https://files.pythonhosted.org/packages/2a/7f/a946aa4f8752b37102b41e64dca18a1976ac705c3a0d1dfe74d820a02552/mistune-3.2.1-py3-none-any.whl", hash = "sha256:78cdb0ba5e938053ccf63651b352508d2efa9411dc8810bfb05f2dc5140c0048", size = 53749, upload-time = "2026-05-03T14:33:20.551Z" },
] ]
[[package]] [[package]]
@@ -2152,7 +2152,7 @@ requires-dist = [
{ name = "ical", specifier = ">=11.1.0,<14.0.0" }, { name = "ical", specifier = ">=11.1.0,<14.0.0" },
{ name = "jinja2", specifier = ">=3.1.6,<4.0.0" }, { name = "jinja2", specifier = ">=3.1.6,<4.0.0" },
{ name = "libsass", specifier = ">=0.23.0,<1.0.0" }, { name = "libsass", specifier = ">=0.23.0,<1.0.0" },
{ name = "mistune", specifier = ">=3.2.0,<4.0.0" }, { name = "mistune", specifier = ">=3.2.1,<4.0.0" },
{ name = "phonenumbers", specifier = ">=9.0.29,<10.0.0" }, { name = "phonenumbers", specifier = ">=9.0.29,<10.0.0" },
{ name = "pillow", specifier = ">=12.2.0,<13.0.0" }, { name = "pillow", specifier = ">=12.2.0,<13.0.0" },
{ name = "psutil", specifier = ">=7.2.2,<8.0.0" }, { name = "psutil", specifier = ">=7.2.2,<8.0.0" },
+10 -3
View File
@@ -1,4 +1,5 @@
import { parse, resolve } from "node:path"; import { parse, resolve } from "node:path";
import inject from "@rollup/plugin-inject";
import { glob } from "glob"; import { glob } from "glob";
import { visualizer } from "rollup-plugin-visualizer"; import { visualizer } from "rollup-plugin-visualizer";
import { import {
@@ -80,8 +81,14 @@ export default defineConfig((config: UserConfig) => {
resolve: { resolve: {
alias: getAliases(), alias: getAliases(),
}, },
// biome-ignore lint/style/useNamingConvention: that's how it's called
inject: { Alpine: "alpinejs", htmx: "htmx.org" }, plugins: [
plugins: [visualizer({ filename: ".bundle-size-report.html" }) as PluginOption], inject({
// biome-ignore lint/style/useNamingConvention: that's how it's called
Alpine: "alpinejs",
htmx: "htmx.org",
}),
visualizer({ filename: ".bundle-size-report.html" }) as PluginOption,
],
} satisfies UserConfig; } satisfies UserConfig;
}); });