13 Commits

Author SHA1 Message Date
imperosol
21dc95593b add feedback when moving reservation slot 2025-11-23 02:03:33 +01:00
imperosol
cf00e8e2d7 test: room and slots creation/edition 2025-11-23 02:03:33 +01:00
imperosol
678e4e746b fix: rebase issues 2025-11-23 02:03:31 +01:00
imperosol
f70476b36e test: ReservationForm 2025-11-23 02:03:21 +01:00
imperosol
81a6f97d70 add translations 2025-11-23 02:03:21 +01:00
imperosol
ff0cdf180b Room reservation form 2025-11-23 02:03:19 +01:00
imperosol
e3bed1c9dd Room reservations planning 2025-11-23 02:03:00 +01:00
imperosol
37765e00f3 room management views 2025-11-23 01:56:56 +01:00
imperosol
38919390c8 fix: FutureDateTime form field 2025-11-23 01:56:54 +01:00
imperosol
714f3d4f3d reservable rooms API 2025-11-23 01:56:47 +01:00
imperosol
ebb7c1147d generate test data for the reservations 2025-11-23 01:56:47 +01:00
Thomas Girod
4f68ec93ea create reservation models 2025-11-23 01:56:47 +01:00
Sli
5523646559 Adapt calendar to new tooltip library 2025-11-23 01:56:47 +01:00
115 changed files with 3700 additions and 2538 deletions

View File

@@ -1,16 +1,18 @@
from typing import Annotated from typing import Annotated
from annotated_types import MinLen
from django.db.models import Q from django.db.models import Q
from ninja import FilterLookup, FilterSchema, ModelSchema from ninja import Field, FilterSchema, ModelSchema
from club.models import Club, Membership from club.models import Club, Membership
from core.schemas import NonEmptyStr, SimpleUserSchema from core.schemas import SimpleUserSchema
class ClubSearchFilterSchema(FilterSchema): class ClubSearchFilterSchema(FilterSchema):
search: Annotated[NonEmptyStr | None, FilterLookup("name__icontains")] = None search: Annotated[str, MinLen(1)] | None = Field(None, q="name__icontains")
is_active: bool | None = None is_active: bool | None = None
parent_id: int | None = None parent_id: int | None = None
parent_name: str | None = Field(None, q="parent__name__icontains")
exclude_ids: set[int] | None = None exclude_ids: set[int] | None = None
def filter_exclude_ids(self, value: set[int] | None): def filter_exclude_ids(self, value: set[int] | None):

View File

@@ -1,25 +1,63 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from "reservation/macros.jinja" import room_detail %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
{% endblock %}
{% block content %} {% block content %}
<h3>{% trans %}Club tools{% endtrans %}</h3> <h3>{% trans %}Club tools{% endtrans %} ({{ club.name }})</h3>
<div> <div>
<h4>{% trans %}Communication:{% endtrans %}</h4> <h4>{% trans %}Communication:{% endtrans %}</h4>
<ul> <ul>
<li> <a href="{{ url('com:news_new') }}?club={{ object.id }}">{% trans %}Create a news{% endtrans %}</a></li> <li>
<li> <a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">{% trans %}Post in the Weekmail{% endtrans %}</a></li> <a href="{{ url('com:news_new') }}?club={{ object.id }}">
{% trans %}Create a news{% endtrans %}
</a>
</li>
<li>
<a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">
{% trans %}Post in the Weekmail{% endtrans %}
</a>
</li>
{% if object.trombi %} {% if object.trombi %}
<li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li> <li>
<a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">
{% trans %}Edit Trombi{% endtrans %}</a>
</li>
{% else %} {% else %}
<li> <a href="{{ url('trombi:create', club_id=object.id) }}">{% trans %}New Trombi{% endtrans %}</a></li> <li><a href="{{ url('trombi:create', club_id=object.id) }}">{% trans %}New Trombi{% endtrans %}</a></li>
<li> <a href="{{ url('club:poster_list', club_id=object.id) }}">{% trans %}Posters{% endtrans %}</a></li> <li><a href="{{ url('club:poster_list', club_id=object.id) }}">{% trans %}Posters{% endtrans %}</a></li>
{% endif %} {% endif %}
</ul> </ul>
<h4>{% trans %}Reservable rooms{% endtrans %}</h4>
<a
href="{{ url("reservation:room_create") }}?club={{ object.id }}"
class="btn btn-blue"
>
{% trans %}Add a room{% endtrans %}
</a>
{%- if reservable_rooms|length > 0 -%}
<ul class="card-group">
{%- for room in reservable_rooms -%}
{{ room_detail(
room,
can_edit=user.can_edit(room),
can_delete=request.user.has_perm("reservation.delete_room")
) }}
{%- endfor -%}
</ul>
{%- else -%}
<p>
{% trans %}This club manages no reservable room{% endtrans %}
</p>
{%- endif -%}
<h4>{% trans %}Counters:{% endtrans %}</h4> <h4>{% trans %}Counters:{% endtrans %}</h4>
<ul> <ul>
{% for c in object.counters.filter(type="OFFICE") %} {% for counter in counters %}
<li>{{ c }}: <li>{{ counter }}:
<a href="{{ url('counter:details', counter_id=c.id) }}">View</a> <a href="{{ url('counter:details', counter_id=counter.id) }}">View</a>
<a href="{{ url('counter:admin', counter_id=c.id) }}">Edit</a> <a href="{{ url('counter:admin', counter_id=counter.id) }}">Edit</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@@ -260,6 +260,12 @@ class ClubToolsView(ClubTabsMixin, CanEditMixin, DetailView):
template_name = "club/club_tools.jinja" template_name = "club/club_tools.jinja"
current_tab = "tools" current_tab = "tools"
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"reservable_rooms": list(self.object.reservable_rooms.all()),
"counters": list(self.object.counters.filter(type="OFFICE")),
}
class ClubAddMembersFragment( class ClubAddMembersFragment(
FragmentMixin, PermissionRequiredMixin, SuccessMessageMixin, CreateView FragmentMixin, PermissionRequiredMixin, SuccessMessageMixin, CreateView

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { makeUrl } from "#core:utils/api"; import { makeUrl } from "#core:utils/api";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components"; import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import { Calendar, type EventClickArg, type EventContentArg } from "@fullcalendar/core"; import { Calendar, type EventClickArg } from "@fullcalendar/core";
import type { EventImpl } from "@fullcalendar/core/internal"; import type { EventImpl } from "@fullcalendar/core/internal";
import enLocale from "@fullcalendar/core/locales/en-gb"; import enLocale from "@fullcalendar/core/locales/en-gb";
import frLocale from "@fullcalendar/core/locales/fr"; import frLocale from "@fullcalendar/core/locales/fr";
@@ -25,11 +25,6 @@ export class IcsCalendar extends inheritHtmlElement("div") {
private canDelete = false; private canDelete = false;
private helpUrl = ""; 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) { attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
if (name === "locale") { if (name === "locale") {
this.locale = newValue; this.locale = newValue;
@@ -100,7 +95,6 @@ export class IcsCalendar extends inheritHtmlElement("div") {
refreshEvents() { refreshEvents() {
this.click(); // Remove focus from popup this.click(); // Remove focus from popup
this.recurrenceMap.clear(); // Avoid double detection of the same non recurring event
this.calendar.refetchEvents(); this.calendar.refetchEvents();
} }
@@ -159,24 +153,12 @@ export class IcsCalendar extends inheritHtmlElement("div") {
} }
async getEventSources() { 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 [ return [
{ {
url: `${await makeUrl(calendarCalendarInternal)}`, url: `${await makeUrl(calendarCalendarInternal)}`,
format: "ics", format: "ics",
className: "internal", className: "internal",
cache: false, cache: false,
eventDataTransform: tagRecurringEvents,
}, },
{ {
url: `${await makeUrl(calendarCalendarUnpublished)}`, url: `${await makeUrl(calendarCalendarUnpublished)}`,
@@ -184,7 +166,6 @@ export class IcsCalendar extends inheritHtmlElement("div") {
color: "red", color: "red",
className: "unpublished", className: "unpublished",
cache: false, cache: false,
eventDataTransform: tagRecurringEvents,
}, },
]; ];
} }
@@ -380,14 +361,6 @@ export class IcsCalendar extends inheritHtmlElement("div") {
event.jsEvent.preventDefault(); event.jsEvent.preventDefault();
this.createEventDetailPopup(event); this.createEventDetailPopup(event);
}, },
eventClassNames: (classNamesEvent: EventContentArg) => {
const classes: string[] = [];
if (classNamesEvent.event.extendedProps?.isRecurring) {
classes.push("recurring");
}
return classes;
},
}); });
this.calendar.render(); this.calendar.render();

View File

@@ -81,7 +81,6 @@
} }
#links_content { #links_content {
overflow: auto;
box-shadow: $shadow-color 1px 1px 1px; box-shadow: $shadow-color 1px 1px 1px;
min-height: 20em; min-height: 20em;
padding-bottom: 1em; padding-bottom: 1em;

View File

