Compare commits

..

5 Commits

Author SHA1 Message Date
imperosol cea997350e sqsdqd 2026-05-23 16:28:40 +02:00
imperosol e362d29808 WIP 2026-05-23 16:28:37 +02:00
imperosol 9a0ae505e0 get_list_exact_or_404 util function 2026-05-23 16:24:19 +02:00
imperosol 610184900f fix some tests 2026-05-23 16:24:16 +02:00
imperosol dc8a678e39 Migrate albums and pictures to their own tables 2026-05-23 16:24:03 +02:00
88 changed files with 2578 additions and 2369 deletions
+7 -2
View File
@@ -46,7 +46,7 @@ from django.http import HttpRequest
from ninja_extra import ControllerBase from ninja_extra import ControllerBase
from ninja_extra.permissions import BasePermission from ninja_extra.permissions import BasePermission
from counter.utils import is_logged_in_counter from counter.models import Counter
class IsInGroup(BasePermission): class IsInGroup(BasePermission):
@@ -186,7 +186,12 @@ class IsLoggedInCounter(BasePermission):
"""Check that a user is logged in a counter.""" """Check that a user is logged in a counter."""
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool: def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
return is_logged_in_counter(request) if "/counter/" not in request.META.get("HTTP_REFERER", ""):
return False
token = request.session.get("counter_token")
if not token:
return False
return Counter.objects.filter(token=token).exists()
CanAccessLookup = IsLoggedInCounter | HasPerm("core.access_lookup") CanAccessLookup = IsLoggedInCounter | HasPerm("core.access_lookup")
-58
View File
@@ -21,13 +21,10 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
import itertools
from operator import attrgetter
from django import forms from django import forms
from django.db.models import Exists, OuterRef, Q, QuerySet from django.db.models import Exists, OuterRef, Q, QuerySet
from django.db.models.functions import Lower from django.db.models.functions import Lower
from django.forms.models import ModelChoiceField, ModelChoiceIterator
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -49,37 +46,6 @@ from counter.models import Counter, Selling
from counter.schemas import SaleFilterSchema from counter.schemas import SaleFilterSchema
class ClubRoleChoiceIterator(ModelChoiceIterator):
"""Custom `ModelChoiceIterator` for `ClubRoleChoiceField`"""
def __iter__(self):
if self.field.empty_label is not None:
yield "", self.field.empty_label
queryset = self.queryset.select_related("club").order_by("club", "order")
groups = [
(club, [self.choice(role) for role in roles])
for club, roles in itertools.groupby(queryset, key=attrgetter("club"))
]
if len(groups) == 1:
# there is only one club involved, no need to have optgroups
yield from groups[0][1]
else:
# there are multiple clubs, optgroups are necessary to differentiate
# roles having the same name
yield from groups
class ClubRoleChoiceField(ModelChoiceField):
"""Custom `ModelChoiceField` for `[ClubRole][club.models.ClubRole]`.
If only one club is involved, behave like the base `ModelChoiceField`.
If dealing with the roles of multiple clubs, group the roles
into a different `optgroup` for each club.
"""
iterator = ClubRoleChoiceIterator
class ClubLinkForm(forms.ModelForm): class ClubLinkForm(forms.ModelForm):
error_css_class = "error" error_css_class = "error"
required_css_class = "required" required_css_class = "required"
@@ -426,30 +392,6 @@ class ClubRoleForm(forms.ModelForm):
self.instance.order = cleaned_data["ORDER"] - 1 self.instance.order = cleaned_data["ORDER"] - 1
return cleaned_data return cleaned_data
def save(self, commit=True): # noqa: FBT002
instance: ClubRole = super().save(commit=commit)
if commit and "is_board" in self.changed_data:
# if the role was moved from board to simple member,
# remove all users with that role from the club board group.
# If the role became a board role, add users with
# that role to the club board group.
group_id = instance.club.board_group_id
if self.cleaned_data["is_board"]:
User.groups.through.objects.bulk_create(
[
User.groups.through(user_id=u, group_id=group_id)
for u in Membership.objects.ongoing()
.filter(role=instance)
.values_list("user_id", flat=True)
],
ignore_conflicts=True,
)
else:
User.groups.through.objects.filter(
user__memberships__role=instance, group_id=group_id
).delete()
return instance
class ClubRoleCreateForm(forms.ModelForm): class ClubRoleCreateForm(forms.ModelForm):
"""Form to create a club role. """Form to create a club role.
+2 -1
View File
@@ -25,7 +25,8 @@ class Migration(migrations.Migration):
"url_base", "url_base",
models.URLField( models.URLField(
help_text=( help_text=(
"The base url that links with this type must respect" "The base url that links with this type "
"must respect (e.g. `https://www.instagram.com`)"
), ),
unique=True, unique=True,
verbose_name="url base", verbose_name="url base",
+4 -1
View File
@@ -793,7 +793,10 @@ class LinkType(models.Model):
url_base = models.URLField( url_base = models.URLField(
"url base", "url base",
unique=True, unique=True,
help_text=_("The base url that links with this type must respect"), 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 = models.CharField(
_("icon"), _("icon"),
+1 -28
View File
@@ -4,7 +4,6 @@ import pytest
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker, seq from model_bakery import baker, seq
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
@@ -240,7 +239,7 @@ class TestClubRoleUpdate(TestCase):
def test_president_moves_itself_out_of_the_presidency(self): def test_president_moves_itself_out_of_the_presidency(self):
"""Test that if the user moves its own role out of the presidency, """Test that if the user moves its own role out of the presidency,
then it loses access to the update page.""" then it's redirected to another page and loses access to the update page."""
self.payload["roles-0-is_presidency"] = False self.payload["roles-0-is_presidency"] = False
self.client.force_login(self.user) self.client.force_login(self.user)
res = self.client.post(self.url, data=self.payload) res = self.client.post(self.url, data=self.payload)
@@ -252,29 +251,3 @@ class TestClubRoleUpdate(TestCase):
res = self.client.get(self.url) res = self.client.get(self.url)
assert res.status_code == 403 assert res.status_code == 403
def test_role_stops_being_board(self):
"""Test that if a role stops being a board role,
its users lose the club board group."""
self.payload["roles-0-is_board"] = False
self.payload["roles-0-is_presidency"] = False
self.payload["roles-1-is_board"] = False
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
assert formset.is_valid()
formset.save()
assert not self.user.groups.contains(self.club.board_group)
def test_role_becomes_board(self):
"""Test that if a role becomes a board role,
its active users get the club board group"""
members = [
baker.make(Membership, club=self.club, role=self.roles[0], end_date=None),
baker.make(Membership, club=self.club, role=self.roles[0], end_date=now()),
]
self.payload["roles-2-is_board"] = True
formset = ClubRoleFormSet(data=self.payload, instance=self.club)
assert formset.is_valid()
formset.save()
# the second membership is finished, so its user shouldn't get the role
assert members[0].user.groups.contains(self.club.board_group)
assert not members[1].user.groups.contains(self.club.board_group)
+2 -2
View File
@@ -99,9 +99,9 @@ class PageAdmin(admin.ModelAdmin):
@admin.register(SithFile) @admin.register(SithFile)
class SithFileAdmin(admin.ModelAdmin): class SithFileAdmin(admin.ModelAdmin):
list_display = ("name", "owner", "size", "date", "is_in_sas") list_display = ("name", "owner", "size", "date")
autocomplete_fields = ("parent", "owner", "moderator") autocomplete_fields = ("parent", "owner", "moderator")
search_fields = ("name", "parent__name") search_fields = ("name",)
@admin.register(OperationLog) @admin.register(OperationLog)
+1 -1
View File
@@ -110,7 +110,7 @@ class SithFileController(ControllerBase):
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
def search_files(self, search: Annotated[str, MinLen(1)]): def search_files(self, search: Annotated[str, MinLen(1)]):
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search) return SithFile.objects.filter(name__icontains=search)
@api_controller("/group") @api_controller("/group")
+63 -158
View File
@@ -20,7 +20,7 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
from datetime import date, datetime, timedelta from datetime import date, timedelta
from io import StringIO from io import StringIO
from pathlib import Path from pathlib import Path
from typing import ClassVar, NamedTuple from typing import ClassVar, NamedTuple
@@ -33,8 +33,7 @@ from django.core.management.base import BaseCommand
from django.db import connection from django.db import connection
from django.db.models import Q from django.db.models import Q
from django.utils import timezone from django.utils import timezone
from django.utils.lorem_ipsum import paragraphs from django.utils.timezone import localdate
from django.utils.timezone import localdate, now
from PIL import Image from PIL import Image
from club.models import Club, ClubLink, ClubRole, LinkType, Membership from club.models import Club, ClubLink, ClubRole, LinkType, Membership
@@ -44,14 +43,13 @@ from core.models import BanGroup, Group, Page, PageRev, SithFile, User
from core.utils import resize_image from core.utils import resize_image
from counter.models import ( from counter.models import (
Counter, Counter,
CounterSellers,
Price, Price,
Product, Product,
ProductType, ProductType,
ReturnableProduct, ReturnableProduct,
StudentCard, StudentCard,
) )
from election.models import Candidature, Election, ElectionList, Role, Vote from election.models import Candidature, Election, ElectionList, Role
from forum.models import Forum from forum.models import Forum
from pedagogy.models import UE from pedagogy.models import UE
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
@@ -126,8 +124,9 @@ class Command(BaseCommand):
p.save(force_lock=True) p.save(force_lock=True)
club_root = SithFile.objects.create(name="clubs", owner=root) club_root = SithFile.objects.create(name="clubs", owner=root)
sas = SithFile.objects.create( sas = SithFile.objects.create(name="SAS", owner=root)
name="SAS", owner=root, id=settings.SITH_SAS_ROOT_DIR_ID main_club = Club.objects.create(
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
) )
clubs = self._create_clubs() clubs = self._create_clubs()
@@ -366,15 +365,62 @@ class Command(BaseCommand):
Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE") Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE")
# Add barman to counter # Add barman to counter
CounterSellers.objects.bulk_create( Counter.sellers.through.objects.bulk_create(
[ [
CounterSellers(counter_id=1, user=skia, is_regular=True), # MDE Counter.sellers.through(counter_id=1, user=skia), # MDE
CounterSellers(counter_id=2, user=krophil, is_regular=True), # Foyer Counter.sellers.through(counter_id=2, user=krophil), # Foyer
] ]
) )
# Create an election # Create an election
self._create_elections(groups, clubs, skia, sli, krophil) el = Election.objects.create(
title="Élection 2017",
description="La roue tourne",
start_candidature="1942-06-12 10:28:45+01",
end_candidature="2042-06-12 10:28:45+01",
start_date="1942-06-12 10:28:45+01",
end_date="7942-06-12 10:28:45+01",
)
el.view_groups.add(groups.public)
el.edit_groups.add(clubs.ae.board_group)
el.candidature_groups.add(groups.subscribers)
el.vote_groups.add(groups.subscribers)
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
listeT = ElectionList.objects.create(title="Troll", election=el)
pres = Role.objects.create(
election=el, title="Président AE", description="Roi de l'AE"
)
resp = Role.objects.create(
election=el, title="Co Respo Info", max_choice=2, description="Ghetto++"
)
Candidature.objects.bulk_create(
[
Candidature(
role=resp,
user=skia,
election_list=liste,
program="Refesons le site AE",
),
Candidature(
role=resp,
user=sli,
election_list=liste,
program="Vasy je deviens mon propre adjoint",
),
Candidature(
role=resp,
user=krophil,
election_list=listeT,
program="Le Pôle Troll !",
),
Candidature(
role=pres,
user=sli,
election_list=listeT,
program="En fait j'aime pas l'info, je voulais faire GMC",
),
]
)
# Forum # Forum
room = Forum.objects.create( room = Forum.objects.create(
@@ -530,32 +576,20 @@ class Command(BaseCommand):
# SAS # SAS
for f in self.SAS_FIXTURE_PATH.glob("*"): for f in self.SAS_FIXTURE_PATH.glob("*"):
if f.is_dir(): if f.is_dir():
album = Album( album = Album.objects.create(name=f.name, is_moderated=True)
parent=sas,
name=f.name,
owner=root,
is_folder=True,
is_in_sas=True,
is_moderated=True,
)
album.clean()
album.save()
for p in f.iterdir(): for p in f.iterdir():
file = resize_image(Image.open(p), 1000, "WEBP") file = resize_image(Image.open(p), 1000, "WEBP")
pict = Picture( pict = Picture(
parent=album, parent=album,
name=p.name, name=p.name,
file=file, original=file,
owner=root, owner=root,
is_folder=False,
is_in_sas=True,
is_moderated=True, is_moderated=True,
mime_type="image/webp",
size=file.size,
) )
pict.file.name = p.name pict.original.name = pict.name
pict.full_clean() pict.generate_thumbnails()
pict.generate_thumbnails(save=True) pict.full_clean(save=True)
album.generate_thumbnail()
img_skia = Picture.objects.get(name="skia.jpg") img_skia = Picture.objects.get(name="skia.jpg")
img_sli = Picture.objects.get(name="sli.jpg") img_sli = Picture.objects.get(name="sli.jpg")
@@ -965,132 +999,3 @@ class Command(BaseCommand):
BanGroup.objects.create(name="Banned from buying alcohol", description="") BanGroup.objects.create(name="Banned from buying alcohol", description="")
BanGroup.objects.create(name="Banned from counters", description="") BanGroup.objects.create(name="Banned from counters", description="")
BanGroup.objects.create(name="Banned to subscribe", description="") BanGroup.objects.create(name="Banned to subscribe", description="")
def _create_elections(
self,
groups: PopulatedGroups,
clubs: PopulatedClubs,
skia: User,
sli: User,
krophil: User,
):
"""Populate elections.
4 elections are created :
- one that has not started yet,
- one on the candidature period
- one on the vote period
- one that is finished
All elections have two lists, are linked to the AE and Troll clubs,
and have one role for each board role of thos two clubs, plus
an additional role linked to no club roles.
The ongoing vote and finished elections have candidates.
The finished election has 10 voters.
"""
def election_factory(title: str, start_candidature: datetime):
return Election(
title=title,
description="",
start_candidature=start_candidature,
end_candidature=start_candidature + timedelta(days=7),
start_date=start_candidature + timedelta(days=7),
end_date=start_candidature + timedelta(days=14),
)
# create the elections
elections = Election.objects.bulk_create(
[
election_factory("Election terminée", now() - timedelta(days=14)),
election_factory("Votes en cours", now() - timedelta(days=7)),
election_factory("Candidatures en cours", now()),
election_factory("Election à venir", now() + timedelta(days=7)),
]
)
finished, ongoing_vote, _ongoing_candidature, _not_started = elections
# set the groups (all elections have the same groups)
groups.public.viewable_elections.set(elections)
clubs.ae.board_group.editable_elections.set(elections)
groups.subscribers.candidate_elections.set(elections)
groups.subscribers.votable_elections.set(elections)
# link elections to clubs (AE and Troll for all elections)
Election.clubs.through.objects.bulk_create(
[
*[Election.clubs.through(club=clubs.ae, election=e) for e in elections],
*[
Election.clubs.through(club=clubs.troll, election=e)
for e in elections
],
]
)
# Create lists (all elections have two lists)
ElectionList.objects.bulk_create(
[
*[ElectionList(title="Candidat libre", election=e) for e in elections],
*[ElectionList(title="Troll", election=e) for e in elections],
]
)
# Create roles.
# Elections have a role for each board club role of AE and Troll,
# +an additional role linked to no club role
club_roles = list(
ClubRole.objects.filter(club__in=[clubs.ae, clubs.troll], is_board=True)
.select_related("club")
.order_by("club_id", "order")
)
Role.objects.bulk_create(
[
*[
Role(election=e, title=f"{r.name} {r.club.name}", club_role=r)
for r in club_roles
for e in elections
],
*[Role(election=e, title="Rôle libre") for e in elections],
]
)
# create candidatures for ongoing_vote and finished elections
candidatures = []
lipsum = "\n\n".join(paragraphs(2))
for election in ongoing_vote, finished:
lists = list(election.election_lists.order_by("id"))
roles = list(election.roles.order_by("order")[:3])
candidatures.extend(
[
Candidature(
role=roles[0], user=skia, election_list=lists[0], program=lipsum
),
Candidature(
role=roles[1], user=sli, election_list=lists[0], program=lipsum
),
Candidature(
role=roles[2], user=krophil, election_list=lists[1], program=""
),
Candidature(
role=roles[2], user=sli, election_list=lists[0], program=lipsum
),
]
)
candidatures = Candidature.objects.bulk_create(candidatures)
skia, sli_vp, krophil, sli_treso = candidatures[4:] # candidates of finished
votes = Vote.objects.bulk_create(
[
*[Vote(role=skia.role) for _ in range(6)],
*[Vote(role=sli_vp.role) for _ in range(8)],
*[Vote(role=krophil.role) for _ in range(9)],
]
)
skia.votes.set(votes[:6])
sli_vp.votes.set(votes[6:14])
krophil.votes.set(votes[14:20])
sli_treso.votes.set(votes[20:23])
finished.voters.set(list(User.objects.all()[:10]))
+27
View File
@@ -0,0 +1,27 @@
# Generated by Django 4.2.17 on 2025-01-26 15:01
from typing import TYPE_CHECKING
from django.db import migrations
from django.db.migrations.state import StateApps
if TYPE_CHECKING:
import core.models
def remove_sas_sithfiles(apps: StateApps, schema_editor):
SithFile: type[core.models.SithFile] = apps.get_model("core", "SithFile")
SithFile.objects.filter(is_in_sas=True).delete()
class Migration(migrations.Migration):
dependencies = [
("core", "0048_alter_user_options"),
("sas", "0007_alter_peoplepicturerelation_picture_and_more"),
]
operations = [
migrations.RunPython(
remove_sas_sithfiles, reverse_code=migrations.RunPython.noop, elidable=True
)
]
@@ -0,0 +1,9 @@
# Generated by Django 4.2.17 on 2025-02-14 11:58
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("core", "0049_remove_sithfiles")]
operations = [migrations.RemoveField(model_name="sithfile", name="is_in_sas")]
-33
View File
@@ -876,9 +876,6 @@ class SithFile(models.Model):
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
) )
asked_for_removal = models.BooleanField(_("asked for removal"), default=False) asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
is_in_sas = models.BooleanField(
_("is in the SAS"), default=False, db_index=True
) # Allows to query this flag, updated at each call to save()
class Meta: class Meta:
verbose_name = _("file") verbose_name = _("file")
@@ -887,24 +884,10 @@ class SithFile(models.Model):
return self.get_parent_path() + "/" + self.name return self.get_parent_path() + "/" + self.name
def save(self, *args, **kwargs): 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()
)
adding = self._state.adding adding = self._state.adding
super().save(*args, **kwargs) super().save(*args, **kwargs)
if adding: if adding:
self.copy_rights() self.copy_rights()
if self.is_in_sas:
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
):
Notification(
user=user,
url=reverse("sas:moderation"),
type="SAS_MODERATION",
param="1",
).save()
def is_owned_by(self, user: User) -> bool: def is_owned_by(self, user: User) -> bool:
if user.is_anonymous: if user.is_anonymous:
@@ -917,8 +900,6 @@ class SithFile(models.Model):
return user.is_board_member return user.is_board_member
if user.is_com_admin: if user.is_com_admin:
return True return True
if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return True
return user.id == self.owner_id return user.id == self.owner_id
def can_be_viewed_by(self, user: User) -> bool: def can_be_viewed_by(self, user: User) -> bool:
@@ -945,8 +926,6 @@ class SithFile(models.Model):
super().clean() super().clean()
if "/" in self.name: if "/" in self.name:
raise ValidationError(_("Character '/' not authorized in name")) raise ValidationError(_("Character '/' not authorized in name"))
if self == self.parent:
raise ValidationError(_("Loop in folder tree"), code="loop")
if self == self.parent or ( if self == self.parent or (
self.parent is not None and self in self.get_parent_list() self.parent is not None and self in self.get_parent_list()
): ):
@@ -1027,18 +1006,6 @@ class SithFile(models.Model):
def is_file(self): def is_file(self):
return not self.is_folder return not self.is_folder
@cached_property
def as_picture(self):
from sas.models import Picture
return Picture.objects.filter(id=self.id).first()
@cached_property
def as_album(self):
from sas.models import Album
return Album.objects.filter(id=self.id).first()
def get_parent_list(self): def get_parent_list(self):
parents = [] parents = []
current = self.parent current = self.parent
-4
View File
@@ -46,10 +46,6 @@ details.accordion>.accordion-content {
border-bottom-right-radius: 3px; border-bottom-right-radius: 3px;
border-bottom-left-radius: 3px; border-bottom-left-radius: 3px;
overflow: hidden; overflow: hidden;
@media screen and (max-width: 600px) {
padding: .75em 1.5em;
}
} }
@mixin animation($selector) { @mixin animation($selector) {
+1 -6
View File
@@ -29,12 +29,7 @@
align-items: center; align-items: center;
gap: 20px; gap: 20px;
&:disabled { &.clickable:hover {
background-color: darken($primary-neutral-light-color, 5%);
opacity: 65%;
}
&.clickable:not(:disabled):hover {
background-color: darken($primary-neutral-light-color, 5%); background-color: darken($primary-neutral-light-color, 5%);
} }
+1 -1
View File
@@ -23,7 +23,7 @@
border-radius: 5px; border-radius: 5px;
color: black; color: black;
&:not(.link-like):not(:disabled):hover { &:hover {
background: hsl(0, 0%, 83%); background: hsl(0, 0%, 83%);
} }
} }
+2 -2
View File
@@ -123,7 +123,7 @@ $background-color-hovered: #283747;
justify-content: center; justify-content: center;
} }
a.button { >.button {
box-sizing: border-box; box-sizing: border-box;
height: 35px; height: 35px;
background-color: transparent; background-color: transparent;
@@ -139,7 +139,7 @@ $background-color-hovered: #283747;
font-size: .9em; font-size: .9em;
width: 120px; width: 120px;
&:not(.link-like):not(:disabled):hover { &:hover {
background-color: $background-color-hovered; background-color: $background-color-hovered;
} }
} }
+8 -3
View File
@@ -22,9 +22,14 @@
</form> </form>
<ul class="bars"> <ul class="bars">
{% cache 100 "counters_activity" %} {% cache 100 "counters_activity" %}
{# It would be cleaner to handle the timeout with django-celery-beat, {# The sith has no periodic tasks manager
but doing it here is simpler and less error-prone #} and using cron jobs would be way too overkill here.
{% do Counter.objects.filter(type="BAR").handle_timeout() %} Thus the barmen timeout is handled in the only place that
is loaded on every page : the header bar.
However, let's be clear : this has nothing to do here.
It's' merely a contrived workaround that should
replaced by a proper task manager as soon as possible. #}
{% set _ = Counter.objects.filter(type="BAR").handle_timeout() %}
{% endcache %} {% endcache %}
{% for bar in Counter.objects.annotate_has_barman(user).annotate_is_open().filter(type="BAR") %} {% for bar in Counter.objects.annotate_has_barman(user).annotate_is_open().filter(type="BAR") %}
<li> <li>
+1 -1
View File
@@ -10,7 +10,7 @@
<template x-for="(message, index) in $notifications.getAll()"> <template x-for="(message, index) in $notifications.getAll()">
<div class="alert" :class="`alert-${message.tag}`" x-transition> <div class="alert" :class="`alert-${message.tag}`" x-transition>
<span class="alert-main" x-text="message.text"></span> <span class="alert-main" x-text="message.text"></span>
<span class="clickable" @click="$store.notifications = $store.notifications.filter((item, i) => i !== index)"> <span class="clickable" @click="messages = messages.filter((item, i) => i !== index)">
<i class="fa fa-close"></i> <i class="fa fa-close"></i>
</span> </span>
</div> </div>
+41 -11
View File
@@ -5,6 +5,7 @@ from typing import Callable
from uuid import uuid4 from uuid import uuid4
import pytest import pytest
from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
from django.test import Client, TestCase from django.test import Client, TestCase
@@ -17,8 +18,8 @@ from pytest_django.asserts import assertNumQueries
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
from core.models import Group, QuickUploadImage, SithFile, User from core.models import Group, QuickUploadImage, SithFile, User
from core.utils import RED_PIXEL_PNG from core.utils import RED_PIXEL_PNG
from sas.baker_recipes import picture_recipe
from sas.models import Picture from sas.models import Picture
from sith import settings
@pytest.mark.django_db @pytest.mark.django_db
@@ -30,24 +31,19 @@ class TestImageAccess:
lambda: baker.make( lambda: baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)] User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
), ),
lambda: baker.make(
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
),
], ],
) )
def test_sas_image_access(self, user_factory: Callable[[], User]): def test_sas_image_access(self, user_factory: Callable[[], User]):
"""Test that only authorized users can access the sas image.""" """Test that only authorized users can access the sas image."""
user = user_factory() user = user_factory()
picture: SithFile = baker.make( picture = picture_recipe.make()
Picture, parent=SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID) assert user.can_edit(picture)
)
assert picture.is_owned_by(user)
def test_sas_image_access_owner(self): def test_sas_image_access_owner(self):
"""Test that the owner of the image can access it.""" """Test that the owner of the image can access it."""
user = baker.make(User) user = baker.make(User)
picture: Picture = baker.make(Picture, owner=user) picture = picture_recipe.make(owner=user)
assert picture.is_owned_by(user) assert user.can_edit(picture)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"user_factory", "user_factory",
@@ -63,7 +59,41 @@ class TestImageAccess:
user = user_factory() user = user_factory()
owner = baker.make(User) owner = baker.make(User)
picture: Picture = baker.make(Picture, owner=owner) picture: Picture = baker.make(Picture, owner=owner)
assert not picture.is_owned_by(user) assert not user.can_edit(picture)
@pytest.mark.django_db
class TestUserPicture:
def test_anonymous_user_unauthorized(self, client):
"""An anonymous user shouldn't have access to an user's photo page."""
response = client.get(
reverse(
"sas:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == 403
@pytest.mark.parametrize(
("username", "status"),
[
("guy", 403),
("root", 200),
("skia", 200),
("sli", 200),
],
)
def test_page_is_working(self, client, username, status):
"""Only user that subscribed (or admins) should be able to see the page."""
# Test for simple user
client.force_login(User.objects.get(username=username))
response = client.get(
reverse(
"sas:user_pictures",
kwargs={"user_id": User.objects.get(username="sli").pk},
)
)
assert response.status_code == status
# TODO: many tests on the pages: # TODO: many tests on the pages:
+2 -1
View File
@@ -27,6 +27,7 @@ from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Permanency, Refilling, Selling from counter.models import Counter, Customer, Permanency, Refilling, Selling
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
from eboutic.models import Invoice, InvoiceItem from eboutic.models import Invoice, InvoiceItem
from sas.models import Picture
class TestSearchUsers(TestCase): class TestSearchUsers(TestCase):
@@ -34,7 +35,7 @@ class TestSearchUsers(TestCase):
def setUpTestData(cls): def setUpTestData(cls):
# News.author has on_delete=PROTECT, so news must be deleted beforehand # News.author has on_delete=PROTECT, so news must be deleted beforehand
News.objects.all().delete() News.objects.all().delete()
SithFile.objects.all().delete() Picture.objects.all().delete() # same for pictures
User.objects.all().delete() User.objects.all().delete()
user_recipe = Recipe( user_recipe = Recipe(
User, User,
+76 -3
View File
@@ -12,18 +12,23 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from dataclasses import dataclass
from datetime import date, timedelta from datetime import date, timedelta
# Image utils # Image utils
from io import BytesIO from io import BytesIO
from typing import Final from typing import Any, Final, Unpack
import PIL import PIL
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.uploadedfile import UploadedFile from django.core.files.uploadedfile import UploadedFile
from django.http import HttpRequest from django.db import models
from django.forms import BaseForm
from django.http import Http404, HttpRequest
from django.shortcuts import get_list_or_404
from django.template.loader import render_to_string
from django.utils.safestring import SafeString
from django.utils.timezone import localdate from django.utils.timezone import localdate
from PIL.Image import Image, Resampling from PIL.Image import Image, Resampling
@@ -41,6 +46,21 @@ to generate a dummy image that is considered valid nonetheless
""" """
@dataclass
class FormFragmentTemplateData[T: BaseForm]:
"""Dataclass used to pre-render form fragments"""
form: T
template: str
context: dict[str, Any]
def render(self, request: HttpRequest) -> SafeString:
# Request is needed for csrf_tokens
return render_to_string(
self.template, context={"form": self.form, **self.context}, request=request
)
def get_start_of_semester(today: date | None = None) -> date: def get_start_of_semester(today: date | None = None) -> date:
"""Return the date of the start of the semester of the given date. """Return the date of the start of the semester of the given date.
If no date is given, return the start date of the current semester. If no date is given, return the start date of the current semester.
@@ -188,3 +208,56 @@ def get_client_ip(request: HttpRequest) -> str | None:
return ip return ip
return None return None
Filterable = type[models.Model] | models.QuerySet | models.Manager
ListFilter = dict[str, list | tuple | set]
def get_list_exact_or_404(klass: Filterable, **kwargs: Unpack[ListFilter]) -> list:
"""Use filter() to return a list of objects from a list of unique keys (like ids)
or raises Http404 if the list has not the same length as the given one.
Work like `get_object_or_404()` but for lists of objects, with some caveats :
- The filter must be a list, a tuple or a set.
- There can't be more than exactly one filter.
- There must be no duplicate in the filter.
- The filter should consist in unique keys (like ids), or it could fail randomly.
klass may be a Model, Manager, or QuerySet object. All other passed
arguments and keyword arguments are used in the filter() query.
Raises:
Http404: If the list is empty or doesn't have as many elements as the keys list.
ValueError: If the first argument is not a Model, Manager, or QuerySet object.
ValueError: If more than one filter is passed.
TypeError: If the given filter is not a list, a tuple or a set.
Examples:
Get all the products with ids 1, 2, 3: ::
products = get_list_exact_or_404(Product, id__in=[1, 2, 3])
Don't work with duplicate ids: ::
products = get_list_exact_or_404(Product, id__in=[1, 2, 3, 3])
# Raises Http404: "The list of keys must contain no duplicates."
"""
if len(kwargs) > 1:
raise ValueError("get_list_exact_or_404() only accepts one filter.")
key, list_filter = next(iter(kwargs.items()))
if not isinstance(list_filter, (list, tuple, set)):
raise TypeError(
f"The given filter must be a list, a tuple or a set, not {type(list_filter)}"
)
if len(list_filter) != len(set(list_filter)):
raise ValueError("The list of keys must contain no duplicates.")
kwargs = {key: list_filter}
obj_list = get_list_or_404(klass, **kwargs)
if len(obj_list) != len(list_filter):
raise Http404(
"The given list of keys doesn't match the number of objects found."
f"Expected {len(list_filter)} items, got {len(obj_list)}."
)
return obj_list
+1 -1
View File
@@ -374,7 +374,7 @@ class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
class FileModerationView(AllowFragment, ListView): class FileModerationView(AllowFragment, ListView):
model = SithFile model = SithFile
template_name = "core/file_moderation.jinja" template_name = "core/file_moderation.jinja"
queryset = SithFile.objects.filter(is_moderated=False, is_in_sas=False) queryset = SithFile.objects.filter(is_moderated=False)
ordering = "id" ordering = "id"
paginate_by = 100 paginate_by = 100
+16 -36
View File
@@ -9,7 +9,6 @@ from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet from django.forms import BaseModelFormSet
from django.http import HttpRequest
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_celery_beat.models import ClockedSchedule from django_celery_beat.models import ClockedSchedule
@@ -18,7 +17,6 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget
from club.models import Club from club.models import Club
from club.widgets.ajax_select import AutoCompleteSelectClub from club.widgets.ajax_select import AutoCompleteSelectClub
from core.models import User, UserQuerySet from core.models import User, UserQuerySet
from core.views import LoginForm
from core.views.forms import ( from core.views.forms import (
FutureDateTimeField, FutureDateTimeField,
NFCTextInput, NFCTextInput,
@@ -93,18 +91,30 @@ class StudentCardForm(forms.ModelForm):
class GetUserForm(forms.Form): class GetUserForm(forms.Form):
"""Find a user to show its click page.""" """The Form class aims at providing a valid user_id field in its cleaned data, in order to pass it to some view,
reverse function, or any other use.
The Form implements a nice JS widget allowing the user to type a customer account id, or search the database with
some nickname, first name, or last name (TODO)
"""
code = forms.CharField( code = forms.CharField(
label="Code", label="Code",
max_length=StudentCard.UID_SIZE, max_length=StudentCard.UID_SIZE,
required=False, required=False,
widget=NFCTextInput(attrs={"autofocus": True}), widget=NFCTextInput,
) )
id = forms.CharField( id = forms.CharField(
label=_("Select user"), widget=AutoCompleteSelectUser, required=False label=_("Select user"),
help_text=None,
widget=AutoCompleteSelectUser,
required=False,
) )
def as_p(self):
self.fields["code"].widget.attrs["autofocus"] = True
return super().as_p()
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()
customer = None customer = None
@@ -126,40 +136,11 @@ class GetUserForm(forms.Form):
if customer is None or not customer.can_buy: if customer is None or not customer.can_buy:
raise forms.ValidationError(_("User not found")) raise forms.ValidationError(_("User not found"))
cleaned_data["user_id"] = customer.user_id cleaned_data["user_id"] = customer.user.id
cleaned_data["user"] = customer.user cleaned_data["user"] = customer.user
return cleaned_data return cleaned_data
class CounterLoginForm(LoginForm):
"""LoginForm to log a barman in a counter.
To be able to log in a counter, a user must :
- be part of the sellers of the given counter
- not being already logged in any counter
"""
def __init__(self, *args, request: HttpRequest, counter: Counter, **kwargs):
super().__init__(*args, **kwargs)
self.counter = counter
self.request = request
def confirm_login_allowed(self, user: User):
super().confirm_login_allowed(user)
if not self.counter.sellers.contains(user):
raise ValidationError(
message=_("You are not a barman of this counter."), code="not_barman"
)
if user in self.request.barmen:
message = (
_("You are already logged in this counter.")
if user in self.counter.barmen_list
else _("You are already logged in another counter.")
)
raise ValidationError(message=message, code="already_logged_in")
class RefillForm(forms.ModelForm): class RefillForm(forms.ModelForm):
allowed_refilling_methods = [ allowed_refilling_methods = [
Refilling.PaymentMethod.CASH, Refilling.PaymentMethod.CASH,
@@ -428,7 +409,6 @@ class ProductForm(forms.ModelForm):
"club", "club",
"limit_age", "limit_age",
"tray", "tray",
"clic_limit",
"archived", "archived",
] ]
help_texts = { help_texts = {
-64
View File
@@ -1,64 +0,0 @@
from typing import TYPE_CHECKING, Callable
from django.db.models import Exists, OuterRef
from django.http import HttpRequest, HttpResponse
from django.utils.functional import SimpleLazyObject, empty
from core.models import User
from counter.models import Permanency
if TYPE_CHECKING:
from django.contrib.sessions.backends.base import SessionBase
SESSION_BARMEN_KEY = "barmen_ids"
def get_cached_barmen(request: HttpRequest) -> set[User]:
if not hasattr(request, "_cached_barmen"):
session: SessionBase = request.session
barmen_ids = session.get(SESSION_BARMEN_KEY, [])
if barmen_ids:
request._cached_barmen = set(
User.objects.filter(
Exists(Permanency.objects.filter(user=OuterRef("pk"), end=None)),
id__in=barmen_ids,
)
)
else:
request._cached_barmen = set()
return request._cached_barmen
class BarmenMiddleware:
"""Inject barmen logged in the current session.
In a similar fashion as `request.user`, `request.barmen` contains
users that are barmen in the current session, and ONLY them ;
if a user is logged as a barman on another session,
it will not be in `request.barmen`.
Notes:
In case of ended permanence, users will be automatically
removed from `request.barmen`.
However, in case of newly started permanence, this middleware
cannot add new barmen in the session data, so that operation
must be explicitly done in the barman login view.
"""
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
self.get_response = get_response
def __call__(self, request: HttpRequest):
request.barmen = SimpleLazyObject(lambda: get_cached_barmen(request))
response = self.get_response(request)
if request.barmen._wrapped is not empty and {
b.id for b in request.barmen
} != set(request.session.get(SESSION_BARMEN_KEY, [])):
# update the session data only if `session.barmen`
# has been accessed and modified.
request.session[SESSION_BARMEN_KEY] = [b.id for b in request.barmen]
return response
@@ -1,25 +0,0 @@
# Generated by Django 5.2.13 on 2026-05-13 11:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("counter", "0039_price")]
operations = [
migrations.RemoveField(model_name="product", name="buying_groups"),
migrations.AddField(
model_name="product",
name="clic_limit",
field=models.PositiveSmallIntegerField(
blank=True,
help_text=(
"If a limit is set, the product won't be purchasable "
"anymore once the latter is reached."
),
null=True,
verbose_name="clic limit",
),
),
migrations.RemoveField(model_name="counter", name="token"),
]
+16 -49
View File
@@ -22,7 +22,7 @@ import string
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from datetime import timezone as tz from datetime import timezone as tz
from decimal import Decimal from decimal import Decimal
from typing import Literal, Self from typing import TYPE_CHECKING, Literal, Self
from dict2xml import dict2xml from dict2xml import dict2xml
from django.conf import settings from django.conf import settings
@@ -34,7 +34,6 @@ from django.forms import ValidationError
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_celery_beat.models import PeriodicTask from django_celery_beat.models import PeriodicTask
from django_countries.fields import CountryField from django_countries.fields import CountryField
@@ -48,6 +47,9 @@ from core.utils import get_start_of_semester
from counter.fields import CurrencyField from counter.fields import CurrencyField
from subscription.models import Subscription from subscription.models import Subscription
if TYPE_CHECKING:
from collections.abc import Sequence
def get_eboutic() -> Counter: def get_eboutic() -> Counter:
return Counter.objects.filter(type="EBOUTIC").order_by("id").first() return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
@@ -351,40 +353,6 @@ class ProductType(OrderedModel):
return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID) return user.is_in_group(pk=settings.SITH_GROUP_ACCOUNTING_ADMIN_ID)
class ProductQuerySet(models.QuerySet):
def under_clic_limit(self) -> Self:
"""Filter product which clic limit isn't reached yet.
The clic limit is reached when the amount of sales
and of items in a basket for less than 15 minutes
is greater or equal than `Product.clic_limit`.
"""
# import here to avoid circular import
from eboutic.models import BasketItem
nb_click_subquery = Subquery(
Selling.objects.filter(product_id=OuterRef("id"))
.values("product_id")
.annotate(res=Sum("quantity", default=0))
.values("res")[:1]
)
nb_basket_items_subquery = Subquery(
BasketItem.objects.filter(
product_id=OuterRef("id"),
basket__date__gt=now()
- settings.SITH_EBOUTIC_BASKET_TIMEOUT
- settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT,
)
.values("product_id")
.annotate(res=Sum("quantity"))
.values("res")[:1]
)
return self.annotate(
clicked=Coalesce(nb_click_subquery, 0),
reserved=Coalesce(nb_basket_items_subquery, 0),
).filter(Q(clic_limit=None) | Q(clic_limit__gt=(F("clicked") + F("reserved"))))
class Product(models.Model): class Product(models.Model):
"""A product, with all its related information.""" """A product, with all its related information."""
@@ -402,7 +370,8 @@ class Product(models.Model):
) )
code = models.CharField(_("code"), max_length=16, blank=True) code = models.CharField(_("code"), max_length=16, blank=True)
purchase_price = CurrencyField( purchase_price = CurrencyField(
_("purchase price"), help_text=_("Initial cost of purchasing the product") _("purchase price"),
help_text=_("Initial cost of purchasing the product"),
) )
icon = ResizedImageField( icon = ResizedImageField(
height=70, height=70,
@@ -419,21 +388,13 @@ class Product(models.Model):
tray = models.BooleanField( tray = models.BooleanField(
_("tray price"), help_text=_("Buy five, get the sixth free"), default=False _("tray price"), help_text=_("Buy five, get the sixth free"), default=False
) )
clic_limit = models.PositiveSmallIntegerField( buying_groups = models.ManyToManyField(
_("clic limit"), Group, related_name="products", verbose_name=_("buying groups"), blank=True
help_text=_(
"If a limit is set, the product won't be purchasable "
"anymore on the eboutic once the latter is reached."
),
null=True,
blank=True,
) )
archived = models.BooleanField(_("archived"), default=False) archived = models.BooleanField(_("archived"), default=False)
created_at = models.DateTimeField(_("created at"), auto_now_add=True) created_at = models.DateTimeField(_("created at"), auto_now_add=True)
updated_at = models.DateTimeField(_("updated at"), auto_now=True) updated_at = models.DateTimeField(_("updated at"), auto_now=True)
objects = ProductQuerySet.as_manager()
class Meta: class Meta:
verbose_name = _("product") verbose_name = _("product")
@@ -619,6 +580,7 @@ class Counter(models.Model):
view_groups = models.ManyToManyField( view_groups = models.ManyToManyField(
Group, related_name="viewable_counters", blank=True Group, related_name="viewable_counters", blank=True
) )
token = models.CharField(_("token"), max_length=30, null=True, blank=True)
objects = CounterQuerySet.as_manager() objects = CounterQuerySet.as_manager()
@@ -771,8 +733,10 @@ class Counter(models.Model):
# but they share the same primary key # but they share the same primary key
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list) return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
def get_prices_for(self, customer: Customer) -> PriceQuerySet: def get_prices_for(
return ( self, customer: Customer, *, order_by: Sequence[str] | None = None
) -> list[Price]:
qs = (
Price.objects.filter( Price.objects.filter(
product__counters=self, product__product_type__isnull=False product__counters=self, product__product_type__isnull=False
) )
@@ -780,6 +744,9 @@ class Counter(models.Model):
.select_related("product", "product__product_type") .select_related("product", "product__product_type")
.prefetch_related("groups") .prefetch_related("groups")
) )
if order_by:
qs = qs.order_by(*order_by)
return list(qs)
class CounterSellers(models.Model): class CounterSellers(models.Model):
+14 -7
View File
@@ -20,34 +20,41 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
import random
from django.db.models.signals import pre_delete from django.db.models.signals import pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from core.middleware import get_signal_request from core.middleware import get_signal_request
from core.models import OperationLog from core.models import OperationLog
from counter.models import Refilling, Selling from counter.models import Counter, Refilling, Selling
def write_log(instance: Selling | Refilling, operation_type): def write_log(instance, operation_type):
def get_user(): def get_user():
request = get_signal_request() request = get_signal_request()
if not request: if not request:
return None return None
if request.barmen: # Get a random barmen if deletion is from a counter
return random.choice(list(request.barmen)) session = getattr(request, "session", {})
session_token = session.get("counter_token", None)
if session_token:
counter = Counter.objects.filter(token=session_token).first()
if counter and len(counter.barmen_list) > 0:
return counter.get_random_barman()
# Get the current logged user if not from a counter # Get the current logged user if not from a counter
if request.user.is_authenticated: if request.user and not request.user.is_anonymous:
return request.user return request.user
# Return None by default
return None return None
OperationLog( OperationLog(
label=str(instance), operator=get_user(), operation_type=operation_type label=str(instance),
operator=get_user(),
operation_type=operation_type,
).save() ).save()
@@ -1,6 +1,6 @@
import type { RecursivePartial, TomSettings } from "tom-select/src/types"; import type { RecursivePartial, TomSettings } from "tom-select/dist/types/types";
import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base"; import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components"; import { registerComponent } from "#core:utils/web-components.ts";
const productParsingRegex = /^(\d+x)?(.*)/i; const productParsingRegex = /^(\d+x)?(.*)/i;
const codeParsingRegex = / \((\w+)\)$/; const codeParsingRegex = / \((\w+)\)$/;
@@ -63,6 +63,13 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
); );
}, },
); );
this.widget.hook("after", "onOptionSelect", () => {
/* Focus the next element if it's an input */
if (this.nextElementSibling.nodeName === "INPUT") {
(this.nextElementSibling as HTMLInputElement).focus();
}
});
} }
protected tomSelectSettings(): RecursivePartial<TomSettings> { protected tomSelectSettings(): RecursivePartial<TomSettings> {
/* We disable the dropdown on focus because we're going to always autofocus the widget */ /* We disable the dropdown on focus because we're going to always autofocus the widget */
@@ -73,7 +80,9 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
// We need to manually set weights or it results on an inconsistent // We need to manually set weights or it results on an inconsistent
// behavior between production and development environment // behavior between production and development environment
searchField: [ searchField: [
// @ts-expect-error documentation says it's fine, specified type is wrong
{ field: "code", weight: 2 }, { field: "code", weight: 2 },
// @ts-expect-error documentation says it's fine, specified type is wrong
{ field: "text", weight: 0.5 }, { field: "text", weight: 0.5 },
], ],
}; };
@@ -25,9 +25,6 @@ document.addEventListener("alpine:init", () => {
} }
this.codeField = this.$refs.codeField; this.codeField = this.$refs.codeField;
this.codeField.widget.hook("after", "onOptionSelect", () => {
this.handleCode();
});
this.codeField.widget.focus(); this.codeField.widget.focus();
// It's quite tricky to manually apply attributes to the management part // It's quite tricky to manually apply attributes to the management part
@@ -157,7 +154,6 @@ document.addEventListener("alpine:init", () => {
this.addToBasket(code, quantity); this.addToBasket(code, quantity);
} }
this.codeField.widget.clear(); this.codeField.widget.clear();
this.codeField.widget.setTextboxValue("");
this.codeField.widget.focus(); this.codeField.widget.focus();
}, },
})); }));
+1 -22
View File
@@ -42,28 +42,7 @@
min-width: 350px; min-width: 350px;
ul { ul {
list-style: none; list-style-type: none;
display: flex;
flex-direction: column;
gap: .5rem;
margin-left: 0;
.basket-row {
display: flex;
align-items: center;
gap: 1rem;
.product-name {
flex: 1 2 0;
min-width: 0;
text-wrap: wrap;
}
}
}
form {
margin-top: .5rem;
margin-bottom: .5rem;
} }
} }
+9 -18
View File
@@ -56,15 +56,10 @@
<div class="accordion-content"> <div class="accordion-content">
{% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %} {% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %}
<form method="post" action="" @submit.prevent="handleCode"> <form method="post" action=""
class="code_form" @submit.prevent="handleCode">
<counter-product-select <counter-product-select name="code" x-ref="codeField" autofocus required placeholder="{% trans %}Select a product...{% endtrans %}">
name="code"
x-ref="codeField"
autofocus
required
placeholder="{% trans %}Select a product...{% endtrans %}"
>
<option value=""></option> <option value=""></option>
<optgroup label="{% trans %}Operations{% endtrans %}"> <optgroup label="{% trans %}Operations{% endtrans %}">
<option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option> <option value="FIN">{% trans %}Confirm (FIN){% endtrans %}</option>
@@ -73,11 +68,13 @@
{%- for category, prices in categories.items() -%} {%- for category, prices in categories.items() -%}
<optgroup label="{{ category }}"> <optgroup label="{{ category }}">
{%- for price in prices -%} {%- for price in prices -%}
<option value="{{ price.id }}">{{ price.full_label }} ({{ price.product.code }})</option> <option value="{{ price.id }}">{{ price.full_label }}</option>
{%- endfor -%} {%- endfor -%}
</optgroup> </optgroup>
{%- endfor -%} {%- endfor -%}
</counter-product-select> </counter-product-select>
<input type="submit" value="{% trans %}Go{% endtrans %}"/>
</form> </form>
{% for error in form.non_form_errors() %} {% for error in form.non_form_errors() %}
@@ -105,9 +102,7 @@
{{ form.management_form }} {{ form.management_form }}
</div> </div>
<ul> <ul>
<li x-show="getBasketSize() === 0"> <li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
<em>{% trans %}This basket is empty{% endtrans %}</em>
</li>
<template x-for="(item, index) in Object.values(basket)" :key="item.product.price.id"> <template x-for="(item, index) in Object.values(basket)" :key="item.product.price.id">
<li> <li>
<template x-for="error in item.errors"> <template x-for="error in item.errors">
@@ -115,15 +110,12 @@
</div> </div>
</template> </template>
<div class="basket-row">
<div>
<button @click.prevent="addToBasket(item.product.price.id, -1)">-</button> <button @click.prevent="addToBasket(item.product.price.id, -1)">-</button>
<span class="quantity" x-text="item.quantity"></span> <span class="quantity" x-text="item.quantity"></span>
<button @click.prevent="addToBasket(item.product.price.id, 1)">+</button> <button @click.prevent="addToBasket(item.product.price.id, 1)">+</button>
</div>
<span class="product-name" x-text="item.product.name"></span> <span x-text="item.product.name"></span> :
<span x-text="`${item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })} €`"></span> <span x-text="item.sum().toLocaleString(undefined, { minimumFractionDigits: 2 })"></span>
<span x-show="item.getBonusQuantity() > 0" <span x-show="item.getBonusQuantity() > 0"
x-text="`${item.getBonusQuantity()} x P`"></span> x-text="`${item.getBonusQuantity()} x P`"></span>
@@ -131,7 +123,6 @@
class="remove-item" class="remove-item"
@click.prevent="removeFromBasket(item.product.price.id)" @click.prevent="removeFromBasket(item.product.price.id)"
><i class="fa fa-trash-can delete-action"></i></button> ><i class="fa fa-trash-can delete-action"></i></button>
</div>
<input <input
type="hidden" type="hidden"
+15 -33
View File
@@ -32,11 +32,12 @@
</ul> </ul>
<p><strong>{% trans %}Total: {% endtrans %}{{ last_total }} €</strong></p> <p><strong>{% trans %}Total: {% endtrans %}{{ last_total }} €</strong></p>
{% endif %} {% endif %}
{% if can_click %} {% if barmen %}
<p>{% trans %}Enter client code:{% endtrans %}</p> <p>{% trans %}Enter client code:{% endtrans %}</p>
<form method="post" action="" id="select-user-form"> <form method="post" action="">
{% csrf_token %} {% csrf_token %}
{{ form }} <input type="hidden" name="counter_token" value="{{ counter.token }}" />
{{ form.as_p() }}
<p><input type="submit" value="{% trans %}validate{% endtrans %}" /></p> <p><input type="submit" value="{% trans %}validate{% endtrans %}" /></p>
</form> </form>
{% else %} {% else %}
@@ -44,36 +45,17 @@
{% endif %} {% endif %}
</div> </div>
{% if counter.type == 'BAR' %} {% if counter.type == 'BAR' %}
<h3>{% trans %}Barmen:{% endtrans %}</h3>
{% if barmen_here %}
<div class="row gap-2x">
<div> <div>
<h4>{% trans %}On this device{% endtrans %}</h4> <h3>{% trans %}Barman: {% endtrans %}</h3>
{% for b in barmen_here %}
<p>{{ barman_logout_link(b) }}</p>
{% endfor %}
</div>
<div>
<h4>{% trans %}Elsewhere{% endtrans %}</h4>
{% if barmen_here|length == barmen|length %}
{# all logged barmen are logged in this session #}
<p><em>{% trans %}No barman logged elsewhere{% endtrans %}</em></p>
{% else %}
{% for b in barmen %}
{%- if b not in barmen_here -%}
<p>{{ barman_logout_link(b) }}</p>
{%- endif -%}
{% endfor %}
{% endif %}
</div>
</div>
{% else %}
{% for b in barmen %} {% for b in barmen %}
<p>{{ barman_logout_link(b) }}</p> <p>{{ barman_logout_link(b) }}</p>
{% endfor %} {% endfor %}
{% endif %} <form method="post" action="{{ url('counter:login', counter_id=counter.id) }}">
{{ login_fragment }} {% csrf_token %}
{{ login_form.as_p() }}
<p><input type="submit" value="{% trans %}login{% endtrans %}" /></p>
</form>
</div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@@ -81,10 +63,10 @@
{{ super() }} {{ super() }}
<script type="text/javascript"> <script type="text/javascript">
window.addEventListener("DOMContentLoaded", () => { window.addEventListener("DOMContentLoaded", () => {
{# The login form annoyingly takes priority over the code form // The login form annoyingly takes priority over the code form
This is due to the loading time of the web component // This is due to the loading time of the web component
We can't rely on DOMContentLoaded to know if the component is there so we // We can't rely on DOMContentLoaded to know if the component is there so we
periodically run a script until the field is there #} // periodically run a script until the field is there
const autofocus = () => { const autofocus = () => {
const field = document.querySelector("input[id='id_code']"); const field = document.querySelector("input[id='id_code']");
if (field === null){ if (field === null){
@@ -1,5 +0,0 @@
<form hx-post="{{ action }}" hx-swap="outerHTML">
{% csrf_token %}
{{ form }}
<input type="submit" value="{% trans %}Confirm{% endtrans %}"/>
</form>
@@ -118,7 +118,6 @@
</div> </div>
</div> </div>
</fieldset> </fieldset>
<fieldset><div>{{ form.clic_limit.as_field_group() }}</div></fieldset>
<fieldset><div>{{ form.counters.as_field_group() }}</div></fieldset> <fieldset><div>{{ form.counters.as_field_group() }}</div></fieldset>
<h3 class="margin-bottom">{% trans %}Prices{% endtrans %}</h3> <h3 class="margin-bottom">{% trans %}Prices{% endtrans %}</h3>
+51 -115
View File
@@ -17,11 +17,9 @@ from datetime import timedelta
from decimal import Decimal from decimal import Decimal
import pytest import pytest
from bs4 import BeautifulSoup
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission, make_password from django.contrib.auth.models import Permission, make_password
from django.contrib.messages import DEFAULT_LEVELS, get_messages
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import resolve_url from django.shortcuts import resolve_url
from django.test import Client, TestCase from django.test import Client, TestCase
@@ -39,7 +37,6 @@ from core.models import BanGroup, Group, User
from counter.baker_recipes import price_recipe, product_recipe, sale_recipe from counter.baker_recipes import price_recipe, product_recipe, sale_recipe
from counter.models import ( from counter.models import (
Counter, Counter,
CounterSellers,
Customer, Customer,
Permanency, Permanency,
ProductType, ProductType,
@@ -69,14 +66,10 @@ class TestFullClickBase(TestCase):
cls.subscriber = subscriber_user.make() cls.subscriber = subscriber_user.make()
cls.counter = baker.make(Counter, type="BAR") cls.counter = baker.make(Counter, type="BAR")
cls.counter.sellers.add(cls.barmen, cls.board_admin)
cls.other_counter = baker.make(Counter, type="BAR") cls.other_counter = baker.make(Counter, type="BAR")
CounterSellers.objects.bulk_create( cls.other_counter.sellers.add(cls.barmen)
[
CounterSellers(counter=cls.counter, user=cls.barmen),
CounterSellers(counter=cls.counter, user=cls.board_admin),
CounterSellers(counter=cls.other_counter, user=cls.barmen),
]
)
cls.yet_another_counter = baker.make(Counter, type="BAR") cls.yet_another_counter = baker.make(Counter, type="BAR")
@@ -121,10 +114,7 @@ class TestRefilling(TestFullClickBase):
) -> HttpResponse: ) -> HttpResponse:
used_client = client if client is not None else self.client used_client = client if client is not None else self.client
return used_client.post( return used_client.post(
reverse( reverse("counter:refilling_create", kwargs={"customer_id": user.pk}),
"counter:refilling_create",
kwargs={"customer_id": user.pk, "counter_id": self.counter.pk},
),
{"amount": str(amount), "payment_method": Refilling.PaymentMethod.CASH}, {"amount": str(amount), "payment_method": Refilling.PaymentMethod.CASH},
HTTP_REFERER=reverse( HTTP_REFERER=reverse(
"counter:click", kwargs={"counter_id": counter.id, "user_id": user.pk} "counter:click", kwargs={"counter_id": counter.id, "user_id": user.pk}
@@ -148,10 +138,7 @@ class TestRefilling(TestFullClickBase):
return self.client.post( return self.client.post(
reverse( reverse(
"counter:refilling_create", "counter:refilling_create",
kwargs={ kwargs={"customer_id": self.customer.pk},
"customer_id": self.customer.pk,
"counter_id": self.counter.pk,
},
), ),
{"amount": "10", "payment_method": "CASH"}, {"amount": "10", "payment_method": "CASH"},
) )
@@ -455,19 +442,9 @@ class TestCounterClick(TestFullClickBase):
def test_click_not_connected(self): def test_click_not_connected(self):
force_refill_user(self.customer, 10) force_refill_user(self.customer, 10)
# trying to click on a bar without being logged should result
# in a redirect to the counter page with an error message
res = self.submit_basket(self.customer, [BasketItem(self.snack.id, 2)]) res = self.submit_basket(self.customer, [BasketItem(self.snack.id, 2)])
assertRedirects(res, self.counter.get_absolute_url()) assertRedirects(res, self.counter.get_absolute_url())
messages = list(get_messages(res.wsgi_request))
assert len(messages) == 1
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
assert (
messages[0].message == "Vous ne pouvez pas cliquer des gens sur ce comptoir"
)
# trying to click on an office counter without permission should 403
res = self.submit_basket( res = self.submit_basket(
self.customer, [BasketItem(self.snack.id, 2)], counter=self.club_counter self.customer, [BasketItem(self.snack.id, 2)], counter=self.club_counter
) )
@@ -619,7 +596,7 @@ class TestCounterClick(TestFullClickBase):
product=iter(_product_recipe.make(archived=False, _quantity=2)), product=iter(_product_recipe.make(archived=False, _quantity=2)),
groups=[group], groups=[group],
) )
customer_prices = list(counter.get_prices_for(customer)) customer_prices = counter.get_prices_for(customer)
assert unarchived_prices == customer_prices assert unarchived_prices == customer_prices
@@ -741,97 +718,59 @@ class TestCounterStats(TestCase):
class TestBarmanConnection(TestCase): class TestBarmanConnection(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.barman = subscriber_user.make() cls.krophil = User.objects.get(username="krophil")
cls.barman.set_password("plop") cls.skia = User.objects.get(username="skia")
cls.barman.save() cls.skia.customer.account = 800
cls.counter = baker.make(Counter, type="BAR", sellers=[cls.barman]) cls.krophil.customer.save()
cls.login_url = reverse("counter:login", kwargs={"counter_id": cls.counter.id}) cls.skia.customer.save()
cls.detail_url = reverse(
"counter:details", kwargs={"counter_id": cls.counter.id} cls.counter = Counter.objects.get(id=2)
)
def test_barman_granted(self): def test_barman_granted(self):
response = self.client.post(
self.login_url, {"username": self.barman.username, "password": "plop"}
)
assert response.status_code == 200
assert response.headers["HX-Redirect"] == self.detail_url
last_perm = Permanency.objects.last()
assert last_perm.counter == self.counter
assert last_perm.user == self.barman
assert last_perm.end is None
assert self.barman in response.wsgi_request.barmen
response = self.client.get(
self.detail_url, {"username": self.barman.username, "password": "plop"}
)
assert response.context_data.get("barmen") == [self.barman]
soup = BeautifulSoup(response.text, "lxml")
assert soup.find("form", id="select-user-form") is not None
def assert_counter_login_fails(self, user: User):
initial_perms = set(self.counter.permanencies.filter(user=user, end=None))
response = self.client.post(
self.login_url, {"username": user.username, "password": "plop"}
)
assert "HX-Redirect" not in response.headers
assert (
set(self.counter.permanencies.filter(user=user, end=None)) == initial_perms
)
if initial_perms:
# the user was already logged in, and we already tested
# that it didn't re-login, so we can skip the next assertions.
return
self.counter.refresh_from_db()
assert response.wsgi_request.barmen.isdisjoint(set(self.counter.barmen_list))
response = self.client.get(self.detail_url)
assert response.context_data.get("barmen") == []
soup = BeautifulSoup(response.text, "lxml")
assert soup.find("form", id="select-user-form") is None
def test_barman_not_seller(self):
"""Test when the barman is not a seller of the counter"""
not_barman = subscriber_user.make()
not_barman.set_password("plop")
not_barman.save()
self.assert_counter_login_fails(not_barman)
def test_barman_already_logged(self):
"""Test when the barman is already logged in the current counter."""
self.client.post( self.client.post(
self.login_url, {"username": self.barman.username, "password": "plop"} reverse("counter:login", args=[self.counter.id]),
{"username": "krophil", "password": "plop"},
) )
self.assert_counter_login_fails(self.barman) response = self.client.get(reverse("counter:details", args=[self.counter.id]))
def test_barman_already_logged_elsewhere(self): assert "<p>Entrez un code client : </p>" in str(response.content)
"""Test when the barman is already logged in another counter."""
other_counter = baker.make(Counter, type="BAR") def test_counters_list_barmen(self):
CounterSellers.objects.create(counter=other_counter, user=self.barman)
self.client.post( self.client.post(
reverse("counter:login", kwargs={"counter_id": other_counter.id}), reverse("counter:login", args=[self.counter.id]),
{"username": self.barman.username, "password": "plop"}, {"username": "krophil", "password": "plop"},
) )
self.assert_counter_login_fails(self.barman) response = self.client.get(reverse("counter:activity", args=[self.counter.id]))
def test_login_on_non_bar_counter(self): assert '<li><a href="/user/10/">Kro Phil&#39;</a></li>' in str(response.content)
counter = baker.make(Counter, type="OFFICE")
CounterSellers.objects.create(counter=counter, user=self.barman) def test_barman_denied(self):
url = reverse("counter:login", kwargs={"counter_id": counter.id}) self.client.post(
response = self.client.get(url) reverse("counter:login", args=[self.counter.id]),
assert response.status_code == 403 {"username": "skia", "password": "plop"},
response = self.client.post(
url, {"username": self.barman.username, "password": "plop"}
) )
assert response.status_code == 403 response_get = self.client.get(
reverse("counter:details", args=[self.counter.id])
)
assert "<p>Merci de vous identifier</p>" in str(response_get.content)
def test_counters_list_no_barmen(self):
self.client.post(
reverse("counter:login", args=[self.counter.id]),
{"username": "krophil", "password": "plop"},
)
response = self.client.get(reverse("counter:activity", args=[self.counter.id]))
assert '<li><a href="/user/1/">S&#39; Kia</a></li>' not in str(response.content)
@pytest.mark.django_db @pytest.mark.django_db
def test_barman_timeout(client: Client): def test_barman_timeout():
"""Test that barmen timeout is well managed.""" """Test that barmen timeout is well managed."""
bar = baker.make(Counter, type="BAR") bar = baker.make(Counter, type="BAR")
user = baker.make(User) user = baker.make(User)
CounterSellers.objects.create(counter=bar, user=user) bar.sellers.add(user)
baker.make(Permanency, counter=bar, user=user, start=now()) baker.make(Permanency, counter=bar, user=user, start=now())
qs = Counter.objects.annotate_is_open().filter(pk=bar.pk) qs = Counter.objects.annotate_is_open().filter(pk=bar.pk)
@@ -847,8 +786,6 @@ def test_barman_timeout(client: Client):
bar = qs[0] bar = qs[0]
assert not bar.is_open assert not bar.is_open
assert bar.barmen_list == [] assert bar.barmen_list == []
res = client.get("")
assert res.wsgi_request.barmen == set()
class TestClubCounterClickAccess(TestCase): class TestClubCounterClickAccess(TestCase):
@@ -898,14 +835,14 @@ class TestClubCounterClickAccess(TestCase):
def test_barman(self): def test_barman(self):
"""Sellers should be able to click on office counters""" """Sellers should be able to click on office counters"""
CounterSellers.objects.create(counter=self.counter, user=self.user) self.counter.sellers.add(self.user)
self.client.force_login(self.user) self.client.force_login(self.user)
res = self.client.get(self.click_url) res = self.client.get(self.click_url)
assert res.status_code == 200 assert res.status_code == 200
def test_both_barman_and_board_member(self): def test_both_barman_and_board_member(self):
"""If the user is barman and board member, he should be authorized as well.""" """If the user is barman and board member, he should be authorized as well."""
CounterSellers.objects.create(counter=self.counter, user=self.user) self.counter.sellers.add(self.user)
baker.make( baker.make(
Membership, club=self.counter.club, user=self.user, role=self.board_role Membership, club=self.counter.club, user=self.user, role=self.board_role
) )
@@ -931,15 +868,14 @@ class TestCounterLogout:
) )
assertRedirects( assertRedirects(
res, res,
reverse("counter:details", kwargs={"counter_id": permanence.counter_id}), reverse(
"counter:details", kwargs={"counter_id": permanence.counter_id}
),
) )
permanence.refresh_from_db() permanence.refresh_from_db()
assert permanence.end == permanence.activity assert permanence.end == now()
assert permanence.user not in res.wsgi_request.barmen
def test_logout_doesnt_change_old_permanences(self, client: Client): def test_logout_doesnt_change_old_permanences(self, client: Client):
# regression test for #1141
# https://github.com/ae-utbm/sith/pull/1141
perm_counter = baker.make(Counter, type="BAR") perm_counter = baker.make(Counter, type="BAR")
permanence = baker.make( permanence = baker.make(
Permanency, Permanency,
@@ -960,6 +896,6 @@ class TestCounterLogout:
data={"user_id": permanence.user_id}, data={"user_id": permanence.user_id},
) )
permanence.refresh_from_db() permanence.refresh_from_db()
assert permanence.end == permanence.activity assert permanence.end == now()
old_permanence.refresh_from_db() old_permanence.refresh_from_db()
assert old_permanence.end == old_end assert old_permanence.end == old_end
+2 -61
View File
@@ -1,4 +1,3 @@
import itertools
from io import BytesIO from io import BytesIO
from typing import Callable from typing import Callable
from uuid import uuid4 from uuid import uuid4
@@ -9,7 +8,6 @@ from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker from model_bakery import baker
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from PIL import Image from PIL import Image
@@ -18,10 +16,9 @@ from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club from club.models import Club
from core.baker_recipes import board_user, subscriber_user from core.baker_recipes import board_user, subscriber_user
from core.models import Group, User from core.models import Group, User
from counter.baker_recipes import product_recipe, sale_recipe from counter.baker_recipes import product_recipe
from counter.forms import ProductForm, ProductPriceFormSet from counter.forms import ProductForm, ProductPriceFormSet
from counter.models import Price, Product, ProductType, Selling from counter.models import Price, Product, ProductType
from eboutic.models import Basket, BasketItem
@pytest.mark.django_db @pytest.mark.django_db
@@ -225,59 +222,3 @@ def test_price_for_user():
assert list(qs.for_user(users[0])) == [prices[0], prices[1], prices[4]] assert list(qs.for_user(users[0])) == [prices[0], prices[1], prices[4]]
assert list(qs.for_user(users[1])) == [prices[0], prices[4]] assert list(qs.for_user(users[1])) == [prices[0], prices[4]]
assert list(qs.for_user(users[2])) == [prices[0], prices[3]] assert list(qs.for_user(users[2])) == [prices[0], prices[3]]
class TestProductClicLimit(TestCase):
@classmethod
def setUpTestData(cls):
cls.products = product_recipe.make(
clic_limit=itertools.chain([5, 10, 15], itertools.repeat(None)),
_quantity=6,
_bulk_create=True,
)
cls.qs = Product.objects.filter(id__in=[p.id for p in cls.products])
def test_no_sales_or_basket(self):
"""Test that it works if no sales has been made yet"""
assert list(self.qs.under_clic_limit()) == self.products
def test_with_sales(self):
"""Test that it works when there are existing sales"""
sales = sale_recipe.make(
product=itertools.cycle(self.products),
_quantity=len(self.products) * 5,
_bulk_create=True,
)
Selling.objects.filter(id__in=[s.id for s in sales]).update(quantity=2)
assert list(self.qs.under_clic_limit()) == self.products[2:]
def test_with_sales_and_basket(self):
"""Test that it works when there are existing sales and basket items."""
sales = sale_recipe.make(
product=itertools.cycle(self.products),
_quantity=len(self.products) * 5,
_bulk_create=True,
)
Selling.objects.filter(id__in=[s.id for s in sales]).update(quantity=1)
basket = baker.make(
Basket, date=now() - settings.SITH_EBOUTIC_BASKET_TIMEOUT / 2
)
items = baker.make(
BasketItem,
product=itertools.cycle(self.products),
basket=basket,
_quantity=len(self.products) * 5,
)
BasketItem.objects.filter(id__in=[i.id for i in items]).update(quantity=1)
assert list(self.qs.under_clic_limit()) == self.products[2:]
# expired basket items shouldn't be accounted when computing clic limit
item = BasketItem.objects.filter(product=self.products[1])[0]
item.basket = baker.make(
Basket,
date=now()
- settings.SITH_EBOUTIC_BASKET_TIMEOUT
- settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT,
)
item.save()
assert list(self.qs.under_clic_limit()) == self.products[1:]
+3 -4
View File
@@ -41,6 +41,7 @@ from counter.views.admin import (
ReturnableProductUpdateView, ReturnableProductUpdateView,
SellingDeleteView, SellingDeleteView,
) )
from counter.views.auth import counter_login, counter_logout
from counter.views.cash import ( from counter.views.cash import (
CashSummaryEditView, CashSummaryEditView,
CashSummaryListView, CashSummaryListView,
@@ -56,9 +57,7 @@ from counter.views.eticket import (
from counter.views.home import ( from counter.views.home import (
CounterActivityView, CounterActivityView,
CounterLastOperationsView, CounterLastOperationsView,
CounterLoginFragment,
CounterMain, CounterMain,
counter_logout,
) )
from counter.views.invoice import InvoiceCallView from counter.views.invoice import InvoiceCallView
from counter.views.student_card import StudentCardDeleteView, StudentCardFormFragment from counter.views.student_card import StudentCardDeleteView, StudentCardFormFragment
@@ -67,7 +66,7 @@ urlpatterns = [
path("<int:counter_id>/", CounterMain.as_view(), name="details"), path("<int:counter_id>/", CounterMain.as_view(), name="details"),
path("<int:counter_id>/click/<int:user_id>/", CounterClick.as_view(), name="click"), path("<int:counter_id>/click/<int:user_id>/", CounterClick.as_view(), name="click"),
path( path(
"<int:counter_id>/refill/<int:customer_id>/", "refill/<int:customer_id>/",
RefillingCreateView.as_view(), RefillingCreateView.as_view(),
name="refilling_create", name="refilling_create",
), ),
@@ -83,7 +82,7 @@ urlpatterns = [
), ),
path("<int:counter_id>/activity/", CounterActivityView.as_view(), name="activity"), path("<int:counter_id>/activity/", CounterActivityView.as_view(), name="activity"),
path("<int:counter_id>/stats/", CounterStatView.as_view(), name="stats"), path("<int:counter_id>/stats/", CounterStatView.as_view(), name="stats"),
path("<int:counter_id>/login/", CounterLoginFragment.as_view(), name="login"), path("<int:counter_id>/login/", counter_login, name="login"),
path("<int:counter_id>/logout/", counter_logout, name="logout"), path("<int:counter_id>/logout/", counter_logout, name="logout"),
path("eticket/<int:selling_id>/pdf/", EticketPDFView.as_view(), name="eticket_pdf"), path("eticket/<int:selling_id>/pdf/", EticketPDFView.as_view(), name="eticket_pdf"),
path( path(
+16 -3
View File
@@ -3,6 +3,8 @@ from urllib.parse import urlparse
from django.http import HttpRequest from django.http import HttpRequest
from django.urls import resolve from django.urls import resolve
from counter.models import Counter
def is_logged_in_counter(request: HttpRequest) -> bool: def is_logged_in_counter(request: HttpRequest) -> bool:
"""Check if the request is sent from a device logged to a counter. """Check if the request is sent from a device logged to a counter.
@@ -18,13 +20,24 @@ def is_logged_in_counter(request: HttpRequest) -> bool:
or the request path belongs to the counter app or the request path belongs to the counter app
(eg. the barman went back to the main by missclick and go back (eg. the barman went back to the main by missclick and go back
to the counter) to the counter)
- There are barmen logged in the current session - The current session has a counter token associated with it.
- A counter with this token exists.
- The counter is open
""" """
referer_ok = ( referer_ok = (
"HTTP_REFERER" in request.META "HTTP_REFERER" in request.META
and resolve(urlparse(request.META["HTTP_REFERER"]).path).app_name == "counter" and resolve(urlparse(request.META["HTTP_REFERER"]).path).app_name == "counter"
) )
if not referer_ok and request.resolver_match.app_name != "counter": has_token = (
(referer_ok or request.resolver_match.app_name == "counter")
and "counter_token" in request.session
and request.session["counter_token"]
)
if not has_token:
return False return False
return bool(request.barmen) return (
Counter.objects.annotate_is_open()
.filter(token=request.session["counter_token"], is_open=True)
.exists()
)
+53
View File
@@ -0,0 +1,53 @@
#
# Copyright 2023 © AE UTBM
# ae@utbm.fr / ae.info@utbm.fr
#
# This file is part of the website of the UTBM Student Association (AE UTBM),
# https://ae.utbm.fr.
#
# You can find the source code of the website at https://github.com/ae-utbm/sith
#
# LICENSED UNDER THE GNU GENERAL PUBLIC LICENSE VERSION 3 (GPLv3)
# SEE : https://raw.githubusercontent.com/ae-utbm/sith/master/LICENSE
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from django.http import HttpRequest, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.utils import timezone
from django.utils.timezone import now
from django.views.decorators.http import require_POST
from core.views.forms import LoginForm
from counter.models import Counter, Permanency
@require_POST
def counter_login(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
"""Log a user in a counter.
A successful login will result in the beginning of a counter duty
for the user.
"""
counter = get_object_or_404(Counter, pk=counter_id)
form = LoginForm(request, data=request.POST)
if not form.is_valid():
return redirect(counter.get_absolute_url() + "?credentials")
user = form.get_user()
if not counter.sellers.contains(user) or user in counter.barmen_list:
return redirect(counter.get_absolute_url() + "?sellers")
if len(counter.barmen_list) == 0:
counter.gen_token()
request.session["counter_token"] = counter.token
counter.permanencies.create(user=user, start=timezone.now())
return redirect(counter)
@require_POST
def counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
"""End the permanency of a user in this counter."""
Permanency.objects.filter(
counter=counter_id, user=request.POST["user_id"], end=None
).update(end=now())
return redirect("counter:details", counter_id=counter_id)
+20 -20
View File
@@ -12,10 +12,8 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
import random
from collections import defaultdict from collections import defaultdict
from django.contrib import messages
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
from django.db.models import Q from django.db.models import Q
@@ -23,7 +21,6 @@ from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, resolve_url from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import SafeString from django.utils.safestring import SafeString
from django.utils.translation import gettext as _
from django.views.generic import FormView from django.views.generic import FormView
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from ninja.main import HttpRequest from ninja.main import HttpRequest
@@ -32,7 +29,13 @@ from core.auth.mixins import CanViewMixin
from core.models import User from core.models import User
from core.views.mixins import FragmentMixin, UseFragmentsMixin from core.views.mixins import FragmentMixin, UseFragmentsMixin
from counter.forms import BasketForm, RefillForm from counter.forms import BasketForm, RefillForm
from counter.models import Counter, Customer, ProductFormula, ReturnableProduct, Selling from counter.models import (
Counter,
Customer,
ProductFormula,
ReturnableProduct,
Selling,
)
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
from counter.views.mixins import CounterTabsMixin from counter.views.mixins import CounterTabsMixin
from counter.views.student_card import StudentCardFormFragment from counter.views.student_card import StudentCardFormFragment
@@ -43,7 +46,7 @@ def get_operator(request: HttpRequest, counter: Counter, customer: Customer) ->
return request.user return request.user
if counter.customer_is_barman(customer): if counter.customer_is_barman(customer):
return customer.user return customer.user
return random.choice(list(request.barmen)) return counter.get_random_barman()
class CounterClick( class CounterClick(
@@ -75,7 +78,7 @@ class CounterClick(
return kwargs return kwargs
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
self.customer = get_object_or_404(Customer, user_id=self.kwargs["user_id"]) self.customer = get_object_or_404(Customer, user__id=self.kwargs["user_id"])
obj: Counter = self.get_object() obj: Counter = self.get_object()
if not self.customer.can_buy or self.customer.user.is_banned_counter: if not self.customer.can_buy or self.customer.user.is_banned_counter:
@@ -93,13 +96,14 @@ class CounterClick(
# or a seller of this counter. # or a seller of this counter.
raise PermissionDenied raise PermissionDenied
if obj.type == "BAR" and not ( if obj.type == "BAR" and (
request.barmen and request.barmen.issubset(set(obj.barmen_list)) not obj.is_open
or "counter_token" not in request.session
or request.session["counter_token"] != obj.token
): ):
messages.error(request, _("You cannot click users on this counter"))
return redirect(obj) # Redirect to counter return redirect(obj) # Redirect to counter
self.prices = list(obj.get_prices_for(self.customer)) self.prices = obj.get_prices_for(self.customer)
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@@ -195,7 +199,7 @@ class CounterClick(
) )
if self.object.can_refill(): if self.object.can_refill():
res["refilling_fragment"] = RefillingCreateView.as_fragment()( res["refilling_fragment"] = RefillingCreateView.as_fragment()(
self.request, customer=self.customer, counter=self.object self.request, customer=self.customer
) )
return res return res
@@ -233,13 +237,11 @@ class RefillingCreateView(FragmentMixin, FormView):
if not is_logged_in_counter(request): if not is_logged_in_counter(request):
raise PermissionDenied raise PermissionDenied
self.counter: Counter = get_object_or_404(Counter, id=self.kwargs["counter_id"]) self.counter: Counter = get_object_or_404(
Counter, token=request.session["counter_token"]
)
if not ( if not self.counter.can_refill():
request.barmen
and request.barmen.issubset(self.counter.barmen_list)
and self.counter.can_refill()
):
raise PermissionDenied raise PermissionDenied
self.operator = get_operator(request, self.counter, self.customer) self.operator = get_operator(request, self.counter, self.customer)
@@ -248,7 +250,6 @@ class RefillingCreateView(FragmentMixin, FormView):
def render_fragment(self, request, **kwargs) -> SafeString: def render_fragment(self, request, **kwargs) -> SafeString:
self.customer = kwargs.pop("customer") self.customer = kwargs.pop("customer")
self.counter = kwargs.pop("counter")
return super().render_fragment(request, **kwargs) return super().render_fragment(request, **kwargs)
def form_valid(self, form): def form_valid(self, form):
@@ -263,8 +264,7 @@ class RefillingCreateView(FragmentMixin, FormView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["action"] = reverse( kwargs["action"] = reverse(
"counter:refilling_create", "counter:refilling_create", kwargs={"customer_id": self.customer.pk}
kwargs={"customer_id": self.customer.pk, "counter_id": self.counter.pk},
) )
return kwargs return kwargs
+52 -97
View File
@@ -15,120 +15,78 @@
from datetime import timedelta from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied from django.http import HttpResponseRedirect
from django.db.models import F from django.urls import reverse, reverse_lazy
from django.http import HttpRequest, HttpResponseRedirect
from django.shortcuts import redirect
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.safestring import SafeString from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_POST
from django.views.generic import DetailView from django.views.generic import DetailView
from django.views.generic.detail import SingleObjectMixin from django.views.generic.edit import FormMixin, ProcessFormView
from django.views.generic.edit import FormView
from core.auth.mixins import CanViewMixin from core.auth.mixins import CanViewMixin
from core.views import FragmentMixin, UseFragmentsMixin from core.views.forms import LoginForm
from counter.forms import CounterLoginForm, GetUserForm from counter.forms import GetUserForm
from counter.models import Counter, Permanency from counter.models import Counter
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
from counter.views.mixins import CounterTabsMixin from counter.views.mixins import CounterTabsMixin
class CounterLoginFragment(FragmentMixin, SingleObjectMixin, FormView):
model = Counter
form_class = CounterLoginForm
reload_on_redirect = True
pk_url_kwarg = "counter_id"
template_name = "counter/fragments/login.jinja"
def dispatch(self, request, *args, **kwargs):
self.object = self.get_object()
if self.object.type != "BAR":
# barmen have to log in only if it is a bar,
# so calling this view on a non-bar counter makes no sense
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"request": self.request,
"counter": self.object,
}
def form_valid(self, form: CounterLoginForm):
user = form.get_user()
self.object.permanencies.create(user=user, start=timezone.now())
self.request.barmen.add(user)
self.success_url = reverse(
"counter:details", kwargs={"counter_id": self.object.id}
)
return super().form_valid(form)
def render_fragment(self, request, **kwargs) -> SafeString:
self.object = kwargs.pop("counter")
return super().render_fragment(request, **kwargs)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"action": reverse("counter:login", kwargs={"counter_id": self.object.id})
}
@require_POST
def counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
"""End the permanency of a user in this counter."""
Permanency.objects.filter(
counter=counter_id, user=request.POST["user_id"], end=None
).update(end=F("activity"))
return redirect("counter:details", counter_id=counter_id)
class CounterMain( class CounterMain(
CounterTabsMixin, UseFragmentsMixin, CanViewMixin, SingleObjectMixin, FormView CounterTabsMixin, CanViewMixin, DetailView, ProcessFormView, FormMixin
): ):
"""The public (barman) view.""" """The public (barman) view."""
model = Counter model = Counter
queryset = Counter.objects.exclude(type="EBOUTIC")
template_name = "counter/counter_main.jinja" template_name = "counter/counter_main.jinja"
pk_url_kwarg = "counter_id" pk_url_kwarg = "counter_id"
form_class = GetUserForm form_class = (
GetUserForm # Form to enter a client code and get the corresponding user id
)
current_tab = "counter" current_tab = "counter"
def dispatch(self, request, *args, **kwargs): def get_queryset(self):
self.object: Counter = self.get_object() return super().get_queryset().exclude(type="EBOUTIC")
if self.object.type == "BAR":
self.object.update_activity()
return super().dispatch(request, *args, **kwargs)
def get_fragment_context_data(self) -> dict[str, SafeString]: def post(self, request, *args, **kwargs):
login_fragment = ( self.object = self.get_object()
CounterLoginFragment.as_fragment()(self.request, counter=self.object) if self.object.type == "BAR" and not (
if self.object.type == "BAR" "counter_token" in self.request.session
else "" and self.request.session["counter_token"] == self.object.token
): # Check the token to avoid the bar to be stolen
return HttpResponseRedirect(
reverse_lazy(
"counter:details",
args=self.args,
kwargs={"counter_id": self.object.id},
) )
return super().get_fragment_context_data() | {"login_fragment": login_fragment} + "?bad_location"
)
return super().post(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
"""We handle here the login form for the barman.""" """We handle here the login form for the barman."""
if self.request.method == "POST":
self.object = self.get_object()
self.object.update_activity()
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["login_form"] = LoginForm()
kwargs["login_form"].fields["username"].widget.attrs["autofocus"] = True
kwargs[
"login_form"
].cleaned_data = {} # add_error fails if there are no cleaned_data
if "credentials" in self.request.GET:
kwargs["login_form"].add_error(None, _("Bad credentials"))
if "sellers" in self.request.GET:
kwargs["login_form"].add_error(None, _("User is not barman"))
kwargs["form"] = self.get_form()
kwargs["form"].cleaned_data = {} # same as above
if "bad_location" in self.request.GET:
kwargs["form"].add_error(
None, _("Bad location, someone is already logged in somewhere else")
)
if self.object.type == "BAR": if self.object.type == "BAR":
kwargs["barmen"] = self.object.barmen_list kwargs["barmen"] = self.object.barmen_list
kwargs["barmen_here"] = list( elif self.request.user.is_authenticated:
self.request.barmen.intersection(self.object.barmen_list) kwargs["barmen"] = [self.request.user]
)
kwargs["can_click"] = (
self.object.type == "BAR"
and self.request.barmen
and self.request.barmen.issubset(set(self.object.barmen_list))
) or (
self.object.type == "OFFICE"
and (
self.object.sellers.contains(self.request.user)
or self.object.club.has_rights_in_club(self.request.user)
)
)
if "last_basket" in self.request.session: if "last_basket" in self.request.session:
kwargs["last_basket"] = self.request.session.pop("last_basket") kwargs["last_basket"] = self.request.session.pop("last_basket")
kwargs["last_customer"] = self.request.session.pop("last_customer") kwargs["last_customer"] = self.request.session.pop("last_customer")
@@ -138,17 +96,14 @@ class CounterMain(
) )
return kwargs return kwargs
def form_valid(self, form: GetUserForm): def form_valid(self, form):
"""We handle here the redirection, passing the user id of the asked customer.""" """We handle here the redirection, passing the user id of the asked customer."""
self.success_url = reverse( self.kwargs["user_id"] = form.cleaned_data["user_id"]
"counter:click",
kwargs={
"counter_id": self.kwargs["counter_id"],
"user_id": form.cleaned_data["user_id"],
},
)
return super().form_valid(form) return super().form_valid(form)
def get_success_url(self):
return reverse_lazy("counter:click", args=self.args, kwargs=self.kwargs)
class CounterLastOperationsView(CounterTabsMixin, CanViewMixin, DetailView): class CounterLastOperationsView(CounterTabsMixin, CanViewMixin, DetailView):
"""Provide the last operations to allow barmen to delete them.""" """Provide the last operations to allow barmen to delete them."""
-31
View File
@@ -1,6 +1,4 @@
## Fonctionnement général
La boutique en ligne nécessite une interaction La boutique en ligne nécessite une interaction
avec la banque pour son fonctionnement. avec la banque pour son fonctionnement.
@@ -11,32 +9,3 @@ Nous ne pouvons donc que vous redirigez vers la doc du crédit
agricole : agricole :
[https://www.ca-moncommerce.com/espace-client-mon-commerce/up2pay-e-transactions/ma-documentation/](https://www.ca-moncommerce.com/espace-client-mon-commerce/up2pay-e-transactions/ma-documentation/) [https://www.ca-moncommerce.com/espace-client-mon-commerce/up2pay-e-transactions/ma-documentation/](https://www.ca-moncommerce.com/espace-client-mon-commerce/up2pay-e-transactions/ma-documentation/)
## Limite de clic et expiration des paniers
Certains produits peuvent avoir un quota de vente.
Une fois ce dernier atteint, il ne doit plus être possible de les acheter.
Pour éviter que cette limite soit dépassée si jamais plusieurs utilisateurs
commandent et achètent ce produit à peu près en même temps,
un produit est considéré comme « réservé » une fois placé dans un panier.
La création du panier s'effectue lors de la soumission du formulaire sur l'eboutic.
Une fois la transaction accomplie, le panier est supprimé.
Cependant, il reste un problème :
que faire des utilisateurs qui créent un panier, mais ne terminent
pas la transaction ?
Pour résoudre ce cas, les paniers ont une durée de validité,
définie dans le `settings.py`, grâce à deux variables :
- `settings.SITH_EBOUTIC_BASKET_TIMEOUT` :
le temps pendant lequel un utilisateur peut payer avec son compte AE
ou démarrer une etransaction
- `settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT` :
le temps alloué à l'utilisateur pour effectuer une etransaction ;
au-delà de cette durée, la banque refusera le paiement
et notifiera le sith de l'erreur.
Une fois expiré le temps défini par
`settings.SITH_EBOUTIC_BASKET_TIMEOUT + settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT`,
les produits contenus dans le panier sont à nouveau
disponibles à la vente.
+32
View File
@@ -263,3 +263,35 @@ avec un unique champ permettant de sélectionner des groupes.
Par défaut, seuls les utilisateurs avec la permission Par défaut, seuls les utilisateurs avec la permission
`auth.change_permission` auront accès à ce formulaire `auth.change_permission` auront accès à ce formulaire
(donc, normalement, uniquement les utilisateurs Root). (donc, normalement, uniquement les utilisateurs Root).
```mermaid
sequenceDiagram
participant A as Utilisateur
participant B as ReverseProxy
participant C as MarkdownImage
participant D as Model
A->>B: GET /page/foo
B->>C: GET /page/foo
C-->>B: La page, avec les urls
B-->>A: La page, avec les urls
alt image publique
A->>B: GET markdown/public/2025/img.webp
B-->>A: img.webp
end
alt image privée
A->>B: GET markdown_image/{id}
B->>C: GET markdown_image/{id}
C->>D: user.can_view(image)
alt l'utilisateur a le droit de voir l'image
D-->>C: True
C-->>B: 200 (avec le X-Accel-Redirect)
B-->>A: img.webp
end
alt l'utilisateur n'a pas le droit de l'image
D-->>C: False
C-->>B: 403
B-->>A: 403
end
end
```
+1 -10
View File
@@ -1,6 +1,3 @@
from typing import Any
from ninja import Status
from ninja_extra import ControllerBase, api_controller, route from ninja_extra import ControllerBase, api_controller, route
from ninja_extra.exceptions import NotFound from ninja_extra.exceptions import NotFound
@@ -11,19 +8,13 @@ from eboutic.models import Basket
@api_controller("/etransaction", permissions=[CanView]) @api_controller("/etransaction", permissions=[CanView])
class EtransactionInfoController(ControllerBase): class EtransactionInfoController(ControllerBase):
@route.get( @route.get("/data/{basket_id}", url_name="etransaction_data")
"/data/{basket_id}",
url_name="etransaction_data",
response={200: dict[str, Any], 410: str},
)
def fetch_etransaction_data(self, basket_id: int): def fetch_etransaction_data(self, basket_id: int):
"""Generate the data to pay an eboutic command with paybox. """Generate the data to pay an eboutic command with paybox.
The data is generated with the basket that is used by the current session. The data is generated with the basket that is used by the current session.
""" """
basket: Basket = self.get_object_or_exception(Basket, pk=basket_id) basket: Basket = self.get_object_or_exception(Basket, pk=basket_id)
if basket.is_expired:
return Status(410, "This basket is expired.")
try: try:
return dict(basket.get_e_transaction_data()) return dict(basket.get_e_transaction_data())
except BillingInfo.DoesNotExist as e: except BillingInfo.DoesNotExist as e:
+7 -35
View File
@@ -24,7 +24,6 @@ from django.conf import settings
from django.db import DataError, models from django.db import DataError, models
from django.db.models import F, OuterRef, Subquery, Sum from django.db.models import F, OuterRef, Subquery, Sum
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import User from core.models import User
@@ -96,19 +95,6 @@ class Basket(models.Model):
] ]
) )
@property
def is_expired(self) -> bool:
"""Return True if this basket is expired.
An expired basket can no longer be used tp pay with sith account
or to start an etransaction.
Warnings:
Users have an additional time if they pay with an etransaction,
so an expired basket may be purchased after its expiration in that case.
"""
return (self.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT) <= now()
def generate_sales( def generate_sales(
self, counter, seller: User, payment_method: Selling.PaymentMethod self, counter, seller: User, payment_method: Selling.PaymentMethod
): ):
@@ -147,20 +133,9 @@ class Basket(models.Model):
] ]
def get_e_transaction_data(self) -> list[tuple[str, str]]: def get_e_transaction_data(self) -> list[tuple[str, str]]:
"""Get data for etransaction payment.
Raises:
Customer.DoesNotExist: if the user linked to this basket
has no customer account
BillingInfo.DoesNotExist: if the user linked to this basket has no
billing infos, or incorrect billing infos.
ValueError: if this is called on a basket which payment delay is expired.
"""
user = self.user user = self.user
if not hasattr(user, "customer"): if not hasattr(user, "customer"):
raise Customer.DoesNotExist raise Customer.DoesNotExist
if self.is_expired:
raise ValueError("This method cannot be called on an expired basket.")
customer = user.customer customer = user.customer
if ( if (
not hasattr(user.customer, "billing_infos") not hasattr(user.customer, "billing_infos")
@@ -180,10 +155,6 @@ class Basket(models.Model):
("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT), ("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT),
("PBX_TOTAL", str(int(self.total * 100))), ("PBX_TOTAL", str(int(self.total * 100))),
("PBX_DEVISE", "978"), # This is Euro ("PBX_DEVISE", "978"), # This is Euro
(
"PBX_DISPLAY",
str(int(settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT.total_seconds())),
),
("PBX_CMD", str(self.id)), ("PBX_CMD", str(self.id)),
("PBX_PORTEUR", user.email), ("PBX_PORTEUR", user.email),
("PBX_RETOUR", "Amount:M;BasketID:R;Auto:A;Error:E;Sig:K"), ("PBX_RETOUR", "Amount:M;BasketID:R;Auto:A;Error:E;Sig:K"),
@@ -248,14 +219,16 @@ class Invoice(models.Model):
if self.validated: if self.validated:
raise DataError(_("Invoice already validated")) raise DataError(_("Invoice already validated"))
customer, _created = Customer.get_or_create(user=self.user) customer, _created = Customer.get_or_create(user=self.user)
kwargs = {"counter": get_eboutic(), "customer": customer, "date": self.date} kwargs = {
"counter": get_eboutic(),
"customer": customer,
"date": self.date,
"payment_method": Selling.PaymentMethod.CARD,
}
for i in self.items.select_related("product"): for i in self.items.select_related("product"):
if i.product.product_type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING: if i.product.product_type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:
Refilling.objects.create( Refilling.objects.create(
**kwargs, **kwargs, operator=self.user, amount=i.unit_price * i.quantity
operator=self.user,
amount=i.unit_price * i.quantity,
payment_method=Refilling.PaymentMethod.CARD,
) )
else: else:
Selling.objects.create( Selling.objects.create(
@@ -266,7 +239,6 @@ class Invoice(models.Model):
seller=self.user, seller=self.user,
unit_price=i.unit_price, unit_price=i.unit_price,
quantity=i.quantity, quantity=i.quantity,
payment_method=Selling.PaymentMethod.CARD,
) )
self.validated = True self.validated = True
self.save() self.save()
@@ -1,71 +1,21 @@
import { type Notification, NotificationLevel } from "#core:utils/notifications";
import { etransactioninfoFetchEtransactionData } from "#openapi"; import { etransactioninfoFetchEtransactionData } from "#openapi";
interface Basket {
id: number;
timeout: Date;
}
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("etransaction", (initialData, basket: Basket) => ({ Alpine.data("etransaction", (initialData, basketId: number) => ({
data: initialData, data: initialData,
isCbAvailable: Object.keys(initialData).length > 0, isCbAvailable: Object.keys(initialData).length > 0,
isSithAvailable: true,
init() {
const now = new Date();
const timeout = basket.timeout.getTime() - now.getTime();
if (timeout <= 0) {
// basket was already outdated at initial page load
this.timeoutBasket();
} else {
setTimeout(() => this.timeoutBasket(), timeout);
}
},
/**
* Make this basket into a timeout state.
* All submission inputs are disabled, and an error message is displayed.
*/
timeoutBasket() {
this.isCbAvailable = false;
this.isSithAvailable = false;
const message = gettext("Basket expired");
const existingNotif: Notification | undefined = this.$notifications
.getAll()
.find(
(n: Notification) =>
n.tag === NotificationLevel.Error && n.message === message,
);
if (existingNotif === undefined) {
this.$notifications.error(message);
}
},
/**
* Refresh the data used for etransaction.
*
* Note: if this is called while the basket is expired, it will be a no-op
*/
async fill() { async fill() {
if (new Date() > basket.timeout) {
// refresh etransaction data only if the basket is still valid.
this.timeoutBasket();
return;
}
this.isCbAvailable = false; this.isCbAvailable = false;
const res = await etransactioninfoFetchEtransactionData({ const res = await etransactioninfoFetchEtransactionData({
path: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { basket_id: basket.id }, basket_id: basketId,
},
}); });
if (res.response.ok) { if (res.response.ok) {
this.data = res.data; this.data = res.data;
this.isCbAvailable = true; this.isCbAvailable = true;
} else if (res.response.status === 410) {
// The basket is expired, so no payment method should be available at all.
// This shouldn't happen, because we don't send the request
// when the timeout is passed, but we are better safe than sorry
this.timeoutBasket();
} }
}, },
})); }));
+11 -17
View File
@@ -11,7 +11,7 @@ const BASKET_CACHE_KEY = "basket";
const BASKET_CACHE_VERSION = 1; const BASKET_CACHE_VERSION = 1;
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("basket", (validPrices: number[], lastPurchaseTime?: number) => ({ Alpine.data("basket", (lastPurchaseTime?: number) => ({
basket: [] as BasketItem[], basket: [] as BasketItem[],
init() { init() {
@@ -19,6 +19,15 @@ document.addEventListener("alpine:init", () => {
this.$watch("basket", () => { this.$watch("basket", () => {
this.saveBasket(); this.saveBasket();
}); });
// Invalidate basket if a purchase was made
if (lastPurchaseTime !== null && localStorage.basketTimestamp !== undefined) {
if (
new Date(lastPurchaseTime) >=
new Date(Number.parseInt(localStorage.basketTimestamp, 10))
) {
this.basket = [];
}
}
document document
.getElementById("id_form-TOTAL_FORMS") .getElementById("id_form-TOTAL_FORMS")
.setAttribute(":value", "basket.length"); .setAttribute(":value", "basket.length");
@@ -28,22 +37,7 @@ document.addEventListener("alpine:init", () => {
const cached = versionedLocalStorage.getItem<BasketItem[]>(BASKET_CACHE_KEY, { const cached = versionedLocalStorage.getItem<BasketItem[]>(BASKET_CACHE_KEY, {
version: BASKET_CACHE_VERSION, version: BASKET_CACHE_VERSION,
}); });
if (!cached) { return cached ?? [];
return [];
}
if (
lastPurchaseTime !== null &&
localStorage.basketTimestamp !== undefined &&
new Date(lastPurchaseTime) >=
new Date(Number.parseInt(localStorage.basketTimestamp, 10))
) {
// Invalidate basket if a purchase was made
return [];
}
// The basket is cached and not expired, so return it,
// but without items that are invalid
// (e.g. because the product is archived, or sold out)
return cached.filter((item) => validPrices.includes(item.priceId));
}, },
saveBasket() { saveBasket() {
@@ -21,7 +21,6 @@
hx-swap="outerHTML" hx-swap="outerHTML"
hx-target="#billing-infos-fragment" hx-target="#billing-infos-fragment"
x-show="collapsed" x-show="collapsed"
x-cloak
> >
{% csrf_token %} {% csrf_token %}
{{ form.as_p() }} {{ form.as_p() }}
@@ -16,13 +16,10 @@
<h3>{% trans %}Eboutic{% endtrans %}</h3> <h3>{% trans %}Eboutic{% endtrans %}</h3>
<script type="text/javascript"> <script type="text/javascript">
const billingInfos = {{ billing_infos|safe }}; let billingInfos = {{ billing_infos|safe }};
</script> </script>
<div x-data='etransaction( <div x-data="etransaction(billingInfos, {{ basket.id }})">
billingInfos,
{ id: {{ basket.id }}, timeout: new Date("{{ basket.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT }}") }
)'>
<p>{% trans %}Basket: {% endtrans %}</p> <p>{% trans %}Basket: {% endtrans %}</p>
<table> <table>
<thead> <thead>
@@ -75,11 +72,7 @@
x-cloak x-cloak
type="submit" type="submit"
id="bank-submit-button" id="bank-submit-button"
{% if basket.is_expired %}
disabled="disabled"
{% else %}
:disabled="!isCbAvailable" :disabled="!isCbAvailable"
{% endif %}
class="btn btn-blue" class="btn btn-blue"
value="{% trans %}Pay with credit card{% endtrans %}" value="{% trans %}Pay with credit card{% endtrans %}"
/> />
@@ -100,16 +93,7 @@
{% else %} {% else %}
<form method="post" action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}" name="sith-pay-form"> <form method="post" action="{{ url('eboutic:pay_with_sith', basket_id=basket.id) }}" name="sith-pay-form">
{% csrf_token %} {% csrf_token %}
<input <input class="btn btn-blue" type="submit" value="{% trans %}Pay with Sith account{% endtrans %}"/>
{% if basket.is_expired %}
disabled="disabled"
{% else %}
:disabled="!isSithAvailable"
{% endif %}
class="btn btn-blue"
type="submit"
value="{% trans %}Pay with Sith account{% endtrans %}"
/>
</form> </form>
{% endif %} {% endif %}
</div> </div>
+2 -16
View File
@@ -30,17 +30,7 @@
{% block content %} {% block content %}
<h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1> <h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1>
<div <div id="eboutic" x-data="basket({{ last_purchase_time }})">
id="eboutic"
x-data="basket(
[{%- for prices in categories -%}
{%- for p in prices -%}
{% if not p.sold_out %}{{ p.id }},{% endif %}
{%- endfor -%}
{%- endfor -%}],
{{ last_purchase_time }},
)"
>
<div id="basket"> <div id="basket">
<h3>Panier</h3> <h3>Panier</h3>
<form method="post" action=""> <form method="post" action="">
@@ -197,10 +187,9 @@
{% for price in prices %} {% for price in prices %}
<button <button
id="{{ price.id }}" id="{{ price.id }}"
class="card clickable shadow" class="card product-button clickable shadow"
:class="{selected: basket.some((i) => i.priceId === {{ price.id }})}" :class="{selected: basket.some((i) => i.priceId === {{ price.id }})}"
@click='addFromCatalog({{ price.id }}, {{ price.full_label|tojson }}, {{ price.amount }})' @click='addFromCatalog({{ price.id }}, {{ price.full_label|tojson }}, {{ price.amount }})'
{% if price.sold_out %}disabled{% endif %}
> >
{% if price.product.icon %} {% if price.product.icon %}
<img <img
@@ -213,9 +202,6 @@
{% endif %} {% endif %}
<div class="card-content"> <div class="card-content">
<h4 class="card-title">{{ price.full_label }}</h4> <h4 class="card-title">{{ price.full_label }}</h4>
{% if price.sold_out -%}
<p><em>{% trans %}Product sold out{% endtrans %}</em></p>
{%- endif %}
<p>{{ price.amount }} €</p> <p>{{ price.amount }} €</p>
</div> </div>
</button> </button>
+20 -46
View File
@@ -1,19 +1,14 @@
import re
from datetime import datetime, timezone from datetime import datetime, timezone
import freezegun
import pytest import pytest
from bs4 import BeautifulSoup
from django.conf import settings
from django.http import HttpResponse from django.http import HttpResponse
from django.test import TestCase from django.test import TestCase
from django.test.client import Client from django.test.client import Client
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import localdate, now from django.utils.timezone import localdate
from model_bakery import baker from model_bakery import baker
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
import eboutic.models
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import Group, User from core.models import Group, User
from counter.baker_recipes import ( from counter.baker_recipes import (
@@ -135,11 +130,9 @@ def test_eboutic_basket_expiry(
_bulk_create=True, _bulk_create=True,
) )
soup = BeautifulSoup(client.get(reverse("eboutic:main")).text, "lxml")
assert ( assert (
# remove any space from the value before asserting f'x-data="basket({int(expected.timestamp() * 1000) if expected else "null"})"'
re.sub(r"\s+", "", soup.find(id="eboutic").attrs["x-data"]) in client.get(reverse("eboutic:main")).text
== f"basket([],{int(expected.timestamp() * 1000) if expected else 'null'},)"
) )
@@ -238,45 +231,26 @@ class TestEboutic(TestCase):
def test_add_forbidden_product(self): def test_add_forbidden_product(self):
self.client.force_login(self.new_customer) self.client.force_login(self.new_customer)
for product in self.beer, self.cotiz, self.not_in_counter: response = self.submit_basket([BasketItem(self.beer.id, 1)])
response = self.submit_basket([BasketItem(product.id, 1)])
assert response.status_code == 200 assert response.status_code == 200
assert not Basket.objects.exists() assert Basket.objects.first() is None
def test_sold_out_product(self): response = self.submit_basket([BasketItem(self.cotiz.id, 1)])
sold_out = product_recipe.make(
clic_limit=3, counters=[self.eboutic], product_type=baker.make(ProductType)
)
price = price_recipe.make(product=sold_out, groups=[self.group_cotiz], amount=0)
sale_recipe.make(
product=sold_out,
customer=self.subscriber.customer,
unit_price=0,
quantity=1,
)
baker.make(
eboutic.models.BasketItem,
basket=baker.make(Basket),
product=sold_out,
quantity=2,
)
self.client.force_login(self.subscriber)
response = self.submit_basket([BasketItem(price.id, 1)])
assert response.status_code == 200 assert response.status_code == 200
assert Basket.objects.count() == 1 assert Basket.objects.first() is None
with freezegun.freeze_time(
now() response = self.submit_basket([BasketItem(self.not_in_counter.id, 1)])
+ settings.SITH_EBOUTIC_BASKET_TIMEOUT assert response.status_code == 200
+ settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT assert Basket.objects.first() is None
):
# after a while, unpaid basket items should expire and make the self.client.force_login(self.new_customer)
# product available again. response = self.submit_basket([BasketItem(self.cotiz.id, 1)])
response = self.submit_basket([BasketItem(price.id, 1)]) assert response.status_code == 200
assertRedirects( assert Basket.objects.first() is None
response,
reverse("eboutic:checkout", kwargs={"basket_id": Basket.objects.last().id}), response = self.submit_basket([BasketItem(self.not_in_counter.id, 1)])
) assert response.status_code == 200
assert Basket.objects.count() == 2 assert Basket.objects.first() is None
def test_create_basket(self): def test_create_basket(self):
self.client.force_login(self.new_customer) self.client.force_login(self.new_customer)
+7 -27
View File
@@ -3,7 +3,6 @@ import urllib
from decimal import Decimal from decimal import Decimal
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import freezegun
from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
from cryptography.hazmat.primitives.hashes import SHA1 from cryptography.hazmat.primitives.hashes import SHA1
@@ -18,7 +17,7 @@ from pytest_django.asserts import assertRedirects
from core.baker_recipes import old_subscriber_user, subscriber_user from core.baker_recipes import old_subscriber_user, subscriber_user
from counter.baker_recipes import price_recipe, product_recipe from counter.baker_recipes import price_recipe, product_recipe
from counter.models import Product, ProductType, Refilling, Selling from counter.models import Product, ProductType, Selling
from counter.tests.test_counter import force_refill_user from counter.tests.test_counter import force_refill_user
from eboutic.models import Basket, BasketItem from eboutic.models import Basket, BasketItem
@@ -106,7 +105,7 @@ class TestPaymentSith(TestPaymentBase):
), ),
reverse("eboutic:payment_result", kwargs={"result": "success"}), reverse("eboutic:payment_result", kwargs={"result": "success"}),
) )
assert not Basket.objects.filter(id=self.basket.id).exists() assert Basket.objects.filter(id=self.basket.id).first() is None
self.customer.customer.refresh_from_db() self.customer.customer.refresh_from_db()
assert self.customer.customer.amount == Decimal(1) assert self.customer.customer.amount == Decimal(1)
@@ -140,7 +139,10 @@ class TestPaymentSith(TestPaymentBase):
assert len(messages) == 1 assert len(messages) == 1
assert messages[0].level == DEFAULT_LEVELS["ERROR"] assert messages[0].level == DEFAULT_LEVELS["ERROR"]
assert messages[0].message == "Solde insuffisant" assert messages[0].message == "Solde insuffisant"
assert not Basket.objects.filter(id=self.basket.id).exists()
assert Basket.objects.contains(self.basket), (
"After an unsuccessful request, the basket should be kept"
)
def test_refilling_in_basket(self): def test_refilling_in_basket(self):
BasketItem.from_price(self.refilling.prices.first(), 1, self.basket).save() BasketItem.from_price(self.refilling.prices.first(), 1, self.basket).save()
@@ -155,7 +157,7 @@ class TestPaymentSith(TestPaymentBase):
response, response,
reverse("eboutic:payment_result", kwargs={"result": "failure"}), reverse("eboutic:payment_result", kwargs={"result": "failure"}),
) )
assert not Basket.objects.filter(id=self.basket.id).exists() assert Basket.objects.filter(id=self.basket.id).first() is not None
messages = list(get_messages(response.wsgi_request)) messages = list(get_messages(response.wsgi_request))
assert messages[0].level == DEFAULT_LEVELS["ERROR"] assert messages[0].level == DEFAULT_LEVELS["ERROR"]
assert ( assert (
@@ -165,24 +167,6 @@ class TestPaymentSith(TestPaymentBase):
self.customer.customer.refresh_from_db() self.customer.customer.refresh_from_db()
assert self.customer.customer.amount == initial_account_balance assert self.customer.customer.amount == initial_account_balance
def test_basket_expired(self):
self.client.force_login(self.customer)
initial_account_balance = self.customer.customer.amount
with freezegun.freeze_time(settings.SITH_EBOUTIC_BASKET_TIMEOUT):
response = self.client.post(
reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id})
)
assertRedirects(
response,
reverse("eboutic:payment_result", kwargs={"result": "failure"}),
)
messages = list(get_messages(response.wsgi_request))
assert messages[0].level == DEFAULT_LEVELS["ERROR"]
assert messages[0].message == "Panier expiré"
assert not Basket.objects.filter(id=self.basket.id).exists()
self.customer.customer.refresh_from_db()
assert self.customer.customer.amount == initial_account_balance
class TestPaymentCard(TestPaymentBase): class TestPaymentCard(TestPaymentBase):
def generate_bank_valid_answer(self, basket: Basket): def generate_bank_valid_answer(self, basket: Basket):
@@ -252,10 +236,6 @@ class TestPaymentCard(TestPaymentBase):
self.customer.customer.refresh_from_db() self.customer.customer.refresh_from_db()
assert self.customer.customer.amount == price.amount * 2 assert self.customer.customer.amount == price.amount * 2
refill = self.customer.customer.refillings.last()
assert refill is not None
assert refill.amount == price.amount * 2
assert refill.payment_method == Refilling.PaymentMethod.CARD
def test_multiple_responses(self): def test_multiple_responses(self):
bank_response = self.generate_bank_valid_answer(self.basket) bank_response = self.generate_bank_valid_answer(self.basket)
+8 -30
View File
@@ -33,14 +33,12 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import SuspiciousOperation, ValidationError from django.core.exceptions import SuspiciousOperation, ValidationError
from django.db import DatabaseError, transaction from django.db import DatabaseError, transaction
from django.db.models import Exists, OuterRef, Subquery from django.db.models import Subquery
from django.db.models.fields import forms from django.db.models.fields import forms
from django.db.utils import cached_property from django.db.utils import cached_property
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.formats import localize
from django.utils.timezone import localtime
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_GET from django.views.decorators.http import require_GET
from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View
@@ -92,9 +90,7 @@ class EbouticMainView(LoginRequiredMixin, FormView):
kwargs["form_kwargs"] = { kwargs["form_kwargs"] = {
"customer": self.customer, "customer": self.customer,
"counter": get_eboutic(), "counter": get_eboutic(),
"allowed_prices": { "allowed_prices": {price.id: price for price in self.prices},
price.id: price for price in self.prices if not price.sold_out
},
} }
return kwargs return kwargs
@@ -120,14 +116,9 @@ class EbouticMainView(LoginRequiredMixin, FormView):
@cached_property @cached_property
def prices(self) -> list[Price]: def prices(self) -> list[Price]:
eboutic = get_eboutic() return get_eboutic().get_prices_for(
sold_out_subquery = ~Exists( self.customer,
eboutic.products.under_clic_limit().filter(id=OuterRef("product_id")) order_by=["product__product_type__order", "product_id", "amount"],
)
return list(
eboutic.get_prices_for(self.customer)
.annotate(sold_out=sold_out_subquery)
.order_by("product__product_type__order", "product_id", "amount")
) )
@cached_property @cached_property
@@ -196,7 +187,9 @@ class BillingInfoFormFragment(
def get_initial(self): def get_initial(self):
if self.object is None: if self.object is None:
return {"country": Country(code="FR")} return {
"country": Country(code="FR"),
}
return {} return {}
def render_fragment(self, request, **kwargs) -> SafeString: def render_fragment(self, request, **kwargs) -> SafeString:
@@ -262,15 +255,6 @@ class EbouticCheckout(CanViewMixin, UseFragmentsMixin, DetailView):
kwargs["customer_amount"] = None kwargs["customer_amount"] = None
kwargs["billing_infos"] = {} kwargs["billing_infos"] = {}
if self.object.is_expired:
messages.error(self.request, _("Basket expired"))
else:
timeout = self.object.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT
messages.warning(
self.request,
_("Basket available until %(until)s")
% {"until": localize(localtime(timeout).time())},
)
with contextlib.suppress(BillingInfo.DoesNotExist): with contextlib.suppress(BillingInfo.DoesNotExist):
kwargs["billing_infos"] = json.dumps( kwargs["billing_infos"] = json.dumps(
dict(self.object.get_e_transaction_data()) dict(self.object.get_e_transaction_data())
@@ -284,14 +268,9 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
basket = self.get_object() basket = self.get_object()
if basket.is_expired:
messages.error(self.request, _("Basket expired"))
basket.delete()
return redirect("eboutic:payment_result", "failure")
refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING
if basket.items.filter(product__product_type_id=refilling).exists(): if basket.items.filter(product__product_type_id=refilling).exists():
messages.error(self.request, _("You can't buy a refilling with sith money")) messages.error(self.request, _("You can't buy a refilling with sith money"))
basket.delete()
return redirect("eboutic:payment_result", "failure") return redirect("eboutic:payment_result", "failure")
eboutic = get_eboutic() eboutic = get_eboutic()
@@ -309,7 +288,6 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
except DatabaseError as e: except DatabaseError as e:
sentry_sdk.capture_exception(e) sentry_sdk.capture_exception(e)
except ValidationError as e: except ValidationError as e:
basket.delete()
messages.error(self.request, e.message) messages.error(self.request, e.message)
return redirect("eboutic:payment_result", "failure") return redirect("eboutic:payment_result", "failure")
+30 -133
View File
@@ -1,18 +1,6 @@
from datetime import timedelta
from itertools import groupby, islice
from operator import attrgetter
from django import forms from django import forms
from django.conf import settings
from django.db import transaction
from django.db.models import Count
from django.forms.models import ModelChoiceIterator, ModelChoiceIteratorValue
from django.utils.timezone import localdate, localtime
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from club.forms import ClubRoleChoiceField
from club.models import ClubRole, Membership
from club.widgets.ajax_select import AutoCompleteSelectMultipleClub
from core.models import User from core.models import User
from core.views.forms import SelectDateTime from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import ( from core.views.widgets.ajax_select import (
@@ -91,19 +79,26 @@ class VoteForm(forms.Form):
class RoleForm(forms.ModelForm): class RoleForm(forms.ModelForm):
"""Form for creating a role.""" """Form for creating a role."""
required_css_class = "required"
error_css_class = "error"
class Meta: class Meta:
model = Role model = Role
fields = ["club_role", "title", "description", "max_choice"] fields = ["title", "election", "description", "max_choice"]
field_classes = {"club_role": ClubRoleChoiceField} widgets = {"election": AutoCompleteSelect}
def __init__(self, *args, election: Election, **kwargs): def __init__(self, *args, **kwargs):
election_id = kwargs.pop("election_id", None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.instance.election = election if election_id:
self.fields["club_role"].queryset = ClubRole.objects.filter( self.fields["election"].queryset = Election.objects.filter(
is_board=True, club__in=election.clubs.all() id=election_id
).all()
def clean(self):
cleaned_data = super().clean()
title = cleaned_data.get("title")
election = cleaned_data.get("election")
if Role.objects.filter(title=title, election=election).exists():
raise forms.ValidationError(
_("This role already exists for this election"), code="invalid"
) )
@@ -113,21 +108,21 @@ class ElectionListForm(forms.ModelForm):
fields = ("title", "election") fields = ("title", "election")
widgets = {"election": AutoCompleteSelect} widgets = {"election": AutoCompleteSelect}
def __init__(self, *args, election: Election, **kwargs): def __init__(self, *args, **kwargs):
election_id = kwargs.pop("election_id", None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.instance.election = election if election_id:
self.fields["election"].queryset = Election.objects.filter(
id=election_id
).all()
class ElectionForm(forms.ModelForm): class ElectionForm(forms.ModelForm):
required_css_class = "required"
error_css_class = "error"
class Meta: class Meta:
model = Election model = Election
fields = [ fields = [
"title", "title",
"description", "description",
"clubs",
"archived", "archived",
"start_candidature", "start_candidature",
"end_candidature", "end_candidature",
@@ -139,119 +134,21 @@ class ElectionForm(forms.ModelForm):
"candidature_groups", "candidature_groups",
] ]
widgets = { widgets = {
"clubs": AutoCompleteSelectMultipleClub,
"edit_groups": AutoCompleteSelectMultipleGroup, "edit_groups": AutoCompleteSelectMultipleGroup,
"view_groups": AutoCompleteSelectMultipleGroup, "view_groups": AutoCompleteSelectMultipleGroup,
"vote_groups": AutoCompleteSelectMultipleGroup, "vote_groups": AutoCompleteSelectMultipleGroup,
"candidature_groups": AutoCompleteSelectMultipleGroup, "candidature_groups": AutoCompleteSelectMultipleGroup,
"start_date": SelectDateTime,
"end_date": SelectDateTime,
"start_candidature": SelectDateTime,
"end_candidature": SelectDateTime,
} }
start_date = forms.DateTimeField(
class ElectionCreateForm(ElectionForm): label=_("Start date"), widget=SelectDateTime, required=True
"""ElectionForm, but specifically for creation."""
def __init__(self, *args, initial: dict | None = None, **kwargs):
# propose sound default timestamps :
# start of candidatures at tomorrow 00h01, start of votes a week later.
start = localtime().replace(hour=0, minute=1, second=0) + timedelta(days=1)
default_initial = {
"start_candidature": start,
"end_candidature": start + timedelta(days=7, minutes=-2), # 23h59
"start_date": start + timedelta(days=7), # 00h01
"end_date": start + timedelta(days=14, minutes=-2), # 23h59
"view_groups": [settings.SITH_GROUP_PUBLIC_ID],
"vote_groups": [settings.SITH_GROUP_SUBSCRIBERS_ID],
"candidature_groups": [settings.SITH_GROUP_SUBSCRIBERS_ID],
}
if initial:
default_initial.update(initial)
super().__init__(*args, initial=default_initial, **kwargs)
def save(self, commit=True): # noqa: FBT002
instance = super().save(commit=commit)
if commit:
ElectionList.objects.create(title="Candidat⸱e libre", election=instance)
return instance
class ClubRoleChoiceIterator(ModelChoiceIterator):
"""Iterate over the candidates that gathered enough votes"""
def __iter__(self):
# for each role, yield only the N first candidates,
# where N is the election role max_choice
yield from (
(
f"{role.title} \u2013 {role.club_role.club.name}",
[self.choice(cand) for cand in islice(candidates, role.max_choice)],
) )
for role, candidates in groupby(self.queryset, key=attrgetter("role")) end_date = forms.DateTimeField(
label=_("End date"), widget=SelectDateTime, required=True
) )
start_candidature = forms.DateTimeField(
def choice(self, obj: Candidature): label=_("Start candidature"), widget=SelectDateTime, required=True
return (
ModelChoiceIteratorValue(self.field.prepare_value(obj), obj),
obj.user.get_full_name(),
) )
end_candidature = forms.DateTimeField(
label=_("End candidature"), widget=SelectDateTime, required=True
class ApplyRoleChoiceField(forms.ModelMultipleChoiceField):
"""Custom `ModelChoiceField` for `[ClubRole][club.models.ClubRole]`.
If only one club is involved, behave like the base `ModelChoiceField`.
If dealing with the roles of multiple clubs, group the roles
into a different `optgroup` for each club.
"""
iterator = ClubRoleChoiceIterator
widget = forms.CheckboxSelectMultiple
class ApplyRoleResultForm(forms.Form):
"""Form to select winners of an election, and automatically apply the results."""
candidates = ApplyRoleChoiceField(Candidature.objects.none())
def __init__(self, *args, election: Election, **kwargs):
self.election = election
super().__init__(*args, **kwargs)
qs = (
Candidature.objects.filter(role__election=election)
.exclude(role__club_role=None)
.annotate(nb_votes=Count("votes"))
.order_by("role__order", "-nb_votes")
.select_related("user", "role", "role__club_role", "role__club_role__club")
) )
# pass all candidates to the ModelChoiceField ;
# its inner choice iterator will take care of filtering only the winners.
self.fields["candidates"].queryset = qs
# By default, mark every candidate as selected.
# Election results are usually completely validated during the AG,
# so it makes more sense UX-wise to eventually unselect a candidate
# than to select everyone.
self.fields["candidates"].initial = qs
def save(self):
if self.errors:
return
candidates: list[Candidature] = list(self.cleaned_data["candidates"])
with transaction.atomic():
Membership.objects.filter(
role__in=[c.role.club_role for c in candidates],
end_date=None,
start_date__lt=self.election.end_date,
).update(end_date=localdate())
memberships = [
Membership(
user_id=c.user_id,
club_id=c.role.club_role.club_id,
role=c.role.club_role,
)
for c in candidates
]
Membership.objects.bulk_create(memberships)
Membership._add_club_groups(memberships)
@@ -1,62 +0,0 @@
# Generated by Django 5.2.14 on 2026-05-30 20:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("club", "0017_linktype_clublink"),
("election", "0005_alter_candidature_program_alter_candidature_user"),
]
operations = [
migrations.AddField(
model_name="election",
name="clubs",
field=models.ManyToManyField(
help_text="The club(s) this election is held for.",
related_name="elections",
to="club.club",
verbose_name="clubs",
),
),
migrations.AddField(
model_name="role",
name="club_role",
field=models.ForeignKey(
blank=True,
help_text=(
"A club role. Filling this will allow automatic "
"completion of title and description, "
"and automatic assignation after the elections."
),
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="election_roles",
to="club.clubrole",
verbose_name="club role",
),
),
migrations.AlterField(
model_name="role",
name="description",
field=models.TextField(blank=True, default="", verbose_name="description"),
),
migrations.AlterField(
model_name="role",
name="max_choice",
field=models.PositiveSmallIntegerField(
default=1, verbose_name="max choice"
),
),
migrations.AddConstraint(
model_name="role",
constraint=models.UniqueConstraint(
fields=("title", "election"),
name="title_election_unique_constraint",
violation_error_code="invalid",
violation_error_message="This role already exists for this election",
),
),
]
+5 -46
View File
@@ -5,7 +5,6 @@ from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from ordered_model.models import OrderedModel from ordered_model.models import OrderedModel
from club.models import Club, ClubRole, Membership
from core.models import Group, User from core.models import Group, User
@@ -14,12 +13,6 @@ class Election(models.Model):
title = models.CharField(_("title"), max_length=255) title = models.CharField(_("title"), max_length=255)
description = models.TextField(_("description"), null=True, blank=True) description = models.TextField(_("description"), null=True, blank=True)
clubs = models.ManyToManyField(
Club,
related_name="elections",
verbose_name=_("clubs"),
help_text=_("The club(s) this election is held for."),
)
start_candidature = models.DateTimeField(_("start candidature"), blank=False) start_candidature = models.DateTimeField(_("start candidature"), blank=False)
end_candidature = models.DateTimeField(_("end candidature"), blank=False) end_candidature = models.DateTimeField(_("end candidature"), blank=False)
start_date = models.DateTimeField(_("start date"), blank=False) start_date = models.DateTimeField(_("start date"), blank=False)
@@ -101,18 +94,9 @@ class Election(models.Model):
results[role.title] = role.results(total_vote) results[role.title] = role.results(total_vote)
return results return results
@cached_property
def results_applied(self) -> bool:
"""Returns True if one or more roles of this election have been applied."""
return Membership.objects.filter(
role__election_roles__election=self,
end_date=None,
start_date__gte=self.end_date,
).exists()
class Role(OrderedModel): class Role(OrderedModel):
"""This class allows to create a new role available for a candidature.""" """This class allows to create a new role avaliable for a candidature."""
election = models.ForeignKey( election = models.ForeignKey(
Election, Election,
@@ -121,42 +105,17 @@ class Role(OrderedModel):
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
title = models.CharField(_("title"), max_length=255) title = models.CharField(_("title"), max_length=255)
description = models.TextField(_("description"), default="", blank=True) description = models.TextField(_("description"), null=True, blank=True)
max_choice = models.PositiveSmallIntegerField(_("max choice"), default=1) max_choice = models.IntegerField(_("max choice"), default=1)
club_role = models.ForeignKey(
ClubRole,
related_name="election_roles",
verbose_name=_("club role"),
help_text=_(
"A club role. Filling this will allow automatic "
"completion of title and description, "
"and automatic assignation after the elections."
),
on_delete=models.CASCADE,
null=True,
blank=True,
)
order_with_respect_to = "election"
class Meta(OrderedModel.Meta):
constraints = [
models.UniqueConstraint(
fields=["title", "election"],
name="title_election_unique_constraint",
violation_error_message=_("This role already exists for this election"),
violation_error_code="invalid",
)
]
def __str__(self): def __str__(self):
return f"{self.title} - {self.election.title}" return f"{self.title} - {self.election.title}"
def results(self, total_vote: int) -> dict[str, dict[str, int | float]]: def results(self, total_vote: int) -> dict[str, dict[str, int | float]]:
if total_vote == 0: if total_vote == 0:
candidates = self.candidatures.values_list("user__username", flat=True) candidates = self.candidatures.values_list("user__username")
return { return {
key: {"vote": 0, "percent": 0} for key in ["blank vote", *candidates] key: {"vote": 0, "percent": 0} for key in ["blank_votes", *candidates]
} }
total_vote *= self.max_choice total_vote *= self.max_choice
results = {"total vote": total_vote} results = {"total vote": total_vote}
@@ -29,25 +29,13 @@
{% trans %}Polls closed {% endtrans %} {% trans %}Polls closed {% endtrans %}
{%- else %} {%- else %}
{% trans %}Polls will open {% endtrans %} {% trans %}Polls will open {% endtrans %}
<time datetime="{{ election.start_date }}">{{ election.start_date|localtime|date(DATETIME_FORMAT) }}</time> <time datetime="{{ election.start_date }}">{{ election.start_date|localtime|date(DATETIME_FORMAT)}}</time>
{% trans %}at{% endtrans %} {% trans %} at {% endtrans %}<time>{{ election.start_date|localtime|time(DATETIME_FORMAT)}}</time>
<time>{{ election.start_date|localtime|time(DATETIME_FORMAT) }}</time>
{% trans %}and will close {% endtrans %} {% trans %}and will close {% endtrans %}
{%- endif %} {%- endif %}
<time datetime="{{ election.end_date }}">{{ election.end_date|localtime|date(DATETIME_FORMAT) }}</time> <time datetime="{{ election.end_date }}">{{ election.end_date|localtime|date(DATETIME_FORMAT)}}</time>
{% trans %}at{% endtrans %} {% trans %} at {% endtrans %}<time>{{ election.end_date|localtime|time(DATETIME_FORMAT)}}</time>
<time>{{ election.end_date|localtime|time(DATETIME_FORMAT) }}</time>
</p> </p>
{%- if election.is_vote_finished and user.can_edit(election) %}
<details class="accordion" name="apply-result">
<summary>{% trans %}Apply election result{% endtrans %}</summary>
<div
class="accordion-content aria-busy-grow"
hx-get="{{ url("election:apply_result", election_id=election.id) }}"
hx-trigger="toggle from:closest details once"
></div>
</details>
{% endif %}
{%- if user_has_voted %} {%- if user_has_voted %}
<p class="election__elector-infos"> <p class="election__elector-infos">
{%- if election.is_vote_active %} {%- if election.is_vote_active %}
@@ -59,27 +47,17 @@
{%- endif %} {%- endif %}
</section> </section>
<section class="election_vote"> <section class="election_vote">
<form <form action="{{ url('election:vote', election.id) }}" method="post" class="election__vote-form" name="vote-form" id="vote-form">
action="{{ url('election:vote', election.id) }}"
method="post"
class="election__vote-form"
name="vote-form"
id="vote-form"
>
{% csrf_token %} {% csrf_token %}
<table class="election_table"> <table class="election_table">
<thead class="lists"> <thead class="lists">
<tr> <tr>
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%"> <th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">{% trans %}Blank vote{% endtrans %}</th>
{% trans %}Blank vote{% endtrans %}
</th>
{%- for election_list in election_lists %} {%- for election_list in election_lists %}
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%"> <th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">
<span>{{ election_list.title }}</span> <span>{{ election_list.title }}</span>
{% if user.can_edit(election_list) and election.is_vote_editable -%} {% if user.can_edit(election_list) and election.is_vote_editable -%}
<a href="{{ url('election:delete_list', list_id=election_list.id) }}"> <a href="{{ url('election:delete_list', list_id=election_list.id) }}"><i class="fa-regular fa-trash-can delete-action"></i></a>
<i class="fa-regular fa-trash-can delete-action"></i>
</a>
{% endif %} {% endif %}
</th> </th>
{%- endfor %} {%- endfor %}
@@ -125,45 +103,22 @@
<button disabled><i class="fa fa-arrow-down"></i></button> <button disabled><i class="fa fa-arrow-down"></i></button>
<button disabled><i class="fa fa-caret-down"></i></button> <button disabled><i class="fa fa-caret-down"></i></button>
{%- else -%} {%- else -%}
<button <button type="button" onclick="window.location.replace('?role={{ role.id }}&action=bottom');"><i class="fa fa-arrow-down"></i></button>
type="button" <button type="button" onclick="window.location.replace('?role={{ role.id }}&action=down');"><i class="fa fa-caret-down"></i></button>
onclick="window.location.replace('?role={{ role.id }}&action=bottom');"
>
<i class="fa fa-arrow-down"></i>
</button>
<button
type="button"
onclick="window.location.replace('?role={{ role.id }}&action=down');"
>
<i class="fa fa-caret-down"></i>
</button>
{%- endif -%} {%- endif -%}
{%- if loop.first -%} {%- if loop.first -%}
<button disabled><i class="fa fa-caret-up"></i></button> <button disabled><i class="fa fa-caret-up"></i></button>
<button disabled><i class="fa fa-arrow-up"></i></button> <button disabled><i class="fa fa-arrow-up"></i></button>
{%- else -%} {%- else -%}
<button <button type="button" onclick="window.location.replace('?role={{ role.id }}&action=up');"><i class="fa fa-caret-up"></i></button>
type="button" <button type="button" onclick="window.location.replace('?role={{ role.id }}&action=top');"><i class="fa fa-arrow-up"></i></button>
onclick="window.location.replace('?role={{ role.id }}&action=up');"
>
<i
class="fa fa-caret-up"></i>
</button>
<button
type="button"
onclick="window.location.replace('?role={{ role.id }}&action=top');"
><i class="fa fa-arrow-up"></i>
</button>
{%- endif -%} {%- endif -%}
</div> </div>
{%- endif -%} {%- endif -%}
</td> </td>
</tr> </tr>
<tr class="role_candidates"> <tr class="role_candidates">
<td <td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%">
class="list_per_role"
style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%"
>
{%- if role.max_choice == 1 and show_vote_buttons %} {%- if role.max_choice == 1 and show_vote_buttons %}
<div class="radio-btn"> <div class="radio-btn">
{% set input_id = "blank_vote_" + role.id|string %} {% set input_id = "blank_vote_" + role.id|string %}
@@ -176,46 +131,26 @@
{%- if election.is_vote_finished %} {%- if election.is_vote_finished %}
{%- set results = election_results[role.title]['blank vote'] %} {%- set results = election_results[role.title]['blank vote'] %}
<div class="election__results"> <div class="election__results">
<strong> <strong>{{ results.vote }} {% trans %}votes{% endtrans %} ({{ "%.2f" % results.percent }} %)</strong>
{{ results.vote }} {% trans %}votes{% endtrans %} ({{ "%.2f" % results.percent }} %)
</strong>
</div> </div>
{%- endif %} {%- endif %}
</td> </td>
{%- for election_list in election_lists %} {%- for election_list in election_lists %}
<td <td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%">
class="list_per_role"
style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%"
>
<ul class="candidates"> <ul class="candidates">
{%- for candidature in election_list.candidatures.select_related("user", "user__profile_pict").filter(role=role) %} {%- for candidature in election_list.candidatures.select_related("user", "user__profile_pict").filter(role=role) %}
<li class="candidate"> <li class="candidate">
{%- if show_vote_buttons %} {%- if show_vote_buttons %}
{% set input_id = "candidature_" + candidature.id|string %} {% set input_id = "candidature_" + candidature.id|string %}
<input <input id="{{ input_id }}" type="{{ 'checkbox' if role.max_choice > 1 else 'radio' }}" {{ 'checked' if candidature.id|string in role_data else '' }} {{ 'disabled' if user_has_voted else '' }} name="{{ role.title }}" value="{{ candidature.id }}">
id="{{ input_id }}"
type="{{ 'checkbox' if role.max_choice > 1 else 'radio' }}"
{% if candidature.id|string in role_data %}checked{% endif %}
{% if user_has_voted %}disabled{% endif %}
name="{{ role.title }}"
value="{{ candidature.id }}"
>
<label for="{{ input_id }}"> <label for="{{ input_id }}">
{%- endif %} {%- endif %}
<figure> <figure>
{%- if user.can_view(candidature.user) %} {%- if user.can_view(candidature.user) %}
{% if candidature.user.profile_pict %} {% if candidature.user.profile_pict %}
<img <img class="candidate__picture" src="{{ candidature.user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}">
class="candidate__picture"
src="{{ candidature.user.profile_pict.get_download_url() }}"
alt="{% trans %}Profile{% endtrans %}"
>
{% else %} {% else %}
<img <img class="candidate__picture" src="{{ static('core/img/unknown.jpg') }}" alt="{% trans %}Profile{% endtrans %}">
class="candidate__picture"
src="{{ static('core/img/unknown.jpg') }}"
alt="{% trans %}Profile{% endtrans %}"
>
{% endif %} {% endif %}
{%- endif %} {%- endif %}
<figcaption class="candidate__details"> <figcaption class="candidate__details">
@@ -229,12 +164,8 @@
{%- if user.can_edit(candidature) -%} {%- if user.can_edit(candidature) -%}
{%- if election.is_vote_editable -%} {%- if election.is_vote_editable -%}
<div class="edit_btns"> <div class="edit_btns">
<a href="{{ url('election:update_candidate', candidature_id=candidature.id) }}"> <a href="{{url('election:update_candidate', candidature_id=candidature.id)}}"><i class="fa-regular fa-pen-to-square edit-action"></i></a>
<i class="fa-regular fa-pen-to-square edit-action"></i> <a href="{{url('election:delete_candidate', candidature_id=candidature.id)}}"><i class="fa-regular fa-trash-can delete-action"></i></a>
</a>
<a href="{{ url('election:delete_candidate', candidature_id=candidature.id) }}">
<i class="fa-regular fa-trash-can delete-action"></i>
</a>
</div> </div>
{%- endif -%} {%- endif -%}
{%- endif -%} {%- endif -%}
@@ -7,7 +7,7 @@
{% block head %} {% block head %}
{{ super() -}} {{ super() -}}
<style> <style type="text/css">
small { small {
font-size: smaller; font-size: smaller;
} }
@@ -20,9 +20,6 @@
{% block content %} {% block content %}
<h3>{% trans %}Current elections{% endtrans %}</h3> <h3>{% trans %}Current elections{% endtrans %}</h3>
<a class="btn btn-blue" href="{{ url("election:create") }}">
<i class="fa fa-plus"></i>{% trans %}New election{% endtrans %}
</a>
{%- for election in object_list %} {%- for election in object_list %}
<hr> <hr>
<section> <section>
@@ -35,7 +32,7 @@
{% trans %} at {% endtrans %}<time>{{ election.start_candidature|localtime|time(DATETIME_FORMAT) }}</time> {% trans %} at {% endtrans %}<time>{{ election.start_candidature|localtime|time(DATETIME_FORMAT) }}</time>
{% trans %}to{% endtrans %} {% trans %}to{% endtrans %}
<time datetime="{{ election.end_candidature }}">{{ election.end_candidature|localtime|date(DATETIME_FORMAT) }}</time> <time datetime="{{ election.end_candidature }}">{{ election.end_candidature|localtime|date(DATETIME_FORMAT) }}</time>
{% trans %} at {% endtrans %}<time>{{ election.end_candidature|localtime|time(DATETIME_FORMAT) }}</time> {% trans %} at {% endtrans %}<time>{{ election.end_candidature|time(DATETIME_FORMAT) }}</time>
</p> </p>
<p> <p>
{% trans %}Polls open from{% endtrans %} {% trans %}Polls open from{% endtrans %}
@@ -1,51 +0,0 @@
<div id="apply-election-result-fragment">
{% if not form.candidates.field.choices %}
<em>{% trans %}No result to apply{% endtrans %}</em>
<p>
{% trans trimmed %}
This may be because no role of this election
was linked to a club role.
{% endtrans %}
</p>
{% elif form.election.results_applied %}
<em>
{%- trans trimmed -%}
The results of this election have been applied
{%- endtrans -%}
</em>
<p>
{% for club in clubs %}
<a href="{{ url("club:club_members", club_id=club.id) }}" class="btn btn-blue">
<i class="fa fa-arrow-up-right-from-square"></i>
{% trans club=club.name %}{{ club }} members{% endtrans %}
</a>
{% endfor %}
</p>
{% else %}
<div class="alert alert-yellow">
<div class="alert-main">
<strong class="alert-title">{% trans %}Warning{% endtrans %}</strong>
<p>
{%- trans trimmed -%}
Only election roles linked to a club role will be automatically applied.
{%- endtrans -%}
</p>
<p>
{%- trans trimmed -%}
Don't forget to manually apply the eventual remaining roles afterward.
{%- endtrans -%}
</p>
</div>
</div>
<form
hx-post="{{ url("election:apply_result", election_id=form.election.id) }}"
hx-swap="outerHTML"
hx-target="#apply-election-result-fragment"
hx-disabled-elt="find input[type='submit']"
>
{% csrf_token %}
{{ form }}
<input type="submit" class="btn btn-blue">
</form>
{% endif %}
</div>
@@ -1,53 +0,0 @@
{% extends "core/base.jinja" %}
{% block title %}
{% trans name=object_name %}Election role{% endtrans %}
{% endblock %}
{% block content %}
{% if object %}
<h1>{% trans election=election %}Create role for election "{{ election }}"{% endtrans %}</h1>
{% else %}
<h1>{% trans election=election %}Edit role for election "{{ election }}"{% endtrans %}</h1>
{% endif %}
<form action="" method="post" x-data="{role: null, title: '', description: ''}">
{% csrf_token %}
<div class="form-group">
{{ form.club_role.label_tag() }}
{{ form.club_role.errors }}
{{ form.club_role|add_attr("x-model.fill=role,autofocus=true") }}
<button
class="btn btn-blue"
@click.prevent="title = roles[role]?.title ?? '';
description = roles[role]?.description ?? '';"
>
{% trans %}autofill form{% endtrans %}
</button>
<span class="helptext">{{ form.club_role.help_text }}</span>
</div>
<div class="form-group">
{{ form.title.label_tag() }}
{{ form.title.errors }}
{{ form.title|add_attr("x-model.fill=title") }}
</div>
<div class="form-group">
{{ form.description.label_tag() }}
{{ form.description.errors }}
{{ form.description|add_attr("x-model.fill=description") }}
</div>
<div class="form-group">
{{ form.max_choice.as_field_group() }}
</div>
<p><input type="submit" value="{% trans %}Save{% endtrans %}" /></p>
</form>
{% endblock %}
{% block script %}
<script>
const roles = {
{%- for role in form.club_role.field.queryset -%}
{{ role.id }}: { title: {{ role.name|tojson }}, description: {{ role.description|tojson }} },
{%- endfor -%}
};
</script>
{% endblock %}
@@ -2,15 +2,13 @@ from datetime import timedelta
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import localtime, now from django.utils.timezone import now
from model_bakery import baker from model_bakery import baker
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
from club.models import Club
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import Group, User from core.models import Group, User
from election.models import Candidature, Election, ElectionList, Role, Vote from election.models import Candidature, Election, ElectionList, Role, Vote
@@ -40,6 +38,7 @@ class TestElectionDetail(TestElection):
reverse("election:detail", args=str(self.election.id)) reverse("election:detail", args=str(self.election.id))
) )
assert response.status_code == 200 assert response.status_code == 200
assert "La roue tourne" in str(response.content)
class TestElectionUpdateView(TestElection): class TestElectionUpdateView(TestElection):
@@ -214,42 +213,3 @@ def test_election_results():
"total vote": 100, "total vote": 100,
}, },
} }
@pytest.mark.django_db
def test_create_election(client: Client):
user_group = baker.make(Group)
user = baker.make(
User,
user_permissions=[Permission.objects.get(codename="add_election")],
groups=[user_group],
)
club = baker.make(Club)
client.force_login(user)
url = reverse("election:create")
res = client.get(url)
assert res.status_code == 200
start = localtime().replace(hour=0, minute=1, second=0) + timedelta(days=1)
res = client.post(
url,
data={
"title": "foo",
"clubs": [club.id],
"view_groups": [user_group.id],
"start_candidature": start,
"end_candidature": start + timedelta(days=7, minutes=-2),
"start_date": start + timedelta(days=7),
"end_date": start + timedelta(days=14, minutes=-2),
},
)
election = Election.objects.last()
assertRedirects(
res, reverse("election:detail", kwargs={"election_id": election.id})
)
assert election.title == "foo"
assert list(election.clubs.all()) == [club]
assert list(election.election_lists.values_list("title", flat=True)) == [
"Candidat⸱e libre"
]
View File
-191
View File
@@ -1,191 +0,0 @@
import itertools
from datetime import timedelta
from bs4 import BeautifulSoup
from django.contrib.auth.models import Permission
from django.test import TestCase
from django.urls import reverse
from django.utils.timezone import localdate, now
from model_bakery import baker, seq
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects
from club.models import Club, ClubRole, Membership
from core.baker_recipes import subscriber_user
from core.models import Group, User
from election.models import Candidature, Election, ElectionList, Role, Vote
class TestApplyResult(TestCase):
@classmethod
def setUpTestData(cls):
# setup is a little bit complicated, but we have to make a whole
# election to test result application, including the election,
# the lists, the roles, the candidates and the votes.
cls.club = baker.make(Club)
cls.club_roles = baker.make(
ClubRole,
club=cls.club,
is_presidency=iter([True, False, False]),
is_board=True,
_quantity=3,
_bulk_create=True,
)
cls.election = baker.make(
Election,
clubs=[cls.club],
edit_groups=[baker.make(Group)],
end_date=now() - timedelta(minutes=1),
)
lists = baker.make(
ElectionList, election=cls.election, _quantity=2, _bulk_create=True
)
role_recipe = Recipe(Role, election=cls.election, title=seq("election role "))
roles = [
*role_recipe.make(
club_role=iter(cls.club_roles), _quantity=len(cls.club_roles)
),
role_recipe.make(),
]
roles[1].max_choice = 2
roles[1].save()
cls.candidatures = baker.make(
Candidature,
election_list=itertools.chain(
itertools.repeat(lists[0], len(roles)),
itertools.repeat(lists[1], len(roles)),
),
role=itertools.cycle(roles),
user=iter(
baker.make(
User, username=seq("user "), _quantity=len(lists) * len(roles)
)
),
_quantity=len(lists) * len(roles),
_bulk_create=True,
)
votes = iter(
baker.make(
Vote,
role=itertools.cycle(roles),
_quantity=6 * len(roles),
_bulk_create=True,
)
)
through = []
for cand in cls.candidatures:
nb_voices = 4 if cand.election_list_id == lists[0].id else 2
through.extend(
[
Vote.candidature.through(candidature=cand, vote=v)
for v in itertools.islice(votes, nb_voices)
]
)
Vote.candidature.through.objects.bulk_create(through)
cls.election.voters.set(baker.make(User, _quantity=8, _bulk_create=True))
cls.url = reverse(
"election:apply_result", kwargs={"election_id": cls.election.id}
)
def test_election_result(self):
# we have made a complex setup, so testing the results is
# useful to be sure we didn't make mistake when generating data
assert self.election.results == {
"election role 1": {
"blank vote": {"percent": 25.0, "vote": 2},
"total vote": 8,
"user 1": {"percent": 50.0, "vote": 4},
"user 5": {"percent": 25.0, "vote": 2},
},
"election role 2": {
"blank vote": {"percent": 62.5, "vote": 10},
"total vote": 16,
"user 2": {"percent": 25.0, "vote": 4},
"user 6": {"percent": 12.5, "vote": 2},
},
"election role 3": {
"blank vote": {"percent": 25.0, "vote": 2},
"total vote": 8,
"user 3": {"percent": 50.0, "vote": 4},
"user 7": {"percent": 25.0, "vote": 2},
},
"election role 4": {
"blank vote": {"percent": 25.0, "vote": 2},
"total vote": 8,
"user 4": {"percent": 50.0, "vote": 4},
"user 8": {"percent": 25.0, "vote": 2},
},
}
def test_apply_result(self):
user = baker.make(
User, user_permissions=[Permission.objects.get(codename="add_membership")]
)
self.client.force_login(user)
response = self.client.get(self.url)
soup = BeautifulSoup(response.text, "lxml")
inputs = soup.find_all("input", attrs={"type": "checkbox"})
assert all("checked" in i.attrs for i in inputs)
ids = {int(i.attrs["value"]) for i in inputs}
assert ids == {
self.candidatures[0].id,
self.candidatures[1].id,
self.candidatures[2].id,
self.candidatures[5].id,
}
response = self.client.post(
self.url, data={"candidates": ids.difference({self.candidatures[5].id})}
)
assertRedirects(response, self.url)
for candidate in self.candidatures[0:3]:
assert Membership.objects.filter(
start_date=localdate(),
end_date=None,
user=candidate.user,
role=candidate.role.club_role,
).exists()
assert self.club.members_group.users.contains(candidate.user)
assert self.club.board_group.users.contains(candidate.user)
# candidatures[5] was unchecked, so it shouldn't receive a club role
assert not self.candidatures[5].user.memberships.exists()
# now that results are applied, it shouldn't be possible to replay the request
response = self.client.get(self.url)
assert "Les résultats de cette élection ont été appliqués" in response.text
response = self.client.post(self.url, data={"candidates": ids})
assert response.status_code == 403
def test_no_result_to_apply(self):
self.election.roles.update(club_role=None)
user = baker.make(
User, user_permissions=[Permission.objects.get(codename="add_membership")]
)
self.client.force_login(user)
response = self.client.get(self.url)
soup = BeautifulSoup(response.text, "lxml")
assert not soup.find("input", attrs={"type": "checkbox"})
assert "Pas de résultats à appliquer" in response.text
def test_access_denied(self):
user = subscriber_user.make()
self.client.force_login(user)
response = self.client.get(self.url)
assert response.status_code == 403
response = self.client.post(
self.url, data={"candidates": [self.candidatures[0].id]}
)
assert response.status_code == 403
def test_election_not_finished(self):
user = baker.make(
User, user_permissions=[Permission.objects.get(codename="add_membership")]
)
self.election.end_date = now() + timedelta(minutes=1)
self.election.save()
self.client.force_login(user)
response = self.client.get(self.url)
assert response.status_code == 403
response = self.client.post(
self.url, data={"candidates": [self.candidatures[0].id]}
)
assert response.status_code == 403
-110
View File
@@ -1,110 +0,0 @@
from datetime import timedelta
import pytest
from django.conf import settings
from django.contrib.auth.models import Permission
from django.test import TestCase
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker
from pytest_django.asserts import assertRedirects
from club.models import Club, ClubRole
from core.baker_recipes import subscriber_user
from core.models import Group, User
from election.models import Election, Role
@pytest.mark.django_db
class TestCreateRole(TestCase):
@classmethod
def setUpTestData(cls):
cls.club = baker.make(Club)
cls.edit_group = baker.make(Group)
cls.election = baker.make(
Election,
clubs=[cls.club],
edit_groups=[cls.edit_group],
view_groups=[Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)],
end_candidature=now() + timedelta(days=1),
)
cls.url = reverse(
"election:create_role", kwargs={"election_id": cls.election.id}
)
cls.election_url = reverse(
"election:detail", kwargs={"election_id": cls.election.id}
)
cls.permission = Permission.objects.get(codename="add_role")
def assert_role_creation_ok(self):
response = self.client.get(self.url)
assert response.status_code == 200
response = self.client.post(self.url, data={"title": "foo", "max_choice": 1})
assertRedirects(response, self.election_url)
roles = list(self.election.roles.all())
assert len(roles) == 1
assert roles[0].title == "foo"
def assert_role_creation_denied(self):
initial_role_count = self.election.roles.count()
response = self.client.get(self.url)
assert response.status_code == 403
response = self.client.post(self.url, data={"title": "foo", "max_choice": 1})
assert response.status_code == 403
assert self.election.roles.count() == initial_role_count
def test_admin(self):
user = baker.make(User, user_permissions=[self.permission])
self.client.force_login(user)
self.assert_role_creation_ok()
def test_edit_group(self):
user = baker.make(User, groups=[self.edit_group])
self.client.force_login(user)
self.assert_role_creation_ok()
def test_role_linked_to_club_role(self):
user = baker.make(User, user_permissions=[self.permission])
self.client.force_login(user)
club_role = baker.make(ClubRole, is_board=True, club=self.club)
response = self.client.post(
self.url, data={"title": "foo", "max_choice": 1, "club_role": club_role.id}
)
assertRedirects(response, self.election_url)
roles = list(self.election.roles.all())
assert len(roles) == 1
assert roles[0].title == "foo"
assert roles[0].club_role == club_role
def test_permission_denied(self):
user = subscriber_user.make()
self.client.force_login(user)
self.assert_role_creation_denied()
def test_election_not_editable(self):
user = baker.make(User, user_permissions=[self.permission])
self.election.end_candidature = now() - timedelta(minutes=1)
self.election.save()
self.client.force_login(user)
self.assert_role_creation_denied()
class TestUpdateRole(TestCreateRole):
@classmethod
def setUpTestData(cls):
# TestUpdateRole is just TestCreateRole, but with different parameters
cls.club = baker.make(Club)
cls.edit_group = baker.make(Group)
cls.election = baker.make(
Election,
clubs=[cls.club],
edit_groups=[cls.edit_group],
view_groups=[Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)],
end_candidature=now() + timedelta(days=1),
)
cls.role = baker.make(Role, election=cls.election)
cls.url = reverse("election:update_role", kwargs={"role_id": cls.role.id})
cls.election_url = reverse(
"election:detail", kwargs={"election_id": cls.election.id}
)
cls.permission = Permission.objects.get(codename="change_role")
-6
View File
@@ -1,7 +1,6 @@
from django.urls import path from django.urls import path
from election.views import ( from election.views import (
ApplyResultFragment,
CandidatureCreateView, CandidatureCreateView,
CandidatureDeleteView, CandidatureDeleteView,
CandidatureUpdateView, CandidatureUpdateView,
@@ -57,9 +56,4 @@ urlpatterns = [
), ),
path("<int:election_id>/vote/", VoteFormView.as_view(), name="vote"), path("<int:election_id>/vote/", VoteFormView.as_view(), name="vote"),
path("<int:election_id>/detail/", ElectionDetailView.as_view(), name="detail"), path("<int:election_id>/detail/", ElectionDetailView.as_view(), name="detail"),
path(
"fragment/<int:election_id>/apply/",
ApplyResultFragment.as_view(),
name="apply_result",
),
] ]
+65 -65
View File
@@ -18,9 +18,7 @@ from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateVi
from core.auth.mixins import CanEditMixin, CanViewMixin from core.auth.mixins import CanEditMixin, CanViewMixin
from election.forms import ( from election.forms import (
ApplyRoleResultForm,
CandidateForm, CandidateForm,
ElectionCreateForm,
ElectionForm, ElectionForm,
ElectionListForm, ElectionListForm,
RoleForm, RoleForm,
@@ -210,7 +208,7 @@ class CandidatureCreateView(LoginRequiredMixin, CreateView):
class ElectionCreateView(PermissionRequiredMixin, CreateView): class ElectionCreateView(PermissionRequiredMixin, CreateView):
model = Election model = Election
form_class = ElectionCreateForm form_class = ElectionForm
template_name = "core/create.jinja" template_name = "core/create.jinja"
permission_required = "election.add_election" permission_required = "election.add_election"
@@ -221,7 +219,7 @@ class ElectionCreateView(PermissionRequiredMixin, CreateView):
class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
model = Role model = Role
form_class = RoleForm form_class = RoleForm
template_name = "election/role_form.jinja" template_name = "core/create.jinja"
@cached_property @cached_property
def election(self): def election(self):
@@ -230,17 +228,22 @@ class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
def test_func(self): def test_func(self):
if not self.election.is_vote_editable: if not self.election.is_vote_editable:
return False return False
user = self.request.user if self.request.user.has_perm("election.add_role"):
return user.has_perm("election.add_role") or user.can_edit(self.election) return True
return self.election.edit_groups.filter(
id__in=self.request.user.all_groups
).exists()
def get_initial(self):
return {"election": self.election}
def get_form_kwargs(self): def get_form_kwargs(self):
return super().get_form_kwargs() | {"election": self.election} return super().get_form_kwargs() | {"election_id": self.election.id}
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse("election:detail", kwargs={"election_id": self.election.id}) return reverse(
"election:detail", kwargs={"election_id": self.object.election_id}
def get_context_data(self, **kwargs): )
return super().get_context_data(**kwargs) | {"election": self.election}
class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
@@ -264,11 +267,16 @@ class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView
) )
return not groups.isdisjoint(self.request.user.all_groups.keys()) return not groups.isdisjoint(self.request.user.all_groups.keys())
def get_initial(self):
return {"election": self.election}
def get_form_kwargs(self): def get_form_kwargs(self):
return super().get_form_kwargs() | {"election": self.election} return super().get_form_kwargs() | {"election_id": self.election.id}
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse("election:detail", kwargs={"election_id": self.election.id}) return reverse(
"election:detail", kwargs={"election_id": self.object.election_id}
)
# Update view # Update view
@@ -280,6 +288,18 @@ class ElectionUpdateView(CanEditMixin, UpdateView):
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
pk_url_kwarg = "election_id" pk_url_kwarg = "election_id"
def get_initial(self):
return {
"start_date": self.object.start_date.strftime("%Y-%m-%d %H:%M:%S"),
"end_date": self.object.end_date.strftime("%Y-%m-%d %H:%M:%S"),
"start_candidature": self.object.start_candidature.strftime(
"%Y-%m-%d %H:%M:%S"
),
"end_candidature": self.object.end_candidature.strftime(
"%Y-%m-%d %H:%M:%S"
),
}
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse_lazy("election:detail", kwargs={"election_id": self.object.id}) return reverse_lazy("election:detail", kwargs={"election_id": self.object.id})
@@ -304,30 +324,48 @@ class CandidatureUpdateView(LoginRequiredMixin, CanEditMixin, UpdateView):
) )
class RoleUpdateView(UserPassesTestMixin, UpdateView): class RoleUpdateView(CanEditMixin, UpdateView):
model = Role model = Role
form_class = RoleForm form_class = RoleForm
template_name = "election/role_form.jinja" template_name = "core/edit.jinja"
pk_url_kwarg = "role_id" pk_url_kwarg = "role_id"
@cached_property def dispatch(self, request, *arg, **kwargs):
def election(self): self.object = self.get_object()
return self.get_object().election if not self.object.election.is_vote_editable:
raise PermissionDenied
return super().dispatch(request, *arg, **kwargs)
def test_func(self): def remove_fields(self):
if not self.election.is_vote_editable: self.form.fields.pop("election", None)
return False
user = self.request.user
return user.has_perm("election.change_role") or user.can_edit(self.election)
def get_context_data(self, **kwargs): def get(self, request, *args, **kwargs):
return super().get_context_data(**kwargs) | {"election": self.election} self.object = self.get_object()
self.form = self.get_form()
self.remove_fields()
return self.render_to_response(self.get_context_data(form=self.form))
def post(self, request, *args, **kwargs):
self.object = self.get_object()
self.form = self.get_form()
self.remove_fields()
if (
request.user.is_authenticated
and request.user.can_edit(self.object)
and self.form.is_valid()
):
return super().form_valid(self.form)
return self.form_invalid(self.form)
def get_form_kwargs(self): def get_form_kwargs(self):
return super().get_form_kwargs() | {"election": self.election} kwargs = super().get_form_kwargs()
kwargs["election_id"] = self.object.election.id
return kwargs
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse("election:detail", kwargs={"election_id": self.election.id}) return reverse_lazy(
"election:detail", kwargs={"election_id": self.object.election.id}
)
# Delete Views # Delete Views
@@ -387,41 +425,3 @@ class ElectionListDeleteView(CanEditMixin, DeleteView):
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse("election:detail", kwargs={"election_id": self.election.id}) return reverse("election:detail", kwargs={"election_id": self.election.id})
class ApplyResultFragment(LoginRequiredMixin, UserPassesTestMixin, FormView):
template_name = "election/fragments/apply_result.jinja"
form_class = ApplyRoleResultForm
@cached_property
def election(self):
return get_object_or_404(Election, pk=self.kwargs["election_id"])
def test_func(self):
if not self.election.is_vote_finished:
return False
if self.request.user.has_perm("club.add_membership"):
return True
return self.election.edit_groups.filter(
id__in=self.request.user.all_groups
).exists()
def post(self, request, *args, **kwargs):
if self.election.results_applied:
raise PermissionDenied
return super().post(request, *args, **kwargs)
def get_form_kwargs(self):
return super().get_form_kwargs() | {"election": self.election}
def form_valid(self, form: ApplyRoleResultForm):
form.save()
return super().form_valid(form)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"clubs": self.election.clubs.all()}
def get_success_url(self, **kwargs):
return reverse(
"election:apply_result", kwargs={"election_id": self.election.id}
)
@@ -25,14 +25,13 @@ import warnings
from datetime import timedelta from datetime import timedelta
from typing import Final, Optional from typing import Final, Optional
from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils import timezone from django.utils import timezone
from model_bakery import baker from model_bakery import baker
from club.models import Club, ClubRole, Membership from club.models import Club, ClubRole, Membership
from core.models import Group, Page, SithFile, User from core.models import Group, Page, User
from core.utils import RED_PIXEL_PNG from core.utils import RED_PIXEL_PNG
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
from subscription.models import Subscription from subscription.models import Subscription
@@ -92,13 +91,8 @@ class Command(BaseCommand):
self.NB_CLUBS = options["club_count"] self.NB_CLUBS = options["club_count"]
root = User.objects.filter(username="root").first() root = User.objects.filter(username="root").first()
sas = SithFile.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)
self.galaxy_album = Album.objects.create( self.galaxy_album = Album.objects.create(
name="galaxy-register-file", name="galaxy-register-file", owner=root, is_moderated=True
owner=root,
is_moderated=True,
is_in_sas=True,
parent=sas,
) )
self.make_clubs() self.make_clubs()
@@ -294,14 +288,10 @@ class Command(BaseCommand):
owner=u, owner=u,
name=f"galaxy-picture {u} {i // self.NB_USERS}", name=f"galaxy-picture {u} {i // self.NB_USERS}",
is_moderated=True, is_moderated=True,
is_folder=False,
parent=self.galaxy_album, parent=self.galaxy_album,
is_in_sas=True, original=ContentFile(RED_PIXEL_PNG),
file=ContentFile(RED_PIXEL_PNG),
compressed=ContentFile(RED_PIXEL_PNG), compressed=ContentFile(RED_PIXEL_PNG),
thumbnail=ContentFile(RED_PIXEL_PNG), thumbnail=ContentFile(RED_PIXEL_PNG),
mime_type="image/png",
size=len(RED_PIXEL_PNG),
) )
) )
self.picts[i].file.name = self.picts[i].name self.picts[i].file.name = self.picts[i].name
File diff suppressed because it is too large Load Diff
+1 -5
View File
@@ -7,7 +7,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-17 10:03+0200\n" "POT-Creation-Date: 2026-04-17 22:42+0200\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n" "Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -263,10 +263,6 @@ msgstr "Types de produits réordonnés !"
msgid "Product type reorganisation failed with status code : %d" msgid "Product type reorganisation failed with status code : %d"
msgstr "La réorganisation des types de produit a échoué avec le code : %d" msgstr "La réorganisation des types de produit a échoué avec le code : %d"
#: eboutic/static/bundled/eboutic/checkout-index.ts
msgid "Basket expired"
msgstr "Panier expiré"
#: sas/static/bundled/sas/pictures-download-index.ts #: sas/static/bundled/sas/pictures-download-index.ts
msgid "pictures.%(extension)s" msgid "pictures.%(extension)s"
msgstr "photos.%(extension)s" msgstr "photos.%(extension)s"
+4 -4
View File
@@ -20,9 +20,9 @@ from sas.models import Album, PeoplePictureRelation, Picture, PictureModerationR
@admin.register(Picture) @admin.register(Picture)
class PictureAdmin(admin.ModelAdmin): class PictureAdmin(admin.ModelAdmin):
list_display = ("name", "parent", "date", "size", "is_moderated") list_display = ("name", "parent", "is_moderated")
search_fields = ("name",) search_fields = ("name",)
autocomplete_fields = ("owner", "parent", "edit_groups", "view_groups", "moderator") autocomplete_fields = ("owner", "parent", "moderator")
@admin.register(PeoplePictureRelation) @admin.register(PeoplePictureRelation)
@@ -33,9 +33,9 @@ class PeoplePictureRelationAdmin(admin.ModelAdmin):
@admin.register(Album) @admin.register(Album)
class AlbumAdmin(admin.ModelAdmin): class AlbumAdmin(admin.ModelAdmin):
list_display = ("name", "parent", "date", "owner", "is_moderated") list_display = ("name", "parent")
search_fields = ("name",) search_fields = ("name",)
autocomplete_fields = ("owner", "parent", "edit_groups", "view_groups") autocomplete_fields = ("parent", "edit_groups", "view_groups")
@admin.register(PictureModerationRequest) @admin.register(PictureModerationRequest)
+49 -8
View File
@@ -3,7 +3,8 @@ from typing import Any, Literal
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
from ninja import Body, File, Query from ninja import Body, Query, UploadedFile
from ninja.errors import HttpError
from ninja.security import SessionAuth from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import NotFound, PermissionDenied from ninja_extra.exceptions import NotFound, PermissionDenied
@@ -16,11 +17,12 @@ from api.permissions import (
CanAccessLookup, CanAccessLookup,
CanEdit, CanEdit,
CanView, CanView,
HasPerm,
IsInGroup, IsInGroup,
IsRoot, IsRoot,
) )
from core.models import Notification, User from core.models import Notification, User
from core.schemas import UploadedImage from core.utils import get_list_exact_or_404
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
from sas.schemas import ( from sas.schemas import (
AlbumAutocompleteSchema, AlbumAutocompleteSchema,
@@ -28,6 +30,7 @@ from sas.schemas import (
AlbumSchema, AlbumSchema,
IdentifiedUserSchema, IdentifiedUserSchema,
ModerationRequestSchema, ModerationRequestSchema,
MoveAlbumSchema,
PictureFilterSchema, PictureFilterSchema,
PictureSchema, PictureSchema,
) )
@@ -69,6 +72,44 @@ class AlbumController(ControllerBase):
Album.objects.viewable_by(self.context.request.user).order_by("-date") Album.objects.viewable_by(self.context.request.user).order_by("-date")
) )
@route.patch("/parent")
def change_album_parent(self, payload: list[MoveAlbumSchema]):
"""Change parents of albums
Note:
For this operation to work, the user must be authorized
to edit both the moved albums and their new parent.
"""
user: User = self.context.request.user
albums: list[Album] = get_list_exact_or_404(
Album, pk__in={a.id for a in payload}
)
if not user.has_perm("sas.change_album"):
unauthorized = [a.id for a in albums if not user.can_edit(a)]
if unauthorized:
raise PermissionDenied(
f"You can't move the following albums : {unauthorized}"
)
parents: list[Album] = get_list_exact_or_404(
Album, pk__in={a.new_parent_id for a in payload}
)
if not user.has_perm("sas.change_album"):
unauthorized = [a.id for a in parents if not user.can_edit(a)]
if unauthorized:
raise PermissionDenied(
f"You can't move to the following albums : {unauthorized}"
)
id_to_new_parent = {i.id: i.new_parent_id for i in payload}
for album in albums:
album.parent_id = id_to_new_parent[album.id]
# known caveat : moving an album won't move it's thumbnail.
# E.g. if the album foo/bar is moved to foo/baz,
# the thumbnail will still be foo/bar/thumb.webp
# This has no impact for the end user
# and doing otherwise would be hard for us to implement,
# because we would then have to manage rollbacks on fail.
Album.objects.bulk_update(albums, fields=["parent_id"])
@api_controller("/sas/picture") @api_controller("/sas/picture")
class PicturesController(ControllerBase): class PicturesController(ControllerBase):
@@ -96,7 +137,7 @@ class PicturesController(ControllerBase):
return ( return (
filters.filter(Picture.objects.viewable_by(user)) filters.filter(Picture.objects.viewable_by(user))
.distinct() .distinct()
.order_by("-parent__date", "date") .order_by("-parent__event_date", "created_at")
.select_related("owner", "parent") .select_related("owner", "parent")
) )
@@ -110,26 +151,26 @@ class PicturesController(ControllerBase):
}, },
url_name="upload_picture", url_name="upload_picture",
) )
def upload_picture(self, album_id: Body[int], picture: File[UploadedImage]): def upload_picture(self, album_id: Body[int], picture: UploadedFile):
album = self.get_object_or_exception(Album, pk=album_id) album = self.get_object_or_exception(Album, pk=album_id)
user = self.context.request.user user = self.context.request.user
self_moderate = user.has_perm("sas.moderate_sasfile") self_moderate = user.has_perm("sas.moderate_sasfile")
new = Picture( new = Picture(
parent=album, parent=album,
name=picture.name, name=picture.name,
file=picture, original=picture,
owner=user, owner=user,
is_moderated=self_moderate, is_moderated=self_moderate,
is_folder=False,
mime_type=picture.content_type,
) )
if self_moderate: if self_moderate:
new.moderator = user new.moderator = user
new.generate_thumbnails()
try: try:
new.full_clean() new.full_clean()
new.generate_thumbnails(save=True) new.generate_thumbnails(save=True)
except ValidationError as e: except ValidationError as e:
return self.create_response({"detail": dict(e)}, status_code=409) raise HttpError(status_code=409, message=str(e)) from e
new.save()
@route.get( @route.get(
"/{picture_id}/identified", "/{picture_id}/identified",
+20 -13
View File
@@ -1,29 +1,36 @@
from django.core.files.uploadedfile import SimpleUploadedFile
from django.conf import settings from django.conf import settings
from model_bakery import seq from model_bakery import seq
from model_bakery.recipe import Recipe, foreign_key from model_bakery.recipe import Recipe, foreign_key
from core.utils import RED_PIXEL_PNG
from sas.models import Album, Picture from sas.models import Album, Picture
album_recipe = Recipe( album_recipe = Recipe(
Album, Album,
is_in_sas=True,
is_folder=True,
is_moderated=True,
parent_id=settings.SITH_SAS_ROOT_DIR_ID,
name=seq("Album "), name=seq("Album "),
thumbnail=SimpleUploadedFile(
name="thumb.webp", content=b"", content_type="image/webp"
),
) )
picture_recipe = Recipe( picture_recipe = Recipe(
Picture, Picture,
is_in_sas=True,
is_folder=False,
is_moderated=True, is_moderated=True,
parent=foreign_key(album_recipe),
name=seq("Picture "), name=seq("Picture "),
original=SimpleUploadedFile(
# compressed and thumbnail are generated on save (except if bulk creating).
# For this step no to fail, original must be a valid image.
name="img.png",
content=RED_PIXEL_PNG,
content_type="image/png",
),
compressed=SimpleUploadedFile(
name="img.webp", content=b"", content_type="image/webp"
),
thumbnail=SimpleUploadedFile(
name="img.webp", content=b"", content_type="image/webp"
),
) )
"""A SAS Picture fixture. """A SAS Picture fixture."""
Warnings:
If you don't `bulk_create` this, you need
to explicitly set the parent album, or it won't work
"""
+2 -1
View File
@@ -57,10 +57,11 @@ class PictureEditForm(forms.ModelForm):
class AlbumEditForm(forms.ModelForm): class AlbumEditForm(forms.ModelForm):
class Meta: class Meta:
model = Album model = Album
fields = ["name", "date", "file", "parent", "edit_groups"] fields = ["name", "date", "thumbnail", "parent", "edit_groups"]
widgets = {"edit_groups": AutoCompleteSelectMultipleGroup, "date": SelectDate} widgets = {"edit_groups": AutoCompleteSelectMultipleGroup, "date": SelectDate}
name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name")) name = forms.CharField(max_length=Album.NAME_MAX_LENGTH, label=_("file name"))
date = forms.DateField(label=_("Date"), widget=SelectDate, required=True)
recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False) recursive = forms.BooleanField(label=_("Apply rights recursively"), required=False)
parent = forms.ModelChoiceField( parent = forms.ModelChoiceField(
Album.objects.all(), required=True, widget=AutoCompleteSelectAlbum Album.objects.all(), required=True, widget=AutoCompleteSelectAlbum
+357
View File
@@ -0,0 +1,357 @@
# Generated by Django 4.2.17 on 2025-01-22 21:53
import collections
import itertools
import logging
from typing import TYPE_CHECKING
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.db.migrations.state import StateApps
import sas.models
if TYPE_CHECKING:
import core.models
# NB : tous les commentaires sont écrits en français,
# parce qu'on est sur des opérations qui sont complexes,
# et qui sont surtout DANGEREUSES.
# Ici, la clarté des explications prime sur toute autre considération.
def copy_albums_and_pictures(apps: StateApps, schema_editor):
SithFile: type[core.models.SithFile] = apps.get_model("core", "SithFile")
Album: type[sas.models.Album] = apps.get_model("sas", "Album")
Picture: type[sas.models.Picture] = apps.get_model("sas", "Picture")
logger = logging.getLogger("django")
# Il y a environ 1800 albums, 257k photos et 488k identifications
# d'utilisateurs dans la db de prod.
# En supposant qu'une insertion prenne 10ms (ce qui est très optimiste),
# migrer tous les enregistrements de la db prendrait plus de 2h.
# C'est trop long.
# Mais d'un autre côté, j'ai pas assez confiance dans les capacités de nos
# machines pour charger presque un million d'objets en mémoire.
# Pour faire un compromis, les albums sont migrés individuellement un à un,
# mais tous les objets liés à ces albums
# (photos, groupes de vue, groupe d'édition, identification d'utilisateurs)
# sont migrés en tas.
#
# Ordre des opérations :
# 1. On migre les albums 1 à 1 (il y en a 1800, donc c'est relativement court)
# 2. On migre les photos par paquet de 2500 (soit ~une centaine d'opérations)
# 3. On migre tous les groupes de vue et tous les groupes d'édition des albums
#
# Au total, la migration devrait demander aux alentours de 2000 insertions,
# ce qui est un compromis acceptable entre une migration
# pas trop longue et une RAM pas trop surchargée.
#
# Pour ce qui est de la répartition des tables, quatre nouvelles tables
# sont créées : sas_album, sas_picture,
# sas_pictureviewgroups et sas_picture_editgroups.
# Tous les albums et toutes les photos qui sont dans core_sithfile
# vont être copiés dans ces tables.
# Comme les albums sont migrés un à un, ils recevront une nouvelle
# clef primaire.
# Pour les photos, en revanche, c'est beaucoup plus sûr de leur donner
# le même id que celui qu'il y avait dans core_sithfile.
#
# Les identifications des photos ne sont pas migrées pour l'instant.
# Ce qu'on va faire, c'est qu'on va changer la contrainte de clef étrangère
# sur la colonne des photos pour pointer vers sas_picture
# au lieu de core_sithfile.
# Cependant, pour que ça marche,
# il faut qu'au moment où ce changement est effectué,
# toutes les clefs primaires référencées existent à la fois dans
# les deux tables, sinon les contraintes d'intégrité ne sont pas respectées.
# La migration de ce fichier va donc s'occuper de créer les nouvelles tables
# et d'y copier les données nécessaires.
# Puis une deuxième migration s'occupera de changer les contraintes.
# Et enfin une troisième migration supprimera les anciennes données.
#
# Pavé César
albums = SithFile.objects.filter(is_in_sas=True, is_folder=True).prefetch_related(
"view_groups", "edit_groups"
)
old_albums = collections.deque(
albums.filter(parent_id=settings.SITH_SAS_ROOT_DIR_ID)
)
# Changement de représentation en DB.
# Dans l'ancien système, un fichier était dans le SAS si
# un fichier spécial (le SAS_ROOT) était parmi ses ancêtres.
# Comme maintenant les fichiers du SAS sont dans des tables à part,
# il ne peut plus y avoir de confusion.
# Les photos ont donc obligatoirement un parent (qui est un album)
# et les albums peuvent avoir un parent null.
# Un album sans parent est considéré comme se trouvant à la racine
# de l'arborescence.
# En quelque sorte, None est le nouveau SITH_SAS_ROOT_DIR_ID
album_id_old_to_new = {settings.SITH_SAS_ROOT_DIR_ID: None}
logger.info(f"migrating {albums.count()} albums")
while len(old_albums) > 0:
# Comme les albums référencent leur parent, les albums doivent être migrés
# par ordre croissant de profondeur dans l'arborescence.
# Chaque album est donc pris par la gauche de la file
# et ses enfants ajoutés sur la droite.
old_album = old_albums.popleft()
old_albums.extend(list(albums.filter(parent=old_album)))
new_album = Album.objects.create(
parent_id=album_id_old_to_new[old_album.parent_id],
event_date=old_album.date.date(),
name=old_album.name,
thumbnail=(old_album.file or None),
is_moderated=old_album.is_moderated,
)
# on garde un dictionnaire qui associe les id des albums dans l'ancienne table
# à leur id dans la nouvelle table, pour pouvoir recréer
# les liens de parenté entre albums
album_id_old_to_new[old_album.id] = new_album.id
pictures = SithFile.objects.filter(is_in_sas=True, is_folder=False)
nb_pictures = pictures.count()
logger.info(f"migrating {nb_pictures} pictures")
for i, pictures_batch in enumerate(itertools.batched(pictures, 2500), start=1):
Picture.objects.bulk_create(
[
Picture(
id=p.id,
name=p.name,
parent_id=album_id_old_to_new[p.parent_id],
thumbnail=p.thumbnail,
compressed=p.compressed,
original=p.file,
owner_id=p.owner_id,
created_at=p.date,
is_moderated=p.is_moderated,
asked_for_removal=p.asked_for_removal,
moderator_id=p.moderator_id,
)
for p in pictures_batch
]
)
logger.info(f"Migrated {min(i * 2500, nb_pictures)} / {nb_pictures} pictures")
logger.info("Migrating album groups")
albums = SithFile.objects.filter(is_in_sas=True, is_folder=True).exclude(
id=settings.SITH_SAS_ROOT_DIR_ID
)
Album.edit_groups.through.objects.bulk_create(
[
Album.view_groups.through(
album=album_id_old_to_new[g.sithfile_id], group_id=g.group_id
)
for g in SithFile.view_groups.through.objects.filter(sithfile__in=albums)
]
)
Album.edit_groups.through.objects.bulk_create(
[
Album.view_groups.through(
album=album_id_old_to_new[g.sithfile_id], group_id=g.group_id
)
for g in SithFile.view_groups.through.objects.filter(sithfile__in=albums)
]
)
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("core", "0044_alter_userban_options"),
("sas", "0005_alter_sasfile_options"),
]
operations = [
# les relations et les demandes de modération étaient liées à SithFile,
# via le model proxy Picture.
# Pour que la migration marche malgré la disparition du modèle Proxy,
# on change la relation pour qu'elle pointe directement vers SithFile
migrations.AlterField(
model_name="peoplepicturerelation",
name="picture",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="people",
to="core.sithfile",
verbose_name="picture",
),
),
migrations.AlterField(
model_name="picturemoderationrequest",
name="picture",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="moderation_requests",
to="core.sithfile",
verbose_name="Picture",
),
),
migrations.DeleteModel(name="Album"),
migrations.DeleteModel(name="Picture"),
migrations.DeleteModel(name="SasFile"),
migrations.CreateModel(
name="Album",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"thumbnail",
models.FileField(
max_length=256,
upload_to=sas.models.get_thumbnail_directory,
verbose_name="thumbnail",
),
),
("name", models.CharField(max_length=100, verbose_name="name")),
(
"event_date",
models.DateField(
default=django.utils.timezone.localdate,
help_text="The date on which the photos in this album were taken",
verbose_name="event date",
),
),
(
"is_moderated",
models.BooleanField(default=False, verbose_name="is moderated"),
),
(
"edit_groups",
models.ManyToManyField(
related_name="editable_albums",
to="core.group",
verbose_name="edit groups",
),
),
(
"parent",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="children",
to="sas.album",
verbose_name="parent",
),
),
(
"view_groups",
models.ManyToManyField(
related_name="viewable_albums",
to="core.group",
verbose_name="view groups",
),
),
],
options={"verbose_name": "album"},
),
migrations.CreateModel(
name="Picture",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"thumbnail",
models.FileField(
unique=True,
upload_to=sas.models.get_thumbnail_directory,
verbose_name="thumbnail",
max_length=256,
),
),
("name", models.CharField(max_length=256, verbose_name="file name")),
(
"original",
models.FileField(
unique=True,
upload_to=sas.models.get_directory,
verbose_name="original image",
max_length=256,
),
),
(
"compressed",
models.FileField(
unique=True,
upload_to=sas.models.get_compressed_directory,
verbose_name="compressed image",
max_length=256,
),
),
("created_at", models.DateTimeField(default=django.utils.timezone.now)),
(
"is_moderated",
models.BooleanField(default=False, verbose_name="is moderated"),
),
(
"asked_for_removal",
models.BooleanField(
default=False, verbose_name="asked for removal"
),
),
(
"moderator",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="moderated_pictures",
to=settings.AUTH_USER_MODEL,
),
),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="owned_pictures",
to=settings.AUTH_USER_MODEL,
verbose_name="owner",
),
),
(
"parent",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="pictures",
to="sas.album",
verbose_name="album",
),
),
],
options={"abstract": False, "verbose_name": "picture"},
),
migrations.AddConstraint(
model_name="picture",
constraint=models.UniqueConstraint(
fields=("name", "parent"), name="sas_picture_unique_per_album"
),
),
migrations.AddConstraint(
model_name="album",
constraint=models.UniqueConstraint(
fields=("name", "parent"), name="unique_album_name_if_same_parent"
),
),
migrations.RunPython(
copy_albums_and_pictures,
reverse_code=migrations.RunPython.noop,
elidable=True,
),
]
@@ -0,0 +1,31 @@
# Generated by Django 4.2.17 on 2025-01-25 23:50
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("sas", "0006_move_the_whole_sas")]
operations = [
migrations.AlterField(
model_name="peoplepicturerelation",
name="picture",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="people",
to="sas.picture",
verbose_name="picture",
),
),
migrations.AlterField(
model_name="picturemoderationrequest",
name="picture",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="moderation_requests",
to="sas.picture",
verbose_name="Picture",
),
),
]
+265 -19
View File
@@ -15,31 +15,60 @@
from __future__ import annotations from __future__ import annotations
import contextlib
from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import ClassVar, Self from typing import TYPE_CHECKING, ClassVar, Self
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.db import models from django.db import models
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.db.models.deletion import Collector
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from PIL import Image from PIL import Image
from core.models import Notification, SithFile, User from core.models import Group, Notification, User
from core.utils import resize_image from core.utils import resize_image
if TYPE_CHECKING:
from django.db.models.fields.files import FieldFile
class SasFile(SithFile):
"""Proxy model for any file in the SAS.
May be used to have logic that should be shared by both def get_directory(instance: SasFile, filename: str):
return f"./{instance.parent_path}/{filename}"
def get_compressed_directory(instance: SasFile, filename: str):
return f"./.compressed/{instance.parent_path}/{filename}"
def get_thumbnail_directory(instance: SasFile, filename: str):
if isinstance(instance, Album):
_, extension = filename.rsplit(".", 1)
filename = f"{instance.name}/thumb.{extension}"
return f"./.thumbnails/{instance.parent_path}/{filename}"
class SasFile(models.Model):
"""Abstract model for SAS files
This model is used to have logic that should be shared by both
[Picture][sas.models.Picture] and [Album][sas.models.Album]. [Picture][sas.models.Picture] and [Album][sas.models.Album].
Notes:
This is an abstract model.
[Album][sas.models.Album] and [Picture][sas.models.Picture]
are separated tables in the database.
""" """
class Meta: class Meta:
proxy = True abstract = True
permissions = [ permissions = [
("moderate_sasfile", "Can moderate SAS files"), ("moderate_sasfile", "Can moderate SAS files"),
("view_unmoderated_sasfile", "Can view not moderated SAS files"), ("view_unmoderated_sasfile", "Can view not moderated SAS files"),
@@ -64,6 +93,169 @@ class SasFile(SithFile):
def can_be_edited_by(self, user): def can_be_edited_by(self, user):
return user.has_perm("sas.change_sasfile") return user.has_perm("sas.change_sasfile")
@cached_property
def parent_path(self) -> str:
"""The parent location in the SAS album tree (e.g. `SAS/foo/bar`)."""
return "/".join(["SAS", *[p.name for p in self.parent_list]])
@cached_property
def parent_list(self) -> list[Album]:
"""The ancestors of this SAS object.
The result is ordered from the direct parent to the farthest one.
"""
parents = []
current = self.parent
while current is not None:
parents.append(current)
current = current.parent
return parents
class AlbumQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
"""Filter the albums that this user can view.
Warning:
Calling this queryset method may add several additional requests.
"""
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return self.all()
if user.was_subscribed:
return self.filter(is_moderated=True)
# known bug : if all children of an album are also albums
# then this album is excluded, even if one of the sub-albums should be visible.
# The fs-like navigation is likely to be half-broken for non-subscribers,
# but that's ok, since non-subscribers are expected to see only the albums
# containing pictures on which they have been identified (hence, very few).
# Most, if not all, of their albums will be displayed on the
# `latest albums` section of the SAS.
# Moreover, they will still see all of their picture in their profile.
return self.filter(
Exists(Picture.objects.filter(parent_id=OuterRef("pk")).viewable_by(user))
)
class Album(SasFile):
NAME_MAX_LENGTH: ClassVar[int] = 50
name = models.CharField(_("name"), max_length=100)
parent = models.ForeignKey(
"self",
related_name="children",
verbose_name=_("parent"),
null=True,
blank=True,
on_delete=models.CASCADE,
)
thumbnail = models.FileField(
upload_to=get_thumbnail_directory,
verbose_name=_("thumbnail"),
max_length=256,
blank=True,
)
view_groups = models.ManyToManyField(
Group, related_name="viewable_albums", verbose_name=_("view groups"), blank=True
)
edit_groups = models.ManyToManyField(
Group, related_name="editable_albums", verbose_name=_("edit groups"), blank=True
)
event_date = models.DateField(
_("event date"),
help_text=_("The date on which the photos in this album were taken"),
default=timezone.localdate,
blank=True,
)
is_moderated = models.BooleanField(_("is moderated"), default=False)
objects = AlbumQuerySet.as_manager()
class Meta:
verbose_name = _("album")
constraints = [
models.UniqueConstraint(
fields=["name", "parent"],
name="unique_album_name_if_same_parent",
# TODO : add `nulls_distinct=True` after upgrading to django>=5.0
)
]
def __str__(self):
return f"Album {self.name}"
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
for user in User.objects.filter(
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
):
Notification(
user=user,
url=reverse("sas:moderation"),
type="SAS_MODERATION",
param="1",
).save()
def get_absolute_url(self):
return reverse("sas:album", kwargs={"album_id": self.id})
def clean(self):
super().clean()
if "/" in self.name:
raise ValidationError(_("Character '/' not authorized in name"))
if self.parent_id is not None and (
self.id == self.parent_id or self in self.parent_list
):
raise ValidationError(_("Loop in album tree"), code="loop")
if self.thumbnail:
try:
Image.open(BytesIO(self.thumbnail.read()))
except Image.UnidentifiedImageError as e:
raise ValidationError(_("This is not a valid album thumbnail")) from e
def delete(self, *args, **kwargs):
"""Delete the album, all of its children and all linked disk files"""
collector = Collector(using="default")
collector.collect([self])
albums: set[Album] = collector.data[Album]
pictures: set[Picture] = collector.data[Picture]
files: list[FieldFile] = [
*[a.thumbnail for a in albums],
*[p.thumbnail for p in pictures],
*[p.compressed for p in pictures],
*[p.original for p in pictures],
]
# `bool(f)` checks that the file actually exists on the disk
files = [f for f in files if bool(f)]
folders = {Path(f.path).parent for f in files}
res = super().delete(*args, **kwargs)
# once the model instances have been deleted,
# delete the actual files.
for file in files:
# save=False ensures that django doesn't recreate the db record,
# which would make the whole deletion pointless
# cf. https://docs.djangoproject.com/en/stable/ref/models/fields/#django.db.models.fields.files.FieldFile.delete
file.delete(save=False)
for folder in folders:
# now that the files are deleted, remove the empty folders
if folder.is_dir() and next(folder.iterdir(), None) is None:
folder.rmdir()
return res
def get_download_url(self):
return reverse("sas:album_preview", kwargs={"album_id": self.id})
def generate_thumbnail(self):
p = (
self.pictures.exclude(thumbnail="").order_by("?").first()
or self.children.exclude(thumbnail="").order_by("?").first()
)
if p:
# The file is loaded into memory to duplicate it.
# It may not be the most efficient way, but thumbnails are
# usually quite small, so it's still ok
self.thumbnail = ContentFile(p.thumbnail.read(), name="thumb.webp")
self.save()
class PictureQuerySet(models.QuerySet): class PictureQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self: def viewable_by(self, user: User) -> Self:
@@ -79,16 +271,65 @@ class PictureQuerySet(models.QuerySet):
return self.filter(people__user_id=user.id, is_moderated=True) return self.filter(people__user_id=user.id, is_moderated=True)
class SASPictureManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_in_sas=True, is_folder=False)
class Picture(SasFile): class Picture(SasFile):
class Meta: name = models.CharField(_("file name"), max_length=256)
proxy = True parent = models.ForeignKey(
Album,
related_name="pictures",
verbose_name=_("album"),
on_delete=models.CASCADE,
)
thumbnail = models.FileField(
upload_to=get_thumbnail_directory,
verbose_name=_("thumbnail"),
max_length=256,
unique=True,
)
original = models.FileField(
upload_to=get_directory,
verbose_name=_("original image"),
max_length=256,
unique=True,
)
compressed = models.FileField(
upload_to=get_compressed_directory,
verbose_name=_("compressed image"),
max_length=256,
unique=True,
)
created_at = models.DateTimeField(default=timezone.now)
owner = models.ForeignKey(
User,
related_name="owned_pictures",
verbose_name=_("owner"),
on_delete=models.PROTECT,
)
objects = SASPictureManager.from_queryset(PictureQuerySet)() is_moderated = models.BooleanField(_("is moderated"), default=False)
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
moderator = models.ForeignKey(
User,
related_name="moderated_pictures",
null=True,
blank=True,
on_delete=models.SET_NULL,
)
objects = PictureQuerySet.as_manager()
class Meta:
verbose_name = _("picture")
constraints = [
models.UniqueConstraint(
fields=["name", "parent"], name="sas_picture_unique_per_album"
)
]
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("sas:picture", kwargs={"picture_id": self.id})
def get_download_url(self): def get_download_url(self):
return reverse( return reverse(
@@ -111,8 +352,13 @@ class Picture(SasFile):
query={"date": int(self.updated_at.timestamp())}, query={"date": int(self.updated_at.timestamp())},
) )
def get_absolute_url(self): @property
return reverse("sas:picture", kwargs={"picture_id": self.id}) def is_vertical(self):
# original, compressed and thumbnail image have all three the same ratio,
# so the smallest one is used to tell if the image is vertical
im = Image.open(BytesIO(self.thumbnail.read()))
(w, h) = im.size
return w < h
def generate_thumbnails( def generate_thumbnails(
self, *, img: Image.Image | None = None, save: bool = False self, *, img: Image.Image | None = None, save: bool = False
@@ -122,13 +368,13 @@ class Picture(SasFile):
Args: Args:
img: if given, this will be used to generate img: if given, this will be used to generate
all three images (file, compressed, thumbnail). all three images (file, compressed, thumbnail).
Else, `self.file` will be used Else, `self.original` will be used
save: if True, save the instance in database. save: if True, save the instance in database.
""" """
img = img or Image.open(self.file) img = img or Image.open(self.original)
extension = self.mime_type.split("/")[-1] extension = self.mime_type.split("/")[-1]
previous_files = [ previous_files = [
f.name for f in (self.file, self.thumbnail, self.compressed) if f f.name for f in (self.original, self.thumbnail, self.compressed) if f
] ]
# convert the compressed image and the thumbnail into webp # convert the compressed image and the thumbnail into webp
# The original image keeps its original type, because it's not # The original image keeps its original type, because it's not
+8 -12
View File
@@ -26,19 +26,10 @@ class SimpleAlbumSchema(ModelSchema):
class AlbumSchema(ModelSchema): class AlbumSchema(ModelSchema):
class Meta: class Meta:
model = Album model = Album
fields = ["id", "name", "is_moderated"] fields = ["id", "name", "is_moderated", "thumbnail"]
thumbnail: str | None
sas_url: str sas_url: str
@staticmethod
def resolve_thumbnail(obj: Album) -> str | None:
# Album thumbnails aren't stored in `Album.thumbnail` but in `Album.file`
# Don't ask me why.
if not obj.file:
return None
return obj.get_download_url()
@staticmethod @staticmethod
def resolve_sas_url(obj: Album) -> str: def resolve_sas_url(obj: Album) -> str:
return obj.get_absolute_url() return obj.get_absolute_url()
@@ -55,7 +46,12 @@ class AlbumAutocompleteSchema(ModelSchema):
@staticmethod @staticmethod
def resolve_path(obj: Album) -> str: def resolve_path(obj: Album) -> str:
return str(Path(obj.get_parent_path()) / obj.name) return str(Path(obj.parent_path) / obj.name)
class MoveAlbumSchema(Schema):
id: int
new_parent_id: int
class PictureFilterSchema(FilterSchema): class PictureFilterSchema(FilterSchema):
@@ -73,7 +69,7 @@ class PictureSchema(ModelSchema):
fields = [ fields = [
"id", "id",
"name", "name",
"date", "created_at",
"updated_at", "updated_at",
"size", "size",
"is_moderated", "is_moderated",
+105
View File
@@ -128,3 +128,108 @@ document.addEventListener("alpine:init", () => {
}, },
})); }));
}); });
// Todo: migrate to alpine.js if we have some time
// $("form#upload_form").submit(function (event) {
// const formData = new FormData($(this)[0]);
//
// if (!formData.get("album_name") && !formData.get("images").name) return false;
//
// if (!formData.get("images").name) {
// return true;
// }
//
// event.preventDefault();
//
// let errorList = this.querySelector("#upload_form ul.errorlist.nonfield");
// if (errorList === null) {
// errorList = document.createElement("ul");
// errorList.classList.add("errorlist", "nonfield");
// this.insertBefore(errorList, this.firstElementChild);
// }
//
// while (errorList.childElementCount > 0)
// errorList.removeChild(errorList.firstElementChild);
//
// let progress = this.querySelector("progress");
// if (progress === null) {
// progress = document.createElement("progress");
// progress.value = 0;
// const p = document.createElement("p");
// p.appendChild(progress);
// this.insertBefore(p, this.lastElementChild);
// }
//
// let dataHolder;
//
// if (formData.get("album_name")) {
// dataHolder = new FormData();
// dataHolder.set("csrfmiddlewaretoken", "{{ csrf_token }}");
// dataHolder.set("album_name", formData.get("album_name"));
// $.ajax({
// method: "POST",
// url: "{{ url('sas:album_upload', album_id=object.id) }}",
// data: dataHolder,
// processData: false,
// contentType: false,
// success: onSuccess,
// });
// }
//
// const images = formData.getAll("images");
// const imagesCount = images.length;
// let completeCount = 0;
//
// const poolSize = 1;
// const imagePool = [];
//
// while (images.length > 0 && imagePool.length < poolSize) {
// const image = images.shift();
// imagePool.push(image);
// sendImage(image);
// }
//
// function sendImage(image) {
// dataHolder = new FormData();
// dataHolder.set("csrfmiddlewaretoken", "{{ csrf_token }}");
// dataHolder.set("images", image);
//
// $.ajax({
// method: "POST",
// url: "{{ url('sas:album_upload', album_id=object.id) }}",
// data: dataHolder,
// processData: false,
// contentType: false,
// })
// .fail(onSuccess.bind(undefined, image))
// .done(onSuccess.bind(undefined, image))
// .always(next.bind(undefined, image));
// }
//
// function next(image, _, __) {
// const index = imagePool.indexOf(image);
// const nextImage = images.shift();
//
// if (index !== -1) {
// imagePool.splice(index, 1);
// }
//
// if (nextImage) {
// imagePool.push(nextImage);
// sendImage(nextImage);
// }
// }
//
// function onSuccess(image, data, _, __) {
// let errors = [];
//
// if ($(data.responseText).find(".errorlist.nonfield")[0])
// errors = Array.from($(data.responseText).find(".errorlist.nonfield")[0].children);
//
// while (errors.length > 0) errorList.appendChild(errors.shift());
//
// progress.value = ++completeCount / imagesCount;
// if (progress.value === 1 && errorList.children.length === 0)
// document.location.reload();
// }
// });
@@ -31,10 +31,10 @@ document.addEventListener("alpine:init", () => {
await Promise.all( await Promise.all(
this.downloadPictures.map((p: PictureSchema) => { this.downloadPictures.map((p: PictureSchema) => {
const imgName = `${p.album.name}/IMG_${p.id}_${p.date.replace(/[:-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`; const imgName = `${p.album.name}/IMG_${p.id}_${p.created_at.replace(/[:-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
return zipWriter.add(imgName, new HttpReader(p.full_size_url), { return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
level: 9, level: 9,
lastModDate: new Date(p.date), lastModDate: new Date(p.created_at),
onstart: incrementProgressBar, onstart: incrementProgressBar,
}); });
}), }),
+19 -19
View File
@@ -20,7 +20,7 @@
{% block content %} {% block content %}
<code> <code>
<a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album.parent) }} {{ album.get_display_name() }} <a href="{{ url('sas:main') }}">SAS</a> / {{ print_path(album.parent) }} {{ album.name }}
</code> </code>
{% set is_sas_admin = user.can_edit(album) %} {% set is_sas_admin = user.can_edit(album) %}
@@ -30,7 +30,7 @@
<form action="" method="post" enctype="multipart/form-data"> <form action="" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<div class="album-navbar"> <div class="album-navbar">
<h3>{{ album.get_display_name() }}</h3> <h3>{{ album.name }}</h3>
<div class="toolbar"> <div class="toolbar">
<a href="{{ url('sas:album_edit', album_id=album.id) }}">{% trans %}Edit{% endtrans %}</a> <a href="{{ url('sas:album_edit', album_id=album.id) }}">{% trans %}Edit{% endtrans %}</a>
@@ -40,17 +40,17 @@
</div> </div>
</div> </div>
{% if clipboard %} {# {% if clipboard %}#}
<div class="clipboard"> {# <div class="clipboard">#}
{% trans %}Clipboard: {% endtrans %} {# {% trans %}Clipboard: {% endtrans %}#}
<ul> {# <ul>#}
{% for f in clipboard %} {# {% for f in clipboard["albums"] %}#}
<li>{{ f.get_full_path() }}</li> {# <li>{{ f.get_full_path() }}</li>#}
{% endfor %} {# {% endfor %}#}
</ul> {# </ul>#}
<input name="clear" type="submit" value="{% trans %}Clear clipboard{% endtrans %}"> {# <input name="clear" type="submit" value="{% trans %}Clear clipboard{% endtrans %}">#}
</div> {# </div>#}
{% endif %} {# {% endif %}#}
{% endif %} {% endif %}
{% if show_albums %} {% if show_albums %}
@@ -73,8 +73,8 @@
<div class="text">{% trans %}To be moderated{% endtrans %}</div> <div class="text">{% trans %}To be moderated{% endtrans %}</div>
</template> </template>
</div> </div>
{% if is_sas_admin %} {% if edit_mode %}
<input type="checkbox" name="file_list" :value="album.id"> <input type="checkbox" name="album_list" :value="album.id">
{% endif %} {% endif %}
</a> </a>
</template> </template>
@@ -100,7 +100,7 @@
</template> </template>
</div> </div>
{% if is_sas_admin %} {% if is_sas_admin %}
<input type="checkbox" name="file_list" :value="picture.id"> <input type="checkbox" name="picture_list" :value="picture.id">
{% endif %} {% endif %}
</a> </a>
</template> </template>
@@ -120,9 +120,9 @@
{% csrf_token %} {% csrf_token %}
<div class="inputs"> <div class="inputs">
<p> <p>
<label for="{{ upload_form.images.id_for_label }}">{{ upload_form.images.label }} :</label> <label for="{{ form.images.id_for_label }}">{{ form.images.label }} :</label>
{{ upload_form.images|add_attr("x-ref=pictures") }} {{ form.images|add_attr("x-ref=pictures") }}
<span class="helptext">{{ upload_form.images.help_text }}</span> <span class="helptext">{{ form.images.help_text }}</span>
</p> </p>
<input type="submit" value="{% trans %}Upload{% endtrans %}" /> <input type="submit" value="{% trans %}Upload{% endtrans %}" />
<progress x-ref="progress" x-show="sending"></progress> <progress x-ref="progress" x-show="sending"></progress>
+3 -5
View File
@@ -1,6 +1,6 @@
{% macro display_album(a, edit_mode) %} {% macro display_album(a, edit_mode) %}
<a href="{{ url('sas:album', album_id=a.id) }}"> <a href="{{ url('sas:album', album_id=a.id) }}">
{% if a.file %} {% if a.thumbnail %}
{% set img = a.get_download_url() %} {% set img = a.get_download_url() %}
{% set alt = a.name %} {% set alt = a.name %}
{% elif a.children.filter(is_folder=False, is_moderated=True).exists() %} {% elif a.children.filter(is_folder=False, is_moderated=True).exists() %}
@@ -11,9 +11,7 @@
{% set img = static('core/img/sas.jpg') %} {% set img = static('core/img/sas.jpg') %}
{% set alt = "sas.jpg" %} {% set alt = "sas.jpg" %}
{% endif %} {% endif %}
<div <div class="album{% if not a.is_moderated %} not_moderated{% endif %}">
class="album{% if not a.is_moderated %} not_moderated{% endif %}"
>
<img src="{{ img }}" alt="{{ alt }}" loading="lazy" /> <img src="{{ img }}" alt="{{ alt }}" loading="lazy" />
{% if not a.is_moderated %} {% if not a.is_moderated %}
<div class="overlay">&nbsp;</div> <div class="overlay">&nbsp;</div>
@@ -31,7 +29,7 @@
{% macro print_path(file) %} {% macro print_path(file) %}
{% if file and file.parent %} {% if file and file.parent %}
{{ print_path(file.parent) }} {{ print_path(file.parent) }}
<a href="{{ url('sas:album', album_id=file.id) }}">{{ file.get_display_name() }}</a> / <a href="{{ url("sas:album", album_id=file.id) }}">{{ file.name }}</a> /
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
+1 -1
View File
@@ -100,7 +100,7 @@
<span <span
x-text="Intl.DateTimeFormat( x-text="Intl.DateTimeFormat(
'{{ LANGUAGE_CODE }}', {dateStyle: 'long'} '{{ LANGUAGE_CODE }}', {dateStyle: 'long'}
).format(Date.parse(currentPicture.date))" ).format(Date.parse(currentPicture.created_at))"
> >
</span> </span>
</div> </div>
+7 -7
View File
@@ -27,8 +27,8 @@ class TestSas(TestCase):
cls.user_b, cls.user_c = subscriber_user.make(_quantity=2) cls.user_b, cls.user_c = subscriber_user.make(_quantity=2)
picture = picture_recipe.extend(owner=owner) picture = picture_recipe.extend(owner=owner)
cls.album_a = baker.make(Album, is_in_sas=True, parent=sas) cls.album_a = baker.make(Album)
cls.album_b = baker.make(Album, is_in_sas=True, parent=sas) cls.album_b = baker.make(Album)
relation_recipe = Recipe(PeoplePictureRelation) relation_recipe = Recipe(PeoplePictureRelation)
relations = [] relations = []
for album in cls.album_a, cls.album_b: for album in cls.album_a, cls.album_b:
@@ -61,7 +61,7 @@ class TestPictureSearch(TestSas):
self.client.force_login(self.user_b) self.client.force_login(self.user_b)
res = self.client.get(self.url + f"?album_id={self.album_a.id}") res = self.client.get(self.url + f"?album_id={self.album_a.id}")
assert res.status_code == 200 assert res.status_code == 200
expected = list(self.album_a.children_pictures.values_list("id", flat=True)) expected = list(self.album_a.pictures.values_list("id", flat=True))
assert [i["id"] for i in res.json()["results"]] == expected assert [i["id"] for i in res.json()["results"]] == expected
def test_filter_by_user(self): def test_filter_by_user(self):
@@ -70,7 +70,7 @@ class TestPictureSearch(TestSas):
assert res.status_code == 200 assert res.status_code == 200
expected = list( expected = list(
self.user_a.pictures.order_by( self.user_a.pictures.order_by(
"-picture__parent__date", "picture__date" "-picture__parent__event_date", "picture__created_at"
).values_list("picture_id", flat=True) ).values_list("picture_id", flat=True)
) )
assert [i["id"] for i in res.json()["results"]] == expected assert [i["id"] for i in res.json()["results"]] == expected
@@ -84,7 +84,7 @@ class TestPictureSearch(TestSas):
assert res.status_code == 200 assert res.status_code == 200
expected = list( expected = list(
self.user_a.pictures.union(self.user_b.pictures.all()) self.user_a.pictures.union(self.user_b.pictures.all())
.order_by("-picture__parent__date", "picture__date") .order_by("-picture__parent__event_date", "picture__created_at")
.values_list("picture_id", flat=True) .values_list("picture_id", flat=True)
) )
assert [i["id"] for i in res.json()["results"]] == expected assert [i["id"] for i in res.json()["results"]] == expected
@@ -97,7 +97,7 @@ class TestPictureSearch(TestSas):
assert res.status_code == 200 assert res.status_code == 200
expected = list( expected = list(
self.user_a.pictures.order_by( self.user_a.pictures.order_by(
"-picture__parent__date", "picture__date" "-picture__parent__event_date", "picture__created_at"
).values_list("picture_id", flat=True) ).values_list("picture_id", flat=True)
) )
assert [i["id"] for i in res.json()["results"]] == expected assert [i["id"] for i in res.json()["results"]] == expected
@@ -123,7 +123,7 @@ class TestPictureSearch(TestSas):
assert res.status_code == 200 assert res.status_code == 200
expected = list( expected = list(
self.user_b.pictures.intersection(self.user_a.pictures.all()) self.user_b.pictures.intersection(self.user_a.pictures.all())
.order_by("-picture__parent__date", "picture__date") .order_by("-picture__parent__event_date", "picture__created_at")
.values_list("picture_id", flat=True) .values_list("picture_id", flat=True)
) )
assert [i["id"] for i in res.json()["results"]] == expected assert [i["id"] for i in res.json()["results"]] == expected
+21 -2
View File
@@ -9,8 +9,8 @@ from PIL import Image
from core.baker_recipes import old_subscriber_user, subscriber_user from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import User from core.models import User
from sas.baker_recipes import picture_recipe from sas.baker_recipes import album_recipe, picture_recipe
from sas.models import PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
class TestPictureQuerySet(TestCase): class TestPictureQuerySet(TestCase):
@@ -105,3 +105,22 @@ def test_generate_thumbnail(save, initially_saved, pass_img_kwarg):
assert new_img.get_flattened_data() == image.get_flattened_data() assert new_img.get_flattened_data() == image.get_flattened_data()
assert Image.open(picture.thumbnail).size == (200, 100) assert Image.open(picture.thumbnail).size == (200, 100)
assert Image.open(picture.compressed).size == (1200, 600) assert Image.open(picture.compressed).size == (1200, 600)
class TestDeleteAlbum(TestCase):
def setUp(cls):
cls.album: Album = album_recipe.make()
cls.album_pictures = picture_recipe.make(parent=cls.album, _quantity=5)
cls.sub_album = album_recipe.make(parent=cls.album)
cls.sub_album_pictures = picture_recipe.make(parent=cls.sub_album, _quantity=5)
def test_delete(self):
album_ids = [self.album.id, self.sub_album.id]
picture_ids = [
*[p.id for p in self.album_pictures],
*[p.id for p in self.sub_album_pictures],
]
self.album.delete()
# assert not p.exists()
assert not Album.objects.filter(id__in=album_ids).exists()
assert not Picture.objects.filter(id__in=picture_ids).exists()
+1 -3
View File
@@ -234,9 +234,7 @@ class TestPictureRotation:
class TestSasModeration(TestCase): class TestSasModeration(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
album = baker.make( album = baker.make(Album)
Album, parent_id=settings.SITH_SAS_ROOT_DIR_ID, is_moderated=True
)
cls.pictures = picture_recipe.make( cls.pictures = picture_recipe.make(
parent=album, _quantity=10, _bulk_create=True parent=album, _quantity=10, _bulk_create=True
) )
+58 -24
View File
@@ -12,6 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from pathlib import Path
from typing import Any from typing import Any
from django.conf import settings from django.conf import settings
@@ -23,12 +24,12 @@ from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import SafeString from django.utils.safestring import SafeString
from django.views.generic import CreateView, DetailView, TemplateView from django.views.generic import CreateView, DetailView, TemplateView
from django.views.generic.edit import FormView, UpdateView from django.views.generic.edit import FormMixin, FormView, UpdateView
from core.auth.mixins import CanEditMixin, CanViewMixin from core.auth.mixins import CanEditMixin, CanViewMixin
from core.models import SithFile, User from core.models import SithFile, User
from core.views import UseFragmentsMixin from core.views import FileView, UseFragmentsMixin
from core.views.files import FileView, send_file from core.views.files import send_raw_file
from core.views.mixins import FragmentMixin, FragmentRenderer from core.views.mixins import FragmentMixin, FragmentRenderer
from core.views.user import UserTabsMixin from core.views.user import UserTabsMixin
from sas.forms import ( from sas.forms import (
@@ -64,6 +65,7 @@ class AlbumCreateFragment(FragmentMixin, CreateView):
class SASMainView(UseFragmentsMixin, TemplateView): class SASMainView(UseFragmentsMixin, TemplateView):
form_class = AlbumCreateForm
template_name = "sas/main.jinja" template_name = "sas/main.jinja"
def get_fragments(self) -> dict[str, FragmentRenderer]: def get_fragments(self) -> dict[str, FragmentRenderer]:
@@ -80,12 +82,26 @@ class SASMainView(UseFragmentsMixin, TemplateView):
root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID) root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID)
return {"album_create_fragment": {"owner": root_user}} return {"album_create_fragment": {"owner": root_user}}
def dispatch(self, request, *args, **kwargs):
if request.method == "POST" and not self.request.user.has_perm("sas.add_album"):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def get_form(self, form_class=None):
if not self.request.user.has_perm("sas.add_album"):
return None
return super().get_form(form_class)
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"owner": User.objects.get(pk=settings.SITH_ROOT_USER_ID),
"parent": None,
}
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
albums_qs = Album.objects.viewable_by(self.request.user) albums_qs = Album.objects.viewable_by(self.request.user)
kwargs["categories"] = list( kwargs["categories"] = list(albums_qs.filter(parent=None).order_by("id"))
albums_qs.filter(parent_id=settings.SITH_SAS_ROOT_DIR_ID).order_by("id")
)
kwargs["latest"] = list( kwargs["latest"] = list(
albums_qs.exclude(id=settings.SITH_SAS_ROOT_DIR_ID).order_by("-id")[:5] albums_qs.exclude(id=settings.SITH_SAS_ROOT_DIR_ID).order_by("-id")[:5]
) )
@@ -94,38 +110,50 @@ class SASMainView(UseFragmentsMixin, TemplateView):
class PictureView(CanViewMixin, DetailView): class PictureView(CanViewMixin, DetailView):
model = Picture model = Picture
queryset = Picture.objects.select_related("parent")
pk_url_kwarg = "picture_id" pk_url_kwarg = "picture_id"
template_name = "sas/picture.jinja" template_name = "sas/picture.jinja"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | { return super().get_context_data(**kwargs) | {"album": self.object.parent}
"album": Album.objects.get(children=self.object)
}
def send_album(request, album_id): def send_album(request, album_id):
return send_file(request, album_id, Album) album = get_object_or_404(Album, id=album_id)
if not album.can_be_viewed_by(request.user):
raise PermissionDenied
return send_raw_file(Path(album.thumbnail.path))
def send_pict(request, picture_id): def send_pict(request, picture_id):
return send_file(request, picture_id, Picture) picture = get_object_or_404(Picture, id=picture_id)
if not picture.can_be_viewed_by(request.user):
raise PermissionDenied
return send_raw_file(Path(picture.original.path))
def send_compressed(request, picture_id): def send_compressed(request, picture_id):
return send_file(request, picture_id, Picture, "compressed") picture = get_object_or_404(Picture, id=picture_id)
if not picture.can_be_viewed_by(request.user):
raise PermissionDenied
return send_raw_file(Path(picture.compressed.path))
def send_thumb(request, picture_id): def send_thumb(request, picture_id):
return send_file(request, picture_id, Picture, "thumbnail") picture = get_object_or_404(Picture, id=picture_id)
if not picture.can_be_viewed_by(request.user):
raise PermissionDenied
return send_raw_file(Path(picture.thumbnail.path))
class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView): class AlbumView(CanViewMixin, UseFragmentsMixin, FormMixin, DetailView):
model = Album model = Album
# exclude the SAS from the album accessible with this view # exclude the SAS from the album accessible with this view
# the SAS can be viewed only with SASMainView # the SAS can be viewed only with SASMainView
queryset = Album.objects.exclude(id=settings.SITH_SAS_ROOT_DIR_ID) queryset = Album.objects.exclude(id=settings.SITH_SAS_ROOT_DIR_ID)
pk_url_kwarg = "album_id" pk_url_kwarg = "album_id"
template_name = "sas/album.jinja" template_name = "sas/album.jinja"
form_class = PictureUploadForm
def get_fragments(self) -> dict[str, FragmentRenderer]: def get_fragments(self) -> dict[str, FragmentRenderer]:
return { return {
@@ -140,26 +168,32 @@ class AlbumView(CanViewMixin, UseFragmentsMixin, DetailView):
except ValueError as e: except ValueError as e:
raise Http404 from e raise Http404 from e
if "clipboard" not in request.session: if "clipboard" not in request.session:
request.session["clipboard"] = [] request.session["clipboard"] = {"albums": [], "pictures": []}
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_form(self, *args, **kwargs):
if not self.request.user.can_edit(self.object):
return None
return super().get_form(*args, **kwargs)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
if not request.user.can_edit(self.object): form = self.get_form()
if not form:
# the form is reserved for users that can edit this album.
# If there is no form, it means the user has no right to do a POST
raise PermissionDenied raise PermissionDenied
FileView.handle_clipboard(request, self.object) FileView.handle_clipboard(self.request, self.object)
return HttpResponseRedirect(self.request.path) if not form.is_valid():
return self.form_invalid(form)
return self.form_valid(form)
def get_fragment_data(self) -> dict[str, dict[str, Any]]: def get_fragment_data(self) -> dict[str, dict[str, Any]]:
return {"album_create_fragment": {"owner": self.request.user}} return {"album_create_fragment": {"owner": self.request.user}}
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
if ids := self.request.session.get("clipboard", None): kwargs["clipboard"] = {}
kwargs["clipboard"] = SithFile.objects.filter(id__in=ids)
kwargs["upload_form"] = PictureUploadForm()
# if True, the albums will be fetched with a request to the API
# if False, the section won't be displayed at all
kwargs["show_albums"] = ( kwargs["show_albums"] = (
Album.objects.viewable_by(self.request.user) Album.objects.viewable_by(self.request.user)
.filter(parent_id=self.object.id) .filter(parent_id=self.object.id)
@@ -207,7 +241,7 @@ class ModerationView(PermissionRequiredMixin, TemplateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["albums_to_moderate"] = Album.objects.filter( kwargs["albums_to_moderate"] = Album.objects.filter(
is_moderated=False, is_in_sas=True, is_folder=True is_moderated=False
).order_by("id") ).order_by("id")
pictures = Picture.objects.filter(is_moderated=False).select_related("parent") pictures = Picture.objects.filter(is_moderated=False).select_related("parent")
kwargs["pictures"] = pictures kwargs["pictures"] = pictures
+3 -15
View File
@@ -34,7 +34,6 @@ https://docs.djangoproject.com/en/1.8/ref/settings/
""" """
import binascii import binascii
import contextlib
import os import os
import sys import sys
from datetime import timedelta from datetime import timedelta
@@ -42,7 +41,6 @@ from pathlib import Path
import sentry_sdk import sentry_sdk
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.utils.deprecation import RemovedInDjango60Warning
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from environs import Env from environs import Env
from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.django import DjangoIntegration
@@ -93,8 +91,7 @@ ALLOWED_HOSTS = ["*"]
# RemovedInDjango60Warning: It's a transitional setting helpful in early # RemovedInDjango60Warning: It's a transitional setting helpful in early
# adoption of "https" as the new default value of forms.URLField.assume_scheme. # adoption of "https" as the new default value of forms.URLField.assume_scheme.
# Remove this after upgrading to Django 6.x # Remove this after upgrading to Django 6.x
with contextlib.suppress(RemovedInDjango60Warning): FORMS_URLFIELD_ASSUME_HTTPS = True
FORMS_URLFIELD_ASSUME_HTTPS = True
# Application definition # Application definition
@@ -141,13 +138,13 @@ MIDDLEWARE = (
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"core.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.locale.LocaleMiddleware", "django.middleware.locale.LocaleMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"core.middleware.AuthenticationMiddleware",
"core.middleware.SignalRequestMiddleware", "core.middleware.SignalRequestMiddleware",
"counter.middleware.BarmenMiddleware",
) )
ROOT_URLCONF = "sith.urls" ROOT_URLCONF = "sith.urls"
@@ -270,10 +267,6 @@ LOGGING = {
}, },
}, },
"loggers": { "loggers": {
"django.db.backends": {
"level": "DEBUG",
"handlers": ["log_to_stdout"],
},
"main": { "main": {
"handlers": ["log_to_stdout"], "handlers": ["log_to_stdout"],
"level": "INFO", "level": "INFO",
@@ -578,11 +571,6 @@ SITH_BARMAN_TIMEOUT = 30
# Minutes to delete the last operations # Minutes to delete the last operations
SITH_LAST_OPERATIONS_LIMIT = 10 SITH_LAST_OPERATIONS_LIMIT = 10
# time before a basket is considered expired
SITH_EBOUTIC_BASKET_TIMEOUT = timedelta(minutes=10)
# time that a user can spend on the CB payment page before it to timeout
SITH_EBOUTIC_ETRANSACTION_TIMEOUT = timedelta(minutes=10)
# ET variables # ET variables
SITH_EBOUTIC_CB_ENABLED = env.bool("SITH_EBOUTIC_CB_ENABLED", default=True) SITH_EBOUTIC_CB_ENABLED = env.bool("SITH_EBOUTIC_CB_ENABLED", default=True)
SITH_EBOUTIC_ET_URL = env.str( SITH_EBOUTIC_ET_URL = env.str(
+2 -3
View File
@@ -182,13 +182,12 @@ class OpenApi:
path[action]["operationId"] = "_".join( path[action]["operationId"] = "_".join(
desc["operationId"].split("_")[:-1] desc["operationId"].split("_")[:-1]
) )
schema = str(schema) schema = str(schema)
if old_hash == sha1(schema.encode("utf-8")).hexdigest(): if old_hash == sha1(schema.encode("utf-8")).hexdigest():
logging.getLogger("django").info("✨ Api did not change, nothing to do ✨") logging.getLogger("django").info("✨ Api did not change, nothing to do ✨")
return return
with open(out, "w") as f: out.write_text(schema)
_ = f.write(schema)
return subprocess.Popen(["npm", "run", "openapi"]) return subprocess.Popen(["npm", "run", "openapi"])