diff --git a/core/static/bundled/alpine-index.js b/core/static/bundled/alpine-index.js index 211600a5..4e04e7dd 100644 --- a/core/static/bundled/alpine-index.js +++ b/core/static/bundled/alpine-index.js @@ -1,7 +1,8 @@ +import { limitedChoices } from "#core:alpine/limited-choices"; import sort from "@alpinejs/sort"; import Alpine from "alpinejs"; -Alpine.plugin(sort); +Alpine.plugin([sort, limitedChoices]); window.Alpine = Alpine; window.addEventListener("DOMContentLoaded", () => { diff --git a/core/static/bundled/alpine/limited-choices.ts b/core/static/bundled/alpine/limited-choices.ts new file mode 100644 index 00000000..211441d0 --- /dev/null +++ b/core/static/bundled/alpine/limited-choices.ts @@ -0,0 +1,69 @@ +import type { Alpine as AlpineType } from "alpinejs"; + +export function limitedChoices(Alpine: AlpineType) { + /** + * Directive to limit the number of elements + * that can be selected in a group of checkboxes. + * + * When the max numbers of selectable elements is reached, + * new elements will still be inserted, but oldest ones will be deselected. + * For example, if checkboxes A, B and C have been selected and the max + * number of selections is 3, then selecting D will result in having + * B, C and D selected. + * + * # Example in template + * ```html + *
+ * + * + * + * + * + *
+ * ``` + */ + Alpine.directive( + "limited-choices", + (el, { expression }, { evaluateLater, effect }) => { + const getMaxChoices = evaluateLater(expression); + let maxChoices: number; + const inputs: HTMLInputElement[] = Array.from( + el.querySelectorAll("input[type='checkbox']"), + ); + const checked = [] as HTMLInputElement[]; + + const manageDequeue = () => { + if (checked.length <= maxChoices) { + // There isn't too many checkboxes selected. Nothing to do + return; + } + const popped = checked.splice(0, checked.length - maxChoices); + for (const p of popped) { + p.checked = false; + } + }; + + for (const input of inputs) { + input.addEventListener("change", (_e) => { + if (input.checked) { + checked.push(input); + } else { + checked.splice(checked.indexOf(input), 1); + } + manageDequeue(); + }); + } + effect(() => { + getMaxChoices((value: string) => { + const previousValue = maxChoices; + maxChoices = Number.parseInt(value); + if (maxChoices < previousValue) { + // The maximum number of selectable items has been lowered. + // Some currently selected elements may need to be removed + manageDequeue(); + } + }); + }); + }, + ); +} diff --git a/election/forms.py b/election/forms.py new file mode 100644 index 00000000..8a4f7601 --- /dev/null +++ b/election/forms.py @@ -0,0 +1,157 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + +from core.models import User +from core.views.forms import SelectDateTime +from core.views.widgets.ajax_select import ( + AutoCompleteSelect, + AutoCompleteSelectMultipleGroup, + AutoCompleteSelectUser, +) +from core.views.widgets.markdown import MarkdownInput +from election.models import Candidature, Election, ElectionList, Role + + +class LimitedCheckboxField(forms.ModelMultipleChoiceField): + """A `ModelMultipleChoiceField`, with a max limit of selectable inputs.""" + + def __init__(self, queryset, max_choice, **kwargs): + self.max_choice = max_choice + super().__init__(queryset, **kwargs) + + def clean(self, value): + qs = super().clean(value) + self.validate(qs) + return qs + + def validate(self, qs): + if qs.count() > self.max_choice: + raise forms.ValidationError( + _("You have selected too much candidates."), code="invalid" + ) + + +class CandidateForm(forms.ModelForm): + """Form to candidate.""" + + required_css_class = "required" + + class Meta: + model = Candidature + fields = ["user", "role", "program", "election_list"] + labels = { + "user": _("User to candidate"), + } + widgets = { + "program": MarkdownInput, + "user": AutoCompleteSelectUser, + "role": AutoCompleteSelect, + "election_list": AutoCompleteSelect, + } + + def __init__( + self, *args, election: Election | None, can_edit: bool = False, **kwargs + ): + super().__init__(*args, **kwargs) + self.fields["role"].queryset = election.roles.select_related("election") + self.fields["election_list"].queryset = election.election_lists.all() + if not can_edit: + self.fields["user"].widget = forms.HiddenInput() + + +class VoteForm(forms.Form): + def __init__(self, election: Election, user: User, *args, **kwargs): + super().__init__(*args, **kwargs) + if election.can_vote(user): + return + for role in election.roles.all(): + cand = role.candidatures + if role.max_choice > 1: + self.fields[role.title] = LimitedCheckboxField( + cand, role.max_choice, required=False + ) + else: + self.fields[role.title] = forms.ModelChoiceField( + cand, + required=False, + widget=forms.RadioSelect(), + empty_label=_("Blank vote"), + ) + + +class RoleForm(forms.ModelForm): + """Form for creating a role.""" + + class Meta: + model = Role + fields = ["title", "election", "description", "max_choice"] + widgets = {"election": AutoCompleteSelect} + + def __init__(self, *args, **kwargs): + election_id = kwargs.pop("election_id", None) + super().__init__(*args, **kwargs) + 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): + class Meta: + model = ElectionList + fields = ("title", "election") + widgets = {"election": AutoCompleteSelect} + + def __init__(self, *args, **kwargs): + election_id = kwargs.pop("election_id", None) + super().__init__(*args, **kwargs) + if election_id: + self.fields["election"].queryset = Election.objects.filter( + id=election_id + ).all() + + +class ElectionForm(forms.ModelForm): + class Meta: + model = Election + fields = [ + "title", + "description", + "archived", + "start_candidature", + "end_candidature", + "start_date", + "end_date", + "edit_groups", + "view_groups", + "vote_groups", + "candidature_groups", + ] + widgets = { + "edit_groups": AutoCompleteSelectMultipleGroup, + "view_groups": AutoCompleteSelectMultipleGroup, + "vote_groups": AutoCompleteSelectMultipleGroup, + "candidature_groups": AutoCompleteSelectMultipleGroup, + } + + 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 + ) diff --git a/election/migrations/0005_alter_candidature_program_alter_candidature_user.py b/election/migrations/0005_alter_candidature_program_alter_candidature_user.py new file mode 100644 index 00000000..92930b2d --- /dev/null +++ b/election/migrations/0005_alter_candidature_program_alter_candidature_user.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.20 on 2025-03-14 18:18 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("election", "0004_auto_20191006_0049"), + ] + + operations = [ + migrations.AlterField( + model_name="candidature", + name="program", + field=models.TextField(blank=True, default="", verbose_name="description"), + ), + migrations.AlterField( + model_name="candidature", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="candidates", + to=settings.AUTH_USER_MODEL, + verbose_name="user", + ), + ), + ] diff --git a/election/models.py b/election/models.py index dc6ab474..3e807ff6 100644 --- a/election/models.py +++ b/election/models.py @@ -1,5 +1,7 @@ from django.db import models +from django.db.models import Count from django.utils import timezone +from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from ordered_model.models import OrderedModel @@ -22,21 +24,18 @@ class Election(models.Model): verbose_name=_("edit groups"), blank=True, ) - view_groups = models.ManyToManyField( Group, related_name="viewable_elections", verbose_name=_("view groups"), blank=True, ) - vote_groups = models.ManyToManyField( Group, related_name="votable_elections", verbose_name=_("vote groups"), blank=True, ) - candidature_groups = models.ManyToManyField( Group, related_name="candidate_elections", @@ -45,7 +44,7 @@ class Election(models.Model): ) voters = models.ManyToManyField( - User, verbose_name=("voters"), related_name="voted_elections" + User, verbose_name=_("voters"), related_name="voted_elections" ) archived = models.BooleanField(_("archived"), default=False) @@ -55,20 +54,20 @@ class Election(models.Model): @property def is_vote_active(self): now = timezone.now() - return bool(now <= self.end_date and now >= self.start_date) + return self.start_date <= now <= self.end_date @property def is_vote_finished(self): - return bool(timezone.now() > self.end_date) + return timezone.now() > self.end_date @property def is_candidature_active(self): now = timezone.now() - return bool(now <= self.end_candidature and now >= self.start_candidature) + return self.start_candidature <= now <= self.end_candidature @property def is_vote_editable(self): - return bool(timezone.now() <= self.end_candidature) + return timezone.now() <= self.end_candidature def can_candidate(self, user): for group_id in self.candidature_groups.values_list("pk", flat=True): @@ -87,7 +86,7 @@ class Election(models.Model): def has_voted(self, user): return self.voters.filter(id=user.id).exists() - @property + @cached_property def results(self): results = {} total_vote = self.voters.count() @@ -95,12 +94,6 @@ class Election(models.Model): results[role.title] = role.results(total_vote) return results - def delete(self, *args, **kwargs): - self.election_lists.all().delete() - super().delete(*args, **kwargs) - - # Permissions - class Role(OrderedModel): """This class allows to create a new role avaliable for a candidature.""" @@ -115,36 +108,37 @@ class Role(OrderedModel): description = models.TextField(_("description"), null=True, blank=True) max_choice = models.IntegerField(_("max choice"), default=1) - def results(self, total_vote): - results = {} - total_vote *= self.max_choice - non_blank = 0 - for candidature in self.candidatures.all(): - cand_results = {} - cand_results["vote"] = self.votes.filter(candidature=candidature).count() - if total_vote == 0: - cand_results["percent"] = 0 - else: - cand_results["percent"] = cand_results["vote"] * 100 / total_vote - non_blank += cand_results["vote"] - results[candidature.user.username] = cand_results - results["total vote"] = total_vote + 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: - results["blank vote"] = {"vote": 0, "percent": 0} - else: - results["blank vote"] = { - "vote": total_vote - non_blank, - "percent": (total_vote - non_blank) * 100 / total_vote, + candidates = self.candidatures.values_list("user__username") + return { + key: {"vote": 0, "percent": 0} for key in ["blank_votes", *candidates] } + total_vote *= self.max_choice + results = {"total vote": total_vote} + non_blank = 0 + candidatures = self.candidatures.annotate(nb_votes=Count("votes")).values( + "nb_votes", "user__username" + ) + for candidature in candidatures: + non_blank += candidature["nb_votes"] + results[candidature["user__username"]] = { + "vote": candidature["nb_votes"], + "percent": candidature["nb_votes"] * 100 / total_vote, + } + results["blank vote"] = { + "vote": total_vote - non_blank, + "percent": (total_vote - non_blank) * 100 / total_vote, + } return results @property def edit_groups(self): return self.election.edit_groups - def __str__(self): - return ("%s : %s") % (self.election.title, self.title) - class ElectionList(models.Model): """To allow per list vote.""" @@ -163,11 +157,6 @@ class ElectionList(models.Model): def can_be_edited_by(self, user): return user.can_edit(self.election) - def delete(self, *args, **kwargs): - for candidature in self.candidatures.all(): - candidature.delete() - super().delete(*args, **kwargs) - class Candidature(models.Model): """This class is a component of responsability.""" @@ -182,10 +171,9 @@ class Candidature(models.Model): User, verbose_name=_("user"), related_name="candidates", - blank=True, on_delete=models.CASCADE, ) - program = models.TextField(_("description"), null=True, blank=True) + program = models.TextField(_("description"), default="", blank=True) election_list = models.ForeignKey( ElectionList, related_name="candidatures", @@ -196,13 +184,10 @@ class Candidature(models.Model): def __str__(self): return f"{self.role.title} : {self.user.username}" - def delete(self): - for vote in self.votes.all(): - vote.delete() - super().delete() - def can_be_edited_by(self, user): - return (user == self.user) or user.can_edit(self.role.election) + return ( + (user == self.user) or user.can_edit(self.role.election) + ) and self.role.election.is_vote_editable class Vote(models.Model): diff --git a/election/templates/election/election_detail.jinja b/election/templates/election/election_detail.jinja index 28b2a956..b93ab9b7 100644 --- a/election/templates/election/election_detail.jinja +++ b/election/templates/election/election_detail.jinja @@ -31,7 +31,7 @@ {% trans %} at {% endtrans %}

- {%- if election.has_voted(user) %} + {%- if user_has_voted %}

{%- if election.is_vote_active %} {% trans %}You already have submitted your vote.{% endtrans %} @@ -45,12 +45,11 @@

{% csrf_token %} - {%- set election_lists = election.election_lists.all() -%} - + {%- for election_list in election_lists %} - - {%- set role_list = election.roles.order_by('order').all() %} - {%- for role in role_list %} - {%- set count = [0] %} + {%- for role in election_roles %} {%- set role_data = election_form.data.getlist(role.title) if role.title in election_form.data else [] %} - + + 1 -%} + x-data x-limited-choices="{{ role.max_choice }}" + {%- endif %} + class="role {% if role.title in election_form.errors %}role_error{% endif %}" + > - {%- for election_list in election_lists %} -
{% trans %}Blank vote{% endtrans %}{% trans %}Blank vote{% endtrans %} + {{ election_list.title }} {% if user.can_edit(election_list) and election.is_vote_editable -%} @@ -59,18 +58,26 @@ {%- endfor %}

{{ role.title }}

{{ role.description }}

- {%- if role.max_choice > 1 and not election.has_voted(user) and election.can_vote(user) %} - {% trans %}You may choose up to{% endtrans %} {{ role.max_choice }} {% trans %}people.{% endtrans %} + {%- if role.max_choice > 1 and show_vote_buttons %} + + {% trans trimmed nb_choices=role.max_choice %} + You may choose up to {{ nb_choices }} people. + {% endtrans %} + {%- endif %} {%- if election_form.errors[role.title] is defined %} @@ -81,36 +88,40 @@
{% if user.can_edit(role) and election.is_vote_editable -%}
- - - {%- if role == role_list.last() %} + ️ + + + + + + {%- if loop.last -%} - {%- else %} + {%- else -%} - {%- endif %} - {% if role == role_list.first() %} + {%- endif -%} + {%- if loop.first -%} - {% else %} + {%- else -%} - {% endif %} + {%- endif -%}
{%- endif -%}
- {%- if role.max_choice == 1 and election.can_vote(user) %} + + {%- if role.max_choice == 1 and show_vote_buttons %}
- -
- {%- set _ = count.append(count.pop() + 1) %} {%- endif %} {%- if election.is_vote_finished %} {%- set results = election_results[role.title]['blank vote'] %} @@ -120,13 +131,14 @@ {%- endif %}
+
    - {%- for candidature in election_list.candidatures.filter(role=role) %} + {%- for candidature in election_list.candidatures.select_related("user", "user__profile_pict").filter(role=role) %}
  • - {%- if election.can_vote(user) %} - -