@@ -1,9 +1,11 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from "com/macros.jinja" import news_moderation_alert %} {% from "com/macros.jinja" import news_moderation_alert %}
{% block title %}AE UTBM{% endblock %}
{% block additional_css %} {% block additional_css %}
<link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}"> <link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}">
<link rel="stylesheet" href="{{ static('com/components/ics-calendar.scss') }}"> <link rel="stylesheet" href="{{ static('core/components/calendar.scss') }}">
{# Atom feed discovery, not really css but also goes there #} {# Atom feed discovery, not really css but also goes there #}
<link rel="alternate" type="application/rss+xml" title="{% trans %}News feed{% endtrans %}" href="{{ url("com:news_feed") }}"> <link rel="alternate" type="application/rss+xml" title="{% trans %}News feed{% endtrans %}" href="{{ url("com:news_feed") }}">
@@ -203,7 +205,7 @@
<ul> <ul>
<li> <li>
<i class="fa-solid fa-graduation-cap fa-xl"></i> <i class="fa-solid fa-graduation-cap fa-xl"></i>
<a href="{{ url("pedagogy:guide") }}">{% trans %}UE Guide{% endtrans %}</a> <a href="{{ url("pedagogy:guide") }}">{% trans %}UV Guide{% endtrans %}</a>
</li> </li>
<li> <li>
<i class="fa-solid fa-calendar-days fa-xl"></i> <i class="fa-solid fa-calendar-days fa-xl"></i>
@@ -211,8 +213,14 @@
</li> </li>
<li> <li>
<i class="fa-solid fa-magnifying-glass fa-xl"></i> <i class="fa-solid fa-magnifying-glass fa-xl"></i>
<a href="{{ url("matmat:search") }}">{% trans %}Matmatronch{% endtrans %}</a> <a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
</li> </li>
{% if user.has_perm("reservation.view_reservationslot") %}
<li>
<i class="fa-solid fa-thumbtack fa-xl"></i>
<a href="{{ url("reservation:main") }}">{% trans %}Room reservation{% endtrans %}</a>
</li>
{% endif %}
<li> <li>
<i class="fa-solid fa-check-to-slot fa-xl"></i> <i class="fa-solid fa-check-to-slot fa-xl"></i>
<a href="{{ url("election:list") }}">{% trans %}Elections{% endtrans %}</a> <a href="{{ url("election:list") }}">{% trans %}Elections{% endtrans %}</a>

View File

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

View File

@@ -1,6 +1,7 @@
import random import random
from datetime import date, timedelta from datetime import date, timedelta
from datetime import timezone as tz from datetime import timezone as tz
from math import ceil
from typing import Iterator from typing import Iterator
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
@@ -23,7 +24,8 @@ from counter.models import (
Selling, Selling,
) )
from forum.models import Forum, ForumMessage, ForumTopic from forum.models import Forum, ForumMessage, ForumTopic
from pedagogy.models import UE from pedagogy.models import UV
from reservation.models import ReservationSlot, Room
from subscription.models import Subscription from subscription.models import Subscription
@@ -40,45 +42,20 @@ class Command(BaseCommand):
self.stdout.write("Creating users...") self.stdout.write("Creating users...")
users = self.create_users() users = self.create_users()
# len(subscribers) is approximately 480
subscribers = random.sample(users, k=int(0.8 * len(users))) subscribers = random.sample(users, k=int(0.8 * len(users)))
self.stdout.write("Creating subscriptions...") self.stdout.write("Creating subscriptions...")
self.create_subscriptions(subscribers) self.create_subscriptions(subscribers)
self.stdout.write("Creating club memberships...") self.stdout.write("Creating club memberships...")
users_qs = User.objects.filter(id__in=[s.id for s in subscribers]) self.create_club_memberships(subscribers)
subscribers_now = list( self.stdout.write("Creating rooms and reservation...")
users_qs.annotate( self.create_resources_and_reservations(random.sample(subscribers, k=40))
filter=Exists(
Subscription.objects.filter(
member_id=OuterRef("pk"), subscription_end__gte=now()
)
)
)
)
old_subscribers = list(
users_qs.annotate(
filter=Exists(
Subscription.objects.filter(
member_id=OuterRef("pk"), subscription_end__lt=now()
)
)
)
)
self.make_club(
Club.objects.get(id=settings.SITH_MAIN_CLUB_ID),
random.sample(subscribers_now, k=min(30, len(subscribers_now))),
random.sample(old_subscribers, k=min(60, len(old_subscribers))),
)
self.make_club(
Club.objects.get(name="Troll Penché"),
random.sample(subscribers_now, k=min(20, len(subscribers_now))),
random.sample(old_subscribers, k=min(80, len(old_subscribers))),
)
self.stdout.write("Creating uvs...") self.stdout.write("Creating uvs...")
self.create_ues() self.create_uvs()
self.stdout.write("Creating products...") self.stdout.write("Creating products...")
self.create_products() self.create_products()
self.stdout.write("Creating sales and refills...") self.stdout.write("Creating sales and refills...")
sellers = random.sample(list(User.objects.all()), 100) sellers = list(User.objects.order_by("?")[:100])
self.create_sales(sellers) self.create_sales(sellers)
self.stdout.write("Creating permanences...") self.stdout.write("Creating permanences...")
self.create_permanences(sellers) self.create_permanences(sellers)
@@ -192,7 +169,98 @@ class Command(BaseCommand):
memberships = Membership.objects.bulk_create(memberships) memberships = Membership.objects.bulk_create(memberships)
Membership._add_club_groups(memberships) Membership._add_club_groups(memberships)
def create_ues(self): def create_club_memberships(self, users: list[User]):
users_qs = User.objects.filter(id__in=[s.id for s in users])
subscribers_now = list(
users_qs.annotate(
filter=Exists(
Subscription.objects.filter(
member_id=OuterRef("pk"), subscription_end__gte=now()
)
)
)
)
old_subscribers = list(
users_qs.annotate(
filter=Exists(
Subscription.objects.filter(
member_id=OuterRef("pk"), subscription_end__lt=now()
)
)
)
)
self.make_club(
Club.objects.get(id=settings.SITH_MAIN_CLUB_ID),
random.sample(subscribers_now, k=min(30, len(subscribers_now))),
random.sample(old_subscribers, k=min(60, len(old_subscribers))),
)
self.make_club(
Club.objects.get(name="Troll Penché"),
random.sample(subscribers_now, k=min(20, len(subscribers_now))),
random.sample(old_subscribers, k=min(80, len(old_subscribers))),
)
def create_resources_and_reservations(self, users: list[User]):
"""Generate reservable rooms and reservations slots for those rooms.
Contrary to the other data generator,
this one generates more data than what is expected on the real db.
"""
ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID)
pdf = Club.objects.get(id=settings.SITH_PDF_CLUB_ID)
troll = Club.objects.get(name="Troll Penché")
rooms = [
Room(
name=name,
club=club,
location=location,
description=self.faker.text(100),
)
for name, club, location in [
("Champi", ae, "BELFORT"),
("Muzik", ae, "BELFORT"),
("Pôle Tech", ae, "BELFORT"),
("Jolly", troll, "BELFORT"),
("Cookut", pdf, "BELFORT"),
("Lucky", pdf, "BELFORT"),
("Potards", pdf, "SEVENANS"),
("Bureau AE", ae, "SEVENANS"),
]
]
rooms = Room.objects.bulk_create(rooms)
reservations = []
for room in rooms:
# how much people use this room.
# The higher the number, the more reservations exist,
# the smaller the interval between two slot is,
# and the more future reservations have already been made ahead of time
affluence = random.randint(2, 6)
slot_start = make_aware(self.faker.past_datetime("-5y").replace(minute=0))
generate_until = make_aware(
self.faker.future_datetime(timedelta(days=1) * affluence**2)
)
while slot_start < generate_until:
if slot_start.hour < 8:
# if a reservation would start in the middle of the night
# make it start the next morning instead
slot_start += timedelta(hours=10 - slot_start.hour)
duration = timedelta(minutes=15) * (1 + int(random.gammavariate(3, 2)))
reservations.append(
ReservationSlot(
room=room,
author=random.choice(users),
start_at=slot_start,
end_at=slot_start + duration,
created_at=slot_start - self.faker.time_delta("+7d"),
)
)
slot_start += duration + (
timedelta(minutes=15) * ceil(random.expovariate(affluence / 192))
)
reservations.sort(key=lambda slot: slot.created_at)
ReservationSlot.objects.bulk_create(reservations)
def create_uvs(self):
root = User.objects.get(username="root") root = User.objects.get(username="root")
categories = ["CS", "TM", "OM", "QC", "EC"] categories = ["CS", "TM", "OM", "QC", "EC"]
branches = ["TC", "GMC", "GI", "EDIM", "E", "IMSI", "HUMA"] branches = ["TC", "GMC", "GI", "EDIM", "E", "IMSI", "HUMA"]
@@ -207,7 +275,7 @@ class Command(BaseCommand):
+ str(random.randint(10, 90)) + str(random.randint(10, 90))
) )
uvs.append( uvs.append(
UE( UV(
code=code, code=code,
author=root, author=root,
manager=random.choice(teachers), manager=random.choice(teachers),
@@ -229,7 +297,7 @@ class Command(BaseCommand):
hours_TE=random.randint(15, 40), hours_TE=random.randint(15, 40),
) )
) )
UE.objects.bulk_create(uvs, ignore_conflicts=True) UV.objects.bulk_create(uvs, ignore_conflicts=True)
def create_products(self): def create_products(self):
categories = [ categories = [
@@ -350,6 +418,7 @@ class Command(BaseCommand):
date=make_aware( date=make_aware(
self.faker.date_time_between(customer.since, localdate()) self.faker.date_time_between(customer.since, localdate())
), ),
is_validated=True,
) )
) )
sales.extend(this_customer_sales) sales.extend(this_customer_sales)
@@ -388,7 +457,7 @@ class Command(BaseCommand):
Permanency.objects.bulk_create(perms) Permanency.objects.bulk_create(perms)
def create_forums(self): def create_forums(self):
forumers = random.sample(list(User.objects.all()), 100) forumers = list(User.objects.order_by("?")[:100])
most_actives = random.sample(forumers, 10) most_actives = random.sample(forumers, 10)
categories = list(Forum.objects.filter(is_category=True)) categories = list(Forum.objects.filter(is_category=True))
new_forums = [ new_forums = [
@@ -406,7 +475,7 @@ class Command(BaseCommand):
for _ in range(100) for _ in range(100)
] ]
ForumTopic.objects.bulk_create(new_topics) ForumTopic.objects.bulk_create(new_topics)
topics = list(ForumTopic.objects.all()) topics = list(ForumTopic.objects.values_list("id", flat=True))
def get_author(): def get_author():
if random.random() > 0.5: if random.random() > 0.5:
@@ -414,7 +483,7 @@ class Command(BaseCommand):
return random.choice(forumers) return random.choice(forumers)
messages = [] messages = []
for t in topics: for topic_id in topics:
nb_messages = max(1, int(random.normalvariate(mu=90, sigma=50))) nb_messages = max(1, int(random.normalvariate(mu=90, sigma=50)))
dates = sorted( dates = sorted(
[ [
@@ -426,7 +495,7 @@ class Command(BaseCommand):
messages.extend( messages.extend(
[ [
ForumMessage( ForumMessage(
topic=t, topic_id=topic_id,
author=get_author(), author=get_author(),
date=d, date=d,
message="\n\n".join( message="\n\n".join(

View File

@@ -38,6 +38,7 @@ from django.contrib.auth.models import AnonymousUser as AuthAnonymousUser
from django.contrib.auth.models import Group as AuthGroup from django.contrib.auth.models import Group as AuthGroup
from django.contrib.staticfiles.storage import staticfiles_storage from django.contrib.staticfiles.storage import staticfiles_storage
from django.core import validators from django.core import validators
from django.core.cache import cache
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied, ValidationError
from django.core.files import File from django.core.files import File
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
@@ -76,6 +77,16 @@ class Group(AuthGroup):
def get_absolute_url(self) -> str: def get_absolute_url(self) -> str:
return reverse("core:group_list") 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: def validate_promo(value: int) -> None:
last_promo = get_last_promo() last_promo = get_last_promo()

View File

@@ -1,4 +1,3 @@
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Annotated, Any from typing import Annotated, Any
@@ -9,14 +8,12 @@ from django.urls import reverse
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from haystack.query import SearchQuerySet from haystack.query import SearchQuerySet
from ninja import FilterLookup, FilterSchema, ModelSchema, Schema, UploadedFile from ninja import FilterSchema, ModelSchema, Schema, UploadedFile
from pydantic import AliasChoices, Field, field_validator from pydantic import AliasChoices, Field
from pydantic_core.core_schema import ValidationInfo from pydantic_core.core_schema import ValidationInfo
from core.models import Group, QuickUploadImage, SithFile, User from core.models import Group, QuickUploadImage, SithFile, User
from core.utils import get_last_promo, is_image from core.utils import is_image
NonEmptyStr = Annotated[str, MinLen(1)]
class UploadedImage(UploadedFile): class UploadedImage(UploadedFile):
@@ -110,11 +107,7 @@ class GroupSchema(ModelSchema):
class UserFilterSchema(FilterSchema): class UserFilterSchema(FilterSchema):
search: Annotated[str, MinLen(1)] | None = None search: Annotated[str, MinLen(1)]
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( exclude: list[int] | None = Field(
None, validation_alias=AliasChoices("exclude", "exclude[]") None, validation_alias=AliasChoices("exclude", "exclude[]")
) )
@@ -143,13 +136,6 @@ class UserFilterSchema(FilterSchema):
return Q() return Q()
return ~Q(id__in=value) 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): class MarkdownSchema(Schema):
text: str text: str

View File

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

View File

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

View File

@@ -16,16 +16,74 @@
--event-details-padding: 20px; --event-details-padding: 20px;
--event-details-border: 1px solid #EEEEEE; --event-details-border: 1px solid #EEEEEE;
--event-details-border-radius: 4px; --event-details-border-radius: 4px;
--event-details-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%); --event-details-box-shadow: 0 6px 20px 4px rgb(0 0 0 / 16%);
--event-details-max-width: 600px; --event-details-max-width: 600px;
--event-recurring-internal-color: #6f69cd;
--event-recurring-unpublished-color: orange;
} }
ics-calendar { ics-calendar,
room-scheduler {
border: none; border: none;
box-shadow: none; box-shadow: none;
a.fc-col-header-cell-cushion,
a.fc-col-header-cell-cushion:hover {
color: black;
}
a.fc-daygrid-day-number,
a.fc-daygrid-day-number:hover {
color: rgb(34, 34, 34);
}
td {
overflow: visible; // Show events on multiple days
}
td, th {
text-align: unset;
}
//Reset from style.scss
table {
box-shadow: none;
border-radius: 0;
-moz-border-radius: 0;
margin: 0;
}
// Reset from style.scss
thead {
background-color: white;
color: black;
}
// Reset from style.scss
tbody > tr {
&:nth-child(even):not(.highlight) {
background: white;
}
}
.fc .fc-toolbar.fc-footer-toolbar {
margin-bottom: 0.5em;
}
button.text-copy,
button.text-copy:focus,
button.text-copy:hover {
background-color: #67AE6E !important;
transition: 500ms ease-in;
}
button.text-copied,
button.text-copied:focus,
button.text-copied:hover {
transition: 500ms ease-out;
}
}
ics-calendar {
#event-details { #event-details {
z-index: 10; z-index: 10;
max-width: 1151px; max-width: 1151px;
@@ -62,82 +120,60 @@ ics-calendar {
align-items: start; align-items: start;
flex-direction: row; flex-direction: row;
background-color: var(--event-details-background-color); background-color: var(--event-details-background-color);
margin-top: 0px; margin-top: 0;
margin-bottom: 4px; margin-bottom: 4px;
} }
} }
}
a.fc-col-header-cell-cushion, // Reset from style.scss
a.fc-col-header-cell-cushion:hover { thead {
color: black; background-color: white;
color: black;
}
// Reset from style.scss
tbody > tr {
&:nth-child(even):not(.highlight) {
background: white;
} }
}
a.fc-daygrid-day-number, .fc .fc-toolbar.fc-footer-toolbar {
a.fc-daygrid-day-number:hover { margin-bottom: 0.5em;
color: rgb(34, 34, 34); }
}
td { button.text-copy,
overflow: visible; // Show events on multiple days button.text-copy:focus,
} button.text-copy:hover {
background-color: #67AE6E !important;
transition: 500ms ease-in;
}
//Reset from style.scss button.text-copied,
table { button.text-copied:focus,
box-shadow: none; button.text-copied:hover {
border-radius: 0px; transition: 500ms ease-out;
-moz-border-radius: 0px; }
margin: 0px;
}
// Reset from style.scss .fc .fc-getCalendarLink-button {
thead { margin-right: 0.5rem;
background-color: white; }
color: black;
}
// Reset from style.scss .fc .fc-helpButton-button {
tbody>tr { border-radius: 70%;
&:nth-child(even):not(.highlight) { padding-left: 0.5rem;
background: white; padding-right: 0.5rem;
} background-color: rgba(0, 0, 0, 0.8);
} transition: 100ms ease-out;
width: 30px;
.fc .fc-toolbar.fc-footer-toolbar { height: 30px;
margin-bottom: 0.5em; font-size: 11px;
} }
button.text-copy,
button.text-copy:focus,
button.text-copy:hover {
background-color: #67AE6E !important;
transition: 500ms ease-in;
}
button.text-copied,
button.text-copied:focus,
button.text-copied:hover {
transition: 500ms ease-out;
}
.fc .fc-getCalendarLink-button {
margin-right: 0.5rem;
}
.fc .fc-helpButton-button {
border-radius: 70%;
padding-left: 0.5rem;
padding-right: 0.5rem;
background-color: rgba(0, 0, 0, 0.8);
transition: 100ms ease-out;
width: 30px;
height: 30px;
font-size: 11px;
}
.fc .fc-helpButton-button:hover { .fc .fc-helpButton-button:hover {
background-color: rgba(20, 20, 20, 0.6); background-color: rgba(20, 20, 20, 0.6);
}
} }
.tooltip.calendar-copy-tooltip { .tooltip.calendar-copy-tooltip {
@@ -148,29 +184,4 @@ ics-calendar {
.tooltip.calendar-copy-tooltip.text-copied { .tooltip.calendar-copy-tooltip.text-copied {
opacity: 0; opacity: 0;
transition: opacity 500ms ease-out; 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

@@ -16,6 +16,13 @@
} }
} }
.card-group {
display: flex;
gap: 15px;
margin-bottom: 30px;
flex-wrap: wrap;
}
.card { .card {
background-color: $primary-neutral-light-color; background-color: $primary-neutral-light-color;
border-radius: 5px; border-radius: 5px;
@@ -92,13 +99,23 @@
} }
@media screen and (max-width: 765px) { @media screen and (max-width: 765px) {
@include row-layout @include row-layout;
} }
// When combined with card, card-row display the card in a row layout, // When combined with card, card-row display the card in a row layout,
// whatever the size of the screen. // whatever the size of the screen.
&.card-row { &.card-row {
@include row-layout @include row-layout;
&.card-row-m {
//width: 50%;
max-width: 50%;
}
&.card-row-s {
//width: 33%;
max-width: 33%;
}
} }
} }

View File

@@ -143,15 +143,6 @@ form {
line-height: 1; line-height: 1;
white-space: nowrap; 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 { .helptext {
margin-top: .25rem; margin-top: .25rem;
margin-bottom: .25rem; margin-bottom: .25rem;

View File

@@ -10,10 +10,9 @@
border-radius: 5px; border-radius: 5px;
padding: 5px 10px; padding: 5px 10px;
position: absolute; position: absolute;
white-space: nowrap;
opacity: 0; opacity: 0;
transition: opacity 500ms ease-out; transition: opacity 500ms ease-out;
width: max-content;
white-space: normal; white-space: normal;
left: 0; left: 0;

View File

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

View File

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

View File

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

View File

@@ -78,6 +78,12 @@
{% endif %} {% endif %}
{% endmacro %} {% 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) %} {% macro paginate_alpine(page, nb_pages) %}
{# Add pagination buttons for ajax based content with alpine {# Add pagination buttons for ajax based content with alpine
@@ -151,13 +157,12 @@
{% if current_page.has_previous() %} {% if current_page.has_previous() %}
<a <a
{% if use_htmx -%} {% if use_htmx -%}
hx-get="?{{ querystring(page=current_page.previous_page_number()) }}" hx-get="?page={{ current_page.previous_page_number() }}"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-target="#content" hx-target="#content"
hx-push-url="true" hx-push-url="true"
hx-trigger="click, keyup[key=='ArrowLeft'] from:body"
{%- else -%} {%- else -%}
href="?{{ querystring(page=current_page.previous_page_number()) }}" href="?page={{ current_page.previous_page_number() }}"
{%- endif -%} {%- endif -%}
> >
<button> <button>
@@ -175,12 +180,12 @@
{% else %} {% else %}
<a <a
{% if use_htmx -%} {% if use_htmx -%}
hx-get="?{{ querystring(page=i) }}" hx-get="?page={{ i }}"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-target="#content" hx-target="#content"
hx-push-url="true" hx-push-url="true"
{%- else -%} {%- else -%}
href="?{{ querystring(page=i) }}" href="?page={{ i }}"
{%- endif -%} {%- endif -%}
> >
<button>{{ i }}</button> <button>{{ i }}</button>
@@ -190,13 +195,12 @@
{% if current_page.has_next() %} {% if current_page.has_next() %}
<a <a
{% if use_htmx -%} {% if use_htmx -%}
hx-get="?{{querystring(page=current_page.next_page_number())}}" hx-get="?page={{ current_page.next_page_number() }}"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-target="#content" hx-target="#content"
hx-push-url="true" hx-push-url="true"
hx-trigger="click, keyup[key=='ArrowRight'] from:body"
{%- else -%} {%- else -%}
href="?{{querystring(page=current_page.next_page_number())}}" href="?page={{ current_page.next_page_number() }}"
{%- endif -%} {%- endif -%}
><button> ><button>
<i class="fa fa-caret-right"></i> <i class="fa fa-caret-right"></i>
@@ -245,17 +249,3 @@
}"></div> }"></div>
{% endif %} {% endif %}
{% endmacro %} {% 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 %} {% block content %}
{% if target %} {% if target %}
<p>{% trans user=form.user.get_display_name() %}Change password for {{ user }}{% endtrans %}</p> <p>{% trans user=target.get_display_name() %}Change password for {{ user }}{% endtrans %}</p>
{% endif %} {% endif %}
<form method="post" action=""> <form method="post" action="">
{% csrf_token %} {% csrf_token %}

View File

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

View File

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

View File

@@ -29,16 +29,7 @@
<a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link"> <a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link">
{{ u.get_mini_item() | safe }} {{ u.get_mini_item() | safe }}
</a> </a>
{% if user == profile or user.is_root or user.is_board_member %} {{ delete_godfather(user, profile, u, True) }}
<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> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@@ -55,16 +46,7 @@
<a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link"> <a href="{{ url('core:user_godfathers', user_id=u.id) }}" class="mini_profile_link">
{{ u.get_mini_item()|safe }} {{ u.get_mini_item()|safe }}
</a> </a>
{% if user == profile or user.is_root or user.is_board_member %} {{ delete_godfather(user, profile, u, False) }}
<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> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

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

View File

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

View File

@@ -55,17 +55,31 @@ def phonenumber(
return value 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") @register.filter(name="format_timedelta")
def format_timedelta(value: datetime.timedelta) -> str: def format_timedelta(value: datetime.timedelta) -> str:
value = value - datetime.timedelta(microseconds=value.microseconds)
days = value.days days = value.days
if days == 0: if days == 0:
return str(value) return str(value)
remainder = value - datetime.timedelta(days=days) remainder = value - datetime.timedelta(days=days)
return ngettext( return ngettext(
"%(nb_days)d day, %(remainder)s", "%(nb_days)d day, %(remainder)s", "%(nb_days)d days, %(remainder)s", days
"%(nb_days)d days, %(remainder)s",
days,
) % {"nb_days": days, "remainder": str(remainder)} ) % {"nb_days": days, "remainder": str(remainder)}

View File

@@ -35,7 +35,6 @@ from pytest_django.asserts import assertInHTML, assertRedirects
from antispam.models import ToxicDomain from antispam.models import ToxicDomain
from club.models import Club, Membership from club.models import Club, Membership
from core.baker_recipes import subscriber_user
from core.markdown import markdown from core.markdown import markdown
from core.models import AnonymousUser, Group, Page, User, validate_promo from core.models import AnonymousUser, Group, Page, User, validate_promo
from core.utils import get_last_promo, get_semester_code, get_start_of_semester from core.utils import get_last_promo, get_semester_code, get_start_of_semester
@@ -552,10 +551,3 @@ def test_allow_fragment_mixin():
assert not TestAllowFragmentView.as_view()(request) assert not TestAllowFragmentView.as_view()(request)
request.headers = {"HX-Request": True, **base_headers} request.headers = {"HX-Request": True, **base_headers}
assert TestAllowFragmentView.as_view()(request) 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

@@ -1,64 +0,0 @@
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,4 +1,3 @@
import itertools
from datetime import timedelta from datetime import timedelta
from unittest import mock from unittest import mock
@@ -24,7 +23,7 @@ from core.baker_recipes import (
from core.models import AnonymousUser, Group, User from core.models import AnonymousUser, Group, User
from core.views import UserTabsMixin from core.views import UserTabsMixin
from counter.baker_recipes import sale_recipe from counter.baker_recipes import sale_recipe
from counter.models import Counter, Customer, Permanency, Refilling, Selling from counter.models import Counter, Customer, Refilling, Selling
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
from eboutic.models import Invoice, InvoiceItem from eboutic.models import Invoice, InvoiceItem
@@ -188,7 +187,11 @@ class TestFilterInactive(TestCase):
time_inactive = time_active - timedelta(days=3) time_inactive = time_active - timedelta(days=3)
counter, seller = baker.make(Counter), baker.make(User) counter, seller = baker.make(Counter), baker.make(User)
sale_recipe = Recipe( sale_recipe = Recipe(
Selling, counter=counter, club=counter.club, seller=seller, unit_price=0 Selling,
counter=counter,
club=counter.club,
seller=seller,
is_validated=True,
) )
cls.users = [ cls.users = [
@@ -425,106 +428,3 @@ class TestUserQuerySetViewableBy:
user = user_factory() user = user_factory()
viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user) viewable = User.objects.filter(id__in=[u.id for u in users]).viewable_by(user)
assert not viewable.exists() 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,7 +24,6 @@
from django.urls import path, re_path, register_converter from django.urls import path, re_path, register_converter
from django.views.generic import RedirectView from django.views.generic import RedirectView
from com.views import NewsListView
from core.converters import ( from core.converters import (
BooleanStringConverter, BooleanStringConverter,
FourDigitYearConverter, FourDigitYearConverter,
@@ -54,8 +53,6 @@ from core.views import (
PagePropView, PagePropView,
PageRevView, PageRevView,
PageView, PageView,
PasswordRootChangeView,
SearchView,
SithLoginView, SithLoginView,
SithPasswordChangeDoneView, SithPasswordChangeDoneView,
SithPasswordChangeView, SithPasswordChangeView,
@@ -79,8 +76,13 @@ from core.views import (
UserUpdateProfileView, UserUpdateProfileView,
UserView, UserView,
delete_user_godfather, delete_user_godfather,
index,
logout, logout,
notification, notification,
password_root_change,
search_json,
search_user_json,
search_view,
send_file, send_file,
) )
@@ -89,18 +91,20 @@ register_converter(TwoDigitMonthConverter, "mm")
register_converter(BooleanStringConverter, "bool") register_converter(BooleanStringConverter, "bool")
urlpatterns = [ urlpatterns = [
path("", NewsListView.as_view(), name="index"), path("", index, name="index"),
path("notifications/", NotificationList.as_view(), name="notification_list"), path("notifications/", NotificationList.as_view(), name="notification_list"),
path("notification/<int:notif_id>/", notification, name="notification"), path("notification/<int:notif_id>/", notification, name="notification"),
# Search # Search
path("search/", SearchView.as_view(), name="search"), path("search/", search_view, name="search"),
path("search_json/", search_json, name="search_json"),
path("search_user/", search_user_json, name="search_user"),
# Login and co # Login and co
path("login/", SithLoginView.as_view(), name="login"), path("login/", SithLoginView.as_view(), name="login"),
path("logout/", logout, name="logout"), path("logout/", logout, name="logout"),
path("password_change/", SithPasswordChangeView.as_view(), name="password_change"), path("password_change/", SithPasswordChangeView.as_view(), name="password_change"),
path( path(
"password_change/<int:user_id>/", "password_change/<int:user_id>/",
PasswordRootChangeView.as_view(), password_root_change,
name="password_root_change", name="password_root_change",
), ),
path( path(

View File

@@ -40,9 +40,8 @@ from django.forms import (
DateInput, DateInput,
DateTimeInput, DateTimeInput,
TextInput, TextInput,
Widget,
) )
from django.utils.timezone import now from django.utils.timezone import localtime, now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from phonenumber_field.widgets import RegionalPhoneNumberWidget from phonenumber_field.widgets import RegionalPhoneNumberWidget
from PIL import Image from PIL import Image
@@ -100,8 +99,8 @@ class FutureDateTimeField(forms.DateTimeField):
default_validators = [validate_future_timestamp] default_validators = [validate_future_timestamp]
def widget_attrs(self, widget: Widget) -> dict[str, str]: def widget_attrs(self, widget: forms.Widget) -> dict[str, str]:
return {"min": widget.format_value(now())} return {"min": widget.format_value(localtime())}
# Forms # Forms
@@ -303,6 +302,7 @@ class UserGodfathersForm(forms.Form):
) )
user = forms.ModelChoiceField( user = forms.ModelChoiceField(
label=_("Select user"), label=_("Select user"),
help_text=None,
required=True, required=True,
widget=AutoCompleteSelectUser, widget=AutoCompleteSelectUser,
queryset=User.objects.all(), queryset=User.objects.all(),
@@ -314,6 +314,8 @@ class UserGodfathersForm(forms.Form):
def clean_user(self): def clean_user(self):
other_user = self.cleaned_data.get("user") other_user = self.cleaned_data.get("user")
if not other_user:
raise ValidationError(_("This user does not exist"))
if other_user == self.target_user: if other_user == self.target_user:
raise ValidationError(_("You cannot be related to yourself")) raise ValidationError(_("You cannot be related to yourself"))
return other_user return other_user

View File

@@ -22,49 +22,106 @@
# #
# #
import json
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.decorators import login_required
from django.db.models import F from django.core import serializers
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.http import HttpRequest from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import redirect, render
from django.views.generic import ListView, TemplateView from django.utils import html
from django.utils.text import slugify
from django.views.generic import ListView
from haystack.query import SearchQuerySet
from club.models import Club from club.models import Club
from core.models import Notification, User from core.models import Notification, User
from core.schemas import UserFilterSchema
class NotificationList(LoginRequiredMixin, ListView): def index(request, context=None):
from com.views import NewsListView
return NewsListView.as_view()(request)
class NotificationList(ListView):
model = Notification model = Notification
template_name = "core/notification_list.jinja" template_name = "core/notification_list.jinja"
def get_queryset(self) -> QuerySet[Notification]: 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: if "see_all" in self.request.GET:
self.request.user.notifications.filter(viewed=False).update(viewed=True) self.request.user.notifications.filter(viewed=False).update(viewed=True)
return self.request.user.notifications.order_by("-date")[:20] return self.request.user.notifications.order_by("-date")[:20]
def notification(request: HttpRequest, notif_id: int): def notification(request, notif_id):
notif = get_object_or_404(Notification, id=notif_id) notif = Notification.objects.filter(id=notif_id).first()
if notif.type not in settings.SITH_PERMANENT_NOTIFICATIONS: if notif:
notif.viewed = True if notif.type not in settings.SITH_PERMANENT_NOTIFICATIONS:
else: notif.viewed = True
notif.callback() else:
notif.save() notif.callback()
return redirect(notif.url) notif.save()
return redirect(notif.url)
return redirect("/")
class SearchView(LoginRequiredMixin, TemplateView): def search_user(query):
template_name = "core/search.jinja" 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 []
def get_context_data(self, **kwargs):
users, clubs = [], [] def search_club(query, *, as_json=False):
if query := self.request.GET.get("query"): clubs = []
users = list( if query:
UserFilterSchema(search=query) clubs = Club.objects.filter(name__icontains=query).all()
.filter(User.objects.viewable_by(self.request.user)) clubs = clubs[:5]
.order_by(F("last_login").desc(nulls_last=True)) if as_json:
) # Re-loads json to avoid double encoding by JsonResponse, but still benefit from serializers
clubs = list(Club.objects.filter(name__icontains=query)[:5]) clubs = json.loads(serializers.serialize("json", clubs, fields=("name")))
return super().get_context_data(**kwargs) | {"users": users, "clubs": clubs} 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)

View File

@@ -78,7 +78,7 @@ class FragmentMixin(TemplateResponseMixin, ContextMixin):
return render( return render(
request, request,
"app/template.jinja", "app/template.jinja",
context={"fragment": fragment(request) context={"fragment": fragment(request)}
} }
# in urls.py # in urls.py

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import json import json
import math import math
import uuid import uuid
from datetime import date, datetime, timezone from datetime import date
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django import forms from django import forms
@@ -136,10 +136,7 @@ class GetUserForm(forms.Form):
class RefillForm(forms.ModelForm): class RefillForm(forms.ModelForm):
allowed_refilling_methods = [ allowed_refilling_methods = ["CASH", "CARD"]
Refilling.PaymentMethod.CASH,
Refilling.PaymentMethod.CARD,
]
error_css_class = "error" error_css_class = "error"
required_css_class = "required" required_css_class = "required"
@@ -149,7 +146,7 @@ class RefillForm(forms.ModelForm):
class Meta: class Meta:
model = Refilling model = Refilling
fields = ["amount", "payment_method"] fields = ["amount", "payment_method", "bank"]
widgets = {"payment_method": forms.RadioSelect} widgets = {"payment_method": forms.RadioSelect}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -163,6 +160,9 @@ class RefillForm(forms.ModelForm):
if self.fields["payment_method"].initial not in self.allowed_refilling_methods: if self.fields["payment_method"].initial not in self.allowed_refilling_methods:
self.fields["payment_method"].initial = self.allowed_refilling_methods[0] 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 CounterEditForm(forms.ModelForm):
class Meta: class Meta:
@@ -235,19 +235,6 @@ class ScheduledProductActionForm(forms.ModelForm):
) )
return super().clean() 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): class BaseScheduledProductActionFormSet(BaseModelFormSet):
def __init__(self, *args, product: Product, **kwargs): def __init__(self, *args, product: Product, **kwargs):
@@ -334,19 +321,11 @@ class ProductForm(forms.ModelForm):
def is_valid(self): def is_valid(self):
return super().is_valid() and self.action_formset.is_valid() return super().is_valid() and self.action_formset.is_valid()
def save(self, *args, **kwargs) -> Product: def save(self, *args, **kwargs):
product = super().save(*args, **kwargs) ret = super().save(*args, **kwargs)
product.counters.set(self.cleaned_data["counters"]) self.instance.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() self.action_formset.save()
return product return ret
class ReturnableProductForm(forms.ModelForm): class ReturnableProductForm(forms.ModelForm):
@@ -390,6 +369,7 @@ class EticketForm(forms.ModelForm):
class CloseCustomerAccountForm(forms.Form): class CloseCustomerAccountForm(forms.Form):
user = forms.ModelChoiceField( user = forms.ModelChoiceField(
label=_("Refound this account"), label=_("Refound this account"),
help_text=None,
required=True, required=True,
widget=AutoCompleteSelectUser, widget=AutoCompleteSelectUser,
queryset=User.objects.all(), queryset=User.objects.all(),
@@ -509,14 +489,13 @@ class InvoiceCallForm(forms.Form):
def __init__(self, *args, month: date, **kwargs): def __init__(self, *args, month: date, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.month = month self.month = month
month_start = datetime(month.year, month.month, month.day, tzinfo=timezone.utc)
self.clubs = list( self.clubs = list(
Club.objects.filter( Club.objects.filter(
Exists( Exists(
Selling.objects.filter( Selling.objects.filter(
club=OuterRef("pk"), club=OuterRef("pk"),
date__gte=month_start, date__gte=month,
date__lte=month_start + relativedelta(months=1), date__lte=month + relativedelta(months=1),
) )
) )
).annotate( ).annotate(

View File

@@ -119,6 +119,7 @@ class Command(BaseCommand):
quantity=1, quantity=1,
unit_price=account.amount, unit_price=account.amount,
date=now(), date=now(),
is_validated=True,
) )
for account in accounts for account in accounts
] ]

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,12 +11,8 @@ from model_bakery import baker
from core.models import Group, User from core.models import Group, User
from counter.baker_recipes import counter_recipe, product_recipe from counter.baker_recipes import counter_recipe, product_recipe
from counter.forms import ( from counter.forms import ScheduledProductActionForm, ScheduledProductActionFormSet
ProductForm, from counter.models import ScheduledProductAction
ScheduledProductActionForm,
ScheduledProductActionFormSet,
)
from counter.models import Product, ScheduledProductAction
@pytest.mark.django_db @pytest.mark.django_db
@@ -38,39 +34,6 @@ def test_edit_product(client: Client):
assert res.status_code == 200 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 @pytest.mark.django_db
class TestProductActionForm: class TestProductActionForm:
def test_single_form_archive(self): 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): def force_refill_user(user: User, amount: Decimal | int):
baker.make(Refilling, amount=amount, customer=user.customer) baker.make(Refilling, amount=amount, customer=user.customer, is_validated=False)
class TestFullClickBase(TestCase): class TestFullClickBase(TestCase):
@@ -115,10 +115,18 @@ class TestRefilling(TestFullClickBase):
) -> HttpResponse: ) -> HttpResponse:
used_client = client if client is not None else self.client used_client = client if client is not None else self.client
return used_client.post( return used_client.post(
reverse("counter:refilling_create", kwargs={"customer_id": user.pk}), reverse(
{"amount": str(amount), "payment_method": Refilling.PaymentMethod.CASH}, "counter:refilling_create",
kwargs={"customer_id": user.pk},
),
{
"amount": str(amount),
"payment_method": "CASH",
"bank": "OTHER",
},
HTTP_REFERER=reverse( 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},
), ),
) )
@@ -141,7 +149,11 @@ class TestRefilling(TestFullClickBase):
"counter:refilling_create", "counter:refilling_create",
kwargs={"customer_id": self.customer.pk}, kwargs={"customer_id": self.customer.pk},
), ),
{"amount": "10", "payment_method": "CASH"}, {
"amount": "10",
"payment_method": "CASH",
"bank": "OTHER",
},
) )
self.client.force_login(self.club_admin) self.client.force_login(self.club_admin)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -110,9 +110,7 @@ class Basket(models.Model):
)["total"] )["total"]
) )
def generate_sales( def generate_sales(self, counter, seller: User, payment_method: str):
self, counter, seller: User, payment_method: Selling.PaymentMethod
):
"""Generate a list of sold items corresponding to the items """Generate a list of sold items corresponding to the items
of this basket WITHOUT saving them NOR deleting the basket. of this basket WITHOUT saving them NOR deleting the basket.
@@ -253,7 +251,8 @@ class Invoice(models.Model):
customer=customer, customer=customer,
operator=self.user, operator=self.user,
amount=i.product_unit_price * i.quantity, amount=i.product_unit_price * i.quantity,
payment_method=Refilling.PaymentMethod.CARD, payment_method="CARD",
bank="OTHER",
date=self.date, date=self.date,
) )
new.save() new.save()
@@ -268,7 +267,8 @@ class Invoice(models.Model):
customer=customer, customer=customer,
unit_price=i.product_unit_price, unit_price=i.product_unit_price,
quantity=i.quantity, quantity=i.quantity,
payment_method=Selling.PaymentMethod.CARD, payment_method="CARD",
is_validated=True,
date=self.date, date=self.date,
) )
new.save() new.save()

View File

