Compare commits

...

12 Commits

Author SHA1 Message Date
imperosol
59d8d73c88 exclude hidden users from ajax search 2025-11-09 21:19:15 +01:00
imperosol
cb6fe18916 Show groups of Permission in admin 2025-11-09 19:16:41 +01:00
imperosol
edbf07e6b8 rename User.is_subscriber_viewable => User.is_viewable 2025-11-09 18:28:44 +01:00
imperosol
144b05e49c don't show hidden users in picture identifications 2025-11-09 15:34:00 +01:00
thomas girod
1f3a483b1b Merge pull request #1046 from ae-utbm/refactor-election
Refactor election
2025-11-08 22:44:16 +01:00
imperosol
2e288368f0 apply review comments 2025-11-07 19:18:24 +01:00
imperosol
8b98e10ba3 remove CanCreateMixin usage from election 2025-11-07 19:18:24 +01:00
imperosol
59655e0414 refactor election result computing 2025-11-07 19:18:24 +01:00
imperosol
0d6856e925 refactor election detail view 2025-11-07 19:18:24 +01:00
imperosol
e754b48371 feat: add x-limited-choices directive 2025-11-07 19:18:22 +01:00
imperosol
a56b1c244e refactor CandidatureForm 2025-11-07 19:18:00 +01:00
imperosol
0d9d048ea3 move forms to their own file 2025-11-07 19:18:00 +01:00
27 changed files with 807 additions and 520 deletions

View File

@@ -240,10 +240,11 @@ class NewsListView(TemplateView):
if not self.request.user.has_perm("core.view_user"): if not self.request.user.has_perm("core.view_user"):
return [] return []
return itertools.groupby( return itertools.groupby(
User.objects.filter( User.objects.viewable_by(self.request.user)
.filter(
date_of_birth__month=localdate().month, date_of_birth__month=localdate().month,
date_of_birth__day=localdate().day, date_of_birth__day=localdate().day,
is_subscriber_viewable=True, is_viewable=True,
) )
.filter(role__in=["STUDENT", "FORMER STUDENT"]) .filter(role__in=["STUDENT", "FORMER STUDENT"])
.order_by("-date_of_birth"), .order_by("-date_of_birth"),

View File

@@ -74,9 +74,19 @@ class UserBanAdmin(admin.ModelAdmin):
autocomplete_fields = ("user", "ban_group") autocomplete_fields = ("user", "ban_group")
class GroupInline(admin.TabularInline):
model = Group.permissions.through
readonly_fields = ("group",)
extra = 0
def has_add_permission(self, request, obj):
return False
@admin.register(Permission) @admin.register(Permission)
class PermissionAdmin(admin.ModelAdmin): class PermissionAdmin(admin.ModelAdmin):
search_fields = ("codename",) search_fields = ("codename",)
inlines = (GroupInline,)
@admin.register(Page) @admin.register(Page)

View File

@@ -74,7 +74,7 @@ class MailingListController(ControllerBase):
class UserController(ControllerBase): class UserController(ControllerBase):
@route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup]) @route.get("", response=list[UserProfileSchema], permissions=[CanAccessLookup])
def fetch_profiles(self, pks: Query[set[int]]): def fetch_profiles(self, pks: Query[set[int]]):
return User.objects.filter(pk__in=pks) return User.objects.viewable_by(self.context.request.user).filter(pk__in=pks)
@route.get("/{int:user_id}", response=UserSchema, permissions=[CanView]) @route.get("/{int:user_id}", response=UserSchema, permissions=[CanView])
def fetch_user(self, user_id: int): def fetch_user(self, user_id: int):
@@ -90,7 +90,9 @@ class UserController(ControllerBase):
@paginate(PageNumberPaginationExtra, page_size=20) @paginate(PageNumberPaginationExtra, page_size=20)
def search_users(self, filters: Query[UserFilterSchema]): def search_users(self, filters: Query[UserFilterSchema]):
return filters.filter( return filters.filter(
User.objects.order_by(F("last_login").desc(nulls_last=True)) User.objects.viewable_by(self.context.request.user).order_by(
F("last_login").desc(nulls_last=True)
)
) )

View File

@@ -150,7 +150,8 @@ class Command(BaseCommand):
Weekmail().save() Weekmail().save()
# Here we add a lot of test datas, that are not necessary for the Sith, but that provide a basic development environment # Here we add a lot of test datas, that are not necessary for the Sith,
# but that provide a basic development environment
self.now = timezone.now().replace(hour=12, second=0) self.now = timezone.now().replace(hour=12, second=0)
skia = User.objects.create_user( skia = User.objects.create_user(

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.8 on 2025-11-09 15:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("core", "0047_alter_notification_date_alter_notification_type")]
operations = [
migrations.AlterModelOptions(
name="user",
options={
"permissions": [("view_hidden_user", "Can view hidden users")],
"verbose_name": "user",
"verbose_name_plural": "users",
},
),
migrations.RenameField(
model_name="user", old_name="is_subscriber_viewable", new_name="is_viewable"
),
migrations.AlterField(
model_name="user",
name="is_viewable",
field=models.BooleanField(
default=True,
verbose_name="Profile visible by subscribers",
help_text=(
"If you disable this option, only admin users "
"will be able to see your profile."
),
),
),
]

View File

@@ -180,6 +180,15 @@ class UserQuerySet(models.QuerySet):
Q(Exists(subscriptions)) | Q(Exists(refills)) | Q(Exists(purchases)) Q(Exists(subscriptions)) | Q(Exists(refills)) | Q(Exists(purchases))
) )
def viewable_by(self, user: User) -> Self:
if user.has_perm("core.view_hidden_user"):
return self
if user.has_perm("core.view_user"):
return self.filter(is_viewable=True)
if user.is_anonymous:
return self.none()
return self.filter(id=user.id)
class CustomUserManager(UserManager.from_queryset(UserQuerySet)): class CustomUserManager(UserManager.from_queryset(UserQuerySet)):
# see https://docs.djangoproject.com/fr/stable/topics/migrations/#model-managers # see https://docs.djangoproject.com/fr/stable/topics/migrations/#model-managers
@@ -315,13 +324,24 @@ class User(AbstractUser):
parent_address = models.CharField( parent_address = models.CharField(
_("parent address"), max_length=128, blank=True, default="" _("parent address"), max_length=128, blank=True, default=""
) )
is_subscriber_viewable = models.BooleanField( is_viewable = models.BooleanField(
_("is subscriber viewable"), default=True _("Profile visible by subscribers"),
help_text=_(
"If you disable this option, only admin users "
"will be able to see your profile."
),
default=True,
) )
godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True) godfathers = models.ManyToManyField("User", related_name="godchildren", blank=True)
objects = CustomUserManager() objects = CustomUserManager()
class Meta(AbstractUser.Meta):
abstract = False
permissions = [
("view_hidden_user", "Can view hidden users"),
]
def __str__(self): def __str__(self):
return self.get_display_name() return self.get_display_name()
@@ -604,8 +624,12 @@ class User(AbstractUser):
def can_be_edited_by(self, user): def can_be_edited_by(self, user):
return user.is_root or user.is_board_member return user.is_root or user.is_board_member
def can_be_viewed_by(self, user): def can_be_viewed_by(self, user: User) -> bool:
return (user.was_subscribed and self.is_subscriber_viewable) or user.is_root return (
user.id == self.id
or user.has_perm("core.view_hidden_user")
or (user.has_perm("core.view_user") and self.is_viewable)
)
def get_mini_item(self): def get_mini_item(self):
return """ return """

