Compare commits

..

3 Commits

Author SHA1 Message Date
dependabot[bot]
c625db4316 Bump vite from 6.3.6 to 6.4.1
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.6 to 6.4.1.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@6.4.1/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.4.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-21 03:43:06 +00:00
Kenneth Soares
710b4aa942 Merge pull request #1213 from ae-utbm/taiste
HTMX, Alpine, Invoice Calls, Products, Bugfixes, Other
2025-10-18 17:29:15 +02:00
Kenneth Soares
5fee2e4720 Merge pull request #1180 from ae-utbm/taiste
Com, Subscriptions, Posters, Others
2025-09-19 21:31:28 +02:00
62 changed files with 1922 additions and 2392 deletions

View File

@@ -1,7 +1,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.14.4
rev: v0.11.13
hooks:
- id: ruff-check # just check the code, and print the errors
- id: ruff-check # actually fix the fixable errors, but print nothing
@@ -14,7 +14,7 @@ repos:
- id: biome-check
additional_dependencies: ["@biomejs/biome@1.9.4"]
- repo: https://github.com/rtts/djhtml
rev: 3.0.10
rev: 3.0.7
hooks:
- id: djhtml
name: format templates

View File

@@ -1,5 +1,7 @@
from typing import Annotated
from annotated_types import MinLen
from django.db.models import Prefetch
from ninja import Query
from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
@@ -8,7 +10,7 @@ from ninja_extra.schemas import PaginatedResponseSchema
from api.auth import ApiKeyAuth
from api.permissions import CanAccessLookup, HasPerm
from club.models import Club, Membership
from club.schemas import ClubSchema, ClubSearchFilterSchema, SimpleClubSchema
from club.schemas import ClubSchema, SimpleClubSchema
@api_controller("/club")
@@ -21,8 +23,8 @@ class ClubController(ControllerBase):
url_name="search_club",
)
@paginate(PageNumberPaginationExtra, page_size=50)
def search_club(self, filters: Query[ClubSearchFilterSchema]):
return filters.filter(Club.objects.all())
def search_club(self, search: Annotated[str, MinLen(1)]):
return Club.objects.filter(name__icontains=search).values()
@route.get(
"/{int:club_id}",

View File

@@ -1,26 +1,9 @@
from typing import Annotated
from annotated_types import MinLen
from django.db.models import Q
from ninja import Field, FilterSchema, ModelSchema
from ninja import ModelSchema
from club.models import Club, Membership
from core.schemas import SimpleUserSchema
class ClubSearchFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] | None = Field(None, q="name__icontains")
is_active: bool | None = None
parent_id: int | None = None
parent_name: str | None = Field(None, q="parent__name__icontains")
exclude_ids: set[int] | None = None
def filter_exclude_ids(self, value: set[int] | None):
if value is None:
return Q()
return ~Q(id__in=value)
class SimpleClubSchema(ModelSchema):
class Meta:
model = Club

View File

@@ -6,11 +6,11 @@ because it works with a somewhat dynamic form,
but was written before Alpine was introduced in the project.
TODO : rewrite the pagination used in this template an Alpine one
#}
{% macro paginate(page_obj, paginator) %}
{% set js = "formPagination(this)" %}
{% macro paginate(page_obj, paginator, js_action) %}
{% set js = js_action|default('') %}
{% if page_obj.has_previous() or page_obj.has_next() %}
{% if page_obj.has_previous() %}
<a type="submit" onclick="{{ js }}" href="?page={{ page_obj.previous_page_number() }}">{% trans %}Previous{% endtrans %}</a>
<a {% if js %} type="submit" onclick="{{ js }}" {% endif %} href="?page={{ page_obj.previous_page_number() }}">{% trans %}Previous{% endtrans %}</a>
{% else %}
<span class="disabled">{% trans %}Previous{% endtrans %}</span>
{% endif %}
@@ -18,11 +18,11 @@ TODO : rewrite the pagination used in this template an Alpine one
{% if page_obj.number == i %}
<span class="active">{{ i }} <span class="sr-only">({% trans %}current{% endtrans %})</span></span>
{% else %}
<a type="submit" onclick="{{ js }}" href="?page={{ i }}">{{ i }}</a>
<a {% if js %} type="submit" onclick="{{ js }}" {% endif %} href="?page={{ i }}">{{ i }}</a>
{% endif %}
{% endfor %}
{% if page_obj.has_next() %}
<a type="submit" onclick="{{ js }}" href="?page={{ page_obj.next_page_number() }}">{% trans %}Next{% endtrans %}</a>
<a {% if js %} type="submit" onclick="{{ js }}" {% endif %} href="?page={{ page_obj.next_page_number() }}">{% trans %}Next{% endtrans %}</a>
{% else %}
<span class="disabled">{% trans %}Next{% endtrans %}</span>
{% endif %}
@@ -81,10 +81,6 @@ TODO : rewrite the pagination used in this template an Alpine one
{% endfor %}
</tbody>
</table>
{{ paginate(paginated_result, paginator) }}
{% endblock %}
{% block script %}
<script type="text/javascript">
function formPagination(link){
const form = document.getElementById("form")
@@ -93,6 +89,7 @@ TODO : rewrite the pagination used in this template an Alpine one
form.submit();
}
</script>
{{ paginate(paginated_result, paginator, "formPagination(this)") }}
{% endblock %}

View File

@@ -1,8 +1,7 @@
from datetime import date, timedelta
import pytest
from django.contrib.auth.models import Permission
from django.test import Client, TestCase
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from model_bakery.recipe import Recipe
@@ -10,54 +9,6 @@ from pytest_django.asserts import assertNumQueries
from club.models import Club, Membership
from core.baker_recipes import subscriber_user
from core.models import Group, Page, User
class TestClubSearch(TestCase):
@classmethod
def setUpTestData(cls):
cls.url = reverse("api:search_club")
cls.user = baker.make(
User, user_permissions=[Permission.objects.get(codename="access_lookup")]
)
# delete existing clubs to avoid side effect
groups = list(
Group.objects.exclude(club=None, club_board=None).values_list(
"id", flat=True
)
)
Page.objects.exclude(club=None).delete()
Club.objects.all().delete()
Group.objects.filter(id__in=groups).delete()
cls.clubs = baker.make(
Club,
_quantity=5,
name=iter(["AE", "ae 1", "Troll", "Dev AE", "pdf"]),
is_active=True,
)
def test_inactive_club(self):
self.client.force_login(self.user)
inactive_ids = {self.clubs[0].id, self.clubs[2].id}
Club.objects.filter(id__in=inactive_ids).update(is_active=False)
response = self.client.get(self.url, {"is_active": False})
assert response.status_code == 200
assert {d["id"] for d in response.json()["results"]} == inactive_ids
def test_excluded_id(self):
self.client.force_login(self.user)
response = self.client.get(self.url, {"exclude_ids": [self.clubs[1].id]})
assert response.status_code == 200
ids = {d["id"] for d in response.json()["results"]}
assert ids == {c.id for c in [self.clubs[0], *self.clubs[2:]]}
def test_club_search(self):
self.client.force_login(self.user)
response = self.client.get(self.url, {"search": "AE"})
assert response.status_code == 200
ids = {d["id"] for d in response.json()["results"]}
assert ids == {c.id for c in [self.clubs[0], self.clubs[1], self.clubs[3]]}
@pytest.mark.django_db

View File