@@ -108,22 +108,12 @@ def test_eboutic_basket_expiry(
client.force_login(customer.user) client.force_login(customer.user)
if sellings: for date in sellings:
sale_recipe.make( sale_recipe.make(
customer=customer, customer=customer, counter=eboutic, date=date, is_validated=True
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 ( assert (
f'x-data="basket({int(expected.timestamp() * 1000) if expected else "null"})"' f'x-data="basket({int(expected.timestamp() * 1000) if expected else "null"})"'

View File

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

View File

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

View File

@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-19 23:10+0100\n" "POT-Creation-Date: 2025-11-12 21:44+0100\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -218,7 +218,7 @@ msgid "You can not make loops in clubs"
msgstr "Vous ne pouvez pas faire de boucles dans les clubs" msgstr "Vous ne pouvez pas faire de boucles dans les clubs"
#: club/models.py core/models.py counter/models.py eboutic/models.py #: club/models.py core/models.py counter/models.py eboutic/models.py
#: election/models.py pedagogy/models.py sas/models.py trombi/models.py #: election/models.py sas/models.py trombi/models.py
msgid "user" msgid "user"
msgstr "utilisateur" msgstr "utilisateur"
@@ -239,7 +239,7 @@ msgid "role"
msgstr "rôle" msgstr "rôle"
#: club/models.py core/models.py counter/models.py election/models.py #: club/models.py core/models.py counter/models.py election/models.py
#: forum/models.py #: forum/models.py reservation/models.py
msgid "description" msgid "description"
msgstr "description" msgstr "description"
@@ -470,15 +470,14 @@ msgstr "Méthode de paiement"
#: core/templates/core/file_detail.jinja #: core/templates/core/file_detail.jinja
#: core/templates/core/file_moderation.jinja #: core/templates/core/file_moderation.jinja
#: core/templates/core/group_detail.jinja core/templates/core/group_list.jinja #: core/templates/core/group_detail.jinja core/templates/core/group_list.jinja
#: core/templates/core/page/prop.jinja #: core/templates/core/macros.jinja core/templates/core/page/prop.jinja
#: core/templates/core/user_account_detail.jinja #: core/templates/core/user_account_detail.jinja
#: core/templates/core/user_clubs.jinja core/templates/core/user_edit.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/fragments/create_student_card.jinja
#: counter/templates/counter/last_ops.jinja #: counter/templates/counter/last_ops.jinja
#: election/templates/election/election_detail.jinja #: election/templates/election/election_detail.jinja
#: forum/templates/forum/macros.jinja pedagogy/templates/pedagogy/guide.jinja #: forum/templates/forum/macros.jinja pedagogy/templates/pedagogy/guide.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja sas/templates/sas/album.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja sas/templates/sas/album.jinja
#: sas/templates/sas/moderation.jinja sas/templates/sas/picture.jinja #: sas/templates/sas/moderation.jinja sas/templates/sas/picture.jinja
#: trombi/templates/trombi/detail.jinja #: trombi/templates/trombi/detail.jinja
#: trombi/templates/trombi/edit_profile.jinja #: trombi/templates/trombi/edit_profile.jinja
@@ -514,6 +513,18 @@ msgstr "Nouveau Trombi"
msgid "Posters" msgid "Posters"
msgstr "Affiches" msgstr "Affiches"
#: club/templates/club/club_tools.jinja
msgid "Reservable rooms"
msgstr "Salles réservables"
#: club/templates/club/club_tools.jinja
msgid "Add a room"
msgstr "Ajouter une salle"
#: club/templates/club/club_tools.jinja
msgid "This club manages no reservable room"
msgstr "Ce club ne gère pas de salle réservable"
#: club/templates/club/club_tools.jinja #: club/templates/club/club_tools.jinja
msgid "Counters:" msgid "Counters:"
msgstr "Comptoirs : " msgstr "Comptoirs : "
@@ -673,7 +684,7 @@ msgstr "Outils"
#: counter/templates/counter/counter_list.jinja #: counter/templates/counter/counter_list.jinja
#: election/templates/election/election_detail.jinja #: election/templates/election/election_detail.jinja
#: forum/templates/forum/macros.jinja pedagogy/templates/pedagogy/guide.jinja #: forum/templates/forum/macros.jinja pedagogy/templates/pedagogy/guide.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja sas/templates/sas/album.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja sas/templates/sas/album.jinja
#: trombi/templates/trombi/detail.jinja #: trombi/templates/trombi/detail.jinja
#: trombi/templates/trombi/edit_profile.jinja #: trombi/templates/trombi/edit_profile.jinja
msgid "Edit" msgid "Edit"
@@ -792,7 +803,7 @@ msgstr "Une description plus détaillée et exhaustive de l'évènement."
msgid "The club which organizes the event." msgid "The club which organizes the event."
msgstr "Le club qui organise l'évènement." msgstr "Le club qui organise l'évènement."
#: com/models.py pedagogy/models.py trombi/models.py #: com/models.py pedagogy/models.py reservation/models.py trombi/models.py
msgid "author" msgid "author"
msgstr "auteur" msgstr "auteur"
@@ -1077,9 +1088,9 @@ msgstr "Liens"
msgid "Our services" msgid "Our services"
msgstr "Nos services" msgstr "Nos services"
#: com/templates/com/news_list.jinja pedagogy/templates/pedagogy/guide.jinja #: com/templates/com/news_list.jinja
msgid "UE Guide" msgid "UV Guide"
msgstr "Guide des UEs" msgstr "Guide des UVs"
#: com/templates/com/news_list.jinja #: com/templates/com/news_list.jinja
msgid "Timetable" msgid "Timetable"
@@ -1089,6 +1100,11 @@ msgstr "Emploi du temps"
msgid "Matmatronch" msgid "Matmatronch"
msgstr "Matmatronch" msgstr "Matmatronch"
#: com/templates/com/news_list.jinja
#: reservation/templates/reservation/schedule.jinja
msgid "Room reservation"
msgstr "Réservation de salle"
#: com/templates/com/news_list.jinja core/templates/core/base/navbar.jinja #: com/templates/com/news_list.jinja core/templates/core/base/navbar.jinja
#: core/templates/core/user_tools.jinja #: core/templates/core/user_tools.jinja
msgid "Elections" msgid "Elections"
@@ -1215,7 +1231,7 @@ msgstr "Descendre"
#: com/templates/com/weekmail_preview.jinja #: com/templates/com/weekmail_preview.jinja
#: core/templates/core/user_account_detail.jinja #: core/templates/core/user_account_detail.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
#: trombi/templates/trombi/comment_moderation.jinja #: trombi/templates/trombi/comment_moderation.jinja
#: trombi/templates/trombi/export.jinja #: trombi/templates/trombi/export.jinja
msgid "Back" msgid "Back"
@@ -1949,6 +1965,7 @@ msgstr "Confirmation"
#: core/templates/core/file_delete_confirm.jinja #: core/templates/core/file_delete_confirm.jinja
#: counter/templates/counter/counter_click.jinja #: counter/templates/counter/counter_click.jinja
#: counter/templates/counter/fragments/delete_student_card.jinja #: counter/templates/counter/fragments/delete_student_card.jinja
#: reservation/templates/reservation/fragments/create_reservation.jinja
#: sas/templates/sas/ask_picture_removal.jinja #: sas/templates/sas/ask_picture_removal.jinja
msgid "Cancel" msgid "Cancel"
msgstr "Annuler" msgstr "Annuler"
@@ -2063,7 +2080,7 @@ msgstr "Éditer le groupe"
#: core/templates/core/group_edit.jinja core/templates/core/user_edit.jinja #: core/templates/core/group_edit.jinja core/templates/core/user_edit.jinja
#: core/templates/core/user_group.jinja #: core/templates/core/user_group.jinja
#: pedagogy/templates/pedagogy/ue_edit.jinja #: pedagogy/templates/pedagogy/uv_edit.jinja
msgid "Update" msgid "Update"
msgstr "Mettre à jour" msgstr "Mettre à jour"
@@ -2659,8 +2676,8 @@ msgid "Buyings"
msgstr "Achats" msgstr "Achats"
#: core/templates/core/user_stats.jinja #: core/templates/core/user_stats.jinja
msgid "Product top 15" msgid "Product top 10"
msgstr "Top 15 produits" msgstr "Top 10 produits"
#: core/templates/core/user_stats.jinja #: core/templates/core/user_stats.jinja
msgid "Product" msgid "Product"
@@ -2788,8 +2805,8 @@ msgid "Subscription stats"
msgstr "Statistiques de cotisation" msgstr "Statistiques de cotisation"
#: core/templates/core/user_tools.jinja pedagogy/templates/pedagogy/guide.jinja #: core/templates/core/user_tools.jinja pedagogy/templates/pedagogy/guide.jinja
msgid "Create UE" msgid "Create UV"
msgstr "Créer UE" msgstr "Créer UV"
#: core/templates/core/user_tools.jinja pedagogy/templates/pedagogy/guide.jinja #: core/templates/core/user_tools.jinja pedagogy/templates/pedagogy/guide.jinja
#: trombi/templates/trombi/detail.jinja #: trombi/templates/trombi/detail.jinja
@@ -2820,8 +2837,8 @@ msgstr "Outils Trombi"
#, python-format #, python-format
msgid "%(nb_days)d day, %(remainder)s" msgid "%(nb_days)d day, %(remainder)s"
msgid_plural "%(nb_days)d days, %(remainder)s" msgid_plural "%(nb_days)d days, %(remainder)s"
msgstr[0] "%(nb_days)d jour, %(remainder)s" msgstr[0] ""
msgstr[1] "%(nb_days)d jours, %(remainder)s" msgstr[1] ""
#: core/views/files.py #: core/views/files.py
msgid "Add a new folder" msgid "Add a new folder"
@@ -2881,6 +2898,10 @@ msgstr "Fillot / Fillote"
msgid "Select user" msgid "Select user"
msgstr "Choisir un utilisateur" msgstr "Choisir un utilisateur"
#: core/views/forms.py
msgid "This user does not exist"
msgstr "Cet utilisateur n'existe pas"
#: core/views/forms.py #: core/views/forms.py
msgid "You cannot be related to yourself" msgid "You cannot be related to yourself"
msgstr "Vous ne pouvez pas être relié à vous-même" msgstr "Vous ne pouvez pas être relié à vous-même"
@@ -2925,6 +2946,18 @@ msgstr "Photos"
msgid "Account" msgid "Account"
msgstr "Compte" 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 #: counter/apps.py counter/models.py
msgid "counter" msgid "counter"
msgstr "comptoir" msgstr "comptoir"
@@ -3053,7 +3086,7 @@ msgstr "Mettre à True si le mail a reçu une erreur"
msgid "The operation that emptied the account." msgid "The operation that emptied the account."
msgstr "L'opération qui a vidé le compte." msgstr "L'opération qui a vidé le compte."
#: counter/models.py pedagogy/models.py #: counter/models.py pedagogy/models.py reservation/models.py
msgid "comment" msgid "comment"
msgstr "commentaire" msgstr "commentaire"
@@ -3137,29 +3170,21 @@ msgstr "vendeurs"
msgid "token" msgid "token"
msgstr "jeton" 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 #: counter/models.py subscription/models.py
msgid "payment method" msgid "payment method"
msgstr "méthode de paiement" msgstr "méthode de paiement"
#: counter/models.py #: counter/models.py
msgid "refilling" msgid "bank"
msgstr "rechargement" msgstr "banque"
#: counter/models.py #: counter/models.py
msgid "Sith account" msgid "is validated"
msgstr "Compte utilisateur" msgstr "est validé"
#: counter/models.py
msgid "refilling"
msgstr "rechargement"
#: counter/models.py eboutic/models.py #: counter/models.py eboutic/models.py
msgid "unit price" msgid "unit price"
@@ -3169,6 +3194,10 @@ msgstr "prix unitaire"
msgid "quantity" msgid "quantity"
msgstr "quantité" msgstr "quantité"
#: counter/models.py
msgid "Sith account"
msgstr "Compte utilisateur"
#: counter/models.py #: counter/models.py
msgid "selling" msgid "selling"
msgstr "vente" msgstr "vente"
@@ -3321,10 +3350,6 @@ msgid ""
"%(value)s” value has the correct format (YYYY-MM) but it is an invalid date." "%(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." 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 #: counter/models.py
msgid "invoice date" msgid "invoice date"
msgstr "date de la facture" msgstr "date de la facture"
@@ -3384,7 +3409,7 @@ msgstr "Coffre vidé"
#: counter/templates/counter/cash_summary_list.jinja counter/views/cash.py #: counter/templates/counter/cash_summary_list.jinja counter/views/cash.py
#: pedagogy/templates/pedagogy/moderation.jinja #: pedagogy/templates/pedagogy/moderation.jinja
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
#: trombi/templates/trombi/comment.jinja #: trombi/templates/trombi/comment.jinja
#: trombi/templates/trombi/user_tools.jinja #: trombi/templates/trombi/user_tools.jinja
msgid "Comment" msgid "Comment"
@@ -4381,14 +4406,6 @@ msgstr "Galaxie de %(user_name)s"
msgid "This citizen has not yet joined the galaxy" msgid "This citizen has not yet joined the galaxy"
msgstr "Ce citoyen n'a pas encore rejoint la galaxie" 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 #: matmat/templates/matmat/search_form.jinja
msgid "Search user" msgid "Search user"
msgstr "Rechercher un utilisateur" msgstr "Rechercher un utilisateur"
@@ -4397,13 +4414,29 @@ msgstr "Rechercher un utilisateur"
msgid "Results" msgid "Results"
msgstr "Résultats" 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 #: pedagogy/forms.py
msgid "Do not vote" msgid "Do not vote"
msgstr "Ne pas voter" msgstr "Ne pas voter"
#: pedagogy/forms.py #: pedagogy/forms.py
msgid "This user has already commented on this UE" msgid "This user has already commented on this UV"
msgstr "Cet utilisateur a déjà commenté cette UE" msgstr "Cet utilisateur a déjà commenté cette UV"
#: pedagogy/forms.py #: pedagogy/forms.py
msgid "Accepted reports" msgid "Accepted reports"
@@ -4415,10 +4448,10 @@ msgstr "Signalements refusés"
#: pedagogy/models.py #: pedagogy/models.py
msgid "" msgid ""
"The code of an UE must only contains uppercase characters without accent and " "The code of an UV must only contains uppercase characters without accent and "
"numbers" "numbers"
msgstr "" msgstr ""
"Le code d'une UE doit seulement contenir des caractères majuscule sans " "Le code d'une UV doit seulement contenir des caractères majuscule sans "
"accents et nombres" "accents et nombres"
#: pedagogy/models.py #: pedagogy/models.py
@@ -4426,8 +4459,8 @@ msgid "credit type"
msgstr "type de crédit" msgstr "type de crédit"
#: pedagogy/models.py #: pedagogy/models.py
msgid "ue manager" msgid "uv manager"
msgstr "gestionnaire d'ue" msgstr "gestionnaire d'uv"
#: pedagogy/models.py #: pedagogy/models.py
msgid "language" msgid "language"
@@ -4478,7 +4511,7 @@ msgid "hours TE"
msgstr "heures TE" msgstr "heures TE"
#: pedagogy/models.py #: pedagogy/models.py
msgid "ue" msgid "uv"
msgstr "UE" msgstr "UE"
#: pedagogy/models.py #: pedagogy/models.py
@@ -4517,6 +4550,10 @@ msgstr "signaler"
msgid "reporter" msgid "reporter"
msgstr "signalant" msgstr "signalant"
#: pedagogy/templates/pedagogy/guide.jinja
msgid "UE Guide"
msgstr "Guide des UEs"
#: pedagogy/templates/pedagogy/guide.jinja #: pedagogy/templates/pedagogy/guide.jinja
msgid "A guide of courses available at UTBM." msgid "A guide of courses available at UTBM."
msgstr "Un guide de tous les cours disponibles à l'UTBM." msgstr "Un guide de tous les cours disponibles à l'UTBM."
@@ -4533,7 +4570,7 @@ msgstr "%(credit_type)s"
#: pedagogy/templates/pedagogy/guide.jinja #: pedagogy/templates/pedagogy/guide.jinja
#: pedagogy/templates/pedagogy/moderation.jinja #: pedagogy/templates/pedagogy/moderation.jinja
msgid "UE" msgid "UV"
msgstr "UE" msgstr "UE"
#: pedagogy/templates/pedagogy/guide.jinja #: pedagogy/templates/pedagogy/guide.jinja
@@ -4545,16 +4582,16 @@ msgid "Credit type"
msgstr "Type de crédit" msgstr "Type de crédit"
#: pedagogy/templates/pedagogy/guide.jinja #: pedagogy/templates/pedagogy/guide.jinja
msgid "closed ue" msgid "closed uv"
msgstr "ue fermée" msgstr "uv fermée"
#: pedagogy/templates/pedagogy/macros.jinja #: pedagogy/templates/pedagogy/macros.jinja
msgid " not rated " msgid " not rated "
msgstr "non noté" msgstr "non noté"
#: pedagogy/templates/pedagogy/moderation.jinja #: pedagogy/templates/pedagogy/moderation.jinja
msgid "UE comment moderation" msgid "UV comment moderation"
msgstr "Modération des commentaires d'UE" msgstr "Modération des commentaires d'UV"
#: pedagogy/templates/pedagogy/moderation.jinja #: pedagogy/templates/pedagogy/moderation.jinja
#: rootplace/templates/rootplace/userban.jinja sas/models.py #: rootplace/templates/rootplace/userban.jinja sas/models.py
@@ -4569,99 +4606,166 @@ msgstr "Supprimer commentaire"
msgid "Delete report" msgid "Delete report"
msgstr "Supprimer signalement" msgstr "Supprimer signalement"
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "UE Details" msgid "UV Details"
msgstr "Détails d'UE" msgstr "Détails d'UV"
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "CM: " msgid "CM: "
msgstr "CM : " msgstr "CM : "
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "TD: " msgid "TD: "
msgstr "TD : " msgstr "TD : "
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "TP: " msgid "TP: "
msgstr "TP : " msgstr "TP : "
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "TE: " msgid "TE: "
msgstr "TE : " msgstr "TE : "
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "THE: " msgid "THE: "
msgstr "THE : " msgstr "THE : "
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "Global grade" msgid "Global grade"
msgstr "Note globale" msgstr "Note globale"
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "Utility" msgid "Utility"
msgstr "Utilité" msgstr "Utilité"
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "Interest" msgid "Interest"
msgstr "Intérêt" msgstr "Intérêt"
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "Teaching" msgid "Teaching"
msgstr "Enseignement" msgstr "Enseignement"
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "Work load" msgid "Work load"
msgstr "Charge de travail" msgstr "Charge de travail"
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "Objectives" msgid "Objectives"
msgstr "Objectifs" msgstr "Objectifs"
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "Program" msgid "Program"
msgstr "Programme" msgstr "Programme"
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "Earned skills" msgid "Earned skills"
msgstr "Compétences acquises" msgstr "Compétences acquises"
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "Key concepts" msgid "Key concepts"
msgstr "Concepts clefs" msgstr "Concepts clefs"
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "UE manager: " msgid "UE manager: "
msgstr "Gestionnaire d'UE : " msgstr "Gestionnaire d'UE : "
#: pedagogy/templates/pedagogy/ue_detail.jinja pedagogy/tests/tests.py #: pedagogy/templates/pedagogy/uv_detail.jinja pedagogy/tests/tests.py
msgid "" msgid ""
"You already posted a comment on this UE. If you want to comment again, " "You already posted a comment on this UV. If you want to comment again, "
"please modify or delete your previous comment." "please modify or delete your previous comment."
msgstr "" msgstr ""
"Vous avez déjà commenté cette UE. Si vous voulez de nouveau commenter, " "Vous avez déjà commenté cette UV. Si vous voulez de nouveau commenter, "
"veuillez modifier ou supprimer votre commentaire précédent." "veuillez modifier ou supprimer votre commentaire précédent."
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "Leave comment" msgid "Leave comment"
msgstr "Laisser un commentaire" msgstr "Laisser un commentaire"
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
#: trombi/templates/trombi/export.jinja #: trombi/templates/trombi/export.jinja
msgid "Comments" msgid "Comments"
msgstr "Commentaires" msgstr "Commentaires"
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "This comment has been reported" msgid "This comment has been reported"
msgstr "Ce commentaire a été signalé" msgstr "Ce commentaire a été signalé"
#: pedagogy/templates/pedagogy/ue_detail.jinja #: pedagogy/templates/pedagogy/uv_detail.jinja
msgid "Report this comment" msgid "Report this comment"
msgstr "Signaler ce commentaire" msgstr "Signaler ce commentaire"
#: pedagogy/templates/pedagogy/ue_edit.jinja #: pedagogy/templates/pedagogy/uv_edit.jinja
msgid "Edit UE" msgid "Edit UE"
msgstr "Éditer l'UE" msgstr "Éditer l'UE"
#: reservation/forms.py
msgid "The start must be set before the end"
msgstr "Le début doit être placé avant la fin"
#: reservation/models.py
msgid "room name"
msgstr "Nom de la salle"
#: reservation/models.py
msgid "room owner"
msgstr "propriétaire de la salle"
#: reservation/models.py
msgid "The club which manages this room"
msgstr "Le club qui gère cette salle"
#: reservation/models.py
msgid "site"
msgstr "site"
#: reservation/models.py
msgid "reservable room"
msgstr "salle réservable"
#: reservation/models.py
msgid "reservable rooms"
msgstr "salles réservables"
#: reservation/models.py
msgid "reserved room"
msgstr "salle réservée"
#: reservation/models.py
msgid "slot start"
msgstr "début du créneau"
#: reservation/models.py
msgid "slot end"
msgstr "fin du créneau"
#: reservation/models.py
msgid "reservation slot"
msgstr "créneau de réservation"
#: reservation/models.py
msgid "reservation slots"
msgstr "créneaux de réservation"
#: reservation/models.py
msgid "There is already a reservation on this slot."
msgstr "Il y a déjà une réservation sur ce créneau."
#: reservation/templates/reservation/fragments/create_reservation.jinja
msgid "Book a room"
msgstr "Réserver une salle"
#: reservation/templates/reservation/schedule.jinja
msgid "You can book a room by selecting a free slot in the calendar."
msgstr ""
"Vous pouvez réserver une salle en sélectionnant un emplacement libre dans le "
"calendrier."
#: reservation/views.py
#, python-format
msgid "%(name)s was updated successfully"
msgstr "%(name)s a été mis à jour avec succès"
#: rootplace/forms.py #: rootplace/forms.py
msgid "User that will be kept" msgid "User that will be kept"
msgstr "Utilisateur qui sera conservé" msgstr "Utilisateur qui sera conservé"

View File

@@ -251,6 +251,14 @@ msgstr "Types de produits réordonnés !"
msgid "Product type reorganisation failed with status code : %d" msgid "Product type reorganisation failed with status code : %d"
msgstr "La réorganisation des types de produit a échoué avec le code : %d" msgstr "La réorganisation des types de produit a échoué avec le code : %d"
#: reservation/static/bundled/reservation/components/room-scheduler-index.ts
msgid "Rooms"
msgstr "Salles"
#: reservation/static/bundled/reservation/slot-reservation-index.ts
msgid "This slot has been successfully moved"
msgstr "Ce créneau a été bougé avec succès"
#: sas/static/bundled/sas/pictures-download-index.ts #: sas/static/bundled/sas/pictures-download-index.ts
msgid "pictures.%(extension)s" msgid "pictures.%(extension)s"
msgstr "photos.%(extension)s" msgstr "photos.%(extension)s"

View File

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

View File

@@ -1,59 +1 @@
# Create your tests here. # 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,8 +23,16 @@
from django.urls import path from django.urls import path
from matmat.views import MatmatronchView from matmat.views import (
SearchClearFormView,
SearchNormalFormView,
SearchQuickFormView,
SearchReverseFormView,
)
urlpatterns = [ urlpatterns = [
path("", MatmatronchView.as_view(), name="search"), 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"),
] ]

View File

@@ -16,49 +16,192 @@
# details. # details.
# #
# You should have received a copy of the GNU General Public License along with # 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 # this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
# Place - Suite 330, Boston, MA 02111-1307, USA. # Place - Suite 330, Boston, MA 02111-1307, USA.
# #
# #
from ast import literal_eval
from enum import Enum
from django.db.models import F from django import forms
from django.views.generic import ListView from django.http.response import HttpResponseRedirect
from django.views.generic.edit import FormMixin 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 core.auth.mixins import FormerSubscriberMixin from core.auth.mixins import FormerSubscriberMixin
from core.models import User, UserQuerySet from core.models import User
from core.schemas import UserFilterSchema from core.views import search_user
from core.views.mixins import AllowFragment from core.views.forms import SelectDate
from matmat.forms import SearchForm
# Enum to select search type
class MatmatronchView(AllowFragment, FormerSubscriberMixin, FormMixin, ListView): 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):
model = User model = User
paginate_by = 20 ordering = ["-id"]
paginate_by = 12
template_name = "matmat/search_form.jinja" template_name = "matmat/search_form.jinja"
form_class = SearchForm
def get(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
self.form = self.get_form() self.form_class = kwargs["form"]
return super().get(request, *args, **kwargs) 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")
def get_initial(self): self.init_query = self.model.objects
return self.request.GET 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)
def get_form_kwargs(self): return super().dispatch(request, *args, **kwargs)
res = super().get_form_kwargs()
if self.request.GET:
res["data"] = self.request.GET
return res
def get_queryset(self) -> UserQuerySet: def post(self, request, *args, **kwargs):
if not self.form.is_valid(): return self.get(request, *args, **kwargs)
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))
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data(form=self.form, **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)
def get_initial(self):
init = self.session.get("matmat_search_form", {})
if not init:
init["department"] = ""
return init
class SearchNormalFormView(SearchFormView):
search_type = SearchType.NORMAL
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"))

435
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "3", "version": "3",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@alpinejs/morph": "^3.14.9",
"@alpinejs/sort": "^3.15.1", "@alpinejs/sort": "^3.15.1",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0", "@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.7.4", "@floating-ui/dom": "^1.7.4",
@@ -16,7 +17,10 @@
"@fullcalendar/core": "^6.1.19", "@fullcalendar/core": "^6.1.19",
"@fullcalendar/daygrid": "^6.1.19", "@fullcalendar/daygrid": "^6.1.19",
"@fullcalendar/icalendar": "^6.1.19", "@fullcalendar/icalendar": "^6.1.19",
"@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/list": "^6.1.19", "@fullcalendar/list": "^6.1.19",
"@fullcalendar/resource": "^6.1.19",
"@fullcalendar/resource-timeline": "^6.1.19",
"@sentry/browser": "^9.46.0", "@sentry/browser": "^9.46.0",
"@zip.js/zip.js": "^2.8.9", "@zip.js/zip.js": "^2.8.9",
"3d-force-graph": "^1.79.0", "3d-force-graph": "^1.79.0",
@@ -30,6 +34,7 @@
"easymde": "^2.20.0", "easymde": "^2.20.0",
"glob": "^11.0.3", "glob": "^11.0.3",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"htmx-ext-alpine-morph": "^2.0.1",
"htmx.org": "^2.0.8", "htmx.org": "^2.0.8",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lit-html": "^3.3.1", "lit-html": "^3.3.1",
@@ -54,10 +59,16 @@
"vite-plugin-static-copy": "^3.1.4" "vite-plugin-static-copy": "^3.1.4"
} }
}, },
"node_modules/@alpinejs/morph": {
"version": "3.15.2",
"resolved": "https://registry.npmjs.org/@alpinejs/morph/-/morph-3.15.2.tgz",
"integrity": "sha512-dt2uAgqRhGbExdVUJ/R4TIIOkzQfOFqGkl6kv6rGxURoFAmMU1iAUNYL4ajA2NCsUWA3KDmk96HrIRA3pv8WWw==",
"license": "MIT"
},
"node_modules/@alpinejs/sort": { "node_modules/@alpinejs/sort": {
"version": "3.15.1", "version": "3.15.2",
"resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.15.1.tgz", "resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.15.2.tgz",
"integrity": "sha512-t64puDxO9D2bUu2ouYAW7A3Ky4fhBW/QsY3wX3S0vr7cDSDhhMqOl/EjmucYvoZnMlieblFvUpVl/CC/Y+u7ng==", "integrity": "sha512-2U/mr/9g1GfozLMsSJMc07jCxW50yHBpeqyQtorKhoXLRMuiSdMjkcwxYsLwvwskvjWar2YMBtjLBSS0R8+yyA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@arendjr/text-clipper": { "node_modules/@arendjr/text-clipper": {
@@ -2257,6 +2268,15 @@
"ical.js": "^1.4.0" "ical.js": "^1.4.0"
} }
}, },
"node_modules/@fullcalendar/interaction": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.19.tgz",
"integrity": "sha512-GOciy79xe8JMVp+1evAU3ytdwN/7tv35t5i1vFkifiuWcQMLC/JnLg/RA2s4sYmQwoYhTw/p4GLcP0gO5B3X5w==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.19"
}
},
"node_modules/@fullcalendar/list": { "node_modules/@fullcalendar/list": {
"version": "6.1.19", "version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.19.tgz", "resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.19.tgz",
@@ -2266,6 +2286,67 @@
"@fullcalendar/core": "~6.1.19" "@fullcalendar/core": "~6.1.19"
} }
}, },
"node_modules/@fullcalendar/premium-common": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/premium-common/-/premium-common-6.1.19.tgz",
"integrity": "sha512-bOWHm1u1dUy6M4fQ0hNK7qEI7SrVWrN1ovv/z4/FE/ybfM19ukz7SFs907Ur7KUBWLNKTQYXBtdrY/ginwWraw==",
"license": "SEE LICENSE IN LICENSE.md",
"peerDependencies": {
"@fullcalendar/core": "~6.1.19"
}
},
"node_modules/@fullcalendar/resource": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/resource/-/resource-6.1.19.tgz",
"integrity": "sha512-br1ylX/aIOfd8m7Tzl2LpJBSI+N9Q6aS1qw7K9qnQjYXWQyHBlfLG6ZcPmmkjfaqTUJc8ARRbtNWj1ts5qOZgQ==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@fullcalendar/premium-common": "~6.1.19"
},
"peerDependencies": {
"@fullcalendar/core": "~6.1.19"
}
},
"node_modules/@fullcalendar/resource-timeline": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/resource-timeline/-/resource-timeline-6.1.19.tgz",
"integrity": "sha512-oC3aVR++dLqJNeBwmLHq9sDgRDFfIG0qSteV7bgBekvNlqEMqXx8wPjUxnELrq8rrhMmK4iV3wO7AB/48IVgyg==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@fullcalendar/premium-common": "~6.1.19",
"@fullcalendar/scrollgrid": "~6.1.19",
"@fullcalendar/timeline": "~6.1.19"
},
"peerDependencies": {
"@fullcalendar/core": "~6.1.19",
"@fullcalendar/resource": "~6.1.19"
}
},
"node_modules/@fullcalendar/scrollgrid": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/scrollgrid/-/scrollgrid-6.1.19.tgz",
"integrity": "sha512-S1pbiYHvmV0ep6z5sWXJQfgW4Y/jrS5iLIAqSagDFPK0jr327nBxl7Ryi3Zb5UdMIP0/O4GXs8jwZabQPd8SOg==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@fullcalendar/premium-common": "~6.1.19"
},
"peerDependencies": {
"@fullcalendar/core": "~6.1.19"
}
},
"node_modules/@fullcalendar/timeline": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/timeline/-/timeline-6.1.19.tgz",
"integrity": "sha512-d2P961mnUTXtJeWNmIq1neoDmZcrPUaK7nGFoc+jQAlnmG3aNSVWQmD1ia694AMqLWtcWkwipW9MuaJgx2QvrA==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@fullcalendar/premium-common": "~6.1.19",
"@fullcalendar/scrollgrid": "~6.1.19"
},
"peerDependencies": {
"@fullcalendar/core": "~6.1.19"
}
},
"node_modules/@hey-api/json-schema-ref-parser": { "node_modules/@hey-api/json-schema-ref-parser": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.6.tgz", "resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.6.tgz",
@@ -2476,9 +2557,9 @@
} }
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.52.5", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
"integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -2490,9 +2571,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.52.5", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
"integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2504,9 +2585,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.52.5", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz",
"integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2518,9 +2599,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.52.5", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
"integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2532,9 +2613,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.52.5", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
"integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2546,9 +2627,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.52.5", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
"integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2560,9 +2641,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.52.5", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
"integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -2574,9 +2655,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.52.5", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
"integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -2588,9 +2669,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.52.5", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
"integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2602,9 +2683,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.52.5", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
"integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2616,9 +2697,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-gnu": { "node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.52.5", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
"integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -2630,9 +2711,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.52.5", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
"integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -2644,9 +2725,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.52.5", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
"integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -2658,9 +2739,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.52.5", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
"integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -2672,9 +2753,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.52.5", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
"integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -2686,9 +2767,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.52.5", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
"integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2700,9 +2781,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.52.5", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
"integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2714,9 +2795,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openharmony-arm64": { "node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.52.5", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
"integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2728,9 +2809,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.52.5", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
"integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2742,9 +2823,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.52.5", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
"integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -2756,9 +2837,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-gnu": { "node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.52.5", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
"integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2770,9 +2851,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.52.5", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
"integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2784,75 +2865,75 @@
] ]
}, },
"node_modules/@sentry-internal/browser-utils": { "node_modules/@sentry-internal/browser-utils": {
"version": "9.46.0", "version": "9.47.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.46.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.47.1.tgz",
"integrity": "sha512-Q0CeHym9wysku8mYkORXmhtlBE0IrafAI+NiPSqxOBKXGOCWKVCvowHuAF56GwPFic2rSrRnub5fWYv7T1jfEQ==", "integrity": "sha512-twv6YhrUlPkvKz4/iQDH4KHgcv9t4cMjmZPf4/dCSCXn4/GOjzjx2d74c1w+1KOdS7lcsQzI+MtbK6SeYLiGfQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry/core": "9.46.0" "@sentry/core": "9.47.1"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry-internal/feedback": { "node_modules/@sentry-internal/feedback": {
"version": "9.46.0", "version": "9.47.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.46.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.47.1.tgz",
"integrity": "sha512-KLRy3OolDkGdPItQ3obtBU2RqDt9+KE8z7r7Gsu7c6A6A89m8ZVlrxee3hPQt6qp0YY0P8WazpedU3DYTtaT8w==", "integrity": "sha512-xJ4vKvIpAT8e+Sz80YrsNinPU0XV7jPxPjdZ4ex8R2mMvx7pM0gq8JiR/sIVmNiOE0WiUDr6VwLDE8j2APSRMA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry/core": "9.46.0" "@sentry/core": "9.47.1"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry-internal/replay": { "node_modules/@sentry-internal/replay": {
"version": "9.46.0", "version": "9.47.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.46.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.47.1.tgz",
"integrity": "sha512-+8JUblxSSnN0FXcmOewbN+wIc1dt6/zaSeAvt2xshrfrLooVullcGsuLAiPhY0d/e++Fk06q1SAl9g4V0V13gg==", "integrity": "sha512-O9ZEfySpstGtX1f73m3NbdbS2utwPikaFt6sgp74RG4ZX4LlXe99VAjKR464xKECpYsLmj2bYpiK4opURF0pBA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry-internal/browser-utils": "9.46.0", "@sentry-internal/browser-utils": "9.47.1",
"@sentry/core": "9.46.0" "@sentry/core": "9.47.1"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry-internal/replay-canvas": { "node_modules/@sentry-internal/replay-canvas": {
"version": "9.46.0", "version": "9.47.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.46.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.47.1.tgz",
"integrity": "sha512-QcBjrdRWFJrrrjbmrr2bbrp2R9RYj1KMEbhHNT2Lm1XplIQw+tULEKOHxNtkUFSLR1RNje7JQbxhzM1j95FxVQ==", "integrity": "sha512-r9nve+l5+elGB9NXSN1+PUgJy790tXN1e8lZNH2ziveoU91jW4yYYt34mHZ30fU9tOz58OpaRMj3H3GJ/jYZVA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry-internal/replay": "9.46.0", "@sentry-internal/replay": "9.47.1",
"@sentry/core": "9.46.0" "@sentry/core": "9.47.1"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry/browser": { "node_modules/@sentry/browser": {
"version": "9.46.0", "version": "9.47.1",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.46.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.47.1.tgz",
"integrity": "sha512-NOnCTQCM0NFuwbyt4DYWDNO2zOTj1mCf43hJqGDFb1XM9F++7zAmSNnCx4UrEoBTiFOy40McJwBBk9D1blSktA==", "integrity": "sha512-at5JOLziw5QpVYytxTDU6xijdV6lDQ/Rxp/qXJaHXud3gIK4suv2cXW+tupJfwoUoHFCnDNfccjCmPmP0yRqiA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry-internal/browser-utils": "9.46.0", "@sentry-internal/browser-utils": "9.47.1",
"@sentry-internal/feedback": "9.46.0", "@sentry-internal/feedback": "9.47.1",
"@sentry-internal/replay": "9.46.0", "@sentry-internal/replay": "9.47.1",
"@sentry-internal/replay-canvas": "9.46.0", "@sentry-internal/replay-canvas": "9.47.1",
"@sentry/core": "9.46.0" "@sentry/core": "9.47.1"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry/core": { "node_modules/@sentry/core": {
"version": "9.46.0", "version": "9.47.1",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.46.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.47.1.tgz",
"integrity": "sha512-it7JMFqxVproAgEtbLgCVBYtQ9fIb+Bu0JD+cEplTN/Ukpe6GaolyYib5geZqslVxhp2sQgT+58aGvfd/k0N8Q==", "integrity": "sha512-KX62+qIt4xgy8eHKHiikfhz2p5fOciXd0Cl+dNzhgPFq8klq4MGMNaf148GB3M/vBqP4nw/eFvRMAayFCgdRQw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -2957,9 +3038,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@zip.js/zip.js": { "node_modules/@zip.js/zip.js": {
"version": "2.8.9", "version": "2.8.11",
"resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.9.tgz", "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.11.tgz",
"integrity": "sha512-/+biUfpFAi/zqW9m+BRrPcUrO13S7CD48kSejrsPclVsWve76WHXv6o5L1NkPVVh4WgrE+ynyzxpPg5OpFuqpA==", "integrity": "sha512-0fztsk/0ryJ+2PPr9EyXS5/Co7OK8q3zY/xOoozEWaUsL5x+C0cyZ4YyMuUffOO2Dx/rAdq4JMPqW0VUtm+vzA==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"bun": ">=0.7.0", "bun": ">=0.7.0",
@@ -3006,9 +3087,9 @@
} }
}, },
"node_modules/alpinejs": { "node_modules/alpinejs": {
"version": "3.15.1", "version": "3.15.2",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.1.tgz", "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.2.tgz",
"integrity": "sha512-HLO1TtiE92VajFHtLLPK8BWaK1YepV/uj31UrfoGnQ00lyFOJZ+oVY3F0DghPAwvg8sLU79pmjGQSytERa2gEg==", "integrity": "sha512-2kYF2aG+DTFkE6p0rHG5XmN4VEb6sO9b02aOdU4+i8QN6rL0DbRZQiypDE1gBcGO65yDcqMz5KKYUYgMUxgNkw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/reactivity": "~3.1.1" "@vue/reactivity": "~3.1.1"
@@ -3134,9 +3215,9 @@
} }
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.25", "version": "2.8.30",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.25.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.30.tgz",
"integrity": "sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA==", "integrity": "sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -3170,9 +3251,9 @@
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.27.0", "version": "4.28.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz",
"integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -3190,10 +3271,10 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.19", "baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001751", "caniuse-lite": "^1.0.30001754",
"electron-to-chromium": "^1.5.238", "electron-to-chromium": "^1.5.249",
"node-releases": "^2.0.26", "node-releases": "^2.0.27",
"update-browserslist-db": "^1.1.4" "update-browserslist-db": "^1.1.4"
}, },
"bin": { "bin": {
@@ -3259,9 +3340,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001754", "version": "1.0.30001756",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001756.tgz",
"integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==", "integrity": "sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -3499,13 +3580,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/core-js-compat": { "node_modules/core-js-compat": {
"version": "3.46.0", "version": "3.47.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.46.0.tgz", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz",
"integrity": "sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law==", "integrity": "sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"browserslist": "^4.26.3" "browserslist": "^4.28.0"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -3761,9 +3842,9 @@
} }
}, },
"node_modules/default-browser": { "node_modules/default-browser": {
"version": "5.2.1", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz",
"integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", "integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3778,9 +3859,9 @@
} }
}, },
"node_modules/default-browser-id": { "node_modules/default-browser-id": {
"version": "5.0.0", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz",
"integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -3850,9 +3931,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.248", "version": "1.5.259",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.248.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.259.tgz",
"integrity": "sha512-zsur2yunphlyAO4gIubdJEXCK6KOVvtpiuDfCIqbM9FjcnMYiyn0ICa3hWfPr0nc41zcLWobgy1iL7VvoOyA2Q==", "integrity": "sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -4121,14 +4202,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/glob": { "node_modules/glob": {
"version": "11.0.3", "version": "11.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
"integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
"license": "ISC", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"foreground-child": "^3.3.1", "foreground-child": "^3.3.1",
"jackspeak": "^4.1.1", "jackspeak": "^4.1.1",
"minimatch": "^10.0.3", "minimatch": "^10.1.1",
"minipass": "^7.1.2", "minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0", "package-json-from-dist": "^1.0.0",
"path-scurry": "^2.0.0" "path-scurry": "^2.0.0"
@@ -4204,6 +4285,14 @@
"node": ">=8.0.0" "node": ">=8.0.0"
} }
}, },
"node_modules/htmx-ext-alpine-morph": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/htmx-ext-alpine-morph/-/htmx-ext-alpine-morph-2.0.2.tgz",
"integrity": "sha512-9pZSSQd0CU0R4/4PhF2/kUbfCcQ+gcxyOMeVwy5fmzfpxOUquVuXWYMoB7EpdMeANzLJ1ceXaakEQwmDj9c9fg==",
"dependencies": {
"htmx.org": "^2.0.2"
}
},
"node_modules/htmx.org": { "node_modules/htmx.org": {
"version": "2.0.8", "version": "2.0.8",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.8.tgz", "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.8.tgz",
@@ -4421,9 +4510,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.0", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -4712,12 +4801,12 @@
} }
}, },
"node_modules/ngraph.graph": { "node_modules/ngraph.graph": {
"version": "20.1.0", "version": "20.1.1",
"resolved": "https://registry.npmjs.org/ngraph.graph/-/ngraph.graph-20.1.0.tgz", "resolved": "https://registry.npmjs.org/ngraph.graph/-/ngraph.graph-20.1.1.tgz",
"integrity": "sha512-1jorNgIc0Kg0L9bTNN4+RCrVvbZ+4pqGVMrbhX3LLyqYcRdLvAQRRnxddmfj9l5f6Eq59SUTfbYZEm8cktiE7Q==", "integrity": "sha512-KNtZWYzYe7SMOuG3vvROznU+fkPmL5cGYFsWjqt+Ob1uF5xZz5EjomtsNOZEIwVuD37/zokeEqNK1ghY4/fhDg==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"ngraph.events": "^1.2.1" "ngraph.events": "^1.4.0"
} }
}, },
"node_modules/ngraph.merge": { "node_modules/ngraph.merge": {
@@ -4832,9 +4921,9 @@
} }
}, },
"node_modules/p-map": { "node_modules/p-map": {
"version": "7.0.3", "version": "7.0.4",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
"integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -4867,9 +4956,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-scurry": { "node_modules/path-scurry": {
"version": "2.0.0", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
"integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"lru-cache": "^11.0.0", "lru-cache": "^11.0.0",
@@ -5110,9 +5199,9 @@
} }
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.52.5", "version": "4.53.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
"integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -5126,28 +5215,28 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.52.5", "@rollup/rollup-android-arm-eabi": "4.53.3",
"@rollup/rollup-android-arm64": "4.52.5", "@rollup/rollup-android-arm64": "4.53.3",
"@rollup/rollup-darwin-arm64": "4.52.5", "@rollup/rollup-darwin-arm64": "4.53.3",
"@rollup/rollup-darwin-x64": "4.52.5", "@rollup/rollup-darwin-x64": "4.53.3",
"@rollup/rollup-freebsd-arm64": "4.52.5", "@rollup/rollup-freebsd-arm64": "4.53.3",
"@rollup/rollup-freebsd-x64": "4.52.5", "@rollup/rollup-freebsd-x64": "4.53.3",
"@rollup/rollup-linux-arm-gnueabihf": "4.52.5", "@rollup/rollup-linux-arm-gnueabihf": "4.53.3",
"@rollup/rollup-linux-arm-musleabihf": "4.52.5", "@rollup/rollup-linux-arm-musleabihf": "4.53.3",
"@rollup/rollup-linux-arm64-gnu": "4.52.5", "@rollup/rollup-linux-arm64-gnu": "4.53.3",
"@rollup/rollup-linux-arm64-musl": "4.52.5", "@rollup/rollup-linux-arm64-musl": "4.53.3",
"@rollup/rollup-linux-loong64-gnu": "4.52.5", "@rollup/rollup-linux-loong64-gnu": "4.53.3",
"@rollup/rollup-linux-ppc64-gnu": "4.52.5", "@rollup/rollup-linux-ppc64-gnu": "4.53.3",
"@rollup/rollup-linux-riscv64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-gnu": "4.53.3",
"@rollup/rollup-linux-riscv64-musl": "4.52.5", "@rollup/rollup-linux-riscv64-musl": "4.53.3",
"@rollup/rollup-linux-s390x-gnu": "4.52.5", "@rollup/rollup-linux-s390x-gnu": "4.53.3",
"@rollup/rollup-linux-x64-gnu": "4.52.5", "@rollup/rollup-linux-x64-gnu": "4.53.3",
"@rollup/rollup-linux-x64-musl": "4.52.5", "@rollup/rollup-linux-x64-musl": "4.53.3",
"@rollup/rollup-openharmony-arm64": "4.52.5", "@rollup/rollup-openharmony-arm64": "4.53.3",
"@rollup/rollup-win32-arm64-msvc": "4.52.5", "@rollup/rollup-win32-arm64-msvc": "4.53.3",
"@rollup/rollup-win32-ia32-msvc": "4.52.5", "@rollup/rollup-win32-ia32-msvc": "4.53.3",
"@rollup/rollup-win32-x64-gnu": "4.52.5", "@rollup/rollup-win32-x64-gnu": "4.53.3",
"@rollup/rollup-win32-x64-msvc": "4.52.5", "@rollup/rollup-win32-x64-msvc": "4.53.3",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },

View File

@@ -21,7 +21,8 @@
"#core:*": "./core/static/bundled/*", "#core:*": "./core/static/bundled/*",
"#pedagogy:*": "./pedagogy/static/bundled/*", "#pedagogy:*": "./pedagogy/static/bundled/*",
"#counter:*": "./counter/static/bundled/*", "#counter:*": "./counter/static/bundled/*",
"#com:*": "./com/static/bundled/*" "#com:*": "./com/static/bundled/*",
"#reservation:*": "./reservation/static/bundled/*"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.28.5", "@babel/core": "^7.28.5",
@@ -39,6 +40,7 @@
"vite-plugin-static-copy": "^3.1.4" "vite-plugin-static-copy": "^3.1.4"
}, },
"dependencies": { "dependencies": {
"@alpinejs/morph": "^3.14.9",
"@alpinejs/sort": "^3.15.1", "@alpinejs/sort": "^3.15.1",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0", "@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.7.4", "@floating-ui/dom": "^1.7.4",
@@ -46,7 +48,10 @@
"@fullcalendar/core": "^6.1.19", "@fullcalendar/core": "^6.1.19",
"@fullcalendar/daygrid": "^6.1.19", "@fullcalendar/daygrid": "^6.1.19",
"@fullcalendar/icalendar": "^6.1.19", "@fullcalendar/icalendar": "^6.1.19",
"@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/list": "^6.1.19", "@fullcalendar/list": "^6.1.19",
"@fullcalendar/resource": "^6.1.19",
"@fullcalendar/resource-timeline": "^6.1.19",
"@sentry/browser": "^9.46.0", "@sentry/browser": "^9.46.0",
"@zip.js/zip.js": "^2.8.9", "@zip.js/zip.js": "^2.8.9",
"3d-force-graph": "^1.79.0", "3d-force-graph": "^1.79.0",
@@ -60,6 +65,7 @@
"easymde": "^2.20.0", "easymde": "^2.20.0",
"glob": "^11.0.3", "glob": "^11.0.3",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"htmx-ext-alpine-morph": "^2.0.1",
"htmx.org": "^2.0.8", "htmx.org": "^2.0.8",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lit-html": "^3.3.1", "lit-html": "^3.3.1",

View File

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

View File

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

View File

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

View File

@@ -2,36 +2,36 @@ from django.conf import settings
from django.core.management import BaseCommand from django.core.management import BaseCommand
from core.models import User from core.models import User
from pedagogy.models import UE from pedagogy.models import UV
from pedagogy.schemas import UeSchema from pedagogy.schemas import UvSchema
from pedagogy.utbm_api import UtbmApiClient from pedagogy.utbm_api import UtbmApiClient
class Command(BaseCommand): class Command(BaseCommand):
help = "Update the UE guide" help = "Update the UV guide"
def handle(self, *args, **options): def handle(self, *args, **options):
seen_ues: set[int] = set() seen_uvs: set[int] = set()
root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID) root_user = User.objects.get(pk=settings.SITH_ROOT_USER_ID)
with UtbmApiClient() as client: with UtbmApiClient() as client:
self.stdout.write( self.stdout.write(
"Fetching UEs from the UTBM API.\n" "Fetching UVs from the UTBM API.\n"
"This may take a few minutes to complete." "This may take a few minutes to complete."
) )
for ue in client.fetch_ues(): for uv in client.fetch_uvs():
db_ue = UE.objects.filter(code=ue.code).first() db_uv = UV.objects.filter(code=uv.code).first()
if db_ue is None: if db_uv is None:
db_ue = UE(code=ue.code, author=root_user) db_uv = UV(code=uv.code, author=root_user)
fields = list(UeSchema.model_fields.keys()) fields = list(UvSchema.model_fields.keys())
fields.remove("id") fields.remove("id")
fields.remove("code") fields.remove("code")
for field in fields: for field in fields:
setattr(db_ue, field, getattr(ue, field)) setattr(db_uv, field, getattr(uv, field))
db_ue.save() db_uv.save()
# if it's a creation, django will set the id when saving, # if it's a creation, django will set the id when saving,
# so at this point, a db_ue will always have an id # so at this point, a db_uv will always have an id
seen_ues.add(db_ue.id) seen_uvs.add(db_uv.id)
# UEs that are in database but have not been returned by the API # UVs that are in database but have not been returned by the API
# are considered as closed UEs # are considered as closed UEs
UE.objects.exclude(id__in=seen_ues).update(semester="CLOSED") UV.objects.exclude(id__in=seen_uvs).update(semester="CLOSED")
self.stdout.write(self.style.SUCCESS("UE guide updated successfully")) self.stdout.write(self.style.SUCCESS("UV guide updated successfully"))

View File

@@ -1,140 +0,0 @@
# Generated by Django 4.2.20 on 2025-04-08 10:12
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("pedagogy", "0003_alter_uv_language"),
]
operations = [
migrations.RenameModel(old_name="UV", new_name="UE"),
migrations.RenameModel(old_name="UVComment", new_name="UEComment"),
migrations.RenameModel(old_name="UVCommentReport", new_name="UECommentReport"),
migrations.RenameModel(old_name="UVResult", new_name="UEResult"),
migrations.RenameField(model_name="ueresult", old_name="uv", new_name="ue"),
migrations.RenameField(model_name="uecomment", old_name="uv", new_name="ue"),
migrations.AlterField(
model_name="ue",
name="credits",
field=models.PositiveIntegerField(verbose_name="credits"),
),
migrations.AlterField(
model_name="ue",
name="hours_CM",
field=models.PositiveIntegerField(default=0, verbose_name="hours CM"),
),
migrations.AlterField(
model_name="ue",
name="hours_TD",
field=models.PositiveIntegerField(default=0, verbose_name="hours TD"),
),
migrations.AlterField(
model_name="ue",
name="hours_TE",
field=models.PositiveIntegerField(default=0, verbose_name="hours TE"),
),
migrations.AlterField(
model_name="ue",
name="hours_THE",
field=models.PositiveIntegerField(default=0, verbose_name="hours THE"),
),
migrations.AlterField(
model_name="ue",
name="hours_TP",
field=models.PositiveIntegerField(default=0, verbose_name="hours TP"),
),
migrations.AlterField(
model_name="ue",
name="manager",
field=models.CharField(max_length=300, verbose_name="ue manager"),
),
migrations.AlterField(
model_name="ueresult",
name="ue",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="results",
to="pedagogy.ue",
verbose_name="ue",
),
),
migrations.AlterField(
model_name="ue",
name="author",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ue_created",
to=settings.AUTH_USER_MODEL,
verbose_name="author",
),
),
migrations.AlterField(
model_name="ue",
name="code",
field=models.CharField(
max_length=10,
unique=True,
validators=[
django.core.validators.RegexValidator(
message=(
"The code of an UE must only contains "
"uppercase characters without accent and numbers"
),
regex="([A-Z0-9]+)",
)
],
verbose_name="code",
),
),
migrations.AlterField(
model_name="uecomment",
name="author",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ue_comments",
to=settings.AUTH_USER_MODEL,
verbose_name="author",
),
),
migrations.AlterField(
model_name="uecomment",
name="comment",
field=models.TextField(blank=True, default="", verbose_name="comment"),
),
migrations.AlterField(
model_name="uecomment",
name="ue",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="comments",
to="pedagogy.ue",
verbose_name="ue",
),
),
migrations.AlterField(
model_name="uecommentreport",
name="reporter",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="reported_ue_comment",
to=settings.AUTH_USER_MODEL,
verbose_name="reporter",
),
),
migrations.AlterField(
model_name="ueresult",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ue_results",
to=settings.AUTH_USER_MODEL,
verbose_name="user",
),
),
]

