Compare commits

..

11 Commits

Author SHA1 Message Date
imperosol 3711bb3959 add tests 2026-06-05 00:32:06 +02:00
imperosol 9c89bde9a0 add translations 2026-06-05 00:32:06 +02:00
imperosol 154af9c47a add translations 2026-06-05 00:32:04 +02:00
imperosol ddc70a9d27 automatically apply election results 2026-06-05 00:31:49 +02:00
imperosol 99d8e6e2b8 create multiple elections in populate.py 2026-06-05 00:31:49 +02:00
imperosol 33b3965f82 add translations 2026-06-05 00:31:49 +02:00
imperosol 0f518244ff button to create new elections 2026-06-05 00:31:49 +02:00
imperosol 18cc60d286 add default initial values on election creation 2026-06-05 00:31:49 +02:00
imperosol 9e5cd70105 feat: add ClubRole selection in election Role form 2026-06-05 00:31:49 +02:00
imperosol 783b9c670c feat: link election Role to ClubRole 2026-06-05 00:31:49 +02:00
imperosol 2a96a93087 feat: custom ClubRoleChoiceField for club roles 2026-06-05 00:31:49 +02:00
24 changed files with 1202 additions and 249 deletions
+34
View File
@@ -21,10 +21,13 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
import itertools
from operator import attrgetter
from django import forms from django import forms
from django.db.models import Exists, OuterRef, Q, QuerySet from django.db.models import Exists, OuterRef, Q, QuerySet
from django.db.models.functions import Lower from django.db.models.functions import Lower
from django.forms.models import ModelChoiceField, ModelChoiceIterator
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -46,6 +49,37 @@ from counter.models import Counter, Selling
from counter.schemas import SaleFilterSchema from counter.schemas import SaleFilterSchema
class ClubRoleChoiceIterator(ModelChoiceIterator):
"""Custom `ModelChoiceIterator` for `ClubRoleChoiceField`"""
def __iter__(self):
if self.field.empty_label is not None:
yield "", self.field.empty_label
queryset = self.queryset.select_related("club").order_by("club", "order")
groups = [
(club, [self.choice(role) for role in roles])
for club, roles in itertools.groupby(queryset, key=attrgetter("club"))
]
if len(groups) == 1:
# there is only one club involved, no need to have optgroups
yield from groups[0][1]
else:
# there are multiple clubs, optgroups are necessary to differentiate
# roles having the same name
yield from groups
class ClubRoleChoiceField(ModelChoiceField):
"""Custom `ModelChoiceField` for `[ClubRole][club.models.ClubRole]`.
If only one club is involved, behave like the base `ModelChoiceField`.
If dealing with the roles of multiple clubs, group the roles
into a different `optgroup` for each club.
"""
iterator = ClubRoleChoiceIterator
class ClubLinkForm(forms.ModelForm): class ClubLinkForm(forms.ModelForm):
error_css_class = "error" error_css_class = "error"
required_css_class = "required" required_css_class = "required"
+134 -51
View File
@@ -20,7 +20,7 @@
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
from datetime import date, timedelta from datetime import date, datetime, timedelta
from io import StringIO from io import StringIO
from pathlib import Path from pathlib import Path
from typing import ClassVar, NamedTuple from typing import ClassVar, NamedTuple
@@ -33,7 +33,8 @@ from django.core.management.base import BaseCommand
from django.db import connection from django.db import connection
from django.db.models import Q from django.db.models import Q
from django.utils import timezone from django.utils import timezone
from django.utils.timezone import localdate from django.utils.lorem_ipsum import paragraphs
from django.utils.timezone import localdate, now
from PIL import Image from PIL import Image
from club.models import Club, ClubLink, ClubRole, LinkType, Membership from club.models import Club, ClubLink, ClubRole, LinkType, Membership
@@ -50,7 +51,7 @@ from counter.models import (
ReturnableProduct, ReturnableProduct,
StudentCard, StudentCard,
) )
from election.models import Candidature, Election, ElectionList, Role from election.models import Candidature, Election, ElectionList, Role, Vote
from forum.models import Forum from forum.models import Forum
from pedagogy.models import UE from pedagogy.models import UE
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
@@ -373,54 +374,7 @@ class Command(BaseCommand):
) )
# Create an election # Create an election
el = Election.objects.create( self._create_elections(groups, clubs, skia, sli, krophil)
title="Élection 2017",
description="La roue tourne",
start_candidature="1942-06-12 10:28:45+01",
end_candidature="2042-06-12 10:28:45+01",
start_date="1942-06-12 10:28:45+01",
end_date="7942-06-12 10:28:45+01",
)
el.view_groups.add(groups.public)
el.edit_groups.add(clubs.ae.board_group)
el.candidature_groups.add(groups.subscribers)
el.vote_groups.add(groups.subscribers)
liste = ElectionList.objects.create(title="Candidature Libre", election=el)
listeT = ElectionList.objects.create(title="Troll", election=el)
pres = Role.objects.create(
election=el, title="Président AE", description="Roi de l'AE"
)
resp = Role.objects.create(
election=el, title="Co Respo Info", max_choice=2, description="Ghetto++"
)
Candidature.objects.bulk_create(
[
Candidature(
role=resp,
user=skia,
election_list=liste,
program="Refesons le site AE",
),
Candidature(
role=resp,
user=sli,
election_list=liste,
program="Vasy je deviens mon propre adjoint",
),
Candidature(
role=resp,
user=krophil,
election_list=listeT,
program="Le Pôle Troll !",
),
Candidature(
role=pres,
user=sli,
election_list=listeT,
program="En fait j'aime pas l'info, je voulais faire GMC",
),
]
)
# Forum # Forum
room = Forum.objects.create( room = Forum.objects.create(
@@ -1011,3 +965,132 @@ class Command(BaseCommand):
BanGroup.objects.create(name="Banned from buying alcohol", description="") BanGroup.objects.create(name="Banned from buying alcohol", description="")
BanGroup.objects.create(name="Banned from counters", description="") BanGroup.objects.create(name="Banned from counters", description="")
BanGroup.objects.create(name="Banned to subscribe", description="") BanGroup.objects.create(name="Banned to subscribe", description="")
def _create_elections(
self,
groups: PopulatedGroups,
clubs: PopulatedClubs,
skia: User,
sli: User,
krophil: User,
):
"""Populate elections.
4 elections are created :
- one that has not started yet,
- one on the candidature period
- one on the vote period
- one that is finished
All elections have two lists, are linked to the AE and Troll clubs,
and have one role for each board role of thos two clubs, plus
an additional role linked to no club roles.
The ongoing vote and finished elections have candidates.
The finished election has 10 voters.
"""
def election_factory(title: str, start_candidature: datetime):
return Election(
title=title,
description="",
start_candidature=start_candidature,
end_candidature=start_candidature + timedelta(days=7),
start_date=start_candidature + timedelta(days=7),
end_date=start_candidature + timedelta(days=14),
)
# create the elections
elections = Election.objects.bulk_create(
[
election_factory("Election terminée", now() - timedelta(days=14)),
election_factory("Votes en cours", now() - timedelta(days=7)),
election_factory("Candidatures en cours", now()),
election_factory("Election à venir", now() + timedelta(days=7)),
]
)
finished, ongoing_vote, _ongoing_candidature, _not_started = elections
# set the groups (all elections have the same groups)
groups.public.viewable_elections.set(elections)
clubs.ae.board_group.editable_elections.set(elections)
groups.subscribers.candidate_elections.set(elections)
groups.subscribers.votable_elections.set(elections)
# link elections to clubs (AE and Troll for all elections)
Election.clubs.through.objects.bulk_create(
[
*[Election.clubs.through(club=clubs.ae, election=e) for e in elections],
*[
Election.clubs.through(club=clubs.troll, election=e)
for e in elections
],
]
)
# Create lists (all elections have two lists)
ElectionList.objects.bulk_create(
[
*[ElectionList(title="Candidat libre", election=e) for e in elections],
*[ElectionList(title="Troll", election=e) for e in elections],
]
)
# Create roles.
# Elections have a role for each board club role of AE and Troll,
# +an additional role linked to no club role
club_roles = list(
ClubRole.objects.filter(club__in=[clubs.ae, clubs.troll], is_board=True)
.select_related("club")
.order_by("club_id", "order")
)
Role.objects.bulk_create(
[
*[
Role(election=e, title=f"{r.name} {r.club.name}", club_role=r)
for r in club_roles
for e in elections
],
*[Role(election=e, title="Rôle libre") for e in elections],
]
)
# create candidatures for ongoing_vote and finished elections
candidatures = []
lipsum = "\n\n".join(paragraphs(2))
for election in ongoing_vote, finished:
lists = list(election.election_lists.order_by("id"))
roles = list(election.roles.order_by("order")[:3])
candidatures.extend(
[
Candidature(
role=roles[0], user=skia, election_list=lists[0], program=lipsum
),
Candidature(
role=roles[1], user=sli, election_list=lists[0], program=lipsum
),
Candidature(
role=roles[2], user=krophil, election_list=lists[1], program=""
),
Candidature(
role=roles[2], user=sli, election_list=lists[0], program=lipsum
),
]
)
candidatures = Candidature.objects.bulk_create(candidatures)
skia, sli_vp, krophil, sli_treso = candidatures[4:] # candidates of finished
votes = Vote.objects.bulk_create(
[
*[Vote(role=skia.role) for _ in range(6)],
*[Vote(role=sli_vp.role) for _ in range(8)],
*[Vote(role=krophil.role) for _ in range(9)],
]
)
skia.votes.set(votes[:6])
sli_vp.votes.set(votes[6:14])
krophil.votes.set(votes[14:20])
sli_treso.votes.set(votes[20:23])
finished.voters.set(list(User.objects.all()[:10]))
@@ -15,7 +15,7 @@ class Migration(migrations.Migration):
blank=True, blank=True,
help_text=( help_text=(
"If a limit is set, the product won't be purchasable " "If a limit is set, the product won't be purchasable "
"anymore on the eboutic once the latter is reached." "anymore once the latter is reached."
), ),
null=True, null=True,
verbose_name="clic limit", verbose_name="clic limit",
@@ -1,26 +0,0 @@
# Generated by Django 5.2.14 on 2026-06-02 10:45
import django_countries.fields
import phonenumber_field.modelfields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("counter", "0040_product_clic_limit")]
operations = [
migrations.AlterField(
model_name="billinginfo",
name="country",
field=django_countries.fields.CountryField(
max_length=2, verbose_name="Country"
),
),
migrations.AlterField(
model_name="billinginfo",
name="phone_number",
field=phonenumber_field.modelfields.PhoneNumberField(
max_length=128, region=None, verbose_name="Phone number"
),
),
]
+9 -2
View File
@@ -228,8 +228,15 @@ class BillingInfo(models.Model):
address_2 = models.CharField(_("Address 2"), max_length=50, blank=True, null=True) address_2 = models.CharField(_("Address 2"), max_length=50, blank=True, null=True)
zip_code = models.CharField(_("Zip code"), max_length=16) # code postal zip_code = models.CharField(_("Zip code"), max_length=16) # code postal
city = models.CharField(_("City"), max_length=50) city = models.CharField(_("City"), max_length=50)
country = CountryField(_("Country")) country = CountryField(blank_label=_("Country"))
phone_number = PhoneNumberField(_("Phone number"))
# This table was created during the A22 semester.
# However, later on, CA asked for the phone number to be added to the billing info.
# As the table was already created, this new field had to be nullable,
# even tough it is required by the bank and shouldn't be null.
# If one day there is no null phone number remaining,
# please make the field non-nullable.
phone_number = PhoneNumberField(_("Phone number"), null=True, blank=False)
def __str__(self): def __str__(self):
return f"{self.first_name} {self.last_name}" return f"{self.first_name} {self.last_name}"
+30 -1
View File
@@ -16,6 +16,7 @@ from __future__ import annotations
import hmac import hmac
from datetime import datetime from datetime import datetime
from enum import Enum
from typing import Self from typing import Self
from dict2xml import dict2xml from dict2xml import dict2xml
@@ -39,6 +40,30 @@ from counter.models import (
) )
class BillingInfoState(Enum):
VALID = 1
EMPTY = 2
MISSING_PHONE_NUMBER = 3
@classmethod
def from_model(cls, info: BillingInfo | None) -> BillingInfoState:
if info is None:
return cls.EMPTY
for attr in [
"first_name",
"last_name",
"address_1",
"zip_code",
"city",
"country",
]:
if getattr(info, attr) == "":
return cls.EMPTY
if info.phone_number is None:
return cls.MISSING_PHONE_NUMBER
return cls.VALID
class Basket(models.Model): class Basket(models.Model):
"""Basket is built when the user connects to an eboutic page.""" """Basket is built when the user connects to an eboutic page."""
@@ -137,7 +162,11 @@ class Basket(models.Model):
if self.is_expired: if self.is_expired:
raise ValueError("This method cannot be called on an expired basket.") raise ValueError("This method cannot be called on an expired basket.")
customer = user.customer customer = user.customer
if not hasattr(user.customer, "billing_infos"): if (
not hasattr(user.customer, "billing_infos")
or BillingInfoState.from_model(user.customer.billing_infos)
!= BillingInfoState.VALID
):
raise BillingInfo.DoesNotExist raise BillingInfo.DoesNotExist
cart = { cart = {
"shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}} "shoppingcart": {"total": {"totalQuantity": min(self.items.count(), 99)}}
@@ -24,7 +24,7 @@
x-cloak x-cloak
> >
{% csrf_token %} {% csrf_token %}
{{ form }} {{ form.as_p() }}
<br> <br>
<input <input
type="submit" class="btn btn-blue clickable" type="submit" class="btn btn-blue clickable"
+5 -2
View File
@@ -37,9 +37,12 @@ class TestBillingInfo:
def test_edit_infos(self, client: Client, payload: dict[str, str]): def test_edit_infos(self, client: Client, payload: dict[str, str]):
user = subscriber_user.make() user = subscriber_user.make()
baker.make(BillingInfo, customer=user.customer, phone_number="06 01 02 03 04") baker.make(BillingInfo, customer=user.customer)
client.force_login(user) client.force_login(user)
response = client.post(reverse("eboutic:billing_infos"), payload) response = client.post(
reverse("eboutic:billing_infos"),
payload,
)
user.refresh_from_db() user.refresh_from_db()
infos = BillingInfo.objects.get(customer__user=user) infos = BillingInfo.objects.get(customer__user=user)
assert response.status_code == 302 assert response.status_code == 302
+21 -10
View File
@@ -58,7 +58,7 @@ from counter.models import (
Selling, Selling,
get_eboutic, get_eboutic,
) )
from eboutic.models import Basket, BasketItem, Invoice, InvoiceItem from eboutic.models import Basket, BasketItem, BillingInfoState, Invoice, InvoiceItem
if TYPE_CHECKING: if TYPE_CHECKING:
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
@@ -187,7 +187,7 @@ def payment_result(request, result: str) -> HttpResponse:
class BillingInfoFormFragment( class BillingInfoFormFragment(
LoginRequiredMixin, FragmentMixin, SuccessMessageMixin, UpdateView LoginRequiredMixin, FragmentMixin, SuccessMessageMixin, UpdateView
): ):
"""Update or create billing info""" """Update billing info"""
model = BillingInfo model = BillingInfo
form_class = BillingInfoForm form_class = BillingInfoForm
@@ -218,15 +218,26 @@ class BillingInfoFormFragment(
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) kwargs = super().get_context_data(**kwargs)
kwargs["billing_infos_state"] = BillingInfoState.from_model(self.object)
kwargs["action"] = reverse("eboutic:billing_infos") kwargs["action"] = reverse("eboutic:billing_infos")
if not self.object: match BillingInfoState.from_model(self.object):
messages.warning( case BillingInfoState.EMPTY:
self.request, messages.warning(
_( self.request,
"You must fill your billing infos " _(
"if you want to pay with your credit card" "You must fill your billing infos if you want to pay with your credit card"
), ),
) )
case BillingInfoState.MISSING_PHONE_NUMBER:
messages.warning(
self.request,
_(
"The Crédit Agricole changed its policy related to the billing "
+ "information that must be provided in order to pay with a credit card. "
+ "If you want to pay with your credit card, you must add a phone number "
+ "to the data you already provided.",
),
)
return kwargs return kwargs
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
+138 -35
View File
@@ -1,6 +1,18 @@
from datetime import timedelta
from itertools import groupby, islice
from operator import attrgetter
from django import forms from django import forms
from django.conf import settings
from django.db import transaction
from django.db.models import Count
from django.forms.models import ModelChoiceIterator, ModelChoiceIteratorValue
from django.utils.timezone import localdate, localtime
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from club.forms import ClubRoleChoiceField
from club.models import ClubRole, Membership
from club.widgets.ajax_select import AutoCompleteSelectMultipleClub
from core.models import User from core.models import User
from core.views.forms import SelectDateTime from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import ( from core.views.widgets.ajax_select import (
@@ -79,27 +91,20 @@ class VoteForm(forms.Form):
class RoleForm(forms.ModelForm): class RoleForm(forms.ModelForm):
"""Form for creating a role.""" """Form for creating a role."""
required_css_class = "required"
error_css_class = "error"
class Meta: class Meta:
model = Role model = Role
fields = ["title", "election", "description", "max_choice"] fields = ["club_role", "title", "description", "max_choice"]
widgets = {"election": AutoCompleteSelect} field_classes = {"club_role": ClubRoleChoiceField}
def __init__(self, *args, **kwargs): def __init__(self, *args, election: Election, **kwargs):
election_id = kwargs.pop("election_id", None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if election_id: self.instance.election = election
self.fields["election"].queryset = Election.objects.filter( self.fields["club_role"].queryset = ClubRole.objects.filter(
id=election_id is_board=True, club__in=election.clubs.all()
).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): class ElectionListForm(forms.ModelForm):
@@ -108,21 +113,21 @@ class ElectionListForm(forms.ModelForm):
fields = ("title", "election") fields = ("title", "election")
widgets = {"election": AutoCompleteSelect} widgets = {"election": AutoCompleteSelect}
def __init__(self, *args, **kwargs): def __init__(self, *args, election: Election, **kwargs):
election_id = kwargs.pop("election_id", None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if election_id: self.instance.election = election
self.fields["election"].queryset = Election.objects.filter(
id=election_id
).all()
class ElectionForm(forms.ModelForm): class ElectionForm(forms.ModelForm):
required_css_class = "required"
error_css_class = "error"
class Meta: class Meta:
model = Election model = Election
fields = [ fields = [
"title", "title",
"description", "description",
"clubs",
"archived", "archived",
"start_candidature", "start_candidature",
"end_candidature", "end_candidature",
@@ -134,21 +139,119 @@ class ElectionForm(forms.ModelForm):
"candidature_groups", "candidature_groups",
] ]
widgets = { widgets = {
"clubs": AutoCompleteSelectMultipleClub,
"edit_groups": AutoCompleteSelectMultipleGroup, "edit_groups": AutoCompleteSelectMultipleGroup,
"view_groups": AutoCompleteSelectMultipleGroup, "view_groups": AutoCompleteSelectMultipleGroup,
"vote_groups": AutoCompleteSelectMultipleGroup, "vote_groups": AutoCompleteSelectMultipleGroup,
"candidature_groups": AutoCompleteSelectMultipleGroup, "candidature_groups": AutoCompleteSelectMultipleGroup,
"start_date": SelectDateTime,
"end_date": SelectDateTime,
"start_candidature": SelectDateTime,
"end_candidature": SelectDateTime,
} }
start_date = forms.DateTimeField(
label=_("Start date"), widget=SelectDateTime, required=True class ElectionCreateForm(ElectionForm):
) """ElectionForm, but specifically for creation."""
end_date = forms.DateTimeField(
label=_("End date"), widget=SelectDateTime, required=True def __init__(self, *args, initial: dict | None = None, **kwargs):
) # propose sound default timestamps :
start_candidature = forms.DateTimeField( # start of candidatures at tomorrow 00h01, start of votes a week later.
label=_("Start candidature"), widget=SelectDateTime, required=True start = localtime().replace(hour=0, minute=1, second=0) + timedelta(days=1)
) default_initial = {
end_candidature = forms.DateTimeField( "start_candidature": start,
label=_("End candidature"), widget=SelectDateTime, required=True "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)
@@ -0,0 +1,62 @@
# 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",
),
),
]
+46 -5
View File
@@ -5,6 +5,7 @@ from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from ordered_model.models import OrderedModel from ordered_model.models import OrderedModel
from club.models import Club, ClubRole, Membership
from core.models import Group, User from core.models import Group, User
@@ -13,6 +14,12 @@ class Election(models.Model):
title = models.CharField(_("title"), max_length=255) title = models.CharField(_("title"), max_length=255)
description = models.TextField(_("description"), null=True, blank=True) description = models.TextField(_("description"), null=True, blank=True)
clubs = models.ManyToManyField(
Club,
related_name="elections",
verbose_name=_("clubs"),
help_text=_("The club(s) this election is held for."),
)
start_candidature = models.DateTimeField(_("start candidature"), blank=False) start_candidature = models.DateTimeField(_("start candidature"), blank=False)
end_candidature = models.DateTimeField(_("end candidature"), blank=False) end_candidature = models.DateTimeField(_("end candidature"), blank=False)
start_date = models.DateTimeField(_("start date"), blank=False) start_date = models.DateTimeField(_("start date"), blank=False)
@@ -94,9 +101,18 @@ class Election(models.Model):
results[role.title] = role.results(total_vote) results[role.title] = role.results(total_vote)
return results return results
@cached_property
def results_applied(self) -> bool:
"""Returns True if one or more roles of this election have been applied."""
return Membership.objects.filter(
role__election_roles__election=self,
end_date=None,
start_date__gte=self.end_date,
).exists()
class Role(OrderedModel): class Role(OrderedModel):
"""This class allows to create a new role avaliable for a candidature.""" """This class allows to create a new role available for a candidature."""
election = models.ForeignKey( election = models.ForeignKey(
Election, Election,
@@ -105,17 +121,42 @@ class Role(OrderedModel):
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
title = models.CharField(_("title"), max_length=255) title = models.CharField(_("title"), max_length=255)
description = models.TextField(_("description"), null=True, blank=True) description = models.TextField(_("description"), default="", blank=True)
max_choice = models.IntegerField(_("max choice"), default=1) 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",
)
]
def __str__(self): def __str__(self):
return f"{self.title} - {self.election.title}" return f"{self.title} - {self.election.title}"
def results(self, total_vote: int) -> dict[str, dict[str, int | float]]: def results(self, total_vote: int) -> dict[str, dict[str, int | float]]:
if total_vote == 0: if total_vote == 0:
candidates = self.candidatures.values_list("user__username") candidates = self.candidatures.values_list("user__username", flat=True)
return { return {
key: {"vote": 0, "percent": 0} for key in ["blank_votes", *candidates] key: {"vote": 0, "percent": 0} for key in ["blank vote", *candidates]
} }
total_vote *= self.max_choice total_vote *= self.max_choice
results = {"total vote": total_vote} results = {"total vote": total_vote}
@@ -29,13 +29,25 @@
{% trans %}Polls closed {% endtrans %} {% trans %}Polls closed {% endtrans %}
{%- else %} {%- else %}
{% trans %}Polls will open {% endtrans %} {% trans %}Polls will open {% endtrans %}
<time datetime="{{ election.start_date }}">{{ election.start_date|localtime|date(DATETIME_FORMAT)}}</time> <time datetime="{{ election.start_date }}">{{ election.start_date|localtime|date(DATETIME_FORMAT) }}</time>
{% trans %} at {% endtrans %}<time>{{ election.start_date|localtime|time(DATETIME_FORMAT)}}</time> {% trans %}at{% endtrans %}
<time>{{ election.start_date|localtime|time(DATETIME_FORMAT) }}</time>
{% trans %}and will close {% endtrans %} {% trans %}and will close {% endtrans %}
{%- endif %} {%- endif %}
<time datetime="{{ election.end_date }}">{{ election.end_date|localtime|date(DATETIME_FORMAT)}}</time> <time datetime="{{ election.end_date }}">{{ election.end_date|localtime|date(DATETIME_FORMAT) }}</time>
{% trans %} at {% endtrans %}<time>{{ election.end_date|localtime|time(DATETIME_FORMAT)}}</time> {% trans %}at{% endtrans %}
<time>{{ election.end_date|localtime|time(DATETIME_FORMAT) }}</time>
</p> </p>
{%- if election.is_vote_finished and user.can_edit(election) %}
<details class="accordion" name="apply-result">
<summary>{% trans %}Apply election result{% endtrans %}</summary>
<div
class="accordion-content aria-busy-grow"
hx-get="{{ url("election:apply_result", election_id=election.id) }}"
hx-trigger="toggle from:closest details once"
></div>
</details>
{% endif %}
{%- if user_has_voted %} {%- if user_has_voted %}
<p class="election__elector-infos"> <p class="election__elector-infos">
{%- if election.is_vote_active %} {%- if election.is_vote_active %}
@@ -47,17 +59,27 @@
{%- endif %} {%- endif %}
</section> </section>
<section class="election_vote"> <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 %} {% csrf_token %}
<table class="election_table"> <table class="election_table">
<thead class="lists"> <thead class="lists">
<tr> <tr>
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">{% 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 %} {%- for election_list in election_lists %}
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%"> <th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">
<span>{{ election_list.title }}</span> <span>{{ election_list.title }}</span>
{% if user.can_edit(election_list) and election.is_vote_editable -%} {% if user.can_edit(election_list) and election.is_vote_editable -%}
<a href="{{ url('election:delete_list', list_id=election_list.id) }}"><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 %} {% endif %}
</th> </th>
{%- endfor %} {%- endfor %}
@@ -103,22 +125,45 @@
<button disabled><i class="fa fa-arrow-down"></i></button> <button disabled><i class="fa fa-arrow-down"></i></button>
<button disabled><i class="fa fa-caret-down"></i></button> <button disabled><i class="fa fa-caret-down"></i></button>
{%- else -%} {%- else -%}
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=bottom');"><i class="fa fa-arrow-down"></i></button> <button
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=down');"><i class="fa fa-caret-down"></i></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 -%} {%- endif -%}
{%- if loop.first -%} {%- if loop.first -%}
<button disabled><i class="fa fa-caret-up"></i></button> <button disabled><i class="fa fa-caret-up"></i></button>
<button disabled><i class="fa fa-arrow-up"></i></button> <button disabled><i class="fa fa-arrow-up"></i></button>
{%- else -%} {%- else -%}
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=up');"><i class="fa fa-caret-up"></i></button> <button
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=top');"><i class="fa fa-arrow-up"></i></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 -%} {%- endif -%}
</div> </div>
{%- endif -%} {%- endif -%}
</td> </td>
</tr> </tr>
<tr class="role_candidates"> <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 %} {%- if role.max_choice == 1 and show_vote_buttons %}
<div class="radio-btn"> <div class="radio-btn">
{% set input_id = "blank_vote_" + role.id|string %} {% set input_id = "blank_vote_" + role.id|string %}
@@ -131,26 +176,46 @@
{%- if election.is_vote_finished %} {%- if election.is_vote_finished %}
{%- set results = election_results[role.title]['blank vote'] %} {%- set results = election_results[role.title]['blank vote'] %}
<div class="election__results"> <div class="election__results">
<strong>{{ results.vote }} {% trans %}votes{% endtrans %} ({{ "%.2f" % results.percent }} %)</strong> <strong>
{{ results.vote }} {% trans %}votes{% endtrans %} ({{ "%.2f" % results.percent }} %)
</strong>
</div> </div>
{%- endif %} {%- endif %}
</td> </td>
{%- for election_list in election_lists %} {%- for election_list in election_lists %}
<td 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"> <ul class="candidates">
{%- for candidature in election_list.candidatures.select_related("user", "user__profile_pict").filter(role=role) %} {%- for candidature in election_list.candidatures.select_related("user", "user__profile_pict").filter(role=role) %}
<li class="candidate"> <li class="candidate">
{%- if show_vote_buttons %} {%- if show_vote_buttons %}
{% set input_id = "candidature_" + candidature.id|string %} {% set input_id = "candidature_" + candidature.id|string %}
<input 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 }}"> <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 }}"
>
<label for="{{ input_id }}"> <label for="{{ input_id }}">
{%- endif %} {%- endif %}
<figure> <figure>
{%- if user.can_view(candidature.user) %} {%- if user.can_view(candidature.user) %}
{% if candidature.user.profile_pict %} {% if candidature.user.profile_pict %}
<img 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 %} {% 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 %}
{%- endif %} {%- endif %}
<figcaption class="candidate__details"> <figcaption class="candidate__details">
@@ -164,8 +229,12 @@
{%- if user.can_edit(candidature) -%} {%- if user.can_edit(candidature) -%}
{%- if election.is_vote_editable -%} {%- if election.is_vote_editable -%}
<div class="edit_btns"> <div class="edit_btns">
<a href="{{url('election:update_candidate', candidature_id=candidature.id)}}"><i class="fa-regular fa-pen-to-square edit-action"></i></a> <a href="{{ url('election:update_candidate', candidature_id=candidature.id) }}">
<a href="{{url('election:delete_candidate', candidature_id=candidature.id)}}"><i class="fa-regular fa-trash-can delete-action"></i></a> <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> </div>
{%- endif -%} {%- endif -%}
{%- endif -%} {%- endif -%}
@@ -7,7 +7,7 @@
{% block head %} {% block head %}
{{ super() -}} {{ super() -}}
<style type="text/css"> <style>
small { small {
font-size: smaller; font-size: smaller;
} }
@@ -20,6 +20,9 @@
{% block content %} {% block content %}
<h3>{% trans %}Current elections{% endtrans %}</h3> <h3>{% trans %}Current elections{% endtrans %}</h3>
<a class="btn btn-blue" href="{{ url("election:create") }}">
<i class="fa fa-plus"></i>{% trans %}New election{% endtrans %}
</a>
{%- for election in object_list %} {%- for election in object_list %}
<hr> <hr>
<section> <section>
@@ -32,7 +35,7 @@
{% trans %} at {% endtrans %}<time>{{ election.start_candidature|localtime|time(DATETIME_FORMAT) }}</time> {% trans %} at {% endtrans %}<time>{{ election.start_candidature|localtime|time(DATETIME_FORMAT) }}</time>
{% trans %}to{% endtrans %} {% trans %}to{% endtrans %}
<time datetime="{{ election.end_candidature }}">{{ election.end_candidature|localtime|date(DATETIME_FORMAT) }}</time> <time datetime="{{ election.end_candidature }}">{{ election.end_candidature|localtime|date(DATETIME_FORMAT) }}</time>
{% trans %} at {% endtrans %}<time>{{ election.end_candidature|time(DATETIME_FORMAT) }}</time> {% trans %} at {% endtrans %}<time>{{ election.end_candidature|localtime|time(DATETIME_FORMAT) }}</time>
</p> </p>
<p> <p>
{% trans %}Polls open from{% endtrans %} {% trans %}Polls open from{% endtrans %}
@@ -0,0 +1,51 @@
<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>
@@ -0,0 +1,53 @@
{% 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 %}
View File
+191
View File
@@ -0,0 +1,191 @@
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
@@ -2,13 +2,15 @@ from datetime import timedelta
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import localtime, now
from model_bakery import baker from model_bakery import baker
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
from pytest_django.asserts import assertRedirects from pytest_django.asserts import assertRedirects
from club.models import Club
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import Group, User from core.models import Group, User
from election.models import Candidature, Election, ElectionList, Role, Vote from election.models import Candidature, Election, ElectionList, Role, Vote
@@ -38,7 +40,6 @@ class TestElectionDetail(TestElection):
reverse("election:detail", args=str(self.election.id)) reverse("election:detail", args=str(self.election.id))
) )
assert response.status_code == 200 assert response.status_code == 200
assert "La roue tourne" in str(response.content)
class TestElectionUpdateView(TestElection): class TestElectionUpdateView(TestElection):
@@ -213,3 +214,42 @@ def test_election_results():
"total vote": 100, "total vote": 100,
}, },
} }
@pytest.mark.django_db
def test_create_election(client: Client):
user_group = baker.make(Group)
user = baker.make(
User,
user_permissions=[Permission.objects.get(codename="add_election")],
groups=[user_group],
)
club = baker.make(Club)
client.force_login(user)
url = reverse("election:create")
res = client.get(url)
assert res.status_code == 200
start = localtime().replace(hour=0, minute=1, second=0) + timedelta(days=1)
res = client.post(
url,
data={
"title": "foo",
"clubs": [club.id],
"view_groups": [user_group.id],
"start_candidature": start,
"end_candidature": start + timedelta(days=7, minutes=-2),
"start_date": start + timedelta(days=7),
"end_date": start + timedelta(days=14, minutes=-2),
},
)
election = Election.objects.last()
assertRedirects(
res, reverse("election:detail", kwargs={"election_id": election.id})
)
assert election.title == "foo"
assert list(election.clubs.all()) == [club]
assert list(election.election_lists.values_list("title", flat=True)) == [
"Candidat⸱e libre"
]
+110
View File
@@ -0,0 +1,110 @@
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,6 +1,7 @@
from django.urls import path from django.urls import path
from election.views import ( from election.views import (
ApplyResultFragment,
CandidatureCreateView, CandidatureCreateView,
CandidatureDeleteView, CandidatureDeleteView,
CandidatureUpdateView, CandidatureUpdateView,
@@ -56,4 +57,9 @@ urlpatterns = [
), ),
path("<int:election_id>/vote/", VoteFormView.as_view(), name="vote"), path("<int:election_id>/vote/", VoteFormView.as_view(), name="vote"),
path("<int:election_id>/detail/", ElectionDetailView.as_view(), name="detail"), path("<int:election_id>/detail/", ElectionDetailView.as_view(), name="detail"),
path(
"fragment/<int:election_id>/apply/",
ApplyResultFragment.as_view(),
name="apply_result",
),
] ]
+65 -65
View File
@@ -18,7 +18,9 @@ from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateVi
from core.auth.mixins import CanEditMixin, CanViewMixin from core.auth.mixins import CanEditMixin, CanViewMixin
from election.forms import ( from election.forms import (
ApplyRoleResultForm,
CandidateForm, CandidateForm,
ElectionCreateForm,
ElectionForm, ElectionForm,
ElectionListForm, ElectionListForm,
RoleForm, RoleForm,
@@ -208,7 +210,7 @@ class CandidatureCreateView(LoginRequiredMixin, CreateView):
class ElectionCreateView(PermissionRequiredMixin, CreateView): class ElectionCreateView(PermissionRequiredMixin, CreateView):
model = Election model = Election
form_class = ElectionForm form_class = ElectionCreateForm
template_name = "core/create.jinja" template_name = "core/create.jinja"
permission_required = "election.add_election" permission_required = "election.add_election"
@@ -219,7 +221,7 @@ class ElectionCreateView(PermissionRequiredMixin, CreateView):
class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
model = Role model = Role
form_class = RoleForm form_class = RoleForm
template_name = "core/create.jinja" template_name = "election/role_form.jinja"
@cached_property @cached_property
def election(self): def election(self):
@@ -228,22 +230,17 @@ class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
def test_func(self): def test_func(self):
if not self.election.is_vote_editable: if not self.election.is_vote_editable:
return False return False
if self.request.user.has_perm("election.add_role"): user = self.request.user
return True return user.has_perm("election.add_role") or user.can_edit(self.election)
return self.election.edit_groups.filter(
id__in=self.request.user.all_groups
).exists()
def get_initial(self):
return {"election": self.election}
def get_form_kwargs(self): def get_form_kwargs(self):
return super().get_form_kwargs() | {"election_id": self.election.id} return super().get_form_kwargs() | {"election": self.election}
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse( return reverse("election:detail", kwargs={"election_id": self.election.id})
"election:detail", kwargs={"election_id": self.object.election_id}
) def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"election": self.election}
class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView): class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
@@ -267,16 +264,11 @@ class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView
) )
return not groups.isdisjoint(self.request.user.all_groups.keys()) return not groups.isdisjoint(self.request.user.all_groups.keys())
def get_initial(self):
return {"election": self.election}
def get_form_kwargs(self): def get_form_kwargs(self):
return super().get_form_kwargs() | {"election_id": self.election.id} return super().get_form_kwargs() | {"election": self.election}
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse( return reverse("election:detail", kwargs={"election_id": self.election.id})
"election:detail", kwargs={"election_id": self.object.election_id}
)
# Update view # Update view
@@ -288,18 +280,6 @@ class ElectionUpdateView(CanEditMixin, UpdateView):
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
pk_url_kwarg = "election_id" pk_url_kwarg = "election_id"
def get_initial(self):
return {
"start_date": self.object.start_date.strftime("%Y-%m-%d %H:%M:%S"),
"end_date": self.object.end_date.strftime("%Y-%m-%d %H:%M:%S"),
"start_candidature": self.object.start_candidature.strftime(
"%Y-%m-%d %H:%M:%S"
),
"end_candidature": self.object.end_candidature.strftime(
"%Y-%m-%d %H:%M:%S"
),
}
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse_lazy("election:detail", kwargs={"election_id": self.object.id}) return reverse_lazy("election:detail", kwargs={"election_id": self.object.id})
@@ -324,48 +304,30 @@ class CandidatureUpdateView(LoginRequiredMixin, CanEditMixin, UpdateView):
) )
class RoleUpdateView(CanEditMixin, UpdateView): class RoleUpdateView(UserPassesTestMixin, UpdateView):
model = Role model = Role
form_class = RoleForm form_class = RoleForm
template_name = "core/edit.jinja" template_name = "election/role_form.jinja"
pk_url_kwarg = "role_id" pk_url_kwarg = "role_id"
def dispatch(self, request, *arg, **kwargs): @cached_property
self.object = self.get_object() def election(self):
if not self.object.election.is_vote_editable: return self.get_object().election
raise PermissionDenied
return super().dispatch(request, *arg, **kwargs)
def remove_fields(self): def test_func(self):
self.form.fields.pop("election", None) 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 get(self, request, *args, **kwargs): def get_context_data(self, **kwargs):
self.object = self.get_object() return super().get_context_data(**kwargs) | {"election": self.election}
self.form = self.get_form()
self.remove_fields()
return self.render_to_response(self.get_context_data(form=self.form))
def post(self, request, *args, **kwargs):
self.object = self.get_object()
self.form = self.get_form()
self.remove_fields()
if (
request.user.is_authenticated
and request.user.can_edit(self.object)
and self.form.is_valid()
):
return super().form_valid(self.form)
return self.form_invalid(self.form)
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() return super().get_form_kwargs() | {"election": self.election}
kwargs["election_id"] = self.object.election.id
return kwargs
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse_lazy( return reverse("election:detail", kwargs={"election_id": self.election.id})
"election:detail", kwargs={"election_id": self.object.election.id}
)
# Delete Views # Delete Views
@@ -425,3 +387,41 @@ class ElectionListDeleteView(CanEditMixin, DeleteView):
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse("election:detail", kwargs={"election_id": self.election.id}) return reverse("election:detail", kwargs={"election_id": self.election.id})
class ApplyResultFragment(LoginRequiredMixin, UserPassesTestMixin, FormView):
template_name = "election/fragments/apply_result.jinja"
form_class = ApplyRoleResultForm
@cached_property
def election(self):
return get_object_or_404(Election, pk=self.kwargs["election_id"])
def test_func(self):
if not self.election.is_vote_finished:
return False
if self.request.user.has_perm("club.add_membership"):
return True
return self.election.edit_groups.filter(
id__in=self.request.user.all_groups
).exists()
def post(self, request, *args, **kwargs):
if self.election.results_applied:
raise PermissionDenied
return super().post(request, *args, **kwargs)
def get_form_kwargs(self):
return super().get_form_kwargs() | {"election": self.election}
def form_valid(self, form: ApplyRoleResultForm):
form.save()
return super().form_valid(form)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"clubs": self.election.clubs.all()}
def get_success_url(self, **kwargs):
return reverse(
"election:apply_result", kwargs={"election_id": self.election.id}
)
+106 -22
View File
@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-06-02 10:53+0200\n" "POT-Creation-Date: 2026-06-04 17:30+0200\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -141,8 +141,7 @@ msgstr "vous devez spécifier au moins un utilisateur ou une adresse email"
msgid "Begin date" msgid "Begin date"
msgstr "Date de début" msgstr "Date de début"
#: club/forms.py com/forms.py counter/forms.py election/forms.py #: club/forms.py com/forms.py counter/forms.py subscription/forms.py
#: subscription/forms.py
msgid "End date" msgid "End date"
msgstr "Date de fin" msgstr "Date de fin"
@@ -261,7 +260,7 @@ msgstr ""
"Si ce rôle est inactif, il ne pourra pas être attribué aux gens qui " "Si ce rôle est inactif, il ne pourra pas être attribué aux gens qui "
"rejoignent le club." "rejoignent le club."
#: club/models.py #: club/models.py election/models.py
msgid "club role" msgid "club role"
msgstr "rôle de club" msgstr "rôle de club"
@@ -592,6 +591,7 @@ msgstr ""
#: counter/templates/counter/cash_register_summary.jinja #: counter/templates/counter/cash_register_summary.jinja
#: counter/templates/counter/invoices_call.jinja #: counter/templates/counter/invoices_call.jinja
#: counter/templates/counter/product_form.jinja #: counter/templates/counter/product_form.jinja
#: election/templates/election/role_form.jinja
#: forum/templates/forum/reply.jinja #: forum/templates/forum/reply.jinja
#: subscription/templates/subscription/fragments/creation_form_existing_user.jinja #: subscription/templates/subscription/fragments/creation_form_existing_user.jinja
#: subscription/templates/subscription/fragments/creation_form_new_user.jinja #: subscription/templates/subscription/fragments/creation_form_new_user.jinja
@@ -975,7 +975,7 @@ msgstr "Prix d'achat"
msgid "Format: 16:9 | Resolution: 1920x1080" msgid "Format: 16:9 | Resolution: 1920x1080"
msgstr "Format : 16:9 | Résolution : 1920x1080" msgstr "Format : 16:9 | Résolution : 1920x1080"
#: com/forms.py election/forms.py subscription/forms.py #: com/forms.py subscription/forms.py
msgid "Start date" msgid "Start date"
msgstr "Date de début" msgstr "Date de début"
@@ -3779,15 +3779,6 @@ msgstr "Confirmer (FIN)"
msgid "Cancel (ANN)" msgid "Cancel (ANN)"
msgstr "Annuler (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 #: counter/templates/counter/counter_click.jinja
#: eboutic/templates/eboutic/eboutic_checkout.jinja #: eboutic/templates/eboutic/eboutic_checkout.jinja
msgid "Basket: " msgid "Basket: "
@@ -3944,6 +3935,14 @@ msgstr ""
msgid "New formula" msgid "New formula"
msgstr "Nouvelle formule" 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 #: counter/templates/counter/fragments/create_student_card.jinja
msgid "No student card registered." msgid "No student card registered."
msgstr "Aucune carte étudiante enregistrée." msgstr "Aucune carte étudiante enregistrée."
@@ -4511,6 +4510,18 @@ msgstr ""
"Vous devez renseigner vos coordonnées de facturation si vous voulez payer " "Vous devez renseigner vos coordonnées de facturation si vous voulez payer "
"par carte bancaire" "par carte bancaire"
#: eboutic/views.py
msgid ""
"The Crédit Agricole changed its policy related to the billing information "
"that must be provided in order to pay with a credit card. If you want to pay "
"with your credit card, you must add a phone number to the data you already "
"provided."
msgstr ""
"Le Crédit Agricole a changé sa politique relative aux informations à "
"fournir pour effectuer un paiement par carte bancaire. De ce fait, si vous "
"souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux "
"données que vous aviez déjà fourni."
#: eboutic/views.py #: eboutic/views.py
msgid "Basket expired" msgid "Basket expired"
msgstr "Panier expiré" msgstr "Panier expiré"
@@ -4540,13 +4551,13 @@ msgstr "Vote blanc"
msgid "This role already exists for this election" msgid "This role already exists for this election"
msgstr "Ce rôle existe déjà pour cette élection" msgstr "Ce rôle existe déjà pour cette élection"
#: election/forms.py #: election/models.py
msgid "Start candidature" msgid "clubs"
msgstr "Début des candidatures" msgstr "clubs"
#: election/forms.py #: election/models.py
msgid "End candidature" msgid "The club(s) this election is held for."
msgstr "Fin des candidatures" msgstr "Le(s) club(s) pour lequel cette élection est tenue."
#: election/models.py #: election/models.py
msgid "start candidature" msgid "start candidature"
@@ -4584,6 +4595,14 @@ msgstr "élection"
msgid "max choice" msgid "max choice"
msgstr "nombre de choix maxi" 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 #: election/models.py
msgid "election list" msgid "election list"
msgstr "liste électorale" msgstr "liste électorale"
@@ -4619,6 +4638,14 @@ msgstr "Votes fermés"
msgid "Polls will open " msgid "Polls will open "
msgstr "Les votes ouvriront " 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_detail.jinja
#: election/templates/election/election_list.jinja #: election/templates/election/election_list.jinja
#: forum/templates/forum/macros.jinja #: forum/templates/forum/macros.jinja
@@ -4626,8 +4653,8 @@ msgid " at "
msgstr " à " msgstr " à "
#: election/templates/election/election_detail.jinja #: election/templates/election/election_detail.jinja
msgid "and will close " msgid "Apply election result"
msgstr "et fermeront" msgstr "Appliquer les résultats de l'élection"
#: election/templates/election/election_detail.jinja #: election/templates/election/election_detail.jinja
msgid "You already have submitted your vote." msgid "You already have submitted your vote."
@@ -4670,6 +4697,10 @@ msgstr "Liste des élections"
msgid "Current elections" msgid "Current elections"
msgstr "Élections actuelles" msgstr "Élections actuelles"
#: election/templates/election/election_list.jinja
msgid "New election"
msgstr "Nouvelle élection"
#: election/templates/election/election_list.jinja #: election/templates/election/election_list.jinja
msgid "Applications open from" msgid "Applications open from"
msgstr "Candidatures ouvertes à partir du" msgstr "Candidatures ouvertes à partir du"
@@ -4682,6 +4713,59 @@ msgstr "au"
msgid "Polls open from" msgid "Polls open from"
msgstr "Votes ouverts du" 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 #: election/views.py
msgid "Form is invalid" msgid "Form is invalid"
msgstr "Formulaire invalide" msgstr "Formulaire invalide"
+4 -5
View File
@@ -115,7 +115,6 @@ INSTALLED_APPS = (
"django_jinja", "django_jinja",
"ninja_extra", "ninja_extra",
"haystack", "haystack",
"django_countries",
"django_celery_results", "django_celery_results",
"django_celery_beat", "django_celery_beat",
"captcha", "captcha",
@@ -271,6 +270,10 @@ LOGGING = {
}, },
}, },
"loggers": { "loggers": {
"django.db.backends": {
"level": "DEBUG",
"handlers": ["log_to_stdout"],
},
"main": { "main": {
"handlers": ["log_to_stdout"], "handlers": ["log_to_stdout"],
"level": "INFO", "level": "INFO",
@@ -295,11 +298,7 @@ USE_TZ = True
LOCALE_PATHS = [BASE_DIR / "locale"] LOCALE_PATHS = [BASE_DIR / "locale"]
# for PhoneNumberField
PHONENUMBER_DEFAULT_REGION = "FR" PHONENUMBER_DEFAULT_REGION = "FR"
# for CountryField
COUNTRIES_FIRST = ["FR", "CH", "DE"]
COUNTRIES_FIRST_BREAK = "───────────"
# Medias # Medias
MEDIA_URL = "/data/" MEDIA_URL = "/data/"