Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
3e258f5940 [UPDATE] Update redis[hiredis] requirement from <7,>=5.3.0 to >=5.3.0,<8
Updates the requirements on [redis[hiredis]](https://github.com/redis/redis-py) to permit the latest version.
- [Release notes](https://github.com/redis/redis-py/releases)
- [Changelog](https://github.com/redis/redis-py/blob/master/CHANGES)
- [Commits](https://github.com/redis/redis-py/compare/v5.3.0...v7.0.0)

---
updated-dependencies:
- dependency-name: redis[hiredis]
  dependency-version: 7.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-07 18:17:03 +00:00
28 changed files with 518 additions and 805 deletions

View File

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

View File

@@ -74,19 +74,9 @@ class UserBanAdmin(admin.ModelAdmin):
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)
class PermissionAdmin(admin.ModelAdmin):
search_fields = ("codename",)
inlines = (GroupInline,)
@admin.register(Page)

View File

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

View File

@@ -150,8 +150,7 @@ class Command(BaseCommand):
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)
skia = User.objects.create_user(

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -3,7 +3,6 @@ from datetime import timedelta
import pytest
from django.conf import settings
from django.contrib import auth
from django.contrib.auth.models import Permission
from django.core.management import call_command
from django.test import Client, RequestFactory, TestCase
from django.urls import reverse
@@ -19,7 +18,7 @@ from core.baker_recipes import (
subscriber_user,
very_old_subscriber_user,
)
from core.models import AnonymousUser, Group, User
from core.models import Group, User
from core.views import UserTabsMixin
from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Refilling, Selling
@@ -369,38 +368,3 @@ class TestRedirectMe:
def test_promo_has_logo(promo):
user = baker.make(User, promo=promo)
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",
"promo",
"forum_signature",
"is_viewable",
"is_subscriber_viewable",
]
widgets = {
"date_of_birth": SelectDate,
@@ -211,8 +211,8 @@ class UserProfileForm(forms.ModelForm):
"quote": forms.Textarea,
}
def __init__(self, *args, label_suffix: str = "", **kwargs):
super().__init__(*args, label_suffix=label_suffix, **kwargs)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# 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

View File

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

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,27 +115,23 @@ 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,
@@ -139,6 +142,9 @@ class Role(OrderedModel):
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,17 +120,16 @@
{%- 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_viewable %}
{%- if user.is_subscriber_viewable %}
{% if candidature.user.profile_pict %}
<img class="candidate__picture" src="{{ candidature.user.profile_pict.get_download_url() }}" alt="{% trans %}Profile{% endtrans %}">
{% else %}
@@ -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()
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 super().form_valid(form)
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

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

View File

@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-09 18:03+0100\n"
"POT-Creation-Date: 2025-11-07 14:50+0100\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"
@@ -715,7 +715,7 @@ msgstr "Prix d'achat"
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"
@@ -1532,15 +1532,8 @@ msgid "parent address"
msgstr "adresse des parents"
#: core/models.py
msgid "Profile visible by subscribers"
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."
msgid "is subscriber viewable"
msgstr "profil visible par les cotisants"
#: core/models.py
msgid "A user with that username already exists"
@@ -4050,30 +4043,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"
@@ -4098,10 +4067,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"
@@ -4157,10 +4122,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"
@@ -4202,6 +4174,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"
@@ -5119,6 +5111,14 @@ msgstr "Membre de Sbarro ou de l'ESTA"
msgid "One semester 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
msgid "Eurok's volunteer"
msgstr "Bénévole Eurockéennes"
@@ -5132,9 +5132,7 @@ msgid "One day"
msgstr "Un jour"
#: sith/settings.py
#, fuzzy
#| msgid "GA staff member (2 weeks)"
msgid "GA staff member"
msgid "GA staff member (2 weeks)"
msgstr "Membre staff GA (2 semaines)"
#: sith/settings.py
@@ -5678,12 +5676,3 @@ msgstr "Vous ne pouvez plus écrire de commentaires, la date est passée."
#, python-format
msgid "Maximum characters: %(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
if not (request.user.is_board_member or request.user.is_root):
self.can_see_hidden = False
self.init_query = self.init_query.filter(is_viewable=True)
self.init_query = self.init_query.exclude(is_subscriber_viewable=False)
return super().dispatch(request, *args, **kwargs)
@@ -130,7 +130,7 @@ class SearchFormListView(FormerSubscriberMixin, SingleObjectMixin, ListView):
else:
q = []
if not self.can_see_hidden and len(q) > 0:
q = [user for user in q if user.is_viewable]
q = [user for user in q if user.is_subscriber_viewable]
else:
search_dict = {}
for key, value in self.valid_form.items():

View File

@@ -44,7 +44,7 @@ dependencies = [
"django-honeypot>=1.3.0,<2",
"pydantic-extra-types>=2.10.6,<3.0.0",
"ical>=11.1.0,<12",
"redis[hiredis]<7,>=5.3.0",
"redis[hiredis]>=5.3.0,<8",
"environs[django]>=14.5.0,<15.0.0",
"requests>=2.32.5,<3.0.0",
"honcho>=2.0.0",

View File

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

View File

@@ -265,15 +265,6 @@ def sas_notification_callback(notif: Notification):
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):
"""The PeoplePictureRelation class makes the connection between User and Picture."""
@@ -290,8 +281,6 @@ class PeoplePictureRelation(models.Model):
on_delete=models.CASCADE,
)
objects = PeoplePictureRelationQuerySet.as_manager()
class Meta:
unique_together = ["user", "picture"]

View File

@@ -186,29 +186,6 @@ class TestPictureRelation(TestSas):
assert res.status_code == 404
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):
@classmethod

View File

@@ -1,11 +1,10 @@
import pytest
from django.test import TestCase
from model_bakery import baker
from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import User
from sas.baker_recipes import picture_recipe
from sas.models import PeoplePictureRelation, Picture
from sas.models import Picture
class TestPictureQuerySet(TestCase):
@@ -45,25 +44,3 @@ class TestPictureQuerySet(TestCase):
user.pictures.create(picture=self.pictures[1]) # moderated
pictures = list(Picture.objects.viewable_by(user))
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]
]