View File

@@ -36,8 +36,8 @@ from core.models import User
# Create your models here. # Create your models here.
class UE(models.Model): class UV(models.Model):
"""Contains infos about an UE (course).""" """Contains infos about an UV (course)."""
code = models.CharField( code = models.CharField(
_("code"), _("code"),
@@ -47,7 +47,7 @@ class UE(models.Model):
validators.RegexValidator( validators.RegexValidator(
regex="([A-Z0-9]+)", regex="([A-Z0-9]+)",
message=_( message=_(
"The code of an UE must only contains " "The code of an UV must only contains "
"uppercase characters without accent and numbers" "uppercase characters without accent and numbers"
), ),
) )
@@ -55,7 +55,7 @@ class UE(models.Model):
) )
author = models.ForeignKey( author = models.ForeignKey(
User, User,
related_name="ue_created", related_name="uv_created",
verbose_name=_("author"), verbose_name=_("author"),
null=False, null=False,
blank=False, blank=False,
@@ -64,23 +64,29 @@ class UE(models.Model):
credit_type = models.CharField( credit_type = models.CharField(
_("credit type"), _("credit type"),
max_length=10, max_length=10,
choices=settings.SITH_PEDAGOGY_UE_TYPE, choices=settings.SITH_PEDAGOGY_UV_TYPE,
default=settings.SITH_PEDAGOGY_UE_TYPE[0][0], default=settings.SITH_PEDAGOGY_UV_TYPE[0][0],
) )
manager = models.CharField(_("ue manager"), max_length=300) manager = models.CharField(_("uv manager"), max_length=300)
semester = models.CharField( semester = models.CharField(
_("semester"), _("semester"),
max_length=20, max_length=20,
choices=settings.SITH_PEDAGOGY_UE_SEMESTER, choices=settings.SITH_PEDAGOGY_UV_SEMESTER,
default=settings.SITH_PEDAGOGY_UE_SEMESTER[0][0], default=settings.SITH_PEDAGOGY_UV_SEMESTER[0][0],
) )
language = models.CharField( language = models.CharField(
_("language"), _("language"),
max_length=10, max_length=10,
choices=settings.SITH_PEDAGOGY_UE_LANGUAGE, choices=settings.SITH_PEDAGOGY_UV_LANGUAGE,
default=settings.SITH_PEDAGOGY_UE_LANGUAGE[0][0], default=settings.SITH_PEDAGOGY_UV_LANGUAGE[0][0],
) )
credits = models.PositiveIntegerField(_("credits")) credits = models.IntegerField(
_("credits"),
validators=[validators.MinValueValidator(0)],
blank=False,
null=False,
)
# Double star type not implemented yet
department = models.CharField( department = models.CharField(
_("departmenmt"), _("departmenmt"),
@@ -89,9 +95,9 @@ class UE(models.Model):
default=settings.SITH_PROFILE_DEPARTMENTS[-1][0], default=settings.SITH_PROFILE_DEPARTMENTS[-1][0],
) )
# All texts about the UE # All texts about the UV
title = models.CharField(_("title"), max_length=300) title = models.CharField(_("title"), max_length=300)
manager = models.CharField(_("ue manager"), max_length=300) manager = models.CharField(_("uv manager"), max_length=300)
objectives = models.TextField(_("objectives")) objectives = models.TextField(_("objectives"))
program = models.TextField(_("program")) program = models.TextField(_("program"))
skills = models.TextField(_("skills")) skills = models.TextField(_("skills"))
@@ -99,17 +105,47 @@ class UE(models.Model):
# Hours types CM, TD, TP, THE and TE # Hours types CM, TD, TP, THE and TE
# Kind of dirty but I have nothing else in mind for now # Kind of dirty but I have nothing else in mind for now
hours_CM = models.PositiveIntegerField(_("hours CM"), default=0) hours_CM = models.IntegerField(
hours_TD = models.PositiveIntegerField(_("hours TD"), default=0) _("hours CM"),
hours_TP = models.PositiveIntegerField(_("hours TP"), default=0) validators=[validators.MinValueValidator(0)],
hours_THE = models.PositiveIntegerField(_("hours THE"), default=0) blank=False,
hours_TE = models.PositiveIntegerField(_("hours TE"), default=0) null=False,
default=0,
)
hours_TD = models.IntegerField(
_("hours TD"),
validators=[validators.MinValueValidator(0)],
blank=False,
null=False,
default=0,
)
hours_TP = models.IntegerField(
_("hours TP"),
validators=[validators.MinValueValidator(0)],
blank=False,
null=False,
default=0,
)
hours_THE = models.IntegerField(
_("hours THE"),
validators=[validators.MinValueValidator(0)],
blank=False,
null=False,
default=0,
)
hours_TE = models.IntegerField(
_("hours TE"),
validators=[validators.MinValueValidator(0)],
blank=False,
null=False,
default=0,
)
def __str__(self): def __str__(self):
return self.code return self.code
def get_absolute_url(self): def get_absolute_url(self):
return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.id}) return reverse("pedagogy:uv_detail", kwargs={"uv_id": self.id})
def __grade_average_generic(self, field): def __grade_average_generic(self, field):
comments = self.comments.filter(**{field + "__gte": 0}) comments = self.comments.filter(**{field + "__gte": 0})
@@ -124,7 +160,7 @@ class UE(models.Model):
This function checks that no other comment has been posted by a specified user. This function checks that no other comment has been posted by a specified user.
Returns: Returns:
True if the user has already posted a comment on this UE, else False. True if the user has already posted a comment on this UV, else False.
""" """
return self.comments.filter(author=user).exists() return self.comments.filter(author=user).exists()
@@ -149,66 +185,78 @@ class UE(models.Model):
return self.__grade_average_generic("grade_work_load") return self.__grade_average_generic("grade_work_load")
class UECommentQuerySet(models.QuerySet): class UVCommentQuerySet(models.QuerySet):
def viewable_by(self, user: User) -> Self: def viewable_by(self, user: User) -> Self:
if user.has_perms(["pedagogy.view_uecomment", "pedagogy.view_uecommentreport"]): if user.has_perms(["pedagogy.view_uvcomment", "pedagogy.view_uvcommentreport"]):
# the user can view ue comment reports, # the user can view uv comment reports,
# so he can view non-moderated comments # so he can view non-moderated comments
return self return self
if user.has_perm("pedagogy.view_uecomment"): if user.has_perm("pedagogy.view_uvcomment"):
return self.filter(reports=None) return self.filter(reports=None)
return self.filter(author=user) return self.filter(author=user)
def annotate_is_reported(self) -> Self: def annotate_is_reported(self) -> Self:
return self.annotate( return self.annotate(
is_reported=Exists(UECommentReport.objects.filter(comment=OuterRef("pk"))) is_reported=Exists(UVCommentReport.objects.filter(comment=OuterRef("pk")))
) )
class UEComment(models.Model): class UVComment(models.Model):
"""A comment about an UE.""" """A comment about an UV."""
author = models.ForeignKey( author = models.ForeignKey(
User, User,
related_name="ue_comments", related_name="uv_comments",
verbose_name=_("author"), verbose_name=_("author"),
null=False,
blank=False,
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
ue = models.ForeignKey( uv = models.ForeignKey(
UE, related_name="comments", verbose_name=_("ue"), on_delete=models.CASCADE UV, related_name="comments", verbose_name=_("uv"), on_delete=models.CASCADE
) )
comment = models.TextField(_("comment"), blank=True, default="") comment = models.TextField(_("comment"), blank=True)
grade_global = models.IntegerField( grade_global = models.IntegerField(
_("global grade"), _("global grade"),
validators=[validators.MinValueValidator(-1), validators.MaxValueValidator(4)], validators=[validators.MinValueValidator(-1), validators.MaxValueValidator(4)],
blank=False,
null=False,
default=-1, default=-1,
) )
grade_utility = models.IntegerField( grade_utility = models.IntegerField(
_("utility grade"), _("utility grade"),
validators=[validators.MinValueValidator(-1), validators.MaxValueValidator(4)], validators=[validators.MinValueValidator(-1), validators.MaxValueValidator(4)],
blank=False,
null=False,
default=-1, default=-1,
) )
grade_interest = models.IntegerField( grade_interest = models.IntegerField(
_("interest grade"), _("interest grade"),
validators=[validators.MinValueValidator(-1), validators.MaxValueValidator(4)], validators=[validators.MinValueValidator(-1), validators.MaxValueValidator(4)],
blank=False,
null=False,
default=-1, default=-1,
) )
grade_teaching = models.IntegerField( grade_teaching = models.IntegerField(
_("teaching grade"), _("teaching grade"),
validators=[validators.MinValueValidator(-1), validators.MaxValueValidator(4)], validators=[validators.MinValueValidator(-1), validators.MaxValueValidator(4)],
blank=False,
null=False,
default=-1, default=-1,
) )
grade_work_load = models.IntegerField( grade_work_load = models.IntegerField(
_("work load grade"), _("work load grade"),
validators=[validators.MinValueValidator(-1), validators.MaxValueValidator(4)], validators=[validators.MinValueValidator(-1), validators.MaxValueValidator(4)],
blank=False,
null=False,
default=-1, default=-1,
) )
publish_date = models.DateTimeField(_("publish date"), blank=True) publish_date = models.DateTimeField(_("publish date"), blank=True)
objects = UECommentQuerySet.as_manager() objects = UVCommentQuerySet.as_manager()
def __str__(self): def __str__(self):
return f"{self.ue} - {self.author}" return f"{self.uv} - {self.author}"
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.publish_date is None: if self.publish_date is None:
@@ -220,32 +268,30 @@ class UEComment(models.Model):
# to use this model. # to use this model.
# However, it seems that the implementation finally didn't happen. # However, it seems that the implementation finally didn't happen.
# It should be discussed, when possible, of what to do with that : # It should be discussed, when possible, of what to do with that :
# - go on and finally implement the UE results features ? # - go on and finally implement the UV results features ?
# - or fuck go back and remove this model ? # - or fuck go back and remove this model ?
class UEResult(models.Model): class UVResult(models.Model):
"""Results got to an UE. """Results got to an UV.
Views will be implemented after the first release Views will be implemented after the first release
Will list every UE done by an user Will list every UV done by an user
Linked to user and ue Linked to user
Contains a grade settings.SITH_PEDAGOGY_UE_RESULT_GRADE uv
Contains a grade settings.SITH_PEDAGOGY_UV_RESULT_GRADE
a semester (P/A)20xx. a semester (P/A)20xx.
""" """
ue = models.ForeignKey( uv = models.ForeignKey(
UE, related_name="results", verbose_name=_("ue"), on_delete=models.CASCADE UV, related_name="results", verbose_name=_("uv"), on_delete=models.CASCADE
) )
user = models.ForeignKey( user = models.ForeignKey(
User, User, related_name="uv_results", verbose_name=("user"), on_delete=models.CASCADE
related_name="ue_results",
verbose_name=_("user"),
on_delete=models.CASCADE,
) )
grade = models.CharField( grade = models.CharField(
_("grade"), _("grade"),
max_length=10, max_length=10,
choices=settings.SITH_PEDAGOGY_UE_RESULT_GRADE, choices=settings.SITH_PEDAGOGY_UV_RESULT_GRADE,
default=settings.SITH_PEDAGOGY_UE_RESULT_GRADE[0][0], default=settings.SITH_PEDAGOGY_UV_RESULT_GRADE[0][0],
) )
semester = models.CharField( semester = models.CharField(
_("semester"), _("semester"),
@@ -254,21 +300,21 @@ class UEResult(models.Model):
) )
def __str__(self): def __str__(self):
return f"{self.user.username} ; {self.ue.code} ; {self.grade}" return f"{self.user.username} ; {self.uv.code} ; {self.grade}"
class UECommentReport(models.Model): class UVCommentReport(models.Model):
"""Report an inapropriate comment.""" """Report an inapropriate comment."""
comment = models.ForeignKey( comment = models.ForeignKey(
UEComment, UVComment,
related_name="reports", related_name="reports",
verbose_name=_("report"), verbose_name=_("report"),
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
reporter = models.ForeignKey( reporter = models.ForeignKey(
User, User,
related_name="reported_ue_comment", related_name="reported_uv_comment",
verbose_name=_("reporter"), verbose_name=_("reporter"),
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
@@ -278,5 +324,5 @@ class UECommentReport(models.Model):
return f"{self.reporter.username} : {self.reason}" return f"{self.reporter.username} : {self.reason}"
@cached_property @cached_property
def ue(self): def uv(self):
return self.comment.ue return self.comment.uv

View File

@@ -1,17 +1,17 @@
from typing import Annotated, Literal from typing import Literal
from django.db.models import Q from django.db.models import Q
from django.utils import html from django.utils import html
from haystack.query import SearchQuerySet from haystack.query import SearchQuerySet
from ninja import FilterLookup, FilterSchema, ModelSchema, Schema from ninja import FilterSchema, ModelSchema, Schema
from pydantic import AliasPath, ConfigDict, Field, TypeAdapter from pydantic import AliasPath, ConfigDict, Field, TypeAdapter
from pydantic.alias_generators import to_camel from pydantic.alias_generators import to_camel
from pedagogy.models import UE from pedagogy.models import UV
class UtbmShortUeSchema(Schema): class UtbmShortUvSchema(Schema):
"""Short representation of an UE in the UTBM API. """Short representation of an UV in the UTBM API.
Notes: Notes:
This schema holds only the fields we actually need. This schema holds only the fields we actually need.
@@ -35,8 +35,8 @@ class WorkloadSchema(Schema):
nbh: int nbh: int
class SemesterUeState(Schema): class SemesterUvState(Schema):
"""The state of the UE during either autumn or spring semester""" """The state of the UV during either autumn or spring semester"""
model_config = ConfigDict(alias_generator=to_camel) model_config = ConfigDict(alias_generator=to_camel)
@@ -44,11 +44,11 @@ class SemesterUeState(Schema):
ouvert: bool ouvert: bool
ShortUeList = TypeAdapter(list[UtbmShortUeSchema]) ShortUvList = TypeAdapter(list[UtbmShortUvSchema])
class UtbmFullUeSchema(Schema): class UtbmFullUvSchema(Schema):
"""Long representation of an UE in the UTBM API.""" """Long representation of an UV in the UTBM API."""
model_config = ConfigDict(alias_generator=to_camel) model_config = ConfigDict(alias_generator=to_camel)
@@ -71,11 +71,11 @@ class UtbmFullUeSchema(Schema):
) )
class SimpleUeSchema(ModelSchema): class SimpleUvSchema(ModelSchema):
"""Our minimal representation of an UE.""" """Our minimal representation of an UV."""
class Meta: class Meta:
model = UE model = UV
fields = [ fields = [
"id", "id",
"title", "title",
@@ -86,11 +86,11 @@ class SimpleUeSchema(ModelSchema):
] ]
class UeSchema(ModelSchema): class UvSchema(ModelSchema):
"""Our complete representation of an UE""" """Our complete representation of an UV"""
class Meta: class Meta:
model = UE model = UV
fields = [ fields = [
"id", "id",
"title", "title",
@@ -113,15 +113,14 @@ class UeSchema(ModelSchema):
] ]
class UeFilterSchema(FilterSchema): class UvFilterSchema(FilterSchema):
search: Annotated[str | None, FilterLookup("code__icontains")] = None search: str | None = Field(None, q="code__icontains")
semester: set[Literal["AUTUMN", "SPRING"]] | None = None semester: set[Literal["AUTUMN", "SPRING"]] | None = None
credit_type: Annotated[ credit_type: set[Literal["CS", "TM", "EC", "OM", "QC"]] | None = Field(
set[Literal["CS", "TM", "EC", "OM", "QC"]] | None, None, q="credit_type__in"
FilterLookup("credit_type__in"), )
] = None
language: str = "FR" language: str = "FR"
department: Annotated[set[str] | None, FilterLookup("department__in")] = None department: set[str] | None = Field(None, q="department__in")
def filter_search(self, value: str | None) -> Q: def filter_search(self, value: str | None) -> Q:
"""Special filter for the search text. """Special filter for the search text.
@@ -132,12 +131,12 @@ class UeFilterSchema(FilterSchema):
return Q() return Q()
if len(value) < 3 or (len(value) < 5 and any(c.isdigit() for c in value)): if len(value) < 3 or (len(value) < 5 and any(c.isdigit() for c in value)):
# Likely to be an UE code # Likely to be an UV code
return Q(code__istartswith=value) return Q(code__istartswith=value)
qs = list( qs = list(
SearchQuerySet() SearchQuerySet()
.models(UE) .models(UV)
.autocomplete(auto=html.escape(value)) .autocomplete(auto=html.escape(value))
.values_list("pk", flat=True) .values_list("pk", flat=True)
) )
@@ -147,7 +146,7 @@ class UeFilterSchema(FilterSchema):
def filter_semester(self, value: set[str] | None) -> Q: def filter_semester(self, value: set[str] | None) -> Q:
"""Special filter for the semester. """Special filter for the semester.
If either "SPRING" or "AUTUMN" is given, UE that are available If either "SPRING" or "AUTUMN" is given, UV that are available
during "AUTUMN_AND_SPRING" will be filtered. during "AUTUMN_AND_SPRING" will be filtered.
""" """
if not value: if not value:

View File

@@ -25,28 +25,28 @@ from django.db import models
from haystack import indexes, signals from haystack import indexes, signals
from core.search_indexes import BigCharFieldIndex from core.search_indexes import BigCharFieldIndex
from pedagogy.models import UE from pedagogy.models import UV
class IndexSignalProcessor(signals.BaseSignalProcessor): class IndexSignalProcessor(signals.BaseSignalProcessor):
"""Auto update index on CRUD operations.""" """Auto update index on CRUD operations."""
def setup(self): def setup(self):
# Listen only to the ``UE`` model. # Listen only to the ``UV`` model.
models.signals.post_save.connect(self.handle_save, sender=UE) models.signals.post_save.connect(self.handle_save, sender=UV)
models.signals.post_delete.connect(self.handle_delete, sender=UE) models.signals.post_delete.connect(self.handle_delete, sender=UV)
def teardown(self): def teardown(self):
# Disconnect only to the ``UE`` model. # Disconnect only to the ``UV`` model.
models.signals.post_save.disconnect(self.handle_save, sender=UE) models.signals.post_save.disconnect(self.handle_save, sender=UV)
models.signals.post_delete.disconnect(self.handle_delete, sender=UE) models.signals.post_delete.disconnect(self.handle_delete, sender=UV)
class UEIndex(indexes.SearchIndex, indexes.Indexable): class UVIndex(indexes.SearchIndex, indexes.Indexable):
"""Indexer class for UEs.""" """Indexer class for UVs."""
text = BigCharFieldIndex(document=True, use_template=True) text = BigCharFieldIndex(document=True, use_template=True)
auto = indexes.EdgeNgramField(use_template=True) auto = indexes.EdgeNgramField(use_template=True)
def get_model(self): def get_model(self):
return UE return UV

View File

@@ -1,12 +1,12 @@
import { History, getCurrentUrlParams, updateQueryString } from "#core:utils/history"; import { History, getCurrentUrlParams, updateQueryString } from "#core:utils/history";
import { ueFetchUeList } from "#openapi"; import { uvFetchUvList } from "#openapi";
const pageDefault = 1; const pageDefault = 1;
const pageSizeDefault = 100; const pageSizeDefault = 100;
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("ue_search", () => ({ Alpine.data("uv_search", () => ({
ues: { uvs: {
count: 0, count: 0,
next: null, next: null,
previous: null, previous: null,
@@ -103,12 +103,16 @@ document.addEventListener("alpine:init", () => {
args[param] = value; args[param] = value;
} }
} }
this.ues = (await ueFetchUeList({ query: args })).data; this.uvs = (
await uvFetchUvList({
query: args,
})
).data;
this.loading = false; this.loading = false;
}, },
maxPage() { maxPage() {
return Math.ceil(this.ues.count / this.page_size); return Math.ceil(this.uvs.count / this.page_size);
}, },
})); }));
}); });

