Merge pull request #1238 from ae-utbm/taiste

Sith theme, `Selling.date` index, galaxy simplification, OG tags, dependencies update, bugfixes and others
This commit is contained in:
thomas girod
2025-11-10 13:19:43 +01:00
committed by GitHub
86 changed files with 2527 additions and 2362 deletions

View File

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

View File

@@ -6,6 +6,8 @@ from api.models import ApiClient, ApiKey
class ApiKeyAuth(APIKeyHeader): class ApiKeyAuth(APIKeyHeader):
"""Authentication through client api keys."""
param_name = "X-APIKey" param_name = "X-APIKey"
def authenticate(self, request: HttpRequest, key: str | None) -> ApiClient | None: def authenticate(self, request: HttpRequest, key: str | None) -> ApiClient | None:

View File

@@ -0,0 +1,48 @@
import pytest
from django.test import Client
from django.urls import path
from model_bakery import baker
from ninja import NinjaAPI
from ninja.security import SessionAuth
from api.auth import ApiKeyAuth
from api.hashers import generate_key
from api.models import ApiClient, ApiKey
api = NinjaAPI()
@api.post("", auth=[ApiKeyAuth(), SessionAuth()])
def post_method(*args, **kwargs) -> None:
"""Dummy POST route authenticated by either api key or session cookie."""
pass
urlpatterns = [path("", api.urls)]
@pytest.mark.django_db
@pytest.mark.urls(__name__)
@pytest.mark.parametrize("user_logged_in", [False, True])
def test_csrf_token(user_logged_in):
"""Test that CSRF check happens only when no api key is used."""
client = Client(enforce_csrf_checks=True)
key, hashed = generate_key()
api_client = baker.make(ApiClient)
baker.make(ApiKey, client=api_client, hashed_key=hashed)
if user_logged_in:
client.force_login(api_client.owner)
response = client.post("")
assert response.status_code == 403
assert response.json()["detail"] == "CSRF check Failed"
# if using a valid API key, CSRF check should not occur
response = client.post("", headers={"X-APIKey": key})
assert response.status_code == 200
# if using a wrong API key, ApiKeyAuth should fail,
# leading to a fallback into SessionAuth and a CSRF check
response = client.post("", headers={"X-APIKey": generate_key()[0]})
assert response.status_code == 403
assert response.json()["detail"] == "CSRF check Failed"

View File

@@ -1,3 +1,4 @@
from ninja.security import SessionAuth
from ninja_extra import NinjaExtraAPI from ninja_extra import NinjaExtraAPI
api = NinjaExtraAPI( api = NinjaExtraAPI(
@@ -5,6 +6,6 @@ api = NinjaExtraAPI(
description="Portail Interactif de Communication avec les Outils Numériques", description="Portail Interactif de Communication avec les Outils Numériques",
version="0.2.0", version="0.2.0",
urls_namespace="api", urls_namespace="api",
csrf=True, auth=[SessionAuth()],
) )
api.auto_discover_controllers() api.auto_discover_controllers()

View File

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

View File

@@ -1,9 +1,26 @@
from ninja import ModelSchema from typing import Annotated
from annotated_types import MinLen
from django.db.models import Q
from ninja import Field, FilterSchema, ModelSchema
from club.models import Club, Membership from club.models import Club, Membership
from core.schemas import SimpleUserSchema 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 SimpleClubSchema(ModelSchema):
class Meta: class Meta:
model = Club model = Club

View File

@@ -9,6 +9,18 @@
{{ club.short_description }} {{ club.short_description }}
{%- endblock %} {%- endblock %}
{% block metatags %}
<meta property="og:url" content="{{ request.build_absolute_uri(club.get_absolute_url()) }}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="{{ club.name }}" />
<meta property="og:description" content="{{ club.short_description }}" />
{% if club.logo %}
<meta property="og:image" content="{{ request.build_absolute_uri(club.logo.url) }}" />
{% else %}
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% endif %}
{% endblock %}
{% block content %} {% block content %}
<div id="club_detail"> <div id="club_detail">
{% if club.logo %} {% if club.logo %}
@@ -17,7 +29,7 @@
{% if page_revision %} {% if page_revision %}
{{ page_revision|markdown }} {{ page_revision|markdown }}
{% else %} {% else %}
<h3>{% trans %}Club{% endtrans %}</h3> <h3>{{ club.name }}</h3>
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}

View File

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

View File

@@ -1,7 +1,8 @@
from datetime import date, timedelta from datetime import date, timedelta
import pytest import pytest
from django.test import Client from django.contrib.auth.models import Permission
from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from model_bakery.recipe import Recipe from model_bakery.recipe import Recipe
@@ -9,6 +10,54 @@ from pytest_django.asserts import assertNumQueries
from club.models import Club, Membership from club.models import Club, Membership
from core.baker_recipes import subscriber_user 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 @pytest.mark.django_db

View File

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

View File