View File

@@ -1,8 +1,9 @@
import { limitedChoices } from "#core:alpine/limited-choices";
import { alpinePlugin as notificationPlugin } from "#core:utils/notifications"; import { alpinePlugin as notificationPlugin } from "#core:utils/notifications";
import sort from "@alpinejs/sort"; import sort from "@alpinejs/sort";
import Alpine from "alpinejs"; import Alpine from "alpinejs";
Alpine.plugin(sort); Alpine.plugin([sort, limitedChoices]);
Alpine.magic("notifications", notificationPlugin); Alpine.magic("notifications", notificationPlugin);
window.Alpine = Alpine; window.Alpine = Alpine;

View File

@@ -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
* <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

@@ -7,10 +7,13 @@
.profile { .profile {
&-visible { &-visible {
display: flex; display: flex;
justify-content: center; flex-direction: column;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
padding-top: 10px; padding-top: 10px;
input[type="checkbox"]+label {
max-width: unset;
}
} }
&-pictures { &-pictures {
@@ -116,23 +119,19 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: var(--nf-input-size) 10px;
justify-content: center; justify-content: center;
} }
&-field { &-field {
display: flex; display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
gap: 10px;
width: 100%; width: 100%;
max-width: 330px; max-width: 330px;
min-width: 300px; min-width: 300px;
@media (max-width: 750px) { @media (max-width: 750px) {
gap: 4px;
max-width: 100%; max-width: 100%;
} }
@@ -145,22 +144,6 @@
} }
} }
&-label {
text-align: left !important;
}
&-content {
> * {
box-sizing: border-box;
text-align: left !important;
margin: 0;
> * {
text-align: left !important;
}
}
}
textarea { textarea {
height: 7rem; height: 7rem;
} }

View File

@@ -116,12 +116,12 @@
{# All fields #} {# All fields #}
<div class="profile-fields"> <div class="profile-fields">
{%- for field in form -%} {%- for field in form -%}
{%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_subscriber_viewable","forum_signature"] -%} {%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_viewable","forum_signature"] -%}
{%- continue -%} {%- continue -%}
{%- endif -%} {%- endif -%}
<div class="profile-field"> <div class="profile-field">
<div class="profile-field-label">{{ field.label }}</div> {{ field.label_tag() }}
<div class="profile-field-content"> <div class="profile-field-content">
{{ field }} {{ field }}
{%- if field.errors -%} {%- if field.errors -%}
@@ -136,7 +136,7 @@
<div class="profile-fields"> <div class="profile-fields">
{%- for field in [form.quote, form.forum_signature] -%} {%- for field in [form.quote, form.forum_signature] -%}
<div class="profile-field"> <div class="profile-field">
<div class="profile-field-label">{{ field.label }}</div> {{ field.label_tag() }}
<div class="profile-field-content"> <div class="profile-field-content">
{{ field }} {{ field }}
{%- if field.errors -%} {%- if field.errors -%}
@@ -149,8 +149,13 @@
{# Checkboxes #} {# Checkboxes #}
<div class="profile-visible"> <div class="profile-visible">
{{ form.is_subscriber_viewable }} <div class="row">
{{ form.is_subscriber_viewable.label }} {{ form.is_viewable }}
{{ form.is_viewable.label_tag() }}
</div>
<span class="helptext">
{{ form.is_viewable.help_text }}
</span>
</div> </div>
<div class="final-actions"> <div class="final-actions">

View File

@@ -55,7 +55,7 @@ class TestFetchFamilyApi(TestCase):
assert response.status_code == 403 assert response.status_code == 403
def test_fetch_family_hidden_user(self): def test_fetch_family_hidden_user(self):
self.main_user.is_subscriber_viewable = False self.main_user.is_viewable = False
self.main_user.save() self.main_user.save()
for user_to_login, error_code in [ for user_to_login, error_code in [
(self.main_user, 200), (self.main_user, 200),

View File

@@ -3,6 +3,7 @@ from datetime import timedelta
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.contrib import auth from django.contrib import auth
from django.contrib.auth.models import Permission
from django.core.management import call_command from django.core.management import call_command
from django.test import Client, RequestFactory, TestCase from django.test import Client, RequestFactory, TestCase
from django.urls import reverse from django.urls import reverse
@@ -18,7 +19,7 @@ from core.baker_recipes import (
subscriber_user, subscriber_user,
very_old_subscriber_user, very_old_subscriber_user,
) )
from core.models import Group, User from core.models import AnonymousUser, Group, User
from core.views import UserTabsMixin from core.views import UserTabsMixin
from counter.baker_recipes import sale_recipe from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Refilling, Selling from counter.models import Counter, Customer, Refilling, Selling
@@ -368,3 +369,38 @@ class TestRedirectMe:
def test_promo_has_logo(promo): def test_promo_has_logo(promo):
user = baker.make(User, promo=promo) user = baker.make(User, promo=promo)
assert user.promo_has_logo() assert user.promo_has_logo()
@pytest.mark.django_db
class TestUserQuerySetViewableBy:
@pytest.fixture
def users(self) -> list[User]:
return [
baker.make(User),
subscriber_user.make(),
subscriber_user.make(is_viewable=False),
]
def test_admin_user(self, users: list[User]):
user = baker.make(
User,
user_permissions=[Permission.objects.get(codename="view_hidden_user")],
)
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert set(viewable) == set(users)
@pytest.mark.parametrize(
"user_factory", [old_subscriber_user.make, subscriber_user.make]
)
def test_subscriber(self, users: list[User], user_factory):
user = user_factory()
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert set(viewable) == {users[0], users[1]}
@pytest.mark.parametrize(
"user_factory", [lambda: baker.make(User), lambda: AnonymousUser()]
)
def test_not_subscriber(self, users: list[User], user_factory):
user = user_factory()
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert not viewable.exists()

View File

@@ -202,7 +202,7 @@ class UserProfileForm(forms.ModelForm):
"school", "school",
"promo", "promo",
"forum_signature", "forum_signature",
"is_subscriber_viewable", "is_viewable",
] ]
widgets = { widgets = {
"date_of_birth": SelectDate, "date_of_birth": SelectDate,
@@ -211,8 +211,8 @@ class UserProfileForm(forms.ModelForm):
"quote": forms.Textarea, "quote": forms.Textarea,
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, label_suffix: str = "", **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, label_suffix=label_suffix, **kwargs)
# Image fields are injected here to override the file field provided by the model # Image fields are injected here to override the file field provided by the model
# This would be better if we could have a SithImage sort of model input instead of a generic SithFile # This would be better if we could have a SithImage sort of model input instead of a generic SithFile

View File

@@ -103,9 +103,7 @@ def password_root_change(request, user_id):
"""Allows a root user to change someone's password.""" """Allows a root user to change someone's password."""
if not request.user.is_root: if not request.user.is_root:
raise PermissionDenied raise PermissionDenied
user = User.objects.filter(id=user_id).first() user = get_object_or_404(User, id=user_id)
if not user:
raise Http404("User not found")
if request.method == "POST": if request.method == "POST":
form = views.SetPasswordForm(user=user, data=request.POST) form = views.SetPasswordForm(user=user, data=request.POST)
if form.is_valid(): if form.is_valid():

155
election/forms.py Normal file
View File

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

@@ -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",
),
),
]