View File

@@ -50,7 +50,7 @@ $large-devices: 992px;
} }
} }
#ue-list { #uv-list {
font-size: 1.1em; font-size: 1.1em;
overflow-wrap: break-word; overflow-wrap: break-word;
@@ -164,10 +164,10 @@ $large-devices: 992px;
} }
} }
#ue_detail { #uv_detail {
color: #062f38; color: #062f38;
.ue-quick-info-container { .uv-quick-info-container {
display: grid; display: grid;
grid-template-columns: 20% 20% 20% 20% auto; grid-template-columns: 20% 20% 20% 20% auto;
grid-template-rows: auto auto; grid-template-rows: auto auto;
@@ -254,20 +254,20 @@ $large-devices: 992px;
} }
} }
.ue-details-container { .uv-details-container {
display: grid; display: grid;
grid-template-columns: 150px 100px auto; grid-template-columns: 150px 100px auto;
grid-template-rows: 156px 1fr; grid-template-rows: 156px 1fr;
grid-template-areas: grid-template-areas:
"grade grade-stars ue-infos" "grade grade-stars uv-infos"
". . ue-infos"; ". . uv-infos";
@media screen and (max-width: $large-devices) { @media screen and (max-width: $large-devices) {
grid-template-columns: 50% 50%; grid-template-columns: 50% 50%;
grid-template-rows: auto auto; grid-template-rows: auto auto;
grid-template-areas: grid-template-areas:
"grade grade-stars" "grade grade-stars"
"ue-infos ue-infos"; "uv-infos uv-infos";
} }
} }
@@ -290,8 +290,8 @@ $large-devices: 992px;
font-weight: bold; font-weight: bold;
} }
.ue-infos { .uv-infos {
grid-area: ue-infos; grid-area: uv-infos;
padding-left: 10px; padding-left: 10px;
} }

View File

@@ -23,10 +23,10 @@
{% endblock head %} {% endblock head %}
{% block content %} {% block content %}
{% if user.has_perm("pedagogy.add_ue") %} {% if user.has_perm("pedagogy.add_uv") %}
<div class="action-bar"> <div class="action-bar">
<p> <p>
<a href="{{ url('pedagogy:ue_create') }}">{% trans %}Create UE{% endtrans %}</a> <a href="{{ url('pedagogy:uv_create') }}">{% trans %}Create UV{% endtrans %}</a>
</p> </p>
<p> <p>
<a href="{{ url('pedagogy:moderation') }}">{% trans %}Moderate comments{% endtrans %}</a> <a href="{{ url('pedagogy:moderation') }}">{% trans %}Moderate comments{% endtrans %}</a>
@@ -34,7 +34,7 @@
</div> </div>
<br/> <br/>
{% endif %} {% endif %}
<div class="pedagogy" x-data="ue_search" x-cloak> <div class="pedagogy" x-data="uv_search" x-cloak>
<form id="search_form"> <form id="search_form">
<div class="search-form-container"> <div class="search-form-container">
<div class="search-bar"> <div class="search-bar">
@@ -89,43 +89,43 @@
</div> </div>
</div> </div>
</form> </form>
<table id="ue-list"> <table id="uv-list">
<thead> <thead>
<tr> <tr>
<td>{% trans %}UE{% endtrans %}</td> <td>{% trans %}UV{% endtrans %}</td>
<td>{% trans %}Title{% endtrans %}</td> <td>{% trans %}Title{% endtrans %}</td>
<td>{% trans %}Department{% endtrans %}</td> <td>{% trans %}Department{% endtrans %}</td>
<td>{% trans %}Credit type{% endtrans %}</td> <td>{% trans %}Credit type{% endtrans %}</td>
<td><i class="fa fa-leaf"></i></td> <td><i class="fa fa-leaf"></i></td>
<td><i class="fa-regular fa-sun"></i></td> <td><i class="fa-regular fa-sun"></i></td>
{%- if user.has_perm("pedagogy.change_ue") -%} {%- if user.has_perm("pedagogy.change_uv") -%}
<td>{% trans %}Edit{% endtrans %}</td> <td>{% trans %}Edit{% endtrans %}</td>
{%- endif -%} {%- endif -%}
{%- if user.has_perm("pedagogy.delete_ue") -%} {%- if user.has_perm("pedagogy.delete_uv") -%}
<td>{% trans %}Delete{% endtrans %}</td> <td>{% trans %}Delete{% endtrans %}</td>
{% endif %} {% endif %}
</tr> </tr>
</thead> </thead>
<tbody :aria-busy="loading"> <tbody :aria-busy="loading">
<template x-for="ue in ues.results" :key="ue.id"> <template x-for="uv in uvs.results" :key="uv.id">
<tr <tr
@click="window.location.href = `/pedagogy/ue/${ue.id}`" @click="window.location.href = `/pedagogy/uv/${uv.id}`"
class="clickable" class="clickable"
:class="{closed: ue.semester === 'CLOSED'}" :class="{closed: uv.semester === 'CLOSED'}"
> >
<td><a :href="`/pedagogy/ue/${ue.id}`" x-text="ue.code"></a></td> <td><a :href="`/pedagogy/uv/${uv.id}`" x-text="uv.code"></a></td>
<td class="title" <td class="title"
x-text="ue.title + (ue.semester === 'CLOSED' ? ' ({% trans %}closed ue{% endtrans %})' : '')" x-text="uv.title + (uv.semester === 'CLOSED' ? ' ({% trans %}closed uv{% endtrans %})' : '')"
></td> ></td>
<td x-text="ue.department"></td> <td x-text="uv.department"></td>
<td x-text="ue.credit_type"></td> <td x-text="uv.credit_type"></td>
<td><i :class="ue.semester.includes('AUTUMN') && 'fa fa-leaf'"></i></td> <td><i :class="uv.semester.includes('AUTUMN') && 'fa fa-leaf'"></i></td>
<td><i :class="ue.semester.includes('SPRING') && 'fa-regular fa-sun'"></i></td> <td><i :class="uv.semester.includes('SPRING') && 'fa-regular fa-sun'"></i></td>
{%- if user.has_perm("pedagogy.change_ue") -%} {%- if user.has_perm("pedagogy.change_uv") -%}
<td><a :href="`/pedagogy/ue/${ue.id}/edit`">{% trans %}Edit{% endtrans %}</a></td> <td><a :href="`/pedagogy/uv/${uv.id}/edit`">{% trans %}Edit{% endtrans %}</a></td>
{%- endif -%} {%- endif -%}
{%- if user.has_perm("pedagogy.delete_ue") -%} {%- if user.has_perm("pedagogy.delete_uv") -%}
<td><a :href="`/pedagogy/ue/${ue.id}/delete`">{% trans %}Delete{% endtrans %}</a></td> <td><a :href="`/pedagogy/uv/${uv.id}/delete`">{% trans %}Delete{% endtrans %}</a></td>
{%- endif -%} {%- endif -%}
</tr> </tr>
</template> </template>

View File

@@ -1,7 +1,7 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% block title %} {% block title %}
{% trans %}UE comment moderation{% endtrans %} {% trans %}UV comment moderation{% endtrans %}
{% endblock title %} {% endblock title %}
{% block content %} {% block content %}
@@ -9,7 +9,7 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<td>{% trans %}UE{% endtrans %}</td> <td>{% trans %}UV{% endtrans %}</td>
<td>{% trans %}Comment{% endtrans %}</td> <td>{% trans %}Comment{% endtrans %}</td>
<td>{% trans %}Reason{% endtrans %}</td> <td>{% trans %}Reason{% endtrans %}</td>
<td>{% trans %}Action{% endtrans %}</td> <td>{% trans %}Action{% endtrans %}</td>
@@ -22,7 +22,7 @@
<form action="{{ url('pedagogy:moderation') }}" method="post" enctype="multipart/form-data"> <form action="{{ url('pedagogy:moderation') }}" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<tr> <tr>
<td><a href="{{ url('pedagogy:ue_detail', ue_id=report.comment.ue_id) }}#{{ report.comment.ue_id }}">{{ report.comment.ue }}</a></td> <td><a href="{{ url('pedagogy:uv_detail', uv_id=report.comment.uv.id) }}#{{ report.comment.uv.id }}">{{ report.comment.uv }}</a></td>
<td>{{ report.comment.comment|markdown }}</td> <td>{{ report.comment.comment|markdown }}</td>
<td>{{ report.reason|markdown }}</td> <td>{{ report.reason|markdown }}</td>
<td> <td>

View File