@@ -5,7 +5,6 @@ from django.utils.cache import add_never_cache_headers
from ninja import Query from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from api.permissions import HasPerm from api.permissions import HasPerm
@@ -17,17 +16,13 @@ from core.views.files import send_raw_file
@api_controller("/calendar") @api_controller("/calendar")
class CalendarController(ControllerBase): class CalendarController(ControllerBase):
@route.get("/internal.ics", url_name="calendar_internal") @route.get("/internal.ics", auth=None, url_name="calendar_internal")
def calendar_internal(self): def calendar_internal(self):
response = send_raw_file(IcsCalendar.get_internal()) response = send_raw_file(IcsCalendar.get_internal())
add_never_cache_headers(response) add_never_cache_headers(response)
return response return response
@route.get( @route.get("/unpublished.ics", url_name="calendar_unpublished")
"/unpublished.ics",
permissions=[IsAuthenticated],
url_name="calendar_unpublished",
)
def calendar_unpublished(self): def calendar_unpublished(self):
response = HttpResponse( response = HttpResponse(
IcsCalendar.get_unpublished(self.context.request.user), IcsCalendar.get_unpublished(self.context.request.user),
@@ -74,6 +69,7 @@ class NewsController(ControllerBase):
@route.get( @route.get(
"/date", "/date",
auth=None,
url_name="fetch_news_dates", url_name="fetch_news_dates",
response=PaginatedResponseSchema[NewsDateSchema], response=PaginatedResponseSchema[NewsDateSchema],
) )

View File

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

View File

@@ -20,33 +20,7 @@
position: absolute; position: absolute;
display: flex; display: flex;
bottom: 5px; 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%);
}
}
} }
} }
@@ -143,43 +117,15 @@
} }
} }
.edit, .actions {
.moderate, display: flex;
.slideshow { flex-direction: column;
padding: 5px; align-items: stretch;
border-radius: 20px; form {
background-color: hsl(40, 100%, 50%); margin: unset;
color: black; padding: unset;
button {
&:hover { width: 100%;
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 %} {% endfor %}
</tbody> </tbody>
</table> </table>
<h5>{% trans %}Events to moderate{% endtrans %}</h5> <h5 id="moderation">{% trans %}Events to moderate{% endtrans %}</h5>
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -165,6 +165,3 @@
</tbody> </tbody>
</table> </table>
{% endblock %} {% endblock %}

View File

@@ -1,15 +1,20 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import user_profile_link, facebook_share, tweet, link_news_logo, gen_news_metatags %} {% from 'core/macros.jinja' import user_profile_link, link_news_logo %}
{% from "com/macros.jinja" import news_moderation_alert %} {% from "com/macros.jinja" import news_moderation_alert %}
{% block title %} {% block title %}
{% trans %}News{% endtrans %} - {% trans %}News{% endtrans %} - {{ object.title }}
{{ object.title }}
{% endblock %} {% endblock %}
{% block head %} {% block description %}{{ news.summary }}{% endblock %}
{{ super() }}
{{ gen_news_metatags(news) }} {% block metatags %}
<meta property="og:url" content="{{ news.get_full_url() }}" />
<meta property="og:type" content="article" />
<meta property="article:section" content="{% trans %}News{% endtrans %}" />
<meta property="og:title" content="{{ news.title }}" />
<meta property="og:description" content="{{ news.summary }}" />
<meta property="og:image" content="{{ request.build_absolute_uri(link_news_logo(news)) }}" />
{% endblock %} {% endblock %}
@@ -44,8 +49,14 @@
<div><em>{{ news.summary|markdown }}</em></div> <div><em>{{ news.summary|markdown }}</em></div>
<br/> <br/>
<div>{{ news.content|markdown }}</div> <div>{{ news.content|markdown }}</div>
{{ facebook_share(news) }} <a
{{ tweet(news) }} rel="nofollow"
target="#"
class="share_button facebook"
href="https://www.facebook.com/sharer/sharer.php?u={{ news.get_full_url() }}"
>
{% trans %}Share on Facebook{% endtrans %}
</a>
<div class="news_meta"> <div class="news_meta">
<p>{% trans %}Author: {% endtrans %}{{ user_profile_link(news.author) }}</p> <p>{% trans %}Author: {% endtrans %}{{ user_profile_link(news.author) }}</p>
{% if news.moderator %} {% if news.moderator %}

View File

@@ -13,53 +13,53 @@
<div id="title"> <div id="title">
<h3>{% trans %}Posters{% endtrans %}</h3> <h3>{% trans %}Posters{% endtrans %}</h3>
<div id="links" class="right"> <div id="links">
{% if app == "com" %} <a id="create" class="btn btn-blue" href="{{ create_url }}">
<a id="create" class="link" href="{{ url(app + ":poster_create") }}">{% trans %}Create{% endtrans %}</a> <i class="fa fa-plus"></i>
<a id="moderation" class="link" href="{{ url("com:poster_moderate_list") }}">{% trans %}Moderation{% endtrans %}</a> {% trans %}Create{% endtrans %}
{% elif app == "club" %} </a>
<a id="create" class="link" href="{{ url(app + ":poster_create", club.id) }}">{% trans %}Create{% endtrans %}</a>
{% endif %}
</div> </div>
</div> </div>
<div id="posters"> <div id="posters">
{% for poster in poster_list %}
{% if poster_list.count() == 0 %} <div class="poster{% if not poster.is_moderated %} not_moderated{% endif %}">
<div id="no-posters">{% trans %}No posters{% endtrans %}</div> <div class="name">{{ poster.name }}</div>
{% else %} <div
class="image"
{% for poster in poster_list %} hover="{% trans %}Click to expand{% endtrans %}"
<div class="poster{% if not poster.is_moderated %} not_moderated{% endif %}"> @click="active = $el.firstElementChild"
<div class="name">{{ poster.name }}</div> tooltip="{%- for screen in poster.screens.all() -%}
<div {{ screen }}
class="image" {% endfor %}"
hover="{% trans %}Click to expand{% endtrans %}" >
@click="active = $el.firstElementChild" <img src="{{ poster.file.url }}" alt="{{ poster.name }}">
>
<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> </div>
{% endfor %} <div class="dates">
<div class="begin">{{ poster.date_begin | localtime | date("d/M/Y H:m") }}</div>
{% endif %} <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 %}
<div id="no-posters">{% trans %}No posters{% endtrans %}</div>
{% endfor %}
</div> </div>
<div <div
@@ -68,7 +68,9 @@
@click="active = null" @click="active = null"
:class="{active: active !== null}" :class="{active: active !== null}"
> >
<div id="placeholder"><img :src="active?.src"></div> <div id="placeholder">
<img :src="active?.src" :alt="active?.name">
</div>
</div> </div>
</div> </div>

View File

@@ -1,43 +0,0 @@
{% 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,7 +17,9 @@ from unittest.mock import patch
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase from django.test import Client, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import html from django.utils import html
@@ -27,9 +29,10 @@ from model_bakery import baker
from pytest_django.asserts import assertNumQueries, assertRedirects from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club, Membership from club.models import Club, Membership
from com.models import News, NewsDate, Sith, Weekmail, WeekmailArticle from com.models import News, NewsDate, Poster, Sith, Weekmail, WeekmailArticle
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import AnonymousUser, Group, User from core.models import AnonymousUser, Group, User
from core.utils import RED_PIXEL_PNG
@pytest.fixture() @pytest.fixture()
@@ -314,7 +317,6 @@ def test_feed(client: Client):
[ [
reverse("com:poster_list"), reverse("com:poster_list"),
reverse("com:poster_create"), reverse("com:poster_create"),
reverse("com:poster_moderate_list"),
], ],
) )
def test_poster_management_views_crash_test(client: Client, url: str): def test_poster_management_views_crash_test(client: Client, url: str):
@@ -325,3 +327,37 @@ def test_poster_management_views_crash_test(client: Client, url: str):
client.force_login(user) client.force_login(user)
res = client.get(url) res = client.get(url)
assert res.status_code == 200 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,7 +33,6 @@ from com.views import (
PosterDeleteView, PosterDeleteView,
PosterEditView, PosterEditView,
PosterListView, PosterListView,
PosterModerateListView,
PosterModerateView, PosterModerateView,
ScreenCreateView, ScreenCreateView,
ScreenDeleteView, ScreenDeleteView,
@@ -102,11 +101,6 @@ urlpatterns = [
PosterDeleteView.as_view(), PosterDeleteView.as_view(),
name="poster_delete", name="poster_delete",
), ),
path(
"poster/moderate/",
PosterModerateListView.as_view(),
name="poster_moderate_list",
),
path( path(
"poster/<int:object_id>/moderate/", "poster/<int:object_id>/moderate/",
PosterModerateView.as_view(), PosterModerateView.as_view(),

View File

@@ -25,6 +25,7 @@ import itertools
from datetime import date, timedelta from datetime import date, timedelta
from smtplib import SMTPRecipientsRefused from smtplib import SMTPRecipientsRefused
from typing import Any from typing import Any
from urllib.parse import urlparse
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
@@ -34,7 +35,7 @@ from django.contrib.auth.mixins import (
) )
from django.contrib.syndication.views import Feed from django.contrib.syndication.views import Feed
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.db.models import Max from django.db.models import Exists, Max, OuterRef, Value
from django.forms.models import modelform_factory from django.forms.models import modelform_factory
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
@@ -45,7 +46,7 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, TemplateView, View from django.views.generic import DetailView, ListView, TemplateView, View
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.models import Club, Mailing from club.models import Club, Mailing, Membership
from com.forms import NewsDateForm, NewsForm, PosterForm from com.forms import NewsDateForm, NewsForm, PosterForm
from com.ics_calendar import IcsCalendar from com.ics_calendar import IcsCalendar
from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle from com.models import News, NewsDate, Poster, Screen, Sith, Weekmail, WeekmailArticle
@@ -561,16 +562,26 @@ class MailingModerateView(View):
raise PermissionDenied raise PermissionDenied
class PosterListBaseView(PermissionOrClubBoardRequiredMixin, ListView): class PosterListBaseView(ListView):
"""List communication posters.""" """List communication posters."""
model = Poster model = Poster
template_name = "com/poster_list.jinja" template_name = "com/poster_list.jinja"
permission_required = "com.view_poster" permission_required = "com.view_poster"
ordering = ["-date_begin"]
def get_context_data(self, **kwargs): def get_queryset(self):
return super().get_context_data(**kwargs) | {"club": self.club} 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")
class PosterCreateBaseView(PermissionOrClubBoardRequiredMixin, CreateView): class PosterCreateBaseView(PermissionOrClubBoardRequiredMixin, CreateView):
@@ -633,21 +644,17 @@ class PosterDeleteBaseView(
permission_required = "com.delete_poster" permission_required = "com.delete_poster"
class PosterListView(ComTabsMixin, PosterListBaseView): class PosterListView(PermissionRequiredMixin, ComTabsMixin, PosterListBaseView):
"""List communication posters.""" """List communication posters."""
current_tab = "posters" current_tab = "posters"
extra_context = {
def get_queryset(self): "create_url": reverse_lazy("com:poster_create"),
qs = super().get_queryset() "get_edit_url": lambda poster: reverse(
if self.request.user.has_perm("com.view_poster"): "com:poster_edit", kwargs={"poster_id": poster.id}
return qs ),
return qs.filter(club=self.club.id) }
permission_required = "com.view_poster"
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
kwargs["app"] = "com"
return kwargs
class PosterCreateView(ComTabsMixin, PosterCreateBaseView): class PosterCreateView(ComTabsMixin, PosterCreateBaseView):
@@ -672,17 +679,6 @@ class PosterDeleteView(PosterDeleteBaseView):
success_url = reverse_lazy("com:poster_list") 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): class PosterModerateView(PermissionRequiredMixin, ComTabsMixin, View):
"""Moderate communication poster.""" """Moderate communication poster."""
@@ -690,12 +686,21 @@ class PosterModerateView(PermissionRequiredMixin, ComTabsMixin, View):
permission_required = "com.moderate_poster" permission_required = "com.moderate_poster"
extra_context = {"app": "com"} extra_context = {"app": "com"}
def get(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
obj = get_object_or_404(Poster, pk=kwargs["object_id"]) obj = get_object_or_404(Poster, pk=kwargs["object_id"])
obj.is_moderated = True obj.is_moderated = True
obj.moderator = request.user obj.moderator = request.user
obj.save() obj.save()
return redirect("com:poster_moderate_list") # 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("com:poster_list")
class ScreenListView(PermissionRequiredMixin, ComTabsMixin, ListView): class ScreenListView(PermissionRequiredMixin, ComTabsMixin, ListView):

View File

@@ -99,7 +99,7 @@ class SithFileController(ControllerBase):
@route.get( @route.get(
"/search", "/search",
response=PaginatedResponseSchema[SithFileSchema], response=PaginatedResponseSchema[SithFileSchema],
auth=[SessionAuth(), ApiKeyAuth()], auth=[ApiKeyAuth(), SessionAuth()],
permissions=[CanAccessLookup], permissions=[CanAccessLookup],
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
@@ -112,7 +112,7 @@ class GroupController(ControllerBase):
@route.get( @route.get(
"/search", "/search",
response=PaginatedResponseSchema[GroupSchema], response=PaginatedResponseSchema[GroupSchema],
auth=[SessionAuth(), ApiKeyAuth()], auth=[ApiKeyAuth(), SessionAuth()],
permissions=[CanAccessLookup], permissions=[CanAccessLookup],
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)

View File

@@ -24,7 +24,6 @@
from __future__ import annotations from __future__ import annotations
import types import types
import warnings
from typing import TYPE_CHECKING, Any, LiteralString from typing import TYPE_CHECKING, Any, LiteralString
from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin from django.contrib.auth.mixins import AccessMixin, PermissionRequiredMixin
@@ -147,45 +146,6 @@ class GenericContentPermissionMixinBuilder(View):
return super().dispatch(request, *arg, **kwargs) return super().dispatch(request, *arg, **kwargs)
class CanCreateMixin(View):
"""Protect any child view that would create an object.
Raises:
PermissionDenied:
If the user has not the necessary permission
to create the object of the view.
"""
def __init_subclass__(cls, **kwargs):
warnings.warn(
f"{cls.__name__} is deprecated and should be replaced "
"by other permission verification mecanism.",
DeprecationWarning,
stacklevel=2,
)
super().__init_subclass__(**kwargs)
def __init__(self, *args, **kwargs):
warnings.warn(
f"{self.__class__.__name__} is deprecated and should be replaced "
"by other permission verification mecanism.",
DeprecationWarning,
stacklevel=2,
)
super().__init__(*args, **kwargs)
def dispatch(self, request, *arg, **kwargs):
if not request.user.is_authenticated:
raise PermissionDenied
return super().dispatch(request, *arg, **kwargs)
def form_valid(self, form):
obj = form.instance
if can_edit_prop(obj, self.request.user):
return super().form_valid(form)
raise PermissionDenied
class CanEditPropMixin(GenericContentPermissionMixinBuilder): class CanEditPropMixin(GenericContentPermissionMixinBuilder):
"""Ensure the user has owner permissions on the child view object. """Ensure the user has owner permissions on the child view object.

View File

@@ -1,40 +0,0 @@
#
# Copyright 2018
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.core.management.base import BaseCommand
from core.models import SithFile
class Command(BaseCommand):
help = "Recursively check the file system with respect to the DB"
def add_arguments(self, parser):
parser.add_argument(
"ids", metavar="ID", type=int, nargs="+", help="The file IDs to process"
)
def handle(self, *args, **options):
files = SithFile.objects.filter(id__in=options["ids"]).all()
for f in files:
f._check_fs()

View File

@@ -1,41 +0,0 @@
#
# Copyright 2018
# - Skia <skia@libskia.so>
#
# Ce fichier fait partie du site de l'Association des Étudiants de l'UTBM,
# http://ae.utbm.fr.
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License a published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from django.core.management.base import BaseCommand
from core.models import SithFile
class Command(BaseCommand):
help = "Recursively repair the file system with respect to the DB"
def add_arguments(self, parser):
parser.add_argument(
"ids", metavar="ID", type=int, nargs="+", help="The file IDs to process"
)
def handle(self, *args, **options):
files = SithFile.objects.filter(id__in=options["ids"]).all()
for f in files:
f._repair_fs()

View File

@@ -23,14 +23,12 @@
# #
from __future__ import annotations from __future__ import annotations
import logging
import os
import string import string
import unicodedata import unicodedata
from datetime import timedelta from datetime import timedelta
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Optional, Self from typing import TYPE_CHECKING, Self
from uuid import uuid4 from uuid import uuid4
from django.conf import settings from django.conf import settings
@@ -97,48 +95,6 @@ def validate_promo(value: int) -> None:
) )
def get_group(*, pk: int | None = None, name: str | None = None) -> Group | None:
"""Search for a group by its primary key or its name.
Either one of the two must be set.
The result is cached for the default duration (should be 5 minutes).
Args:
pk: The primary key of the group
name: The name of the group
Returns:
The group if it exists, else None
Raises:
ValueError: If no group matches the criteria
"""
if pk is None and name is None:
raise ValueError("Either pk or name must be set")
# replace space characters to hide warnings with memcached backend
pk_or_name: str | int = pk if pk is not None else name.replace(" ", "_")
group = cache.get(f"sith_group_{pk_or_name}")
if group == "not_found":
# Using None as a cache value is a little bit tricky,
# so we use a special string to represent None
return None
elif group is not None:
return group
# if this point is reached, the group is not in cache
if pk is not None:
group = Group.objects.filter(pk=pk).first()
else:
group = Group.objects.filter(name=name).first()
if group is not None:
name = group.name.replace(" ", "_")
cache.set_many({f"sith_group_{group.id}": group, f"sith_group_{name}": group})
else:
cache.set(f"sith_group_{pk_or_name}", "not_found")
return group
class BanGroup(AuthGroup): class BanGroup(AuthGroup):
"""An anti-group, that removes permissions instead of giving them. """An anti-group, that removes permissions instead of giving them.
@@ -382,19 +338,18 @@ class User(AbstractUser):
Returns: Returns:
True if the user is the group, else False True if the user is the group, else False
""" """
if pk is not None: if not pk and not name:
group: Optional[Group] = get_group(pk=pk)
elif name is not None:
group: Optional[Group] = get_group(name=name)
else:
raise ValueError("You must either provide the id or the name of the group") raise ValueError("You must either provide the id or the name of the group")
if group is None: group_id: int | None = (
pk or Group.objects.filter(name=name).values_list("id", flat=True).first()
)
if group_id is None:
return False return False
if group.id == settings.SITH_GROUP_SUBSCRIBERS_ID: if group_id == settings.SITH_GROUP_SUBSCRIBERS_ID:
return self.is_subscribed return self.is_subscribed
if group.id == settings.SITH_GROUP_ROOT_ID: if group_id == settings.SITH_GROUP_ROOT_ID:
return self.is_root return self.is_root
return group in self.cached_groups return any(g.id == group_id for g in self.cached_groups)
@cached_property @cached_property
def cached_groups(self) -> list[Group]: def cached_groups(self) -> list[Group]:
@@ -454,14 +409,6 @@ class User(AbstractUser):
else: else:
raise ValidationError(_("A user with that username already exists")) raise ValidationError(_("A user with that username already exists"))
def get_profile(self):
return {
"last_name": self.last_name,
"first_name": self.first_name,
"nick_name": self.nick_name,
"date_of_birth": self.date_of_birth,
}
def get_short_name(self): def get_short_name(self):
"""Returns the short name for the user.""" """Returns the short name for the user."""
if self.nick_name: if self.nick_name:
@@ -689,8 +636,8 @@ class AnonymousUser(AuthAnonymousUser):
if pk is not None: if pk is not None:
return pk == allowed_id return pk == allowed_id
elif name is not None: elif name is not None:
group = get_group(name=name) group = Group.objects.get(id=allowed_id)
return group is not None and group.id == allowed_id return group.name == name
else: else:
raise ValueError("You must either provide the id or the name of the group") raise ValueError("You must either provide the id or the name of the group")
@@ -1016,63 +963,6 @@ class SithFile(models.Model):
self.clean() self.clean()
self.save() self.save()
def _repair_fs(self):
"""Rebuilds recursively the filesystem as it should be regarding the DB tree."""
if self.is_folder:
for c in self.children.all():
c._repair_fs()
return
elif not self._check_path_consistence():
# First get future parent path and the old file name
# Prepend "." so that we match all relative handling of Django's
# file storage
parent_path = "." + self.parent.get_full_path()
parent_full_path = settings.MEDIA_ROOT + parent_path
os.makedirs(parent_full_path, exist_ok=True)
old_path = self.file.name # Should be relative: "./users/skia/bleh.jpg"
new_path = "." + self.get_full_path()
try:
# Make this atomic, so that a FS problem rolls back the DB change
with transaction.atomic():
# Set the new filesystem path
self.file.name = new_path
self.save()
# Really move at the FS level
if os.path.exists(parent_full_path):
os.rename(
settings.MEDIA_ROOT + old_path,
settings.MEDIA_ROOT + new_path,
)
# Empty directories may remain, but that's not really a
# problem, and that can be solved with a simple shell
# command: `find . -type d -empty -delete`
except Exception as e:
logging.error(e)
def _check_path_consistence(self):
file_path = str(self.file)
file_full_path = settings.MEDIA_ROOT + file_path
db_path = ".%s" % self.get_full_path()
if not os.path.exists(file_full_path):
print("%s: WARNING: real file does not exists!" % self.id) # noqa T201
print("file path: %s" % file_path, end="") # noqa T201
print(" db path: %s" % db_path) # noqa T201
return False
if file_path != db_path:
print("%s: " % self.id, end="") # noqa T201
print("file path: %s" % file_path, end="") # noqa T201
print(" db path: %s" % db_path) # noqa T201
return False
return True
def _check_fs(self):
if self.is_folder:
for c in self.children.all():
c._check_fs()
return
else:
self._check_path_consistence()
@property @property
def is_file(self): def is_file(self):
return not self.is_folder return not self.is_folder
@@ -1157,8 +1047,6 @@ class QuickUploadImage(models.Model):
identifier = str(uuid4()) identifier = str(uuid4())
name = Path(image.name).stem[: cls.IMAGE_NAME_SIZE - 1] name = Path(image.name).stem[: cls.IMAGE_NAME_SIZE - 1]
file = File(convert_image(image), name=f"{identifier}.webp") file = File(convert_image(image), name=f"{identifier}.webp")
width, height = Image.open(file).size
return cls.objects.create( return cls.objects.create(
uuid=identifier, uuid=identifier,
name=name, name=name,

View File

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

View File

@@ -0,0 +1,69 @@
import type { Alpine as AlpineType } from "alpinejs";
export function limitedChoices(Alpine: AlpineType) {
/**
* Directive to limit the number of elements
* that can be selected in a group of checkboxes.
*
* When the max numbers of selectable elements is reached,
* new elements will still be inserted, but oldest ones will be deselected.
* For example, if checkboxes A, B and C have been selected and the max
* number of selections is 3, then selecting D will result in having
* B, C and D selected.
*
* # Example in template
* ```html
* <div x-data="{nbMax: 2}", x-limited-choices="nbMax">
* <button @click="nbMax += 1">Click me to increase the limit</button>
* <input type="checkbox" value="A" name="foo">
* <input type="checkbox" value="B" name="foo">
* <input type="checkbox" value="C" name="foo">
* <input type="checkbox" value="D" name="foo">
* </div>
* ```
*/
Alpine.directive(
"limited-choices",
(el, { expression }, { evaluateLater, effect }) => {
const getMaxChoices = evaluateLater(expression);
let maxChoices: number;
const inputs: HTMLInputElement[] = Array.from(
el.querySelectorAll("input[type='checkbox']"),
);
const checked = [] as HTMLInputElement[];
const manageDequeue = () => {
if (checked.length <= maxChoices) {
// There isn't too many checkboxes selected. Nothing to do
return;
}
const popped = checked.splice(0, checked.length - maxChoices);
for (const p of popped) {
p.checked = false;
}
};
for (const input of inputs) {
input.addEventListener("change", (_e) => {
if (input.checked) {
checked.push(input);
} else {
checked.splice(checked.indexOf(input), 1);
}
manageDequeue();
});
}
effect(() => {
getMaxChoices((value: string) => {
const previousValue = maxChoices;
maxChoices = Number.parseInt(value);
if (maxChoices < previousValue) {
// The maximum number of selectable items has been lowered.
// Some currently selected elements may need to be removed
manageDequeue();
}
});
});
},
);
}

View File

@@ -65,7 +65,7 @@ footer.bottom-links {
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
background-color: $primary-neutral-dark-color; background-color: $primary-neutral-dark-color;
box-shadow: $shadow-color 0 0 15px; box-shadow: black 0 8px 15px;
a { a {
color: $white-color; color: $white-color;

View File

@@ -11,7 +11,8 @@ $hovered-red-text-color: #ff4d4d;
.header { .header {
box-sizing: border-box; box-sizing: border-box;
background-color: $deepblue; background-color: $deepblue;
box-shadow: 3px 3px 3px 0 #dfdfdf; box-shadow: black 0 1px 3px 0,
black 0 4px 8px 3px;
border-radius: 0; border-radius: 0;
width: 100%; width: 100%;
display: flex; display: flex;
@@ -99,7 +100,7 @@ $hovered-red-text-color: #ff4d4d;
border-radius: 0; border-radius: 0;
margin: 0; margin: 0;
box-sizing: border-box; box-sizing: border-box;
background-color: $deepblue; background-color: transparent;
width: 45px; width: 45px;
height: 25px; height: 25px;
padding: 0; padding: 0;
@@ -331,7 +332,7 @@ $hovered-red-text-color: #ff4d4d;
padding: 10px; padding: 10px;
z-index: 100; z-index: 100;
border-radius: 10px; border-radius: 10px;
box-shadow: 3px 3px 3px 0 #767676; @include shadow;
>ul { >ul {
list-style-type: none; list-style-type: none;

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -271,8 +271,9 @@ body {
/*--------------------------------CONTENT------------------------------*/ /*--------------------------------CONTENT------------------------------*/
#content { #content {
padding: 1em 1%; padding: 1.5em 2%;
box-shadow: $shadow-color 0 5px 10px; border-radius: 5px;
box-shadow: black 0 8px 15px;
background: $white-color; background: $white-color;
overflow: auto; overflow: auto;
} }

View File

@@ -4,12 +4,22 @@
{% block head %} {% block head %}
<title>{% block title %}Association des Étudiants de l'UTBM{% endblock %}</title> <title>{% block title %}Association des Étudiants de l'UTBM{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{% block description -%} <meta
{% trans trimmed %} name="description"
AE UTBM is a voluntary organisation run by UTBM students. content="{% block description -%}
It organises student life at UTBM and manages its student facilities. {% trans trimmed %}
{% endtrans %} AE UTBM is a voluntary organisation run by UTBM students.
{%- endblock %}"> It organises student life at UTBM and manages its student facilities.
{% endtrans %}
{%- endblock %}"
>
<meta property="og:site_name" content="Association des Étudiants de l'UTBM" />
{% block metatags %}
<meta property="og:url" content="{{ request.build_absolute_uri() }}" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Association des Étudiants de l'UTBM" />
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% endblock %}
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}"> <link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link rel="stylesheet" href="{{ static('core/base.css') }}"> <link rel="stylesheet" href="{{ static('core/base.css') }}">
<link rel="stylesheet" href="{{ static('core/style.scss') }}"> <link rel="stylesheet" href="{{ static('core/style.scss') }}">
@@ -34,6 +44,18 @@
{% block additional_css %}{% endblock %} {% block additional_css %}{% endblock %}
{% block additional_js %}{% endblock %} {% block additional_js %}{% endblock %}
<style>
{# background image must be declared here, because the static names are
changed during the static collection step,
which means we must gather them with the `static` template function #}
.header {
background-image: url("{{ static("core/img/gala25_background.webp") }}");
background-position-y: 80%; {# There are more stars in this part of the picture #}
}
body {
background-image: url("{{ static("core/img/gala25_background.webp") }}");
}
</style>
{% endblock %} {% endblock %}
</head> </head>

View File

@@ -1,6 +1,6 @@
<header class="header"> <header class="header">
<div class="header-logo"> <div class="header-logo">
<a class="header-logo-picture" href="{{ url('core:index') }}" style="background-image: url('{{ static('core/img/logo_no_text.png') }}')"> <a class="header-logo-picture" href="{{ url('core:index') }}" style="background-image: url('{{ static("core/img/gala25_logo.webp") }}')">
&nbsp; &nbsp;
</a> </a>
<a class="header-logo-text" href="{{ url('core:index') }}"> <a class="header-logo-text" href="{{ url('core:index') }}">

View File

@@ -21,20 +21,6 @@
{% else %} {% else %}
<h2>{% trans %}Save{% endtrans %}</h2> <h2>{% trans %}Save{% endtrans %}</h2>
{% endif %} {% endif %}
{% if messages %}
<div x-data="{show_alert: true}" class="alert alert-green" x-show="show_alert" x-transition>
<span class="alert-main">
{% for message in messages %}
{% if message.level_tag == "success" %}
{{ message }}
{% endif %}
{% endfor %}
</span>
<span class="clickable" @click="show_alert = false">
<i class="fa fa-close"></i>
</span>
</div>
{% endif %}
<form action="" method="post" enctype="multipart/form-data"> <form action="" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
{{ form.as_p() }} {{ form.as_p() }}

View File

@@ -13,30 +13,11 @@
{%- endmacro %} {%- endmacro %}
{% macro link_news_logo(news) -%} {% macro link_news_logo(news) -%}
{% if news.club.logo -%} {%- if news.club.logo -%}
{{ news.club.logo.url }} {{ news.club.logo.url }}
{% else -%} {%- else -%}
{{ static("com/img/news.png") }} {{ static("com/img/news.png") }}
{% endif %} {%- endif -%}
{%- endmacro %}
{% macro gen_news_metatags(news) -%}
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="{{ settings.SITH_TWITTER }}" />
<meta name="twitter:creator" content= "{{ settings.SITH_TWITTER }}" />
<meta property="og:url" content="{{ news.get_full_url() }}" />
<meta property="og:type" content="article" />
<meta property="og:title" content="{{ news.title }}" />
<meta property="og:description" content="{{ news.summary }}" />
<meta property="og:image" content="{{ "https://%s%s" % (settings.SITH_URL, link_news_logo(news)) }}" />
{%- endmacro %}
{% macro facebook_share(news) -%}
<a rel="nofollow" target="#" class="share_button facebook" href="https://www.facebook.com/sharer/sharer.php?u={{ news.get_full_url() }}">{% trans %}Share on Facebook{% endtrans %}</a>
{%- endmacro %}
{% macro tweet(news) -%}
<a rel="nofollow" target="#" class="share_button twitter" href="https://twitter.com/intent/tweet?text={{ news.get_full_url() }}">{% trans %}Tweet{% endtrans %}</a>
{%- endmacro %} {%- endmacro %}
{% macro user_mini_profile(user) %} {% macro user_mini_profile(user) %}
@@ -153,7 +134,7 @@
current_page (django.core.paginator.Page): the current page object current_page (django.core.paginator.Page): the current page object
paginator (django.core.paginator.Paginator): the paginator object paginator (django.core.paginator.Paginator): the paginator object
#} #}
{{ paginate_server_side(current_page, paginator, False) }} {{ paginate_server_side(current_page, paginator, False) }}
{% endmacro %} {% endmacro %}
{% macro paginate_htmx(current_page, paginator) %} {% macro paginate_htmx(current_page, paginator) %}
@@ -168,7 +149,7 @@
current_page (django.core.paginator.Page): the current page object current_page (django.core.paginator.Page): the current page object
paginator (django.core.paginator.Paginator): the paginator object paginator (django.core.paginator.Paginator): the paginator object
#} #}
{{ paginate_server_side(current_page, paginator, True) }} {{ paginate_server_side(current_page, paginator, True) }}
{% endmacro %} {% endmacro %}
{% macro paginate_server_side(current_page, paginator, use_htmx) %} {% macro paginate_server_side(current_page, paginator, use_htmx) %}

View File

@@ -12,6 +12,18 @@
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block metatags %}
{% if page %}
<meta property="og:url" content="{{ request.build_absolute_uri(page.get_absolute_url()) }}" />
<meta property="og:type" content="article" />
<meta property="article:section" content="{% trans %}Page{% endtrans %}" />
<meta property="og:title" content="{{ page.get_display_name() }}" />
<meta property="og:image" content="{{ request.build_absolute_uri(static("core/img/logo_no_text.png")) }}" />
{% else %}
{{ super() }}
{% endif %}
{% endblock %}
{%- macro print_page_name(page) -%} {%- macro print_page_name(page) -%}
{%- if page -%} {%- if page -%}
{{ print_page_name(page.parent) }} > {{ print_page_name(page.parent) }} >

View File

@@ -421,18 +421,16 @@ class TestUserIsInGroup(TestCase):
# clear the cached property `User.cached_groups` # clear the cached property `User.cached_groups`
self.public_user.__dict__.pop("cached_groups", None) self.public_user.__dict__.pop("cached_groups", None)
cache.clear()
# Test when the user is in the group # Test when the user is in the group
with self.assertNumQueries(2): with self.assertNumQueries(1):
self.public_user.is_in_group(pk=group_in.id) self.public_user.is_in_group(pk=group_in.id)
with self.assertNumQueries(0): with self.assertNumQueries(0):
self.public_user.is_in_group(pk=group_in.id) self.public_user.is_in_group(pk=group_in.id)
group_not_in = baker.make(Group) group_not_in = baker.make(Group)
self.public_user.__dict__.pop("cached_groups", None) self.public_user.__dict__.pop("cached_groups", None)
cache.clear()
# Test when the user is not in the group # Test when the user is not in the group
with self.assertNumQueries(2): with self.assertNumQueries(1):
self.public_user.is_in_group(pk=group_not_in.id) self.public_user.is_in_group(pk=group_not_in.id)
with self.assertNumQueries(0): with self.assertNumQueries(0):
self.public_user.is_in_group(pk=group_not_in.id) self.public_user.is_in_group(pk=group_not_in.id)

View File

@@ -46,7 +46,7 @@ class TestFetchFamilyApi(TestCase):
response = self.client.get( response = self.client.get(
reverse("api:family_graph", args=[self.main_user.id]) reverse("api:family_graph", args=[self.main_user.id])
) )
assert response.status_code == 403 assert response.status_code == 401
self.client.force_login(baker.make(User)) # unsubscribed user self.client.force_login(baker.make(User)) # unsubscribed user
response = self.client.get( response = self.client.get(

View File

@@ -269,7 +269,7 @@ def test_apply_rights_recursively():
SimpleUploadedFile( SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg" "test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
), ),
403, 401,
), ),
( (
lambda: baker.make(User), lambda: baker.make(User),

View File

@@ -64,7 +64,7 @@ class CounterController(ControllerBase):
@route.get( @route.get(
"/search", "/search",
response=PaginatedResponseSchema[SimplifiedCounterSchema], response=PaginatedResponseSchema[SimplifiedCounterSchema],
auth=[SessionAuth(), ApiKeyAuth()], auth=[ApiKeyAuth(), SessionAuth()],
permissions=[CanAccessLookup], permissions=[CanAccessLookup],
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
@@ -77,7 +77,7 @@ class ProductController(ControllerBase):
@route.get( @route.get(
"/search", "/search",
response=PaginatedResponseSchema[SimpleProductSchema], response=PaginatedResponseSchema[SimpleProductSchema],
auth=[SessionAuth(), ApiKeyAuth()], auth=[ApiKeyAuth(), SessionAuth()],
permissions=[CanAccessLookup], permissions=[CanAccessLookup],
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
@@ -117,7 +117,7 @@ class ProductTypeController(ControllerBase):
def fetch_all(self): def fetch_all(self):
return ProductType.objects.order_by("order") return ProductType.objects.order_by("order")
@route.patch("/{type_id}/move") @route.patch("/{type_id}/move", url_name="reorder_product_type")
def reorder(self, type_id: int, other_id: Query[ReorderProductTypeSchema]): def reorder(self, type_id: int, other_id: Query[ReorderProductTypeSchema]):
"""Change the order of a product type. """Change the order of a product type.

View File

@@ -0,0 +1,15 @@
# 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)) .annotate(res=Sum(F("unit_price") * F("quantity"), default=0))
.values("res") .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): class Customer(models.Model):
@@ -849,7 +849,7 @@ class Selling(models.Model):
blank=False, blank=False,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
) )
date = models.DateTimeField(_("date")) date = models.DateTimeField(_("date"), db_index=True)
payment_method = models.CharField( payment_method = models.CharField(
_("payment method"), _("payment method"),
max_length=255, max_length=255,

View File

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

View File

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

View File

@@ -3,11 +3,9 @@ from django.conf import settings
from django.test import Client from django.test import Client
from django.urls import reverse from django.urls import reverse
from model_bakery import baker, seq from model_bakery import baker, seq
from ninja_extra.testing import TestClient
from core.baker_recipes import board_user, subscriber_user from core.baker_recipes import board_user, subscriber_user
from core.models import Group, User from core.models import Group, User
from counter.api import ProductTypeController
from counter.models import ProductType from counter.models import ProductType
@@ -19,24 +17,43 @@ def product_types(db) -> list[ProductType]:
return baker.make(ProductType, _quantity=5, order=seq(0)) return baker.make(ProductType, _quantity=5, order=seq(0))
@pytest.fixture()
def counter_admin_client(db, client: Client) -> Client:
client.force_login(
baker.make(
User, groups=[Group.objects.get(id=settings.SITH_GROUP_COUNTER_ADMIN_ID)]
)
)
return client
@pytest.mark.django_db @pytest.mark.django_db
def test_fetch_product_types(product_types: list[ProductType]): def test_fetch_product_types(
counter_admin_client: Client, product_types: list[ProductType]
):
"""Test that the API returns the right products in the right order""" """Test that the API returns the right products in the right order"""
client = TestClient(ProductTypeController) response = counter_admin_client.get(reverse("api:fetch_product_types"))
response = client.get("")
assert response.status_code == 200 assert response.status_code == 200
assert [i["id"] for i in response.json()] == [t.id for t in product_types] assert [i["id"] for i in response.json()] == [t.id for t in product_types]
@pytest.mark.django_db @pytest.mark.django_db
def test_move_below_product_type(product_types: list[ProductType]): def test_move_below_product_type(
counter_admin_client: Client, product_types: list[ProductType]
):
"""Test that moving a product below another works""" """Test that moving a product below another works"""
client = TestClient(ProductTypeController) response = counter_admin_client.patch(
response = client.patch( reverse(
f"/{product_types[-1].id}/move", query={"below": product_types[0].id} "api:reorder_product_type",
kwargs={"type_id": product_types[-1].id},
query={"below": product_types[0].id},
),
) )
assert response.status_code == 200 assert response.status_code == 200
new_order = [i["id"] for i in client.get("").json()] new_order = [
i["id"]
for i in counter_admin_client.get(reverse("api:fetch_product_types")).json()
]
assert new_order == [ assert new_order == [
product_types[0].id, product_types[0].id,
product_types[-1].id, product_types[-1].id,
@@ -45,14 +62,22 @@ def test_move_below_product_type(product_types: list[ProductType]):
@pytest.mark.django_db @pytest.mark.django_db
def test_move_above_product_type(product_types: list[ProductType]): def test_move_above_product_type(
counter_admin_client: Client, product_types: list[ProductType]
):
"""Test that moving a product above another works""" """Test that moving a product above another works"""
client = TestClient(ProductTypeController) response = counter_admin_client.patch(
response = client.patch( reverse(
f"/{product_types[1].id}/move", query={"above": product_types[0].id} "api:reorder_product_type",
kwargs={"type_id": product_types[1].id},
query={"above": product_types[0].id},
),
) )
assert response.status_code == 200 assert response.status_code == 200
new_order = [i["id"] for i in client.get("").json()] new_order = [
i["id"]
for i in counter_admin_client.get(reverse("api:fetch_product_types")).json()
]
assert new_order == [ assert new_order == [
product_types[1].id, product_types[1].id,
product_types[0].id, product_types[0].id,

View File

@@ -182,29 +182,19 @@ ainsi même que de l'héritage de templates.
si on souhaite faire des modifications côté client, si on souhaite faire des modifications côté client,
il faut utiliser du Javascript, rien ne change à ce niveau-là. il faut utiliser du Javascript, rien ne change à ce niveau-là.
### jQuery ### Typescript
[Site officiel](https://jquery.com/) [Site officiel](https://www.typescriptlang.org/)
jQuery est une bibliothèque JavaScript Pour rendre le site interactif, nous n'utilisons
libre et multiplateforme créée pour faciliter pas directement Javascript, mais Typescript.
l'écriture de scripts côté client Il s'agit d'un langage construit par-dessus Javascript,
dans le code HTML des pages web. en ajoutant un typage statique et des éléments de sucre syntaxique.
La première version est lancée en janvier 2006 par John Resig. Grâce au système de type, le code est plus lisible,
à la fois par les humains et par l'IDE, et plus fiable.
C'est une vieille technologie et certains Il faut parfois se battre un peu contre le système de types de Typescript,
feront remarquer à juste titre que le Javascript mais globalement Typescript est une alternative largement préférable à Javascript.
moderne permet d'utiliser assez simplement
la majorité de ce que fournit jQuery
sans rien avoir à installer.
Cependant, de nombreuses dépendances du projet
utilisent encore jQuery qui est toujours
très implanté aujourd'hui.
Le sucre syntaxique qu'offre cette librairie
reste très agréable à utiliser et économise
parfois beaucoup de temps.
Ça fonctionne et ça fonctionne très bien.
C'est maintenu et pratique.
### AlpineJS ### AlpineJS
@@ -270,17 +260,6 @@ sur tous les navigateurs contrairement
à un simple icône unicode qui s'affiche à un simple icône unicode qui s'affiche
lui différemment selon la plate-forme. lui différemment selon la plate-forme.
!!!note
C'est une dépendance capricieuse qui évolue très vite
et qu'il faut très souvent mettre à jour.
!!!warning
Il a été décidé de **ne pas utiliser**
de CDN puisque le site ralentissait régulièrement.
Il est préférable de fournir cette dépendance avec le site.
## Workflow ## Workflow
### Git ### Git

View File

@@ -1,4 +1,4 @@
L'ORM de Django est puissant, très puissant, non par parce qu'il L'ORM de Django est puissant, très puissant, non pas parce qu'il
est performant (après tout, ce n'est qu'une interface, le gros du boulot, est performant (après tout, ce n'est qu'une interface, le gros du boulot,
c'est la db qui le fait), mais parce qu'il permet d'écrire c'est la db qui le fait), mais parce qu'il permet d'écrire
de manière relativement simple un grand panel de requêtes. de manière relativement simple un grand panel de requêtes.

View File

@@ -51,7 +51,7 @@ Pour accéder au fichier, il faut utiliser `static` comme pour le reste mais en
Le bundler ne génère que des modules javascript. Le bundler ne génère que des modules javascript.
Ajouter `type="module"` n'est pas optionnel ! Ajouter `type="module"` n'est pas optionnel !
### Les imports au sein des fichiers des fichiers javascript bundlés ### Les imports au sein des fichiers javascript bundlés
Pour importer au sein d'un fichier js bundlé, il faut préfixer ses imports de `#app:`. Pour importer au sein d'un fichier js bundlé, il faut préfixer ses imports de `#app:`.

View File

@@ -36,11 +36,4 @@ SITH_SUBSCRIPTIONS = {
} }
``` ```
Une fois ceci fait, il faut créer une nouvelle migration : Après ça, n'oubliez pas de gérer les traductions (cf. [ici](./translation.md))
```bash
python ./manage.py makemigrations subscription
python ./manage.py migrate
```
N'oubliez pas non plus les traductions (cf. [ici](./translation.md))

View File

@@ -17,7 +17,6 @@
- can_edit_prop - can_edit_prop
- can_edit - can_edit
- can_view - can_view
- CanCreateMixin
- CanEditMixin - CanEditMixin
- CanViewMixin - CanViewMixin
- CanEditPropMixin - CanEditPropMixin

View File

@@ -1,4 +1,3 @@
Pour l'API, nous utilisons `django-ninja` et sa surcouche `django-ninja-extra`. Pour l'API, nous utilisons `django-ninja` et sa surcouche `django-ninja-extra`.
Ce sont des librairies relativement simples et qui présentent Ce sont des librairies relativement simples et qui présentent
l'immense avantage d'offrir des mécanismes de validation et de sérialisation l'immense avantage d'offrir des mécanismes de validation et de sérialisation
@@ -49,8 +48,9 @@ Notre API offre deux moyens d'authentification :
- par clef d'API - par clef d'API
La plus grande partie des routes de l'API utilisent la méthode par cookie de session. La plus grande partie des routes de l'API utilisent la méthode par cookie de session.
Cette dernière est donc activée par défaut.
Pour placer une route d'API derrière l'une de ces méthodes (ou bien les deux), Pour changer la méthode d'authentification,
utilisez l'attribut `auth` et les classes `SessionAuth` et utilisez l'attribut `auth` et les classes `SessionAuth` et
[`ApiKeyAuth`][api.auth.ApiKeyAuth]. [`ApiKeyAuth`][api.auth.ApiKeyAuth].
@@ -60,13 +60,17 @@ utilisez l'attribut `auth` et les classes `SessionAuth` et
@api_controller("/foo") @api_controller("/foo")
class FooController(ControllerBase): class FooController(ControllerBase):
# Cette route sera accessible uniquement avec l'authentification # Cette route sera accessible uniquement avec l'authentification
# par cookie de session # par clef d'API
@route.get("", auth=[SessionAuth()]) @route.get("", auth=[ApiKeyAuth()])
def fetch_foo(self, club_id: int): ... def fetch_foo(self, club_id: int): ...
# Et celle-ci sera accessible peut importe la méthode d'authentification # Celle-ci sera accessible avec les deux méthodes d'authentification
@route.get("/bar", auth=[SessionAuth(), ApiKeyAuth()]) @route.get("/bar", auth=[ApiKeyAuth(), SessionAuth()])
def fetch_bar(self, club_id: int): ... def fetch_bar(self, club_id: int): ...
# Et celle-ci sera accessible aussi aux utilisateurs non-connectés
@route.get("/public", auth=None)
def fetch_public(self, club_id: int): ...
``` ```
### Permissions ### Permissions
@@ -79,9 +83,7 @@ par-dessus `django-ninja`, le système de permissions de django
et notre propre système. et notre propre système.
Cette dernière est documentée [ici](../perms.md). Cette dernière est documentée [ici](../perms.md).
### Limites des clefs d'API ### Incompatibilité avec certaines permissions
#### Incompatibilité avec certaines permissions
Le système des clefs d'API est apparu très tard dans l'histoire du site Le système des clefs d'API est apparu très tard dans l'histoire du site
(en P25, 10 ans après le début du développement). (en P25, 10 ans après le début du développement).
@@ -112,10 +114,33 @@ Les principaux points de friction sont :
- `IsLoggedInCounter`, qui utilise encore un autre système - `IsLoggedInCounter`, qui utilise encore un autre système
d'authentification maison et qui n'est pas fait pour être utilisé en dehors du site. d'authentification maison et qui n'est pas fait pour être utilisé en dehors du site.
#### Incompatibilité avec les tokens csrf ### CSRF
Le [CSRF (*cross-site request forgery*)](https://fr.wikipedia.org/wiki/Cross-site_request_forgery) !!!info "A propos du csrf"
est un des multiples facteurs d'attaque sur le web.
Le [CSRF (*cross-site request forgery*)](https://fr.wikipedia.org/wiki/Cross-site_request_forgery)
est un vecteur d'attaque sur le web consistant
à soumettre des données au serveur à l'insu
de l'utilisateur, en profitant de sa session.
C'est une attaque qui peut se produire lorsque l'utilisateur
est authentifié par cookie de session.
En effet, les cookies sont joints automatiquement à
toutes les requêtes ;
en l'absence de protection contre le CSRF,
un attaquant parvenant à insérer un formulaire
dans la page de l'utilisateur serait en mesure
de faire presque n'importe quoi en son nom,
et ce sans même que l'utilisateur ni les administrateurs
ne s'en rendent compte avant qu'il ne soit largement trop tard !
Sur le CSRF et les moyens de s'en prémunir, voir :
- [https://owasp.org/www-community/attacks/csrf]()
- [https://security.stackexchange.com/questions/166724/should-i-use-csrf-protection-on-rest-api-endpoints]()
- [https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html]()
Le CSRF, c'est dangereux.
Heureusement, Django vient encore une fois à notre aide, Heureusement, Django vient encore une fois à notre aide,
avec des mécanismes intégrés pour s'en protéger. avec des mécanismes intégrés pour s'en protéger.
Ceux-ci incluent notamment un système de Ceux-ci incluent notamment un système de
@@ -123,16 +148,39 @@ Ceux-ci incluent notamment un système de
à fournir dans les requêtes POST/PUT/PATCH. à fournir dans les requêtes POST/PUT/PATCH.
Ceux-ci sont bien adaptés au cycle requêtes/réponses Ceux-ci sont bien adaptés au cycle requêtes/réponses
typique de l'expérience utilisateur sur un navigateur, typiques de l'expérience utilisateur sur un navigateur,
où les requêtes POST sont toujours effectuées après une requête où les requêtes POST sont toujours effectuées après une requête
GET au cours de laquelle on a pu récupérer un token csrf. GET au cours de laquelle on a pu récupérer un token csrf.
Cependant, le flux des requêtes sur une API est bien différent ; Cependant, ils sont également gênants et moins utiles
de ce fait, il est à attendre que les requêtes POST envoyées à l'API dans le cadre d'une API REST, étant donné
par un client externe n'aient pas de token CSRF et se retrouvent que l'authentification cesse d'être implicite :
donc bloquées. la clef d'API doit être explicitement jointe aux headers,
pour chaque requête.
Pour ces raisons, l'accès aux requêtes POST/PUT/PATCH de l'API Pour ces raisons, la vérification CSRF ne prend place
par un client externe ne marche pas. que pour la vérification de l'authentification
par cookie de session.
!!!warning "L'ordre est important"
Si vous écrivez le code suivant, l'authentification par clef d'API
ne marchera plus :
```python
@api_controller("/foo")
class FooController(ControllerBase):
@route.post("/bar", auth=[SessionAuth(), ApiKeyAuth()])
def post_bar(self, club_id: int): ...
```
En effet, la vérification du cookie de session intègrera
toujours la vérification CSRF.
Or, un échec de cette dernière est traduit par django en un code HTTP 403
au lieu d'un HTTP 401.
L'authentification se retrouve alors court-circuitée,
faisant que la vérification de la clef d'API ne sera jamais appelée.
`SessionAuth` doit donc être déclaré **après** `ApiKeyAuth`.
## Créer un client et une clef d'API ## Créer un client et une clef d'API
@@ -171,5 +219,3 @@ qui en a besoin.
Dites-lui bien de garder cette clef en lieu sûr ! Dites-lui bien de garder cette clef en lieu sûr !
Si la clef est perdue, il n'y a pas moyen de la récupérer, Si la clef est perdue, il n'y a pas moyen de la récupérer,
vous devrez en recréer une. vous devrez en recréer une.

View File

@@ -157,16 +157,18 @@ que sont VsCode et Sublime Text.
Si vous avez réussi à terminer l'installation, vous n'avez donc pas de configuration Si vous avez réussi à terminer l'installation, vous n'avez donc pas de configuration
supplémentaire à effectuer. supplémentaire à effectuer.
Pour utiliser Biome, placez-vous à la racine du projet et lancer la commande suivante: Pour utiliser Biome, placez-vous à la racine du projet et lancez la commande suivante:
```bash ```bash
npx @biomejs/biome check # Pour checker le code avec le linter et le formater npx @biomejs/biome check # Pour checker le code avec le linter et le formater
npx @biomejs/biome check --write # Pour appliquer les changemnts npx @biomejs/biome check --write # Pour appliquer les changements
``` ```
Biome va alors faire son travail sur l'ensemble du projet puis vous dire Biome va alors faire son travail sur l'ensemble du projet puis vous dire
si des documents ont été reformatés (si vous avez fait `npx @biomejs/biome format --write`) si des documents ont été reformatés (si vous avez fait `npx @biomejs/biome format --write`)
ou bien s'il y a des erreurs à réparer (si vous avez faire `npx @biomejs/biome lint`) ou les deux (si vous avez fait `npx @biomejs/biome check --write`). ou bien s'il y a des erreurs à réparer
(si vous avez fait `npx @biomejs/biome lint`)
ou les deux (si vous avez fait `npx @biomejs/biome check --write`).
Appeler Biome en ligne de commandes avant de pousser votre code sur Github Appeler Biome en ligne de commandes avant de pousser votre code sur Github
est une technique qui marche très bien. est une technique qui marche très bien.

View File

@@ -30,7 +30,7 @@ opérations, telles que la validation de formulaire.
En effet, valider un formulaire demande beaucoup En effet, valider un formulaire demande beaucoup
de travail de nettoyage des données et d'affichage de travail de nettoyage des données et d'affichage
des messages d'erreur appropriés. des messages d'erreur appropriés.
Or, tout ce travail existe déjà dans django. Or, tout ce travail existe déjà dans Django.
On veut donc, dans ces cas-là, ne pas demander On veut donc, dans ces cas-là, ne pas demander
toute une page HTML au serveur, mais uniquement toute une page HTML au serveur, mais uniquement
@@ -84,7 +84,7 @@ Grâce à ça, on peut écrire des vues qui
fonctionnent dans les deux contextes. fonctionnent dans les deux contextes.
Par exemple, supposons que nous avons Par exemple, supposons que nous avons
une `EditView` très simple, contenant une `UpdateView` très simple, contenant
uniquement un formulaire. uniquement un formulaire.
On peut écrire la vue et le template de la manière On peut écrire la vue et le template de la manière
suivante : suivante :
@@ -94,8 +94,10 @@ suivante :
```python ```python
from django.views.generic import UpdateView from django.views.generic import UpdateView
from core.views import AllowFragment
class FooUpdateView(UpdateView):
class FooUpdateView(AllowFragment, UpdateView):
model = Foo model = Foo
fields = ["foo", "bar"] fields = ["foo", "bar"]
pk_url_kwarg = "foo_id" pk_url_kwarg = "foo_id"
@@ -132,7 +134,7 @@ Dans ces situations, pouvoir décomposer une vue
en plusieurs vues de fragment permet de ne plus en plusieurs vues de fragment permet de ne plus
raisonner en termes de condition, mais en termes raisonner en termes de condition, mais en termes
de composition : on n'a pas un seul template de composition : on n'a pas un seul template
qui peut changer les situations, on a plusieurs qui peut changer selon les situations, on a plusieurs
templates que l'on injecte dans un template principal. templates que l'on injecte dans un template principal.
Supposons, par exemple, que nous n'avons plus un, Supposons, par exemple, que nous n'avons plus un,
@@ -238,10 +240,10 @@ qui se comportera alors comme une vue normale.
#### La méthode `as_fragment` #### La méthode `as_fragment`
Il est à noter que l'instantiation d'un fragment Il est à noter que l'instanciation d'un fragment
se fait en deux étapes : se fait en deux étapes :
- on commence par instantier la vue en tant que renderer. - on commence par instancier la vue en tant que renderer.
- on appelle le renderer en lui-même - on appelle le renderer en lui-même
Ce qui donne la syntaxe `Fragment.as_fragment()()`. Ce qui donne la syntaxe `Fragment.as_fragment()()`.

View File

@@ -76,7 +76,7 @@ cd /mnt/<la_lettre_du_disque>/vos/fichiers/comme/dhab
```bash ```bash
sudo pacman -Syu # on s'assure que les dépôts et le système sont à jour sudo pacman -Syu # on s'assure que les dépôts et le système sont à jour
sudo pacman -S uv gcc git gettext pkgconf npm redis sudo pacman -S uv gcc git gettext pkgconf npm valkey
``` ```
=== "macOS" === "macOS"

View File

@@ -212,7 +212,7 @@ Pour les vues sous forme de fonction, il y a le décorateur
obj = self.get_object() obj = self.get_object()
obj.is_moderated = True obj.is_moderated = True
obj.save() obj.save()
return redirect(reverse("com:news_list")) return redirect("com:news_list")
``` ```
=== "Function-based view" === "Function-based view"
@@ -233,7 +233,7 @@ Pour les vues sous forme de fonction, il y a le décorateur
news = get_object_or_404(News, id=news_id) news = get_object_or_404(News, id=news_id)
news.is_moderated = True news.is_moderated = True
news.save() news.save()
return redirect(reverse("com:news_list")) return redirect("com:news_list")
``` ```
## Accès à des éléments en particulier ## Accès à des éléments en particulier
@@ -447,10 +447,9 @@ l'utilisateur recevra une liste vide d'objet.
Voici un exemple d'utilisation en reprenant l'objet Article crée précédemment : Voici un exemple d'utilisation en reprenant l'objet Article crée précédemment :
```python ```python
from django.views.generic import CreateView, DetailView from django.views.generic import DetailView
from core.auth.mixins import CanViewMixin, CanCreateMixin
from core.auth.mixins import CanViewMixin
from com.models import WeekmailArticle from com.models import WeekmailArticle
@@ -459,48 +458,15 @@ from com.models import WeekmailArticle
# d'une classe de base pour fonctionner correctement. # d'une classe de base pour fonctionner correctement.
class ArticlesDetailView(CanViewMixin, DetailView): class ArticlesDetailView(CanViewMixin, DetailView):
model = WeekmailArticle model = WeekmailArticle
# Même chose pour une vue de création de l'objet Article
class ArticlesCreateView(CanCreateMixin, CreateView):
model = WeekmailArticle
``` ```
Les mixins suivants sont implémentés : Les mixins suivants sont implémentés :
- [CanCreateMixin][core.auth.mixins.CanCreateMixin] : l'utilisateur peut-il créer l'objet ?
Ce mixin existe, mais est déprécié et ne doit plus être utilisé !
- [CanEditPropMixin][core.auth.mixins.CanEditPropMixin] : l'utilisateur peut-il éditer les propriétés de l'objet ? - [CanEditPropMixin][core.auth.mixins.CanEditPropMixin] : l'utilisateur peut-il éditer les propriétés de l'objet ?
- [CanEditMixin][core.auth.mixins.CanEditMixin] : L'utilisateur peut-il éditer l'objet ? - [CanEditMixin][core.auth.mixins.CanEditMixin] : L'utilisateur peut-il éditer l'objet ?
- [CanViewMixin][core.auth.mixins.CanViewMixin] : L'utilisateur peut-il voir l'objet ? - [CanViewMixin][core.auth.mixins.CanViewMixin] : L'utilisateur peut-il voir l'objet ?
- [FormerSubscriberMixin][core.auth.mixins.FormerSubscriberMixin] : L'utilisateur a-t-il déjà été cotisant ? - [FormerSubscriberMixin][core.auth.mixins.FormerSubscriberMixin] : L'utilisateur a-t-il déjà été cotisant ?
!!!danger "CanCreateMixin"
L'usage de `CanCreateMixin` est dangereux et ne doit en aucun cas être
étendu.
La façon dont ce mixin marche est qu'il valide le formulaire
de création et crée l'objet sans le persister en base de données, puis
vérifie les droits sur cet objet non-persisté.
Le danger de ce système vient de multiples raisons :
- Les vérifications se faisant sur un objet non persisté,
l'utilisation de mécanismes nécessitant une persistance préalable
peut mener à des comportements indésirés, voire à des erreurs.
- Les développeurs de django ayant tendance à restreindre progressivement
les actions qui peuvent être faites sur des objets non-persistés,
les mises-à-jour de django deviennent plus compliquées.
- La vérification des droits ne se fait que dans les requêtes POST,
à la toute fin de la requête.
Tout ce qui arrive avant n'est absolument pas protégé.
Toute opération (même les suppressions et les créations) qui ont
lieu avant la persistance de l'objet seront appliquées,
même sans permission.
- Si un développeur du site fait l'erreur de surcharger
la méthode `form_valid` (ce qui est plutôt courant,
lorsqu'on veut accomplir certaines actions
quand un formulaire est valide), on peut se retrouver
dans une situation où l'objet est persisté sans aucune protection.
!!!danger "Performance" !!!danger "Performance"

View File

@@ -242,7 +242,7 @@ class Invoice(models.Model):
def validate(self): def validate(self):
if self.validated: if self.validated:
raise DataError(_("Invoice already 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() eboutic = Counter.objects.filter(type="EBOUTIC").first()
for i in self.items.all(): for i in self.items.all():
if i.type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING: 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 assert Basket.objects.filter(id=self.basket.id).first() is None
self.customer.customer.refresh_from_db() 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( sellings = Selling.objects.filter(customer=self.customer.customer).order_by(
"quantity" "quantity"

155
election/forms.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,14 +27,14 @@ from functools import partial
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.core.exceptions import PermissionDenied
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db import IntegrityError from django.db import IntegrityError
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils import html, timezone from django.utils import html, timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView, RedirectView from django.views.generic import DetailView, ListView, RedirectView
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
@@ -44,7 +44,6 @@ from honeypot.decorators import check_honeypot
from club.widgets.ajax_select import AutoCompleteSelectClub from club.widgets.ajax_select import AutoCompleteSelectClub
from core.auth.mixins import ( from core.auth.mixins import (
CanCreateMixin,
CanEditMixin, CanEditMixin,
CanEditPropMixin, CanEditPropMixin,
CanViewMixin, CanViewMixin,
@@ -180,11 +179,19 @@ class ForumForm(forms.ModelForm):
) )
class ForumCreateView(CanCreateMixin, CreateView): class ForumCreateView(UserPassesTestMixin, CreateView):
model = Forum model = Forum
form_class = ForumForm form_class = ForumForm
template_name = "core/create.jinja" template_name = "core/create.jinja"
def test_func(self):
if self.request.user.has_perm("forum.add_forum"):
return True
parent = Forum.objects.filter(id=self.request.GET["parent"]).first()
if parent is not None:
return self.request.user.is_owner(parent)
return False
def get_initial(self): def get_initial(self):
init = super().get_initial() init = super().get_initial()
parent = Forum.objects.filter(id=self.request.GET["parent"]).first() parent = Forum.objects.filter(id=self.request.GET["parent"]).first()
@@ -258,18 +265,19 @@ class TopicForm(forms.ModelForm):
@method_decorator( @method_decorator(
partial(check_honeypot, field_name=settings.HONEYPOT_FIELD_NAME_FORUM), name="post" partial(check_honeypot, field_name=settings.HONEYPOT_FIELD_NAME_FORUM), name="post"
) )
class ForumTopicCreateView(CanCreateMixin, CreateView): class ForumTopicCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
model = ForumMessage model = ForumMessage
form_class = TopicForm form_class = TopicForm
template_name = "forum/reply.jinja" template_name = "forum/reply.jinja"
def dispatch(self, request, *args, **kwargs): @cached_property
self.forum = get_object_or_404( def forum(self):
Forum, id=self.kwargs["forum_id"], is_category=False return get_object_or_404(Forum, id=self.kwargs["forum_id"], is_category=False)
def test_func(self):
return self.request.user.has_perm("forum.add_forumtopic") or (
self.request.user.can_view(self.forum)
) )
if not request.user.can_view(self.forum):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form): def form_valid(self, form):
topic = ForumTopic( topic = ForumTopic(
@@ -404,7 +412,7 @@ class ForumMessageUndeleteView(SingleObjectMixin, RedirectView):
@method_decorator( @method_decorator(
partial(check_honeypot, field_name=settings.HONEYPOT_FIELD_NAME_FORUM), name="post" partial(check_honeypot, field_name=settings.HONEYPOT_FIELD_NAME_FORUM), name="post"
) )
class ForumMessageCreateView(CanCreateMixin, CreateView): class ForumMessageCreateView(LoginRequiredMixin, UserPassesTestMixin, CreateView):
model = ForumMessage model = ForumMessage
form_class = forms.modelform_factory( form_class = forms.modelform_factory(
model=ForumMessage, model=ForumMessage,
@@ -413,11 +421,14 @@ class ForumMessageCreateView(CanCreateMixin, CreateView):
) )
template_name = "forum/reply.jinja" template_name = "forum/reply.jinja"
def dispatch(self, request, *args, **kwargs): @cached_property
self.topic = get_object_or_404(ForumTopic, id=self.kwargs["topic_id"]) def topic(self):
if not request.user.can_view(self.topic): return get_object_or_404(ForumTopic, id=self.kwargs["topic_id"])
raise PermissionDenied
return super().dispatch(request, *args, **kwargs) def test_func(self):
return self.request.user.has_perm(
"forum.add_forummessage"
) or self.request.user.can_view(self.topic)
def get_initial(self): def get_initial(self):
init = super().get_initial() init = super().get_initial()

View File

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

View File

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

View File

@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-17 13:41+0200\n" "POT-Creation-Date: 2025-11-07 14:50+0100\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -141,12 +141,12 @@ msgstr "vous devez spécifier au moins un utilisateur ou une adresse email"
msgid "Begin date" msgid "Begin date"
msgstr "Date de début" msgstr "Date de début"
#: club/forms.py com/forms.py counter/forms.py election/views.py #: club/forms.py com/forms.py counter/forms.py election/forms.py
#: subscription/forms.py #: subscription/forms.py
msgid "End date" msgid "End date"
msgstr "Date de fin" msgstr "Date de fin"
#: club/forms.py club/templates/club/club_sellings.jinja #: club/forms.py club/templates/club/club_sellings.jinja club/views.py
#: core/templates/core/user_account_detail.jinja #: core/templates/core/user_account_detail.jinja
#: counter/templates/counter/cash_summary_list.jinja counter/views/mixins.py #: counter/templates/counter/cash_summary_list.jinja counter/views/mixins.py
msgid "Counter" msgid "Counter"
@@ -409,7 +409,7 @@ msgstr "Total : "
msgid "Benefit: " msgid "Benefit: "
msgstr "Bénéfice : " msgstr "Bénéfice : "
#: club/templates/club/club_sellings.jinja #: club/templates/club/club_sellings.jinja club/views.py
#: core/templates/core/user_account_detail.jinja #: core/templates/core/user_account_detail.jinja
#: counter/templates/counter/cash_summary_list.jinja #: counter/templates/counter/cash_summary_list.jinja
#: counter/templates/counter/last_ops.jinja #: counter/templates/counter/last_ops.jinja
@@ -419,34 +419,34 @@ msgstr "Bénéfice : "
msgid "Date" msgid "Date"
msgstr "Date" msgstr "Date"
#: club/templates/club/club_sellings.jinja #: club/templates/club/club_sellings.jinja club/views.py
#: core/templates/core/user_account_detail.jinja #: core/templates/core/user_account_detail.jinja
#: counter/templates/counter/last_ops.jinja #: counter/templates/counter/last_ops.jinja
msgid "Barman" msgid "Barman"
msgstr "Barman" msgstr "Barman"
#: club/templates/club/club_sellings.jinja #: club/templates/club/club_sellings.jinja club/views.py
#: counter/templates/counter/counter_click.jinja #: counter/templates/counter/counter_click.jinja
#: counter/templates/counter/last_ops.jinja #: counter/templates/counter/last_ops.jinja
#: counter/templates/counter/refilling_list.jinja #: counter/templates/counter/refilling_list.jinja
msgid "Customer" msgid "Customer"
msgstr "Client" msgstr "Client"
#: club/templates/club/club_sellings.jinja #: club/templates/club/club_sellings.jinja club/views.py
#: core/templates/core/user_account_detail.jinja #: core/templates/core/user_account_detail.jinja
#: counter/templates/counter/last_ops.jinja #: counter/templates/counter/last_ops.jinja
#: rootplace/templates/rootplace/logs.jinja #: rootplace/templates/rootplace/logs.jinja
msgid "Label" msgid "Label"
msgstr "Étiquette" msgstr "Étiquette"
#: club/templates/club/club_sellings.jinja #: club/templates/club/club_sellings.jinja club/views.py
#: core/templates/core/user_account_detail.jinja #: core/templates/core/user_account_detail.jinja
#: core/templates/core/user_stats.jinja #: core/templates/core/user_stats.jinja
#: counter/templates/counter/last_ops.jinja #: counter/templates/counter/last_ops.jinja
msgid "Quantity" msgid "Quantity"
msgstr "Quantité" msgstr "Quantité"
#: club/templates/club/club_sellings.jinja #: club/templates/club/club_sellings.jinja club/views.py
#: core/templates/core/user_account.jinja #: core/templates/core/user_account.jinja
#: core/templates/core/user_account_detail.jinja #: core/templates/core/user_account_detail.jinja
#: counter/templates/counter/cash_summary_list.jinja #: counter/templates/counter/cash_summary_list.jinja
@@ -456,7 +456,7 @@ msgstr "Quantité"
msgid "Total" msgid "Total"
msgstr "Total" msgstr "Total"
#: club/templates/club/club_sellings.jinja #: club/templates/club/club_sellings.jinja club/views.py
#: core/templates/core/user_account_detail.jinja #: core/templates/core/user_account_detail.jinja
#: core/templates/core/user_detail.jinja #: core/templates/core/user_detail.jinja
#: counter/templates/counter/last_ops.jinja #: counter/templates/counter/last_ops.jinja
@@ -699,11 +699,23 @@ msgstr "Vous êtes maintenant membre de ce club."
msgid "%(user)s has been added to club." msgid "%(user)s has been added to club."
msgstr "%(user)s a été ajouté au 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 #: com/forms.py
msgid "Format: 16:9 | Resolution: 1920x1080" msgid "Format: 16:9 | Resolution: 1920x1080"
msgstr "Format : 16:9 | Résolution : 1920x1080" msgstr "Format : 16:9 | Résolution : 1920x1080"
#: com/forms.py election/views.py subscription/forms.py #: com/forms.py election/forms.py subscription/forms.py
msgid "Start date" msgid "Start date"
msgstr "Date de début" msgstr "Date de début"
@@ -893,7 +905,8 @@ msgstr "Administration des mailing listes"
msgid "Actions" msgid "Actions"
msgstr "Actions" msgstr "Actions"
#: com/templates/com/mailing_admin.jinja core/templates/core/file_detail.jinja #: com/templates/com/mailing_admin.jinja com/templates/com/poster_list.jinja
#: core/templates/core/file_detail.jinja
#: core/templates/core/file_moderation.jinja sas/templates/sas/moderation.jinja #: core/templates/core/file_moderation.jinja sas/templates/sas/moderation.jinja
#: sas/templates/sas/picture.jinja #: sas/templates/sas/picture.jinja
msgid "Moderate" msgid "Moderate"
@@ -1030,7 +1043,7 @@ msgstr "Événements aujourd'hui et dans les prochains jours"
#: com/templates/com/news_list.jinja #: com/templates/com/news_list.jinja
msgid "Administrate news" msgid "Administrate news"
msgstr "Administrer les news" msgstr "Administrer les nouvelles"
#: com/templates/com/news_list.jinja #: com/templates/com/news_list.jinja
msgid "Nothing to come..." msgid "Nothing to come..."
@@ -1109,8 +1122,7 @@ msgstr "Vous n'avez pas accès à ce contenu"
msgid "Poster" msgid "Poster"
msgstr "Affiche" msgstr "Affiche"
#: com/templates/com/poster_edit.jinja com/templates/com/poster_moderate.jinja #: com/templates/com/poster_edit.jinja com/templates/com/screen_edit.jinja
#: com/templates/com/screen_edit.jinja
msgid "List" msgid "List"
msgstr "Liste" msgstr "Liste"
@@ -1123,25 +1135,13 @@ msgstr "Affiche - modifier"
msgid "Create" msgid "Create"
msgstr "Créer" msgstr "Créer"
#: 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 #: com/templates/com/poster_list.jinja com/templates/com/screen_slideshow.jinja
msgid "Click to expand" msgid "Click to expand"
msgstr "Cliquez pour agrandir" msgstr "Cliquez pour agrandir"
#: com/templates/com/poster_moderate.jinja #: com/templates/com/poster_list.jinja
msgid "Posters - moderation" msgid "No posters"
msgstr "Affiches - modération" msgstr "Aucune affiche"
#: com/templates/com/poster_moderate.jinja
msgid "No objects"
msgstr "Aucun éléments"
#: com/templates/com/screen_edit.jinja #: com/templates/com/screen_edit.jinja
msgid "Screen" msgid "Screen"
@@ -4043,6 +4043,30 @@ msgstr ""
msgid "You can't buy a refilling with sith money" msgid "You can't buy a refilling with sith money"
msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith" msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith"
#: election/forms.py
msgid "You have selected too many candidates."
msgstr "Vous avez sélectionné trop de candidats."
#: election/forms.py
msgid "User to candidate"
msgstr "Utilisateur se présentant"
#: election/forms.py election/templates/election/election_detail.jinja
msgid "Blank vote"
msgstr "Vote blanc"
#: election/forms.py
msgid "This role already exists for this election"
msgstr "Ce rôle existe déjà pour cette élection"
#: election/forms.py
msgid "Start candidature"
msgstr "Début des candidatures"
#: election/forms.py
msgid "End candidature"
msgstr "Fin des candidatures"
#: election/models.py #: election/models.py
msgid "start candidature" msgid "start candidature"
msgstr "début des candidatures" msgstr "début des candidatures"
@@ -4067,6 +4091,10 @@ msgstr "groupe de vote"
msgid "candidature groups" msgid "candidature groups"
msgstr "groupe de candidature" msgstr "groupe de candidature"
#: election/models.py
msgid "voters"
msgstr "électeurs"
#: election/models.py #: election/models.py
msgid "election" msgid "election"
msgstr "élection" msgstr "élection"
@@ -4122,17 +4150,10 @@ msgstr "Vous avez déjà soumis votre vote."
msgid "You have voted in this election." msgid "You have voted in this election."
msgstr "Vous avez déjà voté pour cette élection." msgstr "Vous avez déjà voté pour cette élection."
#: election/templates/election/election_detail.jinja election/views.py
msgid "Blank vote"
msgstr "Vote blanc"
#: election/templates/election/election_detail.jinja #: election/templates/election/election_detail.jinja
msgid "You may choose up to" #, python-format
msgstr "Vous pouvez choisir jusqu'à" msgid "You may choose up to %(nb_choices)s people."
msgstr "Vous pouvez choisir jusqu'à %(nb_choices)s personnes."
#: election/templates/election/election_detail.jinja
msgid "people."
msgstr "personne(s)"
#: election/templates/election/election_detail.jinja #: election/templates/election/election_detail.jinja
msgid "Choose blank vote" msgid "Choose blank vote"
@@ -4174,26 +4195,6 @@ msgstr "au"
msgid "Polls open from" msgid "Polls open from"
msgstr "Votes ouverts du" msgstr "Votes ouverts du"
#: election/views.py
msgid "You have selected too much candidates."
msgstr "Vous avez sélectionné trop de candidats."
#: election/views.py
msgid "User to candidate"
msgstr "Utilisateur se présentant"
#: election/views.py
msgid "This role already exists for this election"
msgstr "Ce rôle existe déjà pour cette élection"
#: election/views.py
msgid "Start candidature"
msgstr "Début des candidatures"
#: election/views.py
msgid "End candidature"
msgstr "Fin des candidatures"
#: forum/models.py #: forum/models.py
msgid "is a category" msgid "is a category"
msgstr "est une catégorie" msgstr "est une catégorie"
@@ -5065,47 +5066,47 @@ msgstr "Suppression de rechargement"
#: sith/settings.py #: sith/settings.py
msgid "One semester" msgid "One semester"
msgstr "Un semestre, 20 €" msgstr "Un semestre"
#: sith/settings.py #: sith/settings.py
msgid "Two semesters" msgid "Two semesters"
msgstr "Deux semestres, 35 €" msgstr "Deux semestres"
#: sith/settings.py #: sith/settings.py
msgid "Common core cursus" msgid "Common core cursus"
msgstr "Cursus tronc commun, 60 €" msgstr "Cursus tronc commun"
#: sith/settings.py #: sith/settings.py
msgid "Branch cursus" msgid "Branch cursus"
msgstr "Cursus branche, 60 €" msgstr "Cursus branche"
#: sith/settings.py #: sith/settings.py
msgid "Alternating cursus" msgid "Alternating cursus"
msgstr "Cursus alternant, 30 €" msgstr "Cursus alternant"
#: sith/settings.py #: sith/settings.py
msgid "Honorary member" msgid "Honorary member"
msgstr "Membre honoraire, 0 €" msgstr "Membre honoraire"
#: sith/settings.py #: sith/settings.py
msgid "Assidu member" msgid "Assidu member"
msgstr "Membre d'Assidu, 0 €" msgstr "Membre d'Assidu"
#: sith/settings.py #: sith/settings.py
msgid "Amicale/DOCEO member" msgid "Amicale/DOCEO member"
msgstr "Membre de l'Amicale/DOCEO, 0 €" msgstr "Membre de l'Amicale/DOCEO"
#: sith/settings.py #: sith/settings.py
msgid "UT network member" msgid "UT network member"
msgstr "Cotisant du réseau UT, 0 €" msgstr "Cotisant du réseau UT"
#: sith/settings.py #: sith/settings.py
msgid "CROUS member" msgid "CROUS member"
msgstr "Membres du CROUS, 0 €" msgstr "Membres du CROUS"
#: sith/settings.py #: sith/settings.py
msgid "Sbarro/ESTA member" msgid "Sbarro/ESTA member"
msgstr "Membre de Sbarro ou de l'ESTA, 20 €" msgstr "Membre de Sbarro ou de l'ESTA"
#: sith/settings.py #: sith/settings.py
msgid "One semester Welcome Week" msgid "One semester Welcome Week"
@@ -5132,28 +5133,28 @@ msgid "One day"
msgstr "Un jour" msgstr "Un jour"
#: sith/settings.py #: sith/settings.py
msgid "GA staff member" msgid "GA staff member (2 weeks)"
msgstr "Membre staff GA (2 semaines), 1 €" msgstr "Membre staff GA (2 semaines)"
#: sith/settings.py #: sith/settings.py
msgid "One semester (-20%)" msgid "One semester (-20%)"
msgstr "Un semestre (-20%), 12 €" msgstr "Un semestre (-20%)"
#: sith/settings.py #: sith/settings.py
msgid "Two semesters (-20%)" msgid "Two semesters (-20%)"
msgstr "Deux semestres (-20%), 22 €" msgstr "Deux semestres (-20%)"
#: sith/settings.py #: sith/settings.py
msgid "Common core cursus (-20%)" msgid "Common core cursus (-20%)"
msgstr "Cursus tronc commun (-20%), 36 €" msgstr "Cursus tronc commun (-20%)"
#: sith/settings.py #: sith/settings.py
msgid "Branch cursus (-20%)" msgid "Branch cursus (-20%)"
msgstr "Cursus branche (-20%), 36 €" msgstr "Cursus branche (-20%)"
#: sith/settings.py #: sith/settings.py
msgid "Alternating cursus (-20%)" msgid "Alternating cursus (-20%)"
msgstr "Cursus alternant (-20%), 24 €" msgstr "Cursus alternant (-20%)"
#: sith/settings.py #: sith/settings.py
msgid "One year for free(CA offer)" msgid "One year for free(CA offer)"

1054
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -19,7 +19,7 @@ from pedagogy.utbm_api import UtbmApiClient
class UvController(ControllerBase): class UvController(ControllerBase):
@route.get( @route.get(
"/{code}", "/{code}",
auth=[SessionAuth(), ApiKeyAuth()], auth=[ApiKeyAuth(), SessionAuth()],
permissions=[ permissions=[
# this route will almost always be called in the context # this route will almost always be called in the context
# of a UV creation/edition # of a UV creation/edition
@@ -45,7 +45,7 @@ class UvController(ControllerBase):
"", "",
response=PaginatedResponseSchema[SimpleUvSchema], response=PaginatedResponseSchema[SimpleUvSchema],
url_name="fetch_uvs", url_name="fetch_uvs",
auth=[SessionAuth(), ApiKeyAuth()], auth=[ApiKeyAuth(), SessionAuth()],
permissions=[HasPerm("pedagogy.view_uv")], permissions=[HasPerm("pedagogy.view_uv")],
) )
@paginate(PageNumberPaginationExtra, page_size=100) @paginate(PageNumberPaginationExtra, page_size=100)

View File

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

View File

@@ -8,7 +8,6 @@ from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.exceptions import NotFound, PermissionDenied from ninja_extra.exceptions import NotFound, PermissionDenied
from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.permissions import IsAuthenticated
from ninja_extra.schemas import PaginatedResponseSchema from ninja_extra.schemas import PaginatedResponseSchema
from pydantic import NonNegativeInt from pydantic import NonNegativeInt
@@ -41,7 +40,6 @@ class AlbumController(ControllerBase):
@route.get( @route.get(
"/search", "/search",
response=PaginatedResponseSchema[AlbumSchema], response=PaginatedResponseSchema[AlbumSchema],
permissions=[IsAuthenticated],
url_name="search-album", url_name="search-album",
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
@@ -54,7 +52,7 @@ class AlbumController(ControllerBase):
@route.get( @route.get(
"/autocomplete-search", "/autocomplete-search",
response=PaginatedResponseSchema[AlbumAutocompleteSchema], response=PaginatedResponseSchema[AlbumAutocompleteSchema],
auth=[SessionAuth(), ApiKeyAuth()], auth=[ApiKeyAuth(), SessionAuth()],
permissions=[CanAccessLookup], permissions=[CanAccessLookup],
) )
@paginate(PageNumberPaginationExtra, page_size=50) @paginate(PageNumberPaginationExtra, page_size=50)
@@ -74,12 +72,7 @@ class AlbumController(ControllerBase):
@api_controller("/sas/picture") @api_controller("/sas/picture")
class PicturesController(ControllerBase): class PicturesController(ControllerBase):
@route.get( @route.get("", response=PaginatedResponseSchema[PictureSchema], url_name="pictures")
"",
response=PaginatedResponseSchema[PictureSchema],
permissions=[IsAuthenticated],
url_name="pictures",
)
@paginate(PageNumberPaginationExtra, page_size=100) @paginate(PageNumberPaginationExtra, page_size=100)
def fetch_pictures(self, filters: Query[PictureFilterSchema]): def fetch_pictures(self, filters: Query[PictureFilterSchema]):
"""Find pictures viewable by the user corresponding to the given filters. """Find pictures viewable by the user corresponding to the given filters.
@@ -141,7 +134,7 @@ class PicturesController(ControllerBase):
@route.get( @route.get(
"/{picture_id}/identified", "/{picture_id}/identified",
permissions=[IsAuthenticated, CanView], permissions=[CanView],
response=list[IdentifiedUserSchema], response=list[IdentifiedUserSchema],
) )
def fetch_identifications(self, picture_id: int): def fetch_identifications(self, picture_id: int):
@@ -149,7 +142,7 @@ class PicturesController(ControllerBase):
picture = self.get_object_or_exception(Picture, pk=picture_id) picture = self.get_object_or_exception(Picture, pk=picture_id)
return picture.people.select_related("user") return picture.people.select_related("user")
@route.put("/{picture_id}/identified", permissions=[IsAuthenticated, CanView]) @route.put("/{picture_id}/identified", permissions=[CanView])
def identify_users(self, picture_id: NonNegativeInt, users: set[NonNegativeInt]): def identify_users(self, picture_id: NonNegativeInt, users: set[NonNegativeInt]):
picture = self.get_object_or_exception( picture = self.get_object_or_exception(
Picture.objects.select_related("parent"), pk=picture_id Picture.objects.select_related("parent"), pk=picture_id
@@ -209,7 +202,7 @@ class PicturesController(ControllerBase):
@api_controller("/sas/relation", tags="User identification on SAS pictures") @api_controller("/sas/relation", tags="User identification on SAS pictures")
class UsersIdentifiedController(ControllerBase): class UsersIdentifiedController(ControllerBase):
@route.delete("/{relation_id}", permissions=[IsAuthenticated]) @route.delete("/{relation_id}")
def delete_relation(self, relation_id: NonNegativeInt): def delete_relation(self, relation_id: NonNegativeInt):
"""Untag a user from a SAS picture. """Untag a user from a SAS picture.

View File

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

View File

@@ -57,5 +57,9 @@ document.addEventListener("alpine:init", () => {
} }
this.loading = false; 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 }} })"> <div x-data="pictures({ albumId: {{ album.id }}, maxPageSize: {{ settings.SITH_SAS_IMAGES_PER_PAGE }} })">
<h4>{% trans %}Pictures{% endtrans %}</h4> <h4>{% trans %}Pictures{% endtrans %}</h4>
<br> <br>
{{ download_button(_("Download album")) }} {{ download_button(_("Download album"), "pictures") }}
<div class="photos" :aria-busy="loading" @pictures-upload-done.window="fetchPictures"> <div class="photos" :aria-busy="loading" @pictures-upload-done.window="fetchPictures">
<template x-for="picture in getPage(page)"> <template x-for="picture in getPage(page)">
<a :href="picture.sas_url"> <a :href="picture.sas_url">

View File

@@ -36,21 +36,20 @@
{% endmacro %} {% endmacro %}
{# Helper macro to create a download button for a {# 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: Note:
This requires importing `bundled/sas/pictures-download-index.ts` This requires importing `bundled/sas/pictures-download-index.ts`
Parameters: Parameters:
name (str): name displayed on the button 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) %} {% macro download_button(name, pictures) %}
<div x-data="pictures_download"> <div x-data="pictures_download()" x-modelable="downloadPictures" x-model="{{ pictures }}">
<div x-show="albums.length > 0" x-cloak> <div x-show="downloadPictures.length > 0" x-cloak>
<button <button
:disabled="isDownloading" :disabled="isDownloading"
class="btn btn-blue {% if name == "" %}btn-no-text{% endif %}" class="btn btn-blue {% if name == "" %}btn-no-text{% endif %}"

View File

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

View File

@@ -55,7 +55,7 @@ class TestPictureSearch(TestSas):
def test_anonymous_user_forbidden(self): def test_anonymous_user_forbidden(self):
res = self.client.get(self.url) res = self.client.get(self.url)
assert res.status_code == 403 assert res.status_code == 401
def test_filter_by_album(self): def test_filter_by_album(self):
self.client.force_login(self.user_b) self.client.force_login(self.user_b)
@@ -148,7 +148,7 @@ class TestPictureRelation(TestSas):
relation = PeoplePictureRelation.objects.exclude(user=self.user_a).first() relation = PeoplePictureRelation.objects.exclude(user=self.user_a).first()
res = self.client.delete(f"/api/sas/relation/{relation.id}") res = self.client.delete(f"/api/sas/relation/{relation.id}")
assert res.status_code == 403 assert res.status_code == 401
for user in baker.make(User), self.user_a: for user in baker.make(User), self.user_a:
self.client.force_login(user) self.client.force_login(user)

View File

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

View File

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

View File

@@ -175,45 +175,3 @@ class TestSubscriptionIntegration(TestCase):
user=user, user=user,
) )
assert d == date(2017, 8, 29) 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)

View File

@@ -27,7 +27,7 @@ from datetime import date
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.messages.views import SuccessMessageMixin from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import IntegrityError from django.db import IntegrityError
@@ -35,17 +35,13 @@ from django.forms.models import modelform_factory
from django.http import Http404, HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, RedirectView, TemplateView, View from django.views.generic import DetailView, RedirectView, TemplateView, View
from django.views.generic.edit import CreateView, DeleteView, UpdateView from django.views.generic.edit import CreateView, DeleteView, UpdateView
from club.models import Club from club.models import Club
from core.auth.mixins import ( from core.auth.mixins import CanEditMixin, CanEditPropMixin, CanViewMixin
CanCreateMixin,
CanEditMixin,
CanEditPropMixin,
CanViewMixin,
)
from core.models import User from core.models import User
from core.views.forms import SelectDate from core.views.forms import SelectDate
from core.views.mixins import TabedViewMixin from core.views.mixins import TabedViewMixin
@@ -117,19 +113,25 @@ class TrombiForm(forms.ModelForm):
widgets = {"subscription_deadline": SelectDate, "comments_deadline": SelectDate} widgets = {"subscription_deadline": SelectDate, "comments_deadline": SelectDate}
class TrombiCreateView(CanCreateMixin, CreateView): class TrombiCreateView(UserPassesTestMixin, CreateView):
"""Create a trombi for a club.""" """Create a trombi for a club."""
model = Trombi model = Trombi
form_class = TrombiForm form_class = TrombiForm
template_name = "core/create.jinja" template_name = "core/create.jinja"
@cached_property
def club(self):
return get_object_or_404(Club, id=self.kwargs["club_id"])
def test_func(self):
return self.request.user.can_edit(self.club)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
"""Affect club.""" """Affect club."""
form = self.get_form() form = self.get_form()
if form.is_valid(): if form.is_valid():
club = get_object_or_404(Club, id=self.kwargs["club_id"]) form.instance.club = self.club
form.instance.club = club
ret = self.form_valid(form) ret = self.form_valid(form)
return ret return ret
else: else:

964
uv.lock generated

File diff suppressed because it is too large Load Diff