@@ -23,7 +23,6 @@
#
import csv
import itertools
from typing import Any
from django.conf import settings
@@ -31,14 +30,14 @@ from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import NON_FIELD_ERRORS, PermissionDenied, ValidationError
from django.core.paginator import InvalidPage, Paginator
from django.db.models import F, Q, Sum
from django.db.models import Q, Sum
from django.http import Http404, HttpResponseRedirect, StreamingHttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.safestring import SafeString
from django.utils.timezone import now
from django.utils.translation import gettext
from django.utils.translation import gettext as _t
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, View
from django.views.generic.edit import CreateView, DeleteView, UpdateView
@@ -60,7 +59,7 @@ from com.views import (
PosterEditBaseView,
PosterListBaseView,
)
from core.auth.mixins import CanEditMixin, PermissionOrClubBoardRequiredMixin
from core.auth.mixins import CanEditMixin
from core.models import PageRev
from core.views import DetailFormView, PageEditViewBase, UseFragmentsMixin
from core.views.mixins import FragmentMixin, FragmentRenderer, TabedViewMixin
@@ -371,7 +370,7 @@ class ClubOldMembersView(ClubTabsMixin, PermissionRequiredMixin, DetailView):
class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
"""Sales of a club."""
"""Sellings of a club."""
model = Club
pk_url_kwarg = "club_id"
@@ -397,8 +396,9 @@ class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
qs = Selling.objects.filter(club=self.object)
kwargs["result"] = Selling.objects.none()
kwargs["result"] = qs[:0]
kwargs["paginated_result"] = kwargs["result"]
kwargs["total"] = 0
kwargs["total_quantity"] = 0
@@ -406,7 +406,6 @@ class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
form = self.get_form()
if form.is_valid():
qs = Selling.objects.filter(club=self.object)
if not len([v for v in form.cleaned_data.values() if v is not None]):
qs = Selling.objects.none()
if form.cleaned_data["begin_date"]:
@@ -426,18 +425,18 @@ class ClubSellingView(ClubTabsMixin, CanEditMixin, DetailFormView):
if len(selected_products) > 0:
qs = qs.filter(product__in=selected_products)
kwargs["total"] = qs.annotate(
price=F("quantity") * F("unit_price")
).aggregate(total=Sum("price", default=0))["total"]
kwargs["result"] = qs.select_related(
"counter", "counter__club", "customer", "customer__user", "seller"
).order_by("-id")
kwargs["total_quantity"] = qs.aggregate(total=Sum("quantity", default=0))[
"total"
]
kwargs["benefit"] = qs.exclude(product=None).aggregate(
res=Sum("product__purchase_price", default=0)
)["res"]
kwargs["total"] = sum([s.quantity * s.unit_price for s in kwargs["result"]])
total_quantity = qs.all().aggregate(Sum("quantity"))
if total_quantity["quantity__sum"]:
kwargs["total_quantity"] = total_quantity["quantity__sum"]
benefit = (
qs.exclude(product=None).all().aggregate(Sum("product__purchase_price"))
)
if benefit["product__purchase_price__sum"]:
kwargs["benefit"] = benefit["product__purchase_price__sum"]
kwargs["paginator"] = Paginator(kwargs["result"], self.paginate_by)
try:
@@ -488,40 +487,40 @@ class ClubSellingCSVView(ClubSellingView):
kwargs = self.get_context_data(**kwargs)
# Use the StreamWriter class instead of request for streaming
writer = csv.writer(self.StreamWriter())
pseudo_buffer = self.StreamWriter()
writer = csv.writer(
pseudo_buffer, delimiter=";", lineterminator="\n", quoting=csv.QUOTE_ALL
)
first_rows = [
[gettext("Quantity"), kwargs["total_quantity"]],
[gettext("Total"), kwargs["total"]],
[gettext("Benefit"), kwargs["benefit"]],
writer.writerow([_t("Quantity"), kwargs["total_quantity"]])
writer.writerow([_t("Total"), kwargs["total"]])
writer.writerow([_t("Benefit"), kwargs["benefit"]])
writer.writerow(
[
gettext("Date"),
gettext("Counter"),
gettext("Barman"),
gettext("Customer"),
gettext("Label"),
gettext("Quantity"),
gettext("Total"),
gettext("Payment method"),
gettext("Selling price"),
gettext("Purchase price"),
gettext("Benefit"),
],
]
_t("Date"),
_t("Counter"),
_t("Barman"),
_t("Customer"),
_t("Label"),
_t("Quantity"),
_t("Total"),
_t("Payment method"),
_t("Selling price"),
_t("Purchase price"),
_t("Benefit"),
]
)
# Stream response
response = StreamingHttpResponse(
itertools.chain(
(writer.writerow(r) for r in first_rows),
(
writer.writerow(self.write_selling(selling))
for selling in kwargs["result"]
),
(
writer.writerow(self.write_selling(selling))
for selling in kwargs["result"]
),
content_type="text/csv",
)
name = f"{gettext('Sellings')}_{self.object.name}.csv"
response["Content-Disposition"] = f"attachment; filename={name}"
name = _("Sellings") + "_" + self.object.name + ".csv"
response["Content-Disposition"] = "filename=" + name
return response
@@ -759,13 +758,11 @@ class MailingAutoGenerationView(View):
return redirect("club:mailing", club_id=club.id)
class PosterListView(
PermissionOrClubBoardRequiredMixin, ClubTabsMixin, PosterListBaseView
):
class PosterListView(ClubTabsMixin, PosterListBaseView):
"""List communication posters."""
current_tab = "posters"
permission_required = "com.view_poster"
extra_context = {"app": "club"}
def get_queryset(self):
return super().get_queryset().filter(club=self.club.id)
@@ -773,17 +770,6 @@ class PosterListView(
def get_object(self):
return self.club
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"create_url": reverse_lazy(
"club:poster_create", kwargs={"club_id": self.club.id}
),
"get_edit_url": lambda poster: reverse(
"club:poster_edit",
kwargs={"club_id": self.club.id, "poster_id": poster.id},
),
}
class PosterCreateView(ClubTabsMixin, PosterCreateBaseView):
"""Create communication poster."""

View File

@@ -144,7 +144,7 @@ class News(models.Model):
),
groups__id=settings.SITH_GROUP_COM_ADMIN_ID,
)
notif_url = reverse("com:news_admin_list", fragment="moderation")
notif_url = reverse("com:news_admin_list")
new_notifs = [
Notification(user=user, url=notif_url, type="NEWS_MODERATION")
for user in admins_without_notif
@@ -402,7 +402,9 @@ class Poster(models.Model):
groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
):
Notification.objects.create(
user=user, url=reverse("com:poster_list"), type="POSTER_MODERATION"
user=user,
url=reverse("com:poster_moderate_list"),
type="POSTER_MODERATION",
)
return super().save(*args, **kwargs)

View File

@@ -20,7 +20,33 @@
position: absolute;
display: flex;
bottom: 5px;
left: 0;
&.left {
left: 0;
}
&.right {
right: 0;
}
.link {
padding: 5px;
padding-left: 20px;
padding-right: 20px;
margin-left: 5px;
border-radius: 20px;
background-color: hsl(40, 100%, 50%);
color: black;
&:hover {
color: black;
background-color: hsl(40, 58%, 50%);
}
&.delete {
background-color: hsl(0, 100%, 40%);
}
}
}
}
@@ -117,15 +143,43 @@
}
}
.actions {
display: flex;
flex-direction: column;
align-items: stretch;
form {
margin: unset;
padding: unset;
button {
width: 100%;
.edit,
.moderate,
.slideshow {
padding: 5px;
border-radius: 20px;
background-color: hsl(40, 100%, 50%);
color: black;
&:hover {
color: black;
background-color: hsl(40, 58%, 50%);
}
&:nth-child(2n) {
margin-top: 5px;
margin-bottom: 5px;
}
}
.tooltip {
visibility: hidden;
width: 120px;
background-color: hsl(210, 20%, 98%);
color: hsl(0, 0%, 0%);
text-align: center;
padding: 5px 0;
border-radius: 6px;
position: absolute;
z-index: 10;
ul {
margin-left: 0;
display: inline-block;
li {
display: list-item;
list-style-type: none;
}
}
}

View File

@@ -131,7 +131,7 @@
{% endfor %}
</tbody>
</table>
<h5 id="moderation">{% trans %}Events to moderate{% endtrans %}</h5>
<h5>{% trans %}Events to moderate{% endtrans %}</h5>
<table>
<thead>
<tr>
@@ -165,3 +165,6 @@
</tbody>
</table>
{% endblock %}

View File

@@ -13,53 +13,53 @@
<div id="title">
<h3>{% trans %}Posters{% endtrans %}</h3>
<div id="links">
<a id="create" class="btn btn-blue" href="{{ create_url }}">
<i class="fa fa-plus"></i>
{% trans %}Create{% endtrans %}
</a>
<div id="links" class="right">
{% if app == "com" %}
<a id="create" class="link" href="{{ url(app + ":poster_create") }}">{% trans %}Create{% endtrans %}</a>
<a id="moderation" class="link" href="{{ url("com:poster_moderate_list") }}">{% trans %}Moderation{% endtrans %}</a>
{% elif app == "club" %}
<a id="create" class="link" href="{{ url(app + ":poster_create", club.id) }}">{% trans %}Create{% endtrans %}</a>
{% endif %}
</div>
</div>
<div id="posters">
{% for poster in poster_list %}
<div class="poster{% if not poster.is_moderated %} not_moderated{% endif %}">
<div class="name">{{ poster.name }}</div>
<div
class="image"
hover="{% trans %}Click to expand{% endtrans %}"
@click="active = $el.firstElementChild"
tooltip="{%- for screen in poster.screens.all() -%}
{{ screen }}
{% endfor %}"
>
<img src="{{ poster.file.url }}" alt="{{ poster.name }}">
</div>
<div class="dates">
<div class="begin">{{ poster.date_begin | localtime | date("d/M/Y H:m") }}</div>
<div class="end">{{ poster.date_end | localtime | date("d/M/Y H:m") }}</div>
</div>
<div class="actions">
{% if poster.is_editable %}
<a class="btn btn-blue" href="{{ get_edit_url(poster) }}">
<i class="fa fa-pen-to-square"></i>
{% trans %}Edit{% endtrans %}
</a>
{% endif %}
{% if not poster.is_moderated and user.has_perm("com.moderate_poster") %}
<form action="{{ url("com:poster_moderate", object_id=poster.id) }}" method="post">
{% csrf_token %}
<button type="submit" class="btn btn-green">
<i class="fa fa-check"></i>
{% trans %}Moderate{% endtrans %}
</button>
</form>
{% endif %}
</div>
</div>
{% else %}
{% if poster_list.count() == 0 %}
<div id="no-posters">{% trans %}No posters{% endtrans %}</div>
{% endfor %}
{% else %}
{% for poster in poster_list %}
<div class="poster{% if not poster.is_moderated %} not_moderated{% endif %}">
<div class="name">{{ poster.name }}</div>
<div
class="image"
hover="{% trans %}Click to expand{% endtrans %}"
@click="active = $el.firstElementChild"
>
<img src="{{ poster.file.url }}"></img>
</div>
<div class="dates">
<div class="begin">{{ poster.date_begin | localtime | date("d/M/Y H:m") }}</div>
<div class="end">{{ poster.date_end | localtime | date("d/M/Y H:m") }}</div>
</div>
{% if app == "com" %}
<a class="edit" href="{{ url(app + ":poster_edit", poster.id) }}">{% trans %}Edit{% endtrans %}</a>
{% elif app == "club" %}
<a class="edit" href="{{ url(app + ":poster_edit", club.id, poster.id) }}">{% trans %}Edit{% endtrans %}</a>
{% endif %}
<div class="tooltip">
<ul>
{% for screen in poster.screens.all() %}
<li>{{ screen }}</li>
{% endfor %}
</ul>
</div>
</div>
{% endfor %}
{% endif %}
</div>
<div
@@ -68,9 +68,7 @@
@click="active = null"
:class="{active: active !== null}"
>
<div id="placeholder">
<img :src="active?.src" :alt="active?.name">
</div>
<div id="placeholder"><img :src="active?.src"></div>
</div>
</div>

View File

@@ -0,0 +1,43 @@
{% extends "core/base.jinja" %}
{% block script %}
{{ super() }}
<script src="{{ static('com/js/poster_list.js') }}"></script>
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static('com/css/posters.scss') }}">
{% endblock %}
{% block content %}
<div id="poster_list">
<div id="title">
<div id="links" class="left">
<a id="list" class="link" href="{{ url("com:poster_list") }}">{% trans %}List{% endtrans %}</a>
</div>
<h3>{% trans %}Posters - moderation{% endtrans %}</h3>
</div>
<div id="posters">
{% if object_list.count == 0 %}
<div id="no-posters">{% trans %}No objects{% endtrans %}</div>
{% else %}
{% for poster in object_list %}
<div class="poster{% if not poster.is_moderated %} not_moderated{% endif %}">
<div class="name"> {{ poster.name }} </div>
<div class="image"> <img src="{{ poster.file.url }}"></img> </div>
<a class="moderate" href="{{ url("com:poster_moderate", object_id=poster.id) }}">Moderate</a>
</div>
{% endfor %}
{% endif %}
</div>
<div id="view"><div id="placeholder"></div></div>
</div>
{% endblock %}

View File

@@ -17,9 +17,7 @@ from unittest.mock import patch
import pytest
from django.conf import settings
from django.contrib.auth.models import Permission
from django.contrib.sites.models import Site
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase
from django.urls import reverse
from django.utils import html
@@ -29,10 +27,9 @@ from model_bakery import baker
from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club, Membership
from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
from com.models import News, NewsDate, Sith, Weekmail, WeekmailArticle
from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, Group, User
from core.utils import RED_PIXEL_PNG
@pytest.fixture()
@@ -317,6 +314,7 @@ def test_feed(client: Client):
[
reverse("com:poster_list"),
reverse("com:poster_create"),
reverse("com:poster_moderate_list"),
],
)
def test_poster_management_views_crash_test(client: Client, url: str):
@@ -327,37 +325,3 @@ def test_poster_management_views_crash_test(client: Client, url: str):
client.force_login(user)
res = client.get(url)
assert res.status_code == 200
@pytest.mark.django_db
@pytest.mark.parametrize(
"referer",
[
None,
reverse("com:poster_list"),
reverse("club:poster_list", kwargs={"club_id": settings.SITH_MAIN_CLUB_ID}),
],
)
def test_moderate_poster(client: Client, referer: str | None):
poster = baker.make(
Poster,
is_moderated=False,
file=SimpleUploadedFile("test.png", content=RED_PIXEL_PNG),
club_id=settings.SITH_MAIN_CLUB_ID,
)
user = baker.make(
User,
user_permissions=Permission.objects.filter(
codename__in=["view_poster", "moderate_poster"]
),
)
client.force_login(user)
headers = {"REFERER": f"https://{settings.SITH_URL}{referer}"} if referer else {}
response = client.post(
reverse("com:poster_moderate", kwargs={"object_id": poster.id}), headers=headers
)
result_url = referer or reverse("com:poster_list")
assertRedirects(response, result_url)
poster.refresh_from_db()
assert poster.is_moderated
assert poster.moderator == user