@@ -7,12 +7,12 @@
{% endblock %} {% endblock %}
{% block title %} {% block title %}
{% trans %}UE Details{% endtrans %} {% trans %}UV Details{% endtrans %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="pedagogy"> <div class="pedagogy">
<div id="ue_detail"> <div id="uv_detail">
<button onclick='(function(){ <button onclick='(function(){
// If comes from the guide page, go back with history // If comes from the guide page, go back with history
if (document.referrer.replace(/\?(.+)/gm,"").endsWith(`{{ url("pedagogy:guide") }}`)){ if (document.referrer.replace(/\?(.+)/gm,"").endsWith(`{{ url("pedagogy:guide") }}`)){
@@ -25,7 +25,7 @@
<h1>{{ object.code }} - {{ object.title }}</h1> <h1>{{ object.code }} - {{ object.title }}</h1>
<br> <br>
<div class="ue-quick-info-container"> <div class="uv-quick-info-container">
<div class="hours-cm"> <div class="hours-cm">
<b>{% trans %}CM: {% endtrans %}</b>{{ object.hours_CM }} <b>{% trans %}CM: {% endtrans %}</b>{{ object.hours_CM }}
</div> </div>
@@ -55,7 +55,7 @@
<br> <br>
<div class="ue-details-container"> <div class="uv-details-container">
<div class="grade"> <div class="grade">
<p>{% trans %}Global grade{% endtrans %}</p> <p>{% trans %}Global grade{% endtrans %}</p>
<p>{% trans %}Utility{% endtrans %}</p> <p>{% trans %}Utility{% endtrans %}</p>
@@ -70,7 +70,7 @@
<p>{{ display_star(object.grade_teaching_average) }}</p> <p>{{ display_star(object.grade_teaching_average) }}</p>
<p>{{ display_star(object.grade_work_load_average) }}</p> <p>{{ display_star(object.grade_work_load_average) }}</p>
</div> </div>
<div class="ue-infos"> <div class="uv-infos">
<p><b>{% trans %}Objectives{% endtrans %}</b></p> <p><b>{% trans %}Objectives{% endtrans %}</b></p>
<p>{{ object.objectives|markdown }}</p> <p>{{ object.objectives|markdown }}</p>
<p><b>{% trans %}Program{% endtrans %}</b></p> <p><b>{% trans %}Program{% endtrans %}</b></p>
@@ -86,21 +86,21 @@
<br> <br>
{% if object.has_user_already_commented(user) %} {% if object.has_user_already_commented(user) %}
<div id="leave_comment_not_allowed"> <div id="leave_comment_not_allowed">
<p>{% trans %}You already posted a comment on this UE. If you want to comment again, please modify or delete your previous comment.{% endtrans %}</p> <p>{% trans %}You already posted a comment on this UV. If you want to comment again, please modify or delete your previous comment.{% endtrans %}</p>
</div> </div>
{% elif user.has_perm("pedagogy.add_uecomment") %} {% elif user.has_perm("pedagogy.add_uvcomment") %}
<details class="accordion" id="leave_comment"> <details class="accordion" id="leave_comment">
<summary>{% trans %}Leave comment{% endtrans %}</summary> <summary>{% trans %}Leave comment{% endtrans %}</summary>
<div class="accordion-content"> <div class="accordion-content">
<form action="{{ url('pedagogy:ue_detail', ue_id=object.id) }}" method="post" enctype="multipart/form-data"> <form action="{{ url('pedagogy:uv_detail', uv_id=object.id) }}" method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
<div class="leave-comment-grid-container"> <div class="leave-comment-grid-container">
<div class="form-stars"> <div class="form-stars">
{{ form.author.errors }} {{ form.author.errors }}
{{ form.ue.errors }} {{ form.uv.errors }}
{{ form.author }} {{ form.author }}
{{ form.ue }} {{ form.uv }}
<div class="input-stars"> <div class="input-stars">
<label for="{{ form.grade_global.id_for_label }}">{{ form.grade_global.label }} :</label> <label for="{{ form.grade_global.id_for_label }}">{{ form.grade_global.label }} :</label>
@@ -170,7 +170,7 @@
<div class="comment"> <div class="comment">
<div class="anchor"> <div class="anchor">
<a href="{{ url('pedagogy:ue_detail', ue_id=ue.id) }}#{{ comment.id }}"><i class="fa fa-paragraph"></i></a> <a href="{{ url('pedagogy:uv_detail', uv_id=uv.id) }}#{{ comment.id }}"><i class="fa fa-paragraph"></i></a>
</div> </div>
{{ comment.comment|markdown }} {{ comment.comment|markdown }}
</div> </div>

View File

@@ -9,19 +9,19 @@ from model_bakery.recipe import Recipe
from core.baker_recipes import subscriber_user from core.baker_recipes import subscriber_user
from core.models import Group, User from core.models import Group, User
from pedagogy.models import UE from pedagogy.models import UV
class TestUESearch(TestCase): class TestUVSearch(TestCase):
"""Test UE guide rights for view and API.""" """Test UV guide rights for view and API."""
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.root = User.objects.get(username="root") cls.root = User.objects.get(username="root")
cls.url = reverse("api:fetch_ues") cls.url = reverse("api:fetch_uvs")
ue_recipe = Recipe(UE, author=cls.root) uv_recipe = Recipe(UV, author=cls.root)
ues = [ uvs = [
ue_recipe.prepare( uv_recipe.prepare(
code="AP4A", code="AP4A",
credit_type="CS", credit_type="CS",
semester="AUTUMN", semester="AUTUMN",
@@ -32,7 +32,7 @@ class TestUESearch(TestCase):
"Concepts fondamentaux et mise en pratique avec le langage C++" "Concepts fondamentaux et mise en pratique avec le langage C++"
), ),
), ),
ue_recipe.prepare( uv_recipe.prepare(
code="MT01", code="MT01",
credit_type="CS", credit_type="CS",
semester="AUTUMN", semester="AUTUMN",
@@ -40,10 +40,10 @@ class TestUESearch(TestCase):
manager="ben", manager="ben",
title="Intégration1. Algèbre linéaire - Fonctions de deux variables", title="Intégration1. Algèbre linéaire - Fonctions de deux variables",
), ),
ue_recipe.prepare( uv_recipe.prepare(
code="PHYS11", credit_type="CS", semester="AUTUMN", department="TC" code="PHYS11", credit_type="CS", semester="AUTUMN", department="TC"
), ),
ue_recipe.prepare( uv_recipe.prepare(
code="TNEV", code="TNEV",
credit_type="TM", credit_type="TM",
semester="SPRING", semester="SPRING",
@@ -51,10 +51,10 @@ class TestUESearch(TestCase):
manager="moss", manager="moss",
title="tnetennba", title="tnetennba",
), ),
ue_recipe.prepare( uv_recipe.prepare(
code="MT10", credit_type="TM", semester="AUTUMN", department="IMSI" code="MT10", credit_type="TM", semester="AUTUMN", department="IMSI"
), ),
ue_recipe.prepare( uv_recipe.prepare(
code="DA50", code="DA50",
credit_type="TM", credit_type="TM",
semester="AUTUMN_AND_SPRING", semester="AUTUMN_AND_SPRING",
@@ -62,7 +62,7 @@ class TestUESearch(TestCase):
manager="francky", manager="francky",
), ),
] ]
UE.objects.bulk_create(ues) UV.objects.bulk_create(uvs)
call_command("update_index") call_command("update_index")
def test_permissions(self): def test_permissions(self):
@@ -93,7 +93,7 @@ class TestUESearch(TestCase):
"""Test that the return data format is correct""" """Test that the return data format is correct"""
self.client.force_login(self.root) self.client.force_login(self.root)
res = self.client.get(self.url + "?search=PA00") res = self.client.get(self.url + "?search=PA00")
ue = UE.objects.get(code="PA00") uv = UV.objects.get(code="PA00")
assert res.status_code == 200 assert res.status_code == 200
assert json.loads(res.content) == { assert json.loads(res.content) == {
"count": 1, "count": 1,
@@ -101,12 +101,12 @@ class TestUESearch(TestCase):
"previous": None, "previous": None,
"results": [ "results": [
{ {
"id": ue.id, "id": uv.id,
"title": ue.title, "title": uv.title,
"code": ue.code, "code": uv.code,
"credit_type": ue.credit_type, "credit_type": uv.credit_type,
"semester": ue.semester, "semester": uv.semester,
"department": ue.department, "department": uv.department,
} }
], ],
} }
@@ -114,7 +114,7 @@ class TestUESearch(TestCase):
def test_search_by_text(self): def test_search_by_text(self):
self.client.force_login(self.root) self.client.force_login(self.root)
for query, expected in ( for query, expected in (
# UE code search case insensitive # UV code search case insensitive
("m", {"MT01", "MT10"}), ("m", {"MT01", "MT10"}),
("M", {"MT01", "MT10"}), ("M", {"MT01", "MT10"}),
("mt", {"MT01", "MT10"}), ("mt", {"MT01", "MT10"}),
@@ -126,24 +126,24 @@ class TestUESearch(TestCase):
): ):
res = self.client.get(self.url + f"?search={query}") res = self.client.get(self.url + f"?search={query}")
assert res.status_code == 200 assert res.status_code == 200
assert {ue["code"] for ue in json.loads(res.content)["results"]} == expected assert {uv["code"] for uv in json.loads(res.content)["results"]} == expected
def test_search_by_credit_type(self): def test_search_by_credit_type(self):
self.client.force_login(self.root) self.client.force_login(self.root)
res = self.client.get(self.url + "?credit_type=CS") res = self.client.get(self.url + "?credit_type=CS")
assert res.status_code == 200 assert res.status_code == 200
codes = [ue["code"] for ue in json.loads(res.content)["results"]] codes = [uv["code"] for uv in json.loads(res.content)["results"]]
assert codes == ["AP4A", "MT01", "PHYS11"] assert codes == ["AP4A", "MT01", "PHYS11"]
res = self.client.get(self.url + "?credit_type=CS&credit_type=OM") res = self.client.get(self.url + "?credit_type=CS&credit_type=OM")
assert res.status_code == 200 assert res.status_code == 200
codes = {ue["code"] for ue in json.loads(res.content)["results"]} codes = {uv["code"] for uv in json.loads(res.content)["results"]}
assert codes == {"AP4A", "MT01", "PHYS11", "PA00"} assert codes == {"AP4A", "MT01", "PHYS11", "PA00"}
def test_search_by_semester(self): def test_search_by_semester(self):
self.client.force_login(self.root) self.client.force_login(self.root)
res = self.client.get(self.url + "?semester=SPRING") res = self.client.get(self.url + "?semester=SPRING")
assert res.status_code == 200 assert res.status_code == 200
codes = {ue["code"] for ue in json.loads(res.content)["results"]} codes = {uv["code"] for uv in json.loads(res.content)["results"]}
assert codes == {"DA50", "TNEV", "PA00"} assert codes == {"DA50", "TNEV", "PA00"}
def test_search_multiple_filters(self): def test_search_multiple_filters(self):
@@ -152,7 +152,7 @@ class TestUESearch(TestCase):
self.url + "?semester=AUTUMN&credit_type=CS&department=TC" self.url + "?semester=AUTUMN&credit_type=CS&department=TC"
) )
assert res.status_code == 200 assert res.status_code == 200
codes = {ue["code"] for ue in json.loads(res.content)["results"]} codes = {uv["code"] for uv in json.loads(res.content)["results"]}
assert codes == {"MT01", "PHYS11"} assert codes == {"MT01", "PHYS11"}
def test_search_fails(self): def test_search_fails(self):
@@ -163,15 +163,15 @@ class TestUESearch(TestCase):
def test_search_pa00_fail(self): def test_search_pa00_fail(self):
self.client.force_login(self.root) self.client.force_login(self.root)
# Search with UE code # Search with UV code
response = self.client.get(reverse("pedagogy:guide"), {"search": "IFC"}) response = self.client.get(reverse("pedagogy:guide"), {"search": "IFC"})
self.assertNotContains(response, text="PA00") self.assertNotContains(response, text="PA00")
# Search with first letter of UE code # Search with first letter of UV code
response = self.client.get(reverse("pedagogy:guide"), {"search": "I"}) response = self.client.get(reverse("pedagogy:guide"), {"search": "I"})
self.assertNotContains(response, text="PA00") self.assertNotContains(response, text="PA00")
# Search with UE manager # Search with UV manager
response = self.client.get(reverse("pedagogy:guide"), {"search": "GILLES"}) response = self.client.get(reverse("pedagogy:guide"), {"search": "GILLES"})
self.assertNotContains(response, text="PA00") self.assertNotContains(response, text="PA00")

View File

@@ -33,14 +33,14 @@ from pytest_django.asserts import assertRedirects
from core.baker_recipes import old_subscriber_user, subscriber_user from core.baker_recipes import old_subscriber_user, subscriber_user
from core.models import Notification, User from core.models import Notification, User
from pedagogy.models import UE, UEComment, UECommentReport from pedagogy.models import UV, UVComment, UVCommentReport
def create_ue_template(user_id, code="IFC1", exclude_list=None): def create_uv_template(user_id, code="IFC1", exclude_list=None):
"""Factory to help UE creation/update in post requests.""" """Factory to help UV creation/update in post requests."""
if exclude_list is None: if exclude_list is None:
exclude_list = [] exclude_list = []
ue = { uv = {
"code": code, "code": code,
"author": user_id, "author": user_id,
"credit_type": "TM", "credit_type": "TM",
@@ -74,15 +74,15 @@ def create_ue_template(user_id, code="IFC1", exclude_list=None):
* Chaînes de caractères""", * Chaînes de caractères""",
} }
for excluded in exclude_list: for excluded in exclude_list:
ue.pop(excluded) uv.pop(excluded)
return ue return uv
# UE class tests # UV class tests
class TestUECreation(TestCase): class TestUVCreation(TestCase):
"""Test ue creation.""" """Test uv creation."""
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@@ -90,62 +90,62 @@ class TestUECreation(TestCase):
cls.tutu = User.objects.get(username="tutu") cls.tutu = User.objects.get(username="tutu")
cls.sli = User.objects.get(username="sli") cls.sli = User.objects.get(username="sli")
cls.guy = User.objects.get(username="guy") cls.guy = User.objects.get(username="guy")
cls.create_ue_url = reverse("pedagogy:ue_create") cls.create_uv_url = reverse("pedagogy:uv_create")
def test_create_ue_admin_success(self): def test_create_uv_admin_success(self):
self.client.force_login(self.bibou) self.client.force_login(self.bibou)
response = self.client.post( response = self.client.post(
self.create_ue_url, create_ue_template(self.bibou.id) self.create_uv_url, create_uv_template(self.bibou.id)
) )
assert response.status_code == 302 assert response.status_code == 302
assert UE.objects.filter(code="IFC1").exists() assert UV.objects.filter(code="IFC1").exists()
def test_create_ue_pedagogy_admin_success(self): def test_create_uv_pedagogy_admin_success(self):
self.client.force_login(self.tutu) self.client.force_login(self.tutu)
response = self.client.post( response = self.client.post(
self.create_ue_url, create_ue_template(self.tutu.id) self.create_uv_url, create_uv_template(self.tutu.id)
) )
assert response.status_code == 302 assert response.status_code == 302
assert UE.objects.filter(code="IFC1").exists() assert UV.objects.filter(code="IFC1").exists()
def test_create_ue_unauthorized_fail(self): def test_create_uv_unauthorized_fail(self):
# Test with anonymous user # Test with anonymous user
response = self.client.post(self.create_ue_url, create_ue_template(0)) response = self.client.post(self.create_uv_url, create_uv_template(0))
assertRedirects( assertRedirects(
response, reverse("core:login", query={"next": self.create_ue_url}) response, reverse("core:login", query={"next": self.create_uv_url})
) )
# Test with subscribed user # Test with subscribed user
self.client.force_login(self.sli) self.client.force_login(self.sli)
response = self.client.post(self.create_ue_url, create_ue_template(self.sli.id)) response = self.client.post(self.create_uv_url, create_uv_template(self.sli.id))
assert response.status_code == 403 assert response.status_code == 403
# Test with non subscribed user # Test with non subscribed user
self.client.force_login(self.guy) self.client.force_login(self.guy)
response = self.client.post(self.create_ue_url, create_ue_template(self.guy.id)) response = self.client.post(self.create_uv_url, create_uv_template(self.guy.id))
assert response.status_code == 403 assert response.status_code == 403
# Check that the UE has never been created # Check that the UV has never been created
assert not UE.objects.filter(code="IFC1").exists() assert not UV.objects.filter(code="IFC1").exists()
def test_create_ue_bad_request_fail(self): def test_create_uv_bad_request_fail(self):
self.client.force_login(self.tutu) self.client.force_login(self.tutu)
# Test with wrong user id (if someone cheats on the hidden input) # Test with wrong user id (if someone cheats on the hidden input)
response = self.client.post( response = self.client.post(
self.create_ue_url, create_ue_template(self.bibou.id) self.create_uv_url, create_uv_template(self.bibou.id)
) )
assert response.status_code == 200 assert response.status_code == 200
# Remove a required field # Remove a required field
response = self.client.post( response = self.client.post(
self.create_ue_url, self.create_uv_url,
create_ue_template(self.tutu.id, exclude_list=["title"]), create_uv_template(self.tutu.id, exclude_list=["title"]),
) )
assert response.status_code == 200 assert response.status_code == 200
# Check that the UE has never been created # Check that the UV hase never been created
assert not UE.objects.filter(code="IFC1").exists() assert not UV.objects.filter(code="IFC1").exists()
@pytest.mark.django_db @pytest.mark.django_db
@@ -171,8 +171,8 @@ def test_guide_anonymous_permission_denied(client: Client):
assert res.status_code == 302 assert res.status_code == 302
class TestUEDelete(TestCase): class TestUVDelete(TestCase):
"""Test UE deletion rights.""" """Test UV deletion rights."""
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@@ -180,37 +180,37 @@ class TestUEDelete(TestCase):
cls.tutu = User.objects.get(username="tutu") cls.tutu = User.objects.get(username="tutu")
cls.sli = User.objects.get(username="sli") cls.sli = User.objects.get(username="sli")
cls.guy = User.objects.get(username="guy") cls.guy = User.objects.get(username="guy")
cls.ue = UE.objects.get(code="PA00") cls.uv = UV.objects.get(code="PA00")
cls.delete_ue_url = reverse("pedagogy:ue_delete", kwargs={"ue_id": cls.ue.id}) cls.delete_uv_url = reverse("pedagogy:uv_delete", kwargs={"uv_id": cls.uv.id})
def test_ue_delete_root_success(self): def test_uv_delete_root_success(self):
self.client.force_login(self.bibou) self.client.force_login(self.bibou)
self.client.post(self.delete_ue_url) self.client.post(self.delete_uv_url)
assert not UE.objects.filter(pk=self.ue.pk).exists() assert not UV.objects.filter(pk=self.uv.pk).exists()
def test_ue_delete_pedagogy_admin_success(self): def test_uv_delete_pedagogy_admin_success(self):
self.client.force_login(self.tutu) self.client.force_login(self.tutu)
self.client.post(self.delete_ue_url) self.client.post(self.delete_uv_url)
assert not UE.objects.filter(pk=self.ue.pk).exists() assert not UV.objects.filter(pk=self.uv.pk).exists()
def test_ue_delete_pedagogy_unauthorized_fail(self): def test_uv_delete_pedagogy_unauthorized_fail(self):
# Anonymous user # Anonymous user
response = self.client.post(self.delete_ue_url) response = self.client.post(self.delete_uv_url)
assertRedirects( assertRedirects(
response, reverse("core:login", query={"next": self.delete_ue_url}) response, reverse("core:login", query={"next": self.delete_uv_url})
) )
assert UE.objects.filter(pk=self.ue.pk).exists() assert UV.objects.filter(pk=self.uv.pk).exists()
for user in baker.make(User), subscriber_user.make(): for user in baker.make(User), subscriber_user.make():
with self.subTest(): with self.subTest():
self.client.force_login(user) self.client.force_login(user)
response = self.client.post(self.delete_ue_url) response = self.client.post(self.delete_uv_url)
assert response.status_code == 403 assert response.status_code == 403
assert UE.objects.filter(pk=self.ue.pk).exists() assert UV.objects.filter(pk=self.uv.pk).exists()
class TestUEUpdate(TestCase): class TestUVUpdate(TestCase):
"""Test UE update rights.""" """Test UV update rights."""
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@@ -218,79 +218,79 @@ class TestUEUpdate(TestCase):
cls.tutu = User.objects.get(username="tutu") cls.tutu = User.objects.get(username="tutu")
cls.sli = User.objects.get(username="sli") cls.sli = User.objects.get(username="sli")
cls.guy = User.objects.get(username="guy") cls.guy = User.objects.get(username="guy")
cls.ue = UE.objects.get(code="PA00") cls.uv = UV.objects.get(code="PA00")
cls.update_ue_url = reverse("pedagogy:ue_update", kwargs={"ue_id": cls.ue.id}) cls.update_uv_url = reverse("pedagogy:uv_update", kwargs={"uv_id": cls.uv.id})
def test_ue_update_root_success(self): def test_uv_update_root_success(self):
self.client.force_login(self.bibou) self.client.force_login(self.bibou)
self.client.post( self.client.post(
self.update_ue_url, create_ue_template(self.bibou.id, code="PA00") self.update_uv_url, create_uv_template(self.bibou.id, code="PA00")
) )
self.ue.refresh_from_db() self.uv.refresh_from_db()
assert self.ue.credit_type == "TM" assert self.uv.credit_type == "TM"
def test_ue_update_pedagogy_admin_success(self): def test_uv_update_pedagogy_admin_success(self):
self.client.force_login(self.tutu) self.client.force_login(self.tutu)
self.client.post( self.client.post(
self.update_ue_url, create_ue_template(self.bibou.id, code="PA00") self.update_uv_url, create_uv_template(self.bibou.id, code="PA00")
) )
self.ue.refresh_from_db() self.uv.refresh_from_db()
assert self.ue.credit_type == "TM" assert self.uv.credit_type == "TM"
def test_ue_update_original_author_does_not_change(self): def test_uv_update_original_author_does_not_change(self):
self.client.force_login(self.tutu) self.client.force_login(self.tutu)
response = self.client.post( response = self.client.post(
self.update_ue_url, self.update_uv_url,
create_ue_template(self.tutu.id, code="PA00"), create_uv_template(self.tutu.id, code="PA00"),
) )
assert response.status_code == 200 assert response.status_code == 200
self.ue.refresh_from_db() self.uv.refresh_from_db()
assert self.ue.author == self.bibou assert self.uv.author == self.bibou
def test_ue_update_pedagogy_unauthorized_fail(self): def test_uv_update_pedagogy_unauthorized_fail(self):
# Anonymous user # Anonymous user
response = self.client.post( response = self.client.post(
self.update_ue_url, create_ue_template(self.bibou.id, code="PA00") self.update_uv_url, create_uv_template(self.bibou.id, code="PA00")
) )
assertRedirects( assertRedirects(
response, reverse("core:login", query={"next": self.update_ue_url}) response, reverse("core:login", query={"next": self.update_uv_url})
) )
# Not subscribed user # Not subscribed user
self.client.force_login(self.guy) self.client.force_login(self.guy)
response = self.client.post( response = self.client.post(
self.update_ue_url, create_ue_template(self.bibou.id, code="PA00") self.update_uv_url, create_uv_template(self.bibou.id, code="PA00")
) )
assert response.status_code == 403 assert response.status_code == 403
# Simply subscribed user # Simply subscribed user
self.client.force_login(self.sli) self.client.force_login(self.sli)
response = self.client.post( response = self.client.post(
self.update_ue_url, create_ue_template(self.bibou.id, code="PA00") self.update_uv_url, create_uv_template(self.bibou.id, code="PA00")
) )
assert response.status_code == 403 assert response.status_code == 403
# Check that the UE has not changed # Check that the UV has not changed
self.ue.refresh_from_db() self.uv.refresh_from_db()
assert self.ue.credit_type == "OM" assert self.uv.credit_type == "OM"
# UEComment class tests # UVComment class tests
def create_ue_comment_template(user_id, ue_code="PA00", exclude_list=None): def create_uv_comment_template(user_id, uv_code="PA00", exclude_list=None):
"""Factory to help UEComment creation/update in post requests.""" """Factory to help UVComment creation/update in post requests."""
if exclude_list is None: if exclude_list is None:
exclude_list = [] exclude_list = []
comment = { comment = {
"author": user_id, "author": user_id,
"ue": UE.objects.get(code=ue_code).id, "uv": UV.objects.get(code=uv_code).id,
"grade_global": 4, "grade_global": 4,
"grade_utility": 4, "grade_utility": 4,
"grade_interest": 4, "grade_interest": 4,
"grade_teaching": -1, "grade_teaching": -1,
"grade_work_load": 2, "grade_work_load": 2,
"comment": "Superbe UE qui fait vivre la vie associative de l'école", "comment": "Superbe UV qui fait vivre la vie associative de l'école",
} }
for excluded in exclude_list: for excluded in exclude_list:
comment.pop(excluded) comment.pop(excluded)
@@ -298,7 +298,7 @@ def create_ue_comment_template(user_id, ue_code="PA00", exclude_list=None):
class TestUVCommentCreationAndDisplay(TestCase): class TestUVCommentCreationAndDisplay(TestCase):
"""Test UEComment creation and its display. """Test UVComment creation and its display.
Display and creation are the same view. Display and creation are the same view.
""" """
@@ -309,124 +309,124 @@ class TestUVCommentCreationAndDisplay(TestCase):
cls.tutu = User.objects.get(username="tutu") cls.tutu = User.objects.get(username="tutu")
cls.sli = User.objects.get(username="sli") cls.sli = User.objects.get(username="sli")
cls.guy = User.objects.get(username="guy") cls.guy = User.objects.get(username="guy")
cls.ue = UE.objects.get(code="PA00") cls.uv = UV.objects.get(code="PA00")
cls.ue_url = reverse("pedagogy:ue_detail", kwargs={"ue_id": cls.ue.id}) cls.uv_url = reverse("pedagogy:uv_detail", kwargs={"uv_id": cls.uv.id})
def test_create_ue_comment_admin_success(self): def test_create_uv_comment_admin_success(self):
self.client.force_login(self.bibou) self.client.force_login(self.bibou)
response = self.client.post( response = self.client.post(
self.ue_url, create_ue_comment_template(self.bibou.id) self.uv_url, create_uv_comment_template(self.bibou.id)
) )
assertRedirects(response, self.ue_url) assertRedirects(response, self.uv_url)
response = self.client.get(self.ue_url) response = self.client.get(self.uv_url)
self.assertContains(response, text="Superbe UE") self.assertContains(response, text="Superbe UV")
def test_create_ue_comment_pedagogy_admin_success(self): def test_create_uv_comment_pedagogy_admin_success(self):
self.client.force_login(self.tutu) self.client.force_login(self.tutu)
response = self.client.post( response = self.client.post(
self.ue_url, create_ue_comment_template(self.tutu.id) self.uv_url, create_uv_comment_template(self.tutu.id)
) )
self.assertRedirects(response, self.ue_url) self.assertRedirects(response, self.uv_url)
response = self.client.get(self.ue_url) response = self.client.get(self.uv_url)
self.assertContains(response, text="Superbe UE") self.assertContains(response, text="Superbe UV")
def test_create_ue_comment_subscriber_success(self): def test_create_uv_comment_subscriber_success(self):
self.client.force_login(self.sli) self.client.force_login(self.sli)
response = self.client.post( response = self.client.post(
self.ue_url, create_ue_comment_template(self.sli.id) self.uv_url, create_uv_comment_template(self.sli.id)
) )
self.assertRedirects(response, self.ue_url) self.assertRedirects(response, self.uv_url)
response = self.client.get(self.ue_url) response = self.client.get(self.uv_url)
self.assertContains(response, text="Superbe UE") self.assertContains(response, text="Superbe UV")
def test_create_ue_comment_unauthorized_fail(self): def test_create_uv_comment_unauthorized_fail(self):
nb_comments = self.ue.comments.count() nb_comments = self.uv.comments.count()
# Test with anonymous user # Test with anonymous user
response = self.client.post(self.ue_url, create_ue_comment_template(0)) response = self.client.post(self.uv_url, create_uv_comment_template(0))
assertRedirects(response, reverse("core:login", query={"next": self.ue_url})) assertRedirects(response, reverse("core:login", query={"next": self.uv_url}))
# Test with non subscribed user # Test with non subscribed user
self.client.force_login(self.guy) self.client.force_login(self.guy)
response = self.client.post( response = self.client.post(
self.ue_url, create_ue_comment_template(self.guy.id) self.uv_url, create_uv_comment_template(self.guy.id)
) )
assert response.status_code == 403 assert response.status_code == 403
# Check that no comment has been created # Check that no comment has been created
assert self.ue.comments.count() == nb_comments assert self.uv.comments.count() == nb_comments
def test_create_ue_comment_bad_form_fail(self): def test_create_uv_comment_bad_form_fail(self):
nb_comments = self.ue.comments.count() nb_comments = self.uv.comments.count()
self.client.force_login(self.bibou) self.client.force_login(self.bibou)
response = self.client.post( response = self.client.post(
self.ue_url, self.uv_url,
create_ue_comment_template(self.bibou.id, exclude_list=["grade_global"]), create_uv_comment_template(self.bibou.id, exclude_list=["grade_global"]),
) )
assert response.status_code == 200 assert response.status_code == 200
assert self.ue.comments.count() == nb_comments assert self.uv.comments.count() == nb_comments
def test_create_ue_comment_twice_fail(self): def test_create_uv_comment_twice_fail(self):
# Checks that the has_user_already_commented method works proprely # Checks that the has_user_already_commented method works proprely
assert not self.ue.has_user_already_commented(self.bibou) assert not self.uv.has_user_already_commented(self.bibou)
# Create a first comment # Create a first comment
self.client.force_login(self.bibou) self.client.force_login(self.bibou)
self.client.post(self.ue_url, create_ue_comment_template(self.bibou.id)) self.client.post(self.uv_url, create_uv_comment_template(self.bibou.id))
# Checks that the has_user_already_commented method works proprely # Checks that the has_user_already_commented method works proprely
assert self.ue.has_user_already_commented(self.bibou) assert self.uv.has_user_already_commented(self.bibou)
# Create the second comment # Create the second comment
comment = create_ue_comment_template(self.bibou.id) comment = create_uv_comment_template(self.bibou.id)
comment["comment"] = "Twice" comment["comment"] = "Twice"
response = self.client.post(self.ue_url, comment) response = self.client.post(self.uv_url, comment)
assert response.status_code == 200 assert response.status_code == 200
assert UEComment.objects.filter(comment__contains="Superbe UE").exists() assert UVComment.objects.filter(comment__contains="Superbe UV").exists()
assert not UEComment.objects.filter(comment__contains="Twice").exists() assert not UVComment.objects.filter(comment__contains="Twice").exists()
self.assertContains( self.assertContains(
response, response,
_( _(
"You already posted a comment on this UE. " "You already posted a comment on this UV. "
"If you want to comment again, " "If you want to comment again, "
"please modify or delete your previous comment." "please modify or delete your previous comment."
), ),
) )
# Ensure that there is no crash when no ue or no author is given # Ensure that there is no crash when no uv or no author is given
self.client.post( self.client.post(
self.ue_url, create_ue_comment_template(self.bibou.id, exclude_list=["ue"]) self.uv_url, create_uv_comment_template(self.bibou.id, exclude_list=["uv"])
) )
assert response.status_code == 200 assert response.status_code == 200
self.client.post( self.client.post(
self.ue_url, self.uv_url,
create_ue_comment_template(self.bibou.id, exclude_list=["author"]), create_uv_comment_template(self.bibou.id, exclude_list=["author"]),
) )
assert response.status_code == 200 assert response.status_code == 200
class TestUVCommentDelete(TestCase): class TestUVCommentDelete(TestCase):
"""Test UEComment deletion rights.""" """Test UVComment deletion rights."""
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.comment = baker.make(UEComment) cls.comment = baker.make(UVComment)
def test_ue_comment_delete_success(self): def test_uv_comment_delete_success(self):
url = reverse("pedagogy:comment_delete", kwargs={"comment_id": self.comment.id}) url = reverse("pedagogy:comment_delete", kwargs={"comment_id": self.comment.id})
for user in ( for user in (
baker.make(User, is_superuser=True), baker.make(User, is_superuser=True),
baker.make( baker.make(
User, user_permissions=[Permission.objects.get(codename="view_ue")] User, user_permissions=[Permission.objects.get(codename="view_uv")]
), ),
self.comment.author, self.comment.author,
): ):
with self.subTest(): with self.subTest():
self.client.force_login(user) self.client.force_login(user)
self.client.post(url) self.client.post(url)
assert not UEComment.objects.filter(id=self.comment.id).exists() assert not UVComment.objects.filter(id=self.comment.id).exists()
def test_ue_comment_delete_unauthorized_fail(self): def test_uv_comment_delete_unauthorized_fail(self):
url = reverse("pedagogy:comment_delete", kwargs={"comment_id": self.comment.id}) url = reverse("pedagogy:comment_delete", kwargs={"comment_id": self.comment.id})
# Anonymous user # Anonymous user
@@ -441,11 +441,11 @@ class TestUVCommentDelete(TestCase):
assert response.status_code == 403 assert response.status_code == 403
# Check that the comment still exists # Check that the comment still exists
assert UEComment.objects.filter(id=self.comment.id).exists() assert UVComment.objects.filter(id=self.comment.id).exists()
class TestUVCommentUpdate(TestCase): class TestUVCommentUpdate(TestCase):
"""Test UEComment update rights.""" """Test UVComment update rights."""
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@@ -457,17 +457,17 @@ class TestUVCommentUpdate(TestCase):
def setUp(self): def setUp(self):
# Prepare a comment # Prepare a comment
comment_kwargs = create_ue_comment_template(self.krophil.id) comment_kwargs = create_uv_comment_template(self.krophil.id)
comment_kwargs["author"] = self.krophil comment_kwargs["author"] = self.krophil
comment_kwargs["ue"] = UE.objects.get(id=comment_kwargs["ue"]) comment_kwargs["uv"] = UV.objects.get(id=comment_kwargs["uv"])
self.comment = UEComment(**comment_kwargs) self.comment = UVComment(**comment_kwargs)
self.comment.save() self.comment.save()
# Prepare edit of this comment for post requests # Prepare edit of this comment for post requests
self.comment_edit = create_ue_comment_template(self.krophil.id) self.comment_edit = create_uv_comment_template(self.krophil.id)
self.comment_edit["comment"] = "Edited" self.comment_edit["comment"] = "Edited"
def test_ue_comment_update_root_success(self): def test_uv_comment_update_root_success(self):
self.client.force_login(self.bibou) self.client.force_login(self.bibou)
response = self.client.post( response = self.client.post(
reverse("pedagogy:comment_update", kwargs={"comment_id": self.comment.id}), reverse("pedagogy:comment_update", kwargs={"comment_id": self.comment.id}),
@@ -477,7 +477,7 @@ class TestUVCommentUpdate(TestCase):
self.comment.refresh_from_db() self.comment.refresh_from_db()
self.assertEqual(self.comment.comment, self.comment_edit["comment"]) self.assertEqual(self.comment.comment, self.comment_edit["comment"])
def test_ue_comment_update_author_success(self): def test_uv_comment_update_author_success(self):
self.client.force_login(self.krophil) self.client.force_login(self.krophil)
response = self.client.post( response = self.client.post(
reverse("pedagogy:comment_update", kwargs={"comment_id": self.comment.id}), reverse("pedagogy:comment_update", kwargs={"comment_id": self.comment.id}),
@@ -487,7 +487,7 @@ class TestUVCommentUpdate(TestCase):
self.comment.refresh_from_db() self.comment.refresh_from_db()
self.assertEqual(self.comment.comment, self.comment_edit["comment"]) self.assertEqual(self.comment.comment, self.comment_edit["comment"])
def test_ue_comment_update_unauthorized_fail(self): def test_uv_comment_update_unauthorized_fail(self):
url = reverse("pedagogy:comment_update", kwargs={"comment_id": self.comment.id}) url = reverse("pedagogy:comment_update", kwargs={"comment_id": self.comment.id})
# Anonymous user # Anonymous user
response = self.client.post(url, self.comment_edit) response = self.client.post(url, self.comment_edit)
@@ -506,7 +506,7 @@ class TestUVCommentUpdate(TestCase):
self.comment.refresh_from_db() self.comment.refresh_from_db()
self.assertNotEqual(self.comment.comment, self.comment_edit["comment"]) self.assertNotEqual(self.comment.comment, self.comment_edit["comment"])
def test_ue_comment_update_original_author_does_not_change(self): def test_uv_comment_update_original_author_does_not_change(self):
self.client.force_login(self.bibou) self.client.force_login(self.bibou)
self.comment_edit["author"] = User.objects.get(username="root").id self.comment_edit["author"] = User.objects.get(username="root").id
@@ -531,31 +531,31 @@ class TestUVModerationForm(TestCase):
def setUp(self): def setUp(self):
# Prepare a comment # Prepare a comment
comment_kwargs = create_ue_comment_template(self.krophil.id) comment_kwargs = create_uv_comment_template(self.krophil.id)
comment_kwargs["author"] = self.krophil comment_kwargs["author"] = self.krophil
comment_kwargs["ue"] = UE.objects.get(id=comment_kwargs["ue"]) comment_kwargs["uv"] = UV.objects.get(id=comment_kwargs["uv"])
self.comment_1 = UEComment(**comment_kwargs) self.comment_1 = UVComment(**comment_kwargs)
self.comment_1.save() self.comment_1.save()
# Prepare another comment # Prepare another comment
comment_kwargs = create_ue_comment_template(self.krophil.id) comment_kwargs = create_uv_comment_template(self.krophil.id)
comment_kwargs["author"] = self.krophil comment_kwargs["author"] = self.krophil
comment_kwargs["ue"] = UE.objects.get(id=comment_kwargs["ue"]) comment_kwargs["uv"] = UV.objects.get(id=comment_kwargs["uv"])
self.comment_2 = UEComment(**comment_kwargs) self.comment_2 = UVComment(**comment_kwargs)
self.comment_2.save() self.comment_2.save()
# Prepare a comment report for comment 1 # Prepare a comment report for comment 1
self.report_1 = UECommentReport( self.report_1 = UVCommentReport(
comment=self.comment_1, reporter=self.krophil, reason="C'est moche" comment=self.comment_1, reporter=self.krophil, reason="C'est moche"
) )
self.report_1.save() self.report_1.save()
self.report_1_bis = UECommentReport( self.report_1_bis = UVCommentReport(
comment=self.comment_1, reporter=self.krophil, reason="C'est moche 2" comment=self.comment_1, reporter=self.krophil, reason="C'est moche 2"
) )
self.report_1_bis.save() self.report_1_bis.save()
# Prepare a comment report for comment 2 # Prepare a comment report for comment 2
self.report_2 = UECommentReport( self.report_2 = UVCommentReport(
comment=self.comment_2, reporter=self.krophil, reason="C'est moche" comment=self.comment_2, reporter=self.krophil, reason="C'est moche"
) )
self.report_2.save() self.report_2.save()
@@ -593,11 +593,11 @@ class TestUVModerationForm(TestCase):
assert response.status_code == 302 assert response.status_code == 302
# Test that nothing has changed # Test that nothing has changed
assert UECommentReport.objects.filter(id=self.report_1.id).exists() assert UVCommentReport.objects.filter(id=self.report_1.id).exists()
assert UEComment.objects.filter(id=self.comment_1.id).exists() assert UVComment.objects.filter(id=self.comment_1.id).exists()
assert UECommentReport.objects.filter(id=self.report_1_bis.id).exists() assert UVCommentReport.objects.filter(id=self.report_1_bis.id).exists()
assert UECommentReport.objects.filter(id=self.report_2.id).exists() assert UVCommentReport.objects.filter(id=self.report_2.id).exists()
assert UEComment.objects.filter(id=self.comment_2.id).exists() assert UVComment.objects.filter(id=self.comment_2.id).exists()
def test_delete_comment(self): def test_delete_comment(self):
self.client.force_login(self.bibou) self.client.force_login(self.bibou)
@@ -607,14 +607,14 @@ class TestUVModerationForm(TestCase):
assert response.status_code == 302 assert response.status_code == 302
# Test that the comment and it's associated report has been deleted # Test that the comment and it's associated report has been deleted
assert not UECommentReport.objects.filter(id=self.report_1.id).exists() assert not UVCommentReport.objects.filter(id=self.report_1.id).exists()
assert not UEComment.objects.filter(id=self.comment_1.id).exists() assert not UVComment.objects.filter(id=self.comment_1.id).exists()
# Test that the bis report has been deleted # Test that the bis report has been deleted
assert not UECommentReport.objects.filter(id=self.report_1_bis.id).exists() assert not UVCommentReport.objects.filter(id=self.report_1_bis.id).exists()
# Test that the other comment and report still exists # Test that the other comment and report still exists
assert UECommentReport.objects.filter(id=self.report_2.id).exists() assert UVCommentReport.objects.filter(id=self.report_2.id).exists()
assert UEComment.objects.filter(id=self.comment_2.id).exists() assert UVComment.objects.filter(id=self.comment_2.id).exists()
def test_delete_comment_bulk(self): def test_delete_comment_bulk(self):
self.client.force_login(self.bibou) self.client.force_login(self.bibou)
@@ -625,12 +625,12 @@ class TestUVModerationForm(TestCase):
assert response.status_code == 302 assert response.status_code == 302
# Test that comments and their associated reports has been deleted # Test that comments and their associated reports has been deleted
assert not UECommentReport.objects.filter(id=self.report_1.id).exists() assert not UVCommentReport.objects.filter(id=self.report_1.id).exists()
assert not UEComment.objects.filter(id=self.comment_1.id).exists() assert not UVComment.objects.filter(id=self.comment_1.id).exists()
assert not UECommentReport.objects.filter(id=self.report_2.id).exists() assert not UVCommentReport.objects.filter(id=self.report_2.id).exists()
assert not UEComment.objects.filter(id=self.comment_2.id).exists() assert not UVComment.objects.filter(id=self.comment_2.id).exists()
# Test that the bis report has been deleted # Test that the bis report has been deleted
assert not UECommentReport.objects.filter(id=self.report_1_bis.id).exists() assert not UVCommentReport.objects.filter(id=self.report_1_bis.id).exists()
def test_delete_comment_with_bis(self): def test_delete_comment_with_bis(self):
# Test case if two reports targets the same comment and are both deleted # Test case if two reports targets the same comment and are both deleted
@@ -642,10 +642,10 @@ class TestUVModerationForm(TestCase):
assert response.status_code == 302 assert response.status_code == 302
# Test that the comment and it's associated report has been deleted # Test that the comment and it's associated report has been deleted
assert not UECommentReport.objects.filter(id=self.report_1.id).exists() assert not UVCommentReport.objects.filter(id=self.report_1.id).exists()
assert not UEComment.objects.filter(id=self.comment_1.id).exists() assert not UVComment.objects.filter(id=self.comment_1.id).exists()
# Test that the bis report has been deleted # Test that the bis report has been deleted
assert not UECommentReport.objects.filter(id=self.report_1_bis.id).exists() assert not UVCommentReport.objects.filter(id=self.report_1_bis.id).exists()
def test_delete_report(self): def test_delete_report(self):
self.client.force_login(self.bibou) self.client.force_login(self.bibou)
@@ -655,14 +655,14 @@ class TestUVModerationForm(TestCase):
assert response.status_code == 302 assert response.status_code == 302
# Test that the report has been deleted and that the comment still exists # Test that the report has been deleted and that the comment still exists
assert not UECommentReport.objects.filter(id=self.report_1.id).exists() assert not UVCommentReport.objects.filter(id=self.report_1.id).exists()
assert UEComment.objects.filter(id=self.comment_1.id).exists() assert UVComment.objects.filter(id=self.comment_1.id).exists()
# Test that the bis report is still there # Test that the bis report is still there
assert UECommentReport.objects.filter(id=self.report_1_bis.id).exists() assert UVCommentReport.objects.filter(id=self.report_1_bis.id).exists()
# Test that the other comment and report still exists # Test that the other comment and report still exists
assert UECommentReport.objects.filter(id=self.report_2.id).exists() assert UVCommentReport.objects.filter(id=self.report_2.id).exists()
assert UEComment.objects.filter(id=self.comment_2.id).exists() assert UVComment.objects.filter(id=self.comment_2.id).exists()
def test_delete_report_bulk(self): def test_delete_report_bulk(self):
self.client.force_login(self.bibou) self.client.force_login(self.bibou)
@@ -679,12 +679,12 @@ class TestUVModerationForm(TestCase):
assert response.status_code == 302 assert response.status_code == 302
# Test that every reports has been deleted # Test that every reports has been deleted
assert not UECommentReport.objects.filter(id=self.report_1.id).exists() assert not UVCommentReport.objects.filter(id=self.report_1.id).exists()
assert not UECommentReport.objects.filter(id=self.report_1_bis.id).exists() assert not UVCommentReport.objects.filter(id=self.report_1_bis.id).exists()
assert not UECommentReport.objects.filter(id=self.report_2.id).exists() assert not UVCommentReport.objects.filter(id=self.report_2.id).exists()
# Test that comments still exists # Test that comments still exists
assert UEComment.objects.filter(id=self.comment_1.id).exists() assert UVComment.objects.filter(id=self.comment_1.id).exists()
assert UEComment.objects.filter(id=self.comment_2.id).exists() assert UVComment.objects.filter(id=self.comment_2.id).exists()
def test_delete_mixed(self): def test_delete_mixed(self):
self.client.force_login(self.bibou) self.client.force_login(self.bibou)
@@ -698,15 +698,15 @@ class TestUVModerationForm(TestCase):
assert response.status_code == 302 assert response.status_code == 302
# Test that report 2 and his comment has been deleted # Test that report 2 and his comment has been deleted
assert not UECommentReport.objects.filter(id=self.report_2.id).exists() assert not UVCommentReport.objects.filter(id=self.report_2.id).exists()
assert not UEComment.objects.filter(id=self.comment_2.id).exists() assert not UVComment.objects.filter(id=self.comment_2.id).exists()
# Test that report 1 has been deleted and it's comment still exists # Test that report 1 has been deleted and it's comment still exists
assert not UECommentReport.objects.filter(id=self.report_1.id).exists() assert not UVCommentReport.objects.filter(id=self.report_1.id).exists()
assert UEComment.objects.filter(id=self.comment_1.id).exists() assert UVComment.objects.filter(id=self.comment_1.id).exists()
# Test that report 1 bis is still there # Test that report 1 bis is still there
assert UECommentReport.objects.filter(id=self.report_1_bis.id).exists() assert UVCommentReport.objects.filter(id=self.report_1_bis.id).exists()
def test_delete_mixed_with_bis(self): def test_delete_mixed_with_bis(self):
self.client.force_login(self.bibou) self.client.force_login(self.bibou)
@@ -720,16 +720,16 @@ class TestUVModerationForm(TestCase):
assert response.status_code == 302 assert response.status_code == 302
# Test that report 1 and 1 bis has been deleted # Test that report 1 and 1 bis has been deleted
assert not UECommentReport.objects.filter( assert not UVCommentReport.objects.filter(
id__in=[self.report_1.id, self.report_1_bis.id] id__in=[self.report_1.id, self.report_1_bis.id]
).exists() ).exists()
# Test that comment 1 has been deleted # Test that comment 1 has been deleted
assert not UEComment.objects.filter(id=self.comment_1.id).exists() assert not UVComment.objects.filter(id=self.comment_1.id).exists()
# Test that report and comment 2 still exists # Test that report and comment 2 still exists
assert UECommentReport.objects.filter(id=self.report_2.id).exists() assert UVCommentReport.objects.filter(id=self.report_2.id).exists()
assert UEComment.objects.filter(id=self.comment_2.id).exists() assert UVComment.objects.filter(id=self.comment_2.id).exists()
class TestUVCommentReportCreate(TestCase): class TestUVCommentReportCreate(TestCase):
@@ -743,10 +743,10 @@ class TestUVCommentReportCreate(TestCase):
self.tutu = User.objects.get(username="tutu") self.tutu = User.objects.get(username="tutu")
# Prepare a comment # Prepare a comment
comment_kwargs = create_ue_comment_template(self.krophil.id) comment_kwargs = create_uv_comment_template(self.krophil.id)
comment_kwargs["author"] = self.krophil comment_kwargs["author"] = self.krophil
comment_kwargs["ue"] = UE.objects.get(id=comment_kwargs["ue"]) comment_kwargs["uv"] = UV.objects.get(id=comment_kwargs["uv"])
self.comment = UEComment(**comment_kwargs) self.comment = UVComment(**comment_kwargs)
self.comment.save() self.comment.save()
def create_report_test(self, username: str, *, success: bool): def create_report_test(self, username: str, *, success: bool):
@@ -763,7 +763,7 @@ class TestUVCommentReportCreate(TestCase):
assert response.status_code == 302 assert response.status_code == 302
else: else:
assert response.status_code == 403 assert response.status_code == 403
self.assertEqual(UECommentReport.objects.all().exists(), success) self.assertEqual(UVCommentReport.objects.all().exists(), success)
def test_create_report_root_success(self): def test_create_report_root_success(self):
self.create_report_test("root", success=True) self.create_report_test("root", success=True)
@@ -783,7 +783,7 @@ class TestUVCommentReportCreate(TestCase):
url, {"comment": self.comment.id, "reporter": 0, "reason": "C'est moche"} url, {"comment": self.comment.id, "reporter": 0, "reason": "C'est moche"}
) )
assertRedirects(response, reverse("core:login", query={"next": url})) assertRedirects(response, reverse("core:login", query={"next": url}))
assert not UECommentReport.objects.all().exists() assert not UVCommentReport.objects.all().exists()
def test_notifications(self): def test_notifications(self):
assert not self.tutu.notifications.filter(type="PEDAGOGY_MODERATION").exists() assert not self.tutu.notifications.filter(type="PEDAGOGY_MODERATION").exists()

View File

@@ -24,40 +24,40 @@
from django.urls import path from django.urls import path
from pedagogy.views import ( from pedagogy.views import (
UECommentDeleteView, UVCommentDeleteView,
UECommentReportCreateView, UVCommentReportCreateView,
UECommentUpdateView, UVCommentUpdateView,
UECreateView, UVCreateView,
UEDeleteView, UVDeleteView,
UEDetailFormView, UVDetailFormView,
UEGuideView, UVGuideView,
UEModerationFormView, UVModerationFormView,
UEUpdateView, UVUpdateView,
) )
urlpatterns = [ urlpatterns = [
# Urls displaying the actual application for visitors # Urls displaying the actual application for visitors
path("", UEGuideView.as_view(), name="guide"), path("", UVGuideView.as_view(), name="guide"),
path("ue/<int:ue_id>/", UEDetailFormView.as_view(), name="ue_detail"), path("uv/<int:uv_id>/", UVDetailFormView.as_view(), name="uv_detail"),
path( path(
"comment/<int:comment_id>/edit/", "comment/<int:comment_id>/edit/",
UECommentUpdateView.as_view(), UVCommentUpdateView.as_view(),
name="comment_update", name="comment_update",
), ),
path( path(
"comment/<int:comment_id>/delete/", "comment/<int:comment_id>/delete/",
UECommentDeleteView.as_view(), UVCommentDeleteView.as_view(),
name="comment_delete", name="comment_delete",
), ),
path( path(
"comment/<int:comment_id>/report/", "comment/<int:comment_id>/report/",
UECommentReportCreateView.as_view(), UVCommentReportCreateView.as_view(),
name="comment_report", name="comment_report",
), ),
# Moderation # Moderation
path("moderation/", UEModerationFormView.as_view(), name="moderation"), path("moderation/", UVModerationFormView.as_view(), name="moderation"),
# Administration : Create Update Delete Edit # Administration : Create Update Delete Edit
path("ue/create/", UECreateView.as_view(), name="ue_create"), path("uv/create/", UVCreateView.as_view(), name="uv_create"),
path("ue/<int:ue_id>/delete/", UEDeleteView.as_view(), name="ue_delete"), path("uv/<int:uv_id>/delete/", UVDeleteView.as_view(), name="uv_delete"),
path("ue/<int:ue_id>/edit/", UEUpdateView.as_view(), name="ue_update"), path("uv/<int:uv_id>/edit/", UVUpdateView.as_view(), name="uv_update"),
] ]

View File

@@ -1,4 +1,4 @@
"""Set of functions to interact with the UTBM UE api.""" """Set of functions to interact with the UTBM UV api."""
from typing import Iterator from typing import Iterator
@@ -6,14 +6,14 @@ import requests
from django.conf import settings from django.conf import settings
from django.utils.functional import cached_property from django.utils.functional import cached_property
from pedagogy.schemas import ShortUeList, UeSchema, UtbmFullUeSchema, UtbmShortUeSchema from pedagogy.schemas import ShortUvList, UtbmFullUvSchema, UtbmShortUvSchema, UvSchema
class UtbmApiClient(requests.Session): class UtbmApiClient(requests.Session):
"""A wrapper around `requests.Session` to perform requests to the UTBM UE API.""" """A wrapper around `requests.Session` to perform requests to the UTBM UV API."""
BASE_URL = settings.SITH_PEDAGOGY_UTBM_API BASE_URL = settings.SITH_PEDAGOGY_UTBM_API
_cache = {"short_ues": {}} _cache = {"short_uvs": {}}
@cached_property @cached_property
def current_year(self) -> int: def current_year(self) -> int:
@@ -22,83 +22,83 @@ class UtbmApiClient(requests.Session):
response = self.get(url) response = self.get(url)
return response.json()[-1]["annee"] return response.json()[-1]["annee"]
def fetch_short_ues( def fetch_short_uvs(
self, lang: str = "fr", year: int | None = None self, lang: str = "fr", year: int | None = None
) -> list[UtbmShortUeSchema]: ) -> list[UtbmShortUvSchema]:
"""Get the list of UEs in their short format from the UTBM API""" """Get the list of UVs in their short format from the UTBM API"""
if year is None: if year is None:
year = self.current_year year = self.current_year
if lang not in self._cache["short_ues"]: if lang not in self._cache["short_uvs"]:
self._cache["short_ues"][lang] = {} self._cache["short_uvs"][lang] = {}
if year not in self._cache["short_ues"][lang]: if year not in self._cache["short_uvs"][lang]:
url = f"{self.BASE_URL}/uvs/{lang}/{year}" url = f"{self.BASE_URL}/uvs/{lang}/{year}"
response = self.get(url) response = self.get(url)
ues = ShortUeList.validate_json(response.content) uvs = ShortUvList.validate_json(response.content)
self._cache["short_ues"][lang][year] = ues self._cache["short_uvs"][lang][year] = uvs
return self._cache["short_ues"][lang][year] return self._cache["short_uvs"][lang][year]
def fetch_ues( def fetch_uvs(
self, lang: str = "fr", year: int | None = None self, lang: str = "fr", year: int | None = None
) -> Iterator[UeSchema]: ) -> Iterator[UvSchema]:
"""Fetch all UEs from the UTBM API, parsed in a format that we can use. """Fetch all UVs from the UTBM API, parsed in a format that we can use.
Warning: Warning:
We need infos from the full ue schema, and the UTBM UE API We need infos from the full uv schema, and the UTBM UV API
has no route to get all of them at once. has no route to get all of them at once.
We must do one request per UE (for a total of around 730 UEs), We must do one request per UV (for a total of around 730 UVs),
which takes a lot of time. which takes a lot of time.
Hopefully, there seems to be no rate-limit, so an error Hopefully, there seems to be no rate-limit, so an error
in the middle of the process isn't likely to occur. in the middle of the process isn't likely to occur.
""" """
if year is None: if year is None:
year = self.current_year year = self.current_year
shorts_ues = self.fetch_short_ues(lang, year) shorts_uvs = self.fetch_short_uvs(lang, year)
# When UEs are common to multiple branches (like most HUMA) # When UVs are common to multiple branches (like most HUMA)
# the UTBM API duplicates them for every branch. # the UTBM API duplicates them for every branch.
# We have no way in our db to link a UE to multiple formations, # We have no way in our db to link a UV to multiple formations,
# so we just create a single UE, which formation is the one # so we just create a single UV, which formation is the one
# of the first UE found in the list. # of the first UV found in the list.
# For example, if we have CC01 (TC), CC01 (IMSI) and CC01 (EDIM), # For example, if we have CC01 (TC), CC01 (IMSI) and CC01 (EDIM),
# we will only keep CC01 (TC). # we will only keep CC01 (TC).
unique_short_ues = {} unique_short_uvs = {}
for ue in shorts_ues: for uv in shorts_uvs:
if ue.code not in unique_short_ues: if uv.code not in unique_short_uvs:
unique_short_ues[ue.code] = ue unique_short_uvs[uv.code] = uv
for ue in unique_short_ues.values(): for uv in unique_short_uvs.values():
ue_url = f"{self.BASE_URL}/uv/{lang}/{year}/{ue.code}/{ue.code_formation}" uv_url = f"{self.BASE_URL}/uv/{lang}/{year}/{uv.code}/{uv.code_formation}"
response = requests.get(ue_url) response = requests.get(uv_url)
full_ue = UtbmFullUeSchema.model_validate_json(response.content) full_uv = UtbmFullUvSchema.model_validate_json(response.content)
yield make_clean_ue(ue, full_ue) yield make_clean_uv(uv, full_uv)
def find_uu(self, lang: str, code: str, year: int | None = None) -> UeSchema | None: def find_uv(self, lang: str, code: str, year: int | None = None) -> UvSchema | None:
"""Find an UE from the UTBM API.""" """Find an UV from the UTBM API."""
# query the UE list # query the UV list
if not year: if not year:
year = self.current_year year = self.current_year
# the UTBM API has no way to fetch a single short ue, # the UTBM API has no way to fetch a single short uv,
# and short ues contain infos that we need and are not # and short uvs contain infos that we need and are not
# in the full ue schema, so we must fetch everything. # in the full uv schema, so we must fetch everything.
short_ues = self.fetch_short_ues(lang, year) short_uvs = self.fetch_short_uvs(lang, year)
short_ue = next((ue for ue in short_ues if ue.code == code), None) short_uv = next((uv for uv in short_uvs if uv.code == code), None)
if short_ue is None: if short_uv is None:
return None return None
# get detailed information about the UE # get detailed information about the UV
ue_url = f"{self.BASE_URL}/uv/{lang}/{year}/{code}/{short_ue.code_formation}" uv_url = f"{self.BASE_URL}/uv/{lang}/{year}/{code}/{short_uv.code_formation}"
response = requests.get(ue_url) response = requests.get(uv_url)
full_ue = UtbmFullUeSchema.model_validate_json(response.content) full_uv = UtbmFullUvSchema.model_validate_json(response.content)
return make_clean_ue(short_ue, full_ue) return make_clean_uv(short_uv, full_uv)
def make_clean_ue(short_ue: UtbmShortUeSchema, full_ue: UtbmFullUeSchema) -> UeSchema: def make_clean_uv(short_uv: UtbmShortUvSchema, full_uv: UtbmFullUvSchema) -> UvSchema:
"""Cleans the data up so that it corresponds to our data representation. """Cleans the data up so that it corresponds to our data representation.
Some of the needed information are in the short ue schema, some Some of the needed information are in the short uv schema, some
other in the full ue schema. other in the full uv schema.
Thus we combine those information to obtain a data schema suitable Thus we combine those information to obtain a data schema suitable
for our needs. for our needs.
""" """
if full_ue.departement == "Pôle Humanités": if full_uv.departement == "Pôle Humanités":
department = "HUMA" department = "HUMA"
else: else:
department = { department = {
@@ -112,9 +112,9 @@ def make_clean_ue(short_ue: UtbmShortUeSchema, full_ue: UtbmFullUeSchema) -> UeS
"ED": "EDIM", "ED": "EDIM",
"AI": "GI", "AI": "GI",
"AM": "MC", "AM": "MC",
}.get(short_ue.code_formation, "NA") }.get(short_uv.code_formation, "NA")
match short_ue.ouvert_printemps, short_ue.ouvert_automne: match short_uv.ouvert_printemps, short_uv.ouvert_automne:
case True, True: case True, True:
semester = "AUTUMN_AND_SPRING" semester = "AUTUMN_AND_SPRING"
case True, False: case True, False:
@@ -124,22 +124,22 @@ def make_clean_ue(short_ue: UtbmShortUeSchema, full_ue: UtbmFullUeSchema) -> UeS
case _: case _:
semester = "CLOSED" semester = "CLOSED"
return UeSchema( return UvSchema(
title=full_ue.libelle or "", title=full_uv.libelle or "",
code=full_ue.code, code=full_uv.code,
credit_type=short_ue.code_categorie or "FREE", credit_type=short_uv.code_categorie or "FREE",
semester=semester, semester=semester,
language=short_ue.code_langue.upper(), language=short_uv.code_langue.upper(),
credits=full_ue.credits_ects, credits=full_uv.credits_ects,
department=department, department=department,
hours_THE=next((i.nbh for i in full_ue.activites if i.code == "THE"), 0) // 60, hours_THE=next((i.nbh for i in full_uv.activites if i.code == "THE"), 0) // 60,
hours_TD=next((i.nbh for i in full_ue.activites if i.code == "TD"), 0) // 60, hours_TD=next((i.nbh for i in full_uv.activites if i.code == "TD"), 0) // 60,
hours_TP=next((i.nbh for i in full_ue.activites if i.code == "TP"), 0) // 60, hours_TP=next((i.nbh for i in full_uv.activites if i.code == "TP"), 0) // 60,
hours_TE=next((i.nbh for i in full_ue.activites if i.code == "TE"), 0) // 60, hours_TE=next((i.nbh for i in full_uv.activites if i.code == "TE"), 0) // 60,
hours_CM=next((i.nbh for i in full_ue.activites if i.code == "CM"), 0) // 60, hours_CM=next((i.nbh for i in full_uv.activites if i.code == "CM"), 0) // 60,
manager=full_ue.respo_automne or full_ue.respo_printemps or "", manager=full_uv.respo_automne or full_uv.respo_printemps or "",
objectives=full_ue.objectifs or "", objectives=full_uv.objectifs or "",
program=full_ue.programme or "", program=full_uv.programme or "",
skills=full_ue.acquisition_competences or "", skills=full_uv.acquisition_competences or "",
key_concepts=full_ue.acquisition_notions or "", key_concepts=full_uv.acquisition_notions or "",
) )

View File

@@ -38,39 +38,39 @@ from core.auth.mixins import PermissionOrAuthorRequiredMixin
from core.models import Notification, User from core.models import Notification, User
from core.views import DetailFormView from core.views import DetailFormView
from pedagogy.forms import ( from pedagogy.forms import (
UECommentForm, UVCommentForm,
UECommentModerationForm, UVCommentModerationForm,
UECommentReportForm, UVCommentReportForm,
UEForm, UVForm,
) )
from pedagogy.models import UE, UEComment, UECommentReport from pedagogy.models import UV, UVComment, UVCommentReport
class UEDetailFormView(PermissionRequiredMixin, DetailFormView): class UVDetailFormView(PermissionRequiredMixin, DetailFormView):
"""Display every comment of an UE and detailed infos about it. """Display every comment of an UV and detailed infos about it.
Allow to comment the UE. Allow to comment the UV.
""" """
model = UE model = UV
pk_url_kwarg = "ue_id" pk_url_kwarg = "uv_id"
template_name = "pedagogy/ue_detail.jinja" template_name = "pedagogy/uv_detail.jinja"
form_class = UECommentForm form_class = UVCommentForm
permission_required = "pedagogy.view_ue" permission_required = "pedagogy.view_uv"
def has_permission(self): def has_permission(self):
if self.request.method == "POST" and not self.request.user.has_perm( if self.request.method == "POST" and not self.request.user.has_perm(
"pedagogy.add_uecomment" "pedagogy.add_uvcomment"
): ):
# if it's a POST request, the user is trying to add a new UEComment # if it's a POST request, the user is trying to add a new UVComment
# thus he also needs the "add_uecomment" permission # thus he also needs the "add_uvcomment" permission
return False return False
return super().has_permission() return super().has_permission()
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
kwargs["author_id"] = self.request.user.id kwargs["author_id"] = self.request.user.id
kwargs["ue_id"] = self.object.id kwargs["uv_id"] = self.object.id
kwargs["is_creation"] = True kwargs["is_creation"] = True
return kwargs return kwargs
@@ -89,68 +89,68 @@ class UEDetailFormView(PermissionRequiredMixin, DetailFormView):
} }
def get_success_url(self): def get_success_url(self):
# once the new ue comment has been saved # once the new uv comment has been saved
# redirect to the same page we are currently # redirect to the same page we are currently
return self.request.path return self.request.path
class UECommentUpdateView(PermissionOrAuthorRequiredMixin, UpdateView): class UVCommentUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
"""Allow edit of a given comment.""" """Allow edit of a given comment."""
model = UEComment model = UVComment
form_class = UECommentForm form_class = UVCommentForm
pk_url_kwarg = "comment_id" pk_url_kwarg = "comment_id"
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
permission_required = "pedagogy.change_uecomment" permission_required = "pedagogy.change_uvcomment"
author_field = "author" author_field = "author"
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
kwargs["author_id"] = self.object.author_id kwargs["author_id"] = self.object.author_id
kwargs["ue_id"] = self.object.ue_id kwargs["uv_id"] = self.object.uv_id
kwargs["is_creation"] = False kwargs["is_creation"] = False
return kwargs return kwargs
def get_success_url(self): def get_success_url(self):
return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.object.ue_id}) return reverse("pedagogy:uv_detail", kwargs={"uv_id": self.object.uv_id})
class UECommentDeleteView(PermissionOrAuthorRequiredMixin, DeleteView): class UVCommentDeleteView(PermissionOrAuthorRequiredMixin, DeleteView):
"""Allow to delete a given comment.""" """Allow delete of a given comment."""
model = UEComment model = UVComment
pk_url_kwarg = "comment_id" pk_url_kwarg = "comment_id"
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
permission_required = "pedagogy.delete_uecomment" permission_required = "pedagogy.delete_uvcomment"
author_field = "author" author_field = "author"
def get_success_url(self): def get_success_url(self):
return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.object.ue_id}) return reverse("pedagogy:uv_detail", kwargs={"uv_id": self.object.uv_id})
class UEGuideView(PermissionRequiredMixin, TemplateView): class UVGuideView(PermissionRequiredMixin, TemplateView):
"""UE guide main page.""" """UV guide main page."""
template_name = "pedagogy/guide.jinja" template_name = "pedagogy/guide.jinja"
permission_required = "pedagogy.view_ue" permission_required = "pedagogy.view_uv"
class UECommentReportCreateView(PermissionRequiredMixin, CreateView): class UVCommentReportCreateView(PermissionRequiredMixin, CreateView):
"""Create a new report for an inappropriate comment.""" """Create a new report for an inapropriate comment."""
model = UECommentReport model = UVCommentReport
form_class = UECommentReportForm form_class = UVCommentReportForm
template_name = "core/edit.jinja" template_name = "core/edit.jinja"
permission_required = "pedagogy.add_uecommentreport" permission_required = "pedagogy.add_uvcommentreport"
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
self.ue_comment = get_object_or_404(UEComment, pk=kwargs["comment_id"]) self.uv_comment = get_object_or_404(UVComment, pk=kwargs["comment_id"])
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
kwargs["reporter_id"] = self.request.user.id kwargs["reporter_id"] = self.request.user.id
kwargs["comment_id"] = self.ue_comment.id kwargs["comment_id"] = self.uv_comment.id
return kwargs return kwargs
def form_valid(self, form): def form_valid(self, form):
@@ -172,35 +172,35 @@ class UECommentReportCreateView(PermissionRequiredMixin, CreateView):
return resp return resp
def get_success_url(self): def get_success_url(self):
return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.ue_comment.ue_id}) return reverse("pedagogy:uv_detail", kwargs={"uv_id": self.uv_comment.uv_id})
class UEModerationFormView(PermissionRequiredMixin, FormView): class UVModerationFormView(PermissionRequiredMixin, FormView):
"""Moderation interface (Privileged).""" """Moderation interface (Privileged)."""
form_class = UECommentModerationForm form_class = UVCommentModerationForm
template_name = "pedagogy/moderation.jinja" template_name = "pedagogy/moderation.jinja"
permission_required = "pedagogy.delete_uecomment" permission_required = "pedagogy.delete_uvcomment"
success_url = reverse_lazy("pedagogy:moderation") success_url = reverse_lazy("pedagogy:moderation")
def form_valid(self, form): def form_valid(self, form):
form_clean = form.clean() form_clean = form.clean()
accepted = form_clean.get("accepted_reports", []) accepted = form_clean.get("accepted_reports", [])
if len(accepted) > 0: # delete the reported comments if len(accepted) > 0: # delete the reported comments
UEComment.objects.filter(reports__in=accepted).delete() UVComment.objects.filter(reports__in=accepted).delete()
denied = form_clean.get("denied_reports", []) denied = form_clean.get("denied_reports", [])
if len(denied) > 0: # delete the comments themselves if len(denied) > 0: # delete the comments themselves
UECommentReport.objects.filter(id__in={d.id for d in denied}).delete() UVCommentReport.objects.filter(id__in={d.id for d in denied}).delete()
return super().form_valid(form) return super().form_valid(form)
class UECreateView(PermissionRequiredMixin, CreateView): class UVCreateView(PermissionRequiredMixin, CreateView):
"""Add a new UE (Privileged).""" """Add a new UV (Privileged)."""
model = UE model = UV
form_class = UEForm form_class = UVForm
template_name = "pedagogy/ue_edit.jinja" template_name = "pedagogy/uv_edit.jinja"
permission_required = "pedagogy.add_ue" permission_required = "pedagogy.add_uv"
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
@@ -208,24 +208,24 @@ class UECreateView(PermissionRequiredMixin, CreateView):
return kwargs return kwargs
class UEDeleteView(PermissionRequiredMixin, DeleteView): class UVDeleteView(PermissionRequiredMixin, DeleteView):
"""Allow to delete an UE (Privileged).""" """Allow to delete an UV (Privileged)."""
model = UE model = UV
pk_url_kwarg = "ue_id" pk_url_kwarg = "uv_id"
template_name = "core/delete_confirm.jinja" template_name = "core/delete_confirm.jinja"
permission_required = "pedagogy.delete_ue" permission_required = "pedagogy.delete_uv"
success_url = reverse_lazy("pedagogy:guide") success_url = reverse_lazy("pedagogy:guide")
class UEUpdateView(PermissionRequiredMixin, UpdateView): class UVUpdateView(PermissionRequiredMixin, UpdateView):
"""Allow to edit an UE (Privilegied).""" """Allow to edit an UV (Privilegied)."""
model = UE model = UV
form_class = UEForm form_class = UVForm
pk_url_kwarg = "ue_id" pk_url_kwarg = "uv_id"
template_name = "pedagogy/ue_edit.jinja" template_name = "pedagogy/uv_edit.jinja"
permission_required = "pedagogy.change_ue" permission_required = "pedagogy.change_uv"
def get_form_kwargs(self): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()

