Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] 70e69c5622 [UPDATE] Update faker requirement
Updates the requirements on [faker](https://github.com/joke2k/faker) to permit the latest version.
- [Release notes](https://github.com/joke2k/faker/releases)
- [Changelog](https://github.com/joke2k/faker/blob/master/CHANGELOG.md)
- [Commits](https://github.com/joke2k/faker/compare/v40.18.0...v40.20.0)

---
updated-dependencies:
- dependency-name: faker
  dependency-version: 40.20.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-02 01:40:10 +00:00
48 changed files with 569 additions and 1863 deletions
+7 -2
View File
@@ -46,7 +46,7 @@ from django.http import HttpRequest
from ninja_extra import ControllerBase
from ninja_extra.permissions import BasePermission
from counter.utils import is_logged_in_counter
from counter.models import Counter
class IsInGroup(BasePermission):
@@ -186,7 +186,12 @@ class IsLoggedInCounter(BasePermission):
"""Check that a user is logged in a counter."""
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")
-34
View File
@@ -21,13 +21,10 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
import itertools
from operator import attrgetter
from django import forms
from django.db.models import Exists, OuterRef, Q, QuerySet
from django.db.models.functions import Lower
from django.forms.models import ModelChoiceField, ModelChoiceIterator
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
@@ -49,37 +46,6 @@ from counter.models import Counter, Selling
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):
error_css_class = "error"
required_css_class = "required"
+2 -1
View File
@@ -25,7 +25,8 @@ class Migration(migrations.Migration):
"url_base",
models.URLField(
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,
verbose_name="url base",
+4 -1
View File
@@ -793,7 +793,10 @@ class LinkType(models.Model):
url_base = models.URLField(
"url base",
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"),
+54 -138
View File
@@ -20,7 +20,7 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from datetime import date, datetime, timedelta
from datetime import date, timedelta
from io import StringIO
from pathlib import Path
from typing import ClassVar, NamedTuple
@@ -33,8 +33,7 @@ from django.core.management.base import BaseCommand
from django.db import connection
from django.db.models import Q
from django.utils import timezone
from django.utils.lorem_ipsum import paragraphs
from django.utils.timezone import localdate, now
from django.utils.timezone import localdate
from PIL import Image
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 counter.models import (
Counter,
CounterSellers,
Price,
Product,
ProductType,
ReturnableProduct,
StudentCard,
)
from election.models import Candidature, Election, ElectionList, Role, Vote
from election.models import Candidature, Election, ElectionList, Role
from forum.models import Forum
from pedagogy.models import UE
from sas.models import Album, PeoplePictureRelation, Picture
@@ -366,15 +364,62 @@ class Command(BaseCommand):
Counter.objects.create(name="Carte AE", club=clubs.refound, type="OFFICE")
# Add barman to counter
CounterSellers.objects.bulk_create(
Counter.sellers.through.objects.bulk_create(
[
CounterSellers(counter_id=1, user=skia, is_regular=True), # MDE
CounterSellers(counter_id=2, user=krophil, is_regular=True), # Foyer
Counter.sellers.through(counter_id=1, user=skia), # MDE
Counter.sellers.through(counter_id=2, user=krophil), # Foyer
]
)
# 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
room = Forum.objects.create(
@@ -965,132 +1010,3 @@ class Command(BaseCommand):
BanGroup.objects.create(name="Banned from buying alcohol", description="")
BanGroup.objects.create(name="Banned from counters", 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]))
+1 -6
View File
@@ -29,12 +29,7 @@
align-items: center;
gap: 20px;
&:disabled {
background-color: darken($primary-neutral-light-color, 5%);
opacity: 65%;
}
&.clickable:not(:disabled):hover {
&.clickable:hover {
background-color: darken($primary-neutral-light-color, 5%);
}
+1 -1
View File
@@ -23,7 +23,7 @@
border-radius: 5px;
color: black;
&:not(.link-like):not(:disabled):hover {
&:hover {
background: hsl(0, 0%, 83%);
}
}
+2 -2
View File
@@ -123,7 +123,7 @@ $background-color-hovered: #283747;
justify-content: center;
}
a.button {
>.button {
box-sizing: border-box;
height: 35px;
background-color: transparent;
@@ -139,7 +139,7 @@ $background-color-hovered: #283747;
font-size: .9em;
width: 120px;
&:not(.link-like):not(:disabled):hover {
&:hover {
background-color: $background-color-hovered;
}
}
+8 -3
View File
@@ -22,9 +22,14 @@
</form>
<ul class="bars">
{% cache 100 "counters_activity" %}
{# It would be cleaner to handle the timeout with django-celery-beat,
but doing it here is simpler and less error-prone #}
{% do Counter.objects.filter(type="BAR").handle_timeout() %}
{# The sith has no periodic tasks manager
and using cron jobs would be way too overkill here.
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 %}
{% for bar in Counter.objects.annotate_has_barman(user).annotate_is_open().filter(type="BAR") %}
<li>
+1 -1
View File
@@ -10,7 +10,7 @@
<template x-for="(message, index) in $notifications.getAll()">
<div class="alert" :class="`alert-${message.tag}`" x-transition>
<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>
</span>
</div>
+16 -36
View File
@@ -9,7 +9,6 @@ from django import forms
from django.core.exceptions import ValidationError
from django.db.models import Exists, OuterRef, Q
from django.forms import BaseModelFormSet
from django.http import HttpRequest
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_celery_beat.models import ClockedSchedule
@@ -18,7 +17,6 @@ from phonenumber_field.widgets import RegionalPhoneNumberWidget
from club.models import Club
from club.widgets.ajax_select import AutoCompleteSelectClub
from core.models import User, UserQuerySet
from core.views import LoginForm
from core.views.forms import (
FutureDateTimeField,
NFCTextInput,
@@ -93,18 +91,30 @@ class StudentCardForm(forms.ModelForm):
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(
label="Code",
max_length=StudentCard.UID_SIZE,
required=False,
widget=NFCTextInput(attrs={"autofocus": True}),
widget=NFCTextInput,
)
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):
cleaned_data = super().clean()
customer = None
@@ -126,40 +136,11 @@ class GetUserForm(forms.Form):
if customer is None or not customer.can_buy:
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
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):
allowed_refilling_methods = [
Refilling.PaymentMethod.CASH,
@@ -428,7 +409,6 @@ class ProductForm(forms.ModelForm):
"club",
"limit_age",
"tray",
"clic_limit",
"archived",
]
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 timezone as tz
from decimal import Decimal
from typing import Literal, Self
from typing import TYPE_CHECKING, Literal, Self
from dict2xml import dict2xml
from django.conf import settings
@@ -34,7 +34,6 @@ from django.forms import ValidationError
from django.urls import reverse
from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_celery_beat.models import PeriodicTask
from django_countries.fields import CountryField
@@ -48,6 +47,9 @@ from core.utils import get_start_of_semester
from counter.fields import CurrencyField
from subscription.models import Subscription
if TYPE_CHECKING:
from collections.abc import Sequence
def get_eboutic() -> Counter:
return Counter.objects.filter(type="EBOUTIC").order_by("id").first()
@@ -351,40 +353,6 @@ class ProductType(OrderedModel):
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):
"""A product, with all its related information."""
@@ -402,7 +370,8 @@ class Product(models.Model):
)
code = models.CharField(_("code"), max_length=16, blank=True)
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(
height=70,
@@ -419,21 +388,13 @@ class Product(models.Model):
tray = models.BooleanField(
_("tray price"), help_text=_("Buy five, get the sixth free"), default=False
)
clic_limit = models.PositiveSmallIntegerField(
_("clic limit"),
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,
buying_groups = models.ManyToManyField(
Group, related_name="products", verbose_name=_("buying groups"), blank=True
)
archived = models.BooleanField(_("archived"), default=False)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
updated_at = models.DateTimeField(_("updated at"), auto_now=True)
objects = ProductQuerySet.as_manager()
class Meta:
verbose_name = _("product")
@@ -619,6 +580,7 @@ class Counter(models.Model):
view_groups = models.ManyToManyField(
Group, related_name="viewable_counters", blank=True
)
token = models.CharField(_("token"), max_length=30, null=True, blank=True)
objects = CounterQuerySet.as_manager()
@@ -771,8 +733,10 @@ class Counter(models.Model):
# but they share the same primary key
return self.type == "BAR" and any(b.pk == customer.pk for b in self.barmen_list)
def get_prices_for(self, customer: Customer) -> PriceQuerySet:
return (
def get_prices_for(
self, customer: Customer, *, order_by: Sequence[str] | None = None
) -> list[Price]:
qs = (
Price.objects.filter(
product__counters=self, product__product_type__isnull=False
)
@@ -780,6 +744,9 @@ class Counter(models.Model):
.select_related("product", "product__product_type")
.prefetch_related("groups")
)
if order_by:
qs = qs.order_by(*order_by)
return list(qs)
class CounterSellers(models.Model):
+14 -7
View File
@@ -20,34 +20,41 @@
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
import random
from django.db.models.signals import pre_delete
from django.dispatch import receiver
from core.middleware import get_signal_request
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():
request = get_signal_request()
if not request:
return None
if request.barmen:
return random.choice(list(request.barmen))
# Get a random barmen if deletion is from a counter
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
if request.user.is_authenticated:
if request.user and not request.user.is_anonymous:
return request.user
# Return None by default
return None
OperationLog(
label=str(instance), operator=get_user(), operation_type=operation_type
label=str(instance),
operator=get_user(),
operation_type=operation_type,
).save()
+16 -34
View File
@@ -32,11 +32,12 @@
</ul>
<p><strong>{% trans %}Total: {% endtrans %}{{ last_total }} €</strong></p>
{% endif %}
{% if can_click %}
{% if barmen %}
<p>{% trans %}Enter client code:{% endtrans %}</p>
<form method="post" action="" id="select-user-form">
<form method="post" action="">
{% csrf_token %}
{{ form }}
<input type="hidden" name="counter_token" value="{{ counter.token }}" />
{{ form.as_p() }}
<p><input type="submit" value="{% trans %}validate{% endtrans %}" /></p>
</form>
{% else %}
@@ -44,36 +45,17 @@
{% endif %}
</div>
{% if counter.type == 'BAR' %}
<h3>{% trans %}Barmen:{% endtrans %}</h3>
{% if barmen_here %}
<div class="row gap-2x">
<div>
<h4>{% trans %}On this device{% endtrans %}</h4>
{% 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 %}
<div>
<h3>{% trans %}Barman: {% endtrans %}</h3>
{% for b in barmen %}
<p>{{ barman_logout_link(b) }}</p>
{% endfor %}
{% endif %}
{{ login_fragment }}
<form method="post" action="{{ url('counter:login', counter_id=counter.id) }}">
{% csrf_token %}
{{ login_form.as_p() }}
<p><input type="submit" value="{% trans %}login{% endtrans %}" /></p>
</form>
</div>
{% endif %}
{% endblock %}
@@ -81,10 +63,10 @@
{{ super() }}
<script type="text/javascript">
window.addEventListener("DOMContentLoaded", () => {
{# The login form annoyingly takes priority over the code form
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
periodically run a script until the field is there #}
// The login form annoyingly takes priority over the code form
// 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
// periodically run a script until the field is there
const autofocus = () => {
const field = document.querySelector("input[id='id_code']");
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>
</fieldset>
<fieldset><div>{{ form.clic_limit.as_field_group() }}</div></fieldset>
<fieldset><div>{{ form.counters.as_field_group() }}</div></fieldset>
<h3 class="margin-bottom">{% trans %}Prices{% endtrans %}</h3>
+55 -119
View File
@@ -17,11 +17,9 @@ from datetime import timedelta
from decimal import Decimal
import pytest
from bs4 import BeautifulSoup
from dateutil.relativedelta import relativedelta
from django.conf import settings
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.shortcuts import resolve_url
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.models import (
Counter,
CounterSellers,
Customer,
Permanency,
ProductType,
@@ -69,14 +66,10 @@ class TestFullClickBase(TestCase):
cls.subscriber = subscriber_user.make()
cls.counter = baker.make(Counter, type="BAR")
cls.counter.sellers.add(cls.barmen, cls.board_admin)
cls.other_counter = baker.make(Counter, type="BAR")
CounterSellers.objects.bulk_create(
[
CounterSellers(counter=cls.counter, user=cls.barmen),
CounterSellers(counter=cls.counter, user=cls.board_admin),
CounterSellers(counter=cls.other_counter, user=cls.barmen),
]
)
cls.other_counter.sellers.add(cls.barmen)
cls.yet_another_counter = baker.make(Counter, type="BAR")
@@ -121,10 +114,7 @@ class TestRefilling(TestFullClickBase):
) -> HttpResponse:
used_client = client if client is not None else self.client
return used_client.post(
reverse(
"counter:refilling_create",
kwargs={"customer_id": user.pk, "counter_id": self.counter.pk},
),
reverse("counter:refilling_create", kwargs={"customer_id": user.pk}),
{"amount": str(amount), "payment_method": Refilling.PaymentMethod.CASH},
HTTP_REFERER=reverse(
"counter:click", kwargs={"counter_id": counter.id, "user_id": user.pk}
@@ -148,10 +138,7 @@ class TestRefilling(TestFullClickBase):
return self.client.post(
reverse(
"counter:refilling_create",
kwargs={
"customer_id": self.customer.pk,
"counter_id": self.counter.pk,
},
kwargs={"customer_id": self.customer.pk},
),
{"amount": "10", "payment_method": "CASH"},
)
@@ -455,19 +442,9 @@ class TestCounterClick(TestFullClickBase):
def test_click_not_connected(self):
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)])
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(
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)),
groups=[group],
)
customer_prices = list(counter.get_prices_for(customer))
customer_prices = counter.get_prices_for(customer)
assert unarchived_prices == customer_prices
@@ -741,97 +718,59 @@ class TestCounterStats(TestCase):
class TestBarmanConnection(TestCase):
@classmethod
def setUpTestData(cls):
cls.barman = subscriber_user.make()
cls.barman.set_password("plop")
cls.barman.save()
cls.counter = baker.make(Counter, type="BAR", sellers=[cls.barman])
cls.login_url = reverse("counter:login", kwargs={"counter_id": cls.counter.id})
cls.detail_url = reverse(
"counter:details", kwargs={"counter_id": cls.counter.id}
)
cls.krophil = User.objects.get(username="krophil")
cls.skia = User.objects.get(username="skia")
cls.skia.customer.account = 800
cls.krophil.customer.save()
cls.skia.customer.save()
cls.counter = Counter.objects.get(id=2)
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.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):
"""Test when the barman is already logged in another counter."""
other_counter = baker.make(Counter, type="BAR")
CounterSellers.objects.create(counter=other_counter, user=self.barman)
assert "<p>Entrez un code client : </p>" in str(response.content)
def test_counters_list_barmen(self):
self.client.post(
reverse("counter:login", kwargs={"counter_id": other_counter.id}),
{"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:activity", args=[self.counter.id]))
def test_login_on_non_bar_counter(self):
counter = baker.make(Counter, type="OFFICE")
CounterSellers.objects.create(counter=counter, user=self.barman)
url = reverse("counter:login", kwargs={"counter_id": counter.id})
response = self.client.get(url)
assert response.status_code == 403
response = self.client.post(
url, {"username": self.barman.username, "password": "plop"}
assert '<li><a href="/user/10/">Kro Phil&#39;</a></li>' in str(response.content)
def test_barman_denied(self):
self.client.post(
reverse("counter:login", args=[self.counter.id]),
{"username": "skia", "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
def test_barman_timeout(client: Client):
def test_barman_timeout():
"""Test that barmen timeout is well managed."""
bar = baker.make(Counter, type="BAR")
user = baker.make(User)
CounterSellers.objects.create(counter=bar, user=user)
bar.sellers.add(user)
baker.make(Permanency, counter=bar, user=user, start=now())
qs = Counter.objects.annotate_is_open().filter(pk=bar.pk)
@@ -847,8 +786,6 @@ def test_barman_timeout(client: Client):
bar = qs[0]
assert not bar.is_open
assert bar.barmen_list == []
res = client.get("")
assert res.wsgi_request.barmen == set()
class TestClubCounterClickAccess(TestCase):
@@ -898,14 +835,14 @@ class TestClubCounterClickAccess(TestCase):
def test_barman(self):
"""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)
res = self.client.get(self.click_url)
assert res.status_code == 200
def test_both_barman_and_board_member(self):
"""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(
Membership, club=self.counter.club, user=self.user, role=self.board_role
)
@@ -929,17 +866,16 @@ class TestCounterLogout:
reverse("counter:logout", kwargs={"counter_id": permanence.counter_id}),
data={"user_id": permanence.user_id},
)
assertRedirects(
res,
reverse("counter:details", kwargs={"counter_id": permanence.counter_id}),
)
permanence.refresh_from_db()
assert permanence.end == permanence.activity
assert permanence.user not in res.wsgi_request.barmen
assertRedirects(
res,
reverse(
"counter:details", kwargs={"counter_id": permanence.counter_id}
),
)
permanence.refresh_from_db()
assert permanence.end == now()
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")
permanence = baker.make(
Permanency,
@@ -960,6 +896,6 @@ class TestCounterLogout:
data={"user_id": permanence.user_id},
)
permanence.refresh_from_db()
assert permanence.end == permanence.activity
assert permanence.end == now()
old_permanence.refresh_from_db()
assert old_permanence.end == old_end
+2 -61
View File
@@ -1,4 +1,3 @@
import itertools
from io import BytesIO
from typing import Callable
from uuid import uuid4
@@ -9,7 +8,6 @@ from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker
from model_bakery.recipe import Recipe
from PIL import Image
@@ -18,10 +16,9 @@ from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club
from core.baker_recipes import board_user, subscriber_user
from core.models import Group, User
from counter.baker_recipes import product_recipe, sale_recipe
from counter.baker_recipes import product_recipe
from counter.forms import ProductForm, ProductPriceFormSet
from counter.models import Price, Product, ProductType, Selling
from eboutic.models import Basket, BasketItem
from counter.models import Price, Product, ProductType
@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[1])) == [prices[0], prices[4]]
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,
SellingDeleteView,
)
from counter.views.auth import counter_login, counter_logout
from counter.views.cash import (
CashSummaryEditView,
CashSummaryListView,
@@ -56,9 +57,7 @@ from counter.views.eticket import (
from counter.views.home import (
CounterActivityView,
CounterLastOperationsView,
CounterLoginFragment,
CounterMain,
counter_logout,
)
from counter.views.invoice import InvoiceCallView
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>/click/<int:user_id>/", CounterClick.as_view(), name="click"),
path(
"<int:counter_id>/refill/<int:customer_id>/",
"refill/<int:customer_id>/",
RefillingCreateView.as_view(),
name="refilling_create",
),
@@ -83,7 +82,7 @@ urlpatterns = [
),
path("<int:counter_id>/activity/", CounterActivityView.as_view(), name="activity"),
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("eticket/<int:selling_id>/pdf/", EticketPDFView.as_view(), name="eticket_pdf"),
path(
+16 -3
View File
@@ -3,6 +3,8 @@ from urllib.parse import urlparse
from django.http import HttpRequest
from django.urls import resolve
from counter.models import Counter
def is_logged_in_counter(request: HttpRequest) -> bool:
"""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
(eg. the barman went back to the main by missclick and go back
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 = (
"HTTP_REFERER" in request.META
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 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"
#
#
import random
from collections import defaultdict
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.db import transaction
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.urls import reverse
from django.utils.safestring import SafeString
from django.utils.translation import gettext as _
from django.views.generic import FormView
from django.views.generic.detail import SingleObjectMixin
from ninja.main import HttpRequest
@@ -32,7 +29,13 @@ from core.auth.mixins import CanViewMixin
from core.models import User
from core.views.mixins import FragmentMixin, UseFragmentsMixin
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.views.mixins import CounterTabsMixin
from counter.views.student_card import StudentCardFormFragment
@@ -43,7 +46,7 @@ def get_operator(request: HttpRequest, counter: Counter, customer: Customer) ->
return request.user
if counter.customer_is_barman(customer):
return customer.user
return random.choice(list(request.barmen))
return counter.get_random_barman()
class CounterClick(
@@ -75,7 +78,7 @@ class CounterClick(
return 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()
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.
raise PermissionDenied
if obj.type == "BAR" and not (
request.barmen and request.barmen.issubset(set(obj.barmen_list))
if obj.type == "BAR" and (
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
self.prices = list(obj.get_prices_for(self.customer))
self.prices = obj.get_prices_for(self.customer)
return super().dispatch(request, *args, **kwargs)
@@ -195,7 +199,7 @@ class CounterClick(
)
if self.object.can_refill():
res["refilling_fragment"] = RefillingCreateView.as_fragment()(
self.request, customer=self.customer, counter=self.object
self.request, customer=self.customer
)
return res
@@ -233,13 +237,11 @@ class RefillingCreateView(FragmentMixin, FormView):
if not is_logged_in_counter(request):
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 (
request.barmen
and request.barmen.issubset(self.counter.barmen_list)
and self.counter.can_refill()
):
if not self.counter.can_refill():
raise PermissionDenied
self.operator = get_operator(request, self.counter, self.customer)
@@ -248,7 +250,6 @@ class RefillingCreateView(FragmentMixin, FormView):
def render_fragment(self, request, **kwargs) -> SafeString:
self.customer = kwargs.pop("customer")
self.counter = kwargs.pop("counter")
return super().render_fragment(request, **kwargs)
def form_valid(self, form):
@@ -263,8 +264,7 @@ class RefillingCreateView(FragmentMixin, FormView):
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["action"] = reverse(
"counter:refilling_create",
kwargs={"customer_id": self.customer.pk, "counter_id": self.counter.pk},
"counter:refilling_create", kwargs={"customer_id": self.customer.pk}
)
return kwargs
+53 -98
View File
@@ -15,120 +15,78 @@
from datetime import timedelta
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.db.models import F
from django.http import HttpRequest, HttpResponseRedirect
from django.shortcuts import redirect
from django.urls import reverse
from django.http import HttpResponseRedirect
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.safestring import SafeString
from django.views.decorators.http import require_POST
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import FormView
from django.views.generic.edit import FormMixin, ProcessFormView
from core.auth.mixins import CanViewMixin
from core.views import FragmentMixin, UseFragmentsMixin
from counter.forms import CounterLoginForm, GetUserForm
from counter.models import Counter, Permanency
from core.views.forms import LoginForm
from counter.forms import GetUserForm
from counter.models import Counter
from counter.utils import is_logged_in_counter
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(
CounterTabsMixin, UseFragmentsMixin, CanViewMixin, SingleObjectMixin, FormView
CounterTabsMixin, CanViewMixin, DetailView, ProcessFormView, FormMixin
):
"""The public (barman) view."""
model = Counter
queryset = Counter.objects.exclude(type="EBOUTIC")
template_name = "counter/counter_main.jinja"
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"
def dispatch(self, request, *args, **kwargs):
self.object: Counter = self.get_object()
if self.object.type == "BAR":
self.object.update_activity()
return super().dispatch(request, *args, **kwargs)
def get_queryset(self):
return super().get_queryset().exclude(type="EBOUTIC")
def get_fragment_context_data(self) -> dict[str, SafeString]:
login_fragment = (
CounterLoginFragment.as_fragment()(self.request, counter=self.object)
if self.object.type == "BAR"
else ""
)
return super().get_fragment_context_data() | {"login_fragment": login_fragment}
def post(self, request, *args, **kwargs):
self.object = self.get_object()
if self.object.type == "BAR" and not (
"counter_token" in self.request.session
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},
)
+ "?bad_location"
)
return super().post(request, *args, **kwargs)
def get_context_data(self, **kwargs):
"""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["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":
kwargs["barmen"] = self.object.barmen_list
kwargs["barmen_here"] = list(
self.request.barmen.intersection(self.object.barmen_list)
)
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)
)
)
elif self.request.user.is_authenticated:
kwargs["barmen"] = [self.request.user]
if "last_basket" in self.request.session:
kwargs["last_basket"] = self.request.session.pop("last_basket")
kwargs["last_customer"] = self.request.session.pop("last_customer")
@@ -138,17 +96,14 @@ class CounterMain(
)
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."""
self.success_url = reverse(
"counter:click",
kwargs={
"counter_id": self.kwargs["counter_id"],
"user_id": form.cleaned_data["user_id"],
},
)
self.kwargs["user_id"] = form.cleaned_data["user_id"]
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):
"""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
avec la banque pour son fonctionnement.
@@ -11,32 +9,3 @@ Nous ne pouvons donc que vous redirigez vers la doc du crédit
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/)
## 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.
-9
View File
@@ -98,15 +98,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(
+11 -17
View File
@@ -11,7 +11,7 @@ const BASKET_CACHE_KEY = "basket";
const BASKET_CACHE_VERSION = 1;
document.addEventListener("alpine:init", () => {
Alpine.data("basket", (validPrices: number[], lastPurchaseTime?: number) => ({
Alpine.data("basket", (lastPurchaseTime?: number) => ({
basket: [] as BasketItem[],
init() {
@@ -19,6 +19,15 @@ document.addEventListener("alpine:init", () => {
this.$watch("basket", () => {
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
.getElementById("id_form-TOTAL_FORMS")
.setAttribute(":value", "basket.length");
@@ -28,22 +37,7 @@ document.addEventListener("alpine:init", () => {
const cached = versionedLocalStorage.getItem<BasketItem[]>(BASKET_CACHE_KEY, {
version: BASKET_CACHE_VERSION,
});
if (!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));
return cached ?? [];
},
saveBasket() {
@@ -15,14 +15,10 @@
{% block content %}
<h3>{% trans %}Eboutic{% endtrans %}</h3>
<script type="text/javascript">
const billingInfos = {{ billing_infos|safe }};
</script>
<div x-data='etransaction(
billingInfos,
{ id: {{ basket.id }}, timeout: new Date("{{ basket.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT }}") }
)'>
{{ billing_infos|tojson }},
{ id: {{ basket.id }}, timeout: new Date('{{ basket.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT }}') }
)'>
<p>{% trans %}Basket: {% endtrans %}</p>
<table>
<thead>
+2 -16
View File
@@ -30,17 +30,7 @@
{% block content %}
<h1 id="eboutic-title">{% trans %}Eboutic{% endtrans %}</h1>
<div
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="eboutic" x-data="basket({{ last_purchase_time }})">
<div id="basket">
<h3>Panier</h3>
<form method="post" action="">
@@ -197,10 +187,9 @@
{% for price in prices %}
<button
id="{{ price.id }}"
class="card clickable shadow"
class="card product-button clickable shadow"
:class="{selected: basket.some((i) => i.priceId === {{ price.id }})}"
@click='addFromCatalog({{ price.id }}, {{ price.full_label|tojson }}, {{ price.amount }})'
{% if price.sold_out %}disabled{% endif %}
>
{% if price.product.icon %}
<img
@@ -213,9 +202,6 @@
{% endif %}
<div class="card-content">
<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>
</div>
</button>
+22 -48
View File
@@ -1,19 +1,14 @@
import re
from datetime import datetime, timezone
import freezegun
import pytest
from bs4 import BeautifulSoup
from django.conf import settings
from django.http import HttpResponse
from django.test import TestCase
from django.test.client import Client
from django.urls import reverse
from django.utils.timezone import localdate, now
from django.utils.timezone import localdate
from model_bakery import baker
from pytest_django.asserts import assertRedirects
import eboutic.models
from core.baker_recipes import subscriber_user
from core.models import Group, User
from counter.baker_recipes import (
@@ -135,11 +130,9 @@ def test_eboutic_basket_expiry(
_bulk_create=True,
)
soup = BeautifulSoup(client.get(reverse("eboutic:main")).text, "lxml")
assert (
# remove any space from the value before asserting
re.sub(r"\s+", "", soup.find(id="eboutic").attrs["x-data"])
== f"basket([],{int(expected.timestamp() * 1000) if expected else 'null'},)"
f'x-data="basket({int(expected.timestamp() * 1000) if expected else "null"})"'
in client.get(reverse("eboutic:main")).text
)
@@ -238,45 +231,26 @@ class TestEboutic(TestCase):
def test_add_forbidden_product(self):
self.client.force_login(self.new_customer)
for product in self.beer, self.cotiz, self.not_in_counter:
response = self.submit_basket([BasketItem(product.id, 1)])
assert response.status_code == 200
assert not Basket.objects.exists()
def test_sold_out_product(self):
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)])
response = self.submit_basket([BasketItem(self.beer.id, 1)])
assert response.status_code == 200
assert Basket.objects.count() == 1
with freezegun.freeze_time(
now()
+ settings.SITH_EBOUTIC_BASKET_TIMEOUT
+ settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT
):
# after a while, unpaid basket items should expire and make the
# product available again.
response = self.submit_basket([BasketItem(price.id, 1)])
assertRedirects(
response,
reverse("eboutic:checkout", kwargs={"basket_id": Basket.objects.last().id}),
)
assert Basket.objects.count() == 2
assert Basket.objects.first() is None
response = self.submit_basket([BasketItem(self.cotiz.id, 1)])
assert response.status_code == 200
assert Basket.objects.first() is None
response = self.submit_basket([BasketItem(self.not_in_counter.id, 1)])
assert response.status_code == 200
assert Basket.objects.first() is None
self.client.force_login(self.new_customer)
response = self.submit_basket([BasketItem(self.cotiz.id, 1)])
assert response.status_code == 200
assert Basket.objects.first() is None
response = self.submit_basket([BasketItem(self.not_in_counter.id, 1)])
assert response.status_code == 200
assert Basket.objects.first() is None
def test_create_basket(self):
self.client.force_login(self.new_customer)
+5 -12
View File
@@ -33,7 +33,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import SuspiciousOperation, ValidationError
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.utils import cached_property
from django.http import HttpResponse
@@ -92,9 +92,7 @@ class EbouticMainView(LoginRequiredMixin, FormView):
kwargs["form_kwargs"] = {
"customer": self.customer,
"counter": get_eboutic(),
"allowed_prices": {
price.id: price for price in self.prices if not price.sold_out
},
"allowed_prices": {price.id: price for price in self.prices},
}
return kwargs
@@ -120,14 +118,9 @@ class EbouticMainView(LoginRequiredMixin, FormView):
@cached_property
def prices(self) -> list[Price]:
eboutic = get_eboutic()
sold_out_subquery = ~Exists(
eboutic.products.under_clic_limit().filter(id=OuterRef("product_id"))
)
return list(
eboutic.get_prices_for(self.customer)
.annotate(sold_out=sold_out_subquery)
.order_by("product__product_type__order", "product_id", "amount")
return get_eboutic().get_prices_for(
self.customer,
order_by=["product__product_type__order", "product_id", "amount"],
)
@cached_property
+35 -138
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.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 club.forms import ClubRoleChoiceField
from club.models import ClubRole, Membership
from club.widgets.ajax_select import AutoCompleteSelectMultipleClub
from core.models import User
from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import (
@@ -91,20 +79,27 @@ class VoteForm(forms.Form):
class RoleForm(forms.ModelForm):
"""Form for creating a role."""
required_css_class = "required"
error_css_class = "error"
class Meta:
model = Role
fields = ["club_role", "title", "description", "max_choice"]
field_classes = {"club_role": ClubRoleChoiceField}
fields = ["title", "election", "description", "max_choice"]
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)
self.instance.election = election
self.fields["club_role"].queryset = ClubRole.objects.filter(
is_board=True, club__in=election.clubs.all()
)
if election_id:
self.fields["election"].queryset = Election.objects.filter(
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"
)
class ElectionListForm(forms.ModelForm):
@@ -113,21 +108,21 @@ class ElectionListForm(forms.ModelForm):
fields = ("title", "election")
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)
self.instance.election = election
if election_id:
self.fields["election"].queryset = Election.objects.filter(
id=election_id
).all()
class ElectionForm(forms.ModelForm):
required_css_class = "required"
error_css_class = "error"
class Meta:
model = Election
fields = [
"title",
"description",
"clubs",
"archived",
"start_candidature",
"end_candidature",
@@ -139,119 +134,21 @@ class ElectionForm(forms.ModelForm):
"candidature_groups",
]
widgets = {
"clubs": AutoCompleteSelectMultipleClub,
"edit_groups": AutoCompleteSelectMultipleGroup,
"view_groups": AutoCompleteSelectMultipleGroup,
"vote_groups": AutoCompleteSelectMultipleGroup,
"candidature_groups": AutoCompleteSelectMultipleGroup,
"start_date": SelectDateTime,
"end_date": SelectDateTime,
"start_candidature": SelectDateTime,
"end_candidature": SelectDateTime,
}
class ElectionCreateForm(ElectionForm):
"""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"))
)
def choice(self, obj: Candidature):
return (
ModelChoiceIteratorValue(self.field.prepare_value(obj), obj),
obj.user.get_full_name(),
)
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)
start_date = forms.DateTimeField(
label=_("Start date"), widget=SelectDateTime, required=True
)
end_date = forms.DateTimeField(
label=_("End date"), widget=SelectDateTime, required=True
)
start_candidature = forms.DateTimeField(
label=_("Start candidature"), widget=SelectDateTime, required=True
)
end_candidature = forms.DateTimeField(
label=_("End candidature"), widget=SelectDateTime, required=True
)
@@ -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 ordered_model.models import OrderedModel
from club.models import Club, ClubRole, Membership
from core.models import Group, User
@@ -14,12 +13,6 @@ class Election(models.Model):
title = models.CharField(_("title"), max_length=255)
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)
end_candidature = models.DateTimeField(_("end candidature"), 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)
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):
"""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,
@@ -121,42 +105,17 @@ class Role(OrderedModel):
on_delete=models.CASCADE,
)
title = models.CharField(_("title"), max_length=255)
description = models.TextField(_("description"), default="", blank=True)
max_choice = models.PositiveSmallIntegerField(_("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",
)
]
description = models.TextField(_("description"), null=True, blank=True)
max_choice = models.IntegerField(_("max choice"), default=1)
def __str__(self):
return f"{self.title} - {self.election.title}"
def results(self, total_vote: int) -> dict[str, dict[str, int | float]]:
if total_vote == 0:
candidates = self.candidatures.values_list("user__username", flat=True)
candidates = self.candidatures.values_list("user__username")
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
results = {"total vote": total_vote}
@@ -29,25 +29,13 @@
{% trans %}Polls closed {% endtrans %}
{%- else %}
{% trans %}Polls will open {% endtrans %}
<time datetime="{{ election.start_date }}">{{ election.start_date|localtime|date(DATETIME_FORMAT) }}</time>
{% trans %}at{% endtrans %}
<time>{{ election.start_date|localtime|time(DATETIME_FORMAT) }}</time>
<time datetime="{{ election.start_date }}">{{ election.start_date|localtime|date(DATETIME_FORMAT)}}</time>
{% trans %} at {% endtrans %}<time>{{ election.start_date|localtime|time(DATETIME_FORMAT)}}</time>
{% trans %}and will close {% endtrans %}
{%- endif %}
<time datetime="{{ election.end_date }}">{{ election.end_date|localtime|date(DATETIME_FORMAT) }}</time>
{% trans %}at{% endtrans %}
<time>{{ election.end_date|localtime|time(DATETIME_FORMAT) }}</time>
<time datetime="{{ election.end_date }}">{{ election.end_date|localtime|date(DATETIME_FORMAT)}}</time>
{% trans %} at {% endtrans %}<time>{{ election.end_date|localtime|time(DATETIME_FORMAT)}}</time>
</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 %}
<p class="election__elector-infos">
{%- if election.is_vote_active %}
@@ -59,27 +47,17 @@
{%- endif %}
</section>
<section class="election_vote">
<form
action="{{ url('election:vote', election.id) }}"
method="post"
class="election__vote-form"
name="vote-form"
id="vote-form"
>
<form action="{{ url('election:vote', election.id) }}" method="post" class="election__vote-form" name="vote-form" id="vote-form">
{% csrf_token %}
<table class="election_table">
<thead class="lists">
<tr>
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">
{% trans %}Blank vote{% endtrans %}
</th>
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">{% trans %}Blank vote{% endtrans %}</th>
{%- for election_list in election_lists %}
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">
<span>{{ election_list.title }}</span>
{% if user.can_edit(election_list) and election.is_vote_editable -%}
<a href="{{ url('election:delete_list', list_id=election_list.id) }}">
<i class="fa-regular fa-trash-can delete-action"></i>
</a>
<a href="{{ url('election:delete_list', list_id=election_list.id) }}"><i class="fa-regular fa-trash-can delete-action"></i></a>
{% endif %}
</th>
{%- endfor %}
@@ -125,45 +103,22 @@
<button disabled><i class="fa fa-arrow-down"></i></button>
<button disabled><i class="fa fa-caret-down"></i></button>
{%- else -%}
<button
type="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>
<button type="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 -%}
{%- if loop.first -%}
<button disabled><i class="fa fa-caret-up"></i></button>
<button disabled><i class="fa fa-arrow-up"></i></button>
{%- else -%}
<button
type="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>
<button type="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 -%}
</div>
{%- endif -%}
</td>
</tr>
<tr class="role_candidates">
<td
class="list_per_role"
style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%"
>
<td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%">
{%- if role.max_choice == 1 and show_vote_buttons %}
<div class="radio-btn">
{% set input_id = "blank_vote_" + role.id|string %}
@@ -176,46 +131,26 @@
{%- if election.is_vote_finished %}
{%- set results = election_results[role.title]['blank vote'] %}
<div class="election__results">
<strong>
{{ results.vote }} {% trans %}votes{% endtrans %} ({{ "%.2f" % results.percent }} %)
</strong>
<strong>{{ results.vote }} {% trans %}votes{% endtrans %} ({{ "%.2f" % results.percent }} %)</strong>
</div>
{%- endif %}
</td>
{%- for election_list in election_lists %}
<td
class="list_per_role"
style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%"
>
<td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%">
<ul class="candidates">
{%- for candidature in election_list.candidatures.select_related("user", "user__profile_pict").filter(role=role) %}
<li class="candidate">
{%- if show_vote_buttons %}
{% set input_id = "candidature_" + candidature.id|string %}
<input
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 }}"
>
<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 }}">
<label for="{{ input_id }}">
{%- endif %}
<figure>
{%- if user.can_view(candidature.user) %}
{% if candidature.user.profile_pict %}
<img
class="candidate__picture"
src="{{ candidature.user.profile_pict.get_download_url() }}"
alt="{% trans %}Profile{% endtrans %}"
>
<img class="candidate__picture" src="{{ candidature.user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}">
{% else %}
<img
class="candidate__picture"
src="{{ static('core/img/unknown.jpg') }}"
alt="{% trans %}Profile{% endtrans %}"
>
<img class="candidate__picture" src="{{ static('core/img/unknown.jpg') }}" alt="{% trans %}Profile{% endtrans %}">
{% endif %}
{%- endif %}
<figcaption class="candidate__details">
@@ -229,12 +164,8 @@
{%- if user.can_edit(candidature) -%}
{%- if election.is_vote_editable -%}
<div class="edit_btns">
<a href="{{ url('election:update_candidate', candidature_id=candidature.id) }}">
<i class="fa-regular fa-pen-to-square edit-action"></i>
</a>
<a href="{{ url('election:delete_candidate', candidature_id=candidature.id) }}">
<i class="fa-regular fa-trash-can delete-action"></i>
</a>
<a href="{{url('election:update_candidate', candidature_id=candidature.id)}}"><i class="fa-regular fa-pen-to-square edit-action"></i></a>
<a href="{{url('election:delete_candidate', candidature_id=candidature.id)}}"><i class="fa-regular fa-trash-can delete-action"></i></a>
</div>
{%- endif -%}
{%- endif -%}
@@ -7,7 +7,7 @@
{% block head %}
{{ super() -}}
<style>
<style type="text/css">
small {
font-size: smaller;
}
@@ -20,9 +20,6 @@
{% block content %}
<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 %}
<hr>
<section>
@@ -35,7 +32,7 @@
{% trans %} at {% endtrans %}<time>{{ election.start_candidature|localtime|time(DATETIME_FORMAT) }}</time>
{% trans %}to{% endtrans %}
<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>
{% 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
from django.conf import settings
from django.contrib.auth.models import Permission
from django.test import Client, TestCase
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.recipe import Recipe
from pytest_django.asserts import assertRedirects
from club.models import Club
from core.baker_recipes import subscriber_user
from core.models import Group, User
from election.models import Candidature, Election, ElectionList, Role, Vote
@@ -40,6 +38,7 @@ class TestElectionDetail(TestElection):
reverse("election:detail", args=str(self.election.id))
)
assert response.status_code == 200
assert "La roue tourne" in str(response.content)
class TestElectionUpdateView(TestElection):
@@ -214,42 +213,3 @@ def test_election_results():
"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 election.views import (
ApplyResultFragment,
CandidatureCreateView,
CandidatureDeleteView,
CandidatureUpdateView,
@@ -57,9 +56,4 @@ urlpatterns = [
),
path("<int:election_id>/vote/", VoteFormView.as_view(), name="vote"),
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 election.forms import (
ApplyRoleResultForm,
CandidateForm,
ElectionCreateForm,
ElectionForm,
ElectionListForm,
RoleForm,
@@ -210,7 +208,7 @@ class CandidatureCreateView(LoginRequiredMixin, CreateView):
class ElectionCreateView(PermissionRequiredMixin, CreateView):
model = Election
form_class = ElectionCreateForm
form_class = ElectionForm
template_name = "core/create.jinja"
permission_required = "election.add_election"
@@ -221,7 +219,7 @@ class ElectionCreateView(PermissionRequiredMixin, CreateView):
class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
model = Role
form_class = RoleForm
template_name = "election/role_form.jinja"
template_name = "core/create.jinja"
@cached_property
def election(self):
@@ -230,17 +228,22 @@ class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
def test_func(self):
if not self.election.is_vote_editable:
return False
user = self.request.user
return user.has_perm("election.add_role") or user.can_edit(self.election)
if self.request.user.has_perm("election.add_role"):
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):
return super().get_form_kwargs() | {"election": self.election}
return super().get_form_kwargs() | {"election_id": self.election.id}
def get_success_url(self, **kwargs):
return reverse("election:detail", kwargs={"election_id": self.election.id})
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"election": self.election}
return reverse(
"election:detail", kwargs={"election_id": self.object.election_id}
)
class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
@@ -264,11 +267,16 @@ class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView
)
return not groups.isdisjoint(self.request.user.all_groups.keys())
def get_initial(self):
return {"election": self.election}
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):
return reverse("election:detail", kwargs={"election_id": self.election.id})
return reverse(
"election:detail", kwargs={"election_id": self.object.election_id}
)
# Update view
@@ -280,6 +288,18 @@ class ElectionUpdateView(CanEditMixin, UpdateView):
template_name = "core/edit.jinja"
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):
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
form_class = RoleForm
template_name = "election/role_form.jinja"
template_name = "core/edit.jinja"
pk_url_kwarg = "role_id"
@cached_property
def election(self):
return self.get_object().election
def dispatch(self, request, *arg, **kwargs):
self.object = self.get_object()
if not self.object.election.is_vote_editable:
raise PermissionDenied
return super().dispatch(request, *arg, **kwargs)
def test_func(self):
if not self.election.is_vote_editable:
return False
user = self.request.user
return user.has_perm("election.change_role") or user.can_edit(self.election)
def remove_fields(self):
self.form.fields.pop("election", None)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"election": self.election}
def get(self, request, *args, **kwargs):
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):
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):
return reverse("election:detail", kwargs={"election_id": self.election.id})
return reverse_lazy(
"election:detail", kwargs={"election_id": self.object.election.id}
)
# Delete Views
@@ -387,41 +425,3 @@ class ElectionListDeleteView(CanEditMixin, DeleteView):
def get_success_url(self, **kwargs):
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}
)
+50 -140
View File
@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-06-04 17:30+0200\n"
"POT-Creation-Date: 2026-05-15 11:46+0200\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -141,7 +141,8 @@ msgstr "vous devez spécifier au moins un utilisateur ou une adresse email"
msgid "Begin date"
msgstr "Date de début"
#: club/forms.py com/forms.py counter/forms.py subscription/forms.py
#: club/forms.py com/forms.py counter/forms.py election/forms.py
#: subscription/forms.py
msgid "End date"
msgstr "Date de fin"
@@ -260,7 +261,7 @@ msgstr ""
"Si ce rôle est inactif, il ne pourra pas être attribué aux gens qui "
"rejoignent le club."
#: club/models.py election/models.py
#: club/models.py
msgid "club role"
msgstr "rôle de club"
@@ -362,8 +363,11 @@ msgid "Unregistered user"
msgstr "Utilisateur non enregistré"
#: club/models.py
msgid "The base url that links with this type must respect"
msgstr "L'url de base que tous les liens de ce type doivent respecter"
#, python-format
msgid "The base url that links with this type must respect (e.g. `%(url)s`)"
msgstr ""
"L'url de base que tous les liens de ce type doivent respecter (par exemple "
"`%(url)s`)"
#: club/models.py counter/models.py
msgid "icon"
@@ -591,7 +595,6 @@ msgstr ""
#: counter/templates/counter/cash_register_summary.jinja
#: counter/templates/counter/invoices_call.jinja
#: counter/templates/counter/product_form.jinja
#: election/templates/election/role_form.jinja
#: forum/templates/forum/reply.jinja
#: subscription/templates/subscription/fragments/creation_form_existing_user.jinja
#: subscription/templates/subscription/fragments/creation_form_new_user.jinja
@@ -975,7 +978,7 @@ msgstr "Prix d'achat"
msgid "Format: 16:9 | Resolution: 1920x1080"
msgstr "Format : 16:9 | Résolution : 1920x1080"
#: com/forms.py subscription/forms.py
#: com/forms.py election/forms.py subscription/forms.py
msgid "Start date"
msgstr "Date de début"
@@ -2200,7 +2203,6 @@ msgstr "Êtes-vous sûr de vouloir supprimer \"%(name)s\" ?"
#: core/templates/core/delete_confirm.jinja
#: core/templates/core/file_delete_confirm.jinja
#: counter/templates/counter/fragments/delete_student_card.jinja
#: counter/templates/counter/fragments/login.jinja
msgid "Confirm"
msgstr "Confirmation"
@@ -3204,18 +3206,6 @@ msgstr "Cet UID est invalide"
msgid "User not found"
msgstr "Utilisateur non trouvé"
#: counter/forms.py
msgid "You are not a barman of this counter."
msgstr "Vous n'êtes pas barman sur ce comptoir."
#: counter/forms.py
msgid "You are already logged in this counter."
msgstr "Vous êtes déjà connecté à ce comptoir."
#: counter/forms.py
msgid "You are already logged in another counter."
msgstr "Vous êtes déjà connecté à un autre comptoir."
#: counter/forms.py
msgid "Regular barmen"
msgstr "Barmen réguliers"
@@ -3418,16 +3408,8 @@ msgid "Buy five, get the sixth free"
msgstr "Pour cinq achetés, le sixième offert"
#: counter/models.py
msgid "clic limit"
msgstr "limite de clic"
#: counter/models.py
msgid ""
"If a limit is set, the product won't be purchasable anymore on the eboutic "
"once the latter is reached."
msgstr ""
"Si une limite est donnée, le produit ne sera plus achetable sur l'eboutic "
"une fois celle-ci atteinte."
msgid "buying groups"
msgstr "groupe d'achat"
#: counter/models.py election/models.py
msgid "archived"
@@ -3494,6 +3476,10 @@ msgstr "Bureau"
msgid "sellers"
msgstr "vendeurs"
#: counter/models.py
msgid "token"
msgstr "jeton"
#: counter/models.py
msgid "regular barman"
msgstr "barman régulier"
@@ -3779,6 +3765,15 @@ msgstr "Confirmer (FIN)"
msgid "Cancel (ANN)"
msgstr "Annuler (ANN)"
#: counter/templates/counter/counter_click.jinja
#: counter/templates/counter/fragments/create_refill.jinja
#: counter/templates/counter/fragments/create_student_card.jinja
#: counter/templates/counter/invoices_call.jinja
#: sas/templates/sas/picture.jinja
#: subscription/templates/subscription/stats.jinja
msgid "Go"
msgstr "Valider"
#: counter/templates/counter/counter_click.jinja
#: eboutic/templates/eboutic/eboutic_checkout.jinja
msgid "Basket: "
@@ -3809,7 +3804,7 @@ msgstr ""
#: counter/templates/counter/counter_click.jinja
msgid "No products available on this counter for this user"
msgstr "Pas de produits disponibles dans ce comptoir pour cet utilisateur"
msgstr "Pas de produits disponnibles dans ce comptoir pour cet utilisateur"
#: counter/templates/counter/counter_list.jinja
msgid "Counter admin list"
@@ -3870,20 +3865,12 @@ msgid "Please, login"
msgstr "Merci de vous identifier"
#: counter/templates/counter/counter_main.jinja
msgid "Barmen:"
msgstr "Barmen :"
msgid "Barman: "
msgstr "Barman : "
#: counter/templates/counter/counter_main.jinja
msgid "On this device"
msgstr "Sur cet appareil"
#: counter/templates/counter/counter_main.jinja
msgid "Elsewhere"
msgstr "Ailleurs"
#: counter/templates/counter/counter_main.jinja
msgid "No barman logged elsewhere"
msgstr "Pas de barman connecté ailleurs"
msgid "login"
msgstr "login"
#: counter/templates/counter/eticket_list.jinja
msgid "Eticket list"
@@ -3935,14 +3922,6 @@ msgstr ""
msgid "New formula"
msgstr "Nouvelle formule"
#: counter/templates/counter/fragments/create_refill.jinja
#: counter/templates/counter/fragments/create_student_card.jinja
#: counter/templates/counter/invoices_call.jinja
#: sas/templates/sas/picture.jinja
#: subscription/templates/subscription/stats.jinja
msgid "Go"
msgstr "Valider"
#: counter/templates/counter/fragments/create_student_card.jinja
msgid "No student card registered."
msgstr "Aucune carte étudiante enregistrée."
@@ -4296,14 +4275,22 @@ msgstr "Montant du chèque"
msgid "Check quantity"
msgstr "Nombre de chèque"
#: counter/views/click.py
msgid "You cannot click users on this counter"
msgstr "Vous ne pouvez pas cliquer des gens sur ce comptoir"
#: counter/views/eticket.py
msgid "people(s)"
msgstr "personne(s)"
#: counter/views/home.py
msgid "Bad credentials"
msgstr "Mauvais identifiants"
#: counter/views/home.py
msgid "User is not barman"
msgstr "L'utilisateur n'est pas barman."
#: counter/views/home.py
msgid "Bad location, someone is already logged in somewhere else"
msgstr "Mauvais comptoir, quelqu'un est déjà connecté ailleurs"
#: counter/views/invoice.py
msgid "Invoice calls status has been updated."
msgstr "Le statut des appels à facture a été mis à jour."
@@ -4475,10 +4462,6 @@ msgstr ""
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
"du vendredi au dimanche."
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Product sold out"
msgstr "Produit épuisé"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "There are no items available for sale"
msgstr "Aucun article n'est disponible à la vente"
@@ -4551,13 +4534,13 @@ msgstr "Vote blanc"
msgid "This role already exists for this election"
msgstr "Ce rôle existe déjà pour cette élection"
#: election/models.py
msgid "clubs"
msgstr "clubs"
#: election/forms.py
msgid "Start candidature"
msgstr "Début des candidatures"
#: election/models.py
msgid "The club(s) this election is held for."
msgstr "Le(s) club(s) pour lequel cette élection est tenue."
#: election/forms.py
msgid "End candidature"
msgstr "Fin des candidatures"
#: election/models.py
msgid "start candidature"
@@ -4595,14 +4578,6 @@ msgstr "élection"
msgid "max choice"
msgstr "nombre de choix maxi"
#: election/models.py
msgid ""
"A club role. Filling this will allow automatic completion of title and "
"description, and automatic assignation after the elections."
msgstr ""
"Un rôle de club. Remplir ce champ permet l'autocomplétion du titre et de la "
"description, et l'attribution automatique des rôles après les élections."
#: election/models.py
msgid "election list"
msgstr "liste électorale"
@@ -4638,14 +4613,6 @@ msgstr "Votes fermés"
msgid "Polls will open "
msgstr "Les votes ouvriront "
#: election/templates/election/election_detail.jinja
msgid " at"
msgstr " à"
#: election/templates/election/election_detail.jinja
msgid "and will close "
msgstr "et fermeront"
#: election/templates/election/election_detail.jinja
#: election/templates/election/election_list.jinja
#: forum/templates/forum/macros.jinja
@@ -4653,8 +4620,8 @@ msgid " at "
msgstr " à "
#: election/templates/election/election_detail.jinja
msgid "Apply election result"
msgstr "Appliquer les résultats de l'élection"
msgid "and will close "
msgstr "et fermeront"
#: election/templates/election/election_detail.jinja
msgid "You already have submitted your vote."
@@ -4697,10 +4664,6 @@ msgstr "Liste des élections"
msgid "Current elections"
msgstr "Élections actuelles"
#: election/templates/election/election_list.jinja
msgid "New election"
msgstr "Nouvelle élection"
#: election/templates/election/election_list.jinja
msgid "Applications open from"
msgstr "Candidatures ouvertes à partir du"
@@ -4713,59 +4676,6 @@ msgstr "au"
msgid "Polls open from"
msgstr "Votes ouverts du"
#: election/templates/election/fragments/apply_result.jinja
msgid "No result to apply"
msgstr "Pas de résultats à appliquer"
#: election/templates/election/fragments/apply_result.jinja
msgid "This may be because no role of this election was linked to a club role."
msgstr ""
"Ceci s'explique peut-être parce qu'aucun poste de cette élection n'était lié "
"à un rôle de club."
#: election/templates/election/fragments/apply_result.jinja
msgid "The results of this election have been applied"
msgstr "Les résultats de cette élection ont été appliqués"
#: election/templates/election/fragments/apply_result.jinja
#, python-format
msgid "%(club)s members"
msgstr "Membres %(club)s"
#: election/templates/election/fragments/apply_result.jinja
msgid "Warning"
msgstr "Attention"
#: election/templates/election/fragments/apply_result.jinja
msgid ""
"Only election roles linked to a club role will be automatically applied."
msgstr ""
"Seuls les postes de cette élection qui sont liés à un rôle de club seront "
"automatiquement appliqués."
#: election/templates/election/fragments/apply_result.jinja
msgid "Don't forget to manually apply the eventual remaining roles afterward."
msgstr ""
"N'oubliez pas après d'attribuer manuellement les éventuels postes restants."
#: election/templates/election/role_form.jinja
msgid "Election role"
msgstr "Rôle d'élection"
#: election/templates/election/role_form.jinja
#, python-format
msgid "Create role for election \"%(election)s\""
msgstr "Création d'un rôle pour l'élection « %(election)s »"
#: election/templates/election/role_form.jinja
#, python-format
msgid "Edit role for election \"%(election)s\""
msgstr "Modification d'un rôle pour l'élection « %(election)s »"
#: election/templates/election/role_form.jinja
msgid "autofill form"
msgstr "compléter le formulaire"
#: election/views.py
msgid "Form is invalid"
msgstr "Formulaire invalide"
+1 -1
View File
@@ -68,7 +68,7 @@ dev = [
"pre-commit>=4.6.0,<5.0.0",
"ruff>=0.15.13,<1.0.0",
"djhtml>=3.0.11,<4.0.0",
"faker>=40.18.0,<41.0.0",
"faker>=40.20.0,<41.0.0",
"rjsmin>=1.2.5,<2.0.0",
]
tests = [
+3 -10
View File
@@ -34,7 +34,6 @@ https://docs.djangoproject.com/en/1.8/ref/settings/
"""
import binascii
import contextlib
import os
import sys
from datetime import timedelta
@@ -42,7 +41,6 @@ from pathlib import Path
import sentry_sdk
from dateutil.relativedelta import relativedelta
from django.utils.deprecation import RemovedInDjango60Warning
from django.utils.translation import gettext_lazy as _
from environs import Env
from sentry_sdk.integrations.django import DjangoIntegration
@@ -93,8 +91,7 @@ ALLOWED_HOSTS = ["*"]
# RemovedInDjango60Warning: It's a transitional setting helpful in early
# adoption of "https" as the new default value of forms.URLField.assume_scheme.
# Remove this after upgrading to Django 6.x
with contextlib.suppress(RemovedInDjango60Warning):
FORMS_URLFIELD_ASSUME_HTTPS = True
FORMS_URLFIELD_ASSUME_HTTPS = True
# Application definition
@@ -141,13 +138,13 @@ MIDDLEWARE = (
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"core.middleware.AuthenticationMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django.middleware.security.SecurityMiddleware",
"core.middleware.AuthenticationMiddleware",
"core.middleware.SignalRequestMiddleware",
"counter.middleware.BarmenMiddleware",
)
ROOT_URLCONF = "sith.urls"
@@ -270,10 +267,6 @@ LOGGING = {
},
},
"loggers": {
"django.db.backends": {
"level": "DEBUG",
"handlers": ["log_to_stdout"],
},
"main": {
"handlers": ["log_to_stdout"],
"level": "INFO",