View File

@@ -33,6 +33,7 @@ from com.views import (
PosterDeleteView,
PosterEditView,
PosterListView,
PosterModerateListView,
PosterModerateView,
ScreenCreateView,
ScreenDeleteView,
@@ -101,6 +102,11 @@ urlpatterns = [
PosterDeleteView.as_view(),
name="poster_delete",
),
path(
"poster/moderate/",
PosterModerateListView.as_view(),
name="poster_moderate_list",
),
path(
"poster/<int:object_id>/moderate/",
PosterModerateView.as_view(),

View File

@@ -25,7 +25,6 @@ import itertools
from datetime import date, timedelta
from smtplib import SMTPRecipientsRefused
from typing import Any
from urllib.parse import urlparse
from dateutil.relativedelta import relativedelta
from django.conf import settings
@@ -35,7 +34,7 @@ from django.contrib.auth.mixins import (
)
from django.contrib.syndication.views import Feed
from django.core.exceptions import PermissionDenied, ValidationError
from django.db.models import Exists, Max, OuterRef, Value
from django.db.models import Max
from django.forms.models import modelform_factory
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
@@ -46,7 +45,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, TemplateView, View
from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.models import Club, Mailing, Membership
from club.models import Club, Mailing
from com.forms import NewsDateForm, NewsForm, PosterForm
from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
@@ -240,11 +239,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"),
@@ -563,26 +561,16 @@ class MailingModerateView(View):
raise PermissionDenied
class PosterListBaseView(ListView):
class PosterListBaseView(PermissionOrClubBoardRequiredMixin, ListView):
"""List communication posters."""
model = Poster
template_name = "com/poster_list.jinja"
permission_required = "com.view_poster"
ordering = ["-date_begin"]
def get_queryset(self):
qs = Poster.objects.prefetch_related("screens")
if self.request.user.has_perm("com.edit_poster"):
qs = qs.annotate(is_editable=Value(value=True))
else:
qs = qs.annotate(
is_editable=Exists(
Membership.objects.ongoing()
.board()
.filter(user=self.request.user, club=OuterRef("club_id"))
)
)
return qs.order_by("-date_begin")
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"club": self.club}
class PosterCreateBaseView(PermissionOrClubBoardRequiredMixin, CreateView):
@@ -645,17 +633,21 @@ class PosterDeleteBaseView(
permission_required = "com.delete_poster"
class PosterListView(PermissionRequiredMixin, ComTabsMixin, PosterListBaseView):
class PosterListView(ComTabsMixin, PosterListBaseView):
"""List communication posters."""
current_tab = "posters"
extra_context = {
"create_url": reverse_lazy("com:poster_create"),
"get_edit_url": lambda poster: reverse(
"com:poster_edit", kwargs={"poster_id": poster.id}
),
}
permission_required = "com.view_poster"
def get_queryset(self):
qs = super().get_queryset()
if self.request.user.has_perm("com.view_poster"):
return qs
return qs.filter(club=self.club.id)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "com"
return kwargs
class PosterCreateView(ComTabsMixin, PosterCreateBaseView):
@@ -680,6 +672,17 @@ class PosterDeleteView(PosterDeleteBaseView):
success_url = reverse_lazy("com:poster_list")
class PosterModerateListView(PermissionRequiredMixin, ComTabsMixin, ListView):
"""Moderate list communication poster."""
current_tab = "posters"
model = Poster
template_name = "com/poster_moderate.jinja"
queryset = Poster.objects.filter(is_moderated=False).all()
permission_required = "com.moderate_poster"
extra_context = {"app": "com"}
class PosterModerateView(PermissionRequiredMixin, ComTabsMixin, View):
"""Moderate communication poster."""
@@ -687,21 +690,12 @@ class PosterModerateView(PermissionRequiredMixin, ComTabsMixin, View):
permission_required = "com.moderate_poster"
extra_context = {"app": "com"}
def post(self, request, *args, **kwargs):
def get(self, request, *args, **kwargs):
obj = get_object_or_404(Poster, pk=kwargs["object_id"])
obj.is_moderated = True
obj.moderator = request.user
obj.save()
# The moderation request may be originated from a club context (/club/poster)
# or a global context (/com/poster),
# so the redirection URL will be the URL of the page that called this view,
# as long as the latter belongs to the sith.
referer = self.request.META.get("HTTP_REFERER")
if referer:
parsed = urlparse(referer)
if parsed.netloc == settings.SITH_URL:
return redirect(parsed.path)
return redirect(reverse("com:poster_list"))
return redirect("com:poster_moderate_list")
class ScreenListView(PermissionRequiredMixin, ComTabsMixin, ListView):

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 """
@@ -1181,6 +1157,8 @@ class QuickUploadImage(models.Model):
identifier = str(uuid4())
name = Path(image.name).stem[: cls.IMAGE_NAME_SIZE - 1]
file = File(convert_image(image), name=f"{identifier}.webp")
width, height = Image.open(file).size
return cls.objects.create(
uuid=identifier,
name=name,

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

@@ -153,7 +153,7 @@
current_page (django.core.paginator.Page): the current page object
paginator (django.core.paginator.Paginator): the paginator object
#}
{{ paginate_server_side(current_page, paginator, False) }}
{{ paginate_server_side(current_page, paginator, False) }}
{% endmacro %}
{% macro paginate_htmx(current_page, paginator) %}
@@ -168,7 +168,7 @@
current_page (django.core.paginator.Page): the current page object
paginator (django.core.paginator.Paginator): the paginator object
#}
{{ paginate_server_side(current_page, paginator, True) }}
{{ paginate_server_side(current_page, paginator, True) }}
{% endmacro %}
{% macro paginate_server_side(current_page, paginator, use_htmx) %}

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,15 +0,0 @@
# Generated by Django 5.2.3 on 2025-11-05 08:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("counter", "0033_invoicecall")]
operations = [
migrations.AlterField(
model_name="selling",
name="date",
field=models.DateTimeField(db_index=True, verbose_name="date"),
),
]

View File

@@ -86,7 +86,7 @@ class CustomerQuerySet(models.QuerySet):
.annotate(res=Sum(F("unit_price") * F("quantity"), default=0))
.values("res")
)
return self.update(amount=Coalesce(money_in - money_out, Decimal(0)))
return self.update(amount=Coalesce(money_in - money_out, Decimal("0")))
class Customer(models.Model):
@@ -849,7 +849,7 @@ class Selling(models.Model):
blank=False,
on_delete=models.SET_NULL,
)
date = models.DateTimeField(_("date"), db_index=True)
date = models.DateTimeField(_("date"))
payment_method = models.CharField(
_("payment method"),
max_length=255,

View File

@@ -6,7 +6,7 @@
{% else %}
<h2>{% trans %}Product creation{% endtrans %}</h2>
{% endif %}
<form method="post" enctype="multipart/form-data">
<form method="post">
{% csrf_token %}
{{ form.as_p() }}

View File

@@ -355,7 +355,7 @@ class TestCounterClick(TestFullClickBase):
self.submit_basket(self.barmen, [BasketItem(self.beer.id, 1)])
).status_code == 302
assert self.updated_amount(self.barmen) == Decimal(9)
assert self.updated_amount(self.barmen) == Decimal("9")
def test_click_tray_price(self):
force_refill_user(self.customer, 20)
@@ -364,12 +364,12 @@ class TestCounterClick(TestFullClickBase):
# Not applying tray price
res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 2)])
assert res.status_code == 302
assert self.updated_amount(self.customer) == Decimal(17)
assert self.updated_amount(self.customer) == Decimal("17")
# Applying tray price
res = self.submit_basket(self.customer, [BasketItem(self.beer_tap.id, 7)])
assert res.status_code == 302
assert self.updated_amount(self.customer) == Decimal(8)
assert self.updated_amount(self.customer) == Decimal("8")
def test_click_alcool_unauthorized(self):
self.login_in_bar()
@@ -381,13 +381,13 @@ class TestCounterClick(TestFullClickBase):
res = self.submit_basket(user, [BasketItem(self.snack.id, 2)])
assert res.status_code == 302
assert self.updated_amount(user) == Decimal(7)
assert self.updated_amount(user) == Decimal("7")
# Buy product without age limit
res = self.submit_basket(user, [BasketItem(self.beer.id, 2)])
assert res.status_code == 200
assert self.updated_amount(user) == Decimal(7)
assert self.updated_amount(user) == Decimal("7")
def test_click_unauthorized_customer(self):
self.login_in_bar()
@@ -401,7 +401,7 @@ class TestCounterClick(TestFullClickBase):
assert resp.status_code == 302
assert resp.url == resolve_url(self.counter)
assert self.updated_amount(user) == Decimal(10)
assert self.updated_amount(user) == Decimal("10")
def test_click_user_without_customer(self):
self.login_in_bar()
@@ -418,7 +418,7 @@ class TestCounterClick(TestFullClickBase):
)
assert res.status_code == 302
assert self.updated_amount(self.customer_old_can_buy) == Decimal(7)
assert self.updated_amount(self.customer_old_can_buy) == Decimal("7")
def test_click_wrong_counter(self):
self.login_in_bar()
@@ -443,7 +443,7 @@ class TestCounterClick(TestFullClickBase):
)
assertRedirects(res, self.counter.get_absolute_url())
assert self.updated_amount(self.customer) == Decimal(10)
assert self.updated_amount(self.customer) == Decimal("10")
def test_click_not_connected(self):
force_refill_user(self.customer, 10)
@@ -455,7 +455,7 @@ class TestCounterClick(TestFullClickBase):
)
assert res.status_code == 403
assert self.updated_amount(self.customer) == Decimal(10)
assert self.updated_amount(self.customer) == Decimal("10")
def test_click_product_not_in_counter(self):
force_refill_user(self.customer, 10)
@@ -463,7 +463,7 @@ class TestCounterClick(TestFullClickBase):
res = self.submit_basket(self.customer, [BasketItem(self.stamps.id, 2)])
assert res.status_code == 200
assert self.updated_amount(self.customer) == Decimal(10)
assert self.updated_amount(self.customer) == Decimal("10")
def test_basket_empty(self):
force_refill_user(self.customer, 10)
@@ -477,7 +477,7 @@ class TestCounterClick(TestFullClickBase):
self.submit_basket(self.customer, basket),
self.counter.get_absolute_url(),
)
assert self.updated_amount(self.customer) == Decimal(10)
assert self.updated_amount(self.customer) == Decimal("10")
def test_click_product_invalid(self):
force_refill_user(self.customer, 10)
@@ -490,7 +490,7 @@ class TestCounterClick(TestFullClickBase):
BasketItem(self.beer.id, None),
]:
assert self.submit_basket(self.customer, [item]).status_code == 200
assert self.updated_amount(self.customer) == Decimal(10)
assert self.updated_amount(self.customer) == Decimal("10")
def test_click_not_enough_money(self):
force_refill_user(self.customer, 10)
@@ -501,7 +501,7 @@ class TestCounterClick(TestFullClickBase):
)
assert res.status_code == 200
assert self.updated_amount(self.customer) == Decimal(10)
assert self.updated_amount(self.customer) == Decimal("10")
def test_annotate_has_barman_queryset(self):
"""Test if the custom queryset method `annotate_has_barman` works as intended."""

View File

@@ -242,7 +242,7 @@ class Invoice(models.Model):
def validate(self):
if self.validated:
raise DataError(_("Invoice already validated"))
customer, _created = Customer.get_or_create(user=self.user)
customer, created = Customer.get_or_create(user=self.user)
eboutic = Counter.objects.filter(type="EBOUTIC").first()
for i in self.items.all():
if i.type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING:

View File

@@ -108,7 +108,7 @@ class TestPaymentSith(TestPaymentBase):
)
assert Basket.objects.filter(id=self.basket.id).first() is None
self.customer.customer.refresh_from_db()
assert self.customer.customer.amount == Decimal(1)
assert self.customer.customer.amount == Decimal("1")
sellings = Selling.objects.filter(customer=self.customer.customer).order_by(
"quantity"

View File

@@ -1,155 +0,0 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from core.models import User
from core.views.forms import SelectDateTime
from core.views.widgets.ajax_select import (
AutoCompleteSelect,
AutoCompleteSelectMultipleGroup,
AutoCompleteSelectUser,
)
from core.views.widgets.markdown import MarkdownInput
from election.models import Candidature, Election, ElectionList, Role
class LimitedCheckboxField(forms.ModelMultipleChoiceField):
"""A `ModelMultipleChoiceField`, with a max limit of selectable inputs."""
def __init__(self, queryset, max_choice, **kwargs):
self.max_choice = max_choice
super().__init__(queryset, **kwargs)
def clean(self, value):
qs = super().clean(value)
self.validate(qs)
return qs
def validate(self, qs):
if qs.count() > self.max_choice:
raise forms.ValidationError(
_("You have selected too many candidates."), code="invalid"
)
class CandidateForm(forms.ModelForm):
"""Form to candidate."""
required_css_class = "required"
class Meta:
model = Candidature
fields = ["user", "role", "program", "election_list"]
labels = {
"user": _("User to candidate"),
}
widgets = {
"program": MarkdownInput,
"user": AutoCompleteSelectUser,
"role": AutoCompleteSelect,
"election_list": AutoCompleteSelect,
}
def __init__(self, *args, election: Election, can_edit: bool = False, **kwargs):
super().__init__(*args, **kwargs)
self.fields["role"].queryset = election.roles.select_related("election")
self.fields["election_list"].queryset = election.election_lists.all()
if not can_edit:
self.fields["user"].widget = forms.HiddenInput()
class VoteForm(forms.Form):
def __init__(self, election: Election, user: User, *args, **kwargs):
super().__init__(*args, **kwargs)
if not election.can_vote(user):
return
for role in election.roles.all():
cand = role.candidatures
if role.max_choice > 1:
self.fields[role.title] = LimitedCheckboxField(
cand, role.max_choice, required=False
)
else:
self.fields[role.title] = forms.ModelChoiceField(
cand,
required=False,
widget=forms.RadioSelect(),
empty_label=_("Blank vote"),
)
class RoleForm(forms.ModelForm):
"""Form for creating a role."""
class Meta:
model = Role
fields = ["title", "election", "description", "max_choice"]
widgets = {"election": AutoCompleteSelect}
def __init__(self, *args, **kwargs):
election_id = kwargs.pop("election_id", None)
super().__init__(*args, **kwargs)
if election_id:
self.fields["election"].queryset = Election.objects.filter(
id=election_id
).all()
def clean(self):
cleaned_data = super().clean()
title = cleaned_data.get("title")
election = cleaned_data.get("election")
if Role.objects.filter(title=title, election=election).exists():
raise forms.ValidationError(
_("This role already exists for this election"), code="invalid"
)
class ElectionListForm(forms.ModelForm):
class Meta:
model = ElectionList
fields = ("title", "election")
widgets = {"election": AutoCompleteSelect}
def __init__(self, *args, **kwargs):
election_id = kwargs.pop("election_id", None)
super().__init__(*args, **kwargs)
if election_id:
self.fields["election"].queryset = Election.objects.filter(
id=election_id
).all()
class ElectionForm(forms.ModelForm):
class Meta:
model = Election
fields = [
"title",
"description",
"archived",
"start_candidature",
"end_candidature",
"start_date",
"end_date",
"edit_groups",
"view_groups",
"vote_groups",
"candidature_groups",
]
widgets = {
"edit_groups": AutoCompleteSelectMultipleGroup,
"view_groups": AutoCompleteSelectMultipleGroup,
"vote_groups": AutoCompleteSelectMultipleGroup,
"candidature_groups": AutoCompleteSelectMultipleGroup,
}
start_date = forms.DateTimeField(
label=_("Start date"), widget=SelectDateTime, required=True
)
end_date = forms.DateTimeField(
label=_("End date"), widget=SelectDateTime, required=True
)
start_candidature = forms.DateTimeField(
label=_("Start candidature"), widget=SelectDateTime, required=True
)
end_candidature = forms.DateTimeField(
label=_("End candidature"), widget=SelectDateTime, required=True
)

View File

@@ -1,30 +0,0 @@
# Generated by Django 4.2.20 on 2025-03-14 18:18
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("election", "0004_auto_20191006_0049"),
]
operations = [
migrations.AlterField(
model_name="candidature",
name="program",
field=models.TextField(blank=True, default="", verbose_name="description"),
),
migrations.AlterField(
model_name="candidature",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="candidates",
to=settings.AUTH_USER_MODEL,
verbose_name="user",
),
),
]

View File

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

View File

@@ -31,7 +31,7 @@
<time datetime="{{ election.end_date }}">{{ election.end_date|localtime|date(DATETIME_FORMAT)}}</time>
{% trans %} at {% endtrans %}<time>{{ election.end_date|localtime|time(DATETIME_FORMAT)}}</time>
</p>
{%- if user_has_voted %}
{%- if election.has_voted(user) %}
<p class="election__elector-infos">
{%- if election.is_vote_active %}
<span>{% trans %}You already have submitted your vote.{% endtrans %}</span>
@@ -45,11 +45,12 @@
<form action="{{ url('election:vote', election.id) }}" method="post" class="election__vote-form" name="vote-form" id="vote-form">
{% csrf_token %}
<table class="election_table">
{%- set election_lists = election.election_lists.all() -%}
<thead class="lists">
<tr>
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">{% trans %}Blank vote{% endtrans %}</th>
<th class="column" style="width: {{ 100 / (election_lists.count() + 1) }}%">{% trans %}Blank vote{% endtrans %}</th>
{%- for election_list in election_lists %}
<th class="column" style="width: {{ 100 / (election_lists|length + 1) }}%">
<th class="column" style="width: {{ 100 / (election_lists.count() + 1) }}%">
<span>{{ election_list.title }}</span>
{% if user.can_edit(election_list) and election.is_vote_editable -%}
<a href="{{ url('election:delete_list', list_id=election_list.id) }}"><i class="fa-regular fa-trash-can delete-action"></i></a>
@@ -58,26 +59,18 @@
{%- endfor %}
</tr>
</thead>
{%- for role in election_roles %}
{%- set role_list = election.roles.order_by('order').all() %}
{%- for role in role_list %}
{%- set count = [0] %}
{%- set role_data = election_form.data.getlist(role.title) if role.title in election_form.data else [] %}
<tbody
{% if role.max_choice > 1 -%}
x-data x-limited-choices="{{ role.max_choice }}"
{%- endif %}
class="role {% if role.title in election_form.errors %}role_error{% endif %}"
>
<tbody data-max-choice="{{role.max_choice}}" class="role{{ ' role_error' if role.title in election_form.errors else '' }}{{ ' role__multiple-choices' if role.max_choice > 1 else ''}}">
<tr>
<td class="role_title">
<div class="role_text">
<h4>{{ role.title }}</h4>
<p class="role_description" show-more="300">{{ role.description }}</p>
{%- if role.max_choice > 1 and show_vote_buttons %}
<strong>
{% trans trimmed nb_choices=role.max_choice %}
You may choose up to {{ nb_choices }} people.
{% endtrans %}
</strong>
{%- if role.max_choice > 1 and not election.has_voted(user) and election.can_vote(user) %}
<strong>{% trans %}You may choose up to{% endtrans %} {{ role.max_choice }} {% trans %}people.{% endtrans %}</strong>
{%- endif %}
{%- if election_form.errors[role.title] is defined %}
@@ -88,40 +81,36 @@
</div>
{% if user.can_edit(role) and election.is_vote_editable -%}
<div class="role_buttons">
<a href="{{ url('election:update_role', role_id=role.id) }}">
<i class="fa-regular fa-pen-to-square edit-action"></i>
</a>
<a href="{{ url('election:delete_role', role_id=role.id) }}">
<i class="fa-regular fa-trash-can delete-action"></i>
</a>
{%- if loop.last -%}
<a href="{{url('election:update_role', role_id=role.id)}}"><i class="fa-regular fa-pen-to-square edit-action"></i></a>
<a href="{{url('election:delete_role', role_id=role.id)}}"><i class="fa-regular fa-trash-can delete-action"></i></a>
{%- if role == role_list.last() %}
<button disabled><i class="fa fa-arrow-down"></i></button>
<button disabled><i class="fa fa-caret-down"></i></button>
{%- else -%}
{%- else %}
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=bottom');"><i class="fa fa-arrow-down"></i></button>
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=down');"><i class="fa fa-caret-down"></i></button>
{%- endif -%}
{%- if loop.first -%}
{%- endif %}
{% if role == role_list.first() %}
<button disabled><i class="fa fa-caret-up"></i></button>
<button disabled><i class="fa fa-arrow-up"></i></button>
{%- else -%}
{% else %}
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=up');"><i class="fa fa-caret-up"></i></button>
<button type="button" onclick="window.location.replace('?role={{ role.id }}&action=top');"><i class="fa fa-arrow-up"></i></button>
{%- endif -%}
{% endif %}
</div>
{%- endif -%}
</td>
</tr>
<tr class="role_candidates">
<td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists|length + 1) }}%">
{%- if role.max_choice == 1 and show_vote_buttons %}
<td class="list_per_role" style="width: 100%; max-width: {{ 100 / (election_lists.count() + 1) }}%">
{%- if role.max_choice == 1 and election.can_vote(user) %}
<div class="radio-btn">
{% set input_id = "blank_vote_" + role.id|string %}
<input id="{{ input_id }}" type="radio" name="{{ role.title }}">
<label for="{{ input_id }}">
<input id="id_{{ role.title }}_{{ count[0] }}" type="radio" name="{{ role.title }}" value {{ '' if role_data in election_form else 'checked' }} {{ 'disabled' if election.has_voted(user) else '' }}>
<label for="id_{{ role.title }}_{{ count[0] }}">
<span>{% trans %}Choose blank vote{% endtrans %}</span>
</label>
</div>
{%- set _ = count.append(count.pop() + 1) %}
{%- endif %}
{%- if election.is_vote_finished %}
{%- set results = election_results[role.title]['blank vote'] %}
@@ -131,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()
self.vote(data)
return super().form_valid(form)
res = super(FormView, self).form_valid(form)
for grp_id in self.election.vote_groups.values_list("pk", flat=True):
if self.request.user.is_in_group(pk=grp_id):
self.vote(data)
return res
return res
def get_success_url(self, **kwargs):
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})
@@ -174,22 +310,26 @@ class CandidatureCreateView(LoginRequiredMixin, CreateView):
def dispatch(self, request, *arg, **kwargs):
self.election = get_object_or_404(Election, pk=kwargs["election_id"])
self.can_edit = self.request.user.can_edit(self.election)
return super().dispatch(request, *arg, **kwargs)
def get_initial(self):
return {"user": self.request.user.id}
init = {}
self.can_edit = self.request.user.can_edit(self.election)
init["user"] = self.request.user.id
return init
def get_form_kwargs(self):
return super().get_form_kwargs() | {
"election": self.election,
"can_edit": self.can_edit,
}
kwargs = super().get_form_kwargs()
kwargs["election_id"] = self.election.id
kwargs["can_edit"] = self.can_edit
return kwargs
def form_valid(self, form: CandidateForm):
def form_valid(self, form):
"""Verify that the selected user is in candidate group."""
obj = form.instance
obj.election = self.election
if not hasattr(obj, "user"):
obj.user = self.request.user
if (obj.election.can_candidate(obj.user)) and (
obj.user == self.request.user or self.can_edit
):
@@ -197,7 +337,9 @@ class CandidatureCreateView(LoginRequiredMixin, CreateView):
raise PermissionDenied
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"election": self.election}
kwargs = super().get_context_data(**kwargs)
kwargs["election"] = self.election
return kwargs
def get_success_url(self, **kwargs):
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})
@@ -213,79 +355,80 @@ class ElectionCreateView(PermissionRequiredMixin, CreateView):
return reverse("election:detail", kwargs={"election_id": self.object.id})
class RoleCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
class RoleCreateView(CanCreateMixin, CreateView):
model = Role
form_class = RoleForm
template_name = "core/create.jinja"
@cached_property
def election(self):
return get_object_or_404(Election, pk=self.kwargs["election_id"])
def test_func(self):
def dispatch(self, request, *arg, **kwargs):
self.election = get_object_or_404(Election, pk=kwargs["election_id"])
if not self.election.is_vote_editable:
return False
if self.request.user.has_perm("election.add_role"):
return True
groups = set(self.election.edit_groups.values_list("id", flat=True))
if (
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
and self.request.user.is_subscribed
):
# the subscriber group isn't truly attached to users,
# so it must be dealt with separately
return True
return self.request.user.groups.filter(id__in=groups).exists()
raise PermissionDenied
return super().dispatch(request, *arg, **kwargs)
def get_initial(self):
return {"election": self.election}
init = {}
init["election"] = self.election
return init
def form_valid(self, form):
"""Verify that the user can edit properly."""
obj: Role = form.instance
user: User = self.request.user
if obj.election:
for grp_id in obj.election.edit_groups.values_list("pk", flat=True):
if user.is_in_group(pk=grp_id):
return super(CreateView, self).form_valid(form)
raise PermissionDenied
def get_form_kwargs(self):
return super().get_form_kwargs() | {"election_id": self.election.id}
kwargs = super().get_form_kwargs()
kwargs["election_id"] = self.election.id
return kwargs
def get_success_url(self, **kwargs):
return reverse(
"election:detail", kwargs={"election_id": self.object.election_id}
return reverse_lazy(
"election:detail", kwargs={"election_id": self.object.election.id}
)
class ElectionListCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
class ElectionListCreateView(CanCreateMixin, CreateView):
model = ElectionList
form_class = ElectionListForm
template_name = "core/create.jinja"
@cached_property
def election(self):
return get_object_or_404(Election, pk=self.kwargs["election_id"])
def test_func(self):
def dispatch(self, request, *arg, **kwargs):
self.election = get_object_or_404(Election, pk=kwargs["election_id"])
if not self.election.is_vote_editable:
return False
if self.request.user.has_perm("election.add_electionlist"):
return True
groups = set(
self.election.candidature_groups.values("id")
.union(self.election.edit_groups.values("id"))
.values_list("id", flat=True)
)
if (
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
and self.request.user.is_subscribed
):
# the subscriber group isn't truly attached to users,
# so it must be dealt with separately
return True
return self.request.user.groups.filter(id__in=groups).exists()
raise PermissionDenied
return super().dispatch(request, *arg, **kwargs)
def get_initial(self):
return {"election": self.election}
init = {}
init["election"] = self.election
return init
def get_form_kwargs(self):
return super().get_form_kwargs() | {"election_id": self.election.id}
kwargs = super().get_form_kwargs()
kwargs["election_id"] = self.election.id
return kwargs
def form_valid(self, form):
"""Verify that the user can vote on this election."""
obj: ElectionList = form.instance
user: User = self.request.user
if obj.election:
for grp_id in obj.election.candidature_groups.values_list("pk", flat=True):
if user.is_in_group(pk=grp_id):
return super(CreateView, self).form_valid(form)
for grp_id in obj.election.edit_groups.values_list("pk", flat=True):
if user.is_in_group(pk=grp_id):
return super(CreateView, self).form_valid(form)
raise PermissionDenied
def get_success_url(self, **kwargs):
return reverse(
"election:detail", kwargs={"election_id": self.object.election_id}
return reverse_lazy(
"election:detail", kwargs={"election_id": self.object.election.id}
)
@@ -314,23 +457,45 @@ class ElectionUpdateView(CanEditMixin, UpdateView):
return reverse_lazy("election:detail", kwargs={"election_id": self.object.id})
class CandidatureUpdateView(LoginRequiredMixin, CanEditMixin, UpdateView):
class CandidatureUpdateView(CanEditMixin, UpdateView):
model = Candidature
form_class = CandidateForm
template_name = "core/edit.jinja"
pk_url_kwarg = "candidature_id"
def get_form(self, *args, **kwargs):
form = super().get_form(*args, **kwargs)
form.fields.pop("role", None)
return form
def dispatch(self, request, *arg, **kwargs):
self.object = self.get_object()
if not self.object.role.election.is_vote_editable:
raise PermissionDenied
return super().dispatch(request, *arg, **kwargs)
def remove_fields(self):
self.form.fields.pop("role", None)
def get(self, request, *args, **kwargs):
self.form = self.get_form()
self.remove_fields()
return self.render_to_response(self.get_context_data(form=self.form))
def post(self, request, *args, **kwargs):
self.form = self.get_form()
self.remove_fields()
if (
request.user.is_authenticated
and request.user.can_edit(self.object)
and self.form.is_valid()
):
return super().form_valid(self.form)
return self.form_invalid(self.form)
def get_form_kwargs(self):
return super().get_form_kwargs() | {"election": self.object.role.election}
kwargs = super().get_form_kwargs()
kwargs["election_id"] = self.object.role.election.id
return kwargs
def get_success_url(self, **kwargs):
return reverse(
"election:detail", kwargs={"election_id": self.object.role.election_id}
return reverse_lazy(
"election:detail", kwargs={"election_id": self.object.role.election.id}
)
@@ -381,12 +546,18 @@ class RoleUpdateView(CanEditMixin, UpdateView):
# Delete Views
class ElectionDeleteView(PermissionRequiredMixin, DeleteView):
class ElectionDeleteView(DeleteView):
model = Election
template_name = "core/delete_confirm.jinja"
pk_url_kwarg = "election_id"
permission_required = "election.delete_election"
success_url = reverse_lazy("election:list")
def dispatch(self, request, *args, **kwargs):
if request.user.is_root:
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied
def get_success_url(self, **kwargs):
return reverse_lazy("election:list")
class CandidatureDeleteView(CanEditMixin, DeleteView):
@@ -402,7 +573,7 @@ class CandidatureDeleteView(CanEditMixin, DeleteView):
return super().dispatch(request, *arg, **kwargs)
def get_success_url(self, **kwargs):
return reverse("election:detail", kwargs={"election_id": self.election.id})
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})
class RoleDeleteView(CanEditMixin, DeleteView):
@@ -418,7 +589,7 @@ class RoleDeleteView(CanEditMixin, DeleteView):
return super().dispatch(request, *arg, **kwargs)
def get_success_url(self, **kwargs):
return reverse("election:detail", kwargs={"election_id": self.election.id})
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})
class ElectionListDeleteView(CanEditMixin, DeleteView):
@@ -434,4 +605,4 @@ class ElectionListDeleteView(CanEditMixin, DeleteView):
return super().dispatch(request, *args, **kwargs)
def get_success_url(self, **kwargs):
return reverse("election:detail", kwargs={"election_id": self.election.id})
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})

View File

@@ -45,9 +45,8 @@ class Command(BaseCommand):
"verbosity level should be between 0 and 2 included", stacklevel=2
)
if options["verbosity"] >= 2:
if options["verbosity"] == 2:
logger.setLevel(logging.DEBUG)
logging.getLogger("django.db.backends").setLevel(logging.DEBUG)
elif options["verbosity"] == 1:
logger.setLevel(logging.INFO)
else:
@@ -60,3 +59,6 @@ class Command(BaseCommand):
Galaxy.objects.filter(state__isnull=True).delete()
logger.info("Ruled the galaxy in {} queries.".format(len(connection.queries)))
if options["verbosity"] > 2:
for q in connection.queries:
logger.debug(q)

View File

@@ -31,14 +31,13 @@ from collections import defaultdict
from typing import NamedTuple, TypedDict
from django.db import models
from django.db.models import Count, Exists, F, OuterRef, Q, QuerySet
from django.utils.timezone import localdate, now
from django.db.models import Count, F, Q, QuerySet
from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _
from club.models import Membership
from core.models import User
from sas.models import PeoplePictureRelation, Picture
from subscription.models import Subscription
class GalaxyStar(models.Model):
@@ -199,16 +198,8 @@ class Galaxy(models.Model):
cls, picture_count_threshold: int = DEFAULT_PICTURE_COUNT_THRESHOLD
) -> QuerySet[User]:
return (
User.objects.filter(is_viewable=True)
.exclude(subscriptions=None)
.annotate(
pictures_count=Count("pictures"),
is_active_in_galaxy=Exists(
Subscription.objects.filter(
member=OuterRef("id"), subscription_end__gt=now()
)
),
)
User.objects.exclude(subscriptions=None)
.annotate(pictures_count=Count("pictures"))
.filter(pictures_count__gt=picture_count_threshold)
.distinct()
)
@@ -299,9 +290,9 @@ class Galaxy(models.Model):
31/12/2022 (also two years, but with an offset of one year), then their
club score is 365.
"""
memberships = user.memberships.values("start_date", "end_date", "club_id")
memberships = user.memberships.only("start_date", "end_date", "club_id")
result = defaultdict(int)
today = localdate()
now = localdate()
for membership in memberships:
# This is a N+1 query, but 92% of galaxy users have less than 10 memberships.
# Only 5 users have more than 30 memberships.
@@ -309,23 +300,23 @@ class Galaxy(models.Model):
Membership.objects.exclude(user=user)
.filter(
Q( # start2 <= start1 <= end2
start_date__lte=membership["start_date"],
end_date__gte=membership["start_date"],
start_date__lte=membership.start_date,
end_date__gte=membership.start_date,
)
| Q( # start2 <= start1 <= today
start_date__lte=membership["start_date"], end_date=None
| Q( # start2 <= start1 <= now
start_date__lte=membership.start_date, end_date=None
)
| Q( # start1 <= start2 <= end2
start_date__gte=membership["start_date"],
start_date__lte=membership["end_date"] or today,
start_date__gte=membership.start_date,
start_date__lte=membership.end_date or now,
),
club_id=membership["club_id"],
club_id=membership.club_id,
)
.only("start_date", "end_date", "user_id")
)
for other in common_memberships:
start = max(membership["start_date"], other.start_date)
end = min(membership["end_date"] or today, other.end_date or today)
start = max(membership.start_date, other.start_date)
end = min(membership.end_date or now, other.end_date or now)
result[other.user_id] += (end - start).days * cls.CLUBS_POINTS
return result
@@ -391,22 +382,18 @@ class Galaxy(models.Model):
# this is memory expensive but prevents a lot of db hits, therefore
# is far more time efficient
rulable_users_qs = self.get_rulable_users(picture_count_threshold)
active_users_count = rulable_users_qs.filter(is_active_in_galaxy=True).count()
rulable_users = list(rulable_users_qs)
rulable_users = list(self.get_rulable_users(picture_count_threshold))
rulable_users_count = len(rulable_users)
user1_count = 0
self.logger.info(
f" {len(rulable_users)} citizens (with {active_users_count} active ones) "
f"have been listed. Starting to rule."
f"{rulable_users_count} citizen have been listed. Starting to rule."
)
self.logger.info("Creating stars for all citizen")
individual_scores = self.compute_individual_scores()
GalaxyStar.objects.bulk_create(
[
GalaxyStar(
owner_id=user.id, galaxy=self, mass=individual_scores[user.id]
)
GalaxyStar(owner=user, galaxy=self, mass=individual_scores[user.id])
for user in rulable_users
]
)
@@ -418,9 +405,9 @@ class Galaxy(models.Model):
t_global_start = time.time()
while len(rulable_users) > 0:
user1 = rulable_users.pop()
if not user1.is_active_in_galaxy:
continue
user1_count += 1
rulable_users_count2 = len(rulable_users)
star1 = stars[user1.id]
lanes = []
@@ -461,20 +448,17 @@ class Galaxy(models.Model):
self.logger.info("")
self.logger.info(f" Ruling of {self} ".center(60, "#"))
self.logger.info(
f"Progression: {user1_count}/{active_users_count} "
f"citizen -- {active_users_count - user1_count} remaining"
f"Progression: {user1_count}/{rulable_users_count} "
f"citizen -- {rulable_users_count - user1_count} remaining"
)
self.logger.info(f"Speed: {global_avg_speed:.2f} citizen per second")
eta = len(rulable_users) // global_avg_speed
eta = rulable_users_count2 // global_avg_speed
self.logger.info(
f"ETA: {int(eta // 60 % 60)} minutes {int(eta % 60)} seconds"
)
self.logger.info("#" * 60)
t_global_start = time.time()
count, _ = self.stars.filter(Q(lanes1=None) & Q(lanes2=None)).delete()
self.logger.info(f"{count} orphan stars have been trimmed.")
# Here, we get the IDs of the old galaxies that we'll need to delete. In normal operation, only one galaxy
# should be returned, and we can't delete it yet, as it's the one still displayed by the Sith.
old_galaxies_pks = list(

View File

@@ -122,7 +122,7 @@ class TestGalaxyModel(TestCase):
self.com,
]
with self.assertNumQueries(38):
with self.assertNumQueries(44):
while len(users) > 0:
user1 = users.pop(0)
family_scores = Galaxy.compute_user_family_score(user1)
@@ -150,7 +150,7 @@ class TestGalaxyModel(TestCase):
that the number of queries to rule the galaxy is stable.
"""
galaxy = Galaxy.objects.create()
with self.assertNumQueries(36):
with self.assertNumQueries(39):
galaxy.rule(0) # We want everybody here

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-10-17 13:41+0200\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -141,12 +141,12 @@ 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"
#: club/forms.py club/templates/club/club_sellings.jinja club/views.py
#: club/forms.py club/templates/club/club_sellings.jinja
#: core/templates/core/user_account_detail.jinja
#: counter/templates/counter/cash_summary_list.jinja counter/views/mixins.py
msgid "Counter"
@@ -409,7 +409,7 @@ msgstr "Total : "
msgid "Benefit: "
msgstr "Bénéfice : "
#: club/templates/club/club_sellings.jinja club/views.py
#: club/templates/club/club_sellings.jinja
#: core/templates/core/user_account_detail.jinja
#: counter/templates/counter/cash_summary_list.jinja
#: counter/templates/counter/last_ops.jinja
@@ -419,34 +419,34 @@ msgstr "Bénéfice : "
msgid "Date"
msgstr "Date"
#: club/templates/club/club_sellings.jinja club/views.py
#: club/templates/club/club_sellings.jinja
#: core/templates/core/user_account_detail.jinja
#: counter/templates/counter/last_ops.jinja
msgid "Barman"
msgstr "Barman"
#: club/templates/club/club_sellings.jinja club/views.py
#: club/templates/club/club_sellings.jinja
#: counter/templates/counter/counter_click.jinja
#: counter/templates/counter/last_ops.jinja
#: counter/templates/counter/refilling_list.jinja
msgid "Customer"
msgstr "Client"
#: club/templates/club/club_sellings.jinja club/views.py
#: club/templates/club/club_sellings.jinja
#: core/templates/core/user_account_detail.jinja
#: counter/templates/counter/last_ops.jinja
#: rootplace/templates/rootplace/logs.jinja
msgid "Label"
msgstr "Étiquette"
#: club/templates/club/club_sellings.jinja club/views.py
#: club/templates/club/club_sellings.jinja
#: core/templates/core/user_account_detail.jinja
#: core/templates/core/user_stats.jinja
#: counter/templates/counter/last_ops.jinja
msgid "Quantity"
msgstr "Quantité"
#: club/templates/club/club_sellings.jinja club/views.py
#: club/templates/club/club_sellings.jinja
#: core/templates/core/user_account.jinja
#: core/templates/core/user_account_detail.jinja
#: counter/templates/counter/cash_summary_list.jinja
@@ -456,7 +456,7 @@ msgstr "Quantité"
msgid "Total"
msgstr "Total"
#: club/templates/club/club_sellings.jinja club/views.py
#: club/templates/club/club_sellings.jinja
#: core/templates/core/user_account_detail.jinja
#: core/templates/core/user_detail.jinja
#: counter/templates/counter/last_ops.jinja
@@ -699,23 +699,11 @@ msgstr "Vous êtes maintenant membre de ce club."
msgid "%(user)s has been added to club."
msgstr "%(user)s a été ajouté au club."
#: club/views.py
msgid "Benefit"
msgstr "Bénéfice"
#: club/views.py
msgid "Selling price"
msgstr "Prix de vente"
#: club/views.py
msgid "Purchase price"
msgstr "Prix d'achat"
#: com/forms.py
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"
@@ -905,8 +893,7 @@ msgstr "Administration des mailing listes"
msgid "Actions"
msgstr "Actions"
#: com/templates/com/mailing_admin.jinja com/templates/com/poster_list.jinja
#: core/templates/core/file_detail.jinja
#: com/templates/com/mailing_admin.jinja core/templates/core/file_detail.jinja
#: core/templates/core/file_moderation.jinja sas/templates/sas/moderation.jinja
#: sas/templates/sas/picture.jinja
msgid "Moderate"
@@ -1043,7 +1030,7 @@ msgstr "Événements aujourd'hui et dans les prochains jours"
#: com/templates/com/news_list.jinja
msgid "Administrate news"
msgstr "Administrer les nouvelles"
msgstr "Administrer les news"
#: com/templates/com/news_list.jinja
msgid "Nothing to come..."
@@ -1122,7 +1109,8 @@ msgstr "Vous n'avez pas accès à ce contenu"
msgid "Poster"
msgstr "Affiche"
#: com/templates/com/poster_edit.jinja com/templates/com/screen_edit.jinja
#: com/templates/com/poster_edit.jinja com/templates/com/poster_moderate.jinja
#: com/templates/com/screen_edit.jinja
msgid "List"
msgstr "Liste"
@@ -1135,14 +1123,26 @@ msgstr "Affiche - modifier"
msgid "Create"
msgstr "Créer"
#: com/templates/com/poster_list.jinja com/templates/com/screen_slideshow.jinja
msgid "Click to expand"
msgstr "Cliquez pour agrandir"
#: com/templates/com/poster_list.jinja
msgid "Moderation"
msgstr "Modération"
#: com/templates/com/poster_list.jinja
msgid "No posters"
msgstr "Aucune affiche"
#: com/templates/com/poster_list.jinja com/templates/com/screen_slideshow.jinja
msgid "Click to expand"
msgstr "Cliquez pour agrandir"
#: com/templates/com/poster_moderate.jinja
msgid "Posters - moderation"
msgstr "Affiches - modération"
#: com/templates/com/poster_moderate.jinja
msgid "No objects"
msgstr "Aucun éléments"
#: com/templates/com/screen_edit.jinja
msgid "Screen"
msgstr "Écran"
@@ -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"
@@ -5073,52 +5065,60 @@ msgstr "Suppression de rechargement"
#: sith/settings.py
msgid "One semester"
msgstr "Un semestre"
msgstr "Un semestre, 20 €"
#: sith/settings.py
msgid "Two semesters"
msgstr "Deux semestres"
msgstr "Deux semestres, 35 €"
#: sith/settings.py
msgid "Common core cursus"
msgstr "Cursus tronc commun"
msgstr "Cursus tronc commun, 60 €"
#: sith/settings.py
msgid "Branch cursus"
msgstr "Cursus branche"
msgstr "Cursus branche, 60 €"
#: sith/settings.py
msgid "Alternating cursus"
msgstr "Cursus alternant"
msgstr "Cursus alternant, 30 €"
#: sith/settings.py
msgid "Honorary member"
msgstr "Membre honoraire"
msgstr "Membre honoraire, 0 €"
#: sith/settings.py
msgid "Assidu member"
msgstr "Membre d'Assidu"
msgstr "Membre d'Assidu, 0 €"
#: sith/settings.py
msgid "Amicale/DOCEO member"
msgstr "Membre de l'Amicale/DOCEO"
msgstr "Membre de l'Amicale/DOCEO, 0 €"
#: sith/settings.py
msgid "UT network member"
msgstr "Cotisant du réseau UT"
msgstr "Cotisant du réseau UT, 0 €"
#: sith/settings.py
msgid "CROUS member"
msgstr "Membres du CROUS"
msgstr "Membres du CROUS, 0 €"
#: sith/settings.py
msgid "Sbarro/ESTA member"
msgstr "Membre de Sbarro ou de l'ESTA"
msgstr "Membre de Sbarro ou de l'ESTA, 20 €"
#: sith/settings.py
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,30 +5132,28 @@ msgid "One day"
msgstr "Un jour"
#: sith/settings.py
#, fuzzy
#| msgid "GA staff member (2 weeks)"
msgid "GA staff member"
msgstr "Membre staff GA (2 semaines)"
msgstr "Membre staff GA (2 semaines), 1 €"
#: sith/settings.py
msgid "One semester (-20%)"
msgstr "Un semestre (-20%)"
msgstr "Un semestre (-20%), 12 €"
#: sith/settings.py
msgid "Two semesters (-20%)"
msgstr "Deux semestres (-20%)"
msgstr "Deux semestres (-20%), 22 €"
#: sith/settings.py
msgid "Common core cursus (-20%)"
msgstr "Cursus tronc commun (-20%)"
msgstr "Cursus tronc commun (-20%), 36 €"
#: sith/settings.py
msgid "Branch cursus (-20%)"
msgstr "Cursus branche (-20%)"
msgstr "Cursus branche (-20%), 36 €"
#: sith/settings.py
msgid "Alternating cursus (-20%)"
msgstr "Cursus alternant (-20%)"
msgstr "Cursus alternant (-20%), 24 €"
#: sith/settings.py
msgid "One year for free(CA offer)"
@@ -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():

1046
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,48 +24,48 @@
"#com:*": "./com/static/bundled/*"
},
"devDependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-env": "^7.28.5",
"@biomejs/biome": "^1.9.4",
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@biomejs/biome": "1.9.4",
"@hey-api/openapi-ts": "^0.73.0",
"@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.11",
"@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-klay": "^3.1.5",
"@types/alpinejs": "^3.13.10",
"@types/cytoscape-cxtmenu": "^3.4.4",
"@types/cytoscape-klay": "^3.1.4",
"@types/js-cookie": "^3.0.6",
"typescript": "^5.9.3",
"typescript": "^5.8.3",
"vite": "^6.4.1",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.1.4"
"vite-plugin-static-copy": "^3.1.2"
},
"dependencies": {
"@alpinejs/sort": "^3.15.1",
"@alpinejs/sort": "^3.14.7",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.7.4",
"@fortawesome/fontawesome-free": "^6.7.2",
"@fullcalendar/core": "^6.1.19",
"@fullcalendar/daygrid": "^6.1.19",
"@fullcalendar/icalendar": "^6.1.19",
"@fullcalendar/list": "^6.1.19",
"@sentry/browser": "^9.46.0",
"@zip.js/zip.js": "^2.8.9",
"3d-force-graph": "^1.79.0",
"alpinejs": "^3.15.1",
"chart.js": "^4.5.1",
"@floating-ui/dom": "^1.6.13",
"@fortawesome/fontawesome-free": "^6.6.0",
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/icalendar": "^6.1.15",
"@fullcalendar/list": "^6.1.15",
"@sentry/browser": "^9.29.0",
"@zip.js/zip.js": "^2.7.52",
"3d-force-graph": "^1.73.4",
"alpinejs": "^3.14.7",
"chart.js": "^4.4.4",
"country-flag-emoji-polyfill": "^0.1.8",
"cytoscape": "^3.33.1",
"cytoscape": "^3.30.2",
"cytoscape-cxtmenu": "^3.5.0",
"cytoscape-klay": "^3.1.4",
"d3-force-3d": "^3.0.6",
"easymde": "^2.20.0",
"glob": "^11.0.3",
"d3-force-3d": "^3.0.5",
"easymde": "^2.19.0",
"glob": "^11.0.0",
"html2canvas": "^1.4.1",
"htmx.org": "^2.0.8",
"htmx.org": "^2.0.3",
"js-cookie": "^3.0.5",
"lit-html": "^3.3.1",
"lit-html": "^3.3.0",
"native-file-system-adapter": "^3.0.1",
"three": "^0.177.0",
"three-spritetext": "^1.10.0",
"tom-select": "^2.4.3"
"three-spritetext": "^1.9.0",
"tom-select": "^2.3.1"
}
}

View File

@@ -19,36 +19,36 @@ authors = [
license = { text = "GPL-3.0-only" }
requires-python = "<4.0,>=3.12"
dependencies = [
"django>=5.2.8,<6.0.0",
"django-ninja>=1.4.5,<2.0.0",
"django-ninja-extra>=0.30.2,<1.0.0",
"Pillow>=12.0.0,<13.0.0",
"mistune>=3.1.4,<4.0.0",
"django>=5.2.1,<6.0.0",
"django-ninja<2.0.0,>=1.4.0",
"django-ninja-extra<1.0.0,>=0.22.9",
"Pillow<12.0.0,>=11.1.0",
"mistune<4.0.0,>=3.1.3",
"django-jinja<3.0.0,>=2.11.0",
"cryptography>=46.0.3,<47.0.0",
"django-phonenumber-field>=8.3.0,<9.0.0",
"phonenumbers>=9.0.18,<10.0.0",
"reportlab>=4.4.4,<5.0.0",
"cryptography>=45.0.3,<46.0.0",
"django-phonenumber-field<9.0.0,>=8.1.0",
"phonenumbers>=9.0.2,<10.0.0",
"reportlab<5.0.0,>=4.3.1",
"django-haystack<4.0.0,>=3.3.0",
"xapian-haystack<4.0.0,>=3.1.0",
"libsass<1.0.0,>=0.23.0",
"django-ordered-model<4.0.0,>=3.7.4",
"django-simple-captcha<1.0.0,>=0.6.2",
"python-dateutil<3.0.0.0,>=2.9.0.post0",
"sentry-sdk>=2.43.0,<3.0.0",
"sentry-sdk<3.0.0,>=2.25.1",
"jinja2<4.0.0,>=3.1.6",
"django-countries>=8.0.0,<9.0.0",
"dict2xml>=1.7.7,<2.0.0",
"django-countries<8.0.0,>=7.6.1",
"dict2xml<2.0.0,>=1.7.6",
"Sphinx<6,>=5",
"tomli>=2.3.0,<3.0.0",
"tomli<3.0.0,>=2.2.1",
"django-honeypot>=1.3.0,<2",
"pydantic-extra-types>=2.10.6,<3.0.0",
"ical>=11.1.0,<12",
"pydantic-extra-types<3.0.0,>=2.10.3",
"ical>=11,<12",
"redis[hiredis]<7,>=5.3.0",
"environs[django]>=14.5.0,<15.0.0",
"requests>=2.32.5,<3.0.0",
"environs[django]<15.0.0,>=14.1.1",
"requests>=2.32.3",
"honcho>=2.0.0",
"psutil>=7.1.3,<8.0.0",
"psutil>=7.0.0",
"celery[redis]>=5.5.2",
"django-celery-results>=2.5.1",
"django-celery-beat>=2.7.0",
@@ -60,32 +60,32 @@ documentation = "https://sith-ae.readthedocs.io/"
[dependency-groups]
prod = [
"psycopg[c]>=3.2.12,<4.0.0",
"psycopg[c]>=3.2.9,<4.0.0",
]
dev = [
"django-debug-toolbar>=6.1.0,<7",
"ipython>=9.7.0,<10.0.0",
"pre-commit>=4.3.0,<5.0.0",
"ruff>=0.14.4,<1.0.0",
"djhtml>=3.0.10,<4.0.0",
"faker>=37.12.0,<38.0.0",
"rjsmin>=1.2.5,<2.0.0",
"django-debug-toolbar>=6,<7",
"ipython<10.0.0,>=9.0.2",
"pre-commit<5.0.0,>=4.1.0",
"ruff>=0.11.13,<1.0.0",
"djhtml<4.0.0,>=3.0.7",
"faker<38.0.0,>=37.0.0",
"rjsmin<2.0.0,>=1.2.4",
]
tests = [
"freezegun>=1.5.5,<2.0.0",
"pytest>=8.4.2,<9.0.0",
"pytest-cov>=7.0.0,<8.0.0",
"freezegun<2.0.0,>=1.5.1",
"pytest<9.0.0,>=8.3.5",
"pytest-cov<7.0.0,>=6.0.0",
"pytest-django<5.0.0,>=4.10.0",
"model-bakery<2.0.0,>=1.20.4",
"beautifulsoup4>=4.14.2,<5",
"lxml>=6.0.2,<7",
"beautifulsoup4>=4.13.3,<5",
"lxml>=6,<7",
]
docs = [
"mkdocs<2.0.0,>=1.6.1",
"mkdocs-material>=9.6.23,<10.0.0",
"mkdocstrings>=0.30.1,<1.0.0",
"mkdocstrings-python>=1.18.2,<2.0.0",
"mkdocs-include-markdown-plugin>=7.2.0,<8.0.0",
"mkdocs-material<10.0.0,>=9.6.7",
"mkdocstrings<1.0.0,>=0.28.3",
"mkdocstrings-python<2.0.0,>=1.16.3",
"mkdocs-include-markdown-plugin<8.0.0,>=7.1.5",
]
[tool.uv]

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

@@ -5,13 +5,12 @@ import type { PictureSchema } from "#openapi";
document.addEventListener("alpine:init", () => {
Alpine.data("pictures_download", () => ({
isDownloading: false,
downloadPictures: [] as PictureSchema[],
async downloadZip() {
this.isDownloading = true;
const bar = this.$refs.progress;
bar.value = 0;
bar.max = this.downloadPictures.length;
bar.max = this.pictures.length;
const incrementProgressBar = (_total: number): undefined => {
bar.value++;
@@ -30,7 +29,7 @@ document.addEventListener("alpine:init", () => {
const zipWriter = new ZipWriter(await fileHandle.createWritable());
await Promise.all(
this.downloadPictures.map(async (p: PictureSchema) => {
this.pictures.map((p: PictureSchema) => {
const imgName = `${p.album}/IMG_${p.date.replace(/[:\-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
level: 9,

View File

@@ -57,9 +57,5 @@ document.addEventListener("alpine:init", () => {
}
this.loading = false;
},
allPictures(): PictureSchema[] {
return this.albums.flatMap((album: Album) => album.pictures);
},
}));
});

View File

@@ -85,7 +85,7 @@
<div x-data="pictures({ albumId: {{ album.id }}, maxPageSize: {{ settings.SITH_SAS_IMAGES_PER_PAGE }} })">
<h4>{% trans %}Pictures{% endtrans %}</h4>
<br>
{{ download_button(_("Download album"), "pictures") }}
{{ download_button(_("Download album")) }}
<div class="photos" :aria-busy="loading" @pictures-upload-done.window="fetchPictures">
<template x-for="picture in getPage(page)">
<a :href="picture.sas_url">

View File

@@ -36,20 +36,21 @@
{% endmacro %}
{# Helper macro to create a download button for a
record of albums with alpine.
record of albums with alpine
This needs to be used inside an alpine environment.
Downloaded pictures will be `pictures` from the
parent data store.
Note:
This requires importing `bundled/sas/pictures-download-index.ts`
Parameters:
name (str): name displayed on the button
pictures (str): an alpine variable or function
which holds the images this button should download.
It must be different from "downloadPictures", or it won't work.
#}
{% macro download_button(name, pictures) %}
<div x-data="pictures_download()" x-modelable="downloadPictures" x-model="{{ pictures }}">
<div x-show="downloadPictures.length > 0" x-cloak>
{% macro download_button(name) %}
<div x-data="pictures_download">
<div x-show="albums.length > 0" x-cloak>
<button
:disabled="isDownloading"
class="btn btn-blue {% if name == "" %}btn-no-text{% endif %}"

View File

@@ -17,16 +17,16 @@
{% block content %}
<main x-data="user_pictures({ userId: {{ object.id }}, nbPictures: {{ object.nb_pictures }} })">
{% if user.id == object.id %}
{{ download_button(_("Download all my pictures"), "allPictures()") }}
{{ download_button(_("Download all my pictures")) }}
{% endif %}
<template x-for="album in albums" x-cloak>
<section>
<br />
<div class="row gap">
<div class="row">
<h4 x-text="album.name" :id="`album-${album.id}`"></h4>
{% if user.id == object.id %}
{{ download_button("", "album.pictures") }}
&nbsp;{{ download_button("") }}
{% endif %}
</div>
<div class="photos">

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]
]

View File

@@ -542,7 +542,7 @@ SITH_SUBSCRIPTIONS = {
"duration": 4,
},
"cursus-branche": {"name": _("Branch cursus"), "price": 60, "duration": 6},
"cursus-alternant": {"name": _("Alternating cursus"), "price": 35, "duration": 6},
"cursus-alternant": {"name": _("Alternating cursus"), "price": 30, "duration": 6},
"membre-honoraire": {"name": _("Honorary member"), "price": 0, "duration": 666},
"assidu": {"name": _("Assidu member"), "price": 0, "duration": 2},
"amicale/doceo": {"name": _("Amicale/DOCEO member"), "price": 0, "duration": 2},
@@ -554,6 +554,8 @@ SITH_SUBSCRIPTIONS = {
"price": 0,
"duration": 1,
},
"un-mois-essai": {"name": _("One month for free"), "price": 0, "duration": 0.166},
"deux-mois-essai": {"name": _("Two months for free"), "price": 0, "duration": 0.33},
"benevoles-euroks": {"name": _("Eurok's volunteer"), "price": 5, "duration": 0.1},
"six-semaines-essai": {
"name": _("Six weeks for free"),

View File

@@ -1,21 +0,0 @@
# Generated by Django 5.2.3 on 2025-10-06 11:24
from django.db import migrations, models
import subscription.models
class Migration(migrations.Migration):
dependencies = [("subscription", "0015_alter_subscription_location_and_more")]
operations = [
migrations.AlterField(
model_name="subscription",
name="subscription_type",
field=models.CharField(
choices=subscription.models.get_subscription_types,
max_length=255,
verbose_name="subscription type",
),
)
]

View File

@@ -38,19 +38,16 @@ def validate_payment(value):
raise ValidationError(_("Bad payment method"))
def get_subscription_types():
return (
(k, f"{v['name']}, {v['price']}")
for k, v in sorted(settings.SITH_SUBSCRIPTIONS.items())
)
class Subscription(models.Model):
member = models.ForeignKey(
User, related_name="subscriptions", on_delete=models.CASCADE
)
subscription_type = models.CharField(
_("subscription type"), max_length=255, choices=get_subscription_types
_("subscription type"),
max_length=255,
choices=(
(k, v["name"]) for k, v in sorted(settings.SITH_SUBSCRIPTIONS.items())
),
)
subscription_start = models.DateField(_("subscription start"))
subscription_end = models.DateField(_("subscription end"))
@@ -81,7 +78,7 @@ class Subscription(models.Model):
from counter.models import Customer
Customer.get_or_create(self.member)
customer, _ = Customer.get_or_create(self.member)
# Someone who subscribed once will be considered forever
# as an old subscriber.
self.member.groups.add(settings.SITH_GROUP_OLD_SUBSCRIBERS_ID)

View File

@@ -175,3 +175,45 @@ class TestSubscriptionIntegration(TestCase):
user=user,
)
assert d == date(2017, 8, 29)
def test_dates_renewal_sliding_during_two_free_monthes(self):
user = self.user
s = Subscription(
member=user,
subscription_type="deux-mois-essai",
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1],
)
s.subscription_start = date(2015, 8, 29)
s.subscription_end = s.compute_end(
duration=settings.SITH_SUBSCRIPTIONS[s.subscription_type]["duration"],
start=s.subscription_start,
)
s.save()
assert s.subscription_end == date(2015, 10, 29)
with freezegun.freeze_time("2015-09-25"):
d = Subscription.compute_end(
duration=settings.SITH_SUBSCRIPTIONS["deux-semestres"]["duration"],
user=user,
)
assert d == date(2016, 10, 29)
def test_dates_renewal_sliding_after_two_free_monthes(self):
user = self.user
s = Subscription(
member=user,
subscription_type="deux-mois-essai",
payment_method=settings.SITH_SUBSCRIPTION_PAYMENT_METHOD[1],
)
s.subscription_start = date(2015, 8, 29)
s.subscription_end = s.compute_end(
duration=settings.SITH_SUBSCRIPTIONS[s.subscription_type]["duration"],
start=s.subscription_start,
)
s.save()
assert s.subscription_end == date(2015, 10, 29)
with freezegun.freeze_time("2015-11-05"):
d = Subscription.compute_end(
duration=settings.SITH_SUBSCRIPTIONS["deux-semestres"]["duration"],
user=user,
)
assert d == date(2016, 11, 5)

964
uv.lock generated

File diff suppressed because it is too large Load Diff