View File

@@ -20,8 +20,8 @@ license = { text = "GPL-3.0-only" }
requires-python = "<4.0,>=3.12" requires-python = "<4.0,>=3.12"
dependencies = [ dependencies = [
"django>=5.2.8,<6.0.0", "django>=5.2.8,<6.0.0",
"django-ninja>=1.5.0,<6.0.0", "django-ninja>=1.4.5,<2.0.0",
"django-ninja-extra>=0.30.6", "django-ninja-extra>=0.30.2,<1.0.0",
"Pillow>=12.0.0,<13.0.0", "Pillow>=12.0.0,<13.0.0",
"mistune>=3.1.4,<4.0.0", "mistune>=3.1.4,<4.0.0",
"django-jinja<3.0.0,>=2.11.0", "django-jinja<3.0.0,>=2.11.0",

0
reservation/__init__.py Normal file
View File

19
reservation/admin.py Normal file
View File

@@ -0,0 +1,19 @@
from django.contrib import admin
from reservation.models import ReservationSlot, Room
@admin.register(Room)
class RoomAdmin(admin.ModelAdmin):
list_display = ("name", "club")
list_filter = (("club", admin.RelatedOnlyFieldListFilter), "location")
autocomplete_fields = ("club",)
search_fields = ("name",)
@admin.register(ReservationSlot)
class ReservationSlotAdmin(admin.ModelAdmin):
list_display = ("room", "start_at", "end_at", "author")
autocomplete_fields = ("author",)
list_filter = ("room",)
date_hierarchy = "start_at"

64
reservation/api.py Normal file
View File

@@ -0,0 +1,64 @@
from typing import Any, Literal
from django.core.exceptions import ValidationError
from ninja import Query
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
from ninja_extra.schemas import PaginatedResponseSchema
from api.permissions import HasPerm
from reservation.models import ReservationSlot, Room
from reservation.schemas import (
RoomFilterSchema,
RoomSchema,
SlotFilterSchema,
SlotSchema,
UpdateReservationSlotSchema,
)
@api_controller("/reservation/room")
class ReservableRoomController(ControllerBase):
@route.get(
"",
response=list[RoomSchema],
permissions=[HasPerm("reservation.view_room")],
url_name="fetch_reservable_rooms",
)
def fetch_rooms(self, filters: Query[RoomFilterSchema]):
return filters.filter(Room.objects.select_related("club"))
@api_controller("/reservation/slot")
class ReservationSlotController(ControllerBase):
@route.get(
"",
response=PaginatedResponseSchema[SlotSchema],
permissions=[HasPerm("reservation.view_reservationslot")],
url_name="fetch_reservation_slots",
)
@paginate(PageNumberPaginationExtra)
def fetch_slots(self, filters: Query[SlotFilterSchema]):
return filters.filter(
ReservationSlot.objects.select_related("author").order_by("start_at")
)
@route.patch(
"/reservation/slot/{int:slot_id}",
permissions=[HasPerm("reservation.change_reservationslot")],
response={
200: None,
409: dict[Literal["detail"], dict[str, list[str]]],
422: dict[Literal["detail"], list[dict[str, Any]]],
},
url_name="change_reservation_slot",
)
def update_slot(self, slot_id: int, params: UpdateReservationSlotSchema):
slot = self.get_object_or_exception(ReservationSlot, id=slot_id)
slot.start_at = params.start_at
slot.end_at = params.end_at
try:
slot.full_clean()
slot.save()
except ValidationError as e:
return self.create_response({"detail": dict(e)}, status_code=409)

6
reservation/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ReservationConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "reservation"

60
reservation/forms.py Normal file
View File

@@ -0,0 +1,60 @@
from django import forms
from django.core.exceptions import NON_FIELD_ERRORS
from django.utils.translation import gettext_lazy as _
from club.widgets.ajax_select import AutoCompleteSelectClub
from core.models import User
from core.views.forms import FutureDateTimeField, SelectDateTime
from reservation.models import ReservationSlot, Room
class RoomCreateForm(forms.ModelForm):
required_css_class = "required"
error_css_class = "error"
class Meta:
model = Room
fields = ["name", "club", "location", "description"]
widgets = {"club": AutoCompleteSelectClub}
class RoomUpdateForm(forms.ModelForm):
required_css_class = "required"
error_css_class = "error"
class Meta:
model = Room
fields = ["name", "club", "location", "description"]
widgets = {"club": AutoCompleteSelectClub}
def __init__(self, *args, request_user: User, **kwargs):
super().__init__(*args, **kwargs)
if not request_user.has_perm("reservation.change_room"):
# if the user doesn't have the global edition permission
# (i.e. it's a club board member, but not a sith admin)
# some fields aren't editable
del self.fields["club"]
class ReservationForm(forms.ModelForm):
required_css_class = "required"
error_css_class = "error"
class Meta:
model = ReservationSlot
fields = ["room", "start_at", "end_at", "comment"]
field_classes = {"start_at": FutureDateTimeField, "end_at": FutureDateTimeField}
widgets = {"start_at": SelectDateTime(), "end_at": SelectDateTime()}
error_messages = {
NON_FIELD_ERRORS: {
"start_after_end": _("The start must be set before the end")
}
}
def __init__(self, *args, author: User, **kwargs):
super().__init__(*args, **kwargs)
self.author = author
def save(self, commit: bool = True): # noqa FBT001
self.instance.author = self.author
return super().save(commit)

View File

@@ -0,0 +1,117 @@
# Generated by Django 5.2.1 on 2025-06-05 10:44
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Room",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100, verbose_name="room name")),
(
"description",
models.TextField(
blank=True, default="", verbose_name="description"
),
),
(
"location",
models.CharField(
blank=True,
choices=[
("BELFORT", "Belfort"),
("SEVENANS", "Sévenans"),
("MONTBELIARD", "Montbéliard"),
],
verbose_name="site",
),
),
(
"club",
models.ForeignKey(
help_text="The club which manages this room",
on_delete=django.db.models.deletion.CASCADE,
related_name="reservable_rooms",
to="club.club",
verbose_name="room owner",
),
),
],
options={
"verbose_name": "reservable room",
"verbose_name_plural": "reservable rooms",
},
),
migrations.CreateModel(
name="ReservationSlot",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"comment",
models.TextField(blank=True, default="", verbose_name="comment"),
),
(
"start_at",
models.DateTimeField(db_index=True, verbose_name="slot start"),
),
("end_at", models.DateTimeField(verbose_name="slot end")),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"author",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
verbose_name="author",
),
),
(
"room",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="slots",
to="reservation.room",
verbose_name="reserved room",
),
),
],
options={
"verbose_name": "reservation slot",
"verbose_name_plural": "reservation slots",
"constraints": [
models.CheckConstraint(
condition=models.Q(("end_at__gt", models.F("start_at"))),
name="reservation_slot_end_after_start",
violation_error_code="start_after_end",
)
],
},
),
]

