6 Commits

Author SHA1 Message Date
5b57f75b4e custom django command for promo logos
added path vailidity verification and IOError handling

added option to overwrite existing logo and force flag

improved uppon suggestions

mistake correction

fixed string conversion bugs and logical error

corrected path conversion

f

better error handling and corrections

ajout d'une section de documentation pour la feature

copié coller

fixed documentation bullet points

added resampling clean up error handling

removed useless IOError
2025-07-03 14:28:16 +02:00
3e3c6631ff Merge pull request #1146 from ae-utbm/fix-ts
Fix ts
2025-07-02 09:01:24 +02:00
a3ac04fc9e fix TS types 2025-06-30 18:35:53 +02:00
6e724a9c74 extract AlertMessage to its own file 2025-06-30 18:17:29 +02:00
c177ef2a3a Merge pull request #1145 from ae-utbm/xapian
fix: xapian compilation flags
2025-06-30 13:46:02 +02:00
6cf8910626 fix: xapian compilation flags 2025-06-30 13:09:24 +02:00
24 changed files with 594 additions and 647 deletions

View File

@ -0,0 +1,41 @@
import pathlib
from django.apps import apps
from django.core.management.base import BaseCommand
from PIL import Image, UnidentifiedImageError
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument("number", type=int)
parser.add_argument("path", type=pathlib.Path)
parser.add_argument("-f", "--force", action="store_true")
def handle(self, number: int, path: pathlib.Path, force: int, *args, **options):
if not path.exists() or path.is_dir():
self.stderr.write(f"{path} is not a file or does not exist")
return
dest_path = (
pathlib.Path(apps.get_app_config("core").path)
/ "static"
/ "core"
/ "img"
/ f"promo_{number}.png"
)
if dest_path.exists() and not force:
over = input("File already exists, do you want to overwrite it? (y/N):")
if over.lower() != "y":
self.stdout.write("exiting")
return
try:
im = Image.open(path)
im.resize((120, 120), resample=Image.Resampling.LANCZOS).save(
dest_path, format="PNG"
)
self.stdout.write(
f"Promo logo moved and resized successfully at {dest_path}"
)
except UnidentifiedImageError:
self.stderr.write("image cannot be opened and identified.")

View File

@ -4,13 +4,13 @@
VERSION="$1"
# Cleanup env vars for auto discovery mechanism
export CPATH=
export LIBRARY_PATH=
export CFLAGS=
export LDFLAGS=
export CCFLAGS=
export CXXFLAGS=
export CPPFLAGS=
unset CPATH
unset LIBRARY_PATH
unset CFLAGS
unset LDFLAGS
unset CCFLAGS
unset CXXFLAGS
unset CPPFLAGS
# prepare
rm -rf "$VIRTUAL_ENV/packages"

View File

