2 Commits

Author SHA1 Message Date
TitouanDor 623e3cdbff correction fautes orthographes 2026-03-17 15:04:15 +01:00
TitouanDor d11b32ef2d add button for general term and condition 2026-03-17 15:04:15 +01:00
186 changed files with 3515 additions and 7442 deletions
+21 -15
View File
@@ -12,7 +12,7 @@ runs:
steps:
- name: Install apt packages
if: ${{ inputs.full == 'true' }}
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
with:
packages: gettext
version: 1.0 # increment to reset cache
@@ -23,29 +23,26 @@ runs:
with:
redis-version: "7.x"
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
version: "0.5.14"
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: "Set up Python"
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
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
uses: actions/cache@v5
uses: actions/cache/restore@v4
with:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
path: .venv
key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
restore-keys: |
uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
uv-${{ runner.os }}
- name: Install dependencies
run: uv sync --locked
run: uv sync
shell: bash
- name: Install Xapian
@@ -53,6 +50,15 @@ runs:
run: uv run ./manage.py install_xapian
shell: bash
# compiling xapian accounts for almost the entirety of the virtualenv setup,
# so we save the virtual environment only on workflows where it has been installed
- name: Save cached virtualenv
if: ${{ inputs.full == 'true' }}
uses: actions/cache/save@v4
with:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
path: .venv
- name: Compile gettext messages
if: ${{ inputs.full == 'true' }}
run: uv run ./manage.py compilemessages
+4 -4
View File
@@ -18,8 +18,8 @@ jobs:
name: Launch pre-commits checks (ruff)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- uses: pre-commit/action@v3.0.1
@@ -35,7 +35,7 @@ jobs:
pytest-mark: [not slow]
steps:
- name: Check out repository
uses: actions/checkout@v6
uses: actions/checkout@v4
- uses: ./.github/actions/setup_project
with:
full: true
@@ -49,7 +49,7 @@ jobs:
uv run coverage report
uv run coverage html
- name: Archive code coverage results
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: coverage-report-${{ matrix.pytest-mark }}
path: coverage_report
+3 -1
View File
@@ -14,7 +14,7 @@ jobs:
steps:
- name: SSH Remote Commands
uses: appleboy/ssh-action@v1.2.5
uses: appleboy/ssh-action@v1.1.0
with:
# Proxy
proxy_host : ${{secrets.PROXY_HOST}}
@@ -29,6 +29,8 @@ jobs:
username : ${{secrets.USER}}
key: ${{secrets.KEY}}
script_stop: true
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
script: |
cd ${{secrets.SITH_PATH}}
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: ./.github/actions/setup_project
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
- uses: actions/cache@v5
- uses: actions/cache@v3
with:
key: mkdocs-material-${{ env.cache_id }}
path: .cache
+3 -1
View File
@@ -13,7 +13,7 @@ jobs:
steps:
- name: SSH Remote Commands
uses: appleboy/ssh-action@v1.2.5
uses: appleboy/ssh-action@v1.1.0
with:
# Proxy
proxy_host : ${{secrets.PROXY_HOST}}
@@ -28,6 +28,8 @@ jobs:
username : ${{secrets.USER}}
key: ${{secrets.KEY}}
script_stop: true
# See https://github.com/ae-utbm/sith/wiki/GitHub-Actions#deployment-action
script: |
cd ${{secrets.SITH_PATH}}
+2 -2
View File
@@ -1,7 +1,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.13
rev: v0.15.5
hooks:
- id: ruff-check # just check the code, and print the errors
- id: ruff-check # actually fix the fixable errors, but print nothing
@@ -14,7 +14,7 @@ repos:
- id: biome-check
additional_dependencies: ["@biomejs/biome@2.4.6"]
- repo: https://github.com/rtts/djhtml
rev: 3.0.11
rev: 3.0.10
hooks:
- id: djhtml
name: format templates
+1 -1
View File
@@ -38,6 +38,6 @@
}
},
"javascript": {
"globals": ["Alpine", "gettext", "interpolate"]
"globals": ["Alpine", "$", "jQuery", "gettext", "interpolate"]
}
}
+1 -43
View File
@@ -13,10 +13,8 @@
#
#
from django.contrib import admin
from django.forms.models import ModelForm
from django.http import HttpRequest
from club.models import Club, ClubLink, ClubRole, LinkType, Membership
from club.models import Club, Membership
@admin.register(Club)
@@ -31,31 +29,6 @@ class ClubAdmin(admin.ModelAdmin):
"page",
)
def save_model(
self,
request: HttpRequest,
obj: Club,
form: ModelForm,
change: bool, # noqa: FBT001
):
super().save_model(request, obj, form, change)
if not change:
obj.create_default_roles()
@admin.register(ClubRole)
class ClubRoleAdmin(admin.ModelAdmin):
list_display = ("name", "club", "is_board", "is_presidency")
search_fields = ("name",)
autocomplete_fields = ("club",)
list_select_related = ("club",)
list_filter = (
"is_board",
"is_presidency",
("club", admin.RelatedOnlyFieldListFilter),
)
show_facets = admin.ModelAdmin.show_facets.ALWAYS
@admin.register(Membership)
class MembershipAdmin(admin.ModelAdmin):
@@ -67,18 +40,3 @@ class MembershipAdmin(admin.ModelAdmin):
"club__name",
)
autocomplete_fields = ("user",)
@admin.register(LinkType)
class LinkTypeAdmin(admin.ModelAdmin):
list_display = ("name", "url_base", "icon")
search_fields = ("name",)
@admin.register(ClubLink)
class ClubLinkAdmin(admin.ModelAdmin):
list_display = ("link_type", "club", "url")
list_select_related = ("link_type", "club")
autocomplete_fields = ("link_type", "club")
search_fields = ("link_type__name", "url")
list_filter = ("link_type", ("club", admin.RelatedOnlyFieldListFilter))
+7 -6
View File
@@ -6,7 +6,7 @@ from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from api.auth import ApiKeyAuth
from api.permissions import CanView, HasPerm
from api.permissions import CanAccessLookup, CanView, HasPerm
from club.models import Club, Membership
from club.schemas import (
ClubSchema,
@@ -22,11 +22,13 @@ class ClubController(ControllerBase):
@route.get(
"/search",
response=PaginatedResponseSchema[SimpleClubSchema],
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[CanAccessLookup],
url_name="search_club",
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_club(self, filters: Query[ClubSearchFilterSchema]):
return filters.filter(Club.objects.order_by("name")).values()
return filters.filter(Club.objects.all())
@route.get(
"/{int:club_id}",
@@ -37,11 +39,10 @@ class ClubController(ControllerBase):
)
def fetch_club(self, club_id: int):
prefetch = Prefetch(
"members",
queryset=Membership.objects.ongoing().select_related("user", "role"),
"members", queryset=Membership.objects.ongoing().select_related("user")
)
return self.get_object_or_exception(
Club.objects.prefetch_related(prefetch, "links"), id=club_id
Club.objects.prefetch_related(prefetch), id=club_id
)
@@ -60,5 +61,5 @@ class UserClubController(ControllerBase):
return (
Membership.objects.ongoing()
.filter(user=user)
.select_related("club", "user", "role")
.select_related("club", "user")
)
+27 -144
View File
@@ -23,19 +23,13 @@
#
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.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from club.models import (
Club,
ClubLink,
ClubRole,
Mailing,
MailingSubscription,
Membership,
)
from club.models import Club, Mailing, MailingSubscription, Membership
from core.models import User
from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import (
@@ -46,26 +40,6 @@ from counter.models import Counter, Selling
from counter.schemas import SaleFilterSchema
class ClubLinkForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
class Meta:
model = ClubLink
fields = ["url", "name", "link_type"]
widgets = {
"url": forms.URLInput(
{"pattern": "https://.*", "placeholder": "https://monlien.com"}
),
"link_type": forms.HiddenInput(),
}
ClubLinkFormSet = forms.inlineformset_factory(
Club, ClubLink, ClubLinkForm, extra=0, can_delete_extra=False
)
class ClubEditForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
@@ -75,20 +49,6 @@ class ClubEditForm(forms.ModelForm):
fields = ["address", "logo", "short_description"]
widgets = {"short_description": forms.Textarea()}
def __init__(self, *args, prefix: str | None = None, instance=None, **kwargs):
super().__init__(*args, prefix=prefix, instance=instance, **kwargs)
self.link_formset = ClubLinkFormSet(
*args, instance=self.instance, prefix="link", **kwargs
)
def is_valid(self):
return super().is_valid() and self.link_formset.is_valid()
def save(self, commit=True): # noqa: FBT002
res = super().save(commit=commit)
self.link_formset.save(commit=commit)
return res
class ClubAdminEditForm(ClubEditForm):
admin_fields = ["name", "parent", "is_active"]
@@ -255,7 +215,9 @@ class ClubOldMemberForm(forms.Form):
def __init__(self, *args, user: User, club: Club, **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):
@@ -273,14 +235,19 @@ class ClubMemberForm(forms.ModelForm):
self.request_user = request_user
self.request_user_membership = self.club.get_membership_for(self.request_user)
super().__init__(*args, **kwargs)
self.fields["role"].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
@property
def available_roles(self) -> QuerySet[ClubRole]:
"""The roles that will be obtainable with this form."""
def max_available_role(self):
"""The greatest role that will be obtainable with this form."""
# 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):
@@ -291,22 +258,21 @@ class ClubAddMemberForm(ClubMemberForm):
widgets = {"user": AutoCompleteSelectUser}
@cached_property
def available_roles(self):
"""The roles that will be obtainable with this form.
def max_available_role(self):
"""The greatest role that will be obtainable with this form.
Admins and the club president can attribute any role.
Board members can attribute roles lower than their own.
Other users cannot attribute roles with this form
"""
qs = self.club.roles.filter(is_active=True)
if self.request_user.has_perm("club.add_membership"):
return qs.all()
return settings.SITH_CLUB_ROLES_ID["President"]
membership = self.request_user_membership
if membership is None or not membership.role.is_board:
return ClubRole.objects.none()
if membership.role.is_presidency:
return qs.all()
return qs.above_instance(membership.role)
if membership is None or membership.role <= settings.SITH_MAXIMUM_FREE_ROLE:
return -1
if membership.role == settings.SITH_CLUB_ROLES_ID["President"]:
return membership.role
return membership.role - 1
def clean_user(self):
"""Check that the user is not trying to add a user already in the club.
@@ -330,11 +296,13 @@ class JoinClubForm(ClubMemberForm):
def __init__(self, *args, club: Club, request_user: User, **kwargs):
super().__init__(*args, club=club, request_user=request_user, **kwargs)
# this form doesn't manage the user who will join the club,
# so we must set this here to avoid errors
self.instance.user = self.request_user
@cached_property
def available_roles(self):
return self.club.roles.filter(is_board=False, is_active=True)
def max_available_role(self):
return settings.SITH_MAXIMUM_FREE_ROLE
def clean(self):
"""Check that the user is subscribed and isn't already in the club."""
@@ -347,88 +315,3 @@ class JoinClubForm(ClubMemberForm):
_("You are already a member of this club"), code="invalid"
)
return super().clean()
class ClubSearchForm(forms.ModelForm):
class Meta:
model = Club
fields = ["name"]
widgets = {"name": forms.SearchInput(attrs={"autocomplete": "off"})}
club_status = forms.NullBooleanField(
label=_("Club status"),
widget=forms.RadioSelect(
choices=[(True, _("Active")), (False, _("Inactive")), ("", _("All clubs"))],
),
initial=True,
)
def __init__(self, *args, data: dict | None = None, **kwargs):
super().__init__(*args, data=data, **kwargs)
if data is not None and "club_status" not in data:
# if the key is missing, it is considered as None,
# even though we want the default True value to be applied in such a case
# so we enforce it.
self.fields["club_status"].value = True
self.fields["name"].required = False
class ClubRoleForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
class Meta:
model = ClubRole
fields = ["name", "description", "is_presidency", "is_board", "is_active"]
widgets = {
"is_presidency": forms.HiddenInput(),
"is_board": forms.HiddenInput(),
"is_active": forms.CheckboxInput(attrs={"class": "switch"}),
}
def clean(self):
cleaned_data = super().clean()
if "ORDER" in cleaned_data:
self.instance.order = cleaned_data["ORDER"] - 1
return cleaned_data
class ClubRoleCreateForm(forms.ModelForm):
"""Form to create a club role.
Notes:
For UX purposes, users are not meant to fill `is_presidency`
and `is_board`, so those values are required by the form constructor
in order to initialize the instance properly.
"""
error_css_class = "error"
required_css_class = "required"
class Meta:
model = ClubRole
fields = ["name", "description"]
def __init__(
self, *args, club: Club, is_presidency: bool, is_board: bool, **kwargs
):
super().__init__(*args, **kwargs)
self.instance.club = club
self.instance.is_presidency = is_presidency
self.instance.is_board = is_board
class ClubRoleBaseFormSet(forms.BaseInlineFormSet):
ordering_widget = forms.HiddenInput()
ClubRoleFormSet = forms.inlineformset_factory(
Club,
ClubRole,
ClubRoleForm,
ClubRoleBaseFormSet,
can_delete=False,
can_order=True,
edit_only=True,
extra=0,
)
@@ -2,15 +2,12 @@
import django.db.models.deletion
import django.db.models.functions.datetime
from django.conf import settings
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Q
from django.utils.timezone import localdate
# Before the club role rework, the maximum free role
# was the hardcoded highest non-board role
MAXIMUM_FREE_ROLE = 1
def migrate_meta_groups(apps: StateApps, schema_editor):
"""Attach the existing meta groups to the clubs.
@@ -49,7 +46,10 @@ def migrate_meta_groups(apps: StateApps, schema_editor):
).select_related("user")
club.members_group.users.set([m.user for m in memberships])
club.board_group.users.set(
[m.user for m in memberships.filter(role__gt=MAXIMUM_FREE_ROLE)]
[
m.user
for m in memberships.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
]
)
@@ -1,161 +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(club_id=club_id, role=role, then=new_role.id))
# all updates must happen at the same time
# otherwise, the 10 first created ClubRole would be
# re-modified after their initial creation, and it would
# result in an incoherent state.
# To avoid that, all updates are wrapped in a single giant Case(When) statement
# cf. https://docs.djangoproject.com/fr/stable/ref/models/conditional-expressions/#conditional-update
Membership.objects.update(role=Case(*updates))
class Migration(migrations.Migration):
dependencies = [
("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more"),
("core", "0047_alter_notification_date_alter_notification_type"),
]
operations = [
migrations.AlterField(
model_name="club",
name="page",
field=models.OneToOneField(
blank=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="club",
to="core.page",
),
),
migrations.CreateModel(
name="ClubRole",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"order",
models.PositiveIntegerField(
db_index=True, editable=False, verbose_name="order"
),
),
(
"club",
models.ForeignKey(
help_text="The club with which this role is associated",
on_delete=django.db.models.deletion.CASCADE,
related_name="roles",
to="club.club",
verbose_name="club",
),
),
("name", models.CharField(max_length=50, verbose_name="name")),
(
"description",
models.TextField(
default="", blank=True, verbose_name="description"
),
),
(
"is_board",
models.BooleanField(default=False, verbose_name="Board role"),
),
(
"is_presidency",
models.BooleanField(default=False, verbose_name="Presidency role"),
),
(
"is_active",
models.BooleanField(
default=True,
help_text=(
"If the role is inactive, people joining the club "
"won't be able to get it."
),
verbose_name="is active",
),
),
],
options={
"ordering": ("order",),
"verbose_name": "club role",
"verbose_name_plural": "club roles",
},
),
migrations.AlterField(
model_name="club",
name="board_group",
field=models.OneToOneField(
editable=False,
on_delete=django.db.models.deletion.PROTECT,
related_name="club_board",
to="core.group",
),
),
migrations.AlterField(
model_name="club",
name="members_group",
field=models.OneToOneField(
editable=False,
on_delete=django.db.models.deletion.PROTECT,
related_name="club",
to="core.group",
),
),
migrations.AddConstraint(
model_name="clubrole",
constraint=models.CheckConstraint(
condition=models.Q(
("is_presidency", False), ("is_board", True), _connector="OR"
),
name="clubrole_presidency_implies_board",
violation_error_message=(
"A role cannot be in the presidency while not being in the board"
),
),
),
migrations.RunPython(migrate_roles, migrations.RunPython.noop),
# because Postgres migrations run in a single transaction,
# we cannot change the actual values of Membership.role
# and apply the FOREIGN KEY constraint in the same migration.
# The constraint is created in the next migration
]
@@ -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",
),
),
]
-105
View File
@@ -1,105 +0,0 @@
# Generated by Django 5.2.12 on 2026-04-27 07:39
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("club", "0016_clubrole_alter_membership_role")]
operations = [
migrations.CreateModel(
name="LinkType",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=40, verbose_name="name")),
(
"url_base",
models.URLField(
help_text=(
"The base url that links with this type "
"must respect (e.g. `https://www.instagram.com`)"
),
unique=True,
verbose_name="url base",
),
),
(
"icon",
models.CharField(
help_text=(
"The fontawesome class to use "
"(e.g. `fa-brands fa-instagram`)"
),
max_length=40,
verbose_name="icon",
),
),
],
options={"verbose_name": "link type", "verbose_name_plural": "link types"},
),
migrations.CreateModel(
name="ClubLink",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"name",
models.CharField(blank=True, max_length=40, verbose_name="name"),
),
("url", models.URLField(verbose_name="link url")),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="updated at"),
),
(
"club",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="links",
to="club.club",
verbose_name="club",
),
),
(
"link_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="links",
to="club.linktype",
verbose_name="link type",
),
),
],
options={
"verbose_name": "club link",
"verbose_name_plural": "club links",
"constraints": [
models.UniqueConstraint(
fields=["club", "url"],
name="club_clublink_unique_club_url",
violation_error_message="Duplicated url",
)
],
},
),
]
+29 -240
View File
@@ -28,15 +28,15 @@ from typing import Iterable, Self
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email
from django.db import ProgrammingError, models, transaction
from django.db.models import Exists, F, OuterRef, Q
from django.db import models, transaction
from django.db.models import Exists, F, OuterRef, Q, Value
from django.db.models.functions import Greatest
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.text import slugify
from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _
from ordered_model.models import OrderedModel
from core.fields import ResizedImageField
from core.models import Group, Notification, Page, SithFile, User
@@ -89,13 +89,13 @@ class Club(models.Model):
on_delete=models.SET_NULL,
)
page = models.OneToOneField(
Page, related_name="club", blank=True, on_delete=models.PROTECT
Page, related_name="club", blank=True, on_delete=models.CASCADE
)
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(
Group, related_name="club_board", on_delete=models.PROTECT, editable=False
Group, related_name="club_board", on_delete=models.PROTECT
)
objects = ClubQuerySet.as_manager()
@@ -138,7 +138,9 @@ class Club(models.Model):
@cached_property
def president(self) -> Membership | None:
"""Fetch the membership of the current president of this club."""
return self.members.filter(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):
"""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.save(force_lock=True)
def create_default_roles(self):
"""Create some roles that should exist by default for this club.
The created roles are : president, treasurer, active member and curious.
Warnings:
When calling this method, no club must exist yet for this club.
"""
if self.roles.exists():
raise ProgrammingError(
"Default roles can be created only for clubs "
"that don't have associated roles yet"
)
# The names are written in French, because there is no gettext involved
# for strings stored in database, and the majority of users are french.
roles = [
ClubRole(name="Président⸱e", is_board=True, is_presidency=True),
ClubRole(name="Trésorier⸱e", is_board=True, is_presidency=False),
ClubRole(name="Membre actif⸱ve", is_board=False, is_presidency=False),
ClubRole(
name="Curieux⸱euse",
description=(
"Les gens qui suivent l'activité "
"du club sans forcément y participer"
),
is_board=False,
is_presidency=False,
),
]
for i, role in enumerate(roles):
role.club = self
role.order = i
ClubRole.objects.bulk_create(roles)
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
self.board_group.delete()
self.members_group.delete()
@@ -238,20 +206,9 @@ class Club(models.Model):
"""Method to see if that object can be edited by the given user."""
return self.has_rights_in_club(user)
def can_roles_be_edited_by(self, user: User) -> bool:
"""Return True if the given user can edit the roles of this club"""
return user.is_authenticated and (
user.has_perm("club.change_clubrole")
or self.members.ongoing()
.filter(user=user, role__is_presidency=True)
.exists()
)
@cached_property
def current_members(self) -> list[Membership]:
return list(
self.members.ongoing().select_related("user", "role").order_by("-role")
)
return list(self.members.ongoing().select_related("user").order_by("-role"))
def get_membership_for(self, user: User) -> Membership | None:
"""Return the current membership of the given user."""
@@ -263,95 +220,6 @@ class Club(models.Model):
return user.is_in_group(pk=self.board_group_id)
class ClubRole(OrderedModel):
club = models.ForeignKey(
Club,
verbose_name=_("club"),
help_text=_("The club with which this role is associated"),
related_name="roles",
on_delete=models.CASCADE,
)
name = models.CharField(_("name"), max_length=50)
description = models.TextField(_("description"), blank=True, default="")
is_board = models.BooleanField(_("Board role"), default=False)
is_presidency = models.BooleanField(_("Presidency role"), default=False)
is_active = models.BooleanField(
_("is active"),
default=True,
help_text=_(
"If the role is inactive, people joining the club won't be able to get it."
),
)
order_with_respect_to = "club"
class Meta(OrderedModel.Meta):
verbose_name = _("club role")
verbose_name_plural = _("club roles")
constraints = [
# presidency IMPLIES board <=> NOT presidency OR board
# cf. MT1 :)
models.CheckConstraint(
condition=Q(is_presidency=False) | Q(is_board=True),
name="clubrole_presidency_implies_board",
violation_error_message=_(
"A role cannot be in the presidency while not being in the board"
),
)
]
def __str__(self):
return self.name
def get_display_name(self):
return f"{self.name} - {self.club.name}"
def clean(self):
errors = []
roles = list(self.club.roles.all())
if (
self.is_board
and self.order
and any(r.order < self.order and not r.is_board for r in roles)
):
errors.append(
ValidationError(
_("Role %(role)s cannot be placed below a member role")
% {"role": self.name}
)
)
if (
self.is_presidency
and self.order
and any(r.order < self.order and not r.is_presidency for r in roles)
):
errors.append(
ValidationError(
_("Role %(role)s cannot be placed below a non-presidency role")
% {"role": self.name}
)
)
if errors:
raise ValidationError(errors)
return super().clean()
def save(self, *args, **kwargs):
auto_order = self.order is None and self.is_board
if not auto_order:
super().save(*args, **kwargs)
return
# get the role that should be placed after the role we are dealing with.
# So, if this is role is presidency, get the first board role ;
# if it is a board role, get the first member role ;
# and if it is a member role, get nothing (OrderedModel.save will
# automatically put it in the last position anyway)
filters = {"is_board": self.is_presidency, "is_presidency": False}
next_role = self.club.roles.filter(**filters).order_by("order").first()
super().save(*args, **kwargs)
if next_role:
self.above(next_role)
class MembershipQuerySet(models.QuerySet):
def ongoing(self) -> Self:
"""Filter all memberships which are not finished yet."""
@@ -364,10 +232,9 @@ class MembershipQuerySet(models.QuerySet):
are included, even if there are no more members.
If you want to get the users who are currently in the board,
mind combining this with the [MembershipQuerySet.ongoing][]
queryset method
mind combining this with the `ongoing` 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:
"""Filter Memberships that this user can edit.
@@ -390,16 +257,21 @@ class MembershipQuerySet(models.QuerySet):
"""
if user.has_perm("club.change_membership"):
return self.all()
return self.ongoing().filter(
return self.filter(
Q(user=user)
| Exists(
Membership.objects.ongoing().filter(
Membership.objects.filter(
Q(
role__gt=Greatest(
OuterRef("role"), Value(settings.SITH_MAXIMUM_FREE_ROLE)
)
),
user=user,
end_date=None,
club=OuterRef("club"),
role__is_board=True,
role__order__lt=OuterRef("role__order"),
)
)
),
end_date=None,
)
def update(self, **kwargs) -> int:
@@ -469,11 +341,10 @@ class Membership(models.Model):
)
start_date = models.DateField(_("start date"), default=timezone.now)
end_date = models.DateField(_("end date"), null=True, blank=True)
role = models.ForeignKey(
ClubRole,
verbose_name=_("role"),
related_name="members",
on_delete=models.PROTECT,
role = models.IntegerField(
_("role"),
choices=sorted(settings.SITH_CLUB_ROLES.items()),
default=sorted(settings.SITH_CLUB_ROLES.items())[0][0],
)
description = models.CharField(
_("description"), max_length=128, null=False, blank=True
@@ -491,7 +362,7 @@ class Membership(models.Model):
def __str__(self):
return (
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 ''}"
)
@@ -520,11 +391,7 @@ class Membership(models.Model):
if user.is_root or user.is_board_member:
return True
membership = self.club.get_membership_for(user)
if not membership:
return False
return membership.user_id == user.id or (
membership.is_board and membership.role.order < self.role.order
)
return membership is not None and membership.role >= self.role
def delete(self, *args, **kwargs):
self._remove_club_groups([self])
@@ -600,7 +467,7 @@ class Membership(models.Model):
group_id=membership.club.members_group_id,
)
)
if membership.role.is_board:
if membership.role > settings.SITH_MAXIMUM_FREE_ROLE:
club_groups.append(
User.groups.through(
user_id=membership.user_id,
@@ -773,81 +640,3 @@ class MailingSubscription(models.Model):
def fetch_format(self):
return self.get_email + " "
class LinkType(models.Model):
"""A link type, in order to group links and give them icons.
Notes:
Among all club links, there is a special one, with an empty base url
and a default link icon.
It is use as a fallback item when no actual link type can be found.
Danger:
LinkType.icon is content that will be raw-rendered in the template.
It is NOT safe to allow users to give it.
The edition of this field must be reserved to trusted admins.
"""
name = models.CharField(_("name"), max_length=40)
url_base = models.URLField(
"url base",
unique=True,
help_text=_(
"The base url that links with this type must respect (e.g. `%(url)s`)"
)
% {"url": "https://www.instagram.com"},
)
icon = models.CharField(
_("icon"),
max_length=40,
help_text=_("The fontawesome class to use (e.g. `fa-brands fa-instagram`)"),
)
class Meta:
verbose_name = _("link type")
verbose_name_plural = _("link types")
def __str__(self):
return self.name
class ClubLink(models.Model):
link_type = models.ForeignKey(
LinkType,
verbose_name=_("link type"),
on_delete=models.CASCADE,
related_name="links",
)
name = models.CharField(_("name"), max_length=40, blank=True)
url = models.URLField(_("link url"))
club = models.ForeignKey(
Club, verbose_name=_("club"), on_delete=models.CASCADE, related_name="links"
)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
class Meta:
verbose_name = _("club link")
verbose_name_plural = _("club links")
constraints = [
models.UniqueConstraint(
fields=["club", "url"],
name="club_clublink_unique_club_url",
violation_error_message=_("Duplicated url"),
)
]
def __str__(self):
return self.url
def save(self, **kwargs):
if not self.name:
self.name = self.link_type.name
return super().save(**kwargs)
def clean(self):
if not self.url.startswith(self.link_type.url_base):
raise ValidationError(
_("This link doesn't match with the url base of its type.")
)
+4 -18
View File
@@ -2,9 +2,8 @@ from typing import Annotated
from django.db.models import Q
from ninja import FilterLookup, FilterSchema, ModelSchema
from pydantic import HttpUrl
from club.models import Club, ClubRole, Membership
from club.models import Club, Membership
from core.schemas import NonEmptyStr, SimpleUserSchema
@@ -31,7 +30,7 @@ class ClubProfileSchema(ModelSchema):
class Meta:
model = Club
fields = ["id", "name", "logo", "is_active", "short_description"]
fields = ["id", "name", "logo"]
url: str
@@ -40,21 +39,14 @@ class ClubProfileSchema(ModelSchema):
return obj.get_absolute_url()
class ClubRoleSchema(ModelSchema):
class Meta:
model = ClubRole
fields = ["id", "name", "is_presidency", "is_board"]
class ClubMemberSchema(ModelSchema):
"""A schema to represent all memberships in a club."""
class Meta:
model = Membership
fields = ["start_date", "end_date", "description"]
fields = ["start_date", "end_date", "role", "description"]
user: SimpleUserSchema
role: ClubRoleSchema
class ClubSchema(ModelSchema):
@@ -63,11 +55,6 @@ class ClubSchema(ModelSchema):
fields = ["id", "name", "logo", "is_active", "short_description", "address"]
members: list[ClubMemberSchema]
links: list[HttpUrl]
@staticmethod
def resolve_links(obj: Club):
return [link.url for link in obj.links.all()]
class UserMembershipSchema(ModelSchema):
@@ -75,7 +62,6 @@ class UserMembershipSchema(ModelSchema):
class Meta:
model = Membership
fields = ["id", "start_date", "description"]
fields = ["id", "start_date", "role", "description"]
club: SimpleClubSchema
role: ClubRoleSchema
@@ -1,61 +0,0 @@
import type { AlpineComponent } from "alpinejs";
interface RoleGroupData {
isBoard: boolean;
isPresidency: boolean;
roleId: number;
}
document.addEventListener("alpine:init", () => {
Alpine.data("clubRoleList", (config: { userRoleId: number | null }) => ({
confirmOnSubmit: false,
/**
* Edit relevant item data after it has been moved by x-sort
*/
reorder(item: AlpineComponent<RoleGroupData>, conf: RoleGroupData) {
item.isBoard = conf.isBoard;
item.isPresidency = conf.isPresidency;
// if the user has moved its own role outside the presidency,
// submitting the form will require a confirmation
this.confirmOnSubmit = config.userRoleId === item.roleId && !item.isPresidency;
this.resetOrder();
},
/**
* Reset the value of the ORDER input of all items in the list.
* This is to be called after any reordering operation, in order to make sure
* that the order that will be saved is coherent with what is displayed.
*/
resetOrder() {
// When moving items with x-sort, the only information we truly have is
// the end position in the target group, not the previous position nor
// the position in the global list.
// To overcome this, we loop through an enumeration of all inputs
// that are in the form `roles-X-ORDER` and sequentially set the value of the field.
const inputs = document.querySelectorAll<HTMLInputElement>(
"input[name^='roles'][name$='ORDER']",
);
for (const [i, elem] of inputs.entries()) {
elem.value = (i + 1).toString();
}
},
/**
* If the user moved its role out of the presidency, ask a confirmation
* before submitting the form
*/
confirmSubmission(event: SubmitEvent) {
if (
this.confirmOnSubmit &&
!confirm(
gettext(
"You're going to remove your own role from the presidency. " +
"You may lock yourself out of this page. Do you want to continue ? ",
),
)
) {
event.preventDefault();
}
},
}));
});
-66
View File
@@ -1,66 +0,0 @@
#club-detail {
img.club-logo {
display: block;
max-height: 200px;
max-width: 200px;
}
#club-attributes {
ul {
list-style: none;
margin-left: 0;
display: flex;
flex-direction: column;
gap: .75rem;
li i {
margin-right: .5rem;
}
}
}
&:not(.has-links) {
#club-attributes {
float: right;
margin: 1em 0 1em 2em;
@media screen and (max-width: 650px) {
margin-left: 1em;
}
@media screen and (max-width: 400px) {
float: unset;
img.club-logo {
margin: auto;
}
}
}
}
&.has-links {
display: flex;
flex-direction: row-reverse;
gap: 2em;
@media screen and (max-width: 650px) {
flex-direction: column;
gap: 1em;
}
#club-attributes {
display: flex;
flex-direction: column;
gap: 1em;
min-width: 200px;
@media screen and (max-width: 650px) {
margin-top: 1em;
flex-direction: row-reverse;
justify-content: flex-end;
h4 {
margin: 0;
}
img.club-logo {
margin-left: auto;
}
}
}
}
}
-47
View File
@@ -1,47 +0,0 @@
#club-list {
display: flex;
flex-direction: column;
gap: 2em;
padding: 2em;
.card {
display: block;
background-color: unset;
.club-image {
float: left;
margin-right: 2rem;
margin-bottom: .5rem;
width: 150px;
height: 150px;
border-radius: 10%;
background-color: rgba(173, 173, 173, 0.2);
@media screen and (max-width: 500px) {
width: 100px;
height: 100px;
}
}
i.club-image {
display: flex;
flex-direction: column;
justify-content: center;
color: black;
}
.content {
display: block;
text-align: justify;
h4 {
margin-top: 0;
margin-right: .5rem;
}
p {
font-size: 100%;
}
}
}
}
-7
View File
@@ -1,7 +0,0 @@
.fa-grip-vertical {
display: flex;
flex-direction: column;
justify-content: center;
cursor: pointer;
margin-right: .5em;
}
+9 -36
View File
@@ -21,43 +21,16 @@
{% endif %}
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("club/detail.scss") }}">
{% endblock %}
{% block content %}
<h3>{{ club.name }}</h3>
<div id="club-detail" {% if links %}class="has-links"{% endif %}>
<div id="club-attributes">
{% if club.logo %}
<img
class="club-logo"
src="{{ club.logo.url }}"
alt="{{ club.name }}"
width="{{ club.logo.width }}"
height="{{ club.logo.height }}"
>
{% endif %}
{% if links %}
<div id="club-links">
<h4>{% trans %}Links{% endtrans %}</h4>
<ul>
{% for link in links %}
<li>
<a href="{{ link.url }}" rel="noopener external" target="_blank">
<i class="{{ link.link_type.icon }} fa-xl"></i>{{ link.name }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
<div id="club-page">
{% if page_revision %}
{{ page_revision|markdown }}
{% endif %}
</div>
<div id="club_detail">
{% if club.logo %}
<div class="club_logo"><img src="{{ club.logo.url }}" alt="{{ club.name }}"></div>
{% endif %}
{% if page_revision %}
{{ page_revision|markdown }}
{% else %}
<h3>{{ club.name }}</h3>
{% endif %}
</div>
{% endblock %}
+43 -86
View File
@@ -1,95 +1,52 @@
{% if is_fragment %}
{% extends "core/base_fragment.jinja" %}
{% extends "core/base.jinja" %}
{% block metatags %}
<meta property="og:url" content="{{ request.build_absolute_uri() }}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Liste des clubs et assos" />
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% endblock %}
{% block title -%}
{% trans %}Club list{% endtrans %}
{%- endblock %}
{# Don't display tabs and errors #}
{% block tabs %}
{% endblock %}
{% block errors %}
{% endblock %}
{% else %}
{% extends "core/base.jinja" %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("club/list.scss") }}">
{% endblock %}
{% block description -%}
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
{%- endblock %}
{% block title -%}
{% trans %}Club list{% endtrans %}
{%- endblock %}
{% endif %}
{% block description -%}
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
{%- endblock %}
{% from "core/macros.jinja" import paginate_htmx %}
{% macro display_club(club) -%}
{% if club.is_active or user.is_root %}
<li><a href="{{ url('club:club_view', club_id=club.id) }}">{{ club.name }}</a>
{% if not club.is_active %}
({% trans %}inactive{% endtrans %})
{% endif %}
{% if club.president %} - <a href="{{ url('core:user_profile', user_id=club.president.user.id) }}">{{ club.president.user }}</a>{% endif %}
{% if club.short_description %}<p>{{ club.short_description|markdown }}</p>{% endif %}
{% endif %}
{%- if club.children.all()|length != 0 %}
<ul>
{%- for c in club.children.order_by('name').prefetch_related("children") %}
{{ display_club(c) }}
{%- endfor %}
</ul>
{%- endif -%}
</li>
{%- endmacro %}
{% block content %}
<main>
<h3>{% trans %}Filters{% endtrans %}</h3>
<form
id="club-list-filters"
hx-get="{{ url("club:club_list") }}"
hx-target="#content"
hx-swap="outerHtml"
hx-push-url="true"
>
<div class="row gap-4x">
{{ form }}
</div>
<button type="submit" class="btn btn-blue margin-bottom">
<i class="fa fa-magnifying-glass"></i>{% trans %}Search{% endtrans %}
</button>
</form>
{% if user.is_root %}
<p><a href="{{ url('club:club_new') }}">{% trans %}New club{% endtrans %}</a></p>
{% endif %}
{% if club_list %}
<h3>{% trans %}Club list{% endtrans %}</h3>
{% if user.has_perm("club.add_club") %}
<br>
<a href="{{ url('club:club_new') }}" class="btn btn-blue">
<i class="fa fa-plus"></i> {% trans %}New club{% endtrans %}
</a>
{% endif %}
<section class="aria-busy-grow" id="club-list">
{% for club in object_list %}
<div class="card">
{% set club_url = club.get_absolute_url() %}
<a href="{{ club_url }}">
{% if club.logo %}
<img class="club-image" src="{{ club.logo.url }}" alt="logo {{ club.name }}">
{% else %}
<i class="fa-regular fa-image fa-4x club-image"></i>
{% endif %}
</a>
<div class="content">
<a href="{{ club_url }}">
<h4>
{{ club.name }} {% if not club.is_active %}({% trans %}inactive{% endtrans %}){% endif %}
</h4>
</a>
{% set links = club.links.all() %}
{% if links %}
<br>
<div class="row gap-2x">
{% for link in club.links.all() %}
<a href="{{ link.url }}" rel="noopener external" target="_blank">
<i class="{{ link.link_type.icon }} fa-xl"></i>
<strong>{{ link.name }}</strong>
</a>
{% endfor %}
</div>
{% endif %}
{{ club.short_description|markdown }}
</div>
</div>
{% endfor %}
</section>
{% if is_paginated %}
{{ paginate_htmx(request, page_obj, paginator) }}
{% endif %}
</main>
<ul>
{%- for club in club_list %}
{{ display_club(club) }}
{%- endfor %}
</ul>
{% else %}
{% trans %}There is no club in this website.{% endtrans %}
{% endif %}
{% endblock %}
+5 -10
View File
@@ -1,7 +1,11 @@
{% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import user_profile_link, select_all_checkbox %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/core/components/ajax-select-index.ts") }}"></script>
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("bundled/core/components/ajax-select-index.css") }}">
<link rel="stylesheet" href="{{ static("club/members.scss") }}">
{% endblock %}
@@ -12,15 +16,6 @@
<h2>{% trans %}Club members{% endtrans %}</h2>
{% if club.can_roles_be_edited_by(user) %}
<a
href="{{ url("club:club_roles", club_id=object.id) }}"
class="btn btn-blue margin-bottom"
>
<i class="fa fa-users-gear"></i> {% trans %}Manage roles{% endtrans %}
</a>
{% endif %}
{% if add_member_fragment %}
<br />
{{ add_member_fragment }}
@@ -50,7 +45,7 @@
{% for m in members %}
<tr>
<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.start_date }}</td>
{%- if can_end_membership -%}
+1 -1
View File
@@ -17,7 +17,7 @@
{% for member in old_members %}
<tr>
<td>{{ user_profile_link(member.user) }}</td>
<td>{{ member.role.name }}</td>
<td>{{ settings.SITH_CLUB_ROLES[member.role] }}</td>
<td>{{ member.description }}</td>
<td>{{ member.start_date }}</td>
<td>{{ member.end_date }}</td>
-172
View File
@@ -1,172 +0,0 @@
{% extends "core/base.jinja" %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/club/role-list-index.ts") }}" xmlns="http://www.w3.org/1999/html"></script>
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("club/roles.scss") }}">
{% endblock %}
{% macro display_subform(subform) %}
<div
class="row"
x-data="{
isPresidency: {{ subform.is_presidency.value()|lower }},
isBoard: {{ subform.is_board.value()|lower }},
roleId: {{ subform.id.value() }},
}"
x-sort:item="$data"
>
{# hidden fields #}
{{ subform.ORDER }}
{{ subform.id }}
{{ subform.club }}
{{ subform.is_presidency|add_attr("x-model=isPresidency") }}
{{ subform.is_board|add_attr("x-model=isBoard") }}
<i class="fa fa-grip-vertical" x-sort:handle></i>
<details class="accordion grow" {% if subform.errors %}open{% endif %}>
<summary>
{{ subform.name.value() }}
{% if not subform.instance.is_active -%}
({% trans %}inactive{% endtrans %})
{%- endif %}
</summary>
<div class="accordion-content">
{{ subform.non_field_errors() }}
<div class="form-group">
{{ subform.name.as_field_group() }}
</div>
<div class="form-group">
{{ subform.description.as_field_group() }}
</div>
<div class="form-group">
<div>
{{ subform.is_active }}
{{ subform.is_active.label_tag() }}
</div>
<span class="helptext">
{{ subform.is_active.help_text }}
</span>
</div>
</div>
</details>
</div>
{% endmacro %}
{% block content %}
<p>
{% trans trimmed %}
Roles give rights on the club.
Higher roles grant more rights, and the members having them are displayed higher
in the club members list.
{% endtrans %}
</p>
<p>
{% trans trimmed %}
On this page, you can edit their name and description, as well as their order.
You can also drag roles from a category to another
(e.g. a board role can be made into a presidency role).
{% endtrans %}
</p>
<form
method="post"
x-data="clubRoleList({ userRoleId: {{ user_role or "null" }} })"
@submit="confirmSubmission"
>
{% csrf_token %}
{{ form.management_form }}
{{ form.non_form_errors() }}
<h3>{% trans %}Presidency{% endtrans %}</h3>
<a class="btn btn-grey margin-bottom" href="{{ url("club:new_role_president", club_id=club.id) }}">
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
</a>
<details class="clickable margin-bottom">
<summary>{% trans %}Help{% endtrans %}</summary>
{# The style we use for markdown rendering is quite nice for what we want to display,
so we are just gonna reuse it. #}
<div class="markdown">
<p>{% trans %}Users with a presidency role can :{% endtrans %}</p>
<ul>
<li>{% trans %}create new club roles and edit existing ones{% endtrans %}</li>
<li>{% trans %}manage the club counters{% endtrans %}</li>
<li>{% trans %}add new members with any active role and end any membership{% endtrans %}</li>
</ul>
<p>{% trans %}They also have all the rights of the club board.{% endtrans %}</p>
</div>
</details>
<div
x-sort="reorder($item, { isBoard: true, isPresidency: true })"
x-sort:group="roles"
>
{% for subform in form %}
{% if subform.is_presidency.value() %}
{{ display_subform(subform) }}
{% endif %}
{% endfor %}
</div>
<br>
<h3>{% trans %}Board{% endtrans %}</h3>
<a class="btn btn-grey margin-bottom" href="{{ url("club:new_role_board", club_id=club.id) }}">
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
</a>
<details class="clickable margin-bottom">
<summary>{% trans %}Help{% endtrans %}</summary>
<div class="markdown">
<p>
{% trans trimmed %}
Board members can do most administrative actions in the club, including :
{% endtrans %}
</p>
<ul>
<li>{% trans %}manage the club posters{% endtrans %}</li>
<li>{% trans %}create news for the club{% endtrans %}</li>
<li>{% trans %}click users on the club's counters{% endtrans %}</li>
<li>
{% trans trimmed %}
add new members and end active memberships
for roles that are lower than their own.
{% endtrans %}
</li>
</ul>
</div>
</details>
<div
x-sort="reorder($item, { isBoard: true, isPresidency: false })"
x-sort:group="roles"
>
{% for subform in form %}
{% if subform.is_board.value() and not subform.is_presidency.value() %}
{{ display_subform(subform) }}
{% endif %}
{% endfor %}
</div>
<br>
<h3>{% trans %}Members{% endtrans %}</h3>
<a class="btn btn-grey margin-bottom" href="{{ url("club:new_role_member", club_id=club.id) }}">
<i class="fa fa-plus"></i> {% trans %}add role{% endtrans %}
</a>
<details class="clickable margin-bottom">
<summary>{% trans %}Help{% endtrans %}</summary>
<div class="markdown">
<p>{% trans %}Simple members cannot perform administrative actions.{% endtrans %}</p>
</div>
</details>
<div
x-sort="reorder($item, { isBoard: false, isPresidency: false })"
x-sort:group="roles"
>
{% for subform in form %}
{% if not subform.is_board.value() %}
{{ display_subform(subform) }}
{% endif %}
{% endfor %}
</div>
<br>
<p>
<button type="submit" class="btn btn-blue">
<i class="fa fa-check"></i>{% trans %}Save{% endtrans %}
</button>
</p>
</form>
{% endblock content %}
+2 -13
View File
@@ -5,19 +5,8 @@
<div>
<h4>{% trans %}Communication:{% endtrans %}</h4>
<ul>
<li>
<a href="{{ url('com:news_new') }}?club={{ object.id }}">
{% trans %}Create a news{% endtrans %}
</a>
</li>
<li>
<a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">
{% trans %}Post in the Weekmail{% endtrans %}
</a>
</li>
{% if object.can_roles_be_edited_by(user) %}
<li><a href="{{ url("club:club_roles", club_id=object.id) }}"></a></li>
{% endif %}
<li> <a href="{{ url('com:news_new') }}?club={{ object.id }}">{% trans %}Create a news{% endtrans %}</a></li>
<li> <a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">{% trans %}Post in the Weekmail{% endtrans %}</a></li>
{% if object.trombi %}
<li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li>
{% else %}
+6 -100
View File
@@ -1,63 +1,9 @@
{% extends "core/base.jinja" %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/core/dynamic-formset-index.ts") }}"></script>
{% endblock %}
{% block title %}
{% trans name=object %}Edit {{ name }}{% endtrans %}
{% endblock %}
{% macro link_form(form) %}
<fieldset
{# set url in x-init rather than in x-data,
in order to trigger the $watch on initial load #}
x-data="{ url: '', linkType: { icon: '', id: 0 } }"
x-init="() => {
$watch('url', (u) => linkType = linkTypes.find((t) => u.startsWith(t.url)));
url = '{{ form.url.value() or "" }}';
}"
>
{{ form.non_field_errors() }}
<div class="form-group row gap-2x">
<div>
{{ form.url.label_tag() }}
{{ form.url.errors }}
<span>
{# we change the icon when the user change it and leave the input,
or when it is pasted from the clipboard #}
{{ form.url|add_attr("x-model.change=url,@paste.prevent=url = $event.clipboardData.getData('text')") }}
<i
:class="linkType.icon || 'fa fa-link'"
tooltip="{% trans %}This icon will change according to the given url.{% endtrans %}"
></i>
</span>
</div>
<div>{{ form.name.as_field_group() }}</div>
</div>
{%- if form.DELETE -%}
<div class="form-group row gap">
{{ form.DELETE.as_field_group() }}
</div>
{%- else -%}
<br>
<button
class="btn btn-grey"
@click.prevent="removeForm($event.target.closest('fieldset'))"
>
<i class="fa fa-minus"></i> {% trans %}Remove link{% endtrans %}
</button>
{%- endif -%}
{{ form.link_type|add_attr(":value=linkType.id") }}
{%- for field in form.hidden_fields() -%}
{%- if field != form.link_type -%}
{{ field }}
{%- endif -%}
{%- endfor -%}
</fieldset>
{% endmacro %}
{% block content %}
<h2>{% trans name=object %}Edit {{ name }}{% endtrans %}</h2>
@@ -71,7 +17,7 @@
and explicitly separate them from the non-admin ones,
with some help text.
Non-admin users will only see the regular form fields,
so they don't need those explanations #}
so they don't need thoses explanations #}
<h3>{% trans %}Club properties{% endtrans %}</h3>
<p class="helptext">
{% trans trimmed %}
@@ -79,7 +25,7 @@
Only admin users can see and edit them.
{% endtrans %}
</p>
<fieldset class="margin-bottom">
<fieldset class="required margin-bottom">
{% for field_name in form.admin_fields %}
{% set field = form[field_name] %}
<div class="form-group">
@@ -90,13 +36,11 @@
{# Remove the the admin fields from the form.
The remaining non-admin fields will be rendered
at once with a simple {{ form.as_p() }} #}
{% do form.fields.pop(field_name) %}
{% set _ = form.fields.pop(field_name) %}
{% endfor %}
</fieldset>
{% endif %}
<h3>{% trans %}Club informations{% endtrans %}</h3>
{% if form.admin_fields %}
<h3>{% trans %}Club informations{% endtrans %}</h3>
<p class="helptext">
{% trans trimmed %}
The following form fields are linked to the basic description of a club.
@@ -104,45 +48,7 @@
{% endtrans %}
</p>
{% endif %}
<fieldset class="margin-bottom">
{{ form.as_p() }}
</fieldset>
<h3>{% trans %}Club links{% endtrans %}</h3>
<div x-data="dynamicFormSet({ prefix: '{{ form.link_formset.prefix }}' })" class="margin-bottom">
{{ form.link_formset.management_form }}
<div x-ref="formContainer">
{%- for f in form.link_formset.forms -%}
{{ link_form(f) }}
{%- endfor -%}
</div>
<template x-ref="formTemplate">
{{ link_form(form.link_formset.empty_form) }}
</template>
<p>
<i>{% trans trimmed %}
Note: if the icon of one of your links doesn't exist yet,
you can ask the info team to add it.
{% endtrans %}</i>
</p>
<br>
<button @click.prevent="addForm()" class="btn btn-grey">
<i class="fa fa-plus"></i>{% trans %}Add link{% endtrans %}
</button>
</div>
<hr>
<button type="submit" class="btn btn-blue">
<i class="fa fa-check"></i>{% trans %}Save{% endtrans %}
</button>
{{ form.as_p() }}
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock content %}
{% block script %}
<script>
const linkTypes = [
{%- for t in link_types -%}
{ id: {{ t.id }}, url: '{{ t.url_base }}', icon: '{{ t.icon }}' },
{%- endfor -%}
];
</script>
{% endblock %}
+5 -15
View File
@@ -8,7 +8,7 @@ from django.utils.timezone import now
from model_bakery import baker
from model_bakery.recipe import Recipe
from club.models import Club, ClubRole, Membership
from club.models import Club, Membership
from core.baker_recipes import old_subscriber_user, subscriber_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.club = baker.make(Club)
cls.president_role = baker.make(
ClubRole, club=cls.club, is_board=True, is_presidency=True, order=0
)
cls.board_role = baker.make(ClubRole, club=cls.club, is_board=True, order=1)
cls.member_role = baker.make(ClubRole, club=cls.club, order=2)
cls.new_members_url = reverse(
"club:club_new_members", kwargs={"club_id": cls.club.id}
)
@@ -56,17 +51,12 @@ class TestClub(TestCase):
yesterday = now() - timedelta(days=1)
membership_recipe = Recipe(Membership, club=cls.club)
membership_recipe.make(
user=cls.simple_board_member, start_date=a_month_ago, role=cls.board_role
)
membership_recipe.make(user=cls.richard, role=cls.member_role)
membership_recipe.make(
user=cls.president, start_date=a_month_ago, role=cls.president_role
user=cls.simple_board_member, start_date=a_month_ago, role=3
)
membership_recipe.make(user=cls.richard, role=1)
membership_recipe.make(user=cls.president, start_date=a_month_ago, role=10)
membership_recipe.make( # sli was a member but isn't anymore
user=cls.sli,
start_date=a_month_ago,
end_date=yesterday,
role=cls.board_role,
user=cls.sli, start_date=a_month_ago, end_date=yesterday, role=2
)
def setUp(self):
+5 -105
View File
@@ -1,18 +1,12 @@
from datetime import timedelta
import pytest
from django.conf import settings
from django.db import ProgrammingError
from django.test import Client
from django.urls import reverse
from django.utils.timezone import localdate
from model_bakery import baker
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects
from club.models import Club, ClubRole, Membership
from club.models import Club, Membership
from core.baker_recipes import subscriber_user
from core.models import User
@pytest.mark.django_db
@@ -22,106 +16,12 @@ def test_club_queryset_having_board_member():
membership_recipe = Recipe(
Membership, user=user, start_date=localdate() - timedelta(days=3)
)
membership_recipe.make(club=clubs[0], role=1)
membership_recipe.make(club=clubs[1], role=3)
membership_recipe.make(club=clubs[2], role=7)
membership_recipe.make(
club=clubs[0], role=baker.make(ClubRole, club=clubs[0], is_board=False)
)
membership_recipe.make(
club=clubs[1], role=baker.make(ClubRole, club=clubs[1], is_board=True)
)
membership_recipe.make(
club=clubs[2], role=baker.make(ClubRole, club=clubs[2], is_board=True)
)
membership_recipe.make(
club=clubs[3],
role=baker.make(ClubRole, club=clubs[3], is_board=True),
end_date=localdate() - timedelta(days=1),
club=clubs[3], role=3, end_date=localdate() - timedelta(days=1)
)
club_ids = Club.objects.having_board_member(user).values_list("id", flat=True)
assert set(club_ids) == {clubs[1].id, clubs[2].id}
@pytest.mark.parametrize("nb_additional_clubs", [10, 30])
@pytest.mark.parametrize("is_fragment", [True, False])
@pytest.mark.django_db
def test_club_list(client: Client, nb_additional_clubs: int, is_fragment):
client.force_login(baker.make(User))
baker.make(Club, _quantity=nb_additional_clubs)
headers = {"HX-Request": True} if is_fragment else {}
res = client.get(reverse("club:club_list"), headers=headers)
assert res.status_code == 200
def assert_club_created(club_name: str):
club = Club.objects.last()
assert club.name == club_name
assert club.board_group.name == f"{club_name} - Bureau"
assert club.members_group.name == f"{club_name} - Membres"
# default roles should be added on club creation,
# whether the creation happens on the admin site or on the user site
assert list(club.roles.values("name", "is_presidency", "is_board")) == [
{"name": "Président⸱e", "is_presidency": True, "is_board": True},
{"name": "Trésorier⸱e", "is_presidency": False, "is_board": True},
{"name": "Membre actif⸱ve", "is_presidency": False, "is_board": False},
{"name": "Curieux⸱euse", "is_presidency": False, "is_board": False},
]
@pytest.mark.django_db
def test_create_view(admin_client: Client):
"""Test that the club creation view works well"""
res = admin_client.get(reverse("club:club_new"))
assert res.status_code == 200
res = admin_client.post(
reverse("club:club_new"),
data={"name": "foo", "parent": settings.SITH_MAIN_CLUB_ID},
)
club = Club.objects.last()
assertRedirects(res, club.get_absolute_url())
assert_club_created("foo")
@pytest.mark.django_db
def test_default_roles_for_club_with_roles_fails():
"""Test that an Error is raised if trying to create
default roles for a club that already has roles.
"""
club = baker.make(Club)
baker.make(ClubRole, club=club)
with pytest.raises(ProgrammingError):
club.create_default_roles()
@pytest.mark.django_db
class TestAdminInterface:
def test_create(self, admin_client: Client):
"""Test the creation of a club via the admin interface."""
res = admin_client.post(
reverse("admin:club_club_add"),
data={
"name": "foo",
"parent": settings.SITH_MAIN_CLUB_ID,
"address": "Rome",
},
)
assertRedirects(res, reverse("admin:club_club_changelist"))
assert_club_created("foo")
def test_change(self, admin_client: Client):
"""Test the edition of a club via the admin interface."""
club = baker.make(Club)
res = admin_client.post(
reverse("admin:club_club_change", kwargs={"object_id": club.id}),
data={
"name": "foo",
"page": club.page_id,
"home": club.home_id,
"address": club.address,
},
)
assertRedirects(res, reverse("admin:club_club_changelist"))
club.refresh_from_db()
assert club.name == "foo"
# Club roles shouldn't be modified when editing the club on the admin interface
# This club had no roles beforehand, therefore it shouldn't have roles now.
assert not club.roles.exists()
+4 -7
View File
@@ -1,7 +1,6 @@
from datetime import date, timedelta
import pytest
from django.conf import settings
from django.contrib.auth.models import Permission
from django.test import Client, TestCase
from django.urls import reverse
@@ -9,7 +8,7 @@ from model_bakery import baker
from model_bakery.recipe import Recipe
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.models import Group, Page, User
@@ -27,10 +26,8 @@ class TestClubSearch(TestCase):
"id", flat=True
)
)
Membership.objects.all().delete()
ClubRole.objects.all().delete()
Page.objects.exclude(club=None).delete()
Club.objects.all().delete()
Page.objects.exclude(name=settings.SITH_CLUB_ROOT_PAGE).delete()
Group.objects.filter(id__in=groups).delete()
cls.clubs = baker.make(
@@ -88,8 +85,8 @@ class TestFetchClub:
def test_fetch_club_nb_queries(self, client: Client, club: Club):
user = subscriber_user.make()
client.force_login(user)
with assertNumQueries(7):
with assertNumQueries(6):
# - 4 queries for authentication
# - 3 queries for the actual data
# - 2 queries for the actual data
res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id}))
assert res.status_code == 200
-253
View File
@@ -1,253 +0,0 @@
from collections.abc import Callable
import pytest
from django.contrib.auth.models import Permission
from django.test import Client, TestCase
from django.urls import reverse
from model_bakery import baker, seq
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects
from club.forms import ClubRoleFormSet
from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, User
def make_club():
# unittest-style tests cannot use fixture, so we create a function
# that will be callable either by a pytest fixture or inside
# a TestCase.setUpTestData method.
club = baker.make(Club)
recipe = Recipe(ClubRole, club=club, name=seq("role "))
recipe.make(
is_board=iter([True, True, False]),
is_presidency=iter([True, False, False]),
order=iter([0, 1, 2]),
_quantity=3,
_bulk_create=True,
)
return club
@pytest.fixture
def club(db):
"""A club with a presidency role, a board role and a member role"""
return make_club()
@pytest.mark.django_db
def test_order_auto(club):
"""Test that newly created roles are put in the right place."""
roles = list(club.roles.all())
# create new roles one by one (like they will be in prod)
# each new role should be placed at the end of its category
recipe = Recipe(ClubRole, club=club, name=seq("new role "))
role_a = recipe.make(is_board=True, is_presidency=True, order=None)
role_b = recipe.make(is_board=True, is_presidency=False, order=None)
role_c = recipe.make(is_board=False, is_presidency=False, order=None)
assert list(club.roles.order_by("order")) == [
roles[0],
role_a,
roles[1],
role_b,
roles[2],
role_c,
]
@pytest.mark.django_db
@pytest.mark.parametrize(
("user_factory", "is_allowed"),
[
(
lambda club: baker.make(
User,
user_permissions=[Permission.objects.get(codename="change_clubrole")],
),
True,
),
( # user with presidency roles can edit the club roles
lambda club: subscriber_user.make(
memberships=[
baker.make(
Membership,
club=club,
role=club.roles.filter(is_presidency=True).first(),
)
]
),
True,
),
( # user in the board but not in the presidency cannot edit roles
lambda club: subscriber_user.make(
memberships=[
baker.make(
Membership,
club=club,
role=club.roles.filter(
is_presidency=False, is_board=True
).first(),
)
]
),
False,
),
(lambda _: AnonymousUser(), False),
],
)
def test_can_roles_be_edited_by(
club: Club, user_factory: Callable[[Club], User], is_allowed
):
"""Test that `Club.can_roles_be_edited_by` return the right value"""
user = user_factory(club)
assert club.can_roles_be_edited_by(user) == is_allowed
@pytest.mark.django_db
@pytest.mark.parametrize(
["route", "is_presidency", "is_board"],
[
("club:new_role_president", True, True),
("club:new_role_board", False, True),
("club:new_role_member", False, False),
],
)
def test_create_role_view(client: Client, route: str, is_presidency, is_board):
"""Test that the role creation views work."""
club = baker.make(Club)
role = baker.make(ClubRole, club=club, is_presidency=True, is_board=True)
user = subscriber_user.make()
baker.make(Membership, club=club, role=role, user=user, end_date=None)
url = reverse(route, kwargs={"club_id": club.id})
client.force_login(user)
res = client.get(url)
assert res.status_code == 200
res = client.post(url, data={"name": "foo"})
assertRedirects(res, reverse("club:club_roles", kwargs={"club_id": club.id}))
new_role = club.roles.last()
assert new_role.name == "foo"
assert new_role.is_presidency == is_presidency
assert new_role.is_board == is_board
class TestClubRoleUpdate(TestCase):
@classmethod
def setUpTestData(cls):
cls.club = make_club()
cls.roles = list(cls.club.roles.all())
cls.user = subscriber_user.make()
baker.make(
Membership, club=cls.club, role=cls.roles[0], user=cls.user, end_date=None
)
cls.url = reverse("club:club_roles", kwargs={"club_id": cls.club.id})
cls.redirect_url = reverse("club:club_members", kwargs={"club_id": cls.club.id})
def setUp(self):
self.payload = {
"roles-TOTAL_FORMS": 3,
"roles-INITIAL_FORMS": 3,
"roles-MIN_NUM_FORMS": 0,
"roles-MAX_NUM_FORMS": 1000,
"roles-0-ORDER": 1,
"roles-0-id": self.roles[0].id,
"roles-0-club": self.club.id,
"roles-0-is_presidency": True,
"roles-0-is_board": True,
"roles-0-name": self.roles[0].name,
"roles-0-description": self.roles[0].description,
"roles-0-is_active": True,
"roles-1-ORDER": 2,
"roles-1-id": self.roles[1].id,
"roles-1-club": self.club.id,
"roles-1-is_presidency": False,
"roles-1-is_board": True,
"roles-1-name": self.roles[1].name,
"roles-1-description": self.roles[1].description,
"roles-1-is_active": True,
"roles-2-ORDER": 3,
"roles-2-id": self.roles[2].id,
"roles-2-club": self.club.id,
"roles-2-is_presidency": False,
"roles-2-is_board": False,
"roles-2-name": self.roles[2].name,
"roles-2-description": self.roles[2].description,
"roles-2-is_active": True,
}
def test_view_ok(self):
"""Basic test to check that the view works."""
self.client.force_login(self.user)
res = self.client.get(self.url)
assert res.status_code == 200
self.payload["roles-2-name"] = "foo"
res = self.client.post(self.url, data=self.payload)
assertRedirects(res, self.redirect_url)
self.roles[2].refresh_from_db()
assert self.roles[2].name == "foo"
def test_incoherent_order(self):
"""Test that placing a member role over a board role fails."""
self.payload["roles-0-ORDER"] = 4
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
assert not formset.is_valid()
assert formset.errors == [
{
"__all__": [
f"Le rôle {self.roles[0].name} ne peut pas "
"être placé en-dessous d'un rôle de membre.",
f"Le rôle {self.roles[0].name} ne peut pas être placé "
"en-dessous d'un rôle qui n'est pas de la présidence.",
]
},
{},
{},
]
def test_change_order_ok(self):
"""Test that changing order the intended way works"""
self.payload["roles-1-ORDER"] = 3
self.payload["roles-1-is_board"] = False
self.payload["roles-2-ORDER"] = 2
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
assert formset.is_valid()
formset.save()
assert list(self.club.roles.order_by("order")) == [
self.roles[0],
self.roles[2],
self.roles[1],
]
self.roles[1].refresh_from_db()
assert not self.roles[1].is_board
def test_non_board_presidency_is_forbidden(self):
"""Test that a role cannot be in the presidency without being in the board."""
self.payload["roles-0-is_board"] = False
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
assert not formset.is_valid()
assert formset.errors == [
{
"__all__": [
"Un rôle ne peut pas appartenir à la présidence sans être dans le bureau",
]
},
{},
{},
]
def test_president_moves_itself_out_of_the_presidency(self):
"""Test that if the user moves its own role out of the presidency,
then it's redirected to another page and loses access to the update page."""
self.payload["roles-0-is_presidency"] = False
self.client.force_login(self.user)
res = self.client.post(self.url, data=self.payload)
assertRedirects(res, self.redirect_url)
# When the user clicked that button, it still had the right to update roles,
# so the modification should be applied
self.roles[0].refresh_from_db()
assert self.roles[0].is_presidency is False
res = self.client.get(self.url)
assert res.status_code == 403
+4 -20
View File
@@ -4,7 +4,7 @@ from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from club.models import Club, ClubRole, Membership
from club.models import Club, Membership
from core.baker_recipes import subscriber_user
@@ -12,22 +12,11 @@ from core.baker_recipes import subscriber_user
def test_club_board_member_cannot_edit_club_properties(client: Client):
user = subscriber_user.make()
club = baker.make(Club, name="old name", is_active=True, address="old address")
baker.make(
Membership,
club=club,
user=user,
role=baker.make(ClubRole, club=club, is_board=True),
)
baker.make(Membership, club=club, user=user, role=7)
client.force_login(user)
res = client.post(
reverse("club:club_edit", kwargs={"club_id": club.id}),
{
"name": "new name",
"is_active": False,
"address": "new address",
"link-TOTAL_FORMS": 0,
"link-INITIAL_FORMS": 0,
},
{"name": "new name", "is_active": False, "address": "new address"},
)
# The request should success,
# but admin-only fields shouldn't be taken into account
@@ -43,12 +32,7 @@ def test_edit_club_page_doesnt_crash(client: Client):
"""crash test for club:club_edit"""
club = baker.make(Club)
user = subscriber_user.make()
baker.make(
Membership,
club=club,
user=user,
role=baker.make(ClubRole, club=club, is_board=True),
)
baker.make(Membership, club=club, user=user, role=3)
client.force_login(user)
res = client.get(reverse("club:club_edit", kwargs={"club_id": club.id}))
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.utils import timezone
from django.utils.translation import gettext as _
from model_bakery import baker
from club.forms import MailingForm
from club.models import Club, ClubRole, Mailing, Membership
from club.models import Club, Mailing, Membership
from core.models import User
@@ -26,7 +25,7 @@ class TestMailingForm(TestCase):
user=cls.rbatsbak,
club=cls.club,
start_date=timezone.now(),
role=baker.make(ClubRole, club=cls.club, is_board=True),
role=settings.SITH_CLUB_ROLES_ID["Board member"],
).save()
def test_mailing_list_add_no_moderation(self):
+57 -171
View File
@@ -1,20 +1,20 @@
import itertools
from collections.abc import Callable
from datetime import timedelta
import pytest
from bs4 import BeautifulSoup
from django.conf import settings
from django.contrib.auth.models import Permission
from django.core.cache import cache
from django.db.models import Max
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import localdate, localtime, now
from model_bakery import baker, seq
from model_bakery import baker
from pytest_django.asserts import assertRedirects
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 core.baker_recipes import subscriber_user
from core.models import AnonymousUser, User
@@ -75,22 +75,17 @@ class TestMembershipQuerySet(TestClub):
def test_update_change_club_groups(self):
"""Test that `update` set the user groups accordingly."""
user = baker.make(User)
board_role, member_role = baker.make(
ClubRole, is_board=iter([True, False]), _quantity=2, _bulk_create=True
)
membership = baker.make(
Membership, end_date=None, user=user, role=board_role, club=board_role.club
)
membership = baker.make(Membership, end_date=None, user=user, role=5)
members_group = membership.club.members_group
board_group = membership.club.board_group
assert user.groups.contains(members_group)
assert user.groups.contains(board_group)
user.memberships.update(role=member_role) # from board to simple member
user.memberships.update(role=1) # from board to simple member
assert user.groups.contains(members_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(board_group)
@@ -101,17 +96,7 @@ class TestMembershipQuerySet(TestClub):
def test_delete_remove_from_groups(self):
"""Test that `delete` removes from club groups"""
user = baker.make(User)
club = baker.make(Club)
roles = baker.make(
ClubRole,
is_board=iter([False, True]),
club=club,
_quantity=2,
_bulk_create=True,
)
memberships = baker.make(
Membership, club=club, role=iter(roles), user=user, _quantity=2
)
memberships = baker.make(Membership, role=iter([1, 5]), user=user, _quantity=2)
club_groups = {
memberships[0].club.members_group,
memberships[1].club.members_group,
@@ -127,20 +112,13 @@ class TestMembershipEditableBy(TestCase):
def setUpTestData(cls):
Membership.objects.all().delete()
cls.club_a, cls.club_b = baker.make(Club, _quantity=2)
roles = baker.make(
ClubRole,
is_presidency=itertools.cycle([True, False, False, False]),
is_board=itertools.cycle([True, True, True, False]),
order=itertools.cycle(range(4)),
club=iter(
[*itertools.repeat(cls.club_a, 4), *itertools.repeat(cls.club_b, 4)]
),
_quantity=8,
_bulk_create=True,
)
cls.memberships = [
*baker.make(Membership, role=iter(roles[:4]), club=cls.club_a, _quantity=4),
*baker.make(Membership, role=iter(roles[4:]), club=cls.club_b, _quantity=4),
*baker.make(
Membership, role=iter([7, 3, 3, 1]), club=cls.club_a, _quantity=4
),
*baker.make(
Membership, role=iter([7, 3, 3, 1]), club=cls.club_b, _quantity=4
),
]
def test_admin_user(self):
@@ -162,7 +140,7 @@ class TestMembershipEditableBy(TestCase):
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."""
membership = user.memberships.ongoing().filter(club=self.club).first()
assert membership is not None
@@ -211,27 +189,21 @@ class TestMembership(TestClub):
"Marquer comme ancien",
]
rows = table.find("tbody").find_all("tr")
memberships = (
self.club.members.ongoing()
.order_by("role__order")
.select_related("user", "role")
)
user_role = ClubRole.objects.get(members__user=self.simple_board_member)
for row, membership in zip(rows, memberships, strict=False):
memberships = self.club.members.ongoing().order_by("-role")
for row, membership in zip(
rows, memberships.select_related("user"), strict=False
):
user = membership.user
user_url = reverse("core:user_profile", args=[user.id])
cols = row.find_all("td")
user_link = cols[0].find("a")
assert user_link.attrs["href"] == user_url
assert user_link.text == user.get_display_name()
assert cols[1].text == membership.role.name
assert cols[1].text == settings.SITH_CLUB_ROLES[membership.role]
assert cols[2].text == membership.description
assert cols[3].text == str(membership.start_date)
if (
membership.role.order > user_role.order
or membership.user_id == self.simple_board_member.id
):
if membership.role < 3 or membership.user_id == self.simple_board_member.id:
# 3 is the role of simple_board_member
form_input = cols[4].find("input")
expected_attrs = {
@@ -247,15 +219,14 @@ class TestMembership(TestClub):
"""Test that root users can add members to clubs"""
self.client.force_login(self.root)
response = self.client.post(
self.new_members_url,
{"user": self.subscriber.id, "role": self.board_role.id},
self.new_members_url, {"user": self.subscriber.id, "role": 3}
)
assert response.status_code == 200
assert response.headers.get("HX-Redirect", "") == reverse(
"club:club_members", kwargs={"club_id": self.club.id}
)
self.subscriber.refresh_from_db()
self.assert_membership_started_today(self.subscriber, role=self.board_role)
self.assert_membership_started_today(self.subscriber, role=3)
def test_add_unauthorized_members(self):
"""Test that users who are not currently subscribed
@@ -263,7 +234,7 @@ class TestMembership(TestClub):
"""
for user in self.public, self.old_subscriber:
form = ClubAddMemberForm(
data={"user": user.id, "role": self.member_role},
data={"user": user.id, "role": 1},
request_user=self.root,
club=self.club,
)
@@ -284,7 +255,7 @@ class TestMembership(TestClub):
nb_memberships = self.simple_board_member.memberships.count()
self.client.post(
self.members_url,
{"users": self.simple_board_member.id, "role": self.member_role},
{"users": self.simple_board_member.id, "role": current_membership.role + 1},
)
self.simple_board_member.refresh_from_db()
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"]
for members in [max_id + 1], [max_id + 1, self.subscriber.id]:
form = ClubAddMemberForm(
data={"user": members, "role": self.member_role},
data={"user": members, "role": 1},
request_user=self.root,
club=self.club,
)
@@ -319,13 +290,12 @@ class TestMembership(TestClub):
def test_president_add_members(self):
"""Test that the president of the club can add members."""
president = self.club.members.get(role=self.president_role).user
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": self.president_role.id},
self.new_members_url, {"user": self.subscriber.id, "role": 9}
)
assert response.status_code == 200
assert response.headers.get("HX-Redirect", "") == reverse(
@@ -335,17 +305,14 @@ class TestMembership(TestClub):
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=self.president_role)
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.
"""
user_role = self.simple_board_member.memberships.first().role
other_role = baker.make(ClubRole, club=user_role.club, is_board=True)
other_role.above(user_role)
form = ClubAddMemberForm(
data={"user": self.subscriber.id, "role": other_role.id},
data={"user": self.subscriber.id, "role": 10},
request_user=self.simple_board_member,
club=self.club,
)
@@ -353,10 +320,7 @@ class TestMembership(TestClub):
assert not form.is_valid()
assert form.errors == {
"role": [
"Sélectionnez un choix valide. "
"Ce choix ne fait pas partie de ceux disponibles."
]
"role": ["Sélectionnez un choix valide. 10 n\u2019en fait pas partie."]
}
self.club.refresh_from_db()
assert nb_memberships == self.club.members.count()
@@ -372,9 +336,8 @@ class TestMembership(TestClub):
assert form.errors == {"role": ["Ce champ est obligatoire."]}
def test_add_member_already_there(self):
role = ClubRole.objects.get(members__user=self.simple_board_member)
form = ClubAddMemberForm(
data={"user": self.simple_board_member, "role": role.id},
data={"user": self.simple_board_member, "role": 3},
request_user=self.root,
club=self.club,
)
@@ -385,27 +348,22 @@ class TestMembership(TestClub):
def test_add_other_member_forbidden(self):
non_member = subscriber_user.make()
simple_member = baker.make(
Membership, club=self.club, role=self.member_role
).user
simple_member = baker.make(Membership, club=self.club, role=1).user
for user in non_member, simple_member:
form = ClubAddMemberForm(
data={"user": subscriber_user.make(), "role": self.member_role.id},
data={"user": subscriber_user.make(), "role": 1},
request_user=user,
club=self.club,
)
assert not form.is_valid()
assert form.errors == {
"role": [
"Sélectionnez un choix valide. "
"Ce choix ne fait pas partie de ceux disponibles."
]
"role": ["Sélectionnez un choix valide. 1 n\u2019en fait pas partie."]
}
def test_simple_members_dont_see_form_anymore(self):
"""Test that simple club members don't see the form to add members"""
user = subscriber_user.make()
baker.make(Membership, club=self.club, user=user, role=self.member_role)
baker.make(Membership, club=self.club, user=user, role=1)
self.client.force_login(user)
res = self.client.get(self.members_url)
assert res.status_code == 200
@@ -424,10 +382,9 @@ class TestMembership(TestClub):
"""Test that board members of the club can end memberships
of users with lower roles.
"""
# reminder : simple_board_member has role 3
self.client.force_login(self.simple_board_member)
role = baker.make(ClubRole, club=self.club, is_board=True)
role.below(self.board_role)
membership = baker.make(Membership, club=self.club, role=role)
membership = baker.make(Membership, club=self.club, role=2, end_date=None)
response = self.client.post(self.members_url, {"members_old": [membership.id]})
self.assertRedirects(response, self.members_url)
self.club.refresh_from_db()
@@ -437,9 +394,7 @@ class TestMembership(TestClub):
"""Test that board members of the club cannot end memberships
of users with higher roles.
"""
membership = self.president.memberships.filter(
club=self.club, end_date=None
).first()
membership = self.president.memberships.filter(club=self.club).first()
self.client.force_login(self.simple_board_member)
self.client.post(self.members_url, {"members_old": [membership.id]})
self.club.refresh_from_db()
@@ -481,9 +436,7 @@ class TestMembership(TestClub):
def test_remove_from_club_group(self):
"""Test that when a membership ends, the user is removed from club groups."""
user = baker.make(User)
baker.make(
Membership, user=user, club=self.club, end_date=None, role=self.board_role
)
baker.make(Membership, user=user, club=self.club, end_date=None, role=3)
assert user.groups.contains(self.club.members_group)
assert user.groups.contains(self.club.board_group)
user.memberships.update(end_date=localdate())
@@ -494,20 +447,18 @@ class TestMembership(TestClub):
"""Test that when a membership begins, the user is added to the club group."""
assert not self.subscriber.groups.contains(self.club.members_group)
assert not self.subscriber.groups.contains(self.club.board_group)
baker.make(
Membership, club=self.club, user=self.subscriber, role=self.board_role
)
baker.make(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.board_group)
def test_change_position_in_club(self):
"""Test that when moving from board to members, club group change"""
membership = baker.make(
Membership, club=self.club, user=self.subscriber, role=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.board_group)
membership.role = self.member_role
membership.role = 1
membership.save()
assert self.subscriber.groups.contains(self.club.members_group)
assert not self.subscriber.groups.contains(self.club.board_group)
@@ -520,11 +471,7 @@ class TestMembership(TestClub):
# make sli a board member
self.sli.memberships.all().delete()
Membership(
club=self.ae,
user=self.sli,
role=baker.make(ClubRole, club=self.ae, is_board=True),
).save()
Membership(club=self.ae, user=self.sli, role=3).save()
assert self.club.is_owned_by(self.sli)
def test_change_club_name(self):
@@ -550,7 +497,7 @@ class TestMembership(TestClub):
@pytest.mark.django_db
def test_membership_set_old(client: Client):
membership = baker.make(Membership, end_date=None, user=subscriber_user.make())
membership = baker.make(Membership, end_date=None, user=(subscriber_user.make()))
client.force_login(membership.user)
response = client.post(
reverse("club:membership_set_old", kwargs={"membership_id": membership.id})
@@ -577,50 +524,6 @@ def test_membership_delete(client: Client):
assert not Membership.objects.filter(id=membership.id).exists()
@pytest.mark.django_db
class TestAddMemberForm(TestCase):
@classmethod
def setUpTestData(cls):
cls.club = baker.make(Club)
cls.roles = baker.make(
ClubRole,
club=cls.club,
is_board=iter([True, True, True, True, False, False]),
is_presidency=iter([True, True, False, False, False, False]),
order=seq(0),
_quantity=6,
_bulk_create=True,
)
cls.roles[-1].is_active = False
cls.roles[-1].save()
def test_admin(self):
"""Test that admin users can give any active role."""
user = baker.make(
User, user_permissions=[Permission.objects.get(codename="add_membership")]
)
form = ClubAddMemberForm(request_user=user, club=self.club)
assert list(form.fields["role"].queryset) == self.roles[:-1]
def test_president(self):
"""Test that someone with a presidency role can give any active role."""
user = baker.make(Membership, club=self.club, role=self.roles[0]).user
form = ClubAddMemberForm(request_user=user, club=self.club)
assert list(form.fields["role"].queryset) == self.roles[:-1]
def test_board_member(self):
"""Test that someone with a board role can give lower active role."""
user = baker.make(Membership, club=self.club, role=self.roles[2]).user
form = ClubAddMemberForm(request_user=user, club=self.club)
assert list(form.fields["role"].queryset) == self.roles[3:-1]
def test_simple_member(self):
"""Test that someone with a non-board role cannot give roles."""
user = baker.make(Membership, club=self.club, role=self.roles[4]).user
form = ClubAddMemberForm(request_user=user, club=self.club)
assert list(form.fields["role"].queryset) == []
@pytest.mark.django_db
class TestJoinClub:
@pytest.fixture(autouse=True)
@@ -628,64 +531,55 @@ class TestJoinClub:
cache.clear()
@pytest.mark.parametrize(
("user_factory", "board_role", "errors"),
("user_factory", "role", "errors"),
[
(
subscriber_user.make,
True,
2,
{
"role": [
"Sélectionnez un choix valide. "
"Ce choix ne fait pas partie de ceux disponibles."
"Sélectionnez un choix valide. 2 n\u2019en fait pas partie."
]
},
),
(
lambda: baker.make(User),
False,
1,
{"__all__": ["Vous devez être cotisant pour faire partie d'un club"]},
),
],
)
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)
user = user_factory()
role = baker.make(ClubRole, club=club, is_board=board_role)
form = JoinClubForm(club=club, request_user=user, data={"role": role.id})
form = JoinClubForm(club=club, request_user=user, data={"role": role})
assert not form.is_valid()
assert form.errors == errors
def test_user_already_in_club(self):
club = baker.make(Club)
user = subscriber_user.make()
role = baker.make(ClubRole, is_board=False)
baker.make(Membership, user=user, club=role.club)
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
baker.make(Membership, user=user, club=club)
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
assert not form.is_valid()
assert form.errors == {"__all__": ["Vous êtes déjà membre de ce club."]}
def test_ok(self):
club = baker.make(Club)
user = subscriber_user.make()
role = baker.make(ClubRole, is_board=False)
form = JoinClubForm(club=role.club, request_user=user, data={"role": role.id})
form = JoinClubForm(club=club, request_user=user, data={"role": 1})
assert form.is_valid()
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):
@classmethod
def setUpTestData(cls):
club = baker.make(Club)
roles = baker.make(
ClubRole,
club=club,
is_board=itertools.cycle([True, True, False]),
order=seq(0),
_quantity=10,
_bulk_create=True,
)
roles = [1, 1, 1, 2, 2, 4, 4, 5, 7, 9, 10]
cls.memberships = baker.make(
Membership,
role=iter(roles),
@@ -710,11 +604,3 @@ class TestOldMembersView(TestCase):
self.client.force_login(baker.make(User))
res = self.client.get(self.url)
assert res.status_code == 403
def test_context_data(self):
# mark a membership as not ended, to make sure it is excluded from the result
self.memberships[0].end_date = None
self.memberships[0].save()
self.client.force_login(subscriber_user.make())
res = self.client.get(self.url)
assert list(res.context_data.get("old_members")) == self.memberships[1:]
+4 -9
View File
@@ -5,7 +5,7 @@ from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects
from club.models import Club, ClubRole, Membership
from club.models import Club, Membership
from core.baker_recipes import subscriber_user
from core.markdown import markdown
from core.models import PageRev, User
@@ -21,7 +21,7 @@ def test_page_display_on_club_main_page(client: Client):
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(id="club-page").find(class_="markdown")
detail_html = soup.find(id="club_detail").find(class_="markdown")
assertHTMLEqual(detail_html.decode_contents(), markdown(content))
@@ -34,7 +34,7 @@ def test_club_main_page_without_content(client: Client):
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
detail_html = soup.find(id="club-page")
detail_html = soup.find(id="club_detail")
assert detail_html.find_all("markdown") == []
@@ -59,12 +59,7 @@ def test_page_revision(client: Client):
def test_edit_page(client: Client):
club = baker.make(Club)
user = subscriber_user.make()
baker.make(
Membership,
user=user,
club=club,
role=baker.make(ClubRole, club=club, is_board=True),
)
baker.make(Membership, user=user, club=club, role=3)
client.force_login(user)
url = reverse("club:club_edit_page", kwargs={"club_id": club.id})
content = "# foo\nLorem ipsum dolor sit amet"
+2 -5
View File
@@ -6,7 +6,7 @@ from django.utils.timezone import localdate
from model_bakery import baker
from model_bakery.recipe import Recipe
from club.models import Club, ClubRole, Membership
from club.models import Club, Membership
from club.schemas import UserMembershipSchema
from core.baker_recipes import subscriber_user
from core.models import Page
@@ -19,10 +19,7 @@ class TestFetchClub(TestCase):
pages = baker.make(Page, _quantity=3, _bulk_create=True)
clubs = baker.make(Club, page=iter(pages), _quantity=3, _bulk_create=True)
recipe = Recipe(
Membership,
user=cls.user,
start_date=localdate() - timedelta(days=2),
role=baker.make(ClubRole),
Membership, user=cls.user, start_date=localdate() - timedelta(days=2)
)
cls.members = Membership.objects.bulk_create(
[
-20
View File
@@ -35,10 +35,6 @@ from club.views import (
ClubPageEditView,
ClubPageHistView,
ClubRevView,
ClubRoleBoardCreateView,
ClubRoleMemberCreateView,
ClubRolePresidencyCreateView,
ClubRoleUpdateView,
ClubSellingCSVView,
ClubSellingView,
ClubToolsView,
@@ -75,22 +71,6 @@ urlpatterns = [
ClubOldMembersView.as_view(),
name="club_old_members",
),
path("<int:club_id>/role/", ClubRoleUpdateView.as_view(), name="club_roles"),
path(
"<int:club_id>/role/new/president/",
ClubRolePresidencyCreateView.as_view(),
name="new_role_president",
),
path(
"<int:club_id>/role/new/board/",
ClubRoleBoardCreateView.as_view(),
name="new_role_board",
),
path(
"<int:club_id>/role/new/member/",
ClubRoleMemberCreateView.as_view(),
name="new_role_member",
),
path("<int:club_id>/sellings/", ClubSellingView.as_view(), name="club_sellings"),
path(
"<int:club_id>/sellings/csv/", ClubSellingCSVView.as_view(), name="sellings_csv"
+21 -191
View File
@@ -28,16 +28,12 @@ import csv
import itertools
from typing import TYPE_CHECKING, Any
from django.contrib.auth.mixins import (
LoginRequiredMixin,
PermissionRequiredMixin,
UserPassesTestMixin,
)
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
from django.core.paginator import InvalidPage, Paginator
from django.db.models import F, Prefetch, Q, Sum
from django.db.models.functions import Length
from django.db.models import F, Q, Sum
from django.http import Http404, StreamingHttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
@@ -48,29 +44,18 @@ from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, View
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import CreateView, DeleteView, FormMixin, UpdateView
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.forms import (
ClubAddMemberForm,
ClubAdminEditForm,
ClubEditForm,
ClubOldMemberForm,
ClubRoleCreateForm,
ClubRoleFormSet,
ClubSearchForm,
JoinClubForm,
MailingForm,
SellingsForm,
)
from club.models import (
Club,
ClubLink,
ClubRole,
LinkType,
Mailing,
MailingSubscription,
Membership,
)
from club.models import Club, Mailing, MailingSubscription, Membership
from com.models import Poster
from com.views import (
PosterCreateBaseView,
@@ -81,12 +66,7 @@ from com.views import (
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
from core.models import Page, PageRev
from core.views import BasePageEditView, DetailFormView, UseFragmentsMixin
from core.views.mixins import (
AllowFragment,
FragmentMixin,
FragmentRenderer,
TabedViewMixin,
)
from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin
from counter.models import Selling
if TYPE_CHECKING:
@@ -200,43 +180,15 @@ class ClubTabsMixin(TabedViewMixin):
return tab_list
class ClubListView(AllowFragment, FormMixin, ListView):
"""List the clubs of the AE, with a form to perform basic search.
Notes:
This view is fully public, because we want to advertise as much as possible
the cultural life of the AE.
In accordance with that matter, searching and listing the clubs is done
entirely server-side (no AlpineJS involved) ;
this is done this way in order to be sure the page is the most accessible
and SEO-friendly possible, even if it makes the UX slightly less smooth.
"""
class ClubListView(ListView):
"""List the Clubs."""
model = Club
template_name = "club/club_list.jinja"
form_class = ClubSearchForm
queryset = Club.objects.prefetch_related(
Prefetch("links", queryset=ClubLink.objects.select_related("link_type"))
).order_by("name")
paginate_by = 20
def get_form_kwargs(self):
res = super().get_form_kwargs()
# if request.GET is empty, the form will interpret club_status as None,
# even though we want it to be initially True,
# so we force a defaut True value.
res["data"] = {"club_status": True} | self.request.GET.dict()
return res
def get_queryset(self):
form: ClubSearchForm = self.get_form()
qs = self.queryset
if not form.is_valid():
return qs.none()
if name := form.cleaned_data.get("name"):
qs = qs.filter(name__icontains=name)
if (is_active := form.cleaned_data.get("club_status")) is not None:
qs = qs.filter(is_active=is_active)
return qs
queryset = (
Club.objects.filter(parent=None).order_by("name").prefetch_related("children")
)
context_object_name = "club_list"
class ClubView(ClubTabsMixin, DetailView):
@@ -255,7 +207,6 @@ class ClubView(ClubTabsMixin, DetailView):
.values_list("content", flat=True)
.first()
)
kwargs["links"] = list(self.object.links.select_related("link_type").all())
return kwargs
@@ -367,7 +318,7 @@ class ClubMembersView(
membership = self.object.get_membership_for(self.request.user)
if (
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")
):
# Simple club members won't see the form anymore.
@@ -392,8 +343,8 @@ class ClubMembersView(
kwargs["members"] = list(
self.object.members.ongoing()
.annotate(is_editable=Q(id__in=editable))
.order_by("role__order")
.select_related("user", "role")
.order_by("-role")
.select_related("user")
)
kwargs["can_end_membership"] = len(editable) > 0
return kwargs
@@ -421,125 +372,12 @@ class ClubOldMembersView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
return super().get_context_data(**kwargs) | {
"old_members": (
self.object.members.exclude(end_date=None)
.order_by("role__order", "description", "-end_date")
.select_related("user", "role")
.order_by("-role", "description", "-end_date")
.select_related("user")
)
}
class ClubRoleUpdateView(
ClubTabsMixin, UserPassesTestMixin, SuccessMessageMixin, UpdateView
):
form_class = ClubRoleFormSet
model = Club
template_name = "club/club_roles.jinja"
pk_url_kwarg = "club_id"
current_tab = "members"
success_message = _("Club roles updated")
@cached_property
def club(self) -> Club:
return self.get_object()
def test_func(self):
return self.club.can_roles_be_edited_by(self.request.user)
def get_form_kwargs(self):
return super().get_form_kwargs() | {"form_kwargs": {"label_suffix": ""}}
def get_success_url(self):
return reverse("club:club_members", kwargs={"club_id": self.club.id})
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"user_role": ClubRole.objects.filter(
club=self.club,
members__user=self.request.user,
members__end_date=None,
)
.values_list("id", flat=True)
.first()
}
class ClubRoleBaseCreateView(UserPassesTestMixin, SuccessMessageMixin, CreateView):
"""View to create a new Club Role, using [][club.forms.ClubRoleCreateForm].
This view isn't meant to be called directly, but rather subclassed for each
type of role that can exist :
- `[ClubRolePresidencyCreateView][club.views.ClubRolePresidencyCreateView]`
to create a presidency role
- `[ClubRoleBoardCreateView][club.views.ClubRoleBoardCreateView]`
to create a board role
- `[ClubRoleMemberCreateView][club.views.ClubRoleMemberCreateView]`
to create a member role
Each subclass have to override the following variables :
- `is_presidency` and `is_board`, indicating what type of role
the view creates.
- `role_description`, which is the title of the page, indication
the user what kind of role is being created.
This way, we are making sure the correct type of role will
be created, without bothering the user with the implementation details.
"""
form_class = ClubRoleCreateForm
model = ClubRole
template_name = "core/create.jinja"
success_message = _("Role %(name)s created")
role_description = ""
is_presidency: bool
is_board: bool
@cached_property
def club(self):
return get_object_or_404(Club, id=self.kwargs["club_id"])
def test_func(self):
return self.request.user.is_authenticated and (
self.request.user.has_perm("club.add_clubrole")
or self.club.members.filter(
user=self.request.user, role__is_presidency=True
).exists()
)
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"club": self.club,
"is_presidency": self.is_presidency,
"is_board": self.is_board,
}
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"object_name": self.role_description
}
def get_success_url(self):
return reverse("club:club_roles", kwargs={"club_id": self.club.id})
class ClubRolePresidencyCreateView(ClubRoleBaseCreateView):
is_presidency = True
is_board = True
role_description = _("club role \u2013 presidency")
class ClubRoleBoardCreateView(ClubRoleBaseCreateView):
is_presidency = False
is_board = True
role_description = _("club role \u2013 board")
class ClubRoleMemberCreateView(ClubRoleBaseCreateView):
is_presidency = False
is_board = False
role_description = _("club role \u2013 member")
class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
"""Sales of a club."""
@@ -696,11 +534,6 @@ class ClubEditView(ClubTabsMixin, CanEditMixin, UpdateView):
return ClubAdminEditForm
return ClubEditForm
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"link_types": list(LinkType.objects.order_by(Length("url_base").desc()))
}
class ClubCreateView(PermissionRequiredMixin, CreateView):
"""Create a club (for the Sith admin)."""
@@ -711,11 +544,6 @@ class ClubCreateView(PermissionRequiredMixin, CreateView):
template_name = "core/create.jinja"
permission_required = "club.add_club"
def form_valid(self, form):
res = super().form_valid(form)
self.object.create_default_roles()
return res
class MembershipSetOldView(CanEditMixin, SingleObjectMixin, View):
"""Set a membership as being old."""
@@ -896,7 +724,9 @@ class MailingAutoGenerationView(View):
def get(self, request, *args, **kwargs):
club = self.mailing.club
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():
MailingSubscription(user=member.user, mailing=self.mailing).save()
return redirect("club:mailing", club_id=club.id)
@@ -1,3 +1,4 @@
import { exportToHtml } from "#core:utils/globals.ts";
import { newsDeleteNews, newsFetchNewsDates, newsPublishNews } from "#openapi";
// This will be used in jinja templates,
@@ -12,8 +13,7 @@ const AlertState = {
// biome-ignore lint/style/useNamingConvention: this feels more like an enum
DISPLAYED: 4, // When published at page generation
};
// biome-ignore lint/style/useNamingConvention: it's an enum, PascalCase is better
Object.assign(window, { AlertState });
exportToHtml("AlertState", AlertState);
document.addEventListener("alpine:init", () => {
Alpine.data("moderationAlert", (newsId: number) => ({
+31 -27
View File
@@ -3,7 +3,6 @@
#news {
display: flex;
gap: 1em;
@media (max-width: 800px) {
flex-direction: column;
@@ -16,13 +15,9 @@
#right_column {
flex: 20%;
margin: 3.2px;
display: inline-block;
vertical-align: top;
@media screen and (min-width: 800px) {
max-width: 20%;
min-width: 200px;
}
}
#left_column {
@@ -31,14 +26,12 @@
}
h3 {
--box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 3px 7px 2px;
background: lighten($second-color, 5%);
box-shadow: var(--box-shadow);
padding: .75rem;
background: $second-color;
box-shadow: $shadow-color 1px 1px 1px;
padding: 0.4em;
margin: 0 0 0.5em 0;
text-transform: uppercase;
font-size: 17px;
border-radius: 10px;
&:not(:first-of-type) {
margin: 2em 0 1em 0;
@@ -46,11 +39,12 @@
.feed {
float: right;
color: #e25512;
color: #f26522;
}
}
@media screen and (max-width: 800px) {
@media screen and (max-width: $small-devices) {
#left_column,
#right_column {
flex: 100%;
@@ -63,7 +57,6 @@
max-height: 600px;
overflow-y: scroll;
overflow-x: clip;
margin-top: 1em;
#load-more-news-button {
text-align: center;
@@ -80,14 +73,18 @@
display: block;
width: 100%;
background: white;
font-size: 70%;
margin-bottom: 1em;
font-size: 85%;
h3 {
margin-bottom: 0;
}
#links_content {
overflow: auto;
box-shadow: $shadow-color 1px 1px 1px;
min-height: 20em;
padding: 1em;
padding-bottom: 1em;
h4 {
margin-left: 5px;
@@ -100,10 +97,23 @@
li {
margin: 10px;
.fa-facebook {
color: $faceblue;
}
.fa-discord {
color: $discordblurple;
}
.fa-square-instagram::before {
background: $instagradient;
background-clip: text;
-webkit-text-fill-color: transparent;
}
i {
width: 25px;
text-align: center;
margin-right: .5rem;
}
}
}
@@ -111,15 +121,12 @@
}
#birthdays_content {
box-shadow: $shadow-color 1px 1px 1px;
padding: 1em;
ul.birthdays_year {
margin: 0;
list-style-type: none;
font-weight: bold;
> li {
>li {
padding: 0.5em;
&:nth-child(even) {
@@ -128,7 +135,8 @@
}
ul {
margin: .5em 0 0 1em;
margin: 0;
margin-left: 1em;
list-style-type: square;
list-style-position: inside;
font-weight: normal;
@@ -142,13 +150,9 @@
/* EVENTS TODAY AND NEXT FEW DAYS */
.news_events_group {
box-shadow: $shadow-color 1px 1px 1px;
margin-left: 0;
margin-left: 1em;
margin-bottom: 0.5em;
@media screen and (max-width: $small-devices) {
margin-left: 3px;
}
.news_events_group_date {
display: table-cell;
padding: 0.6em;
+1 -1
View File
@@ -23,7 +23,7 @@
<a target="#" href="{{ url("com:news_feed") }}"><i class="fa fa-rss feed"></i></a>
</h3>
{% if user.is_authenticated and (user.is_com_admin or user.memberships.board().ongoing().exists()) %}
<a class="btn btn-blue" href="{{ url("com:news_new") }}">
<a class="btn btn-blue margin-bottom" href="{{ url("com:news_new") }}">
<i class="fa fa-plus"></i>
{% trans %}Create news{% endtrans %}
</a>
+1 -1
View File
@@ -4,7 +4,7 @@
<title>{% trans %}Slideshow{% endtrans %}</title>
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
<script type="module" src="{{ static('bundled/base-bundle-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
<script type="module" src="{{ static('bundled/com/slideshow-index.ts') }}"></script>
</head>
<body x-data="slideshow([
+1 -2
View File
@@ -7,7 +7,7 @@ from model_bakery import baker
from com.models import News, NewsDate
from core.baker_recipes import subscriber_user
from core.models import Group, Notification, SithFile, User
from core.models import Group, Notification, User
@pytest.mark.django_db
@@ -18,7 +18,6 @@ def test_notification_created():
past_news = baker.make(News, is_published=False)
baker.make(NewsDate, news=past_news, start_date=now() - timedelta(days=1))
com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
SithFile.objects.filter(owner__in=com_admin_group.users.all()).delete()
com_admin_group.users.all().delete()
Notification.objects.all().delete()
com_admin = baker.make(User, groups=[com_admin_group])
+2 -3
View File
@@ -28,7 +28,7 @@ from django.utils.translation import gettext as _
from model_bakery import baker
from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club, ClubRole, Membership
from club.models import Club, Membership
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, Group, User
@@ -214,8 +214,7 @@ class TestNewsCreation(TestCase):
def setUpTestData(cls):
cls.club = baker.make(Club)
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=role)
baker.make(Membership, user=cls.user, club=cls.club, role=5)
def setUp(self):
self.client.force_login(self.user)
+3 -2
View File
@@ -244,8 +244,9 @@ class NewsListView(TemplateView):
.filter(
date_of_birth__month=localdate().month,
date_of_birth__day=localdate().day,
role__in=["STUDENT", "FORMER STUDENT"],
is_viewable=True,
)
.filter(role__in=["STUDENT", "FORMER STUDENT"])
.order_by("-date_of_birth"),
key=lambda u: u.date_of_birth.year,
)
@@ -503,7 +504,7 @@ class WeekmailArticleCreateView(CreateView):
self.object = form.instance
form.is_valid() # Valid a first time to populate club field
m = form.instance.club.get_membership_for(request.user)
if m is None or not m.role.is_board:
if m is None or m.role <= settings.SITH_MAXIMUM_FREE_ROLE:
form.add_error(
"club",
ValidationError(
-1
View File
@@ -63,7 +63,6 @@ class UserAdmin(admin.ModelAdmin):
"scrub_pict",
"user_permissions",
"groups",
"whitelisted_users",
)
inlines = (UserBanInline,)
search_fields = ["first_name", "last_name", "username"]
+1 -1
View File
@@ -123,7 +123,7 @@ class GroupController(ControllerBase):
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_group(self, search: Annotated[str, MinLen(1)]):
return Group.objects.filter(name__icontains=search).order_by("name").values()
return Group.objects.filter(name__icontains=search).values()
DepthValue = Annotated[int, Ge(0), Le(10)]
+3 -5
View File
@@ -4,9 +4,9 @@ from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.utils.timezone import localdate, now
from model_bakery import seq
from model_bakery.recipe import Recipe, 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 subscription.models import Subscription
@@ -52,9 +52,7 @@ ae_board_membership = Recipe(
Membership,
start_date=now() - timedelta(days=30),
club_id=settings.SITH_MAIN_CLUB_ID,
role=foreign_key(
Recipe(ClubRole, club_id=settings.SITH_MAIN_CLUB_ID, is_board=True)
),
role=settings.SITH_CLUB_ROLES_ID["Board member"],
)
board_user = Recipe(
+4 -13
View File
@@ -39,16 +39,12 @@ class Command(BaseCommand):
return None
return xapian.version_string()
def _desired_version(self) -> tuple[str, str, str]:
def _desired_version(self) -> str:
with open(
Path(__file__).parent.parent.parent.parent / "pyproject.toml", "rb"
) as f:
pyproject = tomli.load(f)
return (
pyproject["tool"]["xapian"]["version"],
pyproject["tool"]["xapian"]["core-sha256"],
pyproject["tool"]["xapian"]["bindings-sha256"],
)
return pyproject["tool"]["xapian"]["version"]
def handle(self, *args, force: bool, **options):
if not os.environ.get("VIRTUAL_ENV", None):
@@ -57,7 +53,7 @@ class Command(BaseCommand):
)
return
desired, core_checksum, bindings_checksum = self._desired_version()
desired = self._desired_version()
if desired == self._current_version():
if not force:
self.stdout.write(
@@ -69,12 +65,7 @@ class Command(BaseCommand):
f"Installing xapian version {desired} at {os.environ['VIRTUAL_ENV']}"
)
subprocess.run(
[
str(Path(__file__).parent / "install_xapian.sh"),
desired,
core_checksum,
bindings_checksum,
],
[str(Path(__file__).parent / "install_xapian.sh"), desired],
env=dict(os.environ),
check=True,
)
+1 -11
View File
@@ -1,11 +1,7 @@
#!/usr/bin/env bash
# Originates from https://gist.github.com/jorgecarleitao/ab6246c86c936b9c55fd
# first argument of the script is Xapian version (e.g. 1.2.19)
# second argument of the script is core sha256
# second argument of the script is binding sha256
VERSION="$1"
CORE_SHA256="$2"
BINDINGS_SHA256="$3"
# Cleanup env vars for auto discovery mechanism
unset CPATH
@@ -25,15 +21,9 @@ BINDINGS=xapian-bindings-$VERSION
# download
echo "Downloading source..."
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${CORE}.tar.xz" || exit 1
echo "${CORE_SHA256} ${CORE}.tar.xz" | sha256sum -c - || exit 1
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${CORE}.tar.xz"
curl -O "https://oligarchy.co.uk/xapian/$VERSION/${BINDINGS}.tar.xz"
echo "${BINDINGS_SHA256} ${BINDINGS}.tar.xz" | sha256sum -c - || exit 1
# extract
echo "Extracting source..."
tar xf "${CORE}.tar.xz"
+167 -269
View File
@@ -16,7 +16,7 @@
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
@@ -36,19 +36,12 @@ from django.utils import timezone
from django.utils.timezone import localdate
from PIL import Image
from club.models import Club, ClubLink, ClubRole, LinkType, Membership
from club.models import Club, Membership
from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate, Sith, Weekmail
from core.models import BanGroup, Group, Page, PageRev, SithFile, User
from core.utils import resize_image
from counter.models import (
Counter,
Price,
Product,
ProductType,
ReturnableProduct,
StudentCard,
)
from counter.models import Counter, Product, ProductType, ReturnableProduct, StudentCard
from election.models import Candidature, Election, ElectionList, Role
from forum.models import Forum
from pedagogy.models import UE
@@ -69,13 +62,6 @@ class PopulatedGroups(NamedTuple):
campus_admin: Group
class PopulatedClubs(NamedTuple):
ae: Club
troll: Club
pdf: Club
refound: Club
class Command(BaseCommand):
ROOT_PATH: ClassVar[Path] = Path(__file__).parent.parent.parent.parent
SAS_FIXTURE_PATH: ClassVar[Path] = (
@@ -124,19 +110,29 @@ class Command(BaseCommand):
p.save(force_lock=True)
club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create(
name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID
sas = SithFile.objects.create(name="SAS", owner=root)
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"]
)
)
bar_club = Club.objects.create(
id=settings.SITH_PDF_CLUB_ID,
name="PdF",
address="6 Boulevard Anatole France, 90000 Belfort",
)
clubs = self._create_clubs()
self.reset_index("club")
for bar_id, bar_name in settings.SITH_COUNTER_BARS:
Counter(id=bar_id, name=bar_name, club=clubs.pdf, type="BAR").save()
Counter(id=bar_id, name=bar_name, club=bar_club, type="BAR").save()
self.reset_index("counter")
counters = [
Counter(name="Eboutic", club=clubs.ae, type="EBOUTIC"),
Counter(name="AE", club=clubs.ae, type="OFFICE"),
Counter(name="Vidage comptes AE", club=clubs.ae, type="OFFICE"),
Counter(name="Eboutic", club=main_club, type="EBOUTIC"),
Counter(name="AE", club=main_club, type="OFFICE"),
Counter(name="Vidage comptes AE", club=main_club, type="OFFICE"),
]
Counter.objects.bulk_create(counters)
bar_groups = []
@@ -319,55 +315,178 @@ class Command(BaseCommand):
self._create_subscription(tutu)
StudentCard(uid="9A89B82018B0A0", customer=sli.customer).save()
Membership.objects.create(
user=skia, club=clubs.ae, role=clubs.ae.roles.get(name="Respo Info")
# Clubs
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(
user=comunity,
club=clubs.pdf,
club=bar_club,
start_date=localdate(),
role=clubs.pdf.roles.get(name="Membre du bureau"),
role=settings.SITH_CLUB_ROLES_ID["Board member"],
)
Membership.objects.create(
user=sli,
club=clubs.troll,
role=clubs.troll.roles.get(name="Vice-Président⸱e"),
club=troll,
role=9,
description="Padawan Troll",
start_date=localdate() - timedelta(days=17),
)
Membership.objects.create(
user=krophil,
club=clubs.troll,
role=clubs.troll.roles.get(name="Président⸱e"),
club=troll,
role=10,
description="Maitre Troll",
start_date=localdate() - timedelta(days=200),
)
Membership.objects.create(
user=skia,
club=clubs.troll,
role=clubs.troll.roles.get(name="Membre du bureau"),
club=troll,
role=2,
description="Grand Ancien Troll",
start_date=localdate() - timedelta(days=400),
end_date=localdate() - timedelta(days=86),
)
Membership.objects.create(
user=richard,
club=clubs.troll,
role=clubs.troll.roles.get(name="Membre du bureau"),
club=troll,
role=2,
description="",
start_date=localdate() - timedelta(days=200),
end_date=localdate() - timedelta(days=100),
)
self._create_products(groups, clubs)
p = ProductType.objects.create(name="Bières bouteilles")
c = ProductType.objects.create(name="Cotisations")
r = ProductType.objects.create(name="Rechargements")
verre = ProductType.objects.create(name="Verre")
cotis = Product.objects.create(
name="Cotis 1 semestre",
code="1SCOTIZ",
product_type=c,
purchase_price="15",
selling_price="15",
special_selling_price="15",
club=main_club,
)
cotis2 = Product.objects.create(
name="Cotis 2 semestres",
code="2SCOTIZ",
product_type=c,
purchase_price="28",
selling_price="28",
special_selling_price="28",
club=main_club,
)
refill = Product.objects.create(
name="Rechargement 15 €",
code="15REFILL",
product_type=r,
purchase_price="15",
selling_price="15",
special_selling_price="15",
club=main_club,
)
barb = Product.objects.create(
name="Barbar",
code="BARB",
product_type=p,
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=main_club,
limit_age=18,
)
cble = Product.objects.create(
name="Chimay Bleue",
code="CBLE",
product_type=p,
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=main_club,
limit_age=18,
)
cons = Product.objects.create(
name="Consigne Eco-cup",
code="CONS",
product_type=verre,
purchase_price="1",
selling_price="1",
special_selling_price="1",
club=main_club,
)
dcons = Product.objects.create(
name="Déconsigne Eco-cup",
code="DECO",
product_type=verre,
purchase_price="-1",
selling_price="-1",
special_selling_price="-1",
club=main_club,
)
cors = Product.objects.create(
name="Corsendonk",
code="CORS",
product_type=p,
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=main_club,
limit_age=18,
)
carolus = Product.objects.create(
name="Carolus",
code="CARO",
product_type=p,
purchase_price="1.50",
selling_price="1.7",
special_selling_price="1.6",
club=main_club,
limit_age=18,
)
Product.objects.create(
name="remboursement",
code="REMBOURS",
purchase_price="0",
selling_price="0",
special_selling_price="0",
club=refound,
)
groups.subscribers.products.add(
cotis, cotis2, refill, barb, cble, cors, carolus
)
groups.old_subscribers.products.add(cotis, cotis2)
Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE")
mde = Counter.objects.get(name="MDE")
mde.products.add(barb, cble, cons, dcons)
eboutic = Counter.objects.get(name="Eboutic")
eboutic.products.add(barb, cotis, cotis2, refill)
Counter.objects.create(name="Carte AE", club=refound, type="OFFICE")
ReturnableProduct.objects.create(
product=cons, returned_product=dcons, max_return=3
)
# Add barman to counter
Counter.sellers.through.objects.bulk_create(
[
Counter.sellers.through(counter_id=1, user=skia), # MDE
Counter.sellers.through(counter_id=2, user=krophil), # Foyer
Counter.sellers.through(counter_id=2, user=krophil),
Counter.sellers.through(counter=mde, user=skia),
]
)
@@ -381,7 +500,7 @@ class Command(BaseCommand):
end_date="7942-06-12 10:28:45+01",
)
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.vote_groups.add(groups.subscribers)
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
@@ -454,7 +573,7 @@ class Command(BaseCommand):
title="Apero barman",
summary="Viens boire un coup avec les barmans",
content="Glou glou glou glou glou glou glou",
club=clubs.pdf,
club=bar_club,
author=subscriber,
is_published=True,
moderator=skia,
@@ -472,7 +591,7 @@ class Command(BaseCommand):
content=(
"Viens donc t'enjailler avec les autres barmans aux frais du BdF! \\o/"
),
club=clubs.pdf,
club=bar_club,
author=subscriber,
is_published=True,
moderator=skia,
@@ -488,7 +607,7 @@ class Command(BaseCommand):
title="Repas fromager",
summary="Wien manger du l'bon fromeug'",
content="Fô viendre mangey d'la bonne fondue!",
club=clubs.pdf,
club=bar_club,
author=subscriber,
is_published=True,
moderator=skia,
@@ -504,7 +623,7 @@ class Command(BaseCommand):
title="SdF",
summary="Enjoy la fin des finaux!",
content="Viens faire la fête avec tout plein de gens!",
club=clubs.pdf,
club=bar_club,
author=subscriber,
is_published=True,
moderator=skia,
@@ -522,7 +641,7 @@ class Command(BaseCommand):
summary="Viens jouer!",
content="Rejoins la fine équipe du Troll Penché et viens "
"t'amuser le Vendredi soir!",
club=clubs.troll,
club=troll,
author=subscriber,
is_published=True,
moderator=skia,
@@ -600,7 +719,8 @@ class Command(BaseCommand):
)
pict.file.name = p.name
pict.full_clean()
pict.generate_thumbnails(save=True)
pict.generate_thumbnails()
pict.save()
img_skia = Picture.objects.get(name="skia.jpg")
img_sli = Picture.objects.get(name="sli.jpg")
@@ -622,129 +742,6 @@ class Command(BaseCommand):
]
)
def _create_products(self, groups: PopulatedGroups, clubs: PopulatedClubs):
beers_type, cotis_type, refill_type, verre_type = (
ProductType.objects.bulk_create(
[
ProductType(name="Bières bouteilles"),
ProductType(name="Cotisations"),
ProductType(name="Rechargements"),
ProductType(name="Verre"),
]
)
)
cotis = Product.objects.create(
name="Cotis 1 semestre",
code="1SCOTIZ",
product_type=cotis_type,
purchase_price=15,
club=clubs.ae,
)
cotis2 = Product.objects.create(
name="Cotis 2 semestres",
code="2SCOTIZ",
product_type=cotis_type,
purchase_price="28",
club=clubs.ae,
)
refill = Product.objects.create(
name="Rechargement 15 €",
code="15REFILL",
product_type=refill_type,
purchase_price=15,
club=clubs.ae,
)
barb = Product.objects.create(
name="Barbar",
code="BARB",
product_type=beers_type,
purchase_price="1.50",
club=clubs.pdf,
limit_age=18,
)
cble = Product.objects.create(
name="Chimay Bleue",
code="CBLE",
product_type=beers_type,
purchase_price="1.50",
club=clubs.pdf,
limit_age=18,
)
cons = Product.objects.create(
name="Consigne Eco-cup",
code="CONS",
product_type=verre_type,
purchase_price="1",
club=clubs.pdf,
)
dcons = Product.objects.create(
name="Déconsigne Eco-cup",
code="DECO",
product_type=verre_type,
purchase_price="-1",
club=clubs.pdf,
)
cors = Product.objects.create(
name="Corsendonk",
code="CORS",
product_type=beers_type,
purchase_price="1.50",
club=clubs.pdf,
limit_age=18,
)
carolus = Product.objects.create(
name="Carolus",
code="CARO",
product_type=beers_type,
purchase_price="1.50",
club=clubs.pdf,
limit_age=18,
)
Product.objects.create(
name="remboursement",
code="REMBOURS",
purchase_price=0,
club=clubs.refound,
)
ReturnableProduct.objects.create(
product=cons, returned_product=dcons, max_return=3
)
mde = Counter.objects.get(name="MDE")
mde.products.add(barb, cble, cons, dcons)
eboutic = Counter.objects.get(name="Eboutic")
eboutic.products.add(barb, cotis, cotis2, refill)
cotis, cotis2, refill, barb, cble, cors, carolus, cons, dcons = (
Price.objects.bulk_create(
[
Price(product=cotis, amount=15),
Price(product=cotis2, amount=28),
Price(product=refill, amount=15),
Price(product=barb, amount=1.7),
Price(product=cble, amount=1.7),
Price(product=cors, amount=1.7),
Price(product=carolus, amount=1.7),
Price(product=cons, amount=1),
Price(product=dcons, amount=-1),
]
)
)
Price.groups.through.objects.bulk_create(
[
Price.groups.through(price=cotis, group=groups.subscribers),
Price.groups.through(price=cotis2, group=groups.subscribers),
Price.groups.through(price=refill, group=groups.subscribers),
Price.groups.through(price=barb, group=groups.subscribers),
Price.groups.through(price=cble, group=groups.subscribers),
Price.groups.through(price=cors, group=groups.subscribers),
Price.groups.through(price=carolus, group=groups.subscribers),
Price.groups.through(price=cotis, group=groups.old_subscribers),
Price.groups.through(price=cotis2, group=groups.old_subscribers),
Price.groups.through(price=cons, group=groups.old_subscribers),
Price.groups.through(price=dcons, group=groups.old_subscribers),
]
)
def _create_profile_pict(self, user: User):
path = self.SAS_FIXTURE_PATH / "Family" / f"{user.username}.jpg"
file = resize_image(Image.open(path), 400, "WEBP")
@@ -781,105 +778,6 @@ class Command(BaseCommand):
)
s.save()
def _create_clubs(self) -> PopulatedClubs:
ae = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
)
ae.board_group.permissions.add(
*Permission.objects.filter(
codename__in=[
"view_subscription",
"add_subscription",
"add_membership",
"view_hidden_user",
]
)
)
pdf = Club.objects.create(
id=settings.SITH_PDF_CLUB_ID,
name="PdF",
address="6 Boulevard Anatole France, 90000 Belfort",
)
troll = Club.objects.create(
name="Troll Penché", address="Terre Du Milieu", parent=ae
)
refound = Club.objects.create(
name="Carte AE", address="Jamais imprimée", parent=ae
)
roles = []
presidency_roles = ["Président⸱e", "Vice-Président⸱e"]
board_roles = [
"Trésorier⸱e",
"Secrétaire",
"Respo Info",
"Respo Com",
"Membre du bureau",
]
simple_roles = ["Membre actif⸱ve", "Curieux⸱euse"]
for club in ae, pdf, troll, refound:
for i, role in enumerate(presidency_roles):
roles.append(
ClubRole(
club=club, order=i, name=role, is_presidency=True, is_board=True
)
)
for i, role in enumerate(board_roles, start=len(presidency_roles)):
roles.append(ClubRole(club=club, order=i, name=role, is_board=True))
for i, role in enumerate(
simple_roles, start=len(presidency_roles) + len(board_roles)
):
roles.append(ClubRole(club=club, order=i, name=role))
ClubRole.objects.bulk_create(roles)
insta, fb, discord, _ = LinkType.objects.bulk_create(
[
LinkType(
name="instagram",
icon="fa-brands fa-square-instagram",
url_base="https://www.instagram.com",
),
LinkType(
name="facebook",
icon="fa-brands fa-facebook",
url_base="https://www.facebook.com",
),
LinkType(
name="discord",
icon="fa-brands fa-discord",
url_base="https://discord.gg",
),
LinkType(name="generic", icon="fa fa-link", url_base=""),
]
)
ClubLink.objects.bulk_create(
[
ClubLink(
name="insta AE",
url="https://www.instagram.com/ae_utbm/",
club=ae,
link_type=insta,
),
ClubLink(
name="insta activités AE",
url="https://www.instagram.com/activites_ae/",
club=ae,
link_type=insta,
),
ClubLink(
name="facebook AE",
url="https://www.facebook.com/ae_utbm",
club=ae,
link_type=fb,
),
ClubLink(
name="Discord",
url="https://discord.gg/QvTm3XJrHR",
club=ae,
link_type=discord,
),
]
)
return PopulatedClubs(ae=ae, troll=troll, pdf=pdf, refound=refound)
def _create_groups(self) -> PopulatedGroups:
perms = Permission.objects.all()
+32 -37
View File
@@ -11,13 +11,12 @@ from django.db.models import Count, Exists, Min, OuterRef, Subquery
from django.utils.timezone import localdate, make_aware, now
from faker import Faker
from club.models import Club, ClubRole, Membership
from club.models import Club, Membership
from core.models import Group, User, UserBan
from counter.models import (
Counter,
Customer,
Permanency,
Price,
Product,
ProductType,
Refilling,
@@ -173,25 +172,20 @@ class Command(BaseCommand):
Customer.objects.bulk_create(customers, ignore_conflicts=True)
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, 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]
def zip_roles(users: list[User]) -> Iterator[tuple[User, int]]:
roles = iter(sorted(settings.SITH_CLUB_ROLES.keys(), reverse=True))
user_idx = 0
for _role in important_roles:
while (role := next(roles)) > 2:
# one member for each major role
yield users[user_idx], _role
yield users[user_idx], role
user_idx += 1
for _ in range(int(0.3 * (len(users) - user_idx))):
# 30% of the remaining in the board
yield users[user_idx], simple_board_role
yield users[user_idx], 2
user_idx += 1
for remaining in users[user_idx + 1 :]:
# everything else is a simple member
yield remaining, random.choices(member_roles, weights=(0.8, 0.2))[0]
yield remaining, 1
memberships = []
old_members = old_members.copy()
@@ -203,14 +197,19 @@ class Command(BaseCommand):
start_date=start,
end_date=self.faker.past_date(start),
user=old,
role=random.choice(roles),
role=random.choice(list(settings.SITH_CLUB_ROLES.keys())),
club=club,
)
)
for member, role in zip_roles(members):
start = self.faker.past_date("-1y")
memberships.append(
Membership(start_date=start, user=member, role=role, club=club)
Membership(
start_date=start,
user=member,
role=role,
club=club,
)
)
memberships = Membership.objects.bulk_create(memberships)
Membership._add_club_groups(memberships)
@@ -279,7 +278,6 @@ class Command(BaseCommand):
# 2/3 of the products are owned by AE
clubs = [ae, ae, ae, ae, ae, ae, *other_clubs]
products = []
prices = []
buying_groups = []
selling_places = []
for _ in range(200):
@@ -290,28 +288,25 @@ class Command(BaseCommand):
product_type=random.choice(categories),
code="".join(self.faker.random_letters(length=random.randint(4, 8))),
purchase_price=price,
selling_price=price,
special_selling_price=price - min(0.5, price),
club=random.choice(clubs),
limit_age=0 if random.random() > 0.2 else 18,
archived=self.faker.boolean(60),
archived=bool(random.random() > 0.7),
)
products.append(product)
for i in range(random.randint(0, 3)):
product_price = Price(
amount=price, product=product, is_always_shown=self.faker.boolean()
)
# prices for non-subscribers will be higher than for subscribers
price *= 1.2
prices.append(product_price)
buying_groups.append(
Price.groups.through(price=product_price, group=groups[i])
)
# there will be products without buying groups
# but there are also such products in the real database
buying_groups.extend(
Product.buying_groups.through(product=product, group=group)
for group in random.sample(groups, k=random.randint(0, 3))
)
selling_places.extend(
Counter.products.through(counter=counter, product=product)
for counter in random.sample(counters, random.randint(0, 4))
)
Product.objects.bulk_create(products)
Price.objects.bulk_create(prices)
Price.groups.through.objects.bulk_create(buying_groups)
Product.buying_groups.through.objects.bulk_create(buying_groups)
Counter.products.through.objects.bulk_create(selling_places)
def create_sales(self, sellers: list[User]):
@@ -325,7 +320,7 @@ class Command(BaseCommand):
)
)
)
prices = list(Price.objects.select_related("product").all())
products = list(Product.objects.all())
counters = list(
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette"])
)
@@ -335,14 +330,14 @@ class Command(BaseCommand):
# the longer the customer has existed, the higher the mean of nb_products
mu = 5 + (now().year - customer.since.year) * 2
nb_sales = max(0, int(random.normalvariate(mu=mu, sigma=mu * 5)))
favoured_prices = random.sample(prices, k=(random.randint(1, 5)))
favoured_products = random.sample(products, k=(random.randint(1, 5)))
favoured_counter = random.choice(counters)
this_customer_sales = []
for _ in range(nb_sales):
price = (
random.choice(favoured_prices)
product = (
random.choice(favoured_products)
if random.random() > 0.7
else random.choice(prices)
else random.choice(products)
)
counter = (
favoured_counter
@@ -351,11 +346,11 @@ class Command(BaseCommand):
)
this_customer_sales.append(
Selling(
product=price.product,
product=product,
counter=counter,
club_id=price.product.club_id,
club_id=product.club_id,
quantity=random.randint(1, 5),
unit_price=price.amount,
unit_price=product.selling_price,
seller=random.choice(sellers),
customer=customer,
date=make_aware(
@@ -1,37 +0,0 @@
# Generated by Django 5.2.12 on 2026-03-14 08:39
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("core", "0048_alter_user_options")]
operations = [
migrations.AddField(
model_name="user",
name="whitelisted_users",
field=models.ManyToManyField(
blank=True,
help_text=(
"Even if this profile is hidden, "
"the users in this list will still be able to see it."
),
related_name="visible_by_whitelist",
to=settings.AUTH_USER_MODEL,
verbose_name="whitelisted users",
),
),
migrations.AlterField(
model_name="preferences",
name="show_my_stats",
field=models.BooleanField(
default=False,
help_text=(
"Allow subscribers (or whitelisted users "
"if your profile is hidden) to access your AE account stats."
),
verbose_name="show your stats to others",
),
),
]
@@ -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),
]
+14 -55
View File
@@ -131,9 +131,7 @@ class UserQuerySet(models.QuerySet):
if user.has_perm("core.view_hidden_user"):
return self
if user.has_perm("core.view_user"):
return self.filter(
Q(is_viewable=True) | Q(whitelisted_users=user) | Q(pk=user.pk)
)
return self.filter(is_viewable=True)
if user.is_anonymous:
return self.none()
return self.filter(id=user.id)
@@ -281,16 +279,6 @@ class User(AbstractUser):
),
default=True,
)
whitelisted_users = models.ManyToManyField(
"User",
related_name="visible_by_whitelist",
verbose_name=_("whitelisted users"),
help_text=_(
"Even if this profile is hidden, "
"the users in this list will still be able to see it."
),
blank=True,
)
godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
objects = CustomUserManager()
@@ -530,7 +518,7 @@ class User(AbstractUser):
self.username = user_name
return user_name
def is_owner(self, obj: models.Model):
def is_owner(self, obj):
"""Determine if the object is owned by the user."""
if hasattr(obj, "is_owned_by") and obj.is_owned_by(self):
return True
@@ -538,7 +526,7 @@ class User(AbstractUser):
return True
return self.is_root
def can_edit(self, obj: models.Model):
def can_edit(self, obj):
"""Determine if the object can be edited by the user."""
if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self):
return True
@@ -552,9 +540,11 @@ class User(AbstractUser):
pks = list(obj.edit_groups.values_list("id", flat=True))
if any(self.is_in_group(pk=pk) for pk in pks):
return True
if isinstance(obj, User) and obj == self:
return True
return self.is_owner(obj)
def can_view(self, obj: models.Model):
def can_view(self, obj):
"""Determine if the object can be viewed by the user."""
if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self):
return True
@@ -573,35 +563,14 @@ class User(AbstractUser):
return True
return self.can_edit(obj)
def can_be_edited_by(self, user: User):
return user == self or user.is_root or user.is_board_member
def can_be_edited_by(self, user):
return user.is_root or user.is_board_member
def can_be_viewed_by(self, user: User) -> bool:
"""Check if the given user can be viewed by this user.
Given users A and B. A can be viewed by B if :
- A and B are the same user
- or B has the permission to view hidden users
- or B can view users in general and A didn't hide its profile
- or B is in A's whitelist.
"""
def is_in_whitelist(u: User):
if (
hasattr(self, "_prefetched_objects_cache")
and "whitelisted_users" in self._prefetched_objects_cache
):
return u in self.whitelisted_users.all()
return self.whitelisted_users.contains(u)
return (
user.id == self.id
or user.has_perm("core.view_hidden_user")
or (
user.has_perm("core.view_user")
and (self.is_viewable or is_in_whitelist(user))
)
or (user.has_perm("core.view_user") and self.is_viewable)
)
def get_mini_item(self):
@@ -781,14 +750,7 @@ class Preferences(models.Model):
User, related_name="_preferences", on_delete=models.CASCADE
)
receive_weekmail = models.BooleanField(_("receive the Weekmail"), default=False)
show_my_stats = models.BooleanField(
_("show your stats to others"),
help_text=_(
"Allow subscribers (or whitelisted users "
"if your profile is hidden) to access your AE account stats."
),
default=False,
)
show_my_stats = models.BooleanField(_("show your stats to others"), default=False)
notify_on_click = models.BooleanField(
_("get a notification for every click"), default=False
)
@@ -853,7 +815,7 @@ class SithFile(models.Model):
User,
related_name="owned_files",
verbose_name=_("owner"),
on_delete=models.PROTECT,
on_delete=models.CASCADE,
)
edit_groups = models.ManyToManyField(
Group, related_name="editable_files", verbose_name=_("edit group"), blank=True
@@ -865,7 +827,6 @@ class SithFile(models.Model):
mime_type = models.CharField(_("mime type"), max_length=30)
size = models.IntegerField(_("size"), default=0)
date = models.DateTimeField(_("date"), default=timezone.now)
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
is_moderated = models.BooleanField(_("is moderated"), default=False)
moderator = models.ForeignKey(
User,
@@ -873,7 +834,7 @@ class SithFile(models.Model):
verbose_name=_("owner"),
null=True,
blank=True,
on_delete=models.SET_NULL,
on_delete=models.CASCADE,
)
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
is_in_sas = models.BooleanField(
@@ -887,10 +848,8 @@ class SithFile(models.Model):
return self.get_parent_path() + "/" + self.name
def save(self, *args, **kwargs):
sas_id = settings.SITH_SAS_ROOT_DIR_ID
self.is_in_sas = self.id == sas_id or any(
p.id == sas_id for p in self.get_parent_list()
)
sas = SithFile.objects.filter(id=settings.SITH_SAS_ROOT_DIR_ID).first()
self.is_in_sas = sas in self.get_parent_list() or self == sas
adding = self._state.adding
super().save(*args, **kwargs)
if adding:
+12
View File
@@ -0,0 +1,12 @@
import sort from "@alpinejs/sort";
import Alpine from "alpinejs";
import { limitedChoices } from "#core:alpine/limited-choices.ts";
import { alpinePlugin as notificationPlugin } from "#core:utils/notifications.ts";
Alpine.plugin([sort, limitedChoices]);
Alpine.magic("notifications", notificationPlugin);
window.Alpine = Alpine;
window.addEventListener("DOMContentLoaded", () => {
Alpine.start();
});
-64
View File
@@ -1,64 +0,0 @@
/**
* File containing main functions and library re-exports
* that should be accessible throughout the whole website.
*
* The idea is to group all that shared code into a single bundle,
* for more efficient tree-shaking and gzip compression.
*/
import sort from "@alpinejs/sort";
import Alpine from "alpinejs";
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
import htmx from "htmx.org";
import { limitedChoices } from "#core:alpine/limited-choices";
import { expireOldStorage } from "#core:core/localstorage";
import { default as navbar } from "#core:core/navbar";
import {
type NotificationPlugin,
notificationsPlugin as notifications,
} from "#core:utils/notifications";
/**
* Alpine
*/
declare module "alpinejs" {
interface Magics<T> {
$notifications: NotificationPlugin;
}
}
Alpine.plugin([sort, limitedChoices, notifications]);
// biome-ignore lint/style/useNamingConvention: it's how it's named
Object.assign(window, { Alpine });
window.addEventListener("DOMContentLoaded", () => {
Alpine.start();
});
/**
* Polyfill for country flags (used for language choice)
*/
polyfillCountryFlagEmojis();
/**
* HTMX
*/
document.body.addEventListener("htmx:beforeRequest", (event: CustomEvent) => {
event.detail.target.ariaBusy = true;
});
document.body.addEventListener("htmx:beforeSwap", (event: CustomEvent) => {
event.detail.target.ariaBusy = null;
});
Object.assign(window, { htmx });
/**
* navbar
*/
navbar();
/**
* Script that clears the cache when the cache version changes
*/
expireOldStorage();
@@ -1,136 +1,18 @@
import {
type InheritedHtmlElement,
inheritHtmlElement,
registerComponent,
} from "#core:utils/web-components.ts";
/**
* ElementOnce web components
*
* Those elements ensures that their content is always included only once on a document
* They are compatible with elements that are not managed with our Web Components
**/
export interface ElementOnce<K extends keyof HTMLElementTagNameMap>
extends InheritedHtmlElement<K> {
getElementQuerySelector(): string;
refresh(): void;
}
/**
* Create an abstract class for ElementOnce types Web Components
**/
export function elementOnce<K extends keyof HTMLElementTagNameMap>(tagName: K) {
abstract class ElementOnceImpl
extends inheritHtmlElement(tagName)
implements ElementOnce<K>
{
abstract getElementQuerySelector(): string;
clearNode() {
while (this.firstChild) {
this.removeChild(this.lastChild);
}
}
refresh() {
this.clearNode();
if (document.querySelectorAll(this.getElementQuerySelector()).length === 0) {
this.appendChild(this.node);
}
}
connectedCallback() {
super.connectedCallback(false);
this.refresh();
}
disconnectedCallback() {
// The MutationObserver can't see web components being removed
// It also can't see if something is removed inside after the component gets deleted
// We need to manually clear the containing node to trigger the observer
this.clearNode();
}
}
return ElementOnceImpl;
}
// Set of ElementOnce type components to refresh with the observer
const registeredComponents: Set<string> = new Set();
/**
* Helper to register ElementOnce types Web Components
* It's a wrapper around registerComponent that registers that component on
* a MutationObserver that activates a refresh on them when elements are removed
*
* You are not supposed to unregister an element
**/
export function registerElementOnce(name: string, options?: ElementDefinitionOptions) {
registeredComponents.add(name);
return registerComponent(name, options);
}
// Refresh all ElementOnce components on the document based on the tag name of the removed element
const refreshElement = <
T extends keyof HTMLElementTagNameMap,
K extends keyof HTMLElementTagNameMap,
>(
components: HTMLCollectionOf<ElementOnce<T>>,
removedTagName: K,
) => {
for (const element of components) {
// We can't guess if an element is compatible before we get one
// We exit the function completely if it's not compatible
if (element.inheritedTagName.toUpperCase() !== removedTagName.toUpperCase()) {
return;
}
element.refresh();
}
};
// Since we need to pause the observer, we make an helper to start it with consistent arguments
const startObserver = (observer: MutationObserver) => {
observer.observe(document, {
// We want to also listen for elements contained in the header (eg: link)
subtree: true,
childList: true,
});
};
// Refresh ElementOnce components when changes happens
const observer = new MutationObserver((mutations: MutationRecord[]) => {
// To avoid infinite recursion, we need to pause the observer while manipulation nodes
observer.disconnect();
for (const mutation of mutations) {
for (const node of mutation.removedNodes) {
if (node.nodeType !== node.ELEMENT_NODE) {
continue;
}
for (const registered of registeredComponents) {
refreshElement(
document.getElementsByTagName(registered) as HTMLCollectionOf<
ElementOnce<"html"> // The specific tag doesn't really matter
>,
(node as HTMLElement).tagName as keyof HTMLElementTagNameMap,
);
}
}
}
// We then resume the observer
startObserver(observer);
});
startObserver(observer);
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
/**
* Web component used to import css files only once
* If called multiple times or the file was already imported, it does nothing
**/
@registerElementOnce("link-once")
export class LinkOnce extends elementOnce("link") {
getElementQuerySelector(): string {
@registerComponent("link-once")
export class LinkOnce extends inheritHtmlElement("link") {
connectedCallback() {
super.connectedCallback(false);
// We get href from node.attributes instead of node.href to avoid getting the domain part
return `link[href='${this.node.attributes.getNamedItem("href").nodeValue}']`;
const href = this.node.attributes.getNamedItem("href").nodeValue;
if (document.querySelectorAll(`link[href='${href}']`).length === 0) {
this.appendChild(this.node);
}
}
}
@@ -138,10 +20,14 @@ export class LinkOnce extends elementOnce("link") {
* Web component used to import javascript files only once
* If called multiple times or the file was already imported, it does nothing
**/
@registerElementOnce("script-once")
@registerComponent("script-once")
export class ScriptOnce extends inheritHtmlElement("script") {
getElementQuerySelector(): string {
// We get href from node.attributes instead of node.src to avoid getting the domain part
return `script[src='${this.node.attributes.getNamedItem("src").nodeValue}']`;
connectedCallback() {
super.connectedCallback(false);
// We get src from node.attributes instead of node.src to avoid getting the domain part
const src = this.node.attributes.getNamedItem("src").nodeValue;
if (document.querySelectorAll(`script[src='${src}']`).length === 0) {
this.appendChild(this.node);
}
}
}
@@ -28,7 +28,7 @@ export class Tab extends HTMLElement {
return html`
<button
role="tab"
?aria-selected="${this.active}"
?aria-selected=${this.active}
class="tab-header clickable ${this.active ? "active" : ""}"
@click="${() => this.setActive(true)}"
>
@@ -40,7 +40,7 @@ export class Tab extends HTMLElement {
return html`
<section
class="tab-section"
?hidden="${!this.active}"
?hidden=${!this.active}
>
${unsafeHTML(this.getContentHtml())}
</section>
-72
View File
@@ -1,72 +0,0 @@
/**
* For more detailed infos on how to use this file,
* check /docs/tutorial/front/localstorage.md,
* or https://ae-utbm.github.io/sith/tutorial/front/localstorage/
*/
// increment this number when a breaking change is made with localStorage
const CURRENT_LOCALSTORAGE_VERSION = 1;
/**
* Remove keys that are no longer used from localStorage
*/
export function expireOldStorage() {
const version = Number.parseInt(localStorage.getItem("version") ?? "0", 10);
if (version === CURRENT_LOCALSTORAGE_VERSION) {
// The cache schema is up-to-date. Nothing to do.
return;
}
localStorage.removeItem("basket1");
// remove all storage items which key is in the form
// `userXXXPictures` or `userXXXPicturesNumber`
Object.keys(localStorage)
.filter(
(key) =>
key.startsWith("user") &&
(key.endsWith("Pictures") || key.endsWith("PicturesNumber")),
)
.forEach((key) => {
localStorage.removeItem(key);
});
localStorage.setItem("version", CURRENT_LOCALSTORAGE_VERSION.toString());
}
interface VersionedStorageItem<T> {
version?: number;
val: T | undefined;
}
export const versionedLocalStorage = {
...localStorage,
/**
* set this item in localStorage, alongside its version.
*
* Note: this expects an object, not a JSON string, because the parsing
* into JSON needs to be done inside the function.
*/
setItem<T>(key: string, value: T, { version }: { version: number }) {
localStorage.setItem(key, JSON.stringify({ version: version, val: value }));
},
/**
* Get the item linked with the given key and version from localStorage.
*
* Note: if the given key exists in localStorage but doesn't satisfy
* the given version, it will be cleared from cache.
*
* @return the object if found and with the good version, else null;
*/
getItem<T>(key: string, { version }: { version: number }): T | null {
const stored = localStorage.getItem(key);
if (!stored) {
// this key doesn't exist, return null;
return null;
}
const obj: VersionedStorageItem<T> = JSON.parse(stored);
if (obj.version !== version || obj.val === undefined) {
// The version is wrong, return null and remove this item from cache
localStorage.removeItem(key);
return null;
}
return obj.val;
},
};
@@ -1,10 +1,12 @@
function showMenu() {
import { exportToHtml } from "#core:utils/globals.ts";
exportToHtml("showMenu", () => {
const navbar = document.getElementById("navbar-content");
const current = navbar.getAttribute("mobile-display");
navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden");
}
});
function navbarInit() {
document.addEventListener("alpine:init", () => {
const menuItems = document.querySelectorAll(".navbar details[name='navbar'].menu");
const isDesktop = () => {
return window.innerWidth >= 500;
@@ -31,9 +33,4 @@ function navbarInit() {
}
});
}
}
export default () => {
Object.assign(document, { showMenu });
document.addEventListener("alpine:init", navbarInit);
};
});
@@ -0,0 +1,3 @@
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
polyfillCountryFlagEmojis();
+11
View File
@@ -0,0 +1,11 @@
import htmx from "htmx.org";
document.body.addEventListener("htmx:beforeRequest", (event) => {
event.detail.target.ariaBusy = true;
});
document.body.addEventListener("htmx:beforeSwap", (event) => {
event.detail.target.ariaBusy = null;
});
Object.assign(window, { htmx });
+3 -3
View File
@@ -1,5 +1,6 @@
// biome-ignore lint/performance/noNamespaceImport: this is the recommended way from the documentation
import * as Sentry from "@sentry/browser";
import { exportToHtml } from "#core:utils/globals.ts";
interface LoggedUser {
name: string;
@@ -12,7 +13,7 @@ interface SentryOptions {
user?: LoggedUser;
}
const loadSentryPopup = (options: SentryOptions) => {
exportToHtml("loadSentryPopup", (options: SentryOptions) => {
Sentry.init({
dsn: options.dsn,
});
@@ -20,5 +21,4 @@ const loadSentryPopup = (options: SentryOptions) => {
eventId: options.eventId,
...(options.user ?? {}),
});
};
Object.assign(window, { loadSentryPopup });
});
+1 -1
View File
@@ -1,4 +1,4 @@
import type { NestedKeyOf } from "#core:types/nested-key";
import type { NestedKeyOf } from "#core:utils/types.ts";
interface StringifyOptions<T extends object> {
/** The columns to include in the resulting CSV. */
+14
View File
@@ -5,3 +5,17 @@ declare global {
const gettext: (text: string) => string;
const interpolate: <T>(fmt: string, args: string[] | T, isNamed?: boolean) => string;
}
/**
* Helper function to export typescript functions to regular html and jinja files
* Without it, you either have to use the any keyword and suppress warnings or do a
* very painful type conversion workaround which is only here to please the linter
*
* This is only useful if you're using typescript, this is equivalent to doing
* window.yourFunction = yourFunction
**/
// biome-ignore lint/suspicious/noExplicitAny: Avoid strange tricks to export functions
export function exportToHtml(name: string, func: any) {
// biome-ignore lint/suspicious/noExplicitAny: Avoid strange tricks to export functions
(window as any)[name] = func;
}
+26 -54
View File
@@ -1,64 +1,36 @@
import { Alpine } from "alpinejs";
export enum NotificationLevel {
Error = "error",
Warning = "warning",
Success = "success",
}
export interface NotificationPlugin {
/**
* Add an error message to the notifications.
*/
error: (message: string) => void;
/**
* Add a warning message to the notifications
*/
warning: (message: string) => void;
/**
* Add a success message to the notifications
*/
success: (message: string) => void;
/**
* Remove all notifications displayed on the page.
*/
clear: () => void;
/**
* Add multiple notifications at once.
* The added notifications can have different notification levels.
*/
addMany: (notifs: Notification[]) => void;
/**
* Return all notifications displayed on the page.
*/
getAll: () => Notification[];
}
export interface Notification {
tag: NotificationLevel;
text: string;
}
Alpine.store("notifications", [] as Notification[]);
function createNotification(message: string, level: NotificationLevel) {
(Alpine.store("notifications") as Notification[]).push({ text: message, tag: level });
}
function createManyNotifications(notifs: Notification[]) {
for (const notif of notifs) {
createNotification(notif.text, notif.tag);
export function createNotification(message: string, level: NotificationLevel) {
const element = document.getElementById("quick-notifications");
if (element === null) {
return false;
}
return element.dispatchEvent(
new CustomEvent("quick-notification-add", {
detail: { text: message, tag: level },
}),
);
}
export const notifications: NotificationPlugin = {
error: (message: string) => createNotification(message, NotificationLevel.Error),
warning: (message: string) => createNotification(message, NotificationLevel.Warning),
success: (message: string) => createNotification(message, NotificationLevel.Success),
clear: () => Alpine.store("notifications", []),
addMany: (notifs: Notification[]) => createManyNotifications(notifs),
getAll: () => Alpine.store("notifications") as Notification[],
};
export function notificationsPlugin(GlobalAlpine: Alpine) {
GlobalAlpine.magic("notifications", () => ({ ...notifications }));
export function deleteNotifications() {
const element = document.getElementById("quick-notifications");
if (element === null) {
return false;
}
return element.dispatchEvent(new CustomEvent("quick-notification-delete"));
}
export function alpinePlugin() {
return {
error: (message: string) => createNotification(message, NotificationLevel.Error),
warning: (message: string) =>
createNotification(message, NotificationLevel.Warning),
success: (message: string) =>
createNotification(message, NotificationLevel.Success),
clear: () => deleteNotifications(),
};
}
+6 -32
View File
@@ -23,17 +23,10 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio
* The technique is to:
* create a new web component
* create the desired type inside
* move all attributes to the child component
* pass all attributes to the child component
* store is at as `node` inside the parent
**/
export interface InheritedHtmlElement<K extends keyof HTMLElementTagNameMap>
extends HTMLElement {
readonly inheritedTagName: K;
node: HTMLElementTagNameMap[K];
}
/**
* Generator function that creates an InheritedHtmlElement compatible class
*
* Since we can't use the generic type to instantiate the node, we create a generator function
*
* ```js
* class MyClass extends inheritHtmlElement("select") {
@@ -42,24 +35,11 @@ export interface InheritedHtmlElement<K extends keyof HTMLElementTagNameMap>
* ```
**/
export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagName: K) {
return class InheritedHtmlElementImpl
extends HTMLElement
implements InheritedHtmlElement<K>
{
readonly inheritedTagName = tagName;
private readonly initializedAttribute = "component-initialized";
node: HTMLElementTagNameMap[K];
return class Inherited extends HTMLElement {
protected node: HTMLElementTagNameMap[K];
connectedCallback(autoAddNode?: boolean) {
// When nesting inherited elements, we might trigger the wrapping twice
// To avoid this, we tag a created element as initialized
// We then skip the initialization step and grab the inner content as the node
if (this.hasAttribute(this.initializedAttribute)) {
this.node = this.firstChild as HTMLElementTagNameMap[K];
return;
}
this.node = document.createElement(this.inheritedTagName);
this.node = document.createElement(tagName);
const attributes: Attr[] = []; // We need to make a copy to delete while iterating
for (const attr of this.attributes) {
if (attr.name in this.node) {
@@ -67,12 +47,6 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
}
}
this.setAttribute(this.initializedAttribute, "");
// We move compatible attributes to the child element
// This avoids weird inconsistencies between attributes
// when we manipulate the dom in the future
// This is especially important when using attribute based reactivity
for (const attr of attributes) {
this.removeAttributeNode(attr);
this.node.setAttributeNode(attr);
+3 -4
View File
@@ -53,7 +53,7 @@ details.accordion>.accordion-content {
opacity: 0;
@supports (max-height: calc-size(max-content, size)) {
max-height: 0;
max-height: 0px;
}
}
@@ -71,12 +71,11 @@ details.accordion>.accordion-content {
}
}
// ::details-content is available on firefox only since september 2025
// (and wasn't available when this code was initially written)
// ::details-content isn't available on firefox yet
// we use .accordion-content as a workaround
// But we need to use ::details-content for chrome because it's
// not working correctly otherwise
// it only happens in chrome, not safari or firefox
// it only happen in chrome, not safari or firefox
// Note: `selector` is not supported by scss so we comment it out to
// avoid compiling it and sending it straight to the css
// This is a trick that comes from here :
-1
View File
@@ -157,7 +157,6 @@ form {
margin-bottom: .25rem;
font-size: 80%;
display: block;
max-width: calc(100% - calc(var(--nf-input-size) * 2))
}
fieldset {
+14 -23
View File
@@ -271,7 +271,7 @@ body {
/*--------------------------------CONTENT------------------------------*/
#content {
padding: 1.5em 3%;
padding: 1em 1%;
box-shadow: $shadow-color 0 5px 10px;
background: $white-color;
overflow: auto;
@@ -398,28 +398,6 @@ body {
}
}
/* Fontawesome icons */
.fa-brands, .fa-link {
color: black;
}
.fa-facebook {
color: $faceblue;
}
.fa-discord {
color: $discordblurple;
}
.fa-square-instagram::before, .fa-instagram::before {
background: $instagradient;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.fa-bluesky, .fa-square-bluesky {
color: #0f73ff;
}
}
@media screen and (max-width: $small-devices) {
@@ -771,3 +749,16 @@ textarea {
vertical-align: middle;
}
}
/*--------------------------------JQuery-------------------------------*/
#club_detail {
.club_logo {
float: right;
img {
display: block;
max-height: 10em;
max-width: 10em;
}
}
}
+11
View File
@@ -5,6 +5,17 @@
}
.profile {
&-visible {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding-top: 10px;
input[type="checkbox"]+label {
max-width: unset;
}
}
&-pictures {
box-sizing: border-box;
display: flex;
+22
View File
@@ -19,6 +19,28 @@
}
}
}
&-cards,
&-trombi {
>p {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: justify;
gap: 5px;
margin: 0;
>input,
>select {
min-width: 300px;
}
}
}
&-submit-btn {
margin-top: 10px !important;
max-width: 100px;
}
}
.justify {
+5 -2
View File
@@ -35,9 +35,12 @@
<noscript><link rel="stylesheet" href="{{ static('bundled/fontawesome-index.css') }}"></noscript>
<script src="{{ url('javascript-catalog') }}"></script>
<script type="module" src="{{ static("bundled/base-bundle-index.ts") }}"></script>
<script type="module" src="{{ static("bundled/core/navbar-index.ts") }}"></script>
<script type="module" src="{{ static("bundled/core/components/include-index.ts") }}"></script>
<script type="module" src="{{ static("bundled/core/tooltips-index.ts") }}"></script>
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
<script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/core/tooltips-index.ts') }}"></script>
{% block additional_css %}{% endblock %}
{% block additional_js %}{% endblock %}
+3 -2
View File
@@ -5,8 +5,9 @@
<details name="navbar" class="menu">
<summary class="head">{% trans %}Associations & Clubs{% endtrans %}</summary>
<ul class="content">
<li><a href="{{ url("core:page", page_name="ae") }}">{% trans %}AE{% endtrans %}</a></li>
<li><a href="{{ url("club:club_list") }}">{% trans %}AE's clubs{% endtrans %}</a></li>
<li><a href="{{ url('core:page', page_name='ae') }}">{% trans %}AE{% endtrans %}</a></li>
<li><a href="{{ url('core:page', page_name='clubs') }}">{% trans %}AE's clubs{% endtrans %}</a></li>
<li><a href="{{ url('core:page', page_name='utbm-associations') }}">{% trans %}Others UTBM's Associations{% endtrans %}</a></li>
</ul>
</details>
<details name="navbar" class="menu">
+15 -9
View File
@@ -1,13 +1,19 @@
<div id="quick-notifications"
x-init='$notifications.addMany([
{%- for message in messages -%}
{%- if not message.extra_tags -%}
{ tag: "{{ message.tags }}", text: {{ message|string|tojson }} },
{%- endif -%}
{%- endfor -%}
])'
>
<template x-for="(message, index) in $notifications.getAll()">
x-data="{
messages: [
{% if messages %}
{% for message in messages %}
{
tag: '{{ message.tags }}',
text: '{{ message }}',
},
{% endfor %}
{% endif %}
]
}"
@quick-notification-add="(e) => messages.push(e?.detail)"
@quick-notification-delete="messages = []">
<template x-for="(message, index) in messages">
<div class="alert" :class="`alert-${message.tag}`" x-transition>
<span class="alert-main" x-text="message.text"></span>
<span class="clickable" @click="messages = messages.filter((item, i) => i !== index)">
+2 -9
View File
@@ -1,18 +1,11 @@
{% extends "core/base.jinja" %}
{# if the template context has the `object_name` variable,
then this one will be used in the page title,
instead of the result of `str(object)` #}
{% if not object_name %}
{% set object_name=form.instance.__class__._meta.verbose_name %}
{% endif %}
{% block title %}
{% trans name=object_name %}Create {{ name }}{% endtrans %}
{% trans name=form.instance.__class__._meta.verbose_name %}Create {{ name }}{% endtrans %}
{% endblock %}
{% block content %}
<h2>{% trans name=object_name %}Create {{ name }}{% endtrans %}</h2>
<h2>{% trans name=form.instance.__class__._meta.verbose_name %}Create {{ name }}{% endtrans %}</h2>
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p() }}
+2 -3
View File
@@ -33,8 +33,7 @@
<a href="{{ url("core:file_detail", file_id=f.id) }}">{{ f.name }}</a><br/>
{% trans %}Full name: {% endtrans %}{{ f.get_parent_path()+'/'+f.name }}<br/>
{% trans %}Owner: {% endtrans %}{{ f.owner.get_display_name() }}<br/>
{% trans %}Date: {% endtrans %}
{{ f.date|date(DATE_FORMAT) }} {{ f.date|time(TIME_FORMAT) }}<br/>
{% trans %}Date: {% endtrans %}{{ f.date|date(DATE_FORMAT) }} {{ f.date|time(TIME_FORMAT) }}<br/>
</p>
<p><button
hx-get="{{ url('core:file_moderate', file_id=f.id) }}"
@@ -49,6 +48,6 @@
>{% trans %}Delete{% endtrans %}</button></p>
</div>
{% endfor %}
{{ paginate_htmx(request, page_obj, paginator) }}
{{ paginate_htmx(page_obj, paginator) }}
</div>
{% endblock %}
@@ -1,33 +0,0 @@
<form
hx-post="{{ url("core:user_visibility_fragment", user_id=form.instance.id) }}"
hx-disabled-elt="find input[type='submit']"
hx-swap="outerHTML" x-data="{ isViewable: {{ form.is_viewable.value()|tojson }} }"
>
{% for message in messages %}
{% if message.extra_tags=="visibility" %}
<div class="alert alert-success">
{{ message }}
</div>
{% endif %}
{% endfor %}
{% csrf_token %}
{{ form.non_field_errors() }}
<fieldset class="form-group">
{{ form.is_viewable|add_attr("x-model=isViewable") }}
{{ form.is_viewable.label_tag() }}
<span class="helptext">{{ form.is_viewable.help_text }}</span>
{{ form.is_viewable.errors }}
</fieldset>
<fieldset class="form-group" x-show="!isViewable" x-transition x-cloak>
{{ form.whitelisted_users.as_field_group() }}
</fieldset>
<fieldset class="form-group">
{{ form.show_my_stats }}
{{ form.show_my_stats.label_tag() }}
<span class="helptext">
{{ form.show_my_stats.help_text }}
</span>
{{ form.show_my_stats.errors }}
</fieldset>
<input type="submit" class="btn btn-blue" value="{% trans %}Save{% endtrans %}">
</form>
+38 -37
View File
@@ -118,21 +118,20 @@
</nav>
{% endmacro %}
{% macro paginate_jinja(request, current_page, paginator) %}
{% macro paginate_jinja(current_page, paginator) %}
{# Add pagination buttons for pages without Alpine.
This must be coupled with a view that handles pagination
with the Django Paginator object.
Parameters:
request (django.http.request.HttpRequest): the current django request
current_page (django.core.paginator.Page): the current page object
paginator (django.core.paginator.Paginator): the paginator object
#}
{{ paginate_server_side(request, current_page, paginator, "") }}
{{ paginate_server_side(current_page, paginator, False) }}
{% endmacro %}
{% macro paginate_htmx(request, current_page, paginator, htmx_target="#content") %}
{% macro paginate_htmx(current_page, paginator) %}
{# Add pagination buttons for pages without Alpine but supporting fragments.
This must be coupled with a view that handles pagination
@@ -141,26 +140,24 @@
The replaced fragment will be #content so make sure you are calling this macro inside your content block.
Parameters:
request (django.http.request.HttpRequest): the current django request
current_page (django.core.paginator.Page): the current page object
paginator (django.core.paginator.Paginator): the paginator object
htmx_target (string): htmx target selector (default '#content')
#}
{{ paginate_server_side(request, current_page, paginator, htmx_target) }}
{{ paginate_server_side(current_page, paginator, True) }}
{% endmacro %}
{% macro paginate_server_side(request, current_page, paginator, htmx_target) %}
{% macro paginate_server_side(current_page, paginator, use_htmx) %}
<nav class="pagination">
{% if current_page.has_previous() %}
<a
{% if htmx_target -%}
hx-get="?{{ querystring(request, page=current_page.previous_page_number()) }}"
{% if use_htmx -%}
hx-get="?{{ querystring(page=current_page.previous_page_number()) }}"
hx-swap="innerHTML"
hx-target="{{ htmx_target }}"
hx-target="#content"
hx-push-url="true"
hx-trigger="click, keyup[key=='ArrowLeft'] from:body"
{%- else -%}
href="?{{ querystring(request, page=current_page.previous_page_number()) }}"
href="?{{ querystring(page=current_page.previous_page_number()) }}"
{%- endif -%}
>
<button>
@@ -177,13 +174,13 @@
<strong>{{ paginator.ELLIPSIS }}</strong>
{% else %}
<a
{% if htmx_target -%}
hx-get="?{{ querystring(request, page=i) }}"
{% if use_htmx -%}
hx-get="?{{ querystring(page=i) }}"
hx-swap="innerHTML"
hx-target="{{ htmx_target }}"
hx-target="#content"
hx-push-url="true"
{%- else -%}
href="?{{ querystring(request, page=i) }}"
href="?{{ querystring(page=i) }}"
{%- endif -%}
>
<button>{{ i }}</button>
@@ -192,14 +189,14 @@
{% endfor %}
{% if current_page.has_next() %}
<a
{% if htmx_target -%}
hx-get="?{{querystring(request, page=current_page.next_page_number())}}"
{% if use_htmx -%}
hx-get="?{{querystring(page=current_page.next_page_number())}}"
hx-swap="innerHTML"
hx-target="{{ htmx_target }}"
hx-target="#content"
hx-push-url="true"
hx-trigger="click, keyup[key=='ArrowRight'] from:body"
{%- else -%}
href="?{{querystring(request, page=current_page.next_page_number())}}"
href="?{{querystring(page=current_page.next_page_number())}}"
{%- endif -%}
><button>
<i class="fa fa-caret-right"></i>
@@ -226,7 +223,7 @@
<button type="button" onclick="checkbox_{{form_id}}(false);">{% trans %}Unselect All{% endtrans %}</button>
{% endmacro %}
{% macro update_notifications(messages, clear = True) %}
{% macro update_notifications(messages, clear) %}
{# Update notification area from new messages sent by django backend
This is useful when performing fragment swaps to keep messages up to date
Without this, the fragment would need to take control of the notification area and
@@ -236,25 +233,29 @@
messages: messages from django.contrib
clear : optional boolean that controls if notifications should be cleared first. True is the default
#}
{% set clear = clear|default(true) %}
{% if messages %}
<div x-init='() => {
{%- if clear -%}
$notifications.clear();
{%- endif -%}
$notifications.addMany([
{%- for message in messages -%}
{%- if not message.extra_tags -%}
{ tag: "{{ message.tags }}", text: {{ message|string|tojson }} },
{%- endif -%}
{%- endfor -%}
])
}'></div>
<div x-init="() => {
{% if clear %}
$notifications.clear()
{% endif %}
{% for message in messages %}
$notifications.{{ message.tags }}('{{ message }}')
{% endfor %}
}"></div>
{% endif %}
{% endmacro %}
{% macro querystring(request) %}
{%- set qs = request.GET.copy() -%}
{%- do qs.update(kwargs) -%}
{{- qs | urlencode -}}
{% macro querystring() %}
{%- for key, values in request.GET.lists() -%}
{%- if key not in kwargs -%}
{%- for value in values -%}
{{ key }}={{ value }}&amp;
{%- endfor -%}
{%- endif -%}
{%- endfor -%}
{%- for key, value in kwargs.items() -%}
{{ key }}={{ value }}&amp;
{%- endfor -%}
{% endmacro %}
+4 -4
View File
@@ -23,10 +23,10 @@
</tr>
</thead>
<tbody>
{% for m in profile.memberships.ongoing().select_related("role") %}
{% for m in profile.memberships.filter(end_date=None).all() %}
<tr>
<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.start_date }}</td>
{% if m.can_be_edited_by(user) %}
@@ -65,10 +65,10 @@
</tr>
</thead>
<tbody>
{% for m in profile.memberships.exclude(end_date=None).select_related("role") %}
{% for m in profile.memberships.exclude(end_date=None).all() %}
<tr>
<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.start_date }}</td>
<td>{{ m.end_date }}</td>
+12
View File
@@ -147,7 +147,18 @@
{%- endfor -%}
</div>
{# Checkboxes #}
<div class="profile-visible">
<div class="row">
{{ form.is_viewable }}
{{ form.is_viewable.label_tag() }}
</div>
<span class="helptext">
{{ form.is_viewable.help_text }}
</span>
</div>
<div class="final-actions">
{%- if form.instance == user -%}
<p>
<a href="{{ url('core:password_change') }}">{%- trans -%}Change my password{%- endtrans -%}</a>
@@ -159,6 +170,7 @@
</a>
</p>
{%- endif -%}
<p>
<input type="submit" value="{%- trans -%}Update{%- endtrans -%}" />
</p>
+19 -27
View File
@@ -11,22 +11,30 @@
{% block content %}
<div class="main">
<h2>{% trans %}Preferences{% endtrans %}</h2>
<br />
<h3>{% trans %}Notifications{% endtrans %}</h3>
<form action="" method="post" enctype="multipart/form-data">
<h3>{% trans %}General{% endtrans %}</h3>
<form class="form form-general" action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="form form-general">
{{ form.as_p() }}
</div>
<input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" />
{{ form.as_p() }}
<input class="form-submit-btn" type="submit" value="{% trans %}Save{% endtrans %}" />
</form>
<br />
<h3>{% trans %}Visibility{% endtrans %}</h3>
<h3>{% trans %}Trombi{% endtrans %}</h3>
{% if trombi_form %}
<form class="form form-trombi" action="{{ url('trombi:user_tools') }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ trombi_form.as_p() }}
<input class="form-submit-btn" type="submit" value="{% trans %}Save{% endtrans %}" />
</form>
{% else %}
<p>{% trans trombi=profile.trombi_user.trombi %}You already choose to be in that Trombi: {{ trombi }}.{% endtrans %}
<br />
<a href="{{ url('trombi:user_tools') }}">{% trans %}Go to my Trombi tools{% endtrans %}</a>
</p>
{% endif %}
{{ user_visibility_fragment }}
<br />
{% if student_card_fragment %}
<h3>{% trans %}Student card{% endtrans %}</h3>
{{ student_card_fragment }}
@@ -35,21 +43,5 @@
add a student card yourself, you'll need a NFC reader. We store the UID of the card which is 14 characters long.{% endtrans %}
</p>
{% endif %}
<br />
<h3>{% trans %}Trombi{% endtrans %}</h3>
{% if trombi_form %}
<form action="{{ url('trombi:user_tools') }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ trombi_form.as_p() }}
<input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" />
</form>
{% else %}
<p>{% trans trombi=profile.trombi_user.trombi %}You already choose to be in that Trombi: {{ trombi }}.{% endtrans %}
<br />
<a href="{{ url('trombi:user_tools') }}">{% trans %}Go to my Trombi tools{% endtrans %}</a>
</p>
{% endif %}
</div>
{% endblock %}
@@ -3,7 +3,7 @@
<script-once type="module" src="{{ js }}"></script-once>
{% endfor %}
{% for css in statics.css %}
<link-once rel="stylesheet" type="text/css" href="{{ css }}"></link-once>
<link-once rel="stylesheet" type="text/css" href="{{ css }}" defer></link-once>
{% endfor %}
<{{ component }} name="{{ widget.name }}" {% include "django/forms/widgets/attrs.html" %}>
+1 -1
View File
@@ -103,7 +103,7 @@ def add_attr(field: BoundField, attr: str):
if "=" not in d:
attrs["class"] = d
else:
key, val = d.split("=", maxsplit=1)
key, val = d.split("=")
attrs[key] = val
return field.as_widget(attrs=attrs)
-11
View File
@@ -344,14 +344,3 @@ def test_quick_upload_image(
assert (
parsed["name"] == Path(file.name).stem[: QuickUploadImage.IMAGE_NAME_SIZE - 1]
)
@pytest.mark.django_db
def test_populated_sas_is_in_sas():
"""Test that, in the data generated by the populate command,
the SAS has value is_in_sas=True.
If it's not the case, it has no incidence in prod, but it's annoying
in dev and may cause misunderstandings.
"""
assert SithFile.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID).is_in_sas
+1 -4
View File
@@ -11,7 +11,7 @@ from django.utils.timezone import now
from model_bakery import baker
from pytest_django.asserts import assertHTMLEqual, assertRedirects
from club.models import Club, Membership
from club.models import Club
from core.baker_recipes import board_user, subscriber_user
from core.markdown import markdown
from core.models import AnonymousUser, Page, PageRev, User
@@ -122,9 +122,6 @@ def test_page_revision_club_redirection(client: Client):
@pytest.mark.django_db
def test_viewable_by():
# remove existing pages to prevent side effect
# club pages are protected, so we must delete clubs first
Membership.objects.all().delete()
Club.objects.all().delete()
Page.objects.all().delete()
view_groups = [
[settings.SITH_GROUP_PUBLIC_ID],
+10 -24
View File
@@ -21,7 +21,7 @@ from core.baker_recipes import (
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 counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Permanency, Refilling, Selling
@@ -34,7 +34,6 @@ class TestSearchUsers(TestCase):
def setUpTestData(cls):
# News.author has on_delete=PROTECT, so news must be deleted beforehand
News.objects.all().delete()
SithFile.objects.all().delete()
User.objects.all().delete()
user_recipe = Recipe(
User,
@@ -214,9 +213,9 @@ def test_user_invoice_with_multiple_items():
"""Test that annotate_total() works when invoices contain multiple items."""
user: User = subscriber_user.make()
item_recipe = Recipe(InvoiceItem, invoice=foreign_key(Recipe(Invoice, user=user)))
item_recipe.make(_quantity=3, quantity=1, unit_price=5)
item_recipe.make(_quantity=1, quantity=1, unit_price=5)
item_recipe.make(_quantity=2, quantity=1, unit_price=iter([5, 8]))
item_recipe.make(_quantity=3, quantity=1, product_unit_price=5)
item_recipe.make(_quantity=1, quantity=1, product_unit_price=5)
item_recipe.make(_quantity=2, quantity=1, product_unit_price=iter([5, 8]))
res = list(
Invoice.objects.filter(user=user)
.annotate_total()
@@ -400,37 +399,24 @@ class TestUserQuerySetViewableBy:
return [
baker.make(User),
subscriber_user.make(),
*subscriber_user.make(is_viewable=False, _quantity=2),
subscriber_user.make(is_viewable=False),
]
def test_admin_user(self, users: list[User]):
user = baker.make(
User, user_permissions=[Permission.objects.get(codename="view_hidden_user")]
User,
user_permissions=[Permission.objects.get(codename="view_hidden_user")],
)
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert set(viewable) == set(users)
@pytest.mark.parametrize(
"user_factory",
[
old_subscriber_user.make,
lambda: old_subscriber_user.make(is_viewable=False),
subscriber_user.make,
lambda: subscriber_user.make(is_viewable=False),
],
"user_factory", [old_subscriber_user.make, subscriber_user.make]
)
def test_can_search(self, users: list[User], user_factory):
def test_subscriber(self, users: list[User], user_factory):
user = user_factory()
viewable = User.objects.filter(
id__in=[u.id for u in [*users, user]]
).viewable_by(user)
assert set(viewable) == {user, users[0], users[1]}
def test_whitelist(self, users: list[User]):
user = subscriber_user.make()
users[3].whitelisted_users.add(user)
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert set(viewable) == {users[0], users[1], users[3]}
assert set(viewable) == {users[0], users[1]}
@pytest.mark.parametrize("user_factory", [lambda: baker.make(User), AnonymousUser])
def test_not_subscriber(self, users: list[User], user_factory):
+2 -6
View File
@@ -69,6 +69,7 @@ from core.views import (
UserCreationView,
UserGodfathersTreeView,
UserGodfathersView,
UserListView,
UserMeRedirect,
UserMiniView,
UserPreferencesView,
@@ -77,7 +78,6 @@ from core.views import (
UserUpdateGroupView,
UserUpdateProfileView,
UserView,
UserVisibilityFormFragment,
delete_user_godfather,
logout,
notification,
@@ -136,11 +136,7 @@ urlpatterns = [
"group/<int:group_id>/detail/", GroupTemplateView.as_view(), name="group_detail"
),
# User views
path(
"fragment/user/<int:user_id>/",
UserVisibilityFormFragment.as_view(),
name="user_visibility_fragment",
),
path("user/", UserListView.as_view(), name="user_list"),
path(
"user/me/<path:remaining_path>/",
UserMeRedirect.as_view(),
+17
View File
@@ -25,6 +25,7 @@ from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest
from django.utils.timezone import localdate
from PIL import ExifTags
from PIL.Image import Image, Resampling
RED_PIXEL_PNG: Final[bytes] = (
@@ -177,6 +178,22 @@ def resize_image_explicit(
return ContentFile(content.getvalue())
def exif_auto_rotate(image):
for orientation in ExifTags.TAGS:
if ExifTags.TAGS[orientation] == "Orientation":
break
exif = dict(image._getexif().items())
if exif[orientation] == 3:
image = image.rotate(180, expand=True)
elif exif[orientation] == 6:
image = image.rotate(270, expand=True)
elif exif[orientation] == 8:
image = image.rotate(90, expand=True)
return image
def get_client_ip(request: HttpRequest) -> str | None:
headers = (
"X_FORWARDED_FOR", # Common header for proxies
+2 -34
View File
@@ -48,13 +48,12 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget
from PIL import Image
from antispam.forms import AntiSpamEmailField
from core.models import Gift, Group, Page, PageRev, Preferences, SithFile, User
from core.models import Gift, Group, Page, PageRev, SithFile, User
from core.utils import resize_image
from core.views.widgets.ajax_select import (
AutoCompleteSelect,
AutoCompleteSelectGroup,
AutoCompleteSelectMultipleGroup,
AutoCompleteSelectMultipleUser,
AutoCompleteSelectUser,
)
from core.views.widgets.markdown import MarkdownInput
@@ -180,6 +179,7 @@ class UserProfileForm(forms.ModelForm):
"school",
"promo",
"forum_signature",
"is_viewable",
]
widgets = {
"date_of_birth": SelectDate,
@@ -264,38 +264,6 @@ class UserProfileForm(forms.ModelForm):
self._post_clean()
class UserVisibilityForm(forms.ModelForm):
class Meta:
model = User
fields = ["is_viewable", "whitelisted_users"]
widgets = {
"is_viewable": forms.CheckboxInput(attrs={"class": "switch"}),
"whitelisted_users": AutoCompleteSelectMultipleUser,
}
__preferences_fields = forms.fields_for_model(
Preferences,
["show_my_stats"],
widgets={"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"})},
)
show_my_stats = __preferences_fields["show_my_stats"]
def __init__(
self, *args, initial: dict | None = None, instance: User | None = None, **kwargs
):
if instance:
initial = initial or {}
initial["show_my_stats"] = instance.preferences.show_my_stats
super().__init__(*args, initial=initial, instance=instance, **kwargs)
def save(self, commit=True) -> User: # noqa: FBT002
instance = super().save(commit=commit)
if commit:
instance.preferences.show_my_stats = self.cleaned_data["show_my_stats"]
instance.preferences.save()
return instance
class UserGroupsForm(forms.ModelForm):
error_css_class = "error"
required_css_class = "required"
+1 -1
View File
@@ -54,7 +54,7 @@ class FragmentRenderer(Protocol):
) -> SafeString: ...
class FragmentMixin(TemplateResponseMixin, AllowFragment, ContextMixin):
class FragmentMixin(TemplateResponseMixin, ContextMixin):
"""Make a view buildable as a fragment that can be embedded in a template.
Most fragments are used in two different ways :
+21 -53
View File
@@ -28,12 +28,10 @@ from datetime import timedelta
from operator import itemgetter
from smtplib import SMTPException
from django.contrib import messages
from django.contrib.auth import login, views
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import PermissionDenied
from django.db.models import DateField, F, QuerySet, Sum
from django.db.models.functions import Trunc
@@ -50,6 +48,7 @@ from django.views.generic import (
CreateView,
DeleteView,
DetailView,
ListView,
RedirectView,
TemplateView,
)
@@ -66,9 +65,8 @@ from core.views.forms import (
UserGodfathersForm,
UserGroupsForm,
UserProfileForm,
UserVisibilityForm,
)
from core.views.mixins import FragmentMixin, TabedViewMixin, UseFragmentsMixin
from core.views.mixins import TabedViewMixin, UseFragmentsMixin
from counter.models import Refilling, Selling
from eboutic.models import Invoice
from trombi.views import UserTrombiForm
@@ -250,15 +248,14 @@ class UserTabsMixin(TabedViewMixin):
"name": _("Groups"),
}
)
can_view_account = (
if (
hasattr(user, "customer")
and user.customer
and (
user == self.request.user
or self.request.user.has_perm("counter.view_customer")
)
)
if can_view_account or user.preferences.show_my_stats:
):
tab_list.append(
{
"url": reverse("core:user_stats", kwargs={"user_id": user.id}),
@@ -266,7 +263,6 @@ class UserTabsMixin(TabedViewMixin):
"name": _("Stats"),
}
)
if can_view_account:
tab_list.append(
{
"url": reverse("core:user_account", kwargs={"user_id": user.id}),
@@ -353,7 +349,7 @@ class UserGodfathersTreeView(UserTabsMixin, CanViewMixin, DetailView):
return kwargs
class UserStatsView(UserTabsMixin, UserPassesTestMixin, DetailView):
class UserStatsView(UserTabsMixin, CanViewMixin, DetailView):
"""Display a user's stats."""
model = User
@@ -361,20 +357,15 @@ class UserStatsView(UserTabsMixin, UserPassesTestMixin, DetailView):
context_object_name = "profile"
template_name = "core/user_stats.jinja"
current_tab = "stats"
queryset = User.objects.exclude(customer=None).select_related(
"customer", "_preferences"
)
queryset = User.objects.exclude(customer=None).select_related("customer")
def test_func(self):
profile: User = self.get_object()
return (
profile == self.request.user
or self.request.user.has_perm("counter.view_customer")
or (
self.request.user.can_view(profile)
and profile.preferences.show_my_stats
)
)
def dispatch(self, request, *arg, **kwargs):
profile = self.get_object()
if not (
profile == request.user or request.user.has_perm("counter.view_customer")
):
raise PermissionDenied
return super().dispatch(request, *arg, **kwargs)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
@@ -413,6 +404,13 @@ class UserMiniView(CanViewMixin, DetailView):
template_name = "core/user_mini.jinja"
class UserListView(ListView, CanEditPropMixin):
"""Displays the user list."""
model = User
template_name = "core/user_list.jinja"
# FIXME: the edit_once fields aren't displayed to the user (as expected).
# However, if the user re-add them manually in the form, they are saved.
class UserUpdateProfileView(UserTabsMixin, CanEditMixin, UpdateView):
@@ -470,30 +468,6 @@ class UserClubView(UserTabsMixin, CanViewMixin, DetailView):
current_tab = "clubs"
class UserVisibilityFormFragment(FragmentMixin, SuccessMessageMixin, UpdateView):
model = User
form_class = UserVisibilityForm
template_name = "core/fragment/user_visibility.jinja"
pk_url_kwarg = "user_id"
def get_form_kwargs(self):
return super().get_form_kwargs() | {"label_suffix": ""}
def form_valid(self, form):
response = super().form_valid(form)
messages.success(
self.request, _("Visibility parameters updated."), extra_tags="visibility"
)
return response
def render_fragment(self, request, **kwargs) -> SafeString:
self.object = kwargs.get("user")
return super().render_fragment(request, **kwargs)
def get_success_url(self, **kwargs):
return self.request.path
class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, UpdateView):
"""Edit a user's preferences."""
@@ -507,10 +481,7 @@ class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, Update
current_tab = "prefs"
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"instance": self.object.preferences,
"label_suffix": "",
}
return super().get_form_kwargs() | {"instance": self.object.preferences}
def get_success_url(self):
return self.request.path
@@ -520,9 +491,6 @@ class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, Update
from counter.views.student_card import StudentCardFormFragment
res = super().get_fragment_context_data()
res["user_visibility_fragment"] = UserVisibilityFormFragment.as_fragment()(
self.request, user=self.object
)
if hasattr(self.object, "customer"):
res["student_card_fragment"] = StudentCardFormFragment.as_fragment()(
self.request, customer=self.object.customer
+1 -7
View File
@@ -24,7 +24,6 @@ from counter.models import (
Eticket,
InvoiceCall,
Permanency,
Price,
Product,
ProductType,
Refilling,
@@ -33,24 +32,19 @@ from counter.models import (
)
class PriceInline(admin.TabularInline):
model = Price
autocomplete_fields = ("groups",)
@admin.register(Product)
class ProductAdmin(SearchModelAdmin):
list_display = (
"name",
"code",
"product_type",
"selling_price",
"archived",
"created_at",
"updated_at",
)
list_select_related = ("product_type",)
search_fields = ("name", "code")
inlines = [PriceInline]
@admin.register(ReturnableProduct)
+6 -2
View File
@@ -101,9 +101,13 @@ class ProductController(ControllerBase):
"""Get the detailed information about the products."""
return filters.filter(
Product.objects.select_related("club")
.prefetch_related("prices", "prices__groups")
.prefetch_related("buying_groups")
.select_related("product_type")
.order_by(F("product_type__order").asc(nulls_last=True), "name")
.order_by(
F("product_type__order").asc(nulls_last=True),
"product_type",
"name",
)
)
+1 -2
View File
@@ -2,11 +2,10 @@ from model_bakery.recipe import Recipe, foreign_key
from club.models import Club
from core.models import User
from counter.models import Counter, Price, Product, Refilling, Selling
from counter.models import Counter, Product, Refilling, Selling
counter_recipe = Recipe(Counter)
product_recipe = Recipe(Product, club=foreign_key(Recipe(Club)))
price_recipe = Recipe(Price, product=foreign_key(product_recipe))
sale_recipe = Recipe(
Selling,
product=foreign_key(product_recipe),
+66 -54
View File
@@ -1,12 +1,12 @@
import json
import math
import uuid
from collections import defaultdict
from datetime import date, datetime, timezone
from dateutil.relativedelta import relativedelta
from django import forms
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator
from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet
from django.utils.timezone import now
@@ -37,7 +37,6 @@ from counter.models import (
Customer,
Eticket,
InvoiceCall,
Price,
Product,
ProductFormula,
Refilling,
@@ -375,21 +374,7 @@ ScheduledProductActionFormSet = forms.modelformset_factory(
can_delete=True,
can_delete_extra=False,
extra=0,
)
ProductPriceFormSet = forms.inlineformset_factory(
parent_model=Product,
model=Price,
fields=["amount", "label", "groups", "is_always_shown"],
widgets={
"groups": AutoCompleteSelectMultipleGroup,
"is_always_shown": forms.CheckboxInput(attrs={"class": "switch"}),
},
absolute_max=None,
can_delete_extra=False,
min_num=1,
extra=0,
)
@@ -404,7 +389,10 @@ class ProductForm(forms.ModelForm):
"description",
"product_type",
"code",
"buying_groups",
"purchase_price",
"selling_price",
"special_selling_price",
"icon",
"club",
"limit_age",
@@ -419,8 +407,8 @@ class ProductForm(forms.ModelForm):
}
widgets = {
"product_type": AutoCompleteSelect,
"buying_groups": AutoCompleteSelectMultipleGroup,
"club": AutoCompleteSelectClub,
"tray": forms.CheckboxInput(attrs={"class": "switch"}),
}
counters = forms.ModelMultipleChoiceField(
@@ -430,40 +418,50 @@ class ProductForm(forms.ModelForm):
queryset=Counter.objects.all(),
)
def __init__(self, *args, prefix: str | None = None, instance=None, **kwargs):
super().__init__(*args, prefix=prefix, instance=instance, **kwargs)
self.fields["name"].widget.attrs["autofocus"] = "autofocus"
def __init__(self, *args, instance=None, **kwargs):
super().__init__(*args, instance=instance, **kwargs)
if self.instance.id:
self.fields["counters"].initial = self.instance.counters.all()
if hasattr(self.instance, "formula"):
self.formula_init(self.instance.formula)
self.price_formset = ProductPriceFormSet(
*args, instance=self.instance, prefix="price", **kwargs
)
self.action_formset = ScheduledProductActionFormSet(
*args, product=self.instance, prefix="action", **kwargs
*args, product=self.instance, **kwargs
)
def formula_init(self, formula: ProductFormula):
"""Part of the form initialisation specific to formula products."""
self.fields["selling_price"].help_text = _(
"This product is a formula. "
"Its price cannot be greater than the price "
"of the products constituting it, which is %(price)s"
) % {"price": formula.max_selling_price}
self.fields["special_selling_price"].help_text = _(
"This product is a formula. "
"Its special price cannot be greater than the price "
"of the products constituting it, which is %(price)s"
) % {"price": formula.max_special_selling_price}
for key, price in (
("selling_price", formula.max_selling_price),
("special_selling_price", formula.max_special_selling_price),
):
self.fields[key].widget.attrs["max"] = price
self.fields[key].validators.append(MaxValueValidator(price))
def is_valid(self):
return (
super().is_valid()
and self.price_formset.is_valid()
and self.action_formset.is_valid()
)
return super().is_valid() and self.action_formset.is_valid()
def save(self, *args, **kwargs) -> Product:
product = super().save(*args, **kwargs)
product.counters.set(self.cleaned_data["counters"])
# if it's a creation, the product given in the formset
# wasn't a persisted instance.
# So if we tried to persist the related objects in the current state,
# they would be linked to no product, thus be completely useless
# To make it work, we have to replace
# the initial product with a persisted one
for form in self.action_formset:
# if it's a creation, the product given in the formset
# wasn't a persisted instance.
# So if we tried to persist the scheduled actions in the current state,
# they would be linked to no product, thus be completely useless
# To make it work, we have to replace
# the initial product with a persisted one
form.set_product(product)
self.action_formset.save()
self.price_formset.save()
return product
@@ -486,6 +484,18 @@ class ProductFormulaForm(forms.ModelForm):
"the result and a part of the formula."
),
)
prices = [p.selling_price for p in cleaned_data["products"]]
special_prices = [p.special_selling_price for p in cleaned_data["products"]]
selling_price = cleaned_data["result"].selling_price
special_selling_price = cleaned_data["result"].special_selling_price
if selling_price > sum(prices) or special_selling_price > sum(special_prices):
self.add_error(
"result",
_(
"The result cannot be more expensive "
"than the total of the other products."
),
)
return cleaned_data
@@ -536,47 +546,48 @@ class CloseCustomerAccountForm(forms.Form):
)
class BasketItemForm(forms.Form):
class BasketProductForm(forms.Form):
quantity = forms.IntegerField(min_value=1, required=True)
price_id = forms.IntegerField(min_value=0, required=True)
id = forms.IntegerField(min_value=0, required=True)
def __init__(
self,
customer: Customer,
counter: Counter,
allowed_prices: dict[int, Price],
allowed_products: dict[int, Product],
*args,
**kwargs,
):
self.customer = customer # Used by formset
self.counter = counter # Used by formset
self.allowed_prices = allowed_prices
self.allowed_products = allowed_products
super().__init__(*args, **kwargs)
def clean_price_id(self):
data = self.cleaned_data["price_id"]
def clean_id(self):
data = self.cleaned_data["id"]
# We store self.price so we can use it later on the formset validation
# We store self.product so we can use it later on the formset validation
# And also in the global clean
self.price = self.allowed_prices.get(data, None)
if self.price is None:
self.product = self.allowed_products.get(data, None)
if self.product is None:
raise forms.ValidationError(
_("The selected product isn't available for this user")
)
return data
def clean(self):
cleaned_data = super().clean()
if len(self.errors) > 0:
return cleaned_data
return
# Compute prices
cleaned_data["bonus_quantity"] = 0
if self.price.product.tray:
if self.product.tray:
cleaned_data["bonus_quantity"] = math.floor(
cleaned_data["quantity"] / Product.QUANTITY_FOR_TRAY_PRICE
)
cleaned_data["total_price"] = self.price.amount * (
cleaned_data["total_price"] = self.product.price * (
cleaned_data["quantity"] - cleaned_data["bonus_quantity"]
)
@@ -600,8 +611,8 @@ class BaseBasketForm(forms.BaseFormSet):
raise forms.ValidationError(_("Submitted basket is invalid"))
def _check_product_are_unique(self):
price_ids = {form.cleaned_data["price_id"] for form in self.forms}
if len(price_ids) != len(self.forms):
product_ids = {form.cleaned_data["id"] for form in self.forms}
if len(product_ids) != len(self.forms):
raise forms.ValidationError(_("Duplicated product entries."))
def _check_enough_money(self, counter: Counter, customer: Customer):
@@ -611,9 +622,10 @@ class BaseBasketForm(forms.BaseFormSet):
def _check_recorded_products(self, customer: Customer):
"""Check for, among other things, ecocups and pitchers"""
items = defaultdict(int)
for form in self.forms:
items[form.price.product_id] += form.cleaned_data["quantity"]
items = {
form.cleaned_data["id"]: form.cleaned_data["quantity"]
for form in self.forms
}
ids = list(items.keys())
returnables = list(
ReturnableProduct.objects.filter(
@@ -639,7 +651,7 @@ class BaseBasketForm(forms.BaseFormSet):
BasketForm = forms.formset_factory(
BasketItemForm, formset=BaseBasketForm, absolute_max=None, min_num=1
BasketProductForm, formset=BaseBasketForm, absolute_max=None, min_num=1
)
+1 -2
View File
@@ -32,9 +32,8 @@ class Migration(migrations.Migration):
(
"result",
models.OneToOneField(
help_text="The product got with the formula.",
help_text="The formula product.",
on_delete=django.db.models.deletion.CASCADE,
related_name="formula",
to="counter.product",
verbose_name="result product",
),
-149
View File
@@ -1,149 +0,0 @@
# Generated by Django 5.2.11 on 2026-02-18 13:30
import django.db.models.deletion
from django.db import migrations, models
from django.db.migrations.state import StateApps
import counter.fields
def migrate_prices(apps: StateApps, schema_editor):
Product = apps.get_model("counter", "Product")
Price = apps.get_model("counter", "Price")
prices = [
Price(
amount=p.selling_price,
product=p,
created_at=p.created_at,
updated_at=p.updated_at,
)
for p in Product.objects.all()
]
Price.objects.bulk_create(prices)
groups = [
Price.groups.through(price=price, group=group)
for price in Price.objects.select_related("product").prefetch_related(
"product__buying_groups"
)
for group in price.product.buying_groups.all()
]
Price.groups.through.objects.bulk_create(groups)
class Migration(migrations.Migration):
dependencies = [
("core", "0048_alter_user_options"),
("counter", "0038_countersellers"),
]
operations = [
migrations.CreateModel(
name="Price",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"amount",
counter.fields.CurrencyField(
decimal_places=2, max_digits=12, verbose_name="amount"
),
),
(
"is_always_shown",
models.BooleanField(
default=False,
help_text=(
"If this option is enabled, "
"people will see this price and be able to pay it, "
"even if another cheaper price exists. "
"Else it will visible only if it is the cheapest available price."
),
verbose_name="always show",
),
),
(
"label",
models.CharField(
default="",
help_text=(
"A short label for easier differentiation "
"if a user can see multiple prices."
),
max_length=32,
verbose_name="label",
blank=True,
),
),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
),
(
"updated_at",
models.DateTimeField(auto_now=True, verbose_name="updated at"),
),
(
"groups",
models.ManyToManyField(
related_name="prices", to="core.group", verbose_name="groups"
),
),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="prices",
to="counter.product",
verbose_name="product",
),
),
],
options={"verbose_name": "price"},
),
migrations.AlterField(
model_name="product",
name="tray",
field=models.BooleanField(
default=False,
help_text="Buy five, get the sixth free",
verbose_name="tray price",
),
),
migrations.RunPython(migrate_prices, reverse_code=migrations.RunPython.noop),
migrations.RemoveField(model_name="product", name="selling_price"),
migrations.RemoveField(model_name="product", name="special_selling_price"),
migrations.AlterField(
model_name="product",
name="description",
field=models.TextField(blank=True, default="", verbose_name="description"),
),
migrations.AlterField(
model_name="product",
name="product_type",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="products",
to="counter.producttype",
verbose_name="product type",
),
),
migrations.AlterField(
model_name="productformula",
name="result",
field=models.OneToOneField(
help_text="The product got with the formula.",
on_delete=django.db.models.deletion.CASCADE,
related_name="formula",
to="counter.product",
verbose_name="result product",
),
),
]

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