View File

100
reservation/models.py Normal file
View File

@@ -0,0 +1,100 @@
from __future__ import annotations
from typing import Self
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, Q
from django.utils.translation import gettext_lazy as _
from club.models import Club
from core.models import User
class Room(models.Model):
name = models.CharField(_("room name"), max_length=100)
description = models.TextField(_("description"), blank=True, default="")
club = models.ForeignKey(
Club,
on_delete=models.CASCADE,
related_name="reservable_rooms",
verbose_name=_("room owner"),
help_text=_("The club which manages this room"),
)
location = models.CharField(
_("site"),
blank=True,
choices=[
("BELFORT", "Belfort"),
("SEVENANS", "Sévenans"),
("MONTBELIARD", "Montbéliard"),
],
)
class Meta:
verbose_name = _("reservable room")
verbose_name_plural = _("reservable rooms")
def __str__(self):
return self.name
def can_be_edited_by(self, user: User) -> bool:
# a user may edit a room if it has the global perm
# or is in the owner club board
return user.has_perm("reservation.change_room") or self.club.board_group_id in [
g.id for g in user.cached_groups
]
class ReservationSlotQuerySet(models.QuerySet):
def overlapping_with(self, slot: ReservationSlot) -> Self:
return self.filter(
Q(start_at__lt=slot.start_at, end_at__gt=slot.start_at)
| Q(start_at__lt=slot.end_at, end_at__gt=slot.end_at)
)
class ReservationSlot(models.Model):
room = models.ForeignKey(
Room,
on_delete=models.CASCADE,
related_name="slots",
verbose_name=_("reserved room"),
)
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_("author"))
comment = models.TextField(_("comment"), blank=True, default="")
start_at = models.DateTimeField(_("slot start"), db_index=True)
end_at = models.DateTimeField(_("slot end"))
created_at = models.DateTimeField(auto_now_add=True)
objects = ReservationSlotQuerySet.as_manager()
class Meta:
verbose_name = _("reservation slot")
verbose_name_plural = _("reservation slots")
constraints = [
models.CheckConstraint(
condition=Q(end_at__gt=F("start_at")),
name="reservation_slot_end_after_start",
violation_error_code="start_after_end",
)
]
def __str__(self):
return f"{self.room.name} : {self.start_at} - {self.end_at}"
def clean(self):
super().clean()
if self.end_at is None or self.start_at is None:
# if there is no start or no end, then there is no
# point to check if this perm overlap with another,
# so in this case, don't do the overlap check and let
# Django manage the non-null constraint error.
return
overlapping = ReservationSlot.objects.overlapping_with(self).filter(
room_id=self.room_id
)
if self.id is not None:
overlapping = overlapping.exclude(id=self.id)
if overlapping.exists():
raise ValidationError(_("There is already a reservation on this slot."))

46
reservation/schemas.py Normal file
View File

@@ -0,0 +1,46 @@
from datetime import datetime
from ninja import FilterSchema, ModelSchema, Schema
from pydantic import Field, FutureDatetime
from club.schemas import SimpleClubSchema
from core.schemas import SimpleUserSchema
from reservation.models import ReservationSlot, Room
class RoomFilterSchema(FilterSchema):
club: set[int] | None = Field(None, q="club_id__in")
class RoomSchema(ModelSchema):
class Meta:
model = Room
fields = ["id", "name", "description", "location"]
club: SimpleClubSchema
@staticmethod
def resolve_location(obj: Room):
return obj.get_location_display()
class SlotFilterSchema(FilterSchema):
after: datetime = Field(default=None, q="end_at__gt")
before: datetime = Field(default=None, q="start_at__lt")
room: set[int] | None = None
club: set[int] | None = None
class SlotSchema(ModelSchema):
class Meta:
model = ReservationSlot
fields = ["id", "room", "comment"]
start: datetime = Field(alias="start_at")
end: datetime = Field(alias="end_at")
author: SimpleUserSchema
class UpdateReservationSlotSchema(Schema):
start_at: FutureDatetime
end_at: FutureDatetime

View File

@@ -0,0 +1,138 @@
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import {
Calendar,
type DateSelectArg,
type EventDropArg,
type EventSourceFuncArg,
} from "@fullcalendar/core";
import enLocale from "@fullcalendar/core/locales/en-gb";
import frLocale from "@fullcalendar/core/locales/fr";
import {
type ReservationslotFetchSlotsData,
type SlotSchema,
reservableroomFetchRooms,
reservationslotFetchSlots,
reservationslotUpdateSlot,
} from "#openapi";
import { paginated } from "#core:utils/api";
import type { SlotSelectedEventArg } from "#reservation:reservation/types";
import interactionPlugin, { type EventResizeDoneArg } from "@fullcalendar/interaction";
import resourceTimelinePlugin from "@fullcalendar/resource-timeline";
@registerComponent("room-scheduler")
export class RoomScheduler extends inheritHtmlElement("div") {
static observedAttributes = ["locale", "can_edit_slot", "can_create_slot"];
private scheduler: Calendar;
private locale = "en";
private canEditSlot = false;
private canBookSlot = false;
private canDeleteSlot = false;
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
if (name === "locale") {
this.locale = newValue;
}
if (name === "can_edit_slot") {
this.canEditSlot = newValue.toLowerCase() === "true";
}
if (name === "can_create_slot") {
this.canBookSlot = newValue.toLowerCase() === "true";
}
if (name === "can_delete_slot") {
this.canDeleteSlot = newValue.toLowerCase() === "true";
}
}
/**
* Fetch the events displayed in the timeline.
* cf https://fullcalendar.io/docs/events-function
*/
async fetchEvents(fetchInfo: EventSourceFuncArg) {
const res: SlotSchema[] = await paginated(reservationslotFetchSlots, {
query: { after: fetchInfo.startStr, before: fetchInfo.endStr },
} as ReservationslotFetchSlotsData);
return res.map((i) =>
Object.assign(i, {
title: `${i.author.first_name} ${i.author.last_name}`,
resourceId: i.room,
editable: new Date(i.start) > new Date(),
}),
);
}
/**
* Fetch the resources which events are associated with.
* cf https://fullcalendar.io/docs/resources-function
*/
async fetchResources() {
const res = await reservableroomFetchRooms();
return res.data.map((i) => Object.assign(i, { title: i.name, group: i.location }));
}
/**
* Send a request to the API to change
* the start and the duration of a reservation slot
*/
async changeReservation(args: EventDropArg | EventResizeDoneArg) {
const response = await reservationslotUpdateSlot({
// biome-ignore lint/style/useNamingConvention: api is snake_case
path: { slot_id: Number.parseInt(args.event.id) },
// biome-ignore lint/style/useNamingConvention: api is snake_case
body: { start_at: args.event.startStr, end_at: args.event.endStr },
});
if (response.response.ok) {
document.dispatchEvent(new CustomEvent("reservationSlotChanged"));
this.scheduler.refetchEvents();
}
}
selectFreeSlot(infos: DateSelectArg) {
document.dispatchEvent(
new CustomEvent<SlotSelectedEventArg>("timeSlotSelected", {
detail: {
ressource: Number.parseInt(infos.resource.id),
start: infos.startStr,
end: infos.endStr,
},
}),
);
}
connectedCallback() {
super.connectedCallback();
this.scheduler = new Calendar(this.node, {
schedulerLicenseKey: "GPL-My-Project-Is-Open-Source",
initialView: "resourceTimelineDay",
headerToolbar: {
left: "prev,next today",
center: "title",
right: "resourceTimelineDay,resourceTimelineWeek",
},
plugins: [resourceTimelinePlugin, interactionPlugin],
locales: [frLocale, enLocale],
height: "auto",
locale: this.locale,
resourceGroupField: "group",
resourceAreaHeaderContent: gettext("Rooms"),
editable: this.canEditSlot,
snapDuration: "00:15",
eventConstraint: { start: new Date() }, // forbid edition of past events
eventOverlap: false,
eventResourceEditable: false,
refetchResourcesOnNavigate: true,
resourceAreaWidth: "20%",
resources: this.fetchResources,
events: this.fetchEvents,
select: this.selectFreeSlot,
selectOverlap: false,
selectable: this.canBookSlot,
selectConstraint: { start: new Date() },
nowIndicator: true,
eventDrop: this.changeReservation,
eventResize: this.changeReservation,
});
this.scheduler.render();
}
}

View File

@@ -0,0 +1,39 @@
import { AlertMessage } from "#core:utils/alert-message";
import type { SlotSelectedEventArg } from "#reservation:reservation/types";
document.addEventListener("alpine:init", () => {
Alpine.data("slotReservation", () => ({
start: null as string,
end: null as string,
room: null as number,
showForm: false,
init() {
document.addEventListener(
"timeSlotSelected",
(event: CustomEvent<SlotSelectedEventArg>) => {
this.start = event.detail.start.split("+")[0];
this.end = event.detail.end.split("+")[0];
this.room = event.detail.ressource;
this.showForm = true;
this.$nextTick(() => this.$el.scrollIntoView({ behavior: "smooth" })).then();
},
);
},
}));
/**
* Component that will catch events sent from the scheduler
* to display success messages accordingly.
*/
Alpine.data("scheduleMessages", () => ({
alertMessage: new AlertMessage({ defaultDuration: 2000 }),
init() {
document.addEventListener("reservationSlotChanged", (_event: CustomEvent) => {
this.alertMessage.display(gettext("This slot has been successfully moved"), {
success: true,
});
});
},
}));
});

View File

@@ -0,0 +1,5 @@
export interface SlotSelectedEventArg {
start: string;
end: string;
ressource: number;
}

View File

@@ -0,0 +1,39 @@
#slot-reservation {
margin-top: 3em;
display: flex;
flex-direction: column;
justify-content: center;
h3 {
display: block;
margin: auto;
text-align: left;
}
.alert, .error {
display: block;
margin: 1em auto auto;
max-width: 400px;
word-wrap: break-word;
text-wrap: wrap;
}
form {
display: flex;
flex-direction: column;
gap: .5em;
justify-content: center;
.buttons-row {
input[type="submit"], button {
margin: 0;
}
}
textarea {
max-width: unset;
width: 100%;
margin-top: unset;
}
}
}

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