Merge pull request #1293 from ae-utbm/taiste

Refactors, updates and db optimisations
This commit is contained in:
thomas girod
2026-02-13 15:25:04 +01:00
committed by GitHub
133 changed files with 3277 additions and 3597 deletions

View File

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

View File

@@ -7,20 +7,34 @@
},
"files": {
"ignoreUnknown": false,
"ignore": ["*.min.*", "staticfiles/generated"]
"includes": ["**/static/**"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"lineWidth": 88
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"all": true
"recommended": true,
"style": {
"useNamingConvention": "error"
},
"performance": {
"noNamespaceImport": "error"
},
"suspicious": {
"noConsole": {
"level": "error",
"options": { "allow": ["error", "warn"] }
}
},
"correctness": {
"noUnusedVariables": "error",
"noUndeclaredVariables": "error",
"noUndeclaredDependencies": "error"
}
}
},
"javascript": {

View File

@@ -26,7 +26,6 @@ from __future__ import annotations
from typing import Iterable, Self
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import RegexValidator, validate_email
from django.db import models, transaction
@@ -187,9 +186,6 @@ class Club(models.Model):
self.page.save(force_lock=True)
def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]:
# Invalidate the cache of this club and of its memberships
for membership in self.members.ongoing().select_related("user"):
cache.delete(f"membership_{self.id}_{membership.user.id}")
self.board_group.delete()
self.members_group.delete()
return super().delete(*args, **kwargs)
@@ -210,24 +206,15 @@ class Club(models.Model):
"""Method to see if that object can be edited by the given user."""
return self.has_rights_in_club(user)
def get_membership_for(self, user: User) -> Membership | None:
"""Return the current membership the given user.
@cached_property
def current_members(self) -> list[Membership]:
return list(self.members.ongoing().select_related("user").order_by("-role"))
Note:
The result is cached.
"""
def get_membership_for(self, user: User) -> Membership | None:
"""Return the current membership of the given user."""
if user.is_anonymous:
return None
membership = cache.get(f"membership_{self.id}_{user.id}")
if membership == "not_member":
return None
if membership is None:
membership = self.members.filter(user=user, end_date=None).first()
if membership is None:
cache.set(f"membership_{self.id}_{user.id}", "not_member")
else:
cache.set(f"membership_{self.id}_{user.id}", membership)
return membership
return next((m for m in self.current_members if m.user_id == user.id), None)
def has_rights_in_club(self, user: User) -> bool:
return user.is_in_group(pk=self.board_group_id)
@@ -245,7 +232,7 @@ class MembershipQuerySet(models.QuerySet):
are included, even if there are no more members.
If you want to get the users who are currently in the board,
mind combining this with the :meth:`ongoing` queryset method
mind combining this with the `ongoing` queryset method
"""
return self.filter(role__gt=settings.SITH_MAXIMUM_FREE_ROLE)
@@ -288,42 +275,29 @@ class MembershipQuerySet(models.QuerySet):
)
def update(self, **kwargs) -> int:
"""Refresh the cache and edit group ownership.
Update the cache, when necessary, remove
users from club groups they are no more in
"""Remove users from club groups they are no more in
and add them in the club groups they should be in.
Be aware that this adds three db queries :
one to retrieve the updated memberships,
one to perform group removal and one to perform
group attribution.
- one to retrieve the updated memberships
- one to perform group removal
- and one to perform group attribution.
"""
nb_rows = super().update(**kwargs)
if nb_rows == 0:
# if no row was affected, no need to refresh the cache
# if no row was affected, no need to edit club groups
return 0
cache_memberships = {}
memberships = set(self.select_related("club"))
# delete all User-Group relations and recreate the necessary ones
# It's more concise to write and more reliable
Membership._remove_club_groups(memberships)
Membership._add_club_groups(memberships)
for member in memberships:
cache_key = f"membership_{member.club_id}_{member.user_id}"
if member.end_date is None:
cache_memberships[cache_key] = member
else:
cache_memberships[cache_key] = "not_member"
cache.set_many(cache_memberships)
return nb_rows
def delete(self) -> tuple[int, dict[str, int]]:
"""Work just like the default Django's delete() method,
but add a cache invalidation for the elements of the queryset
before the deletion,
and a removal of the user from the club groups.
but also remove the concerned users from the club groups.
Be aware that this adds some db queries :
@@ -339,12 +313,6 @@ class MembershipQuerySet(models.QuerySet):
nb_rows, rows_counts = super().delete()
if nb_rows > 0:
Membership._remove_club_groups(memberships)
cache.set_many(
{
f"membership_{m.club_id}_{m.user_id}": "not_member"
for m in memberships
}
)
return nb_rows, rows_counts
@@ -408,9 +376,6 @@ class Membership(models.Model):
self._remove_club_groups([self])
if self.end_date is None:
self._add_club_groups([self])
cache.set(f"membership_{self.club_id}_{self.user_id}", self)
else:
cache.set(f"membership_{self.club_id}_{self.user_id}", "not_member")
def get_absolute_url(self):
return reverse("club:club_members", kwargs={"club_id": self.club_id})
@@ -431,7 +396,6 @@ class Membership(models.Model):
def delete(self, *args, **kwargs):
self._remove_club_groups([self])
super().delete(*args, **kwargs)
cache.delete(f"membership_{self.club_id}_{self.user_id}")
@staticmethod
def _remove_club_groups(

View File

@@ -1,18 +1,16 @@
from typing import Annotated
from annotated_types import MinLen
from django.db.models import Q
from ninja import Field, FilterSchema, ModelSchema
from ninja import FilterLookup, FilterSchema, ModelSchema
from club.models import Club, Membership
from core.schemas import SimpleUserSchema
from core.schemas import NonEmptyStr, SimpleUserSchema
class ClubSearchFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] | None = Field(None, q="name__icontains")
search: Annotated[NonEmptyStr | None, FilterLookup("name__icontains")] = None
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):

View File

@@ -1,7 +1,7 @@
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
import { type ClubSchema, clubSearchClub } from "#openapi";
@registerComponent("club-ajax-select")

View File

@@ -72,25 +72,6 @@ class TestMembershipQuerySet(TestClub):
expected.sort(key=lambda i: i.id)
assert members == expected
def test_update_invalidate_cache(self):
"""Test that the `update` queryset method properly invalidate cache."""
mem_skia = self.simple_board_member.memberships.get(club=self.club)
cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia)
self.simple_board_member.memberships.update(end_date=localtime(now()).date())
assert (
cache.get(f"membership_{mem_skia.club_id}_{mem_skia.user_id}")
== "not_member"
)
mem_richard = self.richard.memberships.get(club=self.club)
cache.set(
f"membership_{mem_richard.club_id}_{mem_richard.user_id}", mem_richard
)
self.richard.memberships.update(role=5)
new_mem = self.richard.memberships.get(club=self.club)
assert new_mem != "not_member"
assert new_mem.role == 5
def test_update_change_club_groups(self):
"""Test that `update` set the user groups accordingly."""
user = baker.make(User)
@@ -112,24 +93,6 @@ class TestMembershipQuerySet(TestClub):
assert not user.groups.contains(members_group)
assert not user.groups.contains(board_group)
def test_delete_invalidate_cache(self):
"""Test that the `delete` queryset properly invalidate cache."""
mem_skia = self.simple_board_member.memberships.get(club=self.club)
mem_comptable = self.president.memberships.get(club=self.club)
cache.set(f"membership_{mem_skia.club_id}_{mem_skia.user_id}", mem_skia)
cache.set(
f"membership_{mem_comptable.club_id}_{mem_comptable.user_id}", mem_comptable
)
# should delete the subscriptions of simple_board_member and president
self.club.members.ongoing().board().delete()
for membership in (mem_skia, mem_comptable):
cached_mem = cache.get(
f"membership_{membership.club_id}_{membership.user_id}"
)
assert cached_mem == "not_member"
def test_delete_remove_from_groups(self):
"""Test that `delete` removes from club groups"""
user = baker.make(User)

View File

@@ -4,15 +4,16 @@ from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib.sites.models import Site
from django.contrib.syndication.views import add_domain
from django.db.models import F, QuerySet
from django.db.models import Count, OuterRef, QuerySet, Subquery
from django.http import HttpRequest
from django.urls import reverse
from django.utils import timezone
from ical.calendar import Calendar
from ical.calendar_stream import IcsCalendarStream
from ical.event import Event
from ical.types import Frequency, Recur
from com.models import NewsDate
from com.models import News, NewsDate
from core.models import User
@@ -42,9 +43,9 @@ class IcsCalendar:
with open(cls._INTERNAL_CALENDAR, "wb") as f:
_ = f.write(
cls.ics_from_queryset(
NewsDate.objects.filter(
news__is_published=True,
end_date__gte=timezone.now() - (relativedelta(months=6)),
News.objects.filter(
is_published=True,
dates__end_date__gte=timezone.now() - relativedelta(months=6),
)
)
)
@@ -53,24 +54,35 @@ class IcsCalendar:
@classmethod
def get_unpublished(cls, user: User) -> bytes:
return cls.ics_from_queryset(
NewsDate.objects.viewable_by(user).filter(
news__is_published=False,
end_date__gte=timezone.now() - (relativedelta(months=6)),
),
News.objects.viewable_by(user).filter(
is_published=False,
dates__end_date__gte=timezone.now() - relativedelta(months=6),
)
)
@classmethod
def ics_from_queryset(cls, queryset: QuerySet[NewsDate]) -> bytes:
def ics_from_queryset(cls, queryset: QuerySet[News]) -> bytes:
calendar = Calendar()
for news_date in queryset.annotate(news_title=F("news__title")):
date_subquery = NewsDate.objects.filter(news=OuterRef("pk")).order_by(
"start_date"
)
queryset = queryset.annotate(
start=Subquery(date_subquery.values("start_date")[:1]),
end=Subquery(date_subquery.values("end_date")[:1]),
nb_dates=Count("dates"),
)
for news in queryset:
event = Event(
summary=news_date.news_title,
start=news_date.start_date,
end=news_date.end_date,
summary=news.title,
description=news.summary,
dtstart=news.start,
dtend=news.end,
url=as_absolute_url(
reverse("com:news_detail", kwargs={"news_id": news_date.news_id})
reverse("com:news_detail", kwargs={"news_id": news.id})
),
)
if news.nb_dates > 1:
event.rrule = Recur(freq=Frequency.WEEKLY, count=news.nb_dates)
calendar.events.append(event)
return IcsCalendarStream.calendar_to_ics(calendar).encode("utf-8")

View File

@@ -1,9 +1,9 @@
from datetime import datetime
from typing import Annotated
from ninja import FilterSchema, ModelSchema
from ninja import FilterLookup, FilterSchema, ModelSchema
from ninja_extra import service_resolver
from ninja_extra.context import RouteContext
from pydantic import Field
from club.schemas import ClubProfileSchema
from com.models import News, NewsDate
@@ -11,12 +11,12 @@ from core.markdown import markdown
class NewsDateFilterSchema(FilterSchema):
before: datetime | None = Field(None, q="end_date__lt")
after: datetime | None = Field(None, q="start_date__gt")
club_id: int | None = Field(None, q="news__club_id")
before: Annotated[datetime | None, FilterLookup("end_date__lt")] = None
after: Annotated[datetime | None, FilterLookup("start_date__gt")] = None
club_id: Annotated[int | None, FilterLookup("news__club_id")] = None
news_id: int | None = None
is_published: bool | None = Field(None, q="news__is_published")
title: str | None = Field(None, q="news__title__icontains")
is_published: Annotated[bool | None, FilterLookup("news__is_published")] = None
title: Annotated[str | None, FilterLookup("news__title__icontains")] = None
class NewsSchema(ModelSchema):

View File

@@ -1,6 +1,4 @@
import { makeUrl } from "#core:utils/api";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { Calendar, type EventClickArg } from "@fullcalendar/core";
import { Calendar, type EventClickArg, type EventContentArg } from "@fullcalendar/core";
import type { EventImpl } from "@fullcalendar/core/internal";
import enLocale from "@fullcalendar/core/locales/en-gb";
import frLocale from "@fullcalendar/core/locales/fr";
@@ -8,6 +6,8 @@ import dayGridPlugin from "@fullcalendar/daygrid";
import iCalendarPlugin from "@fullcalendar/icalendar";
import listPlugin from "@fullcalendar/list";
import { type HTMLTemplateResult, html, render } from "lit-html";
import { makeUrl } from "#core:utils/api.ts";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
import {
calendarCalendarInternal,
calendarCalendarUnpublished,
@@ -25,6 +25,11 @@ export class IcsCalendar extends inheritHtmlElement("div") {
private canDelete = false;
private helpUrl = "";
// Hack variable to detect recurring events
// The underlying ics library doesn't include any info about rrules
// That's why we have to detect those events ourselves
private recurrenceMap: Map<string, EventImpl> = new Map();
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
if (name === "locale") {
this.locale = newValue;
@@ -90,11 +95,13 @@ export class IcsCalendar extends inheritHtmlElement("div") {
.split("/")
.filter((s) => s) // Remove blank characters
.pop(),
10,
);
}
refreshEvents() {
this.click(); // Remove focus from popup
this.recurrenceMap.clear(); // Avoid double detection of the same non recurring event
this.calendar.refetchEvents();
}
@@ -153,12 +160,24 @@ export class IcsCalendar extends inheritHtmlElement("div") {
}
async getEventSources() {
const tagRecurringEvents = (eventData: EventImpl) => {
// This functions tags events with a similar event url
// We rely on the fact that the event url is always the same
// for recurring events and always different for single events
const firstEvent = this.recurrenceMap.get(eventData.url);
if (firstEvent !== undefined) {
eventData.extendedProps.isRecurring = true;
firstEvent.extendedProps.isRecurring = true; // Don't forget the first event
}
this.recurrenceMap.set(eventData.url, eventData);
};
return [
{
url: `${await makeUrl(calendarCalendarInternal)}`,
format: "ics",
className: "internal",
cache: false,
eventDataTransform: tagRecurringEvents,
},
{
url: `${await makeUrl(calendarCalendarUnpublished)}`,
@@ -166,6 +185,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
color: "red",
className: "unpublished",
cache: false,
eventDataTransform: tagRecurringEvents,
},
];
}
@@ -361,6 +381,14 @@ export class IcsCalendar extends inheritHtmlElement("div") {
event.jsEvent.preventDefault();
this.createEventDetailPopup(event);
},
eventClassNames: (classNamesEvent: EventContentArg) => {
const classes: string[] = [];
if (classNamesEvent.event.extendedProps?.isRecurring) {
classes.push("recurring");
}
return classes;
},
});
this.calendar.render();

View File

@@ -1,4 +1,4 @@
import { exportToHtml } from "#core:utils/globals";
import { exportToHtml } from "#core:utils/globals.ts";
import { newsDeleteNews, newsFetchNewsDates, newsPublishNews } from "#openapi";
// This will be used in jinja templates,

View File

@@ -18,6 +18,8 @@
--event-details-border-radius: 4px;
--event-details-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%);
--event-details-max-width: 600px;
--event-recurring-internal-color: #6f69cd;
--event-recurring-unpublished-color: orange;
}
ics-calendar {
@@ -146,4 +148,29 @@ ics-calendar {
.tooltip.calendar-copy-tooltip.text-copied {
opacity: 0;
transition: opacity 500ms ease-out;
}
}
// We have to override the color set by the lib in the html
// Hence the !important tag everywhere
.internal.recurring {
.fc-daygrid-event-dot {
border-color: var(--event-recurring-internal-color) !important;
}
&.fc-daygrid-block-event {
background-color: var(--event-recurring-internal-color) !important;
border-color: var(--event-recurring-internal-color) !important;
}
}
.unpublished.recurring {
.fc-daygrid-event-dot {
border-color: var(--event-recurring-unpublished-color) !important;
}
&.fc-daygrid-block-event {
background-color: var(--event-recurring-unpublished-color) !important;
border-color: var(--event-recurring-unpublished-color) !important;
}
}

View File

@@ -203,7 +203,7 @@
<ul>
<li>
<i class="fa-solid fa-graduation-cap fa-xl"></i>
<a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
<a href="{{ url("pedagogy:guide") }}">{% trans %}UE Guide{% endtrans %}</a>
</li>
<li>
<i class="fa-solid fa-calendar-days fa-xl"></i>
@@ -211,7 +211,7 @@
</li>
<li>
<i class="fa-solid fa-magnifying-glass fa-xl"></i>
<a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
<a href="{{ url("matmat:search") }}">{% trans %}Matmatronch{% endtrans %}</a>
</li>
<li>
<i class="fa-solid fa-check-to-slot fa-xl"></i>

View File

@@ -44,7 +44,7 @@ from core.utils import resize_image
from counter.models import Counter, Product, ProductType, ReturnableProduct, StudentCard
from election.models import Candidature, Election, ElectionList, Role
from forum.models import Forum
from pedagogy.models import UV
from pedagogy.models import UE
from sas.models import Album, PeoplePictureRelation, Picture
from subscription.models import Subscription
@@ -661,20 +661,20 @@ class Command(BaseCommand):
# Create some data for pedagogy
UV(
UE(
code="PA00",
author=User.objects.get(id=0),
credit_type=settings.SITH_PEDAGOGY_UV_TYPE[3][0],
credit_type=settings.SITH_PEDAGOGY_UE_TYPE[3][0],
manager="Laurent HEYBERGER",
semester=settings.SITH_PEDAGOGY_UV_SEMESTER[3][0],
language=settings.SITH_PEDAGOGY_UV_LANGUAGE[0][0],
semester=settings.SITH_PEDAGOGY_UE_SEMESTER[3][0],
language=settings.SITH_PEDAGOGY_UE_LANGUAGE[0][0],
department=settings.SITH_PROFILE_DEPARTMENTS[-2][0],
credits=5,
title="Participation dans une association étudiante",
objectives="* Permettre aux étudiants de réaliser, pendant un semestre, un projet culturel ou associatif et de le valoriser.",
program="""* Semestre précédent proposition d'un projet et d'un cahier des charges
* Evaluation par un jury de six membres
* Si accord réalisation dans le cadre de l'UV
* Si accord réalisation dans le cadre de l'UE
* Compte-rendu de l'expérience
* Présentation""",
skills="""* Gérer un projet associatif ou une action éducative en autonomie:
@@ -790,16 +790,16 @@ class Command(BaseCommand):
subscribers = Group.objects.create(name="Cotisants")
subscribers.permissions.add(
*list(perms.filter(codename__in=["add_news", "add_uvcomment"]))
*list(perms.filter(codename__in=["add_news", "add_uecomment"]))
)
old_subscribers = Group.objects.create(name="Anciens cotisants")
old_subscribers.permissions.add(
*list(
perms.filter(
codename__in=[
"view_uv",
"view_uvcomment",
"add_uvcommentreport",
"view_ue",
"view_uecomment",
"add_uecommentreport",
"view_user",
"view_picture",
"view_album",
@@ -875,7 +875,7 @@ class Command(BaseCommand):
pedagogy_admin.permissions.add(
*list(
perms.filter(content_type__app_label="pedagogy")
.exclude(codename__in=["change_uvcomment"])
.exclude(codename__in=["change_uecomment"])
.values_list("pk", flat=True)
)
)

View File

@@ -23,7 +23,7 @@ from counter.models import (
Selling,
)
from forum.models import Forum, ForumMessage, ForumTopic
from pedagogy.models import UV
from pedagogy.models import UE
from subscription.models import Subscription
@@ -74,7 +74,7 @@ class Command(BaseCommand):
random.sample(old_subscribers, k=min(80, len(old_subscribers))),
)
self.stdout.write("Creating uvs...")
self.create_uvs()
self.create_ues()
self.stdout.write("Creating products...")
self.create_products()
self.stdout.write("Creating sales and refills...")
@@ -192,7 +192,7 @@ class Command(BaseCommand):
memberships = Membership.objects.bulk_create(memberships)
Membership._add_club_groups(memberships)
def create_uvs(self):
def create_ues(self):
root = User.objects.get(username="root")
categories = ["CS", "TM", "OM", "QC", "EC"]
branches = ["TC", "GMC", "GI", "EDIM", "E", "IMSI", "HUMA"]
@@ -207,7 +207,7 @@ class Command(BaseCommand):
+ str(random.randint(10, 90))
)
uvs.append(
UV(
UE(
code=code,
author=root,
manager=random.choice(teachers),
@@ -229,7 +229,7 @@ class Command(BaseCommand):
hours_TE=random.randint(15, 40),
)
)
UV.objects.bulk_create(uvs, ignore_conflicts=True)
UE.objects.bulk_create(uvs, ignore_conflicts=True)
def create_products(self):
categories = [
@@ -350,7 +350,6 @@ class Command(BaseCommand):
date=make_aware(
self.faker.date_time_between(customer.since, localdate())
),
is_validated=True,
)
)
sales.extend(this_customer_sales)

View File

@@ -38,7 +38,6 @@ from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser
from django.contrib.auth.models import Group as AuthGroup
from django.contrib.staticfiles.storage import staticfiles_storage
from django.core import validators
from django.core.cache import cache
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.files import File
from django.core.files.base import ContentFile
@@ -77,16 +76,6 @@ class Group(AuthGroup):
def get_absolute_url(self) -> str:
return reverse("core:group_list")
def save(self, *args, **kwargs) -> None:
super().save(*args, **kwargs)
cache.set(f"sith_group_{self.id}", self)
cache.set(f"sith_group_{self.name.replace(' ', '_')}", self)
def delete(self, *args, **kwargs) -> None:
super().delete(*args, **kwargs)
cache.delete(f"sith_group_{self.id}")
cache.delete(f"sith_group_{self.name.replace(' ', '_')}")
def validate_promo(value: int) -> None:
last_promo = get_last_promo()

View File

@@ -1,3 +1,4 @@
from datetime import datetime
from pathlib import Path
from typing import Annotated, Any
@@ -8,12 +9,14 @@ from django.urls import reverse
from django.utils.text import slugify
from django.utils.translation import gettext as _
from haystack.query import SearchQuerySet
from ninja import FilterSchema, ModelSchema, Schema, UploadedFile
from pydantic import AliasChoices, Field
from ninja import FilterLookup, FilterSchema, ModelSchema, Schema, UploadedFile
from pydantic import AliasChoices, Field, field_validator
from pydantic_core.core_schema import ValidationInfo
from core.models import Group, QuickUploadImage, SithFile, User
from core.utils import is_image
from core.utils import get_last_promo, is_image
NonEmptyStr = Annotated[str, MinLen(1)]
class UploadedImage(UploadedFile):
@@ -107,7 +110,11 @@ class GroupSchema(ModelSchema):
class UserFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)]
search: Annotated[str, MinLen(1)] | None = None
role: Annotated[str, FilterLookup("role__icontains")] | None = None
department: str | None = None
promo: int | None = None
date_of_birth: datetime | None = None
exclude: list[int] | None = Field(
None, validation_alias=AliasChoices("exclude", "exclude[]")
)
@@ -136,6 +143,13 @@ class UserFilterSchema(FilterSchema):
return Q()
return ~Q(id__in=value)
@field_validator("promo", mode="after")
@classmethod
def validate_promo(cls, value: int) -> int:
if not 0 < value <= get_last_promo():
raise ValueError(f"{value} is not a valid promo")
return value
class MarkdownSchema(Schema):
text: str