@ -1,8 +1,7 @@
import { limitedChoices } from "#core:alpine/limited-choices";
import sort from "@alpinejs/sort";
import Alpine from "alpinejs";
Alpine.plugin([sort, limitedChoices]);
Alpine.plugin(sort);
window.Alpine = Alpine;
window.addEventListener("DOMContentLoaded", () => {

View File

@ -1,69 +0,0 @@
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
* <div x-data="{nbMax: 2}", x-limited-choices="nbMax">
* <button @click="nbMax += 1">Click me to increase the limit</button>
* <input type="checkbox" value="A" name="foo">
* <input type="checkbox" value="B" name="foo">
* <input type="checkbox" value="C" name="foo">
* <input type="checkbox" value="D" name="foo">
* </div>
* ```
*/
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();
}
});
});
},
);
}

View File

@ -0,0 +1,38 @@
interface AlertParams {
success?: boolean;
duration?: number;
}
export class AlertMessage {
public open: boolean;
public success: boolean;
public content: string;
private timeoutId?: number;
private readonly defaultDuration: number;
constructor(params?: { defaultDuration: number }) {
this.open = false;
this.content = "";
this.timeoutId = null;
this.defaultDuration = params?.defaultDuration ?? 2000;
}
public display(message: string, params: AlertParams) {
this.clear();
this.open = true;
this.content = message;
this.success = params.success ?? true;
this.timeoutId = setTimeout(() => {
this.open = false;
this.timeoutId = null;
}, params.duration ?? this.defaultDuration);
}
public clear() {
if (this.timeoutId !== null) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
this.open = false;
}
}

View File

@ -1,5 +1,5 @@
import type { Client, Options, RequestResult, TDataShape } from "@hey-api/client-fetch";
import { client } from "#openapi";
import type { Client, RequestResult, TDataShape } from "#openapi:client";
import { type Options, client } from "#openapi";
export interface PaginatedResponse<T> {
count: number;

View File

@ -1,3 +1,4 @@
import { AlertMessage } from "#core:utils/alert-message";
import { BasketItem } from "#counter:counter/basket";
import type { CounterConfig, ErrorMessage } from "#counter:counter/types";
import type { CounterProductSelect } from "./components/counter-product-select-index.ts";
@ -5,14 +6,9 @@ import type { CounterProductSelect } from "./components/counter-product-select-i
document.addEventListener("alpine:init", () => {
Alpine.data("counter", (config: CounterConfig) => ({
basket: {} as Record<string, BasketItem>,
errors: [],
customerBalance: config.customerBalance,
codeField: null as CounterProductSelect | null,
alertMessage: {
content: "",
show: false,
timeout: null,
},
alertMessage: new AlertMessage({ defaultDuration: 2000 }),
init() {
// Fill the basket with the initial data
@ -77,22 +73,10 @@ document.addEventListener("alpine:init", () => {
return total;
},
showAlertMessage(message: string) {
if (this.alertMessage.timeout !== null) {
clearTimeout(this.alertMessage.timeout);
}
this.alertMessage.content = message;
this.alertMessage.show = true;
this.alertMessage.timeout = setTimeout(() => {
this.alertMessage.show = false;
this.alertMessage.timeout = null;
}, 2000);
},
addToBasketWithMessage(id: string, quantity: number) {
const message = this.addToBasket(id, quantity);
if (message.length > 0) {
this.showAlertMessage(message);
this.alertMessage.display(message, { success: false });
}
},
@ -109,7 +93,9 @@ document.addEventListener("alpine:init", () => {
finish() {
if (this.getBasketSize() === 0) {
this.showAlertMessage(gettext("You can't send an empty basket."));
this.alertMessage.display(gettext("You can't send an empty basket."), {
success: false,
});
return;
}
this.$refs.basketForm.submit();

View File

@ -167,7 +167,7 @@ document.addEventListener("alpine:init", () => {
});
// if products to download are already in-memory, directly take them.
// If not, fetch them.
const products =
const products: ProductSchema[] =
this.nbPages > 1
? await paginated(productSearchProductsDetailed, this.getQueryParams())
: Object.values<ProductSchema[]>(this.products).flat();

View File

@ -1,15 +1,11 @@
import { AlertMessage } from "#core:utils/alert-message";
import Alpine from "alpinejs";
import { producttypeReorder } from "#openapi";
document.addEventListener("alpine:init", () => {
Alpine.data("productTypesList", () => ({
loading: false,
alertMessage: {
open: false,
success: true,
content: "",
timeout: null,
},
alertMessage: new AlertMessage({ defaultDuration: 2000 }),
async reorder(itemId: number, newPosition: number) {
// The sort plugin of Alpine doesn't manage dynamic lists with x-sort
@ -41,23 +37,14 @@ document.addEventListener("alpine:init", () => {
},
openAlertMessage(response: Response) {
if (response.ok) {
this.alertMessage.success = true;
this.alertMessage.content = gettext("Products types reordered!");
} else {
this.alertMessage.success = false;
this.alertMessage.content = interpolate(
gettext("Product type reorganisation failed with status code : %d"),
[response.status],
);
}
this.alertMessage.open = true;
if (this.alertMessage.timeout !== null) {
clearTimeout(this.alertMessage.timeout);
}
this.alertMessage.timeout = setTimeout(() => {
this.alertMessage.open = false;
}, 2000);
const success = response.ok;
const content = response.ok
? gettext("Products types reordered!")
: interpolate(
gettext("Product type reorganisation failed with status code : %d"),
[response.status],
);
this.alertMessage.display(content, { success: success });
this.loading = false;
},
}));

View File

@ -1,4 +1,4 @@
type ErrorMessage = string;
export type ErrorMessage = string;
export interface InitialFormData {
/* Used to refill the form when the backend raises an error */

View File

@ -12,6 +12,15 @@ nouveau logo d'une promo. C'est un processus manuel.
de faire cette opération manuellement, ça prend quelques
minutes et on est certain de la qualité à la fin.
### avec une commande django
```bash
./manage.py add_promo_logo numero_de_promo chemin_dacces_du_logo
```
options:
* `--force/-f` pour automatiquement écraser les logos de promo avec le même nom.
### manuellement
Les logos de promo sont à manuellement ajouter dans le projet.
Ils se situent dans le dossier `core/static/core/img/`.

View File

@ -1,155 +0,0 @@
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 many 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, 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 not 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
)

View File

@ -1,30 +0,0 @@
# 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",
),
),
]

View File

@ -1,7 +1,5 @@
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
@ -24,18 +22,21 @@ 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",
@ -44,7 +45,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)
@ -54,20 +55,20 @@ class Election(models.Model):
@property
def is_vote_active(self):
now = timezone.now()
return self.start_date <= now <= self.end_date
return bool(now <= self.end_date and now >= self.start_date)
@property
def is_vote_finished(self):
return timezone.now() > self.end_date
return bool(timezone.now() > self.end_date)
@property
def is_candidature_active(self):
now = timezone.now()
return self.start_candidature <= now <= self.end_candidature
return bool(now <= self.end_candidature and now >= self.start_candidature)
@property
def is_vote_editable(self):
return timezone.now() <= self.end_candidature
return bool(timezone.now() <= self.end_candidature)
def can_candidate(self, user):
for group_id in self.candidature_groups.values_list("pk", flat=True):
@ -86,7 +87,7 @@ class Election(models.Model):
def has_voted(self, user):
return self.voters.filter(id=user.id).exists()
@cached_property
@property
def results(self):
results = {}
total_vote = self.voters.count()
@ -94,6 +95,12 @@ 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."""
@ -108,37 +115,36 @@ class Role(OrderedModel):
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")
return {
key: {"vote": 0, "percent": 0} for key in ["blank_votes", *candidates]
}
def results(self, total_vote):
results = {}
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,
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
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,
}
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."""
@ -157,6 +163,11 @@ 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."""
@ -171,9 +182,10 @@ class Candidature(models.Model):
User,
verbose_name=_("user"),
related_name="candidates",
blank=True,
on_delete=models.CASCADE,
)
program = models.TextField(_("description"), default="", blank=True)
program = models.TextField(_("description"), null=True, blank=True)
election_list = models.ForeignKey(
ElectionList,
related_name="candidatures",
@ -184,10 +196,13 @@ 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)
) and self.role.election.is_vote_editable
return (user == self.user) or user.can_edit(self.role.election)
class Vote(models.Model):

View File

@ -31,7 +31,7 @@
<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 user_has_voted %}
{%- if election.has_voted(user) %}
<p class="election__elector-infos">
{%- if election.is_vote_active %}
<span>{% trans %}You already have submitted your vote.{% endtrans %}</span>
@ -45,11 +45,12 @@
<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">
{%- set election_lists = election.election_lists.all() -%}
<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.count() + 1) }}%">{% trans %}Blank vote{% endtrans %}</th>
{%- for election_list in election_lists %}
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">
<th class="column" style="width: {{ 100 / (election_lists.count() + 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>
@ -58,26 +59,18 @@
{%- endfor %}
</tr>
</thead>
{%- for role in election_roles %}
{%- set role_list = election.roles.order_by('order').all() %}
{%- for role in role_list %}
{%- set count = [0] %}
{%- set role_data = election_form.data.getlist(role.title) if role.title in election_form.data else [] %}
<tbody
{% if role.max_choice > 1 -%}
x-data x-limited-choices="{{ role.max_choice }}"
{%- endif %}
class="role {% if role.title in election_form.errors %}role_error{% endif %}"
>
<tbody data-max-choice="{{role.max_choice}}" class="role{{ ' role_error' if role.title in election_form.errors else '' }}{{ ' role__multiple-choices' if role.max_choice > 1 else ''}}">
<tr>
<td class="role_title">
<div class="role_text">
<h4>{{ role.title }}</h4>
<p class="role_description" show-more="300">{{ role.description }}</p>
{%- if role.max_choice > 1 and show_vote_buttons %}
<strong>
{% trans trimmed nb_choices=role.max_choice %}
You may choose up to {{ nb_choices }} people.
{% endtrans %}
</strong>
{%- if role.max_choice > 1 and not election.has_voted(user) and election.can_vote(user) %}
<strong>{% trans %}You may choose up to{% endtrans %} {{ role.max_choice }} {% trans %}people.{% endtrans %}</strong>
{%- endif %}
{%- if election_form.errors[role.title] is defined %}
@ -88,40 +81,36 @@
</div>
{% if user.can_edit(role) and election.is_vote_editable -%}
<div class="role_buttons">
<a href="{{ url('election:update_role', role_id=role.id) }}">
<i class="fa-regular fa-pen-to-square edit-action"></i>
</a>
<a href="{{ url('election:delete_role', role_id=role.id) }}">
<i class="fa-regular fa-trash-can delete-action"></i>
</a>
{%- if loop.last -%}
<a href="{{url('election:update_role', role_id=role.id)}}"><i class="fa-regular fa-pen-to-square edit-action"></i></a>
<a href="{{url('election:delete_role', role_id=role.id)}}"><i class="fa-regular fa-trash-can delete-action"></i></a>
{%- if role == role_list.last() %}
<button disabled><i class="fa fa-arrow-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 type="button" onclick="window.location.replace('?role={{ role.id }}&action=down');"><i class="fa fa-caret-down"></i></button>
{%- endif -%}
{%- if loop.first -%}
{%- endif %}
{% if role == role_list.first() %}
<button disabled><i class="fa fa-caret-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 type="button" onclick="window.location.replace('?role={{ role.id }}&action=top');"><i class="fa fa-arrow-up"></i></button>
{%- endif -%}
{% endif %}
</div>
{%- endif -%}
</td>
</tr>
<tr class="role_candidates">
<td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%">
{%- if role.max_choice == 1 and show_vote_buttons %}
<td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists.count() + 1) }}%">
{%- if role.max_choice == 1 and election.can_vote(user) %}
<div class="radio-btn">
{% set input_id = "blank_vote_" + role.id|string %}
<input id="{{ input_id }}" type="radio" name="{{ role.title }}">
<label for="{{ input_id }}">
<input id="id_{{ role.title }}_{{ count[0] }}" type="radio" name="{{ role.title }}" value {{ '' if role_data in election_form else 'checked' }} {{ 'disabled' if election.has_voted(user) else '' }}>
<label for="id_{{ role.title }}_{{ count[0] }}">
<span>{% trans %}Choose blank vote{% endtrans %}</span>
</label>
</div>
{%- set _ = count.append(count.pop() + 1) %}
{%- endif %}
{%- if election.is_vote_finished %}
{%- set results = election_results[role.title]['blank vote'] %}
@ -131,14 +120,13 @@
{%- 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.count() + 1) }}%">
<ul class="candidates">
{%- for candidature in election_list.candidatures.select_related("user", "user__profile_pict").filter(role=role) %}
{%- for candidature in election_list.candidatures.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' }}" {{ '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 }}">
{%- if election.can_vote(user) %}
<input id="id_{{ role.title }}_{{ count[0] }}" type="{{ 'checkbox' if role.max_choice > 1 else 'radio' }}" {{ 'checked' if candidature.id|string in role_data else '' }} {{ 'disabled' if election.has_voted(user) else '' }} name="{{ role.title }}" value="{{ candidature.id }}">
<label for="id_{{ role.title }}_{{ count[0] }}">
{%- endif %}
<figure>
{%- if user.is_subscriber_viewable %}
@ -152,7 +140,7 @@
<h5>{{ candidature.user.first_name }} <em>{{candidature.user.nick_name or ''}} </em>{{ candidature.user.last_name }}</h5>
{%- if not election.is_vote_finished %}
<q class="candidate_program" show-more="200">
{{ candidature.program|markdown }}
{{ candidature.program|markdown or '' }}
</q>
{%- endif %}
</figcaption>
@ -165,8 +153,9 @@
{%- endif -%}
{%- endif -%}
</figure>
{%- if show_vote_buttons %}
{%- if election.can_vote(user) %}
</label>
{%- set _ = count.append(count.pop() + 1) %}
{%- endif %}
{%- if election.is_vote_finished %}
{%- set results = election_results[role.title][candidature.user.username] %}
@ -202,9 +191,36 @@
<a class="button" href="{{ url('election:delete', election_id=object.id) }}">{% trans %}Delete{% endtrans %}</a>
{%- endif %}
</section>
{%- if show_vote_buttons %}
{%- if not election.has_voted(user) and election.can_vote(user) %}
<section class="buttons">
<button class="button button_send" form="vote-form">{% trans %}Submit the vote !{% endtrans %}</button>
</section>
{%- endif %}
{% endblock %}
{% block script %}
{{ super() }}
<script type="text/javascript">
document.querySelectorAll('.role__multiple-choices').forEach(setupRestrictions);
function setupRestrictions(role) {
var selectedChoices = [];
role.querySelectorAll('input').forEach(setupRestriction);
function setupRestriction(choice) {
if (choice.checked)
selectedChoices.push(choice);
choice.addEventListener('change', onChange);
function onChange() {
if (choice.checked)
selectedChoices.push(choice);
else
selectedChoices.splice(selectedChoices.indexOf(choice), 1);
while (selectedChoices.length > role.dataset.maxChoice)
selectedChoices.shift().checked = false;
}
}
}
</script>
{% endblock %}

View File

@ -1,15 +1,9 @@
from datetime import timedelta
import pytest
from django.conf import settings
from django.test import Client, TestCase
from django.test import TestCase
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker
from core.baker_recipes import subscriber_user
from core.models import Group, User
from election.models import Candidature, Election, ElectionList, Role, Vote
from election.models import Election
class TestElection(TestCase):
@ -18,7 +12,8 @@ class TestElection(TestCase):
cls.election = Election.objects.first()
cls.public_group = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)
cls.sli = User.objects.get(username="sli")
cls.public = baker.make(User)
cls.subscriber = User.objects.get(username="subscriber")
cls.public = User.objects.get(username="public")
class TestElectionDetail(TestElection):
@ -41,7 +36,7 @@ class TestElectionDetail(TestElection):
class TestElectionUpdateView(TestElection):
def test_permission_denied(self):
self.client.force_login(subscriber_user.make())
self.client.force_login(self.subscriber)
response = self.client.get(
reverse("election:update", args=str(self.election.id))
)
@ -50,68 +45,3 @@ class TestElectionUpdateView(TestElection):
reverse("election:update", args=str(self.election.id))
)
assert response.status_code == 403
@pytest.mark.django_db
def test_election_create_list_permission(client: Client):
election = baker.make(Election, end_candidature=now() + timedelta(hours=1))
groups = [
Group.objects.get(pk=settings.SITH_GROUP_SUBSCRIBERS_ID),
baker.make(Group),
]
election.candidature_groups.add(groups[0])
election.edit_groups.add(groups[1])
url = reverse("election:create_list", kwargs={"election_id": election.id})
for user in subscriber_user.make(), baker.make(User, groups=[groups[1]]):
client.force_login(user)
assert client.get(url).status_code == 200
# the post is a 200 instead of a 302, because we don't give form data,
# but we don't care as we only test permissions here
assert client.post(url).status_code == 200
client.force_login(baker.make(User))
assert client.get(url).status_code == 403
assert client.post(url).status_code == 403
@pytest.mark.django_db
def test_election_results():
election = baker.make(
Election, voters=baker.make(User, _quantity=50, _bulk_create=True)
)
lists = baker.make(ElectionList, election=election, _quantity=2, _bulk_create=True)
roles = baker.make(
Role, election=election, max_choice=iter([1, 2]), _quantity=2, _bulk_create=True
)
users = baker.make(User, _quantity=4, _bulk_create=True)
cand = [
baker.make(Candidature, role=roles[0], user=users[0], election_list=lists[0]),
baker.make(Candidature, role=roles[0], user=users[1], election_list=lists[1]),
baker.make(Candidature, role=roles[1], user=users[2], election_list=lists[0]),
baker.make(Candidature, role=roles[1], user=users[3], election_list=lists[1]),
]
votes = [
baker.make(Vote, role=roles[0], _quantity=20, _bulk_create=True),
baker.make(Vote, role=roles[0], _quantity=25, _bulk_create=True),
baker.make(Vote, role=roles[1], _quantity=20, _bulk_create=True),
baker.make(Vote, role=roles[1], _quantity=35, _bulk_create=True),
baker.make(Vote, role=roles[1], _quantity=10, _bulk_create=True),
]
cand[0].votes.set(votes[0])
cand[1].votes.set(votes[1])
cand[2].votes.set([*votes[2], *votes[4]])
cand[3].votes.set([*votes[3], *votes[4]])
assert election.results == {
roles[0].title: {
cand[0].user.username: {"percent": 40.0, "vote": 20},
cand[1].user.username: {"percent": 50.0, "vote": 25},
"blank vote": {"percent": 10.0, "vote": 5},
"total vote": 50,
},
roles[1].title: {
cand[2].user.username: {"percent": 30.0, "vote": 30},
cand[3].user.username: {"percent": 45.0, "vote": 45},
"blank vote": {"percent": 25.0, "vote": 25},
"total vote": 100,
},
}

View File

@ -1,34 +1,183 @@
from typing import TYPE_CHECKING
from cryptography.utils import cached_property
from django.conf import settings
from django.contrib.auth.mixins import (
LoginRequiredMixin,
PermissionRequiredMixin,
UserPassesTestMixin,
)
from django import forms
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.db.models import QuerySet
from django.shortcuts import get_object_or_404
from django.db.models.query import QuerySet
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
from core.auth.mixins import CanEditMixin, CanViewMixin
from election.forms import (
CandidateForm,
ElectionForm,
ElectionListForm,
RoleForm,
VoteForm,
from core.auth.mixins import CanCreateMixin, CanEditMixin, CanViewMixin
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, Vote
if TYPE_CHECKING:
from core.models import User
# Custom form field
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"
)
# Forms
class CandidateForm(forms.ModelForm):
"""Form to candidate."""
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, **kwargs):
election_id = kwargs.pop("election_id", None)
can_edit = kwargs.pop("can_edit", False)
super().__init__(*args, **kwargs)
if election_id:
self.fields["role"].queryset = Role.objects.filter(
election__id=election_id
).all()
self.fields["election_list"].queryset = ElectionList.objects.filter(
election__id=election_id
).all()
if not can_edit:
self.fields["user"].widget = forms.HiddenInput()
class VoteForm(forms.Form):
def __init__(self, election, user, *args, **kwargs):
super().__init__(*args, **kwargs)
if not election.has_voted(user):
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
)
# Display elections
@ -36,21 +185,25 @@ class ElectionsListView(CanViewMixin, ListView):
"""A list of all non archived elections visible."""
model = Election
queryset = model.objects.filter(archived=False)
ordering = ["-id"]
paginate_by = 10
template_name = "election/election_list.jinja"
def get_queryset(self):
return super().get_queryset().filter(archived=False).all()
class ElectionListArchivedView(CanViewMixin, ListView):
"""A list of all archived elections visible."""
model = Election
queryset = model.objects.filter(archived=True)
ordering = ["-id"]
paginate_by = 10
template_name = "election/election_list.jinja"
def get_queryset(self):
return super().get_queryset().filter(archived=True).all()
class ElectionDetailView(CanViewMixin, DetailView):
"""Details an election responsability by responsability."""
@ -59,67 +212,46 @@ class ElectionDetailView(CanViewMixin, DetailView):
template_name = "election/election_detail.jinja"
pk_url_kwarg = "election_id"
@staticmethod
def _reorder_votes(action: str, role: int):
role = Role.objects.filter(id=role).first()
if not role:
return
if action == "up":
role.up()
elif action == "down":
role.down()
elif action == "bottom":
role.bottom()
elif action == "top":
role.top()
def get(self, request, *arg, **kwargs):
response = super().get(request, *arg, **kwargs)
election: Election = self.get_object()
if election.is_vote_editable and request.user.can_edit(election):
if request.user.can_edit(election) and election.is_vote_editable:
action = request.GET.get("action", None)
role = request.GET.get("role", None)
if action and role and role.isdigit():
self._reorder_votes(action, int(role))
return super().get(request, *arg, **kwargs)
if action and role and Role.objects.filter(id=role).exists():
if action == "up":
Role.objects.get(id=role).up()
elif action == "down":
Role.objects.get(id=role).down()
elif action == "bottom":
Role.objects.get(id=role).bottom()
elif action == "top":
Role.objects.get(id=role).top()
return redirect(
reverse("election:detail", kwargs={"election_id": election.id})
)
return response
def get_context_data(self, **kwargs):
"""Add additionnal data to the template."""
user: User = self.request.user
return super().get_context_data(**kwargs) | {
"election_form": VoteForm(self.object, user),
"show_vote_buttons": self.object.can_vote(user),
"user_has_voted": self.object.has_voted(user),
"election_results": (
self.object.results if self.object.is_vote_finished else None
),
"election_lists": list(self.object.election_lists.all()),
"election_roles": list(self.object.roles.order_by("order")),
}
kwargs = super().get_context_data(**kwargs)
kwargs["election_form"] = VoteForm(self.object, self.request.user)
kwargs["election_results"] = self.object.results
return kwargs
# Form view
class VoteFormView(LoginRequiredMixin, UserPassesTestMixin, FormView):
class VoteFormView(CanCreateMixin, FormView):
"""Alows users to vote."""
form_class = VoteForm
template_name = "election/election_detail.jinja"
@cached_property
def election(self):
return get_object_or_404(Election, pk=self.kwargs["election_id"])
def test_func(self):
groups = set(self.election.vote_groups.values_list("id", flat=True))
if (
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
and self.request.user.is_subscribed
):
# the subscriber group isn't truly attached to users,
# so it must be dealt with separately
return True
return self.request.user.groups.filter(id__in=groups).exists()
def dispatch(self, request, *arg, **kwargs):
self.election = get_object_or_404(Election, pk=kwargs["election_id"])
return super().dispatch(request, *arg, **kwargs)
def vote(self, election_data):
with transaction.atomic():
@ -139,16 +271,20 @@ class VoteFormView(LoginRequiredMixin, UserPassesTestMixin, FormView):
self.election.voters.add(self.request.user)
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"election": self.election,
"user": self.request.user,
}
kwargs = super().get_form_kwargs()
kwargs["election"] = self.election
kwargs["user"] = self.request.user
return kwargs
def form_valid(self, form):
"""Verify that the user is part in a vote group."""
data = form.clean()
self.vote(data)
return super().form_valid(form)
res = super(FormView, self).form_valid(form)
for grp_id in self.election.vote_groups.values_list("pk", flat=True):
if self.request.user.is_in_group(pk=grp_id):
self.vote(data)
return res
return res
def get_success_url(self, **kwargs):
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})
@ -174,22 +310,26 @@ class CandidatureCreateView(LoginRequiredMixin, CreateView):
def dispatch(self, request, *arg, **kwargs):
self.election = get_object_or_404(Election, pk=kwargs["election_id"])
self.can_edit = self.request.user.can_edit(self.election)
return super().dispatch(request, *arg, **kwargs)
def get_initial(self):
return {"user": self.request.user.id}
init = {}
self.can_edit = self.request.user.can_edit(self.election)
init["user"] = self.request.user.id
return init
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"election": self.election,
"can_edit": self.can_edit,
}
kwargs = super().get_form_kwargs()
kwargs["election_id"] = self.election.id
kwargs["can_edit"] = self.can_edit
return kwargs
def form_valid(self, form: CandidateForm):
def form_valid(self, form):
"""Verify that the selected user is in candidate group."""
obj = form.instance
obj.election = self.election
if not hasattr(obj, "user"):
obj.user = self.request.user
if (obj.election.can_candidate(obj.user)) and (
obj.user == self.request.user or self.can_edit
):
@ -197,7 +337,9 @@ class CandidatureCreateView(LoginRequiredMixin, CreateView):
raise PermissionDenied
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"election": self.election}
kwargs = super().get_context_data(**kwargs)
kwargs["election"] = self.election
return kwargs
def get_success_url(self, **kwargs):
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})
@ -213,79 +355,80 @@ class ElectionCreateView(PermissionRequiredMixin, CreateView):
return reverse("election:detail", kwargs={"election_id": self.object.id})
class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
class RoleCreateView(CanCreateMixin, CreateView):
model = Role
form_class = RoleForm
template_name = "core/create.jinja"
@cached_property
def election(self):
return get_object_or_404(Election, pk=self.kwargs["election_id"])
def test_func(self):
def dispatch(self, request, *arg, **kwargs):
self.election = get_object_or_404(Election, pk=kwargs["election_id"])
if not self.election.is_vote_editable:
return False
if self.request.user.has_perm("election.add_role"):
return True
groups = set(self.election.edit_groups.values_list("id", flat=True))
if (
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
and self.request.user.is_subscribed
):
# the subscriber group isn't truly attached to users,
# so it must be dealt with separately
return True
return self.request.user.groups.filter(id__in=groups).exists()
raise PermissionDenied
return super().dispatch(request, *arg, **kwargs)
def get_initial(self):
return {"election": self.election}
init = {}
init["election"] = self.election
return init
def form_valid(self, form):
"""Verify that the user can edit properly."""
obj: Role = form.instance
user: User = self.request.user
if obj.election:
for grp_id in obj.election.edit_groups.values_list("pk", flat=True):
if user.is_in_group(pk=grp_id):
return super(CreateView, self).form_valid(form)
raise PermissionDenied
def get_form_kwargs(self):
return super().get_form_kwargs() | {"election_id": self.election.id}
kwargs = super().get_form_kwargs()
kwargs["election_id"] = self.election.id
return kwargs
def get_success_url(self, **kwargs):
return reverse(
"election:detail", kwargs={"election_id": self.object.election_id}
return reverse_lazy(
"election:detail", kwargs={"election_id": self.object.election.id}
)
class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
class ElectionListCreateView(CanCreateMixin, CreateView):
model = ElectionList
form_class = ElectionListForm
template_name = "core/create.jinja"
@cached_property
def election(self):
return get_object_or_404(Election, pk=self.kwargs["election_id"])
def test_func(self):
def dispatch(self, request, *arg, **kwargs):
self.election = get_object_or_404(Election, pk=kwargs["election_id"])
if not self.election.is_vote_editable:
return False
if self.request.user.has_perm("election.add_electionlist"):
return True
groups = set(
self.election.candidature_groups.values("id")
.union(self.election.edit_groups.values("id"))
.values_list("id", flat=True)
)
if (
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
and self.request.user.is_subscribed
):
# the subscriber group isn't truly attached to users,
# so it must be dealt with separately
return True
return self.request.user.groups.filter(id__in=groups).exists()
raise PermissionDenied
return super().dispatch(request, *arg, **kwargs)
def get_initial(self):
return {"election": self.election}
init = {}
init["election"] = self.election
return init
def get_form_kwargs(self):
return super().get_form_kwargs() | {"election_id": self.election.id}
kwargs = super().get_form_kwargs()
kwargs["election_id"] = self.election.id
return kwargs
def form_valid(self, form):
"""Verify that the user can vote on this election."""
obj: ElectionList = form.instance
user: User = self.request.user
if obj.election:
for grp_id in obj.election.candidature_groups.values_list("pk", flat=True):
if user.is_in_group(pk=grp_id):
return super(CreateView, self).form_valid(form)
for grp_id in obj.election.edit_groups.values_list("pk", flat=True):
if user.is_in_group(pk=grp_id):
return super(CreateView, self).form_valid(form)
raise PermissionDenied
def get_success_url(self, **kwargs):
return reverse(
"election:detail", kwargs={"election_id": self.object.election_id}
return reverse_lazy(
"election:detail", kwargs={"election_id": self.object.election.id}
)
@ -314,23 +457,45 @@ class ElectionUpdateView(CanEditMixin, UpdateView):
return reverse_lazy("election:detail", kwargs={"election_id": self.object.id})
class CandidatureUpdateView(LoginRequiredMixin, CanEditMixin, UpdateView):
class CandidatureUpdateView(CanEditMixin, UpdateView):
model = Candidature
form_class = CandidateForm
template_name = "core/edit.jinja"
pk_url_kwarg = "candidature_id"
def get_form(self, *args, **kwargs):
form = super().get_form(*args, **kwargs)
form.fields.pop("role", None)
return form
def dispatch(self, request, *arg, **kwargs):
self.object = self.get_object()
if not self.object.role.election.is_vote_editable:
raise PermissionDenied
return super().dispatch(request, *arg, **kwargs)
def remove_fields(self):
self.form.fields.pop("role", None)
def get(self, request, *args, **kwargs):
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.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.object.role.election}
kwargs = super().get_form_kwargs()
kwargs["election_id"] = self.object.role.election.id
return kwargs
def get_success_url(self, **kwargs):
return reverse(
"election:detail", kwargs={"election_id": self.object.role.election_id}
return reverse_lazy(
"election:detail", kwargs={"election_id": self.object.role.election.id}
)
@ -381,12 +546,18 @@ class RoleUpdateView(CanEditMixin, UpdateView):
# Delete Views
class ElectionDeleteView(PermissionRequiredMixin, DeleteView):
class ElectionDeleteView(DeleteView):
model = Election
template_name = "core/delete_confirm.jinja"
pk_url_kwarg = "election_id"
permission_required = "election.delete_election"
success_url = reverse_lazy("election:list")
def dispatch(self, request, *args, **kwargs):
if request.user.is_root:
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied
def get_success_url(self, **kwargs):
return reverse_lazy("election:list")
class CandidatureDeleteView(CanEditMixin, DeleteView):
@ -402,7 +573,7 @@ class CandidatureDeleteView(CanEditMixin, DeleteView):
return super().dispatch(request, *arg, **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.election.id})
class RoleDeleteView(CanEditMixin, DeleteView):
@ -418,7 +589,7 @@ class RoleDeleteView(CanEditMixin, DeleteView):
return super().dispatch(request, *arg, **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.election.id})
class ElectionListDeleteView(CanEditMixin, DeleteView):
@ -434,4 +605,4 @@ class ElectionListDeleteView(CanEditMixin, DeleteView):
return super().dispatch(request, *args, **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.election.id})

View File

@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-06-27 12:45+0200\n"
"POT-Creation-Date: 2025-06-25 16:29+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,7 @@ 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 election/forms.py
#: club/forms.py com/forms.py counter/forms.py election/views.py
#: subscription/forms.py
msgid "End date"
msgstr "Date de fin"
@ -679,7 +679,7 @@ msgstr "Liste d'affiches"
msgid "Format: 16:9 | Resolution: 1920x1080"
msgstr "Format : 16:9 | Résolution : 1920x1080"
#: com/forms.py election/forms.py subscription/forms.py
#: com/forms.py election/views.py subscription/forms.py
msgid "Start date"
msgstr "Date de début"
@ -1708,27 +1708,27 @@ msgstr "500, Erreur Serveur"
msgid "Welcome!"
msgstr "Bienvenue !"
#: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja
#: core/templates/core/base.jinja core/templates/core/base/navbar.jinja
msgid "Contacts"
msgstr "Contacts"
#: core/templates/core/base/footer.jinja
#: core/templates/core/base.jinja
msgid "Legal notices"
msgstr "Mentions légales"
#: core/templates/core/base/footer.jinja
#: core/templates/core/base.jinja
msgid "Intellectual property"
msgstr "Propriété intellectuelle"
#: core/templates/core/base/footer.jinja
#: core/templates/core/base.jinja
msgid "Help & Documentation"
msgstr "Aide & Documentation"
#: core/templates/core/base/footer.jinja
#: core/templates/core/base.jinja
msgid "R&D"
msgstr "R&D"
#: core/templates/core/base/footer.jinja
#: core/templates/core/base.jinja
msgid "Site created by the IT Department of the AE"
msgstr "Site réalisé par le Pôle Informatique de l'AE"
@ -3926,30 +3926,6 @@ msgstr ""
msgid "You can't buy a refilling with sith money"
msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith"
#: election/forms.py
msgid "You have selected too many candidates."
msgstr "Vous avez sélectionné trop de candidats."
#: election/forms.py
msgid "User to candidate"
msgstr "Utilisateur se présentant"
#: election/forms.py election/templates/election/election_detail.jinja
msgid "Blank vote"
msgstr "Vote blanc"
#: election/forms.py
msgid "This role already exists for this election"
msgstr "Ce rôle existe déjà pour cette élection"
#: election/forms.py
msgid "Start candidature"
msgstr "Début des candidatures"
#: election/forms.py
msgid "End candidature"
msgstr "Fin des candidatures"
#: election/models.py
msgid "start candidature"
msgstr "début des candidatures"
@ -3974,10 +3950,6 @@ msgstr "groupe de vote"
msgid "candidature groups"
msgstr "groupe de candidature"
#: election/models.py
msgid "voters"
msgstr "électeurs"
#: election/models.py
msgid "election"
msgstr "élection"
@ -4033,10 +4005,17 @@ msgstr "Vous avez déjà soumis votre vote."
msgid "You have voted in this election."
msgstr "Vous avez déjà voté pour cette élection."
#: election/templates/election/election_detail.jinja election/views.py
msgid "Blank vote"
msgstr "Vote blanc"
#: election/templates/election/election_detail.jinja
#, python-format
msgid "You may choose up to %(nb_choices)s people."
msgstr "Vous pouvez choisir jusqu'à %(nb_choices)s personnes."
msgid "You may choose up to"
msgstr "Vous pouvez choisir jusqu'à"
#: election/templates/election/election_detail.jinja
msgid "people."
msgstr "personne(s)"
#: election/templates/election/election_detail.jinja
msgid "Choose blank vote"
@ -4078,6 +4057,26 @@ msgstr "au"
msgid "Polls open from"
msgstr "Votes ouverts du"
#: election/views.py
msgid "You have selected too much candidates."
msgstr "Vous avez sélectionné trop de candidats."
#: election/views.py
msgid "User to candidate"
msgstr "Utilisateur se présentant"
#: election/views.py
msgid "This role already exists for this election"
msgstr "Ce rôle existe déjà pour cette élection"
#: election/views.py
msgid "Start candidature"
msgstr "Début des candidatures"
#: election/views.py
msgid "End candidature"
msgstr "Fin des candidatures"
#: forum/models.py
msgid "is a category"
msgstr "est une catégorie"

8
package-lock.json generated
View File

@ -48,6 +48,7 @@
"@types/cytoscape-cxtmenu": "^3.4.4",
"@types/cytoscape-klay": "^3.1.4",
"@types/jquery": "^3.5.31",
"@types/js-cookie": "^3.0.6",
"typescript": "^5.8.3",
"vite": "^6.2.5",
"vite-bundle-visualizer": "^1.2.1",
@ -2865,6 +2866,13 @@
"@types/sizzle": "*"
}
},
"node_modules/@types/js-cookie": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
"integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",

View File

@ -10,7 +10,7 @@
"openapi": "openapi-ts",
"analyse-dev": "vite-bundle-visualizer --mode development",
"analyse-prod": "vite-bundle-visualizer --mode production",
"check": "biome check --write"
"check": "tsc && biome check --write"
},
"keywords": [],
"author": "",
@ -30,9 +30,10 @@
"@hey-api/openapi-ts": "^0.73.0",
"@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.10",
"@types/jquery": "^3.5.31",
"@types/cytoscape-cxtmenu": "^3.4.4",
"@types/cytoscape-klay": "^3.1.4",
"@types/jquery": "^3.5.31",
"@types/js-cookie": "^3.0.6",
"typescript": "^5.8.3",
"vite": "^6.2.5",
"vite-bundle-visualizer": "^1.2.1",

View File

@ -92,7 +92,7 @@ docs = [
default-groups = ["dev", "tests", "docs"]
[tool.xapian]
version = "1.4.25"
version = "1.4.29"
[tool.ruff]
output-format = "concise" # makes ruff error logs easier to read

View File

@ -83,7 +83,6 @@ document.addEventListener("alpine:init", () => {
Alpine.data("pictureUpload", (albumId: number) => ({
errors: [] as string[],
pictures: [],
sending: false,
progress: null as HTMLProgressElement,

View File

@ -1,3 +1,4 @@
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
import { paginated } from "#core:utils/api";
import { exportToHtml } from "#core:utils/globals";
import { History } from "#core:utils/history";
@ -130,7 +131,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
currentPicture: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
is_moderated: true,
id: null,
id: null as number,
name: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
display_name: "",
@ -142,7 +143,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
full_size_url: "",
owner: "",
date: new Date(),
identifications: [],
identifications: [] as IdentifiedUserSchema[],
},
/**
* The picture which will be displayed next if the user press the "next" button
@ -155,7 +156,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
/**
* The select2 component used to identify users
**/
selector: undefined,
selector: undefined as UserAjaxSelect,
/**
* Error message when a moderation operation fails
**/

View File

@ -14,6 +14,7 @@
"types": ["jquery", "alpinejs"],
"paths": {
"#openapi": ["./staticfiles/generated/openapi/client/index.ts"],
"#openapi:*": ["./staticfiles/generated/openapi/client/*"],
"#core:*": ["./core/static/bundled/*"],
"#pedagogy:*": ["./pedagogy/static/bundled/*"],
"#counter:*": ["./counter/static/bundled/*"],