View File

@@ -1,5 +1,7 @@
from django.db import models from django.db import models
from django.db.models import Count
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from ordered_model.models import OrderedModel from ordered_model.models import OrderedModel
@@ -22,21 +24,18 @@ class Election(models.Model):
verbose_name=_("edit groups"), verbose_name=_("edit groups"),
blank=True, blank=True,
) )
view_groups = models.ManyToManyField( view_groups = models.ManyToManyField(
Group, Group,
related_name="viewable_elections", related_name="viewable_elections",
verbose_name=_("view groups"), verbose_name=_("view groups"),
blank=True, blank=True,
) )
vote_groups = models.ManyToManyField( vote_groups = models.ManyToManyField(
Group, Group,
related_name="votable_elections", related_name="votable_elections",
verbose_name=_("vote groups"), verbose_name=_("vote groups"),
blank=True, blank=True,
) )
candidature_groups = models.ManyToManyField( candidature_groups = models.ManyToManyField(
Group, Group,
related_name="candidate_elections", related_name="candidate_elections",
@@ -45,7 +44,7 @@ class Election(models.Model):
) )
voters = models.ManyToManyField( 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) archived = models.BooleanField(_("archived"), default=False)
@@ -55,20 +54,20 @@ class Election(models.Model):
@property @property
def is_vote_active(self): def is_vote_active(self):
now = timezone.now() now = timezone.now()
return bool(now <= self.end_date and now >= self.start_date) return self.start_date <= now <= self.end_date
@property @property
def is_vote_finished(self): def is_vote_finished(self):
return bool(timezone.now() > self.end_date) return timezone.now() > self.end_date
@property @property
def is_candidature_active(self): def is_candidature_active(self):
now = timezone.now() now = timezone.now()
return bool(now <= self.end_candidature and now >= self.start_candidature) return self.start_candidature <= now <= self.end_candidature
@property @property
def is_vote_editable(self): def is_vote_editable(self):
return bool(timezone.now() <= self.end_candidature) return timezone.now() <= self.end_candidature
def can_candidate(self, user): def can_candidate(self, user):
for group_id in self.candidature_groups.values_list("pk", flat=True): 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): def has_voted(self, user):
return self.voters.filter(id=user.id).exists() return self.voters.filter(id=user.id).exists()
@property @cached_property
def results(self): def results(self):
results = {} results = {}
total_vote = self.voters.count() total_vote = self.voters.count()
@@ -95,12 +94,6 @@ class Election(models.Model):
results[role.title] = role.results(total_vote) results[role.title] = role.results(total_vote)
return results return results
def delete(self, *args, **kwargs):
self.election_lists.all().delete()
super().delete(*args, **kwargs)
# Permissions
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 avaliable for a candidature."""
@@ -115,36 +108,37 @@ class Role(OrderedModel):
description = models.TextField(_("description"), null=True, blank=True) description = models.TextField(_("description"), null=True, blank=True)
max_choice = models.IntegerField(_("max choice"), default=1) max_choice = models.IntegerField(_("max choice"), default=1)
def results(self, total_vote): def __str__(self):
results = {} return f"{self.title} - {self.election.title}"
total_vote *= self.max_choice
non_blank = 0 def results(self, total_vote: int) -> dict[str, dict[str, int | float]]:
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: if total_vote == 0:
results["blank vote"] = {"vote": 0, "percent": 0} candidates = self.candidatures.values_list("user__username")
else: return {
results["blank vote"] = { key: {"vote": 0, "percent": 0} for key in ["blank_votes", *candidates]
"vote": total_vote - non_blank,
"percent": (total_vote - non_blank) * 100 / total_vote,
} }
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 return results
@property @property
def edit_groups(self): def edit_groups(self):
return self.election.edit_groups return self.election.edit_groups
def __str__(self):
return ("%s : %s") % (self.election.title, self.title)
class ElectionList(models.Model): class ElectionList(models.Model):
"""To allow per list vote.""" """To allow per list vote."""
@@ -163,11 +157,6 @@ class ElectionList(models.Model):
def can_be_edited_by(self, user): def can_be_edited_by(self, user):
return user.can_edit(self.election) 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): class Candidature(models.Model):
"""This class is a component of responsability.""" """This class is a component of responsability."""
@@ -182,10 +171,9 @@ class Candidature(models.Model):
User, User,
verbose_name=_("user"), verbose_name=_("user"),
related_name="candidates", related_name="candidates",
blank=True,
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
program = models.TextField(_("description"), null=True, blank=True) program = models.TextField(_("description"), default="", blank=True)
election_list = models.ForeignKey( election_list = models.ForeignKey(
ElectionList, ElectionList,
related_name="candidatures", related_name="candidatures",
@@ -196,13 +184,10 @@ class Candidature(models.Model):
def __str__(self): def __str__(self):
return f"{self.role.title} : {self.user.username}" 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): 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): class Vote(models.Model):

View File

@@ -31,7 +31,7 @@
<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.has_voted(user) %} {%- if user_has_voted %}
<p class="election__elector-infos"> <p class="election__elector-infos">
{%- if election.is_vote_active %} {%- if election.is_vote_active %}
<span>{% trans %}You already have submitted your vote.{% endtrans %}</span> <span>{% trans %}You already have submitted your vote.{% endtrans %}</span>
@@ -45,12 +45,11 @@
<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">
{%- set election_lists = election.election_lists.all() -%}
<thead class="lists"> <thead class="lists">
<tr> <tr>
<th class="column" style="width: {{ 100 / (election_lists.count() + 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.count() + 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>
@@ -59,18 +58,26 @@
{%- endfor %} {%- endfor %}
</tr> </tr>
</thead> </thead>
{%- set role_list = election.roles.order_by('order').all() %} {%- for role in election_roles %}
{%- 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 [] %} {%- set role_data = election_form.data.getlist(role.title) if role.title in election_form.data else [] %}
<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 ''}}">
<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 %}"
>
<tr> <tr>
<td class="role_title"> <td class="role_title">
<div class="role_text"> <div class="role_text">
<h4>{{ role.title }}</h4> <h4>{{ role.title }}</h4>
<p class="role_description" show-more="300">{{ role.description }}</p> <p class="role_description" show-more="300">{{ role.description }}</p>
{%- if role.max_choice > 1 and not election.has_voted(user) and election.can_vote(user) %} {%- if role.max_choice > 1 and show_vote_buttons %}
<strong>{% trans %}You may choose up to{% endtrans %} {{ role.max_choice }} {% trans %}people.{% endtrans %}</strong> <strong>
{% trans trimmed nb_choices=role.max_choice %}
You may choose up to {{ nb_choices }} people.
{% endtrans %}
</strong>
{%- endif %} {%- endif %}
{%- if election_form.errors[role.title] is defined %} {%- if election_form.errors[role.title] is defined %}
@@ -81,36 +88,40 @@
</div> </div>
{% if user.can_edit(role) and election.is_vote_editable -%} {% if user.can_edit(role) and election.is_vote_editable -%}
<div class="role_buttons"> <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:update_role', role_id=role.id) }}">
<a href="{{url('election:delete_role', role_id=role.id)}}"><i class="fa-regular fa-trash-can delete-action"></i></a> <i class="fa-regular fa-pen-to-square edit-action"></i>
{%- if role == role_list.last() %} </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 -%}
<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 type="button" onclick="window.location.replace('?role={{ role.id }}&action=bottom');"><i class="fa fa-arrow-down"></i></button>
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=down');"><i class="fa fa-caret-down"></i></button> <button type="button" onclick="window.location.replace('?role={{ role.id }}&action=down');"><i class="fa fa-caret-down"></i></button>
{%- endif %} {%- endif -%}
{% if role == role_list.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 type="button" onclick="window.location.replace('?role={{ role.id }}&action=up');"><i class="fa fa-caret-up"></i></button>
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=top');"><i class="fa fa-arrow-up"></i></button> <button type="button" onclick="window.location.replace('?role={{ role.id }}&action=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.count() + 1) }}%"> <td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%">
{%- if role.max_choice == 1 and election.can_vote(user) %} {%- if role.max_choice == 1 and show_vote_buttons %}
<div class="radio-btn"> <div class="radio-btn">
<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 '' }}> {% set input_id = "blank_vote_" + role.id|string %}
<label for="id_{{ role.title }}_{{ count[0] }}"> <input id="{{ input_id }}" type="radio" name="{{ role.title }}">
<label for="{{ input_id }}">
<span>{% trans %}Choose blank vote{% endtrans %}</span> <span>{% trans %}Choose blank vote{% endtrans %}</span>
</label> </label>
</div> </div>
{%- set _ = count.append(count.pop() + 1) %}
{%- endif %} {%- endif %}
{%- 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'] %}
@@ -120,16 +131,17 @@
{%- 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.count() + 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.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 election.can_vote(user) %} {%- if show_vote_buttons %}
<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 }}"> {% set input_id = "candidature_" + candidature.id|string %}
<label for="id_{{ role.title }}_{{ count[0] }}"> <input id="{{ input_id }}" type="{{ 'checkbox' if role.max_choice > 1 else 'radio' }}" {{ 'checked' if candidature.id|string in role_data else '' }} {{ 'disabled' if user_has_voted else '' }} name="{{ role.title }}" value="{{ candidature.id }}">
<label for="{{ input_id }}">
{%- endif %} {%- endif %}
<figure> <figure>
{%- if user.is_subscriber_viewable %} {%- if user.is_viewable %}
{% 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 %}
@@ -140,7 +152,7 @@
<h5>{{ candidature.user.first_name }} <em>{{candidature.user.nick_name or ''}} </em>{{ candidature.user.last_name }}</h5> <h5>{{ candidature.user.first_name }} <em>{{candidature.user.nick_name or ''}} </em>{{ candidature.user.last_name }}</h5>
{%- if not election.is_vote_finished %} {%- if not election.is_vote_finished %}
<q class="candidate_program" show-more="200"> <q class="candidate_program" show-more="200">
{{ candidature.program|markdown or '' }} {{ candidature.program|markdown }}
</q> </q>
{%- endif %} {%- endif %}
</figcaption> </figcaption>
@@ -153,9 +165,8 @@
{%- endif -%} {%- endif -%}
{%- endif -%} {%- endif -%}
</figure> </figure>
{%- if election.can_vote(user) %} {%- if show_vote_buttons %}
</label> </label>
{%- set _ = count.append(count.pop() + 1) %}
{%- endif %} {%- endif %}
{%- if election.is_vote_finished %} {%- if election.is_vote_finished %}
{%- set results = election_results[role.title][candidature.user.username] %} {%- set results = election_results[role.title][candidature.user.username] %}
@@ -191,36 +202,9 @@
<a class="button" href="{{ url('election:delete', election_id=object.id) }}">{% trans %}Delete{% endtrans %}</a> <a class="button" href="{{ url('election:delete', election_id=object.id) }}">{% trans %}Delete{% endtrans %}</a>
{%- endif %} {%- endif %}
</section> </section>
{%- if not election.has_voted(user) and election.can_vote(user) %} {%- if show_vote_buttons %}
<section class="buttons"> <section class="buttons">
<button class="button button_send" form="vote-form">{% trans %}Submit the vote !{% endtrans %}</button> <button class="button button_send" form="vote-form">{% trans %}Submit the vote !{% endtrans %}</button>
</section> </section>
{%- endif %} {%- endif %}
{% endblock %} {% 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,9 +1,15 @@
from django.conf import settings from datetime import timedelta
from django.test import TestCase
from django.urls import reverse
import pytest
from django.conf import settings
from django.test import Client, 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 core.models import Group, User
from election.models import Election from election.models import Candidature, Election, ElectionList, Role, Vote
class TestElection(TestCase): class TestElection(TestCase):
@@ -12,8 +18,7 @@ class TestElection(TestCase):
cls.election = Election.objects.first() cls.election = Election.objects.first()
cls.public_group = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID) cls.public_group = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)
cls.sli = User.objects.get(username="sli") cls.sli = User.objects.get(username="sli")
cls.subscriber = User.objects.get(username="subscriber") cls.public = baker.make(User)
cls.public = User.objects.get(username="public")
class TestElectionDetail(TestElection): class TestElectionDetail(TestElection):
@@ -36,7 +41,7 @@ class TestElectionDetail(TestElection):
class TestElectionUpdateView(TestElection): class TestElectionUpdateView(TestElection):
def test_permission_denied(self): def test_permission_denied(self):
self.client.force_login(self.subscriber) self.client.force_login(subscriber_user.make())
response = self.client.get( response = self.client.get(
reverse("election:update", args=str(self.election.id)) reverse("election:update", args=str(self.election.id))
) )
@@ -45,3 +50,68 @@ class TestElectionUpdateView(TestElection):
reverse("election:update", args=str(self.election.id)) reverse("election:update", args=str(self.election.id))
) )
assert response.status_code == 403 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,183 +1,34 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from django import forms from cryptography.utils import cached_property
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.conf import settings
from django.contrib.auth.mixins import (
LoginRequiredMixin,
PermissionRequiredMixin,
UserPassesTestMixin,
)
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
from django.db.models.query import QuerySet from django.db.models import QuerySet
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy 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 import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
from core.auth.mixins import CanCreateMixin, CanEditMixin, CanViewMixin from core.auth.mixins import CanEditMixin, CanViewMixin
from core.views.forms import SelectDateTime from election.forms import (
from core.views.widgets.ajax_select import ( CandidateForm,
AutoCompleteSelect, ElectionForm,
AutoCompleteSelectMultipleGroup, ElectionListForm,
AutoCompleteSelectUser, RoleForm,
VoteForm,
) )
from core.views.widgets.markdown import MarkdownInput
from election.models import Candidature, Election, ElectionList, Role, Vote from election.models import Candidature, Election, ElectionList, Role, Vote
if TYPE_CHECKING: if TYPE_CHECKING:
from core.models import User 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 # Display elections
@@ -185,25 +36,21 @@ class ElectionsListView(CanViewMixin, ListView):
"""A list of all non archived elections visible.""" """A list of all non archived elections visible."""
model = Election model = Election
queryset = model.objects.filter(archived=False)
ordering = ["-id"] ordering = ["-id"]
paginate_by = 10 paginate_by = 10
template_name = "election/election_list.jinja" template_name = "election/election_list.jinja"
def get_queryset(self):
return super().get_queryset().filter(archived=False).all()
class ElectionListArchivedView(CanViewMixin, ListView): class ElectionListArchivedView(CanViewMixin, ListView):
"""A list of all archived elections visible.""" """A list of all archived elections visible."""
model = Election model = Election
queryset = model.objects.filter(archived=True)
ordering = ["-id"] ordering = ["-id"]
paginate_by = 10 paginate_by = 10
template_name = "election/election_list.jinja" template_name = "election/election_list.jinja"
def get_queryset(self):
return super().get_queryset().filter(archived=True).all()
class ElectionDetailView(CanViewMixin, DetailView): class ElectionDetailView(CanViewMixin, DetailView):
"""Details an election responsability by responsability.""" """Details an election responsability by responsability."""
@@ -212,46 +59,67 @@ class ElectionDetailView(CanViewMixin, DetailView):
template_name = "election/election_detail.jinja" template_name = "election/election_detail.jinja"
pk_url_kwarg = "election_id" 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): def get(self, request, *arg, **kwargs):
response = super().get(request, *arg, **kwargs)
election: Election = self.get_object() election: Election = self.get_object()
if request.user.can_edit(election) and election.is_vote_editable: if election.is_vote_editable and request.user.can_edit(election):
action = request.GET.get("action", None) action = request.GET.get("action", None)
role = request.GET.get("role", None) role = request.GET.get("role", None)
if action and role and Role.objects.filter(id=role).exists(): if action and role and role.isdigit():
if action == "up": self._reorder_votes(action, int(role))
Role.objects.get(id=role).up() return super().get(request, *arg, **kwargs)
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): def get_context_data(self, **kwargs):
"""Add additionnal data to the template.""" """Add additionnal data to the template."""
kwargs = super().get_context_data(**kwargs) user: User = self.request.user
kwargs["election_form"] = VoteForm(self.object, self.request.user) return super().get_context_data(**kwargs) | {
kwargs["election_results"] = self.object.results "election_form": VoteForm(self.object, user),
return kwargs "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")),
}
# Form view # Form view
class VoteFormView(CanCreateMixin, FormView): class VoteFormView(LoginRequiredMixin, UserPassesTestMixin, FormView):
"""Alows users to vote.""" """Alows users to vote."""
form_class = VoteForm form_class = VoteForm
template_name = "election/election_detail.jinja" template_name = "election/election_detail.jinja"
def dispatch(self, request, *arg, **kwargs): @cached_property
self.election = get_object_or_404(Election, pk=kwargs["election_id"]) def election(self):
return super().dispatch(request, *arg, **kwargs) 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 vote(self, election_data): def vote(self, election_data):
with transaction.atomic(): with transaction.atomic():
@@ -271,20 +139,16 @@ class VoteFormView(CanCreateMixin, FormView):
self.election.voters.add(self.request.user) self.election.voters.add(self.request.user)
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() return super().get_form_kwargs() | {
kwargs["election"] = self.election "election": self.election,
kwargs["user"] = self.request.user "user": self.request.user,
return kwargs }
def form_valid(self, form): def form_valid(self, form):
"""Verify that the user is part in a vote group.""" """Verify that the user is part in a vote group."""
data = form.clean() data = form.clean()
res = super(FormView, self).form_valid(form) self.vote(data)
for grp_id in self.election.vote_groups.values_list("pk", flat=True): return super().form_valid(form)
if self.request.user.is_in_group(pk=grp_id):
self.vote(data)
return res
return res
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id}) return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})
@@ -310,26 +174,22 @@ class CandidatureCreateView(LoginRequiredMixin, CreateView):
def dispatch(self, request, *arg, **kwargs): def dispatch(self, request, *arg, **kwargs):
self.election = get_object_or_404(Election, pk=kwargs["election_id"]) 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) return super().dispatch(request, *arg, **kwargs)
def get_initial(self): def get_initial(self):
init = {} return {"user": self.request.user.id}
self.can_edit = self.request.user.can_edit(self.election)
init["user"] = self.request.user.id
return init
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() return super().get_form_kwargs() | {
kwargs["election_id"] = self.election.id "election": self.election,
kwargs["can_edit"] = self.can_edit "can_edit": self.can_edit,
return kwargs }
def form_valid(self, form): def form_valid(self, form: CandidateForm):
"""Verify that the selected user is in candidate group.""" """Verify that the selected user is in candidate group."""
obj = form.instance obj = form.instance
obj.election = self.election obj.election = self.election
if not hasattr(obj, "user"):
obj.user = self.request.user
if (obj.election.can_candidate(obj.user)) and ( if (obj.election.can_candidate(obj.user)) and (
obj.user == self.request.user or self.can_edit obj.user == self.request.user or self.can_edit
): ):
@@ -337,9 +197,7 @@ class CandidatureCreateView(LoginRequiredMixin, CreateView):
raise PermissionDenied raise PermissionDenied
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs) return super().get_context_data(**kwargs) | {"election": self.election}
kwargs["election"] = self.election
return kwargs
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id}) return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})
@@ -355,80 +213,79 @@ class ElectionCreateView(PermissionRequiredMixin, CreateView):
return reverse("election:detail", kwargs={"election_id": self.object.id}) return reverse("election:detail", kwargs={"election_id": self.object.id})
class RoleCreateView(CanCreateMixin, CreateView): class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
model = Role model = Role
form_class = RoleForm form_class = RoleForm
template_name = "core/create.jinja" template_name = "core/create.jinja"
def dispatch(self, request, *arg, **kwargs): @cached_property
self.election = get_object_or_404(Election, pk=kwargs["election_id"]) def election(self):
return get_object_or_404(Election, pk=self.kwargs["election_id"])
def test_func(self):
if not self.election.is_vote_editable: if not self.election.is_vote_editable:
raise PermissionDenied return False
return super().dispatch(request, *arg, **kwargs) 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()
def get_initial(self): def get_initial(self):
init = {} return {"election": self.election}
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): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() return super().get_form_kwargs() | {"election_id": self.election.id}
kwargs["election_id"] = self.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.object.election.id} "election:detail", kwargs={"election_id": self.object.election_id}
) )
class ElectionListCreateView(CanCreateMixin, CreateView): class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
model = ElectionList model = ElectionList
form_class = ElectionListForm form_class = ElectionListForm
template_name = "core/create.jinja" template_name = "core/create.jinja"
def dispatch(self, request, *arg, **kwargs): @cached_property
self.election = get_object_or_404(Election, pk=kwargs["election_id"]) def election(self):
return get_object_or_404(Election, pk=self.kwargs["election_id"])
def test_func(self):
if not self.election.is_vote_editable: if not self.election.is_vote_editable:
raise PermissionDenied return False
return super().dispatch(request, *arg, **kwargs) 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()
def get_initial(self): def get_initial(self):
init = {} return {"election": self.election}
init["election"] = self.election
return init
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() return super().get_form_kwargs() | {"election_id": self.election.id}
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): def get_success_url(self, **kwargs):
return reverse_lazy( return reverse(
"election:detail", kwargs={"election_id": self.object.election.id} "election:detail", kwargs={"election_id": self.object.election_id}
) )
@@ -457,45 +314,23 @@ class ElectionUpdateView(CanEditMixin, UpdateView):
return reverse_lazy("election:detail", kwargs={"election_id": self.object.id}) return reverse_lazy("election:detail", kwargs={"election_id": self.object.id})
class CandidatureUpdateView(CanEditMixin, UpdateView): class CandidatureUpdateView(LoginRequiredMixin, CanEditMixin, UpdateView):
model = Candidature model = Candidature
form_class = CandidateForm form_class = CandidateForm
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
pk_url_kwarg = "candidature_id" pk_url_kwarg = "candidature_id"
def dispatch(self, request, *arg, **kwargs): def get_form(self, *args, **kwargs):
self.object = self.get_object() form = super().get_form(*args, **kwargs)
if not self.object.role.election.is_vote_editable: form.fields.pop("role", None)
raise PermissionDenied return form
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): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() return super().get_form_kwargs() | {"election": self.object.role.election}
kwargs["election_id"] = self.object.role.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.object.role.election.id} "election:detail", kwargs={"election_id": self.object.role.election_id}
) )
@@ -546,18 +381,12 @@ class RoleUpdateView(CanEditMixin, UpdateView):
# Delete Views # Delete Views
class ElectionDeleteView(DeleteView): class ElectionDeleteView(PermissionRequiredMixin, DeleteView):
model = Election model = Election
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
pk_url_kwarg = "election_id" pk_url_kwarg = "election_id"
permission_required = "election.delete_election"
def dispatch(self, request, *args, **kwargs): success_url = reverse_lazy("election:list")
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): class CandidatureDeleteView(CanEditMixin, DeleteView):
@@ -573,7 +402,7 @@ class CandidatureDeleteView(CanEditMixin, DeleteView):
return super().dispatch(request, *arg, **kwargs) return super().dispatch(request, *arg, **kwargs)
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id}) return reverse("election:detail", kwargs={"election_id": self.election.id})
class RoleDeleteView(CanEditMixin, DeleteView): class RoleDeleteView(CanEditMixin, DeleteView):
@@ -589,7 +418,7 @@ class RoleDeleteView(CanEditMixin, DeleteView):
return super().dispatch(request, *arg, **kwargs) return super().dispatch(request, *arg, **kwargs)
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id}) return reverse("election:detail", kwargs={"election_id": self.election.id})
class ElectionListDeleteView(CanEditMixin, DeleteView): class ElectionListDeleteView(CanEditMixin, DeleteView):
@@ -605,4 +434,4 @@ class ElectionListDeleteView(CanEditMixin, DeleteView):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_success_url(self, **kwargs): def get_success_url(self, **kwargs):
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id}) return reverse("election:detail", kwargs={"election_id": self.election.id})

View File

@@ -199,7 +199,7 @@ class Galaxy(models.Model):
cls, picture_count_threshold: int = DEFAULT_PICTURE_COUNT_THRESHOLD cls, picture_count_threshold: int = DEFAULT_PICTURE_COUNT_THRESHOLD
) -> QuerySet[User]: ) -> QuerySet[User]:
return ( return (
User.objects.filter(is_subscriber_viewable=True) User.objects.filter(is_viewable=True)
.exclude(subscriptions=None) .exclude(subscriptions=None)
.annotate( .annotate(
pictures_count=Count("pictures"), pictures_count=Count("pictures"),

View File

@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-07 14:50+0100\n" "POT-Creation-Date: 2025-11-09 18:03+0100\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,7 +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/views.py #: club/forms.py com/forms.py counter/forms.py election/forms.py
#: subscription/forms.py #: subscription/forms.py
msgid "End date" msgid "End date"
msgstr "Date de fin" msgstr "Date de fin"
@@ -715,7 +715,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/views.py subscription/forms.py #: com/forms.py election/forms.py subscription/forms.py
msgid "Start date" msgid "Start date"
msgstr "Date de début" msgstr "Date de début"
@@ -1532,8 +1532,15 @@ msgid "parent address"
msgstr "adresse des parents" msgstr "adresse des parents"
#: core/models.py #: core/models.py
msgid "is subscriber viewable" msgid "Profile visible by subscribers"
msgstr "profil visible par les cotisants" msgstr "Profil visible par les cotisants"
#: core/models.py
msgid ""
"If you disable this option, only admin users will be able to see your "
"profile."
msgstr ""
"Si vous désactivez cette option, seuls les admins pourront voir votre profil."
#: core/models.py #: core/models.py
msgid "A user with that username already exists" msgid "A user with that username already exists"
@@ -4043,6 +4050,30 @@ msgstr ""
msgid "You can't buy a refilling with sith money" msgid "You can't buy a refilling with sith money"
msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith" 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 #: election/models.py
msgid "start candidature" msgid "start candidature"
msgstr "début des candidatures" msgstr "début des candidatures"
@@ -4067,6 +4098,10 @@ msgstr "groupe de vote"
msgid "candidature groups" msgid "candidature groups"
msgstr "groupe de candidature" msgstr "groupe de candidature"
#: election/models.py
msgid "voters"
msgstr "électeurs"
#: election/models.py #: election/models.py
msgid "election" msgid "election"
msgstr "élection" msgstr "élection"
@@ -4122,17 +4157,10 @@ msgstr "Vous avez déjà soumis votre vote."
msgid "You have voted in this election." msgid "You have voted in this election."
msgstr "Vous avez déjà voté pour cette élection." 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 #: election/templates/election/election_detail.jinja
msgid "You may choose up to" #, python-format
msgstr "Vous pouvez choisir jusqu'à" msgid "You may choose up to %(nb_choices)s people."
msgstr "Vous pouvez choisir jusqu'à %(nb_choices)s personnes."
#: election/templates/election/election_detail.jinja
msgid "people."
msgstr "personne(s)"
#: election/templates/election/election_detail.jinja #: election/templates/election/election_detail.jinja
msgid "Choose blank vote" msgid "Choose blank vote"
@@ -4174,26 +4202,6 @@ msgstr "au"
msgid "Polls open from" msgid "Polls open from"
msgstr "Votes ouverts du" 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 #: forum/models.py
msgid "is a category" msgid "is a category"
msgstr "est une catégorie" msgstr "est une catégorie"
@@ -5111,14 +5119,6 @@ msgstr "Membre de Sbarro ou de l'ESTA"
msgid "One semester Welcome Week" msgid "One semester Welcome Week"
msgstr "Un semestre Welcome Week" msgstr "Un semestre Welcome Week"
#: sith/settings.py
msgid "One month for free"
msgstr "Un mois gratuit"
#: sith/settings.py
msgid "Two months for free"
msgstr "Deux mois gratuits"
#: sith/settings.py #: sith/settings.py
msgid "Eurok's volunteer" msgid "Eurok's volunteer"
msgstr "Bénévole Eurockéennes" msgstr "Bénévole Eurockéennes"
@@ -5132,7 +5132,9 @@ msgid "One day"
msgstr "Un jour" msgstr "Un jour"
#: sith/settings.py #: sith/settings.py
msgid "GA staff member (2 weeks)" #, fuzzy
#| msgid "GA staff member (2 weeks)"
msgid "GA staff member"
msgstr "Membre staff GA (2 semaines)" msgstr "Membre staff GA (2 semaines)"
#: sith/settings.py #: sith/settings.py
@@ -5676,3 +5678,12 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
#, python-format #, python-format
msgid "Maximum characters: %(max_length)s" msgid "Maximum characters: %(max_length)s"
msgstr "Nombre de caractères max: %(max_length)s" msgstr "Nombre de caractères max: %(max_length)s"
#~ msgid "is viewable"
#~ msgstr "profil visible"
#~ msgid "One month for free"
#~ msgstr "Un mois gratuit"
#~ msgid "Two months for free"
#~ msgstr "Deux mois gratuits"

View File

@@ -105,7 +105,7 @@ class SearchFormListView(FormerSubscriberMixin, SingleObjectMixin, ListView):
self.can_see_hidden = True self.can_see_hidden = True
if not (request.user.is_board_member or request.user.is_root): if not (request.user.is_board_member or request.user.is_root):
self.can_see_hidden = False self.can_see_hidden = False
self.init_query = self.init_query.exclude(is_subscriber_viewable=False) self.init_query = self.init_query.filter(is_viewable=True)
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
@@ -130,7 +130,7 @@ class SearchFormListView(FormerSubscriberMixin, SingleObjectMixin, ListView):
else: else:
q = [] q = []
if not self.can_see_hidden and len(q) > 0: if not self.can_see_hidden and len(q) > 0:
q = [user for user in q if user.is_subscriber_viewable] q = [user for user in q if user.is_viewable]
else: else:
search_dict = {} search_dict = {}
for key, value in self.valid_form.items(): for key, value in self.valid_form.items():

View File

@@ -143,11 +143,14 @@ class PicturesController(ControllerBase):
"/{picture_id}/identified", "/{picture_id}/identified",
permissions=[IsAuthenticated, CanView], permissions=[IsAuthenticated, CanView],
response=list[IdentifiedUserSchema], response=list[IdentifiedUserSchema],
url_name="picture_identifications",
) )
def fetch_identifications(self, picture_id: int): def fetch_identifications(self, picture_id: int):
"""Fetch the users that have been identified on the given picture.""" """Fetch the users that have been identified on the given picture."""
picture = self.get_object_or_exception(Picture, pk=picture_id) picture = self.get_object_or_exception(Picture, pk=picture_id)
return picture.people.select_related("user") return picture.people.viewable_by(self.context.request.user).select_related(
"user"
)
@route.put("/{picture_id}/identified", permissions=[IsAuthenticated, CanView]) @route.put("/{picture_id}/identified", permissions=[IsAuthenticated, CanView])
def identify_users(self, picture_id: NonNegativeInt, users: set[NonNegativeInt]): def identify_users(self, picture_id: NonNegativeInt, users: set[NonNegativeInt]):

View File

@@ -265,6 +265,15 @@ def sas_notification_callback(notif: Notification):
notif.param = str(count) notif.param = str(count)
class PeoplePictureRelationQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self:
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
return self
if user.was_subscribed:
return self.filter(Q(user_id=user.id) | Q(user__is_viewable=True))
return self.filter(user_id=user.id)
class PeoplePictureRelation(models.Model): class PeoplePictureRelation(models.Model):
"""The PeoplePictureRelation class makes the connection between User and Picture.""" """The PeoplePictureRelation class makes the connection between User and Picture."""
@@ -281,6 +290,8 @@ class PeoplePictureRelation(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
objects = PeoplePictureRelationQuerySet.as_manager()
class Meta: class Meta:
unique_together = ["user", "picture"] unique_together = ["user", "picture"]

View File

@@ -186,6 +186,29 @@ class TestPictureRelation(TestSas):
assert res.status_code == 404 assert res.status_code == 404
assert PeoplePictureRelation.objects.count() == relation_count assert PeoplePictureRelation.objects.count() == relation_count
def test_fetch_relations_including_hidden_users(self):
"""Test that normal subscribers users cannot see hidden profiles"""
picture = self.album_a.children_pictures.last()
self.user_a.is_viewable = False
self.user_a.save()
url = reverse("api:picture_identifications", kwargs={"picture_id": picture.id})
# a normal subscriber user shouldn't see user_a as identified
self.client.force_login(subscriber_user.make())
response = self.client.get(url)
data = {user["user"]["id"] for user in response.json()}
assert data == {self.user_b.id, self.user_c.id}
# an admin should see everyone
self.client.force_login(
baker.make(
User, groups=[Group.objects.get(id=settings.SITH_GROUP_SAS_ADMIN_ID)]
)
)
response = self.client.get(url)
data = {user["user"]["id"] for user in response.json()}
assert data == {self.user_a.id, self.user_b.id, self.user_c.id}
class TestPictureModeration(TestSas): class TestPictureModeration(TestSas):
@classmethod @classmethod

View File

@@ -1,10 +1,11 @@
import pytest
from django.test import TestCase from django.test import TestCase
from model_bakery import baker from model_bakery import baker
from core.baker_recipes import old_subscriber_user, subscriber_user from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import User from core.models import User
from sas.baker_recipes import picture_recipe from sas.baker_recipes import picture_recipe
from sas.models import Picture from sas.models import PeoplePictureRelation, Picture
class TestPictureQuerySet(TestCase): class TestPictureQuerySet(TestCase):
@@ -44,3 +45,25 @@ class TestPictureQuerySet(TestCase):
user.pictures.create(picture=self.pictures[1]) # moderated user.pictures.create(picture=self.pictures[1]) # moderated
pictures = list(Picture.objects.viewable_by(user)) pictures = list(Picture.objects.viewable_by(user))
assert pictures == [self.pictures[1]] assert pictures == [self.pictures[1]]
@pytest.mark.django_db
def test_identifications_viewable_by_user():
picture = baker.make(Picture)
identifications = baker.make(
PeoplePictureRelation, picture=picture, _quantity=10, _bulk_create=True
)
identifications[0].user.is_viewable = False
identifications[0].user.save()
assert (
list(picture.people.viewable_by(old_subscriber_user.make()))
== identifications[1:]
)
assert (
list(picture.people.viewable_by(baker.make(User, is_superuser=True)))
== identifications
)
assert list(picture.people.viewable_by(identifications[1].user)) == [
identifications[1]
]