View File

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

View File

@@ -56,7 +56,7 @@ export function limitedChoices(Alpine: AlpineType) {
effect(() => {
getMaxChoices((value: string) => {
const previousValue = maxChoices;
maxChoices = Number.parseInt(value);
maxChoices = Number.parseInt(value, 10);
if (maxChoices < previousValue) {
// The maximum number of selectable items has been lowered.
// Some currently selected elements may need to be removed

View File

@@ -1,4 +1,3 @@
import { inheritHtmlElement } from "#core:utils/web-components";
import TomSelect from "tom-select";
import type {
RecursivePartial,
@@ -7,6 +6,7 @@ import type {
TomSettings,
} from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { inheritHtmlElement } from "#core:utils/web-components.ts";
export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
static observedAttributes = [
@@ -29,7 +29,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
) {
switch (name) {
case "delay": {
this.delay = Number.parseInt(newValue) ?? null;
this.delay = Number.parseInt(newValue, 10) ?? null;
break;
}
case "placeholder": {
@@ -37,11 +37,11 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
break;
}
case "max": {
this.max = Number.parseInt(newValue) ?? null;
this.max = Number.parseInt(newValue, 10) ?? null;
break;
}
case "min-characters-for-search": {
this.minCharNumberForSearch = Number.parseInt(newValue) ?? 0;
this.minCharNumberForSearch = Number.parseInt(newValue, 10) ?? 0;
break;
}
default: {

View File

@@ -1,20 +1,19 @@
import "tom-select/dist/css/tom-select.default.css";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import {
type GroupSchema,
type SithFileSchema,
type UserProfileSchema,
groupSearchGroup,
sithfileSearchFiles,
userSearchUsers,
} from "#openapi";
import {
AjaxSelect,
AutoCompleteSelectBase,
} from "#core:core/components/ajax-select-base";
} from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
import {
type GroupSchema,
groupSearchGroup,
type SithFileSchema,
sithfileSearchFiles,
type UserProfileSchema,
userSearchUsers,
} from "#openapi";
@registerComponent("autocomplete-select")
export class AutoCompleteSelect extends AutoCompleteSelectBase {}

View File

@@ -1,14 +1,14 @@
// biome-ignore lint/correctness/noUndeclaredDependencies: shipped by easymde
import "codemirror/lib/codemirror.css";
import "easymde/src/css/easymde.css";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
// biome-ignore lint/correctness/noUndeclaredDependencies: Imported by EasyMDE
import type CodeMirror from "codemirror";
// biome-ignore lint/style/useNamingConvention: This is how they called their namespace
import EasyMDE from "easymde";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
import {
type UploadUploadImageErrors,
markdownRenderMarkdown,
type UploadUploadImageErrors,
uploadUploadImage,
} from "#openapi";

View File

@@ -1,4 +1,4 @@
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
/**
* Web component used to import css files only once

View File

@@ -1,4 +1,4 @@
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
@registerComponent("nfc-input")
export class NfcInput extends inheritHtmlElement("input") {

View File

@@ -1,6 +1,6 @@
import { registerComponent } from "#core:utils/web-components";
import { html, render } from "lit-html";
import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
import { registerComponent } from "#core:utils/web-components.ts";
@registerComponent("ui-tab")
export class Tab extends HTMLElement {

View File

@@ -1,4 +1,4 @@
import { exportToHtml } from "#core:utils/globals";
import { exportToHtml } from "#core:utils/globals.ts";
exportToHtml("showMenu", () => {
const navbar = document.getElementById("navbar-content");

View File

@@ -26,7 +26,7 @@ function showMore(element: HTMLElement) {
const fullContent = element.innerHTML;
const clippedContent = clip(
element.innerHTML,
Number.parseInt(element.getAttribute("show-more") as string),
Number.parseInt(element.getAttribute("show-more") as string, 10),
{
html: true,
},

View File

@@ -1,9 +1,9 @@
import {
type Placement,
autoPlacement,
computePosition,
flip,
offset,
type Placement,
size,
} from "@floating-ui/dom";

View File

@@ -1,11 +1,11 @@
import htmx from "htmx.org";
document.body.addEventListener("htmx:beforeRequest", (event) => {
event.target.ariaBusy = true;
event.detail.target.ariaBusy = true;
});
document.body.addEventListener("htmx:afterRequest", (event) => {
event.originalTarget.ariaBusy = null;
document.body.addEventListener("htmx:beforeSwap", (event) => {
event.detail.target.ariaBusy = null;
});
Object.assign(window, { htmx });

View File

@@ -1,6 +1,6 @@
import { exportToHtml } from "#core:utils/globals";
// biome-ignore lint/style/noNamespaceImport: this is the recommended way from the documentation
// biome-ignore lint/performance/noNamespaceImport: this is the recommended way from the documentation
import * as Sentry from "@sentry/browser";
import { exportToHtml } from "#core:utils/globals.ts";
interface LoggedUser {
name: string;

View File

@@ -8,7 +8,6 @@
// This has been modified to not trigger biome linting
// biome-ignore lint/correctness/noUnusedVariables: this is the official definition
interface Window {
// biome-ignore lint/style/useNamingConvention: this is the official API name
NDEFMessage: NDEFMessage;
@@ -28,7 +27,6 @@ declare interface NDEFMessageInit {
// biome-ignore lint/style/useNamingConvention: this is the official API name
declare type NDEFRecordDataSource = string | BufferSource | NDEFMessageInit;
// biome-ignore lint/correctness/noUnusedVariables: this is the official definition
interface Window {
// biome-ignore lint/style/useNamingConvention: this is the official API name
NDEFRecord: NDEFRecord;
@@ -74,7 +72,6 @@ declare class NDEFReader extends EventTarget {
makeReadOnly: (options?: NDEFMakeReadOnlyOptions) => Promise<void>;
}
// biome-ignore lint/correctness/noUnusedVariables: this is the official definition
interface Window {
// biome-ignore lint/style/useNamingConvention: this is the official API name
NDEFReadingEvent: NDEFReadingEvent;

View File

@@ -1,4 +1,3 @@
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import cytoscape, {
type ElementDefinition,
type NodeSingular,
@@ -6,7 +5,8 @@ import cytoscape, {
} from "cytoscape";
import cxtmenu from "cytoscape-cxtmenu";
import klay, { type KlayLayoutOptions } from "cytoscape-klay";
import { type UserProfileSchema, familyGetFamilyGraph } from "#openapi";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history.ts";
import { familyGetFamilyGraph, type UserProfileSchema } from "#openapi";
cytoscape.use(klay);
cytoscape.use(cxtmenu);
@@ -200,7 +200,7 @@ document.addEventListener("alpine:init", () => {
isZoomEnabled: !isMobile(),
getInitialDepth(prop: string) {
const value = Number.parseInt(initialUrlParams.get(prop));
const value = Number.parseInt(initialUrlParams.get(prop), 10);
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
return defaultDepth;
}

View File

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

View File

@@ -1,4 +1,4 @@
import type { NestedKeyOf } from "#core:utils/types";
import type { NestedKeyOf } from "#core:utils/types.ts";
interface StringifyOptions<T extends object> {
/** The columns to include in the resulting CSV. */

View File

@@ -10,7 +10,6 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio
window.customElements.define(name, component, options);
} catch (e) {
if (e instanceof DOMException) {
// biome-ignore lint/suspicious/noConsole: it's handy to troobleshot
console.warn(e.message);
return;
}

View File

@@ -143,6 +143,15 @@ form {
line-height: 1;
white-space: nowrap;
.fields-centered {
padding: 10px 10px 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--nf-input-size) 10px;
justify-content: center;
}
.helptext {
margin-top: .25rem;
margin-bottom: .25rem;

View File

@@ -1,124 +0,0 @@
// Copyright 2013 Viral Patel and other contributors
// http://viralpatel.net
// Permission is hereby granted, free of charge, to any person obtaining
// a copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to
// the following conditions:
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
!(function (e) {
e.fn.shorten = function (s) {
"use strict";
var t = {
showChars: 100,
minHideChars: 10,
ellipsesText: "...",
moreText: "more",
lessText: "less",
onLess: function () {},
onMore: function () {},
errMsg: null,
force: !1,
};
return (
s && e.extend(t, s),
(!e(this).data("jquery.shorten") || !!t.force) &&
(e(this).data("jquery.shorten", !0),
e(document).off("click", ".morelink"),
e(document).on(
{
click: function () {
var s = e(this);
return (
s.hasClass("less")
? (s.removeClass("less"),
s.html(t.moreText),
s
.parent()
.prev()
.animate({}, function () {
s.parent().prev().prev().show();
})
.hide("fast", function () {
t.onLess();
}))
: (s.addClass("less"),
s.html(t.lessText),
s
.parent()
.prev()
.animate({}, function () {
s.parent().prev().prev().hide();
})
.show("fast", function () {
t.onMore();
})),
!1
);
},
},
".morelink",
),
this.each(function () {
var s = e(this),
n = s.html();
if (s.text().length > t.showChars + t.minHideChars) {
var r = n.substr(0, t.showChars);
if (r.indexOf("<") >= 0) {
for (
var a = !1, o = "", i = 0, l = [], h = null, c = 0, f = 0;
f <= t.showChars;
c++
)
if (
("<" != n[c] ||
a ||
((a = !0),
"/" == (h = n.substring(c + 1, n.indexOf(">", c)))[0]
? h != "/" + l[0]
? (t.errMsg =
"ERROR en HTML: the top of the stack should be the tag that closes")
: l.shift()
: "br" != h.toLowerCase() && l.unshift(h)),
a && ">" == n[c] && (a = !1),
a)
)
o += n.charAt(c);
else if ((f++, i <= t.showChars)) (o += n.charAt(c)), i++;
else if (l.length > 0) {
for (j = 0; j < l.length; j++) o += "</" + l[j] + ">";
break;
}
r = e("<div/>")
.html(o + '<span class="ellip">' + t.ellipsesText + "</span>")
.html();
} else r += t.ellipsesText;
var p =
'<div class="shortcontent">' +
r +
'</div><div class="allcontent">' +
n +
'</div><span><a href="javascript://nop/" class="morelink">' +
t.moreText +
"</a></span>";
s.html(p),
s.find(".allcontent").hide(),
e(".shortcontent p:last", s).css("margin-bottom", 0);
}
}))
);
};
})(jQuery);

View File

@@ -114,15 +114,6 @@
}
}
&-fields {
padding: 10px 10px 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--nf-input-size) 10px;
justify-content: center;
}
&-field {
display: flex;
flex-wrap: wrap;

View File

@@ -195,18 +195,18 @@
}
}
}
}
&.delete {
margin-top: 10px;
display: block;
text-align: center;
color: orangered;
form .link-like {
margin-top: 10px;
display: block;
text-align: center;
color: orangered;
@media (max-width: 375px) {
position: absolute;
bottom: 0;
right: 0;
}
@media (max-width: 375px) {
position: absolute;
bottom: 0;
right: 0;
}
}
}

View File

@@ -23,7 +23,7 @@
<details name="navbar" class="menu">
<summary class="head">{% trans %}Services{% endtrans %}</summary>
<ul class="content">
<li><a href="{{ url('matmat:search_clear') }}">{% trans %}Matmatronch{% endtrans %}</a></li>
<li><a href="{{ url('matmat:search') }}">{% trans %}Matmatronch{% endtrans %}</a></li>
<li><a href="{{ url('core:file_list') }}">{% trans %}Files{% endtrans %}</a></li>
<li><a href="{{ url('pedagogy:guide') }}">{% trans %}Pedagogy{% endtrans %}</a></li>
</ul>

View File

@@ -78,12 +78,6 @@
{% endif %}
{% endmacro %}
{% macro delete_godfather(user, profile, godfather, is_father) %}
{% if user == profile or user.is_root or user.is_board_member %}
<a class="delete" href="{{ url("core:user_godfathers_delete", user_id=profile.id, godfather_id=godfather.id, is_father=is_father) }}">{% trans %}Delete{% endtrans %}</a>
{% endif %}
{% endmacro %}
{% macro paginate_alpine(page, nb_pages) %}
{# Add pagination buttons for ajax based content with alpine
@@ -157,12 +151,13 @@
{% if current_page.has_previous() %}
<a
{% if use_htmx -%}
hx-get="?page={{ current_page.previous_page_number() }}"
hx-get="?{{ querystring(page=current_page.previous_page_number()) }}"
hx-swap="innerHTML"
hx-target="#content"
hx-push-url="true"
hx-trigger="click, keyup[key=='ArrowLeft'] from:body"
{%- else -%}
href="?page={{ current_page.previous_page_number() }}"
href="?{{ querystring(page=current_page.previous_page_number()) }}"
{%- endif -%}
>
<button>
@@ -180,12 +175,12 @@
{% else %}
<a
{% if use_htmx -%}
hx-get="?page={{ i }}"
hx-get="?{{ querystring(page=i) }}"
hx-swap="innerHTML"
hx-target="#content"
hx-push-url="true"
{%- else -%}
href="?page={{ i }}"
href="?{{ querystring(page=i) }}"
{%- endif -%}
>
<button>{{ i }}</button>
@@ -195,12 +190,13 @@
{% if current_page.has_next() %}
<a
{% if use_htmx -%}
hx-get="?page={{ current_page.next_page_number() }}"
hx-get="?{{querystring(page=current_page.next_page_number())}}"
hx-swap="innerHTML"
hx-target="#content"
hx-push-url="true"
hx-trigger="click, keyup[key=='ArrowRight'] from:body"
{%- else -%}
href="?page={{ current_page.next_page_number() }}"
href="?{{querystring(page=current_page.next_page_number())}}"
{%- endif -%}
><button>
<i class="fa fa-caret-right"></i>
@@ -249,3 +245,17 @@
}"></div>
{% endif %}
{% endmacro %}
{% macro querystring() %}
{%- for key, values in request.GET.lists() -%}
{%- if key not in kwargs -%}
{%- for value in values -%}
{{ key }}={{ value }}&amp;
{%- endfor -%}
{%- endif -%}
{%- endfor -%}
{%- for key, value in kwargs.items() -%}
{{ key }}={{ value }}&amp;
{%- endfor -%}
{% endmacro %}

View File

@@ -3,7 +3,7 @@
{% block content %}
{% if target %}
<p>{% trans user=target.get_display_name() %}Change password for {{ user }}{% endtrans %}</p>
<p>{% trans user=form.user.get_display_name() %}Change password for {{ user }}{% endtrans %}</p>
{% endif %}
<form method="post" action="">
{% csrf_token %}

View File

@@ -9,19 +9,17 @@
{% block content %}
<h4>{% trans %}Users{% endtrans %}</h4>
<ul>
{% for i in result.users %}
{% if user.can_view(i) %}
<li>
{{ user_link_with_pict(i) }}
</li>
{% endif %}
{% for user in users %}
<li>
{{ user_link_with_pict(user) }}
</li>
{% endfor %}
</ul>
<h4>{% trans %}Clubs{% endtrans %}</h4>
<ul>
{% for i in result.clubs %}
{% for club in clubs %}
<li>
<a href="{{ url("club:club_view", club_id=i.id) }}">{{ i }}</a>
<a href="{{ url("club:club_view", club_id=club.id) }}">{{ club }}</a>
</li>
{% endfor %}
</ul>

View File

@@ -114,7 +114,7 @@
{# All fields #}
<div class="profile-fields">
<div class="fields-centered">
{%- for field in form -%}
{%- if field.name in ["quote","profile_pict","avatar_pict","scrub_pict","is_viewable","forum_signature"] -%}
{%- continue -%}
@@ -133,7 +133,7 @@
</div>
{# Textareas #}
<div class="profile-fields">
<div class="fields-centered">
{%- for field in [form.quote, form.forum_signature] -%}
<div class="profile-field">
{{ field.label_tag() }}

View File

@@ -29,7 +29,16 @@
<a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link">
{{ u.get_mini_item() | safe }}
</a>
{{ delete_godfather(user, profile, u, True) }}
{% if user == profile or user.is_root or user.is_board_member %}
<form
method="post"
class="no-margin"
action="{{ url("core:user_godfathers_delete", user_id=profile.id, godfather_id=u.id, is_father=True) }}"
>
{% csrf_token %}
<input type="submit" class="link-like" value="{% trans %}Delete{% endtrans %}">
</form>
{% endif %}
</li>
{% endfor %}
</ul>
@@ -46,7 +55,16 @@
<a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link">
{{ u.get_mini_item()|safe }}
</a>
{{ delete_godfather(user, profile, u, False) }}
{% if user == profile or user.is_root or user.is_board_member %}
<form
method="post"
class="no-margin"
action="{{ url("core:user_godfathers_delete", user_id=profile.id, godfather_id=u.id, is_father=False) }}"
>
{% csrf_token %}
<input type="submit" class="link-like link-red" value="{% trans %}Delete{% endtrans %}">
</form>
{% endif %}
</li>
{% endfor %}
</ul>

View File

@@ -11,32 +11,35 @@
{% block content %}
<div class="container">
<div class="row">
{% if profile.permanencies %}
{% if total_perm_time %}
<div>
<h3>{% trans %}Permanencies{% endtrans %}</h3>
<div class="flexed">
<div><span>Foyer :</span><span>{{ total_foyer_time }}</span></div>
<div><span>Gommette :</span><span>{{ total_gommette_time }}</span></div>
<div><span>MDE :</span><span>{{ total_mde_time }}</span></div>
<div><b>Total :</b><b>{{ total_perm_time }}</b></div>
{% for perm in perm_time %}
<div>
<span>{{ perm["counter__name"] }} :</span>
<span>{{ perm["total"]|format_timedelta }}</span>
</div>
{% endfor %}
<div><b>Total :</b><b>{{ total_perm_time|format_timedelta }}</b></div>
</div>
</div>
{% endif %}
<div>
<h3>{% trans %}Buyings{% endtrans %}</h3>
<div class="flexed">
<div><span>Foyer :</span><span>{{ total_foyer_buyings }}&nbsp;€</span></div>
<div><span>Gommette :</span><span>{{ total_gommette_buyings }}&nbsp;€</span></div>
<div><span>MDE :</span><span>{{ total_mde_buyings }}&nbsp;€</span></div>
<div><b>Total :</b><b>{{ total_foyer_buyings + total_gommette_buyings + total_mde_buyings }}&nbsp;€</b>
</div>
{% for sum in purchase_sums %}
<div>
<span>{{ sum["counter__name"] }}</span>
<span>{{ sum["total"] }} €</span>
</div>
{% endfor %}
<div><b>Total : </b><b>{{ total_purchases }} €</b></div>
</div>
</div>
</div>
<div>
<h3>{% trans %}Product top 10{% endtrans %}</h3>
<h3>{% trans %}Product top 15{% endtrans %}</h3>
<table>
<thead>
<tr>

View File

@@ -184,18 +184,18 @@
</div>
{% endif %}
{% if user.has_perm("pedagogy.add_uv") or user.has_perm("pedagogy.delete_uvcomment") %}
{% if user.has_perm("pedagogy.add_ue") or user.has_perm("pedagogy.delete_uecomment") %}
<div>
<h4>{% trans %}Pedagogy{% endtrans %}</h4>
<ul>
{% if user.has_perm("pedagogy.add_uv") %}
{% if user.has_perm("pedagogy.add_ue") %}
<li>
<a href="{{ url("pedagogy:uv_create") }}">
{% trans %}Create UV{% endtrans %}
<a href="{{ url("pedagogy:ue_create") }}">
{% trans %}Create UE{% endtrans %}
</a>
</li>
{% endif %}
{% if user.has_perm("pedagogy.delete_uvcomment") %}
{% if user.has_perm("pedagogy.delete_uecomment") %}
<li>
<a href="{{ url("pedagogy:moderation") }}">
{% trans %}Moderate comments{% endtrans %}

View File

@@ -55,31 +55,17 @@ def phonenumber(
return value
@register.filter(name="truncate_time")
def truncate_time(value, time_unit):
"""Remove everything in the time format lower than the specified unit.
Args:
value: the value to truncate
time_unit: the lowest unit to display
"""
value = str(value)
return {
"millis": lambda: value.split(".")[0],
"seconds": lambda: value.rsplit(":", maxsplit=1)[0],
"minutes": lambda: value.split(":", maxsplit=1)[0],
"hours": lambda: value.rsplit(" ")[0],
}[time_unit]()
@register.filter(name="format_timedelta")
def format_timedelta(value: datetime.timedelta) -> str:
value = value - datetime.timedelta(microseconds=value.microseconds)
days = value.days
if days == 0:
return str(value)
remainder = value - datetime.timedelta(days=days)
return ngettext(
"%(nb_days)d day, %(remainder)s", "%(nb_days)d days, %(remainder)s", days
"%(nb_days)d day, %(remainder)s",
"%(nb_days)d days, %(remainder)s",
days,
) % {"nb_days": days, "remainder": str(remainder)}

View File

@@ -22,19 +22,18 @@ from bs4 import BeautifulSoup
from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import Permission
from django.core import mail
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.mail import EmailMessage
from django.test import Client, RequestFactory, TestCase
from django.urls import reverse
from django.utils.timezone import now
from django.views.generic import View
from django.views.generic.base import ContextMixin
from model_bakery import baker
from pytest_django.asserts import assertInHTML, assertRedirects
from antispam.models import ToxicDomain
from club.models import Club, Membership
from club.models import Club
from core.baker_recipes import subscriber_user
from core.markdown import markdown
from core.models import AnonymousUser, Group, Page, User, validate_promo
from core.utils import get_last_promo, get_semester_code, get_start_of_semester
@@ -435,23 +434,6 @@ class TestUserIsInGroup(TestCase):
with self.assertNumQueries(0):
self.public_user.is_in_group(pk=group_not_in.id)
def test_cache_properly_cleared_membership(self):
"""Test that when the membership of a user end,
the cache is properly invalidated.
"""
membership = baker.make(Membership, club=self.club, user=self.public_user)
cache.clear()
self.club.get_membership_for(self.public_user) # this should populate the cache
assert membership == cache.get(
f"membership_{self.club.id}_{self.public_user.id}"
)
membership.end_date = now() - timedelta(minutes=5)
membership.save()
cached_membership = cache.get(
f"membership_{self.club.id}_{self.public_user.id}"
)
assert cached_membership == "not_member"
def test_not_existing_group(self):
"""Test that searching for a not existing group
returns False.
@@ -551,3 +533,10 @@ def test_allow_fragment_mixin():
assert not TestAllowFragmentView.as_view()(request)
request.headers = {"HX-Request": True, **base_headers}
assert TestAllowFragmentView.as_view()(request)
@pytest.mark.django_db
def test_search_view(client: Client):
client.force_login(subscriber_user.make())
response = client.get(reverse("core:search", query={"query": "foo"}))
assert response.status_code == 200

View File

@@ -118,9 +118,9 @@ class TestFileModerationView:
(lambda: None, 403), # Anonymous user
(lambda: baker.make(User, is_superuser=True), 200),
(lambda: baker.make(User), 403),
(lambda: subscriber_user.make(), 403),
(lambda: old_subscriber_user.make(), 403),
(lambda: board_user.make(), 403),
(subscriber_user.make, 403),
(old_subscriber_user.make, 403),
(board_user.make, 403),
],
)
def test_view_access(
@@ -262,7 +262,7 @@ def test_apply_rights_recursively():
@pytest.mark.django_db
@pytest.mark.parametrize(
("user_receipe", "file", "expected_status"),
("user_recipe", "file", "expected_status"),
[
(
lambda: None,
@@ -279,21 +279,21 @@ def test_apply_rights_recursively():
403,
),
(
lambda: subscriber_user.make(),
subscriber_user.make,
SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
),
200,
),
(
lambda: old_subscriber_user.make(),
old_subscriber_user.make,
SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="image/jpg"
),
200,
),
(
lambda: old_subscriber_user.make(),
old_subscriber_user.make,
SimpleUploadedFile(
"ttesttesttesttesttesttesttesttesttesttesttesttesttesttesttestesttesttesttesttesttesttesttesttesttesttesttest.jpg",
content=RED_PIXEL_PNG,
@@ -302,21 +302,21 @@ def test_apply_rights_recursively():
200,
), # very long file name
(
lambda: old_subscriber_user.make(),
old_subscriber_user.make,
SimpleUploadedFile(
"test.jpg", content=b"invalid", content_type="image/jpg"
),
422,
),
(
lambda: old_subscriber_user.make(),
old_subscriber_user.make,
SimpleUploadedFile(
"test.jpg", content=RED_PIXEL_PNG, content_type="invalid"
),
200, # PIL can guess
),
(
lambda: old_subscriber_user.make(),
old_subscriber_user.make,
SimpleUploadedFile("test.jpg", content=b"invalid", content_type="invalid"),
422,
),
@@ -324,11 +324,11 @@ def test_apply_rights_recursively():
)
def test_quick_upload_image(
client: Client,
user_receipe: Callable[[], User | None],
user_recipe: Callable[[], User | None],
file: UploadedFile | None,
expected_status: int,
):
if (user := user_receipe()) is not None:
if (user := user_recipe()) is not None:
client.force_login(user)
resp = client.post(
reverse("api:quick_upload_image"), {"file": file} if file is not None else {}

View File

@@ -0,0 +1,64 @@
from datetime import timedelta
from operator import attrgetter
import pytest
from bs4 import BeautifulSoup
from django.test import Client, TestCase
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker, seq
from pytest_django.asserts import assertRedirects
from core.baker_recipes import subscriber_user
from core.models import Notification
@pytest.mark.django_db
class TestNotificationList(TestCase):
@classmethod
def setUpTestData(cls):
cls.user = subscriber_user.make()
url = reverse("core:user_profile", kwargs={"user_id": cls.user.id})
cls.notifs = baker.make(
Notification,
user=cls.user,
url=url,
viewed=False,
date=seq(now() - timedelta(days=1), timedelta(hours=1)),
_quantity=10,
_bulk_create=True,
)
def test_list(self):
self.client.force_login(self.user)
response = self.client.get(reverse("core:notification_list"))
assert response.status_code == 200
soup = BeautifulSoup(response.text, "lxml")
ul = soup.find("ul", id="notifications")
elements = list(ul.find_all("li"))
assert len(elements) == len(self.notifs)
notifs = sorted(self.notifs, key=attrgetter("date"), reverse=True)
for element, notif in zip(elements, notifs, strict=True):
assert element.find("a")["href"] == reverse(
"core:notification", kwargs={"notif_id": notif.id}
)
def test_read_all(self):
self.client.force_login(self.user)
response = self.client.get(
reverse("core:notification_list", query={"read_all": None})
)
assert response.status_code == 200
assert not self.user.notifications.filter(viewed=True).exists()
@pytest.mark.django_db
def test_notification_redirect(client: Client):
user = subscriber_user.make()
url = reverse("core:user_profile", kwargs={"user_id": user.id})
notif = baker.make(Notification, user=user, url=url, viewed=False)
client.force_login(user)
response = client.get(reverse("core:notification", kwargs={"notif_id": notif.id}))
assertRedirects(response, url)
notif.refresh_from_db()
assert notif.viewed is True

View File

@@ -1,3 +1,4 @@
import itertools
from datetime import timedelta
from unittest import mock
@@ -23,7 +24,7 @@ from core.baker_recipes import (
from core.models import AnonymousUser, Group, User
from core.views import UserTabsMixin
from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Refilling, Selling
from counter.models import Counter, Customer, Permanency, Refilling, Selling
from counter.utils import is_logged_in_counter
from eboutic.models import Invoice, InvoiceItem
@@ -187,11 +188,7 @@ class TestFilterInactive(TestCase):
time_inactive = time_active - timedelta(days=3)
counter, seller = baker.make(Counter), baker.make(User)
sale_recipe = Recipe(
Selling,
counter=counter,
club=counter.club,
seller=seller,
is_validated=True,
Selling, counter=counter, club=counter.club, seller=seller, unit_price=0
)
cls.users = [
@@ -421,10 +418,111 @@ class TestUserQuerySetViewableBy:
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert set(viewable) == {users[0], users[1]}
@pytest.mark.parametrize(
"user_factory", [lambda: baker.make(User), lambda: AnonymousUser()]
)
@pytest.mark.parametrize("user_factory", [lambda: baker.make(User), AnonymousUser])
def test_not_subscriber(self, users: list[User], user_factory):
user = user_factory()
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert not viewable.exists()
@pytest.mark.django_db
def test_user_preferences(client: Client):
user = subscriber_user.make()
client.force_login(user)
url = reverse("core:user_prefs", kwargs={"user_id": user.id})
response = client.get(url)
assert response.status_code == 200
response = client.post(url, {"notify_on_click": "true"})
assertRedirects(response, url)
user.preferences.refresh_from_db()
assert user.preferences.notify_on_click is True
@pytest.mark.django_db
def test_user_stats(client: Client):
user = subscriber_user.make()
baker.make(Refilling, customer=user.customer, amount=99999)
bars = [b[0] for b in settings.SITH_COUNTER_BARS]
baker.make(
Permanency,
end=now() - timedelta(days=5),
start=now() - timedelta(days=5, hours=3),
counter_id=itertools.cycle(bars),
_quantity=5,
_bulk_create=True,
)
sale_recipe.make(
counter_id=itertools.cycle(bars),
customer=user.customer,
unit_price=1,
quantity=1,
_quantity=5,
)
client.force_login(user)
response = client.get(reverse("core:user_stats", kwargs={"user_id": user.id}))
assert response.status_code == 200
@pytest.mark.django_db
class TestChangeUserPassword:
def test_as_root(self, client: Client, admin_user: User):
client.force_login(admin_user)
user = subscriber_user.make()
url = reverse("core:password_root_change", kwargs={"user_id": user.id})
response = client.get(url)
assert response.status_code == 200
response = client.post(
url, {"new_password1": "poutou", "new_password2": "poutou"}
)
assertRedirects(response, reverse("core:password_change_done"))
user.refresh_from_db()
assert user.check_password("poutou") is True
@pytest.mark.django_db
class TestUserGodfather:
@pytest.mark.parametrize("godfather", [True, False])
def test_add_family(self, client: Client, godfather):
user = subscriber_user.make()
other_user = subscriber_user.make()
client.force_login(user)
url = reverse("core:user_godfathers", kwargs={"user_id": user.id})
response = client.get(url)
assert response.status_code == 200
response = client.post(
url,
{"type": "godfather" if godfather else "godchild", "user": other_user.id},
)
assertRedirects(response, url)
if godfather:
assert user.godfathers.contains(other_user)
else:
assert user.godchildren.contains(other_user)
def test_tree(self, client: Client):
user = subscriber_user.make()
client.force_login(user)
response = client.get(
reverse("core:user_godfathers_tree", kwargs={"user_id": user.id})
)
assert response.status_code == 200
def test_remove_family(self, client: Client):
user = subscriber_user.make()
other_user = subscriber_user.make()
user.godfathers.add(other_user)
client.force_login(user)
response = client.post(
reverse(
"core:user_godfathers_delete",
kwargs={
"user_id": user.id,
"godfather_id": other_user.id,
"is_father": True,
},
)
)
assertRedirects(
response, reverse("core:user_godfathers", kwargs={"user_id": user.id})
)
assert not user.godfathers.contains(other_user)

View File

@@ -24,6 +24,7 @@
from django.urls import path, re_path, register_converter
from django.views.generic import RedirectView
from com.views import NewsListView
from core.converters import (
BooleanStringConverter,
FourDigitYearConverter,
@@ -53,6 +54,8 @@ from core.views import (
PagePropView,
PageRevView,
PageView,
PasswordRootChangeView,
SearchView,
SithLoginView,
SithPasswordChangeDoneView,
SithPasswordChangeView,
@@ -76,13 +79,8 @@ from core.views import (
UserUpdateProfileView,
UserView,
delete_user_godfather,
index,
logout,
notification,
password_root_change,
search_json,
search_user_json,
search_view,
send_file,
)
@@ -91,20 +89,18 @@ register_converter(TwoDigitMonthConverter, "mm")
register_converter(BooleanStringConverter, "bool")
urlpatterns = [
path("", index, name="index"),
path("", NewsListView.as_view(), name="index"),
path("notifications/", NotificationList.as_view(), name="notification_list"),
path("notification/<int:notif_id>/", notification, name="notification"),
# Search
path("search/", search_view, name="search"),
path("search_json/", search_json, name="search_json"),
path("search_user/", search_user_json, name="search_user"),
path("search/", SearchView.as_view(), name="search"),
# Login and co
path("login/", SithLoginView.as_view(), name="login"),
path("logout/", logout, name="logout"),
path("password_change/", SithPasswordChangeView.as_view(), name="password_change"),
path(
"password_change/<int:user_id>/",
password_root_change,
PasswordRootChangeView.as_view(),
name="password_root_change",
),
path(

View File

@@ -303,7 +303,6 @@ class UserGodfathersForm(forms.Form):
)
user = forms.ModelChoiceField(
label=_("Select user"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
@@ -315,8 +314,6 @@ class UserGodfathersForm(forms.Form):
def clean_user(self):
other_user = self.cleaned_data.get("user")
if not other_user:
raise ValidationError(_("This user does not exist"))
if other_user == self.target_user:
raise ValidationError(_("You cannot be related to yourself"))
return other_user

View File

@@ -22,106 +22,49 @@
#
#
import json
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core import serializers
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import F
from django.db.models.query import QuerySet
from django.http import JsonResponse
from django.shortcuts import redirect, render
from django.utils import html
from django.utils.text import slugify
from django.views.generic import ListView
from haystack.query import SearchQuerySet
from django.http import HttpRequest
from django.shortcuts import get_object_or_404, redirect
from django.views.generic import ListView, TemplateView
from club.models import Club
from core.models import Notification, User
from core.schemas import UserFilterSchema
def index(request, context=None):
from com.views import NewsListView
return NewsListView.as_view()(request)
class NotificationList(ListView):
class NotificationList(LoginRequiredMixin, ListView):
model = Notification
template_name = "core/notification_list.jinja"
def get_queryset(self) -> QuerySet[Notification]:
if self.request.user.is_anonymous:
return Notification.objects.none()
# TODO: Bulk update in django 2.2
if "see_all" in self.request.GET:
self.request.user.notifications.filter(viewed=False).update(viewed=True)
return self.request.user.notifications.order_by("-date")[:20]
def notification(request, notif_id):
notif = Notification.objects.filter(id=notif_id).first()
if notif:
if notif.type not in settings.SITH_PERMANENT_NOTIFICATIONS:
notif.viewed = True
else:
notif.callback()
notif.save()
return redirect(notif.url)
return redirect("/")
def notification(request: HttpRequest, notif_id: int):
notif = get_object_or_404(Notification, id=notif_id)
if notif.type not in settings.SITH_PERMANENT_NOTIFICATIONS:
notif.viewed = True
else:
notif.callback()
notif.save()
return redirect(notif.url)
def search_user(query):
try:
# slugify turns everything into ascii and every whitespace into -
# it ends by removing duplicate - (so ' - ' will turn into '-')
# replace('-', ' ') because search is whitespace based
query = slugify(query).replace("-", " ")
# TODO: is this necessary?
query = html.escape(query)
res = (
SearchQuerySet()
.models(User)
.autocomplete(auto=query)
.order_by("-last_login")
.load_all()[:20]
)
return [r.object for r in res]
except TypeError:
return []
class SearchView(LoginRequiredMixin, TemplateView):
template_name = "core/search.jinja"
def search_club(query, *, as_json=False):
clubs = []
if query:
clubs = Club.objects.filter(name__icontains=query).all()
clubs = clubs[:5]
if as_json:
# Re-loads json to avoid double encoding by JsonResponse, but still benefit from serializers
clubs = json.loads(serializers.serialize("json", clubs, fields=("name")))
else:
clubs = list(clubs)
return clubs
@login_required
def search_view(request):
result = {
"users": search_user(request.GET.get("query", "")),
"clubs": search_club(request.GET.get("query", "")),
}
return render(request, "core/search.jinja", context={"result": result})
@login_required
def search_user_json(request):
result = {"users": search_user(request.GET.get("query", ""))}
return JsonResponse(result)
@login_required
def search_json(request):
result = {
"users": search_user(request.GET.get("query", "")),
"clubs": search_club(request.GET.get("query", ""), as_json=True),
}
return JsonResponse(result)
def get_context_data(self, **kwargs):
users, clubs = [], []
if query := self.request.GET.get("query"):
users = list(
UserFilterSchema(search=query)
.filter(User.objects.viewable_by(self.request.user))
.order_by(F("last_login").desc(nulls_last=True))
)
clubs = list(Club.objects.filter(name__icontains=query)[:5])
return super().get_context_data(**kwargs) | {"users": users, "clubs": clubs}

View File

@@ -22,27 +22,28 @@
#
#
import itertools
from datetime import timedelta
# This file contains all the views that concern the user model
from datetime import date, timedelta
from operator import itemgetter
from smtplib import SMTPException
from django.contrib.auth import login, views
from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import PasswordChangeForm, SetPasswordForm
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.core.exceptions import PermissionDenied
from django.db.models import DateField, QuerySet
from django.db.models import DateField, F, QuerySet, Sum
from django.db.models.functions import Trunc
from django.forms.models import modelform_factory
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect
from django.template.loader import render_to_string
from django.template.response import TemplateResponse
from django.urls import reverse, reverse_lazy
from django.utils.decorators import method_decorator
from django.utils.safestring import SafeString
from django.utils.translation import gettext as _
from django.views.decorators.http import require_POST
from django.views.generic import (
CreateView,
DeleteView,
@@ -66,9 +67,8 @@ from core.views.forms import (
UserProfileForm,
)
from core.views.mixins import TabedViewMixin, UseFragmentsMixin
from counter.models import Counter, Refilling, Selling
from counter.models import Refilling, Selling
from eboutic.models import Invoice
from subscription.models import Subscription
from trombi.views import UserTrombiForm
@@ -99,21 +99,23 @@ def logout(request):
return views.logout_then_login(request)
def password_root_change(request, user_id):
class PasswordRootChangeView(UserPassesTestMixin, FormView):
"""Allows a root user to change someone's password."""
if not request.user.is_root:
raise PermissionDenied
user = get_object_or_404(User, id=user_id)
if request.method == "POST":
form = views.SetPasswordForm(user=user, data=request.POST)
if form.is_valid():
form.save()
return redirect("core:password_change_done")
else:
form = views.SetPasswordForm(user=user)
return TemplateResponse(
request, "core/password_change.jinja", {"form": form, "target": user}
)
template_name = "core/password_change.jinja"
form_class = SetPasswordForm
success_url = reverse_lazy("core:password_change_done")
def test_func(self):
return self.request.user.is_root
def get_form_kwargs(self):
user = get_object_or_404(User, id=self.kwargs["user_id"])
return super().get_form_kwargs() | {"user": user}
def form_valid(self, form: SetPasswordForm):
form.save()
return super().form_valid(form)
@method_decorator(check_honeypot, name="post")
@@ -288,10 +290,12 @@ class UserView(UserTabsMixin, CanViewMixin, DetailView):
return kwargs
@require_POST
@login_required
def delete_user_godfather(request, user_id, godfather_id, is_father):
user_is_admin = request.user.is_root or request.user.is_board_member
if user_id != request.user.id and not user_is_admin:
raise PermissionDenied()
raise PermissionDenied
user = get_object_or_404(User, id=user_id)
to_remove = get_object_or_404(User, id=godfather_id)
if is_father:
@@ -353,87 +357,40 @@ class UserStatsView(UserTabsMixin, CanViewMixin, DetailView):
context_object_name = "profile"
template_name = "core/user_stats.jinja"
current_tab = "stats"
queryset = User.objects.exclude(customer=None).select_related("customer")
def dispatch(self, request, *arg, **kwargs):
profile = self.get_object()
if not hasattr(profile, "customer"):
raise Http404
if not (
profile == request.user or request.user.has_perm("counter.view_customer")
):
raise PermissionDenied
return super().dispatch(request, *arg, **kwargs)
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
from django.db.models import Sum
foyer = Counter.objects.filter(name="Foyer").first()
mde = Counter.objects.filter(name="MDE").first()
gommette = Counter.objects.filter(name="La Gommette").first()
semester_start = Subscription.compute_start(d=date.today(), duration=3)
kwargs["perm_time"] = list(
self.object.permanencies.filter(end__isnull=False, counter__type="BAR")
.values("counter", "counter__name")
.annotate(total=Sum(F("end") - F("start"), default=timedelta(seconds=0)))
.order_by("-total")
)
kwargs["total_perm_time"] = sum(
[p.end - p.start for p in self.object.permanencies.exclude(end=None)],
timedelta(),
[perm["total"] for perm in kwargs["perm_time"]], start=timedelta(seconds=0)
)
kwargs["total_foyer_time"] = sum(
[
p.end - p.start
for p in self.object.permanencies.filter(counter=foyer).exclude(
end=None
)
],
timedelta(),
)
kwargs["total_mde_time"] = sum(
[
p.end - p.start
for p in self.object.permanencies.filter(counter=mde).exclude(end=None)
],
timedelta(),
)
kwargs["total_gommette_time"] = sum(
[
p.end - p.start
for p in self.object.permanencies.filter(counter=gommette).exclude(
end=None
)
],
timedelta(),
)
kwargs["total_foyer_buyings"] = sum(
[
b.unit_price * b.quantity
for b in self.object.customer.buyings.filter(
counter=foyer, date__gte=semester_start
)
]
)
kwargs["total_mde_buyings"] = sum(
[
b.unit_price * b.quantity
for b in self.object.customer.buyings.filter(
counter=mde, date__gte=semester_start
)
]
)
kwargs["total_gommette_buyings"] = sum(
[
b.unit_price * b.quantity
for b in self.object.customer.buyings.filter(
counter=gommette, date__gte=semester_start
)
]
kwargs["purchase_sums"] = list(
self.object.customer.buyings.filter(counter__type="BAR")
.values("counter", "counter__name")
.annotate(total=Sum(F("unit_price") * F("quantity")))
.order_by("-total")
)
kwargs["total_purchases"] = sum(s["total"] for s in kwargs["purchase_sums"])
kwargs["top_product"] = (
self.object.customer.buyings.values("product__name")
.annotate(product_sum=Sum("quantity"))
.exclude(product_sum=None)
.order_by("-product_sum")
.all()[:10]
.all()[:15]
)
return kwargs
@@ -465,7 +422,6 @@ class UserUpdateProfileView(UserTabsMixin, CanEditMixin, UpdateView):
form_class = UserProfileForm
current_tab = "edit"
edit_once = ["profile_pict", "date_of_birth", "first_name", "last_name"]
board_only = []
def remove_restricted_fields(self, request):
"""Removes edit_once and board_only fields."""
@@ -474,9 +430,6 @@ class UserUpdateProfileView(UserTabsMixin, CanEditMixin, UpdateView):
request.user.is_board_member or request.user.is_root
):
self.form.fields.pop(i, None)
for i in self.board_only:
if not (request.user.is_board_member or request.user.is_root):
self.form.fields.pop(i, None)
def get(self, request, *args, **kwargs):
self.object = self.get_object()
@@ -528,10 +481,10 @@ class UserPreferencesView(UserTabsMixin, UseFragmentsMixin, CanEditMixin, Update
current_tab = "prefs"
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
pref = self.object.preferences
kwargs.update({"instance": pref})
return kwargs
return super().get_form_kwargs() | {"instance": self.object.preferences}
def get_success_url(self):
return self.request.path
def get_fragment_context_data(self) -> dict[str, SafeString]:
# Avoid cyclic import error

View File

@@ -24,12 +24,6 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
PAYMENT_METHOD = [
("CHECK", _("Check")),
("CASH", _("Cash")),
("CARD", _("Credit card")),
]
class CounterConfig(AppConfig):
name = "counter"

View File

@@ -1,7 +1,7 @@
import json
import math
import uuid
from datetime import date
from datetime import date, datetime, timezone
from dateutil.relativedelta import relativedelta
from django import forms
@@ -136,7 +136,10 @@ class GetUserForm(forms.Form):
class RefillForm(forms.ModelForm):
allowed_refilling_methods = ["CASH", "CARD"]
allowed_refilling_methods = [
Refilling.PaymentMethod.CASH,
Refilling.PaymentMethod.CARD,
]
error_css_class = "error"
required_css_class = "required"
@@ -146,7 +149,7 @@ class RefillForm(forms.ModelForm):
class Meta:
model = Refilling
fields = ["amount", "payment_method", "bank"]
fields = ["amount", "payment_method"]
widgets = {"payment_method": forms.RadioSelect}
def __init__(self, *args, **kwargs):
@@ -160,9 +163,6 @@ class RefillForm(forms.ModelForm):
if self.fields["payment_method"].initial not in self.allowed_refilling_methods:
self.fields["payment_method"].initial = self.allowed_refilling_methods[0]
if "CHECK" not in self.allowed_refilling_methods:
del self.fields["bank"]
class CounterEditForm(forms.ModelForm):
class Meta:
@@ -235,6 +235,19 @@ class ScheduledProductActionForm(forms.ModelForm):
)
return super().clean()
def set_product(self, product: Product):
"""Set the product to which this form's instance is linked.
When this form is linked to a ProductForm in the case of a product's creation,
the product doesn't exist yet, so saving this form as is will result
in having `{"product_id": null}` in the action kwargs.
For the creation to be useful, it may be needed to inject the newly created
product into this form, before saving the latter.
"""
self.product = product
kwargs = json.loads(self.instance.kwargs) | {"product_id": self.product.id}
self.instance.kwargs = json.dumps(kwargs)
class BaseScheduledProductActionFormSet(BaseModelFormSet):
def __init__(self, *args, product: Product, **kwargs):
@@ -321,11 +334,19 @@ class ProductForm(forms.ModelForm):
def is_valid(self):
return super().is_valid() and self.action_formset.is_valid()
def save(self, *args, **kwargs):
ret = super().save(*args, **kwargs)
self.instance.counters.set(self.cleaned_data["counters"])
def save(self, *args, **kwargs) -> Product:
product = super().save(*args, **kwargs)
product.counters.set(self.cleaned_data["counters"])
for form in self.action_formset:
# if it's a creation, the product given in the formset
# wasn't a persisted instance.
# So if we tried to persist the scheduled actions in the current state,
# they would be linked to no product, thus be completely useless
# To make it work, we have to replace
# the initial product with a persisted one
form.set_product(product)
self.action_formset.save()
return ret
return product
class ReturnableProductForm(forms.ModelForm):
@@ -369,7 +390,6 @@ class EticketForm(forms.ModelForm):
class CloseCustomerAccountForm(forms.Form):
user = forms.ModelChoiceField(
label=_("Refound this account"),
help_text=None,
required=True,
widget=AutoCompleteSelectUser,
queryset=User.objects.all(),
@@ -489,13 +509,14 @@ class InvoiceCallForm(forms.Form):
def __init__(self, *args, month: date, **kwargs):
super().__init__(*args, **kwargs)
self.month = month
month_start = datetime(month.year, month.month, month.day, tzinfo=timezone.utc)
self.clubs = list(
Club.objects.filter(
Exists(
Selling.objects.filter(
club=OuterRef("pk"),
date__gte=month,
date__lte=month + relativedelta(months=1),
date__gte=month_start,
date__lte=month_start + relativedelta(months=1),
)
)
).annotate(

View File

@@ -65,10 +65,10 @@ class Command(BaseCommand):
"""Fetch the users which have a pending account dump."""
threshold = now() - settings.SITH_ACCOUNT_DUMP_DELTA
ongoing_dump_operations: QuerySet[AccountDump] = (
AccountDump.objects.ongoing()
.filter(customer__user=OuterRef("pk"), warning_mail_sent_at__lt=threshold)
) # fmt: off
# cf. https://github.com/astral-sh/ruff/issues/14103
AccountDump.objects.ongoing().filter(
customer__user=OuterRef("pk"), warning_mail_sent_at__lt=threshold
)
)
return (
User.objects.filter(Exists(ongoing_dump_operations))
.annotate(
@@ -119,7 +119,6 @@ class Command(BaseCommand):
quantity=1,
unit_price=account.amount,
date=now(),
is_validated=True,
)
for account in accounts
]

View File

@@ -0,0 +1,84 @@
# Generated by Django 5.2.8 on 2025-11-19 17:59
from django.db import migrations, models
from django.db.migrations.state import StateApps
from django.db.models import Case, When
def migrate_selling_payment_method(apps: StateApps, schema_editor):
# 0 <=> SITH_ACCOUNT is the default value, so no need to migrate it
Selling = apps.get_model("counter", "Selling")
Selling.objects.filter(payment_method_str="CARD").update(payment_method=1)
def migrate_selling_payment_method_reverse(apps: StateApps, schema_editor):
Selling = apps.get_model("counter", "Selling")
Selling.objects.filter(payment_method=1).update(payment_method_str="CARD")
def migrate_refilling_payment_method(apps: StateApps, schema_editor):
Refilling = apps.get_model("counter", "Refilling")
Refilling.objects.update(
payment_method=Case(
When(payment_method_str="CARD", then=0),
When(payment_method_str="CASH", then=1),
When(payment_method_str="CHECK", then=2),
)
)
def migrate_refilling_payment_method_reverse(apps: StateApps, schema_editor):
Refilling = apps.get_model("counter", "Refilling")
Refilling.objects.update(
payment_method_str=Case(
When(payment_method=0, then="CARD"),
When(payment_method=1, then="CASH"),
When(payment_method=2, then="CHECK"),
)
)
class Migration(migrations.Migration):
dependencies = [("counter", "0034_alter_selling_date_selling_date_month_idx")]
operations = [
migrations.RemoveField(model_name="selling", name="is_validated"),
migrations.RemoveField(model_name="refilling", name="is_validated"),
migrations.RemoveField(model_name="refilling", name="bank"),
migrations.RenameField(
model_name="selling",
old_name="payment_method",
new_name="payment_method_str",
),
migrations.AddField(
model_name="selling",
name="payment_method",
field=models.PositiveSmallIntegerField(
choices=[(0, "Sith account"), (1, "Credit card")],
default=0,
verbose_name="payment method",
),
),
migrations.RunPython(
migrate_selling_payment_method, migrate_selling_payment_method_reverse
),
migrations.RemoveField(model_name="selling", name="payment_method_str"),
migrations.RenameField(
model_name="refilling",
old_name="payment_method",
new_name="payment_method_str",
),
migrations.AddField(
model_name="refilling",
name="payment_method",
field=models.PositiveSmallIntegerField(
choices=[(0, "Credit card"), (1, "Cash"), (2, "Check")],
default=0,
verbose_name="payment method",
),
),
migrations.RunPython(
migrate_refilling_payment_method, migrate_refilling_payment_method_reverse
),
migrations.RemoveField(model_name="refilling", name="payment_method_str"),
]

View File

@@ -44,7 +44,6 @@ from club.models import Club
from core.fields import ResizedImageField
from core.models import Group, Notification, User
from core.utils import get_start_of_semester
from counter.apps import PAYMENT_METHOD
from counter.fields import CurrencyField
from subscription.models import Subscription
@@ -80,7 +79,8 @@ class CustomerQuerySet(models.QuerySet):
)
money_out = Subquery(
Selling.objects.filter(
customer=OuterRef("pk"), payment_method="SITH_ACCOUNT"
customer=OuterRef("pk"),
payment_method=Selling.PaymentMethod.SITH_ACCOUNT,
)
.values("customer_id")
.annotate(res=Sum(F("unit_price") * F("quantity"), default=0))
@@ -447,8 +447,7 @@ class Product(models.Model):
buying_groups = list(self.buying_groups.all())
if not buying_groups:
return True
res = any(user.is_in_group(pk=group.id) for group in buying_groups)
return res
return any(user.is_in_group(pk=group.id) for group in buying_groups)
@property
def profit(self):
@@ -731,6 +730,11 @@ class RefillingQuerySet(models.QuerySet):
class Refilling(models.Model):
"""Handle the refilling."""
class PaymentMethod(models.IntegerChoices):
CARD = 0, _("Credit card")
CASH = 1, _("Cash")
CHECK = 2, _("Check")
counter = models.ForeignKey(
Counter, related_name="refillings", blank=False, on_delete=models.CASCADE
)
@@ -745,16 +749,9 @@ class Refilling(models.Model):
Customer, related_name="refillings", blank=False, on_delete=models.CASCADE
)
date = models.DateTimeField(_("date"))
payment_method = models.CharField(
_("payment method"),
max_length=255,
choices=PAYMENT_METHOD,
default="CARD",
payment_method = models.PositiveSmallIntegerField(
_("payment method"), choices=PaymentMethod, default=PaymentMethod.CARD
)
bank = models.CharField(
_("bank"), max_length=255, choices=settings.SITH_COUNTER_BANK, default="OTHER"
)
is_validated = models.BooleanField(_("is validated"), default=False)
objects = RefillingQuerySet.as_manager()
@@ -771,10 +768,9 @@ class Refilling(models.Model):
if not self.date:
self.date = timezone.now()
self.full_clean()
if not self.is_validated:
if self._state.adding:
self.customer.amount += self.amount
self.customer.save()
self.is_validated = True
if self.customer.user.preferences.notify_on_refill:
Notification(
user=self.customer.user,
@@ -814,6 +810,10 @@ class SellingQuerySet(models.QuerySet):
class Selling(models.Model):
"""Handle the sellings."""
class PaymentMethod(models.IntegerChoices):
SITH_ACCOUNT = 0, _("Sith account")
CARD = 1, _("Credit card")
# We make sure that sellings have a way begger label than any product name is allowed to
label = models.CharField(_("label"), max_length=128)
product = models.ForeignKey(
@@ -850,13 +850,9 @@ class Selling(models.Model):
on_delete=models.SET_NULL,
)
date = models.DateTimeField(_("date"), db_index=True)
payment_method = models.CharField(
_("payment method"),
max_length=255,
choices=[("SITH_ACCOUNT", _("Sith account")), ("CARD", _("Credit card"))],
default="SITH_ACCOUNT",
payment_method = models.PositiveSmallIntegerField(
_("payment method"), choices=PaymentMethod, default=PaymentMethod.SITH_ACCOUNT
)
is_validated = models.BooleanField(_("is validated"), default=False)
objects = SellingQuerySet.as_manager()
@@ -875,10 +871,12 @@ class Selling(models.Model):
if not self.date:
self.date = timezone.now()
self.full_clean()
if not self.is_validated:
if (
self._state.adding
and self.payment_method == self.PaymentMethod.SITH_ACCOUNT
):
self.customer.amount -= self.quantity * self.unit_price
self.customer.save(allow_negative=allow_negative)
self.is_validated = True
user = self.customer.user
if user.was_subscribed:
if (
@@ -948,7 +946,9 @@ class Selling(models.Model):
def is_owned_by(self, user: User) -> bool:
if user.is_anonymous:
return False
return self.payment_method != "CARD" and user.is_owner(self.counter)
return self.payment_method != self.PaymentMethod.CARD and user.is_owner(
self.counter
)
def can_be_viewed_by(self, user: User) -> bool:
if (
@@ -958,7 +958,7 @@ class Selling(models.Model):
return user == self.customer.user
def delete(self, *args, **kwargs):
if self.payment_method == "SITH_ACCOUNT":
if self.payment_method == Selling.PaymentMethod.SITH_ACCOUNT:
self.customer.amount += self.quantity * self.unit_price
self.customer.save()
super().delete(*args, **kwargs)

View File

@@ -1,13 +1,12 @@
from datetime import datetime
from typing import Annotated, Self
from annotated_types import MinLen
from django.urls import reverse
from ninja import Field, FilterSchema, ModelSchema, Schema
from ninja import FilterLookup, FilterSchema, ModelSchema, Schema
from pydantic import model_validator
from club.schemas import SimpleClubSchema
from core.schemas import GroupSchema, SimpleUserSchema
from core.schemas import GroupSchema, NonEmptyStr, SimpleUserSchema
from counter.models import Counter, Product, ProductType
@@ -21,7 +20,7 @@ class CounterSchema(ModelSchema):
class CounterFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] = Field(None, q="name__icontains")
search: Annotated[NonEmptyStr | None, FilterLookup("name__icontains")] = None
class SimplifiedCounterSchema(ModelSchema):
@@ -93,18 +92,18 @@ class ProductSchema(ModelSchema):
class ProductFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] | None = Field(
None, q=["name__icontains", "code__icontains"]
)
is_archived: bool | None = Field(None, q="archived")
buying_groups: set[int] | None = Field(None, q="buying_groups__in")
product_type: set[int] | None = Field(None, q="product_type__in")
club: set[int] | None = Field(None, q="club__in")
counter: set[int] | None = Field(None, q="counters__in")
search: Annotated[
NonEmptyStr | None, FilterLookup(["name__icontains", "code__icontains"])
] = None
is_archived: Annotated[bool | None, FilterLookup("archived")] = None
buying_groups: Annotated[set[int] | None, FilterLookup("buying_groups__in")] = None
product_type: Annotated[set[int] | None, FilterLookup("product_type__in")] = None
club: Annotated[set[int] | None, FilterLookup("club__in")] = None
counter: Annotated[set[int] | None, FilterLookup("counters__in")] = None
class SaleFilterSchema(FilterSchema):
before: datetime | None = Field(None, q="date__lt")
after: datetime | None = Field(None, q="date__gt")
counters: set[int] | None = Field(None, q="counter__in")
products: set[int] | None = Field(None, q="product__in")
before: Annotated[datetime | None, FilterLookup("date__lt")] = None
after: Annotated[datetime | None, FilterLookup("date__gt")] = None
counters: Annotated[set[int] | None, FilterLookup("counter__in")] = None
products: Annotated[set[int] | None, FilterLookup("product__in")] = None

View File

@@ -1,4 +1,4 @@
import type { Product } from "#counter:counter/types";
import type { Product } from "#counter:counter/types.ts";
export class BasketItem {
quantity: number;

View File

@@ -1,14 +1,14 @@
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
import {
type CounterSchema,
type ProductTypeSchema,
type SimpleProductSchema,
counterSearchCounter,
type ProductTypeSchema,
productSearchProducts,
producttypeFetchAll,
type SimpleProductSchema,
} from "#openapi";
@registerComponent("product-ajax-select")

View File

@@ -1,13 +1,13 @@
import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import type { RecursivePartial, TomSettings } from "tom-select/dist/types/types";
import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
const productParsingRegex = /^(\d+x)?(.*)/i;
const codeParsingRegex = / \((\w+)\)$/;
function parseProduct(query: string): [number, string] {
const parsed = productParsingRegex.exec(query);
return [Number.parseInt(parsed[1] || "1"), parsed[2]];
return [Number.parseInt(parsed[1] || "1", 10), parsed[2]];
}
@registerComponent("counter-product-select")
@@ -80,9 +80,9 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
// We need to manually set weights or it results on an inconsistent
// behavior between production and development environment
searchField: [
// @ts-ignore documentation says it's fine, specified type is wrong
// @ts-expect-error documentation says it's fine, specified type is wrong
{ field: "code", weight: 2 },
// @ts-ignore documentation says it's fine, specified type is wrong
// @ts-expect-error documentation says it's fine, specified type is wrong
{ field: "text", weight: 0.5 },
],
};

View File

@@ -1,6 +1,6 @@
import { AlertMessage } from "#core:utils/alert-message";
import { BasketItem } from "#counter:counter/basket";
import type { CounterConfig, ErrorMessage } from "#counter:counter/types";
import { AlertMessage } from "#core:utils/alert-message.ts";
import { BasketItem } from "#counter:counter/basket.ts";
import type { CounterConfig, ErrorMessage } from "#counter:counter/types.ts";
import type { CounterProductSelect } from "./components/counter-product-select-index.ts";
document.addEventListener("alpine:init", () => {

View File

@@ -1,9 +1,13 @@
import { paginated } from "#core:utils/api";
import { csv } from "#core:utils/csv";
import { History, getCurrentUrlParams, updateQueryString } from "#core:utils/history";
import type { NestedKeyOf } from "#core:utils/types";
import { showSaveFilePicker } from "native-file-system-adapter";
import type TomSelect from "tom-select";
import { paginated } from "#core:utils/api.ts";
import { csv } from "#core:utils/csv.ts";
import {
getCurrentUrlParams,
History,
updateQueryString,
} from "#core:utils/history.ts";
import type { NestedKeyOf } from "#core:utils/types.ts";
import {
type ProductSchema,
type ProductSearchProductsDetailedData,

View File

@@ -1,5 +1,5 @@
import { AlertMessage } from "#core:utils/alert-message";
import Alpine from "alpinejs";
import { AlertMessage } from "#core:utils/alert-message.ts";
import { producttypeReorder } from "#openapi";
document.addEventListener("alpine:init", () => {
@@ -22,7 +22,7 @@ document.addEventListener("alpine:init", () => {
const productTypes = this.$refs.productTypes
.childNodes as NodeListOf<HTMLLIElement>;
const getId = (elem: HTMLLIElement) =>
Number.parseInt(elem.getAttribute("x-sort:item"));
Number.parseInt(elem.getAttribute("x-sort:item"), 10);
const query =
newPosition === 0
? { above: getId(productTypes.item(1)) }

View File

@@ -104,7 +104,7 @@
</div>
<ul>
<li x-show="getBasketSize() === 0">{% trans %}This basket is empty{% endtrans %}</li>
<template x-for="(item, index) in Object.values(basket)">
<template x-for="(item, index) in Object.values(basket)" :key="item.product.id">
<li>
<template x-for="error in item.errors">
<div class="alert alert-red" x-text="error">

View File

@@ -51,7 +51,7 @@
<td>{{ loop.index }}</td>
<td>{{ barman.name }} {% if barman.nickname %}({{ barman.nickname }}){% endif %}</td>
<td>{{ barman.promo or '' }}</td>
<td>{{ barman.perm_sum|format_timedelta|truncate_time("millis") }}</td>
<td>{{ barman.perm_sum|format_timedelta }}</td>
</tr>
{% endfor %}
</tbody>
@@ -73,7 +73,7 @@
<td>{{ loop.index }}</td>
<td>{{ barman.name }} {% if barman.nickname %}({{ barman.nickname }}){% endif %}</td>
<td>{{ barman.promo or '' }}</td>
<td>{{ barman.perm_sum|format_timedelta|truncate_time("millis") }}</td>
<td>{{ barman.perm_sum|format_timedelta }}</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -116,7 +116,6 @@ class TestAccountDumpCommand(TestAccountDump):
operation: Selling = customer.buyings.order_by("date").last()
assert operation.unit_price == initial_amount
assert operation.counter_id == settings.SITH_COUNTER_ACCOUNT_DUMP_ID
assert operation.is_validated is True
dump = customer.dumps.last()
assert dump.dump_operation == operation

View File

@@ -11,8 +11,12 @@ from model_bakery import baker
from core.models import Group, User
from counter.baker_recipes import counter_recipe, product_recipe
from counter.forms import ScheduledProductActionForm, ScheduledProductActionFormSet
from counter.models import ScheduledProductAction
from counter.forms import (
ProductForm,
ScheduledProductActionForm,
ScheduledProductActionFormSet,
)
from counter.models import Product, ScheduledProductAction
@pytest.mark.django_db
@@ -34,6 +38,39 @@ def test_edit_product(client: Client):
assert res.status_code == 200
@pytest.mark.django_db
def test_create_actions_alongside_product():
"""The form should work when the product and the actions are created alongside."""
# non-persisted instance
product: Product = product_recipe.prepare(_save_related=True)
trigger_at = now() + timedelta(minutes=10)
form = ProductForm(
data={
"name": "foo",
"description": "bar",
"product_type": product.product_type_id,
"club": product.club_id,
"code": "FOO",
"purchase_price": 1.0,
"selling_price": 1.0,
"special_selling_price": 1.0,
"limit_age": 0,
"form-TOTAL_FORMS": "2",
"form-INITIAL_FORMS": "0",
"form-0-task": "counter.tasks.archive_product",
"form-0-trigger_at": trigger_at,
},
)
assert form.is_valid()
product = form.save()
action = ScheduledProductAction.objects.last()
assert action.clocked.clocked_time == trigger_at
assert action.enabled is True
assert action.one_off is True
assert action.task == "counter.tasks.archive_product"
assert action.kwargs == json.dumps({"product_id": product.id})
@pytest.mark.django_db
class TestProductActionForm:
def test_single_form_archive(self):

View File

@@ -53,7 +53,7 @@ def set_age(user: User, age: int):
def force_refill_user(user: User, amount: Decimal | int):
baker.make(Refilling, amount=amount, customer=user.customer, is_validated=False)
baker.make(Refilling, amount=amount, customer=user.customer)
class TestFullClickBase(TestCase):
@@ -115,18 +115,10 @@ class TestRefilling(TestFullClickBase):
) -> HttpResponse:
used_client = client if client is not None else self.client
return used_client.post(
reverse(
"counter:refilling_create",
kwargs={"customer_id": user.pk},
),
{
"amount": str(amount),
"payment_method": "CASH",
"bank": "OTHER",
},
reverse("counter:refilling_create", kwargs={"customer_id": user.pk}),
{"amount": str(amount), "payment_method": Refilling.PaymentMethod.CASH},
HTTP_REFERER=reverse(
"counter:click",
kwargs={"counter_id": counter.id, "user_id": user.pk},
"counter:click", kwargs={"counter_id": counter.id, "user_id": user.pk}
),
)
@@ -149,11 +141,7 @@ class TestRefilling(TestFullClickBase):
"counter:refilling_create",
kwargs={"customer_id": self.customer.pk},
),
{
"amount": "10",
"payment_method": "CASH",
"bank": "OTHER",
},
{"amount": "10", "payment_method": "CASH"},
)
self.client.force_login(self.club_admin)

View File

@@ -298,7 +298,6 @@ def test_update_balance():
_quantity=len(customers),
unit_price=10,
quantity=1,
payment_method="SITH_ACCOUNT",
_save_related=True,
),
*sale_recipe.prepare(
@@ -306,14 +305,12 @@ def test_update_balance():
_quantity=3,
unit_price=5,
quantity=2,
payment_method="SITH_ACCOUNT",
_save_related=True,
),
sale_recipe.prepare(
customer=customers[4],
quantity=1,
unit_price=50,
payment_method="SITH_ACCOUNT",
_save_related=True,
),
*sale_recipe.prepare(
@@ -324,7 +321,7 @@ def test_update_balance():
_quantity=len(customers),
unit_price=50,
quantity=1,
payment_method="CARD",
payment_method=Selling.PaymentMethod.CARD,
_save_related=True,
),
]

View File

@@ -6,7 +6,7 @@ from django.contrib.auth.models import Permission
from django.core.exceptions import ValidationError
from django.test import Client
from django.urls import reverse
from django.utils.timezone import localdate
from django.utils.timezone import now
from model_bakery import baker
from pytest_django.asserts import assertRedirects
@@ -57,7 +57,7 @@ def test_invoice_call_view(client: Client, query: dict | None):
@pytest.mark.django_db
def test_invoice_call_form():
Selling.objects.all().delete()
month = localdate() - relativedelta(months=1)
month = now() - relativedelta(months=1)
clubs = baker.make(Club, _quantity=2)
recipe = sale_recipe.extend(date=month, customer=baker.make(Customer, amount=10000))
recipe.make(club=clubs[0], quantity=2, unit_price=200)

View File

@@ -12,7 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from datetime import timedelta
from datetime import datetime, timedelta
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin, UserPassesTestMixin
@@ -23,6 +23,7 @@ from django.forms.models import modelform_factory
from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.timezone import get_current_timezone
from django.utils.translation import gettext as _
from django.views.generic import DetailView, ListView, TemplateView
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
@@ -285,7 +286,13 @@ class CounterStatView(PermissionRequiredMixin, DetailView):
def get_context_data(self, **kwargs):
"""Add stats to the context."""
counter: Counter = self.object
semester_start = get_start_of_semester()
start_date = get_start_of_semester()
semester_start = datetime(
start_date.year,
start_date.month,
start_date.day,
tzinfo=get_current_timezone(),
)
office_hours = counter.get_top_barmen()
kwargs = super().get_context_data(**kwargs)
kwargs.update(

View File

@@ -12,7 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from datetime import datetime
from datetime import datetime, timezone
from urllib.parse import urlencode
from dateutil.relativedelta import relativedelta
@@ -63,19 +63,18 @@ class InvoiceCallView(
"""Add sums to the context."""
kwargs = super().get_context_data(**kwargs)
kwargs["months"] = Selling.objects.datetimes("date", "month", order="DESC")
start_date = self.get_month()
month = self.get_month()
start_date = datetime(month.year, month.month, month.day, tzinfo=timezone.utc)
end_date = start_date + relativedelta(months=1)
kwargs["sum_cb"] = Refilling.objects.filter(
payment_method="CARD",
is_validated=True,
payment_method=Refilling.PaymentMethod.CARD,
date__gte=start_date,
date__lte=end_date,
).aggregate(res=Sum("amount", default=0))["res"]
kwargs["sum_cb"] += (
Selling.objects.filter(
payment_method="CARD",
is_validated=True,
payment_method=Selling.PaymentMethod.CARD,
date__gte=start_date,
date__lte=end_date,
)

View File

@@ -177,7 +177,7 @@ from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("pedagogy", "0003_alter_uv_language"),
("pedagogy", "0003_alter_ue_language"),
]
operations = [
@@ -215,11 +215,12 @@ On modifie donc le modèle :
```python
from django.db import models
from core.models import User
from pedagogy.models import UV
from pedagogy.models import UE
class UserUe(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
ue = models.ForeignKey(UV, on_delete=models.CASCADE)
ue = models.ForeignKey(UE, on_delete=models.CASCADE)
```
On refait la commande `makemigrations` et on obtient :
@@ -237,7 +238,7 @@ class Migration(migrations.Migration):
model_name="userue",
name="ue",
field=models.ForeignKey(
on_delete=models.deletion.CASCADE, to="pedagogy.uv"
on_delete=models.deletion.CASCADE, to="pedagogy.ue"
),
),
]
@@ -280,7 +281,7 @@ python ./manage.py squasmigrations <app> <migration de début (incluse)> <migrat
Par exemple, dans notre cas, ça donnera :
```bash
python ./manage.py squasmigrations pedagogy 0004 0005
python ./manage.py squashmigrations pedagogy 0004 0005
```
La commande vous donnera ceci :
@@ -292,7 +293,7 @@ class Migration(migrations.Migration):
replaces = [("pedagogy", "0004_userue"), ("pedagogy", "0005_alter_userue_ue")]
dependencies = [
("pedagogy", "0003_alter_uv_language"),
("pedagogy", "0003_alter_ue_language"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
@@ -312,7 +313,7 @@ class Migration(migrations.Migration):
(
"ue",
models.ForeignKey(
on_delete=models.deletion.CASCADE, to="pedagogy.uv"
on_delete=models.deletion.CASCADE, to="pedagogy.ue"
),
),
(

View File

@@ -43,12 +43,13 @@ def get_eboutic_products(user: User) -> list[Product]:
products = (
get_eboutic()
.products.filter(product_type__isnull=False)
.filter(archived=False)
.filter(limit_age__lte=user.age)
.annotate(order=F("product_type__order"))
.annotate(category=F("product_type__name"))
.annotate(category_comment=F("product_type__comment"))
.annotate(price=F("selling_price")) # <-- selected price for basket validation
.filter(archived=False, limit_age__lte=user.age)
.annotate(
order=F("product_type__order"),
category=F("product_type__name"),
category_comment=F("product_type__comment"),
price=F("selling_price"), # <-- selected price for basket validation
)
.prefetch_related("buying_groups") # <-- used in `Product.can_be_sold_to`
)
return [p for p in products if p.can_be_sold_to(user)]
@@ -110,7 +111,9 @@ class Basket(models.Model):
)["total"]
)
def generate_sales(self, counter, seller: User, payment_method: str):
def generate_sales(
self, counter, seller: User, payment_method: Selling.PaymentMethod
):
"""Generate a list of sold items corresponding to the items
of this basket WITHOUT saving them NOR deleting the basket.
@@ -251,8 +254,7 @@ class Invoice(models.Model):
customer=customer,
operator=self.user,
amount=i.product_unit_price * i.quantity,
payment_method="CARD",
bank="OTHER",
payment_method=Refilling.PaymentMethod.CARD,
date=self.date,
)
new.save()
@@ -267,8 +269,7 @@ class Invoice(models.Model):
customer=customer,
unit_price=i.product_unit_price,
quantity=i.quantity,
payment_method="CARD",
is_validated=True,
payment_method=Selling.PaymentMethod.CARD,
date=self.date,
)
new.save()

View File

@@ -21,7 +21,7 @@ document.addEventListener("alpine:init", () => {
if (lastPurchaseTime !== null && localStorage.basketTimestamp !== undefined) {
if (
new Date(lastPurchaseTime) >=
new Date(Number.parseInt(localStorage.basketTimestamp))
new Date(Number.parseInt(localStorage.basketTimestamp, 10))
) {
this.basket = [];
}

View File

@@ -18,7 +18,9 @@
#basket {
min-width: 300px;
border-radius: 8px;
box-shadow: rgb(60 64 67 / 30%) 0 1px 3px 0, rgb(60 64 67 / 15%) 0 4px 8px 3px;
box-shadow:
rgb(60 64 67 / 30%) 0 1px 3px 0,
rgb(60 64 67 / 15%) 0 4px 8px 3px;
padding: 10px;
}

View File

@@ -77,6 +77,14 @@
value="{% trans %}Pay with credit card{% endtrans %}"
/>
</form>
{% else %}
<div class="alert alert-yellow">
{% trans trimmed %}
Credit card payments are currently disabled on the eboutic.
You may still refill your account in one of the AE counters.
Please excuse us for the inconvenience.
{% endtrans %}
</div>
{% endif %}
{% if basket.contains_refilling_item %}
<p>{% trans %}AE account payment disabled because your basket contains refilling items.{% endtrans %}</p>

View File

@@ -108,12 +108,22 @@ def test_eboutic_basket_expiry(
client.force_login(customer.user)
for date in sellings:
if sellings:
sale_recipe.make(
customer=customer, counter=eboutic, date=date, is_validated=True
customer=customer,
counter=eboutic,
date=iter(sellings),
_quantity=len(sellings),
_bulk_create=True,
)
if refillings:
refill_recipe.make(
customer=customer,
counter=eboutic,
date=iter(refillings),
_quantity=len(refillings),
_bulk_create=True,
)
for date in refillings:
refill_recipe.make(customer=customer, counter=eboutic, date=date)
assert (
f'x-data="basket({int(expected.timestamp() * 1000) if expected else "null"})"'

View File

@@ -114,13 +114,13 @@ class TestPaymentSith(TestPaymentBase):
"quantity"
)
assert len(sellings) == 2
assert sellings[0].payment_method == "SITH_ACCOUNT"
assert sellings[0].payment_method == Selling.PaymentMethod.SITH_ACCOUNT
assert sellings[0].quantity == 1
assert sellings[0].unit_price == self.snack.selling_price
assert sellings[0].counter.type == "EBOUTIC"
assert sellings[0].product == self.snack
assert sellings[1].payment_method == "SITH_ACCOUNT"
assert sellings[1].payment_method == Selling.PaymentMethod.SITH_ACCOUNT
assert sellings[1].quantity == 2
assert sellings[1].unit_price == self.beer.selling_price
assert sellings[1].counter.type == "EBOUTIC"
@@ -198,13 +198,13 @@ class TestPaymentCard(TestPaymentBase):
"quantity"
)
assert len(sellings) == 2
assert sellings[0].payment_method == "CARD"
assert sellings[0].payment_method == Selling.PaymentMethod.CARD
assert sellings[0].quantity == 1
assert sellings[0].unit_price == self.snack.selling_price
assert sellings[0].counter.type == "EBOUTIC"
assert sellings[0].product == self.snack
assert sellings[1].payment_method == "CARD"
assert sellings[1].payment_method == Selling.PaymentMethod.CARD
assert sellings[1].quantity == 2
assert sellings[1].unit_price == self.beer.selling_price
assert sellings[1].counter.type == "EBOUTIC"

View File

@@ -275,7 +275,9 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View):
return redirect("eboutic:payment_result", "failure")
eboutic = get_eboutic()
sales = basket.generate_sales(eboutic, basket.user, "SITH_ACCOUNT")
sales = basket.generate_sales(
eboutic, basket.user, Selling.PaymentMethod.SITH_ACCOUNT
)
try:
with transaction.atomic():
# Selling.save has some important business logic in it.

View File

@@ -60,8 +60,6 @@ class CandidateForm(forms.ModelForm):
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:
@@ -74,6 +72,7 @@ class VoteForm(forms.Form):
required=False,
widget=forms.RadioSelect(),
empty_label=_("Blank vote"),
blank=True,
)

View File

@@ -14,6 +14,11 @@
{% block content %}
<h3 class="election__title">{{ election.title }}</h3>
{% if not user.has_perm("core.view_user") %}
<div class="alert alert-red">
{% trans %}Candidate pictures won't display for privacy reasons.{% endtrans %}
</div>
{% endif %}
<p class="election__description">{{ election.description }}</p>
<hr>
<section class="election_details">
@@ -117,7 +122,7 @@
{%- if role.max_choice == 1 and show_vote_buttons %}
<div class="radio-btn">
{% set input_id = "blank_vote_" + role.id|string %}
<input id="{{ input_id }}" type="radio" name="{{ role.title }}">
<input id="{{ input_id }}" type="radio" name="{{ role.title }}" value="" checked>
<label for="{{ input_id }}">
<span>{% trans %}Choose blank vote{% endtrans %}</span>
</label>
@@ -185,26 +190,28 @@
</table>
</form>
</section>
<section class="buttons">
{%- if (election.can_candidate(user) and election.is_candidature_active) or (user.can_edit(election) and election.is_vote_editable) %}
<a class="button" href="{{ url('election:candidate', election_id=object.id) }}">{% trans %}Candidate{% endtrans %}</a>
{%- endif %}
{%- if election.is_vote_editable %}
<a class="button" href="{{ url('election:create_list', election_id=object.id) }}">{% trans %}Add a new list{% endtrans %}</a>
{%- endif %}
{%- if user.can_edit(election) %}
{% if election.is_vote_editable %}
<a class="button" href="{{ url('election:create_role', election_id=object.id) }}">{% trans %}Add a new role{% endtrans %}</a>
{% endif %}
<a class="button" href="{{ url('election:update', election_id=object.id) }}">{% trans %}Edit{% endtrans %}</a>
{%- endif %}
{%- if user.is_root %}
<a class="button" href="{{ url('election:delete', election_id=object.id) }}">{% trans %}Delete{% endtrans %}</a>
{%- endif %}
</section>
{%- if show_vote_buttons %}
{% if not user.is_anonymous %}
<section class="buttons">
<button class="button button_send" form="vote-form">{% trans %}Submit the vote !{% endtrans %}</button>
{%- if (election.can_candidate(user) and election.is_candidature_active) or (user.can_edit(election) and election.is_vote_editable) %}
<a class="button" href="{{ url('election:candidate', election_id=object.id) }}">{% trans %}Candidate{% endtrans %}</a>
{%- endif %}
{%- if election.is_vote_editable %}
<a class="button" href="{{ url('election:create_list', election_id=object.id) }}">{% trans %}Add a new list{% endtrans %}</a>
{%- endif %}
{%- if user.can_edit(election) %}
{% if election.is_vote_editable %}
<a class="button" href="{{ url('election:create_role', election_id=object.id) }}">{% trans %}Add a new role{% endtrans %}</a>
{% endif %}
<a class="button" href="{{ url('election:update', election_id=object.id) }}">{% trans %}Edit{% endtrans %}</a>
{%- endif %}
{%- if user.is_root %}
<a class="button" href="{{ url('election:delete', election_id=object.id) }}">{% trans %}Delete{% endtrans %}</a>
{%- endif %}
</section>
{%- endif %}
{%- if show_vote_buttons %}
<section class="buttons">
<button class="button button_send" form="vote-form">{% trans %}Submit the vote !{% endtrans %}</button>
</section>
{%- endif %}
{% endif %}
{% endblock %}

View File

@@ -2,6 +2,7 @@ from typing import TYPE_CHECKING
from cryptography.utils import cached_property
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import (
LoginRequiredMixin,
PermissionRequiredMixin,
@@ -10,8 +11,9 @@ from django.contrib.auth.mixins import (
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.db.models import QuerySet
from django.shortcuts import get_object_or_404
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
@@ -53,7 +55,7 @@ class ElectionListArchivedView(CanViewMixin, ListView):
class ElectionDetailView(CanViewMixin, DetailView):
"""Details an election responsability by responsability."""
"""Details an election responsibility by responsibility."""
model = Election
template_name = "election/election_detail.jinja"
@@ -83,7 +85,7 @@ class ElectionDetailView(CanViewMixin, DetailView):
return super().get(request, *arg, **kwargs)
def get_context_data(self, **kwargs):
"""Add additionnal data to the template."""
"""Add additional data to the template."""
user: User = self.request.user
return super().get_context_data(**kwargs) | {
"election_form": VoteForm(self.object, user),
@@ -101,7 +103,7 @@ class ElectionDetailView(CanViewMixin, DetailView):
class VoteFormView(LoginRequiredMixin, UserPassesTestMixin, FormView):
"""Alows users to vote."""
"""Allows users to vote."""
form_class = VoteForm
template_name = "election/election_detail.jinja"
@@ -111,6 +113,9 @@ class VoteFormView(LoginRequiredMixin, UserPassesTestMixin, FormView):
return get_object_or_404(Election, pk=self.kwargs["election_id"])
def test_func(self):
if not self.election.can_vote(self.request.user):
return False
groups = set(self.election.vote_groups.values_list("id", flat=True))
if (
settings.SITH_GROUP_SUBSCRIBERS_ID in groups
@@ -150,11 +155,17 @@ class VoteFormView(LoginRequiredMixin, UserPassesTestMixin, FormView):
self.vote(data)
return super().form_valid(form)
def form_invalid(self, form):
messages.error(self.request, _("Form is invalid"))
return redirect(
reverse("election:detail", kwargs={"election_id": self.election.id}),
)
def get_success_url(self, **kwargs):
return reverse_lazy("election:detail", kwargs={"election_id": self.election.id})
def get_context_data(self, **kwargs):
"""Add additionnal data to the template."""
"""Add additional data to the template."""
kwargs = super().get_context_data(**kwargs)
kwargs["object"] = self.election
kwargs["election"] = self.election

View File

@@ -1,6 +1,6 @@
import { default as ForceGraph3D } from "3d-force-graph";
import { forceX, forceY, forceZ } from "d3-force-3d";
// biome-ignore lint/style/noNamespaceImport: This is how it should be imported
// biome-ignore lint/performance/noNamespaceImport: This is how it should be imported
import * as Three from "three";
import SpriteText from "three-spritetext";

View File

@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-12 21:44+0100\n"
"POT-Creation-Date: 2026-02-08 16:14+0100\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -218,7 +218,7 @@ msgid "You can not make loops in clubs"
msgstr "Vous ne pouvez pas faire de boucles dans les clubs"
#: club/models.py core/models.py counter/models.py eboutic/models.py
#: election/models.py sas/models.py trombi/models.py
#: election/models.py pedagogy/models.py sas/models.py trombi/models.py
msgid "user"
msgstr "utilisateur"
@@ -470,14 +470,15 @@ msgstr "Méthode de paiement"
#: core/templates/core/file_detail.jinja
#: core/templates/core/file_moderation.jinja
#: core/templates/core/group_detail.jinja core/templates/core/group_list.jinja
#: core/templates/core/macros.jinja core/templates/core/page/prop.jinja
#: core/templates/core/page/prop.jinja
#: core/templates/core/user_account_detail.jinja
#: core/templates/core/user_clubs.jinja core/templates/core/user_edit.jinja
#: core/templates/core/user_godfathers.jinja
#: counter/templates/counter/fragments/create_student_card.jinja
#: counter/templates/counter/last_ops.jinja
#: election/templates/election/election_detail.jinja
#: forum/templates/forum/macros.jinja pedagogy/templates/pedagogy/guide.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja sas/templates/sas/album.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja sas/templates/sas/album.jinja
#: sas/templates/sas/moderation.jinja sas/templates/sas/picture.jinja
#: trombi/templates/trombi/detail.jinja
#: trombi/templates/trombi/edit_profile.jinja
@@ -672,7 +673,7 @@ msgstr "Outils"
#: counter/templates/counter/counter_list.jinja
#: election/templates/election/election_detail.jinja
#: forum/templates/forum/macros.jinja pedagogy/templates/pedagogy/guide.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja sas/templates/sas/album.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja sas/templates/sas/album.jinja
#: trombi/templates/trombi/detail.jinja
#: trombi/templates/trombi/edit_profile.jinja
msgid "Edit"
@@ -1076,9 +1077,9 @@ msgstr "Liens"
msgid "Our services"
msgstr "Nos services"
#: com/templates/com/news_list.jinja
msgid "UV Guide"
msgstr "Guide des UVs"
#: com/templates/com/news_list.jinja pedagogy/templates/pedagogy/guide.jinja
msgid "UE Guide"
msgstr "Guide des UEs"
#: com/templates/com/news_list.jinja
msgid "Timetable"
@@ -1214,7 +1215,7 @@ msgstr "Descendre"
#: com/templates/com/weekmail_preview.jinja
#: core/templates/core/user_account_detail.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: trombi/templates/trombi/comment_moderation.jinja
#: trombi/templates/trombi/export.jinja
msgid "Back"
@@ -2062,7 +2063,7 @@ msgstr "Éditer le groupe"
#: core/templates/core/group_edit.jinja core/templates/core/user_edit.jinja
#: core/templates/core/user_group.jinja
#: pedagogy/templates/pedagogy/uv_edit.jinja
#: pedagogy/templates/pedagogy/ue_edit.jinja
msgid "Update"
msgstr "Mettre à jour"
@@ -2658,8 +2659,8 @@ msgid "Buyings"
msgstr "Achats"
#: core/templates/core/user_stats.jinja
msgid "Product top 10"
msgstr "Top 10 produits"
msgid "Product top 15"
msgstr "Top 15 produits"
#: core/templates/core/user_stats.jinja
msgid "Product"
@@ -2787,8 +2788,8 @@ msgid "Subscription stats"
msgstr "Statistiques de cotisation"
#: core/templates/core/user_tools.jinja pedagogy/templates/pedagogy/guide.jinja
msgid "Create UV"
msgstr "Créer UV"
msgid "Create UE"
msgstr "Créer UE"
#: core/templates/core/user_tools.jinja pedagogy/templates/pedagogy/guide.jinja
#: trombi/templates/trombi/detail.jinja
@@ -2819,8 +2820,8 @@ msgstr "Outils Trombi"
#, python-format
msgid "%(nb_days)d day, %(remainder)s"
msgid_plural "%(nb_days)d days, %(remainder)s"
msgstr[0] ""
msgstr[1] ""
msgstr[0] "%(nb_days)d jour, %(remainder)s"
msgstr[1] "%(nb_days)d jours, %(remainder)s"
#: core/views/files.py
msgid "Add a new folder"
@@ -2880,10 +2881,6 @@ msgstr "Fillot / Fillote"
msgid "Select user"
msgstr "Choisir un utilisateur"
#: core/views/forms.py
msgid "This user does not exist"
msgstr "Cet utilisateur n'existe pas"
#: core/views/forms.py
msgid "You cannot be related to yourself"
msgstr "Vous ne pouvez pas être relié à vous-même"
@@ -2928,18 +2925,6 @@ msgstr "Photos"
msgid "Account"
msgstr "Compte"
#: counter/apps.py sith/settings.py
msgid "Check"
msgstr "Chèque"
#: counter/apps.py sith/settings.py
msgid "Cash"
msgstr "Espèces"
#: counter/apps.py counter/models.py sith/settings.py
msgid "Credit card"
msgstr "Carte bancaire"
#: counter/apps.py counter/models.py
msgid "counter"
msgstr "comptoir"
@@ -3152,22 +3137,30 @@ msgstr "vendeurs"
msgid "token"
msgstr "jeton"
#: counter/models.py sith/settings.py
msgid "Credit card"
msgstr "Carte bancaire"
#: counter/models.py sith/settings.py
msgid "Cash"
msgstr "Espèces"
#: counter/models.py sith/settings.py
msgid "Check"
msgstr "Chèque"
#: counter/models.py subscription/models.py
msgid "payment method"
msgstr "méthode de paiement"
#: counter/models.py
msgid "bank"
msgstr "banque"
#: counter/models.py
msgid "is validated"
msgstr "est validé"
#: counter/models.py
msgid "refilling"
msgstr "rechargement"
#: counter/models.py
msgid "Sith account"
msgstr "Compte utilisateur"
#: counter/models.py eboutic/models.py
msgid "unit price"
msgstr "prix unitaire"
@@ -3176,10 +3169,6 @@ msgstr "prix unitaire"
msgid "quantity"
msgstr "quantité"
#: counter/models.py
msgid "Sith account"
msgstr "Compte utilisateur"
#: counter/models.py
msgid "selling"
msgstr "vente"
@@ -3332,6 +3321,10 @@ msgid ""
"%(value)s” value has the correct format (YYYY-MM) but it is an invalid date."
msgstr "La valeur « %(value)s » a le bon format, mais est une date invalide."
#: counter/models.py
msgid "is validated"
msgstr "est validé"
#: counter/models.py
msgid "invoice date"
msgstr "date de la facture"
@@ -3391,7 +3384,7 @@ msgstr "Coffre vidé"
#: counter/templates/counter/cash_summary_list.jinja counter/views/cash.py
#: pedagogy/templates/pedagogy/moderation.jinja
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: trombi/templates/trombi/comment.jinja
#: trombi/templates/trombi/user_tools.jinja
msgid "Comment"
@@ -3951,6 +3944,16 @@ msgstr "Solde restant : "
msgid "Pay with credit card"
msgstr "Payer avec une carte bancaire"
#: eboutic/templates/eboutic/eboutic_checkout.jinja
msgid ""
"Credit card payments are currently disabled on the eboutic. You may still "
"refill your account in one of the AE counters. Please excuse us for the "
"inconvenience."
msgstr ""
"Les paiements par carte bancaire sont actuellement désactivés sur l'eboutic. "
"Vous pouvez cependant toujours recharger votre compte dans un des lieux de vie de l'AE. "
"Veuillez nous excuser pour le désagrément."
#: eboutic/templates/eboutic/eboutic_checkout.jinja
msgid ""
"AE account payment disabled because your basket contains refilling items."
@@ -4115,6 +4118,12 @@ msgstr "Candidater"
msgid "Candidature are closed for this election"
msgstr "Les candidatures sont fermées pour cette élection"
#: election/templates/election/election_detail.jinja
msgid "Candidate pictures won't display for privacy reasons."
msgstr ""
"La photo du candidat ne s'affiche pas pour "
"des raisons de respect de la vie privée."
#: election/templates/election/election_detail.jinja
msgid "Polls close "
msgstr "Votes fermés"
@@ -4190,6 +4199,10 @@ msgstr "au"
msgid "Polls open from"
msgstr "Votes ouverts du"
#: election/views.py
msgid "Form is invalid"
msgstr "Formulaire invalide"
#: forum/models.py
msgid "is a category"
msgstr "est une catégorie"
@@ -4388,6 +4401,14 @@ msgstr "Galaxie de %(user_name)s"
msgid "This citizen has not yet joined the galaxy"
msgstr "Ce citoyen n'a pas encore rejoint la galaxie"
#: matmat/forms.py
msgid "Last/First name or nickname"
msgstr "Nom de famille, prénom ou surnom"
#: matmat/forms.py
msgid "Empty search"
msgstr "Recherche vide"
#: matmat/templates/matmat/search_form.jinja
msgid "Search user"
msgstr "Rechercher un utilisateur"
@@ -4396,29 +4417,13 @@ msgstr "Rechercher un utilisateur"
msgid "Results"
msgstr "Résultats"
#: matmat/templates/matmat/search_form.jinja
msgid "Search by profile"
msgstr "Recherche par profil"
#: matmat/templates/matmat/search_form.jinja
msgid "Inverted search"
msgstr "Recherche inversée"
#: matmat/templates/matmat/search_form.jinja
msgid "Quick search"
msgstr "Recherche rapide"
#: matmat/views.py
msgid "Last/First name or nickname"
msgstr "Nom de famille, prénom ou surnom"
#: pedagogy/forms.py
msgid "Do not vote"
msgstr "Ne pas voter"
#: pedagogy/forms.py
msgid "This user has already commented on this UV"
msgstr "Cet utilisateur a déjà commenté cette UV"
msgid "This user has already commented on this UE"
msgstr "Cet utilisateur a déjà commenté cette UE"
#: pedagogy/forms.py
msgid "Accepted reports"
@@ -4430,10 +4435,10 @@ msgstr "Signalements refusés"
#: pedagogy/models.py
msgid ""
"The code of an UV must only contains uppercase characters without accent and "
"The code of an UE must only contains uppercase characters without accent and "
"numbers"
msgstr ""
"Le code d'une UV doit seulement contenir des caractères majuscule sans "
"Le code d'une UE doit seulement contenir des caractères majuscule sans "
"accents et nombres"
#: pedagogy/models.py
@@ -4441,8 +4446,8 @@ msgid "credit type"
msgstr "type de crédit"
#: pedagogy/models.py
msgid "uv manager"
msgstr "gestionnaire d'uv"
msgid "ue manager"
msgstr "gestionnaire d'ue"
#: pedagogy/models.py
msgid "language"
@@ -4493,7 +4498,7 @@ msgid "hours TE"
msgstr "heures TE"
#: pedagogy/models.py
msgid "uv"
msgid "ue"
msgstr "UE"
#: pedagogy/models.py
@@ -4532,10 +4537,6 @@ msgstr "signaler"
msgid "reporter"
msgstr "signalant"
#: pedagogy/templates/pedagogy/guide.jinja
msgid "UE Guide"
msgstr "Guide des UEs"
#: pedagogy/templates/pedagogy/guide.jinja
msgid "A guide of courses available at UTBM."
msgstr "Un guide de tous les cours disponibles à l'UTBM."
@@ -4552,7 +4553,7 @@ msgstr "%(credit_type)s"
#: pedagogy/templates/pedagogy/guide.jinja
#: pedagogy/templates/pedagogy/moderation.jinja
msgid "UV"
msgid "UE"
msgstr "UE"
#: pedagogy/templates/pedagogy/guide.jinja
@@ -4564,16 +4565,16 @@ msgid "Credit type"
msgstr "Type de crédit"
#: pedagogy/templates/pedagogy/guide.jinja
msgid "closed uv"
msgstr "uv fermée"
msgid "closed ue"
msgstr "ue fermée"
#: pedagogy/templates/pedagogy/macros.jinja
msgid " not rated "
msgstr "non noté"
#: pedagogy/templates/pedagogy/moderation.jinja
msgid "UV comment moderation"
msgstr "Modération des commentaires d'UV"
msgid "UE comment moderation"
msgstr "Modération des commentaires d'UE"
#: pedagogy/templates/pedagogy/moderation.jinja
#: rootplace/templates/rootplace/userban.jinja sas/models.py
@@ -4588,96 +4589,96 @@ msgstr "Supprimer commentaire"
msgid "Delete report"
msgstr "Supprimer signalement"
#: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "UV Details"
msgstr "Détails d'UV"
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "UE Details"
msgstr "Détails d'UE"
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "CM: "
msgstr "CM : "
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "TD: "
msgstr "TD : "
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "TP: "
msgstr "TP : "
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "TE: "
msgstr "TE : "
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "THE: "
msgstr "THE : "
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Global grade"
msgstr "Note globale"
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Utility"
msgstr "Utilité"
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Interest"
msgstr "Intérêt"
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Teaching"
msgstr "Enseignement"
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Work load"
msgstr "Charge de travail"
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Objectives"
msgstr "Objectifs"
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Program"
msgstr "Programme"
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Earned skills"
msgstr "Compétences acquises"
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Key concepts"
msgstr "Concepts clefs"
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "UE manager: "
msgstr "Gestionnaire d'UE : "
#: pedagogy/templates/pedagogy/uv_detail.jinja pedagogy/tests/tests.py
#: pedagogy/templates/pedagogy/ue_detail.jinja pedagogy/tests/tests.py
msgid ""
"You already posted a comment on this UV. If you want to comment again, "
"You already posted a comment on this UE. If you want to comment again, "
"please modify or delete your previous comment."
msgstr ""
"Vous avez déjà commenté cette UV. Si vous voulez de nouveau commenter, "
"Vous avez déjà commenté cette UE. Si vous voulez de nouveau commenter, "
"veuillez modifier ou supprimer votre commentaire précédent."
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Leave comment"
msgstr "Laisser un commentaire"
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
#: trombi/templates/trombi/export.jinja
msgid "Comments"
msgstr "Commentaires"
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "This comment has been reported"
msgstr "Ce commentaire a été signalé"
#: pedagogy/templates/pedagogy/uv_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja
msgid "Report this comment"
msgstr "Signaler ce commentaire"
#: pedagogy/templates/pedagogy/uv_edit.jinja
#: pedagogy/templates/pedagogy/ue_edit.jinja
msgid "Edit UE"
msgstr "Éditer l'UE"

54
matmat/forms.py Normal file
View File

@@ -0,0 +1,54 @@
#
# Copyright 2025
# - Maréchal <thomas.girod@utbm.fr>
#
# 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 Software Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from typing import Any
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from core.models import User
from core.views.forms import SelectDate
class SearchForm(forms.ModelForm):
class Meta:
model = User
fields = ["promo", "role", "department", "semester", "date_of_birth"]
widgets = {"date_of_birth": SelectDate}
name = forms.CharField(
label=_("Last/First name or nickname"), min_length=1, max_length=255
)
field_order = ["name", "promo", "role", "department", "semester", "date_of_birth"]
def __init__(self, *args, initial: dict[str, Any], **kwargs):
super().__init__(*args, initial=initial, **kwargs)
for key in self.fields:
self.fields[key].required = False
if key not in initial:
self.fields[key].initial = None
def clean(self):
data = self.cleaned_data
if all(data[key] in self.fields[key].empty_values for key in self.fields):
raise ValidationError(_("Empty search"))

View File

@@ -1,12 +1,18 @@
{% extends "core/base.jinja" %}
{% from "core/macros.jinja" import user_mini_profile, paginate_jinja %}
{% if is_fragment %}
{% extends "core/base_fragment.jinja" %}
{% else %}
{% extends "core/base.jinja" %}
{% endif %}
{% from "core/macros.jinja" import user_mini_profile, paginate_htmx with context %}
{% block title %}
{% trans %}Search user{% endtrans %}
{% endblock %}
{% block content %}
{% if result_exists %}
{% if paginator.count > 0 %}
<h2>{% trans %}Results{% endtrans %}</h2>
<div class="matmat_results">
{% for user in object_list %}
@@ -19,47 +25,24 @@
{% endfor %}
</div>
{% if page_obj.has_other_pages() %}
{{ paginate_jinja(page_obj, paginator) }}
{{ paginate_htmx(page_obj, paginator) }}
{% endif %}
<hr>
{% endif %}
<h2>{% trans %}Search user{% endtrans %}</h2>
<h3>{% trans %}Search by profile{% endtrans %}</h3>
<form action="{{ url('matmat:search') }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
{% for field in form %}
{% if field.name not in ('phone', 'quick') %}
<p>
{{ field.errors }}
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
<form action="{{ url('matmat:search') }}" method="get">
{{ form.non_field_errors() }}
<fieldset class="fields-centered">
{% for field in form %}
<div>
{{ field.label_tag() }}
{{ field }}
</p>
{% endif %}
{% endfor %}
<p><input type="submit" value="{% trans %}Search{% endtrans %}" /></p>
</form>
<h3>{% trans %}Inverted search{% endtrans %}</h3>
<form action="{{ url('matmat:search_reverse') }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<p>
{{ form.phone.errors }}
<label for="{{ form.phone.id_for_label }}">{{ form.phone.label }}</label>
{{ form.phone }}
<p><input type="submit" value="{% trans %}Search{% endtrans %}" /></p>
</p>
</form>
<h3>{% trans %}Quick search{% endtrans %}</h3>
<form action="{{ url('matmat:search_quick') }}" method="post">
{% csrf_token %}
<p>
{{ form.quick.errors }}
<label for="{{ form.quick.id_for_label }}">{{ form.quick.label }}</label>
{{ form.quick }}
<p><input type="submit" value="{% trans %}Search{% endtrans %}" /></p>
</p>
{{ field.errors }}
</div>
{% endfor %}
</fieldset>
<div class="fields-centered">
<input class="btn btn-blue" type="submit" value="{% trans %}Search{% endtrans %}" />
</div>
</form>
{% endblock %}
{% block script %}
{{ super() }}
{% endblock %}

View File

@@ -1 +1,59 @@
# Create your tests here.
from django.core.management import call_command
from django.test import TestCase
from django.urls import reverse
from model_bakery import baker
from com.models import News
from core.baker_recipes import subscriber_user
from core.models import User
class TestMatmatronch(TestCase):
@classmethod
def setUpTestData(cls):
News.objects.all().delete()
User.objects.all().delete()
users = [
baker.prepare(User, promo=17),
baker.prepare(User, promo=17),
baker.prepare(User, promo=17, department="INFO"),
baker.prepare(User, promo=18, department="INFO"),
]
cls.users = User.objects.bulk_create(users)
call_command("update_index", "core", "--remove")
def test_search(self):
self.client.force_login(subscriber_user.make())
response = self.client.get(reverse("matmat:search"))
assert response.status_code == 200
response = self.client.get(
reverse("matmat:search", query={"promo": 17, "department": "INFO"})
)
assert response.status_code == 200
assert list(response.context_data["object_list"]) == [self.users[2]]
def test_empty_search(self):
self.client.force_login(subscriber_user.make())
response = self.client.get(reverse("matmat:search"))
assert response.status_code == 200
assert list(response.context_data["object_list"]) == []
assert not response.context_data["form"].is_valid()
response = self.client.get(
reverse(
"matmat:search",
query={
"promo": "",
"role": "",
"department": "",
"semester": "",
"date_of_birth": "",
},
)
)
assert response.status_code == 200
assert list(response.context_data["object_list"]) == []
assert not response.context_data["form"].is_valid()
assert "Recherche vide" in response.context_data["form"].non_field_errors()

View File

@@ -23,16 +23,8 @@
from django.urls import path
from matmat.views import (
SearchClearFormView,
SearchNormalFormView,
SearchQuickFormView,
SearchReverseFormView,
)
from matmat.views import MatmatronchView
urlpatterns = [
path("", SearchNormalFormView.as_view(), name="search"),
path("reverse/", SearchReverseFormView.as_view(), name="search_reverse"),
path("quick/", SearchQuickFormView.as_view(), name="search_quick"),
path("clear/", SearchClearFormView.as_view(), name="search_clear"),
path("", MatmatronchView.as_view(), name="search"),
]

View File

@@ -16,192 +16,49 @@
# 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
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA.
#
#
from ast import literal_eval
from enum import Enum
from django import forms
from django.http.response import HttpResponseRedirect
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import ListView, View
from django.views.generic.detail import SingleObjectMixin
from django.views.generic.edit import FormView
from phonenumber_field.widgets import RegionalPhoneNumberWidget
from django.db.models import F
from django.views.generic import ListView
from django.views.generic.edit import FormMixin
from core.auth.mixins import FormerSubscriberMixin
from core.models import User
from core.views import search_user
from core.views.forms import SelectDate
# Enum to select search type
from core.models import User, UserQuerySet
from core.schemas import UserFilterSchema
from core.views.mixins import AllowFragment
from matmat.forms import SearchForm
class SearchType(Enum):
NORMAL = 1
REVERSE = 2
QUICK = 3
# Custom form
class SearchForm(forms.ModelForm):
class Meta:
model = User
fields = [
"first_name",
"last_name",
"nick_name",
"role",
"department",
"semester",
"promo",
"date_of_birth",
"phone",
]
widgets = {
"date_of_birth": SelectDate,
"phone": RegionalPhoneNumberWidget,
}
quick = forms.CharField(label=_("Last/First name or nickname"), max_length=255)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for key in self.fields:
self.fields[key].required = False
@property
def cleaned_data_json(self):
data = self.cleaned_data
for key, val in data.items():
if key in ("date_of_birth", "phone") and val is not None:
data[key] = str(val)
return data
# Views
class SearchFormListView(FormerSubscriberMixin, SingleObjectMixin, ListView):
class MatmatronchView(AllowFragment, FormerSubscriberMixin, FormMixin, ListView):
model = User
ordering = ["-id"]
paginate_by = 12
paginate_by = 20
template_name = "matmat/search_form.jinja"
def dispatch(self, request, *args, **kwargs):
self.form_class = kwargs["form"]
self.search_type = kwargs["search_type"]
self.session = request.session
self.last_search = self.session.get("matmat_search_result", str([]))
self.last_search = literal_eval(self.last_search)
self.valid_form = kwargs.get("valid_form")
self.init_query = self.model.objects
self.can_see_hidden = True
if not (request.user.is_board_member or request.user.is_root):
self.can_see_hidden = False
self.init_query = self.init_query.filter(is_viewable=True)
return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
return self.get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
self.object = None
kwargs = super().get_context_data(**kwargs)
kwargs["form"] = self.form_class
kwargs["result_exists"] = self.result_exists
return kwargs
def get_queryset(self):
q = self.init_query
if self.valid_form is not None:
if self.search_type == SearchType.REVERSE:
q = q.filter(phone=self.valid_form["phone"]).all()
elif self.search_type == SearchType.QUICK:
if self.valid_form["quick"].strip():
q = search_user(self.valid_form["quick"])
else:
q = []
if not self.can_see_hidden and len(q) > 0:
q = [user for user in q if user.is_viewable]
else:
search_dict = {}
for key, value in self.valid_form.items():
if key not in ("phone", "quick") and not (
value == "" or value is None
):
search_dict[key + "__icontains"] = value
q = q.filter(**search_dict).all()
else:
q = q.filter(pk__in=self.last_search).all()
if isinstance(q, list):
self.result_exists = len(q) > 0
else:
self.result_exists = q.exists()
self.last_search = []
for user in q:
self.last_search.append(user.id)
self.session["matmat_search_result"] = str(self.last_search)
return q
class SearchFormView(FormerSubscriberMixin, FormView):
"""Allows users to search inside the user list."""
form_class = SearchForm
def dispatch(self, request, *args, **kwargs):
self.session = request.session
self.init_query = User.objects
kwargs["form"] = self.get_form()
kwargs["search_type"] = self.search_type
return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
view = SearchFormListView.as_view()
return view(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
form = self.get_form()
view = SearchFormListView.as_view()
if form.is_valid():
kwargs["valid_form"] = form.clean()
request.session["matmat_search_form"] = form.cleaned_data_json
return view(request, *args, **kwargs)
self.form = self.get_form()
return super().get(request, *args, **kwargs)
def get_initial(self):
init = self.session.get("matmat_search_form", {})
if not init:
init["department"] = ""
return init
return self.request.GET
def get_form_kwargs(self):
res = super().get_form_kwargs()
if self.request.GET:
res["data"] = self.request.GET
return res
class SearchNormalFormView(SearchFormView):
search_type = SearchType.NORMAL
def get_queryset(self) -> UserQuerySet:
if not self.form.is_valid():
return User.objects.none()
data = self.form.cleaned_data
data["search"] = data.get("name")
filters = UserFilterSchema(**{key: val for key, val in data.items() if val})
qs = User.objects.viewable_by(self.request.user).select_related("profile_pict")
return filters.filter(qs).order_by(F("last_login").desc(nulls_last=True))
class SearchReverseFormView(SearchFormView):
search_type = SearchType.REVERSE
class SearchQuickFormView(SearchFormView):
search_type = SearchType.QUICK
class SearchClearFormView(FormerSubscriberMixin, View):
"""Clear SearchFormView and redirect to it."""
def dispatch(self, request, *args, **kwargs):
super().dispatch(request, *args, **kwargs)
if "matmat_search_form" in request.session:
request.session.pop("matmat_search_form")
if "matmat_search_result" in request.session:
request.session.pop("matmat_search_result")
return HttpResponseRedirect(reverse("matmat:search"))
def get_context_data(self, **kwargs):
return super().get_context_data(form=self.form, **kwargs)

View File

@@ -12,7 +12,7 @@ export default defineConfig({
{
name: "@hey-api/client-fetch",
baseUrl: false,
runtimeConfigPath: "./openapi-csrf.ts",
runtimeConfigPath: resolve(__dirname, "./openapi-csrf.ts"),
exportFromIndex: true,
},
],

2210
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,9 @@
"keywords": [],
"author": "",
"license": "GPL-3.0-only",
"sideEffects": [".css"],
"sideEffects": [
".css"
],
"imports": {
"#openapi": "./staticfiles/generated/openapi/client/index.ts",
"#core:*": "./core/static/bundled/*",
@@ -24,33 +26,33 @@
"#com:*": "./com/static/bundled/*"
},
"devDependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-env": "^7.28.5",
"@biomejs/biome": "^1.9.4",
"@hey-api/openapi-ts": "^0.73.0",
"@babel/core": "^7.29.0",
"@babel/preset-env": "^7.29.0",
"@biomejs/biome": "^2.3.14",
"@hey-api/openapi-ts": "^0.92.4",
"@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.11",
"@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6",
"typescript": "^5.9.3",
"vite": "^6.4.1",
"vite": "^7.3.1",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.1.4"
"vite-plugin-static-copy": "^3.2.0"
},
"dependencies": {
"@alpinejs/sort": "^3.15.1",
"@alpinejs/sort": "^3.15.8",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.7.4",
"@fortawesome/fontawesome-free": "^6.7.2",
"@fullcalendar/core": "^6.1.19",
"@fullcalendar/daygrid": "^6.1.19",
"@fullcalendar/icalendar": "^6.1.19",
"@fullcalendar/list": "^6.1.19",
"@sentry/browser": "^9.46.0",
"@zip.js/zip.js": "^2.8.9",
"3d-force-graph": "^1.79.0",
"alpinejs": "^3.15.1",
"@floating-ui/dom": "^1.7.5",
"@fortawesome/fontawesome-free": "^7.2.0",
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/icalendar": "^6.1.20",
"@fullcalendar/list": "^6.1.20",
"@sentry/browser": "^10.38.0",
"@zip.js/zip.js": "^2.8.20",
"3d-force-graph": "^1.79.1",
"alpinejs": "^3.15.8",
"chart.js": "^4.5.1",
"country-flag-emoji-polyfill": "^0.1.8",
"cytoscape": "^3.33.1",
@@ -58,14 +60,14 @@
"cytoscape-klay": "^3.1.4",
"d3-force-3d": "^3.0.6",
"easymde": "^2.20.0",
"glob": "^11.0.3",
"glob": "^13.0.2",
"html2canvas": "^1.4.1",
"htmx.org": "^2.0.8",
"js-cookie": "^3.0.5",
"lit-html": "^3.3.1",
"lit-html": "^3.3.2",
"native-file-system-adapter": "^3.0.1",
"three": "^0.177.0",
"three": "^0.182.0",
"three-spritetext": "^1.10.0",
"tom-select": "^2.4.3"
"tom-select": "^2.5.1"
}
}

View File

@@ -23,35 +23,35 @@
from django.contrib import admin
from haystack.admin import SearchModelAdmin
from pedagogy.models import UV, UVComment, UVCommentReport
from pedagogy.models import UE, UEComment, UECommentReport
@admin.register(UV)
class UVAdmin(admin.ModelAdmin):
@admin.register(UE)
class UEAdmin(admin.ModelAdmin):
list_display = ("code", "title", "credit_type", "credits", "department")
search_fields = ("code", "title", "department")
autocomplete_fields = ("author",)
@admin.register(UVComment)
class UVCommentAdmin(admin.ModelAdmin):
list_display = ("author", "uv", "grade_global", "publish_date")
@admin.register(UEComment)
class UECommentAdmin(admin.ModelAdmin):
list_display = ("author", "ue", "grade_global", "publish_date")
search_fields = (
"author__username",
"author__first_name",
"author__last_name",
"uv__code",
"ue__code",
)
autocomplete_fields = ("author",)
@admin.register(UVCommentReport)
class UVCommentReportAdmin(SearchModelAdmin):
list_display = ("reporter", "uv")
@admin.register(UECommentReport)
class UECommentReportAdmin(SearchModelAdmin):
list_display = ("reporter", "ue")
search_fields = (
"reporter__username",
"reporter__first_name",
"reporter__last_name",
"comment__uv__code",
"comment__ue__code",
)
autocomplete_fields = ("reporter",)

View File

@@ -10,23 +10,23 @@ from ninja_extra.pagination import PageNumberPaginationExtra, PaginatedResponseS
from api.auth import ApiKeyAuth
from api.permissions import HasPerm
from pedagogy.models import UV
from pedagogy.schemas import SimpleUvSchema, UvFilterSchema, UvSchema
from pedagogy.models import UE
from pedagogy.schemas import SimpleUeSchema, UeFilterSchema, UeSchema
from pedagogy.utbm_api import UtbmApiClient
@api_controller("/uv")
class UvController(ControllerBase):
@api_controller("/ue")
class UeController(ControllerBase):
@route.get(
"/{code}",
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[
# this route will almost always be called in the context
# of a UV creation/edition
HasPerm(["pedagogy.add_uv", "pedagogy.change_uv"], op=operator.or_)
# of a UE creation/edition
HasPerm(["pedagogy.add_ue", "pedagogy.change_ue"], op=operator.or_)
],
url_name="fetch_uv_from_utbm",
response=UvSchema,
url_name="fetch_ue_from_utbm",
response=UeSchema,
)
def fetch_from_utbm_api(
self,
@@ -34,20 +34,20 @@ class UvController(ControllerBase):
lang: Query[str] = "fr",
year: Query[Annotated[int, Ge(2010)] | None] = None,
):
"""Fetch UV data from the UTBM API and returns it after some parsing."""
"""Fetch UE data from the UTBM API and returns it after some parsing."""
with UtbmApiClient() as client:
res = client.find_uv(lang, code, year)
res = client.find_ue(lang, code, year)
if res is None:
raise NotFound
return res
@route.get(
"",
response=PaginatedResponseSchema[SimpleUvSchema],
url_name="fetch_uvs",
response=PaginatedResponseSchema[SimpleUeSchema],
url_name="fetch_ues",
auth=[ApiKeyAuth(), SessionAuth()],
permissions=[HasPerm("pedagogy.view_uv")],
permissions=[HasPerm("pedagogy.view_ue")],
)
@paginate(PageNumberPaginationExtra, page_size=100)
def fetch_uv_list(self, search: Query[UvFilterSchema]):
return search.filter(UV.objects.order_by("code").values())
def fetch_ue_list(self, search: Query[UeFilterSchema]):
return search.filter(UE.objects.order_by("code"))

View File

@@ -26,14 +26,14 @@ from django.utils.translation import gettext_lazy as _
from core.models import User
from core.views.widgets.markdown import MarkdownInput
from pedagogy.models import UV, UVComment, UVCommentReport
from pedagogy.models import UE, UEComment, UECommentReport
class UVForm(forms.ModelForm):
"""Form handeling creation and edit of an UV."""
class UEForm(forms.ModelForm):
"""Form handeling creation and edit of an UE."""
class Meta:
model = UV
model = UE
fields = (
"code",
"author",
@@ -82,14 +82,14 @@ class StarList(forms.NumberInput):
return context
class UVCommentForm(forms.ModelForm):
"""Form handeling creation and edit of an UVComment."""
class UECommentForm(forms.ModelForm):
"""Form handeling creation and edit of an UEComment."""
class Meta:
model = UVComment
model = UEComment
fields = (
"author",
"uv",
"ue",
"grade_global",
"grade_utility",
"grade_interest",
@@ -100,7 +100,7 @@ class UVCommentForm(forms.ModelForm):
widgets = {
"comment": MarkdownInput,
"author": forms.HiddenInput,
"uv": forms.HiddenInput,
"ue": forms.HiddenInput,
"grade_global": StarList(5),
"grade_utility": StarList(5),
"grade_interest": StarList(5),
@@ -108,35 +108,35 @@ class UVCommentForm(forms.ModelForm):
"grade_work_load": StarList(5),
}
def __init__(self, author_id, uv_id, is_creation, *args, **kwargs):
def __init__(self, author_id, ue_id, is_creation, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["author"].queryset = User.objects.filter(id=author_id).all()
self.fields["author"].initial = author_id
self.fields["uv"].queryset = UV.objects.filter(id=uv_id).all()
self.fields["uv"].initial = uv_id
self.fields["ue"].queryset = UE.objects.filter(id=ue_id).all()
self.fields["ue"].initial = ue_id
self.is_creation = is_creation
def clean(self):
self.cleaned_data = super().clean()
uv = self.cleaned_data.get("uv")
ue = self.cleaned_data.get("ue")
author = self.cleaned_data.get("author")
if self.is_creation and uv and author and uv.has_user_already_commented(author):
if self.is_creation and ue and author and ue.has_user_already_commented(author):
self.add_error(
None,
forms.ValidationError(
_("This user has already commented on this UV"), code="invalid"
_("This user has already commented on this UE"), code="invalid"
),
)
return self.cleaned_data
class UVCommentReportForm(forms.ModelForm):
"""Form handeling creation and edit of an UVReport."""
class UECommentReportForm(forms.ModelForm):
"""Form handeling creation and edit of an UEReport."""
class Meta:
model = UVCommentReport
model = UECommentReport
fields = ("comment", "reporter", "reason")
widgets = {
"comment": forms.HiddenInput,
@@ -148,22 +148,22 @@ class UVCommentReportForm(forms.ModelForm):
super().__init__(*args, **kwargs)
self.fields["reporter"].queryset = User.objects.filter(id=reporter_id).all()
self.fields["reporter"].initial = reporter_id
self.fields["comment"].queryset = UVComment.objects.filter(id=comment_id).all()
self.fields["comment"].queryset = UEComment.objects.filter(id=comment_id).all()
self.fields["comment"].initial = comment_id
class UVCommentModerationForm(forms.Form):
class UECommentModerationForm(forms.Form):
"""Form handeling bulk comment deletion."""
accepted_reports = forms.ModelMultipleChoiceField(
UVCommentReport.objects.all(),
UECommentReport.objects.all(),
label=_("Accepted reports"),
widget=forms.CheckboxSelectMultiple,
required=False,
)
denied_reports = forms.ModelMultipleChoiceField(
UVCommentReport.objects.all(),
UECommentReport.objects.all(),
label=_("Denied reports"),
widget=forms.CheckboxSelectMultiple,
required=False,

Some files were not shown because too many files have changed in this diff Show More