53 Commits

Author SHA1 Message Date
dfb545b15e add feedback when moving reservation slot 2025-06-30 22:50:44 +02:00
f9fa4c0643 extract AlertMessage to its own file 2025-06-30 22:50:27 +02:00
72bb4788f2 test: room and slots creation/edition 2025-06-30 22:36:17 +02:00
0baaf69714 fix: rebase issues 2025-06-30 16:16:24 +02:00
edd8b9a385 test: ReservationForm 2025-06-30 16:11:59 +02:00
a322a0895a add translations 2025-06-30 16:11:59 +02:00
d4e853fa60 Room reservation form 2025-06-30 16:11:59 +02:00
21416dc27a Room reservations planning 2025-06-30 14:05:43 +02:00
b2d97ab138 room management views 2025-06-30 14:05:43 +02:00
f092d44ef7 fix: FutureDateTime form field 2025-06-30 14:05:43 +02:00
08abc62e56 reservable rooms API 2025-06-30 14:05:43 +02:00
5f2caf9d61 generate test data for the reservations 2025-06-30 14:05:43 +02:00
c45be81bb3 create reservation models 2025-06-30 14:05:43 +02:00
Sli
af014e419f Adapt calendar to new tooltip library 2025-06-30 14:05:43 +02:00
c177ef2a3a Merge pull request #1145 from ae-utbm/xapian
fix: xapian compilation flags
2025-06-30 13:46:02 +02:00
6cf8910626 fix: xapian compilation flags 2025-06-30 13:09:24 +02:00
eb4fbcbda4 Merge pull request #1140 from Juknum/feature/update-footer-on-mobile
Màj du footer sur mobile
2025-06-26 16:01:20 +02:00
570510f18d Merge pull request #1135 from ae-utbm/group
Small group tweak
2025-06-25 22:04:56 +02:00
7f371984d8 Merge pull request #1143 from ae-utbm/fix/mail-enumeration
fix: enumeration attack vector on login form
2025-06-25 17:53:53 +02:00
abf7bf6bfa rename location_admin to campus_admin 2025-06-25 17:13:24 +02:00
02ef8fdb88 fix: enumeration attack vector on login form 2025-06-25 17:03:53 +02:00
a7f4630d13 Merge pull request #1138 from ae-utbm/counter-admin
improve counter admin pages
2025-06-25 17:03:03 +02:00
c7087c6e7e Merge pull request #1137 from ae-utbm/fix-user-pictures
fix: user pictures ordering
2025-06-25 16:40:23 +02:00
f38926c4a3 fix: user pictures ordering 2025-06-25 16:25:51 +02:00
9a19f34ea2 Merge pull request #1141 from ae-utbm/fix-permanences
Fix permanences
2025-06-25 14:55:36 +02:00
67884017f8 fix old permanences having end replaced by activity 2025-06-25 01:22:13 +02:00
Sli
f474edc84f Style adjustment on the new footer 2025-06-24 17:04:52 +02:00
f5a8228358 Rework footer's UX on small devices 2025-06-22 20:01:22 +02:00
59a714af9f Merge pull request #1134 from ae-utbm/family
Add zoom controls to family graph
2025-06-21 15:20:47 +02:00
9049d8779c improve counter admin pages 2025-06-21 15:06:08 +02:00
Sli
d111023363 Apply review comments 2025-06-21 12:37:01 +02:00
cdfa76ad57 add missing "Respo site" group 2025-06-18 18:01:37 +02:00
88b70bf51f rename main groups to their real production version 2025-06-18 18:01:37 +02:00
Sli
ca593c7d81 Avoid click on graph when zooming 2025-06-18 16:24:53 +02:00
Sli
94bdc5e615 Remove useless closures 2025-06-18 14:13:06 +02:00
Sli
7d454749e0 Add style to zoom controls on family graph 2025-06-18 14:10:26 +02:00
06090e0cd9 Merge pull request #1133 from ae-utbm/api-fixes
fix: api title typo (again)
2025-06-18 12:25:31 +02:00
a1ae67da7d Merge pull request #1132 from ae-utbm/missing-perm
Missing SAS permission
2025-06-18 12:25:15 +02:00
Sli
10d5b9d63f Add zoom control of family graph 2025-06-18 12:22:30 +02:00
Sli
cc96c93d23 Convert family tree to typescript 2025-06-18 11:59:46 +02:00
8cc0b01e9c fix: api title typo (again) 2025-06-17 21:01:51 +02:00
88755358a6 fix: add missing sas permission 2025-06-17 21:00:38 +02:00
0e850e5486 Merge pull request #1131 from ae-utbm/api-fixes
Api fixes
2025-06-17 15:57:33 +02:00
af67c5fc27 Merge pull request #1130 from ae-utbm/navbar-keyboard-navigation
Fix click on navbar
2025-06-17 15:41:42 +02:00
Sli
30809a69c9 Move navbar script to dedicated file 2025-06-17 15:39:35 +02:00
0c442a8f03 fix: select only active club members on GET /club/{club_id} 2025-06-17 15:35:49 +02:00
f1b69dd47d fix: typo in API name 2025-06-17 15:35:49 +02:00
Sli
b5ebf09fcb Fix click on navbar 2025-06-17 15:31:51 +02:00
9d9ce5b30a Merge pull request #1129 from ae-utbm/fix-docs
fix: documentation CI/CD
2025-06-17 15:09:06 +02:00
a87460fa3e fix: documentation CI/CD 2025-06-17 14:45:51 +02:00
48fae33651 Merge pull request #1119 from ae-utbm/notifs
Improve notification on picture identification
2025-06-17 11:22:06 +02:00
6fec250658 display album name on picture identification notif 2025-06-16 18:36:08 +02:00
75b37cd6e3 fix album grouping on user pictures page 2025-06-16 18:36:08 +02:00
79 changed files with 2626 additions and 768 deletions

View File

@ -1,15 +1,24 @@
name: "Setup project" name: "Setup project"
description: "Setup Python and Poetry" description: "Setup Python and Poetry"
inputs:
full:
description: >
If true, do a full setup, else install
only python, uv and non-xapian python deps
required: false
default: "false"
runs: runs:
using: composite using: composite
steps: steps:
- name: Install apt packages - name: Install apt packages
if: ${{ inputs.full == 'true' }}
uses: awalsh128/cache-apt-pkgs-action@v1.4.3 uses: awalsh128/cache-apt-pkgs-action@v1.4.3
with: with:
packages: gettext packages: gettext
version: 1.0 # increment to reset cache version: 1.0 # increment to reset cache
- name: Install Redis - name: Install Redis
if: ${{ inputs.full == 'true' }}
uses: shogo82148/actions-setup-redis@v1 uses: shogo82148/actions-setup-redis@v1
with: with:
redis-version: "7.x" redis-version: "7.x"
@ -37,15 +46,20 @@ runs:
shell: bash shell: bash
- name: Install Xapian - name: Install Xapian
if: ${{ inputs.full == 'true' }}
run: uv run ./manage.py install_xapian run: uv run ./manage.py install_xapian
shell: bash shell: bash
# compiling xapian accounts for almost the entirety of the virtualenv setup,
# so we save the virtual environment only on workflows where it has been installed
- name: Save cached virtualenv - name: Save cached virtualenv
if: ${{ inputs.full == 'true' }}
uses: actions/cache/save@v4 uses: actions/cache/save@v4
with: with:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }} key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
path: .venv path: .venv
- name: Compile gettext messages - name: Compile gettext messages
if: ${{ inputs.full == 'true' }}
run: uv run ./manage.py compilemessages run: uv run ./manage.py compilemessages
shell: bash shell: bash

View File

@ -37,6 +37,8 @@ jobs:
- name: Check out repository - name: Check out repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- uses: ./.github/actions/setup_project - uses: ./.github/actions/setup_project
with:
full: true
env: env:
# To avoid race conditions on environment cache # To avoid race conditions on environment cache
CACHE_SUFFIX: ${{ matrix.pytest-mark }} CACHE_SUFFIX: ${{ matrix.pytest-mark }}

View File

@ -2,11 +2,7 @@ name: deploy_docs
on: on:
push: push:
branches: branches:
- master - taiste
env:
SECRET_KEY: notTheRealOne
DATABASE_URL: sqlite:///db.sqlite3
CACHE_URL: redis://127.0.0.1:6379/0
permissions: permissions:
contents: write contents: write
jobs: jobs:

View File

@ -2,7 +2,7 @@ from ninja_extra import NinjaExtraAPI
api = NinjaExtraAPI( api = NinjaExtraAPI(
title="PICON", title="PICON",
description="Portail Interaction de Communication avec les Services Étudiants", description="Portail Interactif de Communication avec les Outils Numériques",
version="0.2.0", version="0.2.0",
urls_namespace="api", urls_namespace="api",
csrf=True, csrf=True,

View File

@ -1,6 +1,7 @@
from typing import Annotated from typing import Annotated
from annotated_types import MinLen from annotated_types import MinLen
from django.db.models import Prefetch
from ninja.security import SessionAuth from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.pagination import PageNumberPaginationExtra
@ -8,7 +9,7 @@ from ninja_extra.schemas import PaginatedResponseSchema
from api.auth import ApiKeyAuth from api.auth import ApiKeyAuth
from api.permissions import CanAccessLookup, HasPerm from api.permissions import CanAccessLookup, HasPerm
from club.models import Club from club.models import Club, Membership
from club.schemas import ClubSchema, SimpleClubSchema from club.schemas import ClubSchema, SimpleClubSchema
@ -33,6 +34,9 @@ class ClubController(ControllerBase):
url_name="fetch_club", url_name="fetch_club",
) )
def fetch_club(self, club_id: int): def fetch_club(self, club_id: int):
return self.get_object_or_exception( prefetch = Prefetch(
Club.objects.prefetch_related("members", "members__user"), id=club_id "members", queryset=Membership.objects.ongoing().select_related("user")
)
return self.get_object_or_exception(
Club.objects.prefetch_related(prefetch), id=club_id
) )

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

@ -1,7 +1,10 @@
from datetime import date, timedelta
import pytest import pytest
from django.test import Client from django.test import Client
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertNumQueries from pytest_django.asserts import assertNumQueries
from club.models import Club, Membership from club.models import Club, Membership
@ -9,13 +12,32 @@ from core.baker_recipes import subscriber_user
@pytest.mark.django_db @pytest.mark.django_db
def test_fetch_club(client: Client): class TestFetchClub:
@pytest.fixture()
def club(self):
club = baker.make(Club) club = baker.make(Club)
baker.make(Membership, club=club, _quantity=10, _bulk_create=True) last_month = date.today() - timedelta(days=30)
yesterday = date.today() - timedelta(days=1)
membership_recipe = Recipe(Membership, club=club, start_date=last_month)
membership_recipe.make(end_date=None, _quantity=10, _bulk_create=True)
membership_recipe.make(end_date=yesterday, _quantity=10, _bulk_create=True)
return club
def test_fetch_club_members(self, client: Client, club: Club):
user = subscriber_user.make() user = subscriber_user.make()
client.force_login(user) client.force_login(user)
with assertNumQueries(7): res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id}))
# - 4 queries for authentication assert res.status_code == 200
# - 3 queries for the actual data member_ids = {member["user"]["id"] for member in res.json()["members"]}
assert member_ids == set(
club.members.ongoing().values_list("user_id", flat=True)
)
def test_fetch_club_nb_queries(self, client: Client, club: Club):
user = subscriber_user.make()
client.force_login(user)
with assertNumQueries(6):
# - 4 queries for authentication
# - 2 queries for the actual data
res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id})) res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id}))
assert res.status_code == 200 assert res.status_code == 200

View File

@ -241,6 +241,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 ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView): class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
"""View of a club's members.""" """View of a club's members."""

View File

@ -170,7 +170,6 @@ def news_notification_callback(notif: Notification):
if count: if count:
notif.viewed = False notif.viewed = False
notif.param = str(count) notif.param = str(count)
notif.date = timezone.now()
else: else:
notif.viewed = True notif.viewed = True

View File

@ -81,9 +81,8 @@
} }
#links_content { #links_content {
overflow: auto;
box-shadow: $shadow-color 1px 1px 1px; box-shadow: $shadow-color 1px 1px 1px;
height: 20em; padding: .5rem;
h4 { h4 {
margin-left: 5px; margin-left: 5px;

View File

@ -1,13 +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 %} {% block title %}AE UTBM{% endblock %}
{% trans %}News{% endtrans %}
{% 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") }}">
@ -213,6 +211,12 @@
<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_clear") }}">{% 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

@ -4,13 +4,13 @@
VERSION="$1" VERSION="$1"
# Cleanup env vars for auto discovery mechanism # Cleanup env vars for auto discovery mechanism
export CPATH= unset CPATH
export LIBRARY_PATH= unset LIBRARY_PATH
export CFLAGS= unset CFLAGS
export LDFLAGS= unset LDFLAGS
export CCFLAGS= unset CCFLAGS
export CXXFLAGS= unset CXXFLAGS
export CPPFLAGS= unset CPPFLAGS
# prepare # prepare
rm -rf "$VIRTUAL_ENV/packages" rm -rf "$VIRTUAL_ENV/packages"

View File

@ -59,6 +59,7 @@ class PopulatedGroups(NamedTuple):
counter_admin: Group counter_admin: Group
accounting_admin: Group accounting_admin: Group
pedagogy_admin: Group pedagogy_admin: Group
campus_admin: Group
class Command(BaseCommand): class Command(BaseCommand):
@ -784,13 +785,17 @@ class Command(BaseCommand):
# public has no permission. # public has no permission.
# Its purpose is not to link users to permissions, # Its purpose is not to link users to permissions,
# but to other objects (like products) # but to other objects (like products)
public_group = Group.objects.create(name="Public") public_group = Group.objects.create(name="Publique")
subscribers = Group.objects.create(name="Subscribers") subscribers = Group.objects.create(name="Cotisants")
subscribers.permissions.add( subscribers.permissions.add(
*list(perms.filter(codename__in=["add_news", "add_uvcomment"])) *list(
perms.filter(
codename__in=["add_news", "add_uvcomment", "view_reservationslot"]
) )
old_subscribers = Group.objects.create(name="Old subscribers") )
)
old_subscribers = Group.objects.create(name="Anciens cotisants")
old_subscribers.permissions.add( old_subscribers.permissions.add(
*list( *list(
perms.filter( perms.filter(
@ -812,7 +817,7 @@ class Command(BaseCommand):
) )
) )
accounting_admin = Group.objects.create( accounting_admin = Group.objects.create(
name="Accounting admin", is_manually_manageable=True name="Admin comptabilité", is_manually_manageable=True
) )
accounting_admin.permissions.add( accounting_admin.permissions.add(
*list( *list(
@ -833,7 +838,7 @@ class Command(BaseCommand):
) )
) )
com_admin = Group.objects.create( com_admin = Group.objects.create(
name="Communication admin", is_manually_manageable=True name="Admin communication", is_manually_manageable=True
) )
com_admin.permissions.add( com_admin.permissions.add(
*list( *list(
@ -841,7 +846,7 @@ class Command(BaseCommand):
) )
) )
counter_admin = Group.objects.create( counter_admin = Group.objects.create(
name="Counter admin", is_manually_manageable=True name="Admin comptoirs", is_manually_manageable=True
) )
counter_admin.permissions.add( counter_admin.permissions.add(
*list( *list(
@ -851,14 +856,14 @@ class Command(BaseCommand):
) )
) )
) )
sas_admin = Group.objects.create(name="SAS admin", is_manually_manageable=True) sas_admin = Group.objects.create(name="Admin SAS", is_manually_manageable=True)
sas_admin.permissions.add( sas_admin.permissions.add(
*list( *list(
perms.filter(content_type__app_label="sas").values_list("pk", flat=True) perms.filter(content_type__app_label="sas").values_list("pk", flat=True)
) )
) )
forum_admin = Group.objects.create( forum_admin = Group.objects.create(
name="Forum admin", is_manually_manageable=True name="Admin forum", is_manually_manageable=True
) )
forum_admin.permissions.add( forum_admin.permissions.add(
*list( *list(
@ -868,7 +873,7 @@ class Command(BaseCommand):
) )
) )
pedagogy_admin = Group.objects.create( pedagogy_admin = Group.objects.create(
name="Pedagogy admin", is_manually_manageable=True name="Admin pédagogie", is_manually_manageable=True
) )
pedagogy_admin.permissions.add( pedagogy_admin.permissions.add(
*list( *list(
@ -877,6 +882,16 @@ class Command(BaseCommand):
.values_list("pk", flat=True) .values_list("pk", flat=True)
) )
) )
campus_admin = Group.objects.create(
name="Respo site", is_manually_manageable=True
)
campus_admin.permissions.add(
*counter_admin.permissions.values_list("pk", flat=True),
*perms.filter(content_type__app_label="reservation").values_list(
"pk", flat=True
),
)
self.reset_index("core", "auth") self.reset_index("core", "auth")
return PopulatedGroups( return PopulatedGroups(
@ -889,6 +904,7 @@ class Command(BaseCommand):
accounting_admin=accounting_admin, accounting_admin=accounting_admin,
sas_admin=sas_admin, sas_admin=sas_admin,
pedagogy_admin=pedagogy_admin, pedagogy_admin=pedagogy_admin,
campus_admin=campus_admin,
) )
def _create_ban_groups(self): def _create_ban_groups(self):

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
@ -24,6 +25,7 @@ from counter.models import (
) )
from forum.models import Forum, ForumMessage, ForumTopic from forum.models import Forum, ForumMessage, ForumTopic
from pedagogy.models import UV 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_uvs() 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)
@ -188,6 +165,97 @@ 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_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): 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"]
@ -238,7 +306,13 @@ class Command(BaseCommand):
ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID) ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID)
other_clubs = random.sample(list(Club.objects.all()), k=3) other_clubs = random.sample(list(Club.objects.all()), k=3)
groups = list( groups = list(
Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"]) Group.objects.filter(
id__in=[
settings.SITH_GROUP_SUBSCRIBERS_ID,
settings.SITH_GROUP_OLD_SUBSCRIBERS_ID,
settings.SITH_GROUP_PUBLIC_ID,
]
)
) )
counters = list( counters = list(
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"]) Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"])
@ -379,7 +453,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 = [
@ -397,7 +471,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:
@ -405,7 +479,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(
[ [
@ -417,7 +491,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

@ -0,0 +1,27 @@
# Generated by Django 5.2.1 on 2025-06-11 16:10
from django.db import migrations, models
import core.models
class Migration(migrations.Migration):
dependencies = [("core", "0046_permissionrights")]
operations = [
migrations.AlterField(
model_name="notification",
name="date",
field=models.DateTimeField(auto_now=True, verbose_name="date"),
),
migrations.AlterField(
model_name="notification",
name="type",
field=models.CharField(
choices=core.models.get_notification_types,
default="GENERIC",
max_length=32,
verbose_name="type",
),
),
]

View File

@ -1451,6 +1451,10 @@ class PageRev(models.Model):
return self.page.can_be_edited_by(user) return self.page.can_be_edited_by(user)
def get_notification_types():
return settings.SITH_NOTIFICATIONS
class Notification(models.Model): class Notification(models.Model):
user = models.ForeignKey( user = models.ForeignKey(
User, related_name="notifications", on_delete=models.CASCADE User, related_name="notifications", on_delete=models.CASCADE
@ -1458,9 +1462,9 @@ class Notification(models.Model):
url = models.CharField(_("url"), max_length=255) url = models.CharField(_("url"), max_length=255)
param = models.CharField(_("param"), max_length=128, default="") param = models.CharField(_("param"), max_length=128, default="")
type = models.CharField( type = models.CharField(
_("type"), max_length=32, choices=settings.SITH_NOTIFICATIONS, default="GENERIC" _("type"), max_length=32, choices=get_notification_types, default="GENERIC"
) )
date = models.DateTimeField(_("date"), default=timezone.now) date = models.DateTimeField(_("date"), auto_now=True)
viewed = models.BooleanField(_("viewed"), default=False, db_index=True) viewed = models.BooleanField(_("viewed"), default=False, db_index=True)
def __str__(self): def __str__(self):

View File

@ -1,7 +1,8 @@
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); Alpine.plugin([sort, morph]);
window.Alpine = Alpine; window.Alpine = Alpine;
window.addEventListener("DOMContentLoaded", () => { window.addEventListener("DOMContentLoaded", () => {

View File

@ -0,0 +1,36 @@
import { exportToHtml } from "#core:utils/globals";
exportToHtml("showMenu", () => {
const navbar = document.getElementById("navbar-content");
const current = navbar.getAttribute("mobile-display");
navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden");
});
document.addEventListener("alpine:init", () => {
const menuItems = document.querySelectorAll(".navbar details[name='navbar'].menu");
const isDesktop = () => {
return window.innerWidth >= 500;
};
for (const item of menuItems) {
item.addEventListener("mouseover", () => {
if (isDesktop()) {
item.setAttribute("open", "");
}
});
item.addEventListener("mouseout", () => {
if (isDesktop()) {
item.removeAttribute("open");
}
});
item.addEventListener("click", (event: MouseEvent) => {
// Don't close when clicking on desktop mode
if ((event.target as HTMLElement).nodeName !== "SUMMARY" || event.detail === 0) {
return;
}
if (isDesktop()) {
event.preventDefault();
}
});
}
});

View File

@ -1,4 +1,5 @@
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.target.ariaBusy = true; event.target.ariaBusy = true;

View File

@ -1,274 +0,0 @@
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import cytoscape from "cytoscape";
import cxtmenu from "cytoscape-cxtmenu";
import klay from "cytoscape-klay";
import { familyGetFamilyGraph } from "#openapi";
cytoscape.use(klay);
cytoscape.use(cxtmenu);
async function getGraphData(userId, godfathersDepth, godchildrenDepth) {
const data = (
await familyGetFamilyGraph({
path: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
user_id: userId,
},
query: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
godfathers_depth: godfathersDepth,
// biome-ignore lint/style/useNamingConvention: api is snake_case
godchildren_depth: godchildrenDepth,
},
})
).data;
return [
...data.users.map((user) => {
return { data: user };
}),
...data.relationships.map((rel) => {
return {
data: { source: rel.godfather, target: rel.godchild },
};
}),
];
}
function createGraph(container, data, activeUserId) {
const cy = cytoscape({
boxSelectionEnabled: false,
autounselectify: true,
container,
elements: data,
minZoom: 0.5,
style: [
// the stylesheet for the graph
{
selector: "node",
style: {
label: "data(display_name)",
"background-image": "data(profile_pict)",
width: "100%",
height: "100%",
"background-fit": "cover",
"background-repeat": "no-repeat",
shape: "ellipse",
},
},
{
selector: "edge",
style: {
width: 5,
"line-color": "#ccc",
"target-arrow-color": "#ccc",
"target-arrow-shape": "triangle",
"curve-style": "bezier",
},
},
{
selector: ".traversed",
style: {
"border-width": "5px",
"border-style": "solid",
"border-color": "red",
"target-arrow-color": "red",
"line-color": "red",
},
},
{
selector: ".not-traversed",
style: {
"line-opacity": "0.5",
"background-opacity": "0.5",
"background-image-opacity": "0.5",
},
},
],
layout: {
name: "klay",
nodeDimensionsIncludeLabels: true,
fit: true,
klay: {
addUnnecessaryBendpoints: true,
direction: "DOWN",
nodePlacement: "INTERACTIVE",
layoutHierarchy: true,
},
},
});
const activeUser = cy.getElementById(activeUserId).style("shape", "rectangle");
/* Reset graph */
const resetGraph = () => {
cy.elements((element) => {
if (element.hasClass("traversed")) {
element.removeClass("traversed");
}
if (element.hasClass("not-traversed")) {
element.removeClass("not-traversed");
}
});
};
const onNodeTap = (el) => {
resetGraph();
/* Create path on graph if selected isn't the targeted user */
if (el === activeUser) {
return;
}
cy.elements((element) => {
element.addClass("not-traversed");
});
for (const traversed of cy.elements().aStar({
root: el,
goal: activeUser,
}).path) {
traversed.removeClass("not-traversed");
traversed.addClass("traversed");
}
};
cy.on("tap", "node", (tapped) => {
onNodeTap(tapped.target);
});
cy.zoomingEnabled(false);
/* Add context menu */
cy.cxtmenu({
selector: "node",
commands: [
{
content: '<i class="fa fa-external-link fa-2x"></i>',
select: (el) => {
window.open(el.data().profile_url, "_blank").focus();
},
},
{
content: '<span class="fa fa-mouse-pointer fa-2x"></span>',
select: (el) => {
onNodeTap(el);
},
},
{
content: '<i class="fa fa-eraser fa-2x"></i>',
select: (_) => {
resetGraph();
},
},
],
});
return cy;
}
/**
* @typedef FamilyGraphConfig
* @property {number} activeUser Id of the user to fetch the tree from
* @property {number} depthMin Minimum tree depth for godfathers and godchildren
* @property {number} depthMax Maximum tree depth for godfathers and godchildren
**/
/**
* Create a family graph of an user
* @param {FamilyGraphConfig} config
**/
window.loadFamilyGraph = (config) => {
document.addEventListener("alpine:init", () => {
const defaultDepth = 2;
function getInitialDepth(prop) {
const value = Number.parseInt(initialUrlParams.get(prop));
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
return defaultDepth;
}
return value;
}
Alpine.data("graph", () => ({
loading: false,
godfathersDepth: getInitialDepth("godfathersDepth"),
godchildrenDepth: getInitialDepth("godchildrenDepth"),
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
graph: undefined,
graphData: {},
async init() {
const delayedFetch = Alpine.debounce(async () => {
await this.fetchGraphData();
}, 100);
for (const param of ["godfathersDepth", "godchildrenDepth"]) {
this.$watch(param, async (value) => {
if (value < config.depthMin || value > config.depthMax) {
return;
}
updateQueryString(param, value, History.Replace);
await delayedFetch();
});
}
this.$watch("reverse", async (value) => {
updateQueryString("reverse", value, History.Replace);
await this.reverseGraph();
});
this.$watch("graphData", async () => {
this.generateGraph();
if (this.reverse) {
await this.reverseGraph();
}
});
await this.fetchGraphData();
},
screenshot() {
const link = document.createElement("a");
link.href = this.graph.jpg();
link.download = interpolate(
gettext("family_tree.%(extension)s"),
{ extension: "jpg" },
true,
);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
},
reset() {
this.reverse = false;
this.godfathersDepth = defaultDepth;
this.godchildrenDepth = defaultDepth;
},
async reverseGraph() {
this.graph.elements((el) => {
el.position({ x: -el.position().x, y: -el.position().y });
});
this.graph.center(this.graph.elements());
},
async fetchGraphData() {
this.graphData = await getGraphData(
config.activeUser,
this.godfathersDepth,
this.godchildrenDepth,
);
},
generateGraph() {
this.loading = true;
this.graph = createGraph(
$(this.$refs.graph),
this.graphData,
config.activeUser,
);
this.loading = false;
},
}));
});
};

View File

@ -0,0 +1,287 @@
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import cytoscape, {
type ElementDefinition,
type NodeSingular,
type Singular,
} from "cytoscape";
import cxtmenu from "cytoscape-cxtmenu";
import klay, { type KlayLayoutOptions } from "cytoscape-klay";
import { type UserProfileSchema, familyGetFamilyGraph } from "#openapi";
cytoscape.use(klay);
cytoscape.use(cxtmenu);
type GraphData = (
| { data: UserProfileSchema }
| { data: { source: number; target: number } }
)[];
function isMobile() {
return window.innerWidth < 500;
}
async function getGraphData(
userId: number,
godfathersDepth: number,
godchildrenDepth: number,
): Promise<GraphData> {
const data = (
await familyGetFamilyGraph({
path: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
user_id: userId,
},
query: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
godfathers_depth: godfathersDepth,
// biome-ignore lint/style/useNamingConvention: api is snake_case
godchildren_depth: godchildrenDepth,
},
})
).data;
return [
...data.users.map((user) => {
return { data: user };
}),
...data.relationships.map((rel) => {
return {
data: { source: rel.godfather, target: rel.godchild },
};
}),
];
}
function createGraph(container: HTMLDivElement, data: GraphData, activeUserId: number) {
const cy = cytoscape({
boxSelectionEnabled: false,
autounselectify: true,
container,
elements: data as ElementDefinition[],
minZoom: 0.5,
style: [
// the stylesheet for the graph
{
selector: "node",
style: {
label: "data(display_name)",
"background-image": "data(profile_pict)",
width: "100%",
height: "100%",
"background-fit": "cover",
"background-repeat": "no-repeat",
shape: "ellipse",
},
},
{
selector: "edge",
style: {
width: 5,
"line-color": "#ccc",
"target-arrow-color": "#ccc",
"target-arrow-shape": "triangle",
"curve-style": "bezier",
},
},
{
selector: ".traversed",
style: {
"border-width": "5px",
"border-style": "solid",
"border-color": "red",
"target-arrow-color": "red",
"line-color": "red",
},
},
{
selector: ".not-traversed",
style: {
"line-opacity": 0.5,
"background-opacity": 0.5,
"background-image-opacity": 0.5,
},
},
],
layout: {
name: "klay",
nodeDimensionsIncludeLabels: true,
fit: true,
klay: {
addUnnecessaryBendpoints: true,
direction: "DOWN",
nodePlacement: "INTERACTIVE",
layoutHierarchy: true,
},
} as KlayLayoutOptions,
});
const activeUser = cy
.getElementById(activeUserId.toString())
.style("shape", "rectangle");
/* Reset graph */
const resetGraph = () => {
cy.elements().removeClass("traversed not-traversed");
};
const onNodeTap = (el: Singular) => {
resetGraph();
/* Create path on graph if selected isn't the targeted user */
if (el === activeUser) {
return;
}
cy.elements().addClass("not-traversed");
for (const traversed of cy.elements().aStar({
root: el,
goal: activeUser,
}).path) {
traversed.removeClass("not-traversed");
traversed.addClass("traversed");
}
};
cy.on("tap", "node", (tapped) => {
onNodeTap(tapped.target);
});
/* Add context menu */
cy.cxtmenu({
selector: "node",
commands: [
{
content: '<i class="fa fa-external-link fa-2x"></i>',
select: (el) => {
window.open(el.data().profile_url, "_blank").focus();
},
},
{
content: '<span class="fa fa-mouse-pointer fa-2x"></span>',
select: (el) => {
onNodeTap(el);
},
},
{
content: '<i class="fa fa-eraser fa-2x"></i>',
select: (_) => {
resetGraph();
},
},
],
});
return cy;
}
interface FamilyGraphConfig {
/**Id of the user to fetch the tree from*/
activeUser: number;
/**Minimum tree depth for godfathers and godchildren*/
depthMin: number;
/**Maximum tree depth for godfathers and godchildren*/
depthMax: number;
}
document.addEventListener("alpine:init", () => {
const defaultDepth = 2;
Alpine.data("graph", (config: FamilyGraphConfig) => ({
loading: false,
godfathersDepth: 0,
godchildrenDepth: 0,
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
graph: undefined as cytoscape.Core,
graphData: {},
isZoomEnabled: !isMobile(),
getInitialDepth(prop: string) {
const value = Number.parseInt(initialUrlParams.get(prop));
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
return defaultDepth;
}
return value;
},
async init() {
this.godfathersDepth = this.getInitialDepth("godfathersDepth");
this.godchildrenDepth = this.getInitialDepth("godchildrenDepth");
const delayedFetch = Alpine.debounce(async () => {
await this.fetchGraphData();
}, 100);
for (const param of ["godfathersDepth", "godchildrenDepth"]) {
this.$watch(param, async (value: number) => {
if (value < config.depthMin || value > config.depthMax) {
return;
}
updateQueryString(param, value.toString(), History.Replace);
await delayedFetch();
});
}
this.$watch("reverse", async (value: number) => {
updateQueryString("reverse", value.toString(), History.Replace);
await this.reverseGraph();
});
this.$watch("graphData", async () => {
this.generateGraph();
if (this.reverse) {
await this.reverseGraph();
}
});
this.$watch("isZoomEnabled", () => {
this.graph.userZoomingEnabled(this.isZoomEnabled);
});
await this.fetchGraphData();
},
screenshot() {
const link = document.createElement("a");
link.href = this.graph.jpg();
link.download = interpolate(
gettext("family_tree.%(extension)s"),
{ extension: "jpg" },
true,
);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
},
reset() {
this.reverse = false;
this.godfathersDepth = defaultDepth;
this.godchildrenDepth = defaultDepth;
},
async reverseGraph() {
this.graph.elements((el: NodeSingular) => {
el.position({ x: -el.position().x, y: -el.position().y });
});
this.graph.center(this.graph.elements());
},
async fetchGraphData() {
this.graphData = await getGraphData(
config.activeUser,
this.godfathersDepth,
this.godchildrenDepth,
);
},
generateGraph() {
this.loading = true;
this.graph = createGraph(
this.$refs.graph as HTMLDivElement,
this.graphData,
config.activeUser,
);
this.graph.userZoomingEnabled(this.isZoomEnabled);
this.loading = false;
},
}));
});

View File

@ -0,0 +1,38 @@
interface AlertParams {
success?: boolean;
duration?: number;
}
export class AlertMessage {
public open: boolean;
public success: boolean;
public content: string;
private timeoutId?: number;
private readonly defaultDuration: number;
constructor(params?: { defaultDuration: number }) {
this.open = false;
this.content = "";
this.timeoutId = null;
this.defaultDuration = params?.defaultDuration ?? 2000;
}
public display(message: string, params: AlertParams) {
this.clear();
this.open = true;
this.content = message;
this.success = params.success ?? true;
this.timeoutId = setTimeout(() => {
this.open = false;
this.timeoutId = null;
}, params.duration ?? this.defaultDuration);
}
public clear() {
if (this.timeoutId !== null) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
this.open = false;
}
}

View File

@ -16,14 +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;
} }
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;
@ -60,31 +120,10 @@ 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,
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
}
//Reset from style.scss
table {
box-shadow: none;
border-radius: 0px;
-moz-border-radius: 0px;
margin: 0px;
} }
// Reset from style.scss // Reset from style.scss
@ -136,7 +175,6 @@ ics-calendar {
.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 {
opacity: 1; opacity: 1;

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

@ -0,0 +1,89 @@
@import "colors";
@import "devices";
footer.bottom-links {
@media (max-width: $small-devices) {
margin-top: 0.6em;
padding: 1.25em;
background-color: $primary-neutral-dark-color;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25em;
>section {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 0.8em;
}
a {
color: $white-color;
width: auto;
&:hover {
color: $white-color;
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
}
}
.fa-github {
color: $white-color;
}
hr {
width: 100%;
height: 0px;
border: none;
border-top: 0.5px solid $white-color;
}
}
@media (min-width: $small-devices) {
width: 90%;
margin: 2em auto;
font-size: 90%;
text-align: center;
vertical-align: middle;
section:first-of-type {
margin: 0.6em 0;
color: $white-color;
border-radius: 5px;
display: flex;
flex-wrap: wrap;
align-items: center;
background-color: $primary-neutral-dark-color;
box-shadow: $shadow-color 0 0 15px;
a {
color: $white-color;
width: auto;
padding: 0.8em;
flex: 1;
font-weight: bold;
&:hover {
color: $white-color;
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
}
}
}
.fa-github {
color: $githubblack;
}
hr {
border: none;
height: 5px;
}
}
}

View File

@ -713,47 +713,6 @@ textarea {
margin-top: 10px; margin-top: 10px;
} }
/*--------------------------------FOOTER-------------------------------*/
footer {
width: 90%;
margin: 2em auto;
font-size: 90%;
text-align: center;
vertical-align: middle;
div {
margin: 0.6em 0;
color: $white-color;
border-radius: 5px;
display: flex;
flex-wrap: wrap;
align-items: center;
background-color: $primary-neutral-dark-color;
box-shadow: $shadow-color 0 0 15px;
a {
padding: 0.8em;
flex: 1;
font-weight: bold;
color: $white-color !important;
&:hover {
color: $primary-dark-color;
}
}
}
>.version {
margin-top: 3px;
color: rgba(0, 0, 0, 0.3);
}
.fa-github {
color: $githubblack;
}
}
.ui-dialog .ui-dialog-buttonpane { .ui-dialog .ui-dialog-buttonpane {

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

@ -4,6 +4,12 @@
display: block; display: block;
} }
.zoom-control {
margin-right: 10px;
display: flex;
justify-content: right;
}
.graph-toolbar { .graph-toolbar {
margin-top: 10px; margin-top: 10px;
margin-bottom: 10px; margin-bottom: 10px;
@ -34,30 +40,37 @@
.depth-choice { .depth-choice {
white-space: nowrap; white-space: nowrap;
input[type="number"] { input[type="number"] {
-webkit-appearance: textfield; -webkit-appearance: textfield;
-moz-appearance: textfield; -moz-appearance: textfield;
appearance: textfield; appearance: textfield;
&::-webkit-inner-spin-button, &::-webkit-inner-spin-button,
&::-webkit-outer-spin-button { &::-webkit-outer-spin-button {
-webkit-appearance: none; -webkit-appearance: none;
} }
} }
button { button {
background: none; background: none;
&>.fa { &>.fa {
border-radius: 50%; border-radius: 50%;
font-size: 12px; font-size: 12px;
padding: 5px; padding: 5px;
} }
&:enabled>.fa { &:enabled>.fa {
background-color: #354a5f; background-color: #354a5f;
color: white; color: white;
} }
&:enabled:hover>.fa { &:enabled:hover>.fa {
color: white; color: white;
background-color: #35405f; // just a bit darker background-color: #35405f; // just a bit darker
} }
&:disabled>.fa { &:disabled>.fa {
background-color: gray; background-color: gray;
color: white; color: white;
@ -74,6 +87,7 @@
@media screen and (max-width: 500px) { @media screen and (max-width: 500px) {
flex-direction: column; flex-direction: column;
gap: 20px; gap: 20px;
.toolbar-column { .toolbar-column {
min-width: 100%; min-width: 100%;
} }
@ -91,10 +105,12 @@
margin: 0; margin: 0;
} }
} }
#family-tree-link { #family-tree-link {
display: inline-block; display: inline-block;
margin-top: 10px; margin-top: 10px;
text-align: center; text-align: center;
@media (min-width: 450px) { @media (min-width: 450px) {
margin-right: auto; margin-right: auto;
} }

View File

@ -11,6 +11,7 @@
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}"> <link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
<link rel="stylesheet" href="{{ static('core/header.scss') }}"> <link rel="stylesheet" href="{{ static('core/header.scss') }}">
<link rel="stylesheet" href="{{ static('core/navbar.scss') }}"> <link rel="stylesheet" href="{{ static('core/navbar.scss') }}">
<link rel="stylesheet" href="{{ static('core/footer.scss') }}">
<link rel="stylesheet" href="{{ static('core/pagination.scss') }}"> <link rel="stylesheet" href="{{ static('core/pagination.scss') }}">
<link rel="stylesheet" href="{{ static('core/accordion.scss') }}"> <link rel="stylesheet" href="{{ static('core/accordion.scss') }}">
@ -18,6 +19,7 @@
<noscript><link rel="stylesheet" href="{{ static('bundled/fontawesome-index.css') }}"></noscript> <noscript><link rel="stylesheet" href="{{ static('bundled/fontawesome-index.css') }}"></noscript>
<script src="{{ url('javascript-catalog') }}"></script> <script src="{{ url('javascript-catalog') }}"></script>
<script type="module" src={{ static("bundled/core/navbar-index.ts") }}></script>
<script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script> <script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script>
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script> <script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script> <script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
@ -88,58 +90,12 @@
</div> </div>
</div> </div>
<footer>
{% block footer %} {% block footer %}
<div> {% include "core/base/footer.jinja" %}
<a href="{{ url('core:page', 'contacts') }}">{% trans %}Contacts{% endtrans %}</a>
<a href="{{ url('core:page', 'legals') }}">{% trans %}Legal notices{% endtrans %}</a>
<a href="{{ url('core:page', 'copyright_agent') }}">{% trans %}Intellectual property{% endtrans %}</a>
<a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
<a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
</div>
<a rel="nofollow" href="https://github.com/ae-utbm/sith" target="#">
<i class="fa-brands fa-github"></i>
{% trans %}Site created by the IT Department of the AE{% endtrans %}
</a>
{% endblock %} {% endblock %}
<br>
</footer>
{% block script %} {% block script %}
<script> <script>
const menuItems = document.querySelectorAll(".navbar details[name='navbar'].menu");
const isDesktop = () => {
return window.innerWidth >= 500;
}
for (const item of menuItems){
item.addEventListener("mouseover", () => {
if (isDesktop()){
item.setAttribute("open", "");
}
})
item.addEventListener("mouseout", () => {
if (isDesktop()){
item.removeAttribute("open");
}
})
item.addEventListener("click", (event) => {
// Ignore keyboard clicks
if (event.detail === 0){
return;
}
if (isDesktop()){
event.preventDefault();
}
})
}
function showMenu() {
let navbar = document.getElementById("navbar-content");
const current = navbar.getAttribute("mobile-display");
navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden")
}
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
// Looking at the `s` key when not typing in a form // Looking at the `s` key when not typing in a form
if (e.keyCode !== 83 || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) { if (e.keyCode !== 83 || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) {

View File

@ -0,0 +1,16 @@
<footer class="bottom-links">
<section>
<a href="{{ url('core:page', 'contacts') }}">{% trans %}Contacts{% endtrans %}</a>
<a href="{{ url('core:page', 'legals') }}">{% trans %}Legal notices{% endtrans %}</a>
<a href="{{ url('core:page', 'copyright_agent') }}">{% trans %}Intellectual property{% endtrans %}</a>
<a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
<a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
</section>
<hr>
<section>
<a rel="nofollow" href="https://github.com/ae-utbm/sith" target="#">
<i class="fa-brands fa-github"></i>
{% trans %}Site created by the IT Department of the AE{% endtrans %}
</a>
</section>
</footer>

View File

@ -26,9 +26,11 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
<form method="post" action="{{ url('core:login') }}"> <form method="post" action="{{ url('core:login') }}" id="login-form">
{% if form.errors %} {% if form.errors %}
<p class="alert alert-red">{% trans %}Your username and password didn't match. Please try again.{% endtrans %}</p> <p class="alert alert-red">
{% trans %}Your credentials didn't match. Please try again.{% endtrans %}
</p>
{% endif %} {% endif %}
{% csrf_token %} {% csrf_token %}

View File

@ -7,7 +7,7 @@
{%- endblock -%} {%- endblock -%}
{% block additional_js %} {% block additional_js %}
<script type="module" src="{{ static("bundled/user/family-graph-index.js") }}"></script> <script type="module" src="{{ static("bundled/user/family-graph-index.ts") }}"></script>
{% endblock %} {% endblock %}
{% block title %} {% block title %}
@ -15,7 +15,14 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div x-data="graph" :aria-busy="loading"> <div
x-data="graph({
activeUser: {{ object.id }},
depthMin: {{ depth_min }},
depthMax: {{ depth_max }},
})"
:aria-busy="loading"
>
<div class="graph-toolbar"> <div class="graph-toolbar">
<div class="toolbar-column"> <div class="toolbar-column">
<div class="toolbar-input"> <div class="toolbar-input">
@ -86,17 +93,36 @@
</button> </button>
</div> </div>
</div> </div>
<div class="zoom-control" x-ref="zoomControl">
<button
@click="graph.zoom(graph.zoom() + 1)"
:disabled="!isZoomEnabled"
>
<i class="fa-solid fa-magnifying-glass-plus"></i>
</button>
<button
@click="graph.zoom(graph.zoom() - 1)"
:disabled="!isZoomEnabled"
>
<i class="fa-solid fa-magnifying-glass-minus"></i>
</button>
<button
x-show="isZoomEnabled"
@click="isZoomEnabled = false"
>
<i class="fa-solid fa-unlock"></i>
</button>
<button
x-show="!isZoomEnabled"
@click="isZoomEnabled = true"
>
<i class="fa-solid fa-lock"></i>
</button>
</div>
<div x-ref="graph" class="graph"></div> <div x-ref="graph" class="graph"></div>
</div> </div>
<script>
window.addEventListener("DOMContentLoaded", () => {
loadFamilyGraph({
activeUser: {{ object.id }},
depthMin: {{ depth_min }},
depthMax: {{ depth_max }},
});
});
</script>
{% endblock %} {% endblock %}

View File

@ -38,6 +38,7 @@ from core.markdown import markdown
from core.models import AnonymousUser, Group, Page, User from core.models import AnonymousUser, Group, Page, User
from core.utils import get_semester_code, get_start_of_semester from core.utils import get_semester_code, get_start_of_semester
from core.views import AllowFragment from core.views import AllowFragment
from counter.models import Customer
from sith import settings from sith import settings
@ -151,24 +152,44 @@ class TestUserLogin:
def user(self) -> User: def user(self) -> User:
return baker.make(User, password=make_password("plop")) return baker.make(User, password=make_password("plop"))
def test_login_fail(self, client, user): @pytest.mark.parametrize(
"identifier_getter",
[
lambda user: user.username,
lambda user: user.email,
lambda user: Customer.get_or_create(user)[0].account_id,
],
)
def test_login_fail(self, client, user, identifier_getter):
"""Should not login a user correctly.""" """Should not login a user correctly."""
identifier = identifier_getter(user)
response = client.post( response = client.post(
reverse("core:login"), reverse("core:login"),
{"username": user.username, "password": "wrong-password"}, {"username": identifier, "password": "wrong-password"},
) )
assert response.status_code == 200 assert response.status_code == 200
assert (
'<p class="alert alert-red">Votre nom d\'utilisateur '
"et votre mot de passe ne correspondent pas. Merci de réessayer.</p>"
) in response.text
assert response.wsgi_request.user.is_anonymous assert response.wsgi_request.user.is_anonymous
soup = BeautifulSoup(response.text, "lxml")
form = soup.find(id="login-form")
assert (
form.find(class_="alert alert-red").get_text(strip=True)
== "Vos identifiants ne correspondent pas. Veuillez réessayer."
)
assert form.find("input", attrs={"name": "username"}).get("value") == identifier
def test_login_success(self, client, user): @pytest.mark.parametrize(
"identifier_getter",
[
lambda user: user.username,
lambda user: user.email,
lambda user: Customer.get_or_create(user)[0].account_id,
],
)
def test_login_success(self, client, user, identifier_getter):
"""Should login a user correctly.""" """Should login a user correctly."""
response = client.post( response = client.post(
reverse("core:login"), reverse("core:login"),
{"username": user.username, "password": "plop"}, {"username": identifier_getter(user), "password": "plop"},
) )
assertRedirects(response, reverse("core:index")) assertRedirects(response, reverse("core:index"))
assert response.wsgi_request.user == user assert response.wsgi_request.user == user
@ -361,17 +382,9 @@ class TestUserIsInGroup(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.root_group = Group.objects.get(name="Root") cls.public_group = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)
cls.public_group = Group.objects.get(name="Public")
cls.public_user = baker.make(User) cls.public_user = baker.make(User)
cls.subscribers = Group.objects.get(name="Subscribers")
cls.old_subscribers = Group.objects.get(name="Old subscribers")
cls.accounting_admin = Group.objects.get(name="Accounting admin")
cls.com_admin = Group.objects.get(name="Communication admin")
cls.counter_admin = Group.objects.get(name="Counter admin")
cls.sas_admin = Group.objects.get(name="SAS admin")
cls.club = baker.make(Club) cls.club = baker.make(Club)
cls.main_club = Club.objects.get(id=1)
def assert_in_public_group(self, user): def assert_in_public_group(self, user):
assert user.is_in_group(pk=self.public_group.id) assert user.is_in_group(pk=self.public_group.id)
@ -379,15 +392,7 @@ class TestUserIsInGroup(TestCase):
def assert_only_in_public_group(self, user): def assert_only_in_public_group(self, user):
self.assert_in_public_group(user) self.assert_in_public_group(user)
for group in ( for group in Group.objects.exclude(id=self.public_group.id):
self.root_group,
self.accounting_admin,
self.sas_admin,
self.subscribers,
self.old_subscribers,
self.club.members_group,
self.club.board_group,
):
assert not user.is_in_group(pk=group.pk) assert not user.is_in_group(pk=group.pk)
assert not user.is_in_group(name=group.name) assert not user.is_in_group(name=group.name)

View File

@ -39,9 +39,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 from django.utils.translation import gettext
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
@ -115,7 +114,7 @@ class SelectUser(TextInput):
def validate_future_timestamp(value: date | datetime): def validate_future_timestamp(value: date | datetime):
if value <= now(): if value <= now():
raise ValueError(_("Ensure this timestamp is set in the future")) raise ValidationError(_("Ensure this timestamp is set in the future"))
class FutureDateTimeField(forms.DateTimeField): class FutureDateTimeField(forms.DateTimeField):
@ -123,8 +122,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
@ -132,29 +131,31 @@ class FutureDateTimeField(forms.DateTimeField):
class LoginForm(AuthenticationForm): class LoginForm(AuthenticationForm):
def __init__(self, *arg, **kwargs): def __init__(self, *arg, **kwargs):
if "data" in kwargs:
from counter.models import Customer
data = kwargs["data"].copy()
account_code = re.compile(r"^[0-9]+[A-Za-z]$")
try:
if account_code.match(data["username"]):
user = (
Customer.objects.filter(account_id__iexact=data["username"])
.first()
.user
)
elif "@" in data["username"]:
user = User.objects.filter(email__iexact=data["username"]).first()
else:
user = User.objects.filter(username=data["username"]).first()
data["username"] = user.username
except: # noqa E722 I don't know what error is supposed to be raised here
pass
kwargs["data"] = data
super().__init__(*arg, **kwargs) super().__init__(*arg, **kwargs)
self.fields["username"].label = _("Username, email, or account number") self.fields["username"].label = _("Username, email, or account number")
def clean_username(self):
identifier: str = self.cleaned_data["username"]
account_code = re.compile(r"^[0-9]+[A-Za-z]$")
if account_code.match(identifier):
qs_filter = "customer__account_id__iexact"
elif identifier.count("@") == 1:
qs_filter = "email"
else:
qs_filter = None
if qs_filter:
# if the user gave an email or an account code instead of
# a username, retrieve and return the corresponding username.
# If there is no username, return an empty string, so that
# Django will properly handle the error when failing the authentication
identifier = (
User.objects.filter(**{qs_filter: identifier})
.values_list("username", flat=True)
.first()
or ""
)
return identifier
class RegisteringForm(UserCreationForm): class RegisteringForm(UserCreationForm):
error_css_class = "error" error_css_class = "error"

View File

@ -109,7 +109,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

@ -41,6 +41,7 @@ class ProductAdmin(SearchModelAdmin):
"profit", "profit",
"archived", "archived",
) )
list_select_related = ("product_type",)
search_fields = ("name", "code") search_fields = ("name", "code")
@ -81,20 +82,13 @@ class AccountDumpAdmin(admin.ModelAdmin):
"customer", "customer",
"warning_mail_sent_at", "warning_mail_sent_at",
"warning_mail_error", "warning_mail_error",
"dump_operation", "dump_operation__date",
"amount", "amount",
) )
list_select_related = ("customer", "customer__user", "dump_operation")
autocomplete_fields = ("customer", "dump_operation") autocomplete_fields = ("customer", "dump_operation")
list_filter = ("warning_mail_error",) list_filter = ("warning_mail_error",)
def get_queryset(self, request):
# the `amount` property requires to know the customer and the dump_operation
return (
super()
.get_queryset(request)
.select_related("customer", "customer__user", "dump_operation")
)
@admin.register(Counter) @admin.register(Counter)
class CounterAdmin(admin.ModelAdmin): class CounterAdmin(admin.ModelAdmin):
@ -113,11 +107,14 @@ class RefillingAdmin(SearchModelAdmin):
"customer__account_id", "customer__account_id",
"counter__name", "counter__name",
) )
list_filter = (("counter", admin.RelatedOnlyFieldListFilter),)
date_hierarchy = "date"
@admin.register(Selling) @admin.register(Selling)
class SellingAdmin(SearchModelAdmin): class SellingAdmin(SearchModelAdmin):
list_display = ("customer", "label", "unit_price", "quantity", "counter", "date") list_display = ("customer", "label", "unit_price", "quantity", "counter", "date")
list_select_related = ("customer", "customer__user", "counter")
search_fields = ( search_fields = (
"customer__user__username", "customer__user__username",
"customer__user__first_name", "customer__user__first_name",
@ -126,6 +123,8 @@ class SellingAdmin(SearchModelAdmin):
"counter__name", "counter__name",
) )
autocomplete_fields = ("customer", "seller") autocomplete_fields = ("customer", "seller")
list_filter = (("counter", admin.RelatedOnlyFieldListFilter),)
date_hierarchy = "date"
@admin.register(Permanency) @admin.register(Permanency)

View File

@ -1,3 +1,4 @@
import { AlertMessage } from "#core:utils/alert-message";
import { BasketItem } from "#counter:counter/basket"; import { BasketItem } from "#counter:counter/basket";
import type { CounterConfig, ErrorMessage } from "#counter:counter/types"; import type { CounterConfig, ErrorMessage } from "#counter:counter/types";
import type { CounterProductSelect } from "./components/counter-product-select-index.ts"; import type { CounterProductSelect } from "./components/counter-product-select-index.ts";
@ -5,14 +6,9 @@ import type { CounterProductSelect } from "./components/counter-product-select-i
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("counter", (config: CounterConfig) => ({ Alpine.data("counter", (config: CounterConfig) => ({
basket: {} as Record<string, BasketItem>, basket: {} as Record<string, BasketItem>,
errors: [],
customerBalance: config.customerBalance, customerBalance: config.customerBalance,
codeField: null as CounterProductSelect | null, codeField: null as CounterProductSelect | null,
alertMessage: { alertMessage: new AlertMessage({ defaultDuration: 2000 }),
content: "",
show: false,
timeout: null,
},
init() { init() {
// Fill the basket with the initial data // Fill the basket with the initial data
@ -77,22 +73,10 @@ document.addEventListener("alpine:init", () => {
return total; return total;
}, },
showAlertMessage(message: string) {
if (this.alertMessage.timeout !== null) {
clearTimeout(this.alertMessage.timeout);
}
this.alertMessage.content = message;
this.alertMessage.show = true;
this.alertMessage.timeout = setTimeout(() => {
this.alertMessage.show = false;
this.alertMessage.timeout = null;
}, 2000);
},
addToBasketWithMessage(id: string, quantity: number) { addToBasketWithMessage(id: string, quantity: number) {
const message = this.addToBasket(id, quantity); const message = this.addToBasket(id, quantity);
if (message.length > 0) { if (message.length > 0) {
this.showAlertMessage(message); this.alertMessage.display(message, { success: false });
} }
}, },
@ -109,7 +93,9 @@ document.addEventListener("alpine:init", () => {
finish() { finish() {
if (this.getBasketSize() === 0) { if (this.getBasketSize() === 0) {
this.showAlertMessage(gettext("You can't send an empty basket.")); this.alertMessage.display(gettext("You can't send an empty basket."), {
success: false,
});
return; return;
} }
this.$refs.basketForm.submit(); this.$refs.basketForm.submit();

View File

@ -1,15 +1,11 @@
import { AlertMessage } from "#core:utils/alert-message";
import Alpine from "alpinejs"; import Alpine from "alpinejs";
import { producttypeReorder } from "#openapi"; import { producttypeReorder } from "#openapi";
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("productTypesList", () => ({ Alpine.data("productTypesList", () => ({
loading: false, loading: false,
alertMessage: { alertMessage: new AlertMessage({ defaultDuration: 2000 }),
open: false,
success: true,
content: "",
timeout: null,
},
async reorder(itemId: number, newPosition: number) { async reorder(itemId: number, newPosition: number) {
// The sort plugin of Alpine doesn't manage dynamic lists with x-sort // The sort plugin of Alpine doesn't manage dynamic lists with x-sort
@ -41,23 +37,14 @@ document.addEventListener("alpine:init", () => {
}, },
openAlertMessage(response: Response) { openAlertMessage(response: Response) {
if (response.ok) { const success = response.ok;
this.alertMessage.success = true; const content = response.ok
this.alertMessage.content = gettext("Products types reordered!"); ? gettext("Products types reordered!")
} else { : interpolate(
this.alertMessage.success = false;
this.alertMessage.content = interpolate(
gettext("Product type reorganisation failed with status code : %d"), gettext("Product type reorganisation failed with status code : %d"),
[response.status], [response.status],
); );
} this.alertMessage.display(content, { success: success });
this.alertMessage.open = true;
if (this.alertMessage.timeout !== null) {
clearTimeout(this.alertMessage.timeout);
}
this.alertMessage.timeout = setTimeout(() => {
this.alertMessage.open = false;
}, 2000);
this.loading = false; this.loading = false;
}, },
})); }));

View File

@ -1,4 +1,4 @@
type ErrorMessage = string; declare type ErrorMessage = string;
export interface InitialFormData { export interface InitialFormData {
/* Used to refill the form when the backend raises an error */ /* Used to refill the form when the backend raises an error */

View File

@ -17,6 +17,7 @@ from datetime import timedelta
from decimal import Decimal from decimal import Decimal
import pytest import pytest
from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission, make_password from django.contrib.auth.models import Permission, make_password
from django.core.cache import cache from django.core.cache import cache
@ -823,3 +824,53 @@ class TestClubCounterClickAccess(TestCase):
self.client.force_login(self.user) self.client.force_login(self.user)
res = self.client.get(self.click_url) res = self.client.get(self.click_url)
assert res.status_code == 200 assert res.status_code == 200
@pytest.mark.django_db
class TestCounterLogout:
def test_logout_simple(self, client: Client):
perm_counter = baker.make(Counter, type="BAR")
permanence = baker.make(
Permanency,
counter=perm_counter,
start=now() - timedelta(hours=1),
activity=now() - timedelta(minutes=10),
)
with freeze_time():
res = client.post(
reverse("counter:logout", kwargs={"counter_id": permanence.counter_id}),
data={"user_id": permanence.user_id},
)
assertRedirects(
res,
reverse(
"counter:details", kwargs={"counter_id": permanence.counter_id}
),
)
permanence.refresh_from_db()
assert permanence.end == now()
def test_logout_doesnt_change_old_permanences(self, client: Client):
perm_counter = baker.make(Counter, type="BAR")
permanence = baker.make(
Permanency,
counter=perm_counter,
start=now() - timedelta(hours=1),
activity=now() - timedelta(minutes=10),
)
old_end = now() - relativedelta(year=10)
old_permanence = baker.make(
Permanency,
counter=perm_counter,
end=old_end,
activity=now() - relativedelta(year=8),
)
with freeze_time():
client.post(
reverse("counter:logout", kwargs={"counter_id": permanence.counter_id}),
data={"user_id": permanence.user_id},
)
permanence.refresh_from_db()
assert permanence.end == now()
old_permanence.refresh_from_db()
assert old_permanence.end == old_end

View File

@ -13,10 +13,10 @@
# #
# #
from django.db.models import F
from django.http import HttpRequest, HttpResponseRedirect from django.http import HttpRequest, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.utils import timezone from django.utils import timezone
from django.utils.timezone import now
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from core.views.forms import LoginForm from core.views.forms import LoginForm
@ -47,7 +47,7 @@ def counter_login(request: HttpRequest, counter_id: int) -> HttpResponseRedirect
@require_POST @require_POST
def counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect: def counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
"""End the permanency of a user in this counter.""" """End the permanency of a user in this counter."""
Permanency.objects.filter(counter=counter_id, user=request.POST["user_id"]).update( Permanency.objects.filter(
end=F("activity") counter=counter_id, user=request.POST["user_id"], end=None
) ).update(end=now())
return redirect("counter:details", counter_id=counter_id) return redirect("counter:details", counter_id=counter_id)

View File

@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-06-16 14:54+0200\n" "POT-Creation-Date: 2025-06-30 15:15+0200\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"
@ -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"
@ -515,6 +515,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 : "
@ -755,7 +767,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"
@ -901,7 +913,7 @@ msgid "News admin"
msgstr "Administration des nouvelles" msgstr "Administration des nouvelles"
#: com/templates/com/news_admin_list.jinja com/templates/com/news_detail.jinja #: com/templates/com/news_admin_list.jinja com/templates/com/news_detail.jinja
#: com/templates/com/news_list.jinja com/views.py #: com/views.py
msgid "News" msgid "News"
msgstr "Nouvelles" msgstr "Nouvelles"
@ -1043,6 +1055,11 @@ msgstr "Guide des UVs"
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"
@ -1708,27 +1725,27 @@ msgstr "500, Erreur Serveur"
msgid "Welcome!" msgid "Welcome!"
msgstr "Bienvenue !" msgstr "Bienvenue !"
#: core/templates/core/base.jinja core/templates/core/base/navbar.jinja #: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja
msgid "Contacts" msgid "Contacts"
msgstr "Contacts" msgstr "Contacts"
#: core/templates/core/base.jinja #: core/templates/core/base/footer.jinja
msgid "Legal notices" msgid "Legal notices"
msgstr "Mentions légales" msgstr "Mentions légales"
#: core/templates/core/base.jinja #: core/templates/core/base/footer.jinja
msgid "Intellectual property" msgid "Intellectual property"
msgstr "Propriété intellectuelle" msgstr "Propriété intellectuelle"
#: core/templates/core/base.jinja #: core/templates/core/base/footer.jinja
msgid "Help & Documentation" msgid "Help & Documentation"
msgstr "Aide & Documentation" msgstr "Aide & Documentation"
#: core/templates/core/base.jinja #: core/templates/core/base/footer.jinja
msgid "R&D" msgid "R&D"
msgstr "R&D" msgstr "R&D"
#: core/templates/core/base.jinja #: core/templates/core/base/footer.jinja
msgid "Site created by the IT Department of the AE" msgid "Site created by the IT Department of the AE"
msgstr "Site réalisé par le Pôle Informatique de l'AE" msgstr "Site réalisé par le Pôle Informatique de l'AE"
@ -1868,6 +1885,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"
@ -2015,10 +2033,8 @@ msgid "Please login or create an account to see this page."
msgstr "Merci de vous identifier ou de créer un compte pour voir cette page." msgstr "Merci de vous identifier ou de créer un compte pour voir cette page."
#: core/templates/core/login.jinja #: core/templates/core/login.jinja
msgid "Your username and password didn't match. Please try again." msgid "Your credentials didn't match. Please try again."
msgstr "" msgstr "Vos identifiants ne correspondent pas. Veuillez réessayer."
"Votre nom d'utilisateur et votre mot de passe ne correspondent pas. Merci de "
"réessayer."
#: core/templates/core/login.jinja #: core/templates/core/login.jinja
msgid "Lost password?" msgid "Lost password?"
@ -2994,7 +3010,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"
@ -4574,6 +4590,78 @@ msgstr "Autocomplétion réussite"
msgid "An error occurred: " msgid "An error occurred: "
msgstr "Une erreur est survenue : " msgstr "Une erreur est survenue : "
#: 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 created successfully"
msgstr "%(name)s a été créé avec succès"
#: 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é"
@ -5103,8 +5191,9 @@ msgid "There are %s pictures to be moderated in the SAS"
msgstr "Il y a %s photos à modérer dans le SAS" msgstr "Il y a %s photos à modérer dans le SAS"
#: sith/settings.py #: sith/settings.py
msgid "You've been identified on some pictures" #, python-format
msgstr "Vous avez été identifié sur des photos" msgid "You've been identified in album %s"
msgstr "Vous avez été identifié dans l'album %s"
#: sith/settings.py #: sith/settings.py
#, python-format #, python-format

View File

@ -7,7 +7,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-05-18 12:17+0200\n" "POT-Creation-Date: 2025-06-30 22:48+0200\n"
"PO-Revision-Date: 2024-09-17 11:54+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n"
"Last-Translator: Sli <antoine@bartuccio.fr>\n" "Last-Translator: Sli <antoine@bartuccio.fr>\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -193,7 +193,7 @@ msgstr "Montrer moins"
msgid "Show more" msgid "Show more"
msgstr "Montrer plus" msgstr "Montrer plus"
#: core/static/bundled/user/family-graph-index.js #: core/static/bundled/user/family-graph-index.ts
msgid "family_tree.%(extension)s" msgid "family_tree.%(extension)s"
msgstr "arbre_genealogique.%(extension)s" msgstr "arbre_genealogique.%(extension)s"
@ -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"

194
package-lock.json generated
View File

@ -9,14 +9,18 @@
"version": "3", "version": "3",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@alpinejs/morph": "^3.14.9",
"@alpinejs/sort": "^3.14.7", "@alpinejs/sort": "^3.14.7",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0", "@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.6.13", "@floating-ui/dom": "^1.6.13",
"@fortawesome/fontawesome-free": "^6.6.0", "@fortawesome/fontawesome-free": "^6.6.0",
"@fullcalendar/core": "^6.1.15", "@fullcalendar/core": "^6.1.17",
"@fullcalendar/daygrid": "^6.1.15", "@fullcalendar/daygrid": "^6.1.17",
"@fullcalendar/icalendar": "^6.1.15", "@fullcalendar/icalendar": "^6.1.17",
"@fullcalendar/list": "^6.1.15", "@fullcalendar/interaction": "^6.1.17",
"@fullcalendar/list": "^6.1.17",
"@fullcalendar/resource": "^6.1.17",
"@fullcalendar/resource-timeline": "^6.1.17",
"@sentry/browser": "^9.29.0", "@sentry/browser": "^9.29.0",
"@zip.js/zip.js": "^2.7.52", "@zip.js/zip.js": "^2.7.52",
"3d-force-graph": "^1.73.4", "3d-force-graph": "^1.73.4",
@ -29,6 +33,7 @@
"d3-force-3d": "^3.0.5", "d3-force-3d": "^3.0.5",
"easymde": "^2.19.0", "easymde": "^2.19.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"htmx-ext-alpine-morph": "^2.0.1",
"htmx.org": "^2.0.3", "htmx.org": "^2.0.3",
"jquery": "^3.7.1", "jquery": "^3.7.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
@ -45,12 +50,21 @@
"@hey-api/openapi-ts": "^0.73.0", "@hey-api/openapi-ts": "^0.73.0",
"@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.10", "@types/alpinejs": "^3.13.10",
"@types/cytoscape-cxtmenu": "^3.4.4",
"@types/cytoscape-klay": "^3.1.4",
"@types/jquery": "^3.5.31", "@types/jquery": "^3.5.31",
"typescript": "^5.8.3",
"vite": "^6.2.5", "vite": "^6.2.5",
"vite-bundle-visualizer": "^1.2.1", "vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.0.2" "vite-plugin-static-copy": "^3.0.2"
} }
}, },
"node_modules/@alpinejs/morph": {
"version": "3.14.9",
"resolved": "https://registry.npmjs.org/@alpinejs/morph/-/morph-3.14.9.tgz",
"integrity": "sha512-i1mrH5Gza/egszxnCVwQWypRhsKGq28RFWHWuW7aI0+rWo1pvFw+aPhJLImbpt7hx44DtDOr5m4l9Ah+JPFmFw==",
"license": "MIT"
},
"node_modules/@alpinejs/sort": { "node_modules/@alpinejs/sort": {
"version": "3.14.9", "version": "3.14.9",
"resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.14.9.tgz", "resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.14.9.tgz",
@ -2221,6 +2235,15 @@
"ical.js": "^1.4.0" "ical.js": "^1.4.0"
} }
}, },
"node_modules/@fullcalendar/interaction": {
"version": "6.1.17",
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.17.tgz",
"integrity": "sha512-AudvQvgmJP2FU89wpSulUUjeWv24SuyCx8FzH2WIPVaYg+vDGGYarI7K6PcM3TH7B/CyaBjm5Rqw9lXgnwt5YA==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.17"
}
},
"node_modules/@fullcalendar/list": { "node_modules/@fullcalendar/list": {
"version": "6.1.17", "version": "6.1.17",
"resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.17.tgz", "resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.17.tgz",
@ -2230,6 +2253,67 @@
"@fullcalendar/core": "~6.1.17" "@fullcalendar/core": "~6.1.17"
} }
}, },
"node_modules/@fullcalendar/premium-common": {
"version": "6.1.17",
"resolved": "https://registry.npmjs.org/@fullcalendar/premium-common/-/premium-common-6.1.17.tgz",
"integrity": "sha512-zoN7fMwGMcP6Xu+2YudRAGfdwD2J+V+A/xAieXgYDSZT+5ekCsjZiwb2rmvthjt+HVnuZcqs6sGp7rnJ8Ie/mA==",
"license": "SEE LICENSE IN LICENSE.md",
"peerDependencies": {
"@fullcalendar/core": "~6.1.17"
}
},
"node_modules/@fullcalendar/resource": {
"version": "6.1.17",
"resolved": "https://registry.npmjs.org/@fullcalendar/resource/-/resource-6.1.17.tgz",
"integrity": "sha512-hWnbOWlroIN5Wt4NJmHAJh/F7ge2cV6S0PdGSmLFoZJZJA0hJX9GeYRzyz4MlUoj7f4dGzBlesy2RdC+t5FEMw==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@fullcalendar/premium-common": "~6.1.17"
},
"peerDependencies": {
"@fullcalendar/core": "~6.1.17"
}
},
"node_modules/@fullcalendar/resource-timeline": {
"version": "6.1.17",
"resolved": "https://registry.npmjs.org/@fullcalendar/resource-timeline/-/resource-timeline-6.1.17.tgz",
"integrity": "sha512-QMrtc1mLs4c6DtlBNmWICef8Lr4CmzE47uWS/rcJBd9K2kBzvusTp7AQQ1qn3RX5UnjNHqT8pkKO/wE4yspJQw==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@fullcalendar/premium-common": "~6.1.17",
"@fullcalendar/scrollgrid": "~6.1.17",
"@fullcalendar/timeline": "~6.1.17"
},
"peerDependencies": {
"@fullcalendar/core": "~6.1.17",
"@fullcalendar/resource": "~6.1.17"
}
},
"node_modules/@fullcalendar/scrollgrid": {
"version": "6.1.17",
"resolved": "https://registry.npmjs.org/@fullcalendar/scrollgrid/-/scrollgrid-6.1.17.tgz",
"integrity": "sha512-lzphEKwxWMS4xQVEuimzZjKFLijlSn49ExvzkYZls0VLDwOa3BYHcRlDJBjQ0LP6kauz9aatg3MfRIde/LAazA==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@fullcalendar/premium-common": "~6.1.17"
},
"peerDependencies": {
"@fullcalendar/core": "~6.1.17"
}
},
"node_modules/@fullcalendar/timeline": {
"version": "6.1.17",
"resolved": "https://registry.npmjs.org/@fullcalendar/timeline/-/timeline-6.1.17.tgz",
"integrity": "sha512-UhL2OOph/S0cEKs3lzbXjS2gTxmQwaNug2XFjdljvO/ERj10v7OBXj/zvJrPyhjvWR/CSgjNgBaUpngkCu4JtQ==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"@fullcalendar/premium-common": "~6.1.17",
"@fullcalendar/scrollgrid": "~6.1.17"
},
"peerDependencies": {
"@fullcalendar/core": "~6.1.17"
}
},
"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",
@ -2723,75 +2807,75 @@
] ]
}, },
"node_modules/@sentry-internal/browser-utils": { "node_modules/@sentry-internal/browser-utils": {
"version": "9.29.0", "version": "9.33.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.29.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.33.0.tgz",
"integrity": "sha512-Wp6UJCDVV2KVK+TG8GwdLZyDy4GtUYDmVhGMpHKPS3G/Qgpf36cY/XHwChwaHZ5P9Bk1sjS9Ok698J59S8L2nw==", "integrity": "sha512-DT9J0jIamavygIvW6rapgFb4L+7VoATPfEaV0UnXfGNXpSq18x7+vj1CyGMc//GBqqgb9SCHxJHOSkfuDYX7ZA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry/core": "9.29.0" "@sentry/core": "9.33.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry-internal/feedback": { "node_modules/@sentry-internal/feedback": {
"version": "9.29.0", "version": "9.33.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.29.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.33.0.tgz",
"integrity": "sha512-ADvetGrtr+RfYcQKrQxah4fHs/xDJ/VjbStVMSuaNllzwWPYNkWIGFE6YjQ7wZszj0DQIu5/H+B6lZKsFYk4xw==", "integrity": "sha512-NQ3Q3d1xvtagI2cYZnI6C1i6hmMkUxIXUMjfO5JFTYpWGNIkzhIaoaY0HFqbiZ94FWwWdfodlQlj6r8Y+M0bnw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry/core": "9.29.0" "@sentry/core": "9.33.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry-internal/replay": { "node_modules/@sentry-internal/replay": {
"version": "9.29.0", "version": "9.33.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.29.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.33.0.tgz",
"integrity": "sha512-we/1JPRje8sNowQCyogOV1OYWuDOP/3XmDi48XoFG2HB0XMl2HfL5LI8AvgAvC/5nrqVAAo4ktbjoVLm1fb7rg==", "integrity": "sha512-xDFrN19hDkP6+yS4ARYBruI0RinGYD8FPm7JC0BaIMP5yNWAJ80LTT0Jq9Dh1hQfDwUX34dpHy/9Aa7qv+2bRQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry-internal/browser-utils": "9.29.0", "@sentry-internal/browser-utils": "9.33.0",
"@sentry/core": "9.29.0" "@sentry/core": "9.33.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry-internal/replay-canvas": { "node_modules/@sentry-internal/replay-canvas": {
"version": "9.29.0", "version": "9.33.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.29.0.tgz", "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.33.0.tgz",
"integrity": "sha512-TrQYhSAVPhyenvu0fNkon7BznFibu1mzS5bCudxhgOWajZluUVrXcbp8Q3WZ3R+AogrcgA3Vy6aumP/+fMKdwg==", "integrity": "sha512-lFO5DYJ32K/mui5Ck7PbqcD7wzRxTyRKiy49gCGAp7x/mhLg5utf5vWPtegiUoCiiMB22rj+n2z0geZwiGKH4A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry-internal/replay": "9.29.0", "@sentry-internal/replay": "9.33.0",
"@sentry/core": "9.29.0" "@sentry/core": "9.33.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry/browser": { "node_modules/@sentry/browser": {
"version": "9.29.0", "version": "9.33.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.29.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.33.0.tgz",
"integrity": "sha512-+GFX/yb+rh6V1fSgTYM6ttAgledl2aUR3T3Rg86HNuegbdX8ym6lOtUOIZ0j9jPK015HR47KIPyIZVZZJ7Rj9g==", "integrity": "sha512-emlZlpE62lcpxMEzvrQzecnh0WeS36XLQlFLEUhGaYVOw7TBl5JPIoSB4mxPrzIn4GpW++3JrtKRpDAHQn/c4Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sentry-internal/browser-utils": "9.29.0", "@sentry-internal/browser-utils": "9.33.0",
"@sentry-internal/feedback": "9.29.0", "@sentry-internal/feedback": "9.33.0",
"@sentry-internal/replay": "9.29.0", "@sentry-internal/replay": "9.33.0",
"@sentry-internal/replay-canvas": "9.29.0", "@sentry-internal/replay-canvas": "9.33.0",
"@sentry/core": "9.29.0" "@sentry/core": "9.33.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@sentry/core": { "node_modules/@sentry/core": {
"version": "9.29.0", "version": "9.33.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.29.0.tgz", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.33.0.tgz",
"integrity": "sha512-wDyNe45PM+RCGtUn1tK7LzJ08ksv8i8KRUHrst7lsinEfRm83YH+wbWrPmwkVNEngUZvYkHwGLbNXM7xgFUuDQ==", "integrity": "sha512-0mtJAU+x10+q5aV/txyeuPjJ0TmObcD701R0tY0s71yJJOltqqMrmgNpqyuMI/VOASuzTZesiMYdbG6xb3zeSw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@ -2819,6 +2903,33 @@
"@types/tern": "*" "@types/tern": "*"
} }
}, },
"node_modules/@types/cytoscape": {
"version": "3.21.9",
"resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.21.9.tgz",
"integrity": "sha512-JyrG4tllI6jvuISPjHK9j2Xv/LTbnLekLke5otGStjFluIyA9JjgnvgZrSBsp8cEDpiTjwgZUZwpPv8TSBcoLw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/cytoscape-cxtmenu": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/@types/cytoscape-cxtmenu/-/cytoscape-cxtmenu-3.4.4.tgz",
"integrity": "sha512-cuv+IdbKekswDRBIrHn97IYOzWS2/UjVr0kDIHCOYvqWy3iZkuGGM4qmHNPQ+63Dn7JgtmD0l3MKW1moyhoaKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/cytoscape": "*"
}
},
"node_modules/@types/cytoscape-klay": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@types/cytoscape-klay/-/cytoscape-klay-3.1.4.tgz",
"integrity": "sha512-H+tIadpcVjmDGWKFUfibwzIpH/kddfwAFsuhPparjiC+bWBm+MeNqIwwY+19ofkJZWcqWqZL6Jp8lkp+sP8Aig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/cytoscape": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -4119,6 +4230,14 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/htmx-ext-alpine-morph": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/htmx-ext-alpine-morph/-/htmx-ext-alpine-morph-2.0.1.tgz",
"integrity": "sha512-teGpcVatx5IjDUYQs959x9FcePM1TIksjfW5tSe1KVQVEVSmbGxEoemneC7XV6RYpX+27i/xn1fPjduwvHDrAw==",
"dependencies": {
"htmx.org": "^2.0.2"
}
},
"node_modules/htmx.org": { "node_modules/htmx.org": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz", "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz",
@ -5558,7 +5677,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -5774,9 +5892,9 @@
} }
}, },
"node_modules/vite-plugin-static-copy": { "node_modules/vite-plugin-static-copy": {
"version": "3.0.2", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.0.2.tgz", "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.0.tgz",
"integrity": "sha512-/seLvhUg44s1oU9RhjTZZy/0NPbfNctozdysKcvPovxxXZdI5l19mGq6Ri3IaTf1Dy/qChS4BSR7ayxeu8o9aQ==", "integrity": "sha512-ONFBaYoN1qIiCxMCfeHI96lqLza7ujx/QClIXp4kEULUbyH2qLgYoaL8JHhk3FWjSB4TpzoaN3iMCyCFldyXzw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -5790,7 +5908,7 @@
"node": "^18.0.0 || >=20.0.0" "node": "^18.0.0 || >=20.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"vite": "^5.0.0 || ^6.0.0" "vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/vite-plugin-static-copy/node_modules/chokidar": { "node_modules/vite-plugin-static-copy/node_modules/chokidar": {

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.25.2", "@babel/core": "^7.25.2",
@ -31,19 +32,26 @@
"@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.10", "@types/alpinejs": "^3.13.10",
"@types/jquery": "^3.5.31", "@types/jquery": "^3.5.31",
"@types/cytoscape-cxtmenu": "^3.4.4",
"@types/cytoscape-klay": "^3.1.4",
"typescript": "^5.8.3",
"vite": "^6.2.5", "vite": "^6.2.5",
"vite-bundle-visualizer": "^1.2.1", "vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.0.2" "vite-plugin-static-copy": "^3.0.2"
}, },
"dependencies": { "dependencies": {
"@alpinejs/morph": "^3.14.9",
"@alpinejs/sort": "^3.14.7", "@alpinejs/sort": "^3.14.7",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0", "@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.6.13", "@floating-ui/dom": "^1.6.13",
"@fortawesome/fontawesome-free": "^6.6.0", "@fortawesome/fontawesome-free": "^6.6.0",
"@fullcalendar/core": "^6.1.15", "@fullcalendar/core": "^6.1.17",
"@fullcalendar/daygrid": "^6.1.15", "@fullcalendar/daygrid": "^6.1.17",
"@fullcalendar/icalendar": "^6.1.15", "@fullcalendar/icalendar": "^6.1.17",
"@fullcalendar/list": "^6.1.15", "@fullcalendar/interaction": "^6.1.17",
"@fullcalendar/list": "^6.1.17",
"@fullcalendar/resource": "^6.1.17",
"@fullcalendar/resource-timeline": "^6.1.17",
"@sentry/browser": "^9.29.0", "@sentry/browser": "^9.29.0",
"@zip.js/zip.js": "^2.7.52", "@zip.js/zip.js": "^2.7.52",
"3d-force-graph": "^1.73.4", "3d-force-graph": "^1.73.4",
@ -56,6 +64,7 @@
"d3-force-3d": "^3.0.5", "d3-force-3d": "^3.0.5",
"easymde": "^2.19.0", "easymde": "^2.19.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"htmx-ext-alpine-morph": "^2.0.1",
"htmx.org": "^2.0.3", "htmx.org": "^2.0.3",
"jquery": "^3.7.1", "jquery": "^3.7.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",

View File

@ -92,7 +92,7 @@ docs = [
default-groups = ["dev", "tests", "docs"] default-groups = ["dev", "tests", "docs"]
[tool.xapian] [tool.xapian]
version = "1.4.25" version = "1.4.29"
[tool.ruff] [tool.ruff]
output-format = "concise" # makes ruff error logs easier to read output-format = "concise" # makes ruff error logs easier to read

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,137 @@
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 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) {
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,
});
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;
}
}
}

View File

@ -0,0 +1,51 @@
<section
id="slot-reservation"
x-data="slotReservation"
x-show="showForm"
hx-target="this"
hx-ext="alpine-morph"
hx-swap="morph"
>
<h3>{% trans %}Book a room{% endtrans %}</h3>
{% set non_field_errors = form.non_field_errors() %}
{% if non_field_errors %}
<div class="alert alert-red">
{% for error in non_field_errors %}
<span>{{ error }}</span>
{% endfor %}
</div>
{% endif %}
<form
id="slot-reservation-form"
hx-post="{{ url("reservation:make_reservation") }}"
hx-disabled-elt="find input[type='submit']"
>
{% csrf_token %}
<div class="form-group">
{{ form.room.errors }}
{{ form.room.label_tag() }}
{{ form.room|add_attr("x-model=room") }}
</div>
<div class="form-group">
{{ form.start_at.errors }}
{{ form.start_at.label_tag() }}
{{ form.start_at|add_attr("x-model=start") }}
</div>
<div class="form-group">
{{ form.end_at.errors }}
{{ form.end_at.label_tag() }}
{{ form.end_at|add_attr("x-model=end") }}
</div>
<div class="form-group">
{{ form.comment.errors }}
{{ form.comment.label_tag() }}
{{ form.comment }}
</div>
<div class="row gap buttons-row">
<button class="btn btn-grey grow" @click.prevent="showForm = false">
{% trans %}Cancel{% endtrans %}
</button>
<input class="btn btn-blue grow" type="submit">
</div>
</form>
</section>

View File

@ -0,0 +1,27 @@
{% macro room_detail(room, can_edit, can_delete) %}
<div class="card card-row card-row-m">
<div class="card-content">
<strong class="card-title">{{ room.name }}</strong>
<em>{{ room.get_location_display() }}</em>
<p>{{ room.description|truncate(250) }}</p>
</div>
<div class="card-top-left">
{% if can_edit %}
<a
class="btn btn-grey btn-no-text"
href="{{ url("reservation:room_edit", room_id=room.id) }}"
>
<i class="fa fa-edit"></i>
</a>
{% endif %}
{% if can_delete %}
<a
class="btn btn-red btn-no-text"
href="{{ url("reservation:room_delete", room_id=room.id) }}"
>
<i class="fa fa-trash"></i>
</a>
{% endif %}
</div>
</div>
{% endmacro %}

View File

@ -0,0 +1,33 @@
{% extends "core/base.jinja" %}
{% block additional_js %}
<script type="module" src="{{ static("bundled/reservation/components/room-scheduler-index.ts") }}"></script>
<script type="module" src="{{ static("bundled/reservation/slot-reservation-index.ts") }}"></script>
{% endblock %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static('core/components/calendar.scss') }}">
<link rel="stylesheet" href="{{ static('reservation/reservation.scss') }}">
{% endblock %}
{% block content %}
<h2 class="margin-bottom">{% trans %}Room reservation{% endtrans %}</h2>
<p
x-data="scheduleMessages"
class="alert snackbar"
:class="alertMessage.success ? 'alert-green' : 'alert-red'"
x-show="alertMessage.open"
x-transition.duration.500ms
x-text="alertMessage.content"
></p>
<room-scheduler
locale="{{ LANGUAGE_CODE }}"
can_edit_slot="{{ user.has_perm("reservation.change_reservationslot") }}"
can_create_slot="{{ user.has_perm("reservation.add_reservationslot") }}"
></room-scheduler>
{% if user.has_perm("reservation.add_reservationslot") %}
<p><em>{% trans %}You can book a room by selecting a free slot in the calendar.{% endtrans %}</em></p>
{{ add_slot_fragment }}
{% endif %}
{% endblock %}

View File

View File

@ -0,0 +1,113 @@
import pytest
from django.contrib.auth.models import Permission
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from pytest_django.asserts import assertNumQueries, assertRedirects
from club.models import Club
from core.models import User
from reservation.forms import RoomUpdateForm
from reservation.models import Room
@pytest.mark.django_db
class TestFetchRoom:
@pytest.fixture
def user(self):
return baker.make(
User,
user_permissions=[Permission.objects.get(codename="view_room")],
)
def test_fetch_simple(self, client: Client, user: User):
rooms = baker.make(Room, _quantity=3, _bulk_create=True)
client.force_login(user)
response = client.get(reverse("api:fetch_reservable_rooms"))
assert response.status_code == 200
assert response.json() == [
{
"id": room.id,
"name": room.name,
"description": room.description,
"location": room.location,
"club": {"id": room.club.id, "name": room.club.name},
}
for room in rooms
]
def test_nb_queries(self, client: Client, user: User):
client.force_login(user)
with assertNumQueries(5):
# 4 for authentication
# 1 to fetch the actual data
client.get(reverse("api:fetch_reservable_rooms"))
@pytest.mark.django_db
class TestCreateRoom:
def test_ok(self, client: Client):
perm = Permission.objects.get(codename="add_room")
club = baker.make(Club)
client.force_login(
baker.make(User, user_permissions=[perm], groups=[club.board_group])
)
response = client.post(
reverse("reservation:room_create"),
data={"club": club.id, "name": "test", "location": "BELFORT"},
)
assertRedirects(response, reverse("club:tools", kwargs={"club_id": club.id}))
room = Room.objects.last()
assert room is not None
assert room.club == club
assert room.name == "test"
assert room.location == "BELFORT"
def test_permission_denied(self, client: Client):
club = baker.make(Club)
client.force_login(baker.make(User))
response = client.get(reverse("reservation:room_create"))
assert response.status_code == 403
response = client.post(
reverse("reservation:room_create"),
data={"club": club.id, "name": "test", "location": "BELFORT"},
)
assert response.status_code == 403
@pytest.mark.django_db
class TestUpdateRoom:
def test_ok(self, client: Client):
club = baker.make(Club)
room = baker.make(Room, club=club)
client.force_login(baker.make(User, groups=[club.board_group]))
url = reverse("reservation:room_edit", kwargs={"room_id": room.id})
response = client.post(url, data={"name": "test", "location": "BELFORT"})
assertRedirects(response, url)
room.refresh_from_db()
assert room.club == club
assert room.name == "test"
assert room.location == "BELFORT"
def test_permission_denied(self, client: Client):
club = baker.make(Club)
room = baker.make(Room, club=club)
client.force_login(baker.make(User))
url = reverse("reservation:room_edit", kwargs={"room_id": room.id})
response = client.get(url)
assert response.status_code == 403
response = client.post(url, data={"name": "test", "location": "BELFORT"})
assert response.status_code == 403
@pytest.mark.django_db
class TestUpdateRoomForm:
def test_form_club_edition_rights(self):
"""The club field should appear only if the request user can edit it."""
room = baker.make(Room)
perm = Permission.objects.get(codename="change_room")
user_authorized = baker.make(User, user_permissions=[perm])
assert "club" in RoomUpdateForm(request_user=user_authorized).fields
user_forbidden = baker.make(User, groups=[room.club.board_group])
assert "club" not in RoomUpdateForm(request_user=user_forbidden).fields

View File

@ -0,0 +1,207 @@
from datetime import timedelta
import pytest
from django.contrib.auth.models import Permission
from django.test import Client
from django.urls import reverse
from django.utils.timezone import now
from model_bakery import baker
from pytest_django.asserts import assertNumQueries
from core.models import User
from reservation.forms import ReservationForm
from reservation.models import ReservationSlot, Room
@pytest.mark.django_db
class TestFetchReservationSlotsApi:
@pytest.fixture
def user(self):
perm = Permission.objects.get(codename="view_reservationslot")
return baker.make(User, user_permissions=[perm])
def test_fetch_simple(self, client: Client, user: User):
slots = baker.make(ReservationSlot, _quantity=5, _bulk_create=True)
client.force_login(user)
response = client.get(reverse("api:fetch_reservation_slots"))
assert response.json()["results"] == [
{
"id": slot.id,
"room": slot.room_id,
"comment": slot.comment,
"start": slot.start_at.isoformat(timespec="milliseconds").replace(
"+00:00", "Z"
),
"end": slot.end_at.isoformat(timespec="milliseconds").replace(
"+00:00", "Z"
),
"author": {
"id": slot.author.id,
"first_name": slot.author.first_name,
"last_name": slot.author.last_name,
"nick_name": slot.author.nick_name,
},
}
for slot in slots
]
def test_nb_queries(self, client: Client, user: User):
client.force_login(user)
with assertNumQueries(5):
# 4 for authentication
# 1 to fetch the actual data
client.get(reverse("api:fetch_reservation_slots"))
@pytest.mark.django_db
class TestUpdateReservationSlotApi:
@pytest.fixture
def user(self):
perm = Permission.objects.get(codename="change_reservationslot")
return baker.make(User, user_permissions=[perm])
@pytest.fixture
def slot(self):
return baker.make(
ReservationSlot,
start_at=now() + timedelta(hours=2),
end_at=now() + timedelta(hours=4),
)
def test_ok(self, client: Client, user: User, slot: ReservationSlot):
client.force_login(user)
new_start = (slot.start_at + timedelta(hours=1)).replace(microsecond=0)
response = client.patch(
reverse("api:change_reservation_slot", kwargs={"slot_id": slot.id}),
{"start_at": new_start, "end_at": new_start + timedelta(hours=2)},
content_type="application/json",
)
assert response.status_code == 200
slot.refresh_from_db()
assert slot.start_at.replace(microsecond=0) == new_start
assert slot.end_at.replace(microsecond=0) == new_start + timedelta(hours=2)
def test_change_past_event(self, client, user: User, slot: ReservationSlot):
"""Test that moving a slot that already began is impossible."""
client.force_login(user)
new_start = now() - timedelta(hours=1)
response = client.patch(
reverse("api:change_reservation_slot", kwargs={"slot_id": slot.id}),
{"start_at": new_start, "end_at": new_start + timedelta(hours=2)},
content_type="application/json",
)
assert response.status_code == 422
def test_move_event_to_occupied_slot(
self, client: Client, user: User, slot: ReservationSlot
):
client.force_login(user)
other_slot = baker.make(
ReservationSlot,
room=slot.room,
start_at=slot.end_at + timedelta(hours=1),
end_at=slot.end_at + timedelta(hours=3),
)
response = client.patch(
reverse("api:change_reservation_slot", kwargs={"slot_id": slot.id}),
{
"start_at": other_slot.start_at - timedelta(hours=1),
"end_at": other_slot.start_at + timedelta(hours=1),
},
content_type="application/json",
)
assert response.status_code == 409
@pytest.mark.django_db
class TestReservationForm:
def test_ok(self):
start = now() + timedelta(hours=2)
end = start + timedelta(hours=1)
form = ReservationForm(
author=baker.make(User),
data={"room": baker.make(Room), "start_at": start, "end_at": end},
)
assert form.is_valid()
@pytest.mark.parametrize(
("start_date", "end_date", "errors"),
[
(
now() - timedelta(hours=2),
now() + timedelta(hours=2),
{"start_at": ["Assurez-vous que cet horodatage est dans le futur"]},
),
(
now() + timedelta(hours=3),
now() + timedelta(hours=2),
{"__all__": ["Le début doit être placé avant la fin"]},
),
],
)
def test_invalid_timedates(self, start_date, end_date, errors):
form = ReservationForm(
author=baker.make(User),
data={"room": baker.make(Room), "start_at": start_date, "end_at": end_date},
)
assert not form.is_valid()
assert form.errors == errors
def test_unavailable_room(self):
room = baker.make(Room)
baker.make(
ReservationSlot,
room=room,
start_at=now() + timedelta(hours=2),
end_at=now() + timedelta(hours=4),
)
form = ReservationForm(
author=baker.make(User),
data={
"room": room,
"start_at": now() + timedelta(hours=1),
"end_at": now() + timedelta(hours=3),
},
)
assert not form.is_valid()
assert form.errors == {
"__all__": ["Il y a déjà une réservation sur ce créneau."]
}
@pytest.mark.django_db
class TestCreateReservationSlot:
@pytest.fixture
def user(self):
perms = Permission.objects.filter(
codename__in=["add_reservationslot", "view_reservationslot"]
)
return baker.make(User, user_permissions=list(perms))
def test_ok(self, client: Client, user: User):
client.force_login(user)
start = now() + timedelta(hours=2)
end = start + timedelta(hours=1)
room = baker.make(Room)
response = client.post(
reverse("reservation:make_reservation"),
{"room": room.id, "start_at": start, "end_at": end},
)
assert response.status_code == 200
assert response.headers.get("HX-Redirect", "") == reverse("reservation:main")
slot = ReservationSlot.objects.filter(room=room).last()
assert slot is not None
assert slot.start_at == start
assert slot.end_at == end
assert slot.author == user
def test_permissions_denied(self, client: Client):
client.force_login(baker.make(User))
start = now() + timedelta(hours=2)
end = start + timedelta(hours=1)
response = client.post(
reverse("reservation:make_reservation"),
{"room": baker.make(Room), "start_at": start, "end_at": end},
)
assert response.status_code == 403

19
reservation/urls.py Normal file
View File

@ -0,0 +1,19 @@
from django.urls import path
from reservation.views import (
ReservationFragment,
ReservationScheduleView,
RoomCreateView,
RoomDeleteView,
RoomUpdateView,
)
urlpatterns = [
path("", ReservationScheduleView.as_view(), name="main"),
path("room/create/", RoomCreateView.as_view(), name="room_create"),
path("room/<int:room_id>/edit", RoomUpdateView.as_view(), name="room_edit"),
path("room/<int:room_id>/delete", RoomDeleteView.as_view(), name="room_delete"),
path(
"fragment/reservation", ReservationFragment.as_view(), name="make_reservation"
),
]

72
reservation/views.py Normal file
View File

@ -0,0 +1,72 @@
# Create your views here.
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DeleteView, TemplateView, UpdateView
from club.models import Club
from core.auth.mixins import CanEditMixin
from core.views import UseFragmentsMixin
from core.views.mixins import FragmentMixin
from reservation.forms import ReservationForm, RoomCreateForm, RoomUpdateForm
from reservation.models import ReservationSlot, Room
class ReservationFragment(PermissionRequiredMixin, FragmentMixin, CreateView):
model = ReservationSlot
form_class = ReservationForm
permission_required = "reservation.add_reservationslot"
template_name = "reservation/fragments/create_reservation.jinja"
success_url = reverse_lazy("reservation:main")
reload_on_redirect = True
object = None
def get_form_kwargs(self):
return super().get_form_kwargs() | {"author": self.request.user}
class ReservationScheduleView(PermissionRequiredMixin, UseFragmentsMixin, TemplateView):
template_name = "reservation/schedule.jinja"
permission_required = "reservation.view_reservationslot"
fragments = {"add_slot_fragment": ReservationFragment}
class RoomCreateView(PermissionRequiredMixin, CreateView):
form_class = RoomCreateForm
template_name = "core/create.jinja"
permission_required = "reservation.add_room"
def get_initial(self):
init = super().get_initial()
if "club" in self.request.GET:
club_id = self.request.GET["club"]
if club_id.isdigit() and int(club_id) > 0:
init["club"] = Club.objects.filter(id=int(club_id)).first()
return init
def get_success_url(self):
return reverse("club:tools", kwargs={"club_id": self.object.club_id})
class RoomUpdateView(SuccessMessageMixin, CanEditMixin, UpdateView):
model = Room
pk_url_kwarg = "room_id"
form_class = RoomUpdateForm
template_name = "core/edit.jinja"
success_message = _("%(name)s was updated successfully")
def get_form_kwargs(self):
return super().get_form_kwargs() | {"request_user": self.request.user}
def get_success_url(self):
return self.request.path
class RoomDeleteView(PermissionRequiredMixin, DeleteView):
model = Room
pk_url_kwarg = "room_id"
template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("reservation:room_list")
permission_required = "reservation.delete_room"

View File

@ -53,9 +53,9 @@ class TestMergeUser(TestCase):
self.to_keep.address = "Jerusalem" self.to_keep.address = "Jerusalem"
self.to_delete.parent_address = "Rome" self.to_delete.parent_address = "Rome"
self.to_delete.address = "Rome" self.to_delete.address = "Rome"
subscribers = Group.objects.get(name="Subscribers") subscribers = Group.objects.get(id=settings.SITH_GROUP_SUBSCRIBERS_ID)
mde_admin = Group.objects.get(name="MDE admin") mde_admin = Group.objects.get(name="MDE admin")
sas_admin = Group.objects.get(name="SAS admin") sas_admin = Group.objects.get(id=settings.SITH_GROUP_SAS_ADMIN_ID)
self.to_keep.groups.add(subscribers.id) self.to_keep.groups.add(subscribers.id)
self.to_delete.groups.add(mde_admin.id) self.to_delete.groups.add(mde_admin.id)
self.to_keep.groups.add(sas_admin.id) self.to_keep.groups.add(sas_admin.id)

View File

@ -2,7 +2,6 @@ from typing import Any, Literal
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import F
from django.urls import reverse from django.urls import reverse
from ninja import Body, File, Query from ninja import Body, File, Query
from ninja.security import SessionAuth from ninja.security import SessionAuth
@ -105,8 +104,7 @@ class PicturesController(ControllerBase):
filters.filter(Picture.objects.viewable_by(user)) filters.filter(Picture.objects.viewable_by(user))
.distinct() .distinct()
.order_by("-parent__date", "date") .order_by("-parent__date", "date")
.select_related("owner") .select_related("owner", "parent")
.annotate(album=F("parent__name"))
) )
@route.post( @route.post(
@ -153,7 +151,9 @@ class PicturesController(ControllerBase):
@route.put("/{picture_id}/identified", permissions=[IsAuthenticated, CanView]) @route.put("/{picture_id}/identified", permissions=[IsAuthenticated, CanView])
def identify_users(self, picture_id: NonNegativeInt, users: set[NonNegativeInt]): def identify_users(self, picture_id: NonNegativeInt, users: set[NonNegativeInt]):
picture = self.get_object_or_exception(Picture, pk=picture_id) picture = self.get_object_or_exception(
Picture.objects.select_related("parent"), pk=picture_id
)
db_users = list(User.objects.filter(id__in=users)) db_users = list(User.objects.filter(id__in=users))
if len(users) != len(db_users): if len(users) != len(db_users):
raise NotFound raise NotFound
@ -166,13 +166,15 @@ class PicturesController(ControllerBase):
] ]
PeoplePictureRelation.objects.bulk_create(relations) PeoplePictureRelation.objects.bulk_create(relations)
for u in identified: for u in identified:
html_id = f"album-{picture.parent_id}"
url = reverse(
"sas:user_pictures", kwargs={"user_id": u.id}, fragment=html_id
)
Notification.objects.get_or_create( Notification.objects.get_or_create(
user=u, user=u,
viewed=False, viewed=False,
type="NEW_PICTURES", type="NEW_PICTURES",
defaults={ defaults={"url": url, "param": picture.parent.name},
"url": reverse("sas:user_pictures", kwargs={"user_id": u.id})
},
) )
@route.delete("/{picture_id}", permissions=[IsSasAdmin]) @route.delete("/{picture_id}", permissions=[IsSasAdmin])

View File

@ -0,0 +1,19 @@
# Generated by Django 5.2.3 on 2025-06-17 18:53
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("sas", "0004_picturemoderationrequest_and_more")]
operations = [
migrations.AlterModelOptions(
name="sasfile",
options={
"permissions": [
("moderate_sasfile", "Can moderate SAS files"),
("view_unmoderated_sasfile", "Can view not moderated SAS files"),
]
},
),
]

View File

@ -25,11 +25,10 @@ from django.core.cache import cache
from django.db import models from django.db import models
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from PIL import Image from PIL import Image
from core.models import SithFile, User from core.models import Notification, SithFile, User
from core.utils import exif_auto_rotate, resize_image from core.utils import exif_auto_rotate, resize_image
@ -42,6 +41,10 @@ class SasFile(SithFile):
class Meta: class Meta:
proxy = True proxy = True
permissions = [
("moderate_sasfile", "Can moderate SAS files"),
("view_unmoderated_sasfile", "Can view not moderated SAS files"),
]
def can_be_viewed_by(self, user): def can_be_viewed_by(self, user):
if user.is_anonymous: if user.is_anonymous:
@ -60,7 +63,7 @@ class SasFile(SithFile):
return self.id in viewable return self.id in viewable
def can_be_edited_by(self, user): def can_be_edited_by(self, user):
return user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) return user.has_perm("sas.change_sasfile")
class PictureQuerySet(models.QuerySet): class PictureQuerySet(models.QuerySet):
@ -70,7 +73,7 @@ class PictureQuerySet(models.QuerySet):
Warning: Warning:
Calling this queryset method may add several additional requests. Calling this queryset method may add several additional requests.
""" """
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID): if user.has_perm("sas.moderate_sasfile"):
return self.all() return self.all()
if user.was_subscribed: if user.was_subscribed:
return self.filter(Q(is_moderated=True) | Q(owner=user)) return self.filter(Q(is_moderated=True) | Q(owner=user))
@ -183,7 +186,7 @@ class AlbumQuerySet(models.QuerySet):
Warning: Warning:
Calling this queryset method may add several additional requests. Calling this queryset method may add several additional requests.
""" """
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID): if user.has_perm("sas.moderate_sasfile"):
return self.all() return self.all()
if user.was_subscribed: if user.was_subscribed:
return self.filter(Q(is_moderated=True) | Q(owner=user)) return self.filter(Q(is_moderated=True) | Q(owner=user))
@ -256,14 +259,10 @@ class Album(SasFile):
self.save() self.save()
def sas_notification_callback(notif): def sas_notification_callback(notif: Notification):
count = Picture.objects.filter(is_moderated=False).count() count = Picture.objects.filter(is_moderated=False).count()
if count: notif.viewed = not bool(count)
notif.viewed = False notif.param = str(count)
else:
notif.viewed = True
notif.param = "%s" % count
notif.date = timezone.now()
class PeoplePictureRelation(models.Model): class PeoplePictureRelation(models.Model):

View File

@ -18,6 +18,12 @@ class AlbumFilterSchema(FilterSchema):
parent_id: int | None = Field(None, q="parent_id") parent_id: int | None = Field(None, q="parent_id")
class SimpleAlbumSchema(ModelSchema):
class Meta:
model = Album
fields = ["id", "name"]
class AlbumSchema(ModelSchema): class AlbumSchema(ModelSchema):
class Meta: class Meta:
model = Album model = Album
@ -70,7 +76,7 @@ class PictureSchema(ModelSchema):
full_size_url: str full_size_url: str
compressed_url: str compressed_url: str
thumb_url: str thumb_url: str
album: str album: SimpleAlbumSchema = Field(alias="parent")
report_url: str report_url: str
edit_url: str edit_url: str

View File

@ -9,28 +9,35 @@ interface PagePictureConfig {
userId: number; userId: number;
} }
interface Album {
id: number;
name: string;
pictures: PictureSchema[];
}
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("user_pictures", (config: PagePictureConfig) => ({ Alpine.data("user_pictures", (config: PagePictureConfig) => ({
loading: true, loading: true,
pictures: [] as PictureSchema[], albums: [] as Album[],
albums: {} as Record<string, PictureSchema[]>,
async init() { async init() {
this.pictures = await paginated(picturesFetchPictures, { const pictures = await paginated(picturesFetchPictures, {
// biome-ignore lint/style/useNamingConvention: from python api // biome-ignore lint/style/useNamingConvention: from python api
query: { users_identified: [config.userId] }, query: { users_identified: [config.userId] },
} as PicturesFetchPicturesData); } as PicturesFetchPicturesData);
const groupedAlbums = Object.groupBy(pictures, (i: PictureSchema) => i.album.id);
this.albums = this.pictures.reduce( this.albums = Object.values(groupedAlbums).map((pictures: PictureSchema[]) => {
(acc: Record<string, PictureSchema[]>, picture: PictureSchema) => { return {
if (!acc[picture.album]) { id: pictures[0].album.id,
acc[picture.album] = []; name: pictures[0].album.name,
pictures: pictures,
};
});
this.albums.sort((a: Album, b: Album) => b.id - a.id);
const hash = document.location.hash.replace("#", "");
if (hash.startsWith("album-")) {
this.$nextTick(() => document.getElementById(hash)?.scrollIntoView()).then();
} }
acc[picture.album].push(picture);
return acc;
},
{},
);
this.loading = false; this.loading = false;
}, },
})); }));

View File

@ -50,7 +50,7 @@
#} #}
{% macro download_button(name) %} {% macro download_button(name) %}
<div x-data="pictures_download"> <div x-data="pictures_download">
<div x-show="pictures.length > 0" x-cloak> <div x-show="albums.length > 0" x-cloak>
<button <button
:disabled="isDownloading" :disabled="isDownloading"
class="btn btn-blue {% if name == "" %}btn-no-text{% endif %}" class="btn btn-blue {% if name == "" %}btn-no-text{% endif %}"

View File

@ -20,17 +20,17 @@
{{ download_button(_("Download all my pictures")) }} {{ download_button(_("Download all my pictures")) }}
{% endif %} {% endif %}
<template x-for="[album, pictures] in Object.entries(albums)" x-cloak> <template x-for="album in albums" x-cloak>
<section> <section>
<br /> <br />
<div class="row"> <div class="row">
<h4 x-text="album"></h4> <h4 x-text="album.name" :id="`album-${album.id}`"></h4>
{% if user.id == object.id %} {% if user.id == object.id %}
&nbsp;{{ download_button("") }} &nbsp;{{ download_button("") }}
{% endif %} {% endif %}
</div> </div>
<div class="photos"> <div class="photos">
<template x-for="picture in pictures"> <template x-for="picture in album.pictures">
<a :href="picture.sas_url"> <a :href="picture.sas_url">
<div <div
class="photo" class="photo"

View File

@ -122,6 +122,7 @@ INSTALLED_APPS = (
"trombi", "trombi",
"matmat", "matmat",
"pedagogy", "pedagogy",
"reservation",
"galaxy", "galaxy",
"antispam", "antispam",
"api", "api",
@ -273,7 +274,7 @@ LOGGING = {
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/1.8/topics/i18n/ # https://docs.djangoproject.com/en/1.8/topics/i18n/
LANGUAGE_CODE = "fr-FR" LANGUAGE_CODE = "fr"
LANGUAGES = [("en", _("English")), ("fr", _("French"))] LANGUAGES = [("en", _("English")), ("fr", _("French"))]
@ -381,10 +382,10 @@ SITH_GROUP_SAS_ADMIN_ID = env.int("SITH_GROUP_SAS_ADMIN_ID", default=8)
SITH_GROUP_FORUM_ADMIN_ID = env.int("SITH_GROUP_FORUM_ADMIN_ID", default=9) SITH_GROUP_FORUM_ADMIN_ID = env.int("SITH_GROUP_FORUM_ADMIN_ID", default=9)
SITH_GROUP_PEDAGOGY_ADMIN_ID = env.int("SITH_GROUP_PEDAGOGY_ADMIN_ID", default=10) SITH_GROUP_PEDAGOGY_ADMIN_ID = env.int("SITH_GROUP_PEDAGOGY_ADMIN_ID", default=10)
SITH_GROUP_BANNED_ALCOHOL_ID = env.int("SITH_GROUP_BANNED_ALCOHOL_ID", default=11) SITH_GROUP_BANNED_ALCOHOL_ID = env.int("SITH_GROUP_BANNED_ALCOHOL_ID", default=12)
SITH_GROUP_BANNED_COUNTER_ID = env.int("SITH_GROUP_BANNED_COUNTER_ID", default=12) SITH_GROUP_BANNED_COUNTER_ID = env.int("SITH_GROUP_BANNED_COUNTER_ID", default=13)
SITH_GROUP_BANNED_SUBSCRIPTION_ID = env.int( SITH_GROUP_BANNED_SUBSCRIPTION_ID = env.int(
"SITH_GROUP_BANNED_SUBSCRIPTION_ID", default=13 "SITH_GROUP_BANNED_SUBSCRIPTION_ID", default=14
) )
SITH_CLUB_REFOUND_ID = env.int("SITH_CLUB_REFOUND_ID", default=89) SITH_CLUB_REFOUND_ID = env.int("SITH_CLUB_REFOUND_ID", default=89)
@ -677,7 +678,7 @@ SITH_NOTIFICATIONS = [
("NEWS_MODERATION", _("There are %s fresh news to be moderated")), ("NEWS_MODERATION", _("There are %s fresh news to be moderated")),
("FILE_MODERATION", _("New files to be moderated")), ("FILE_MODERATION", _("New files to be moderated")),
("SAS_MODERATION", _("There are %s pictures to be moderated in the SAS")), ("SAS_MODERATION", _("There are %s pictures to be moderated in the SAS")),
("NEW_PICTURES", _("You've been identified on some pictures")), ("NEW_PICTURES", _("You've been identified in album %s")),
("REFILLING", _("You just refilled of %s")), ("REFILLING", _("You just refilled of %s")),
("SELLING", _("You just bought %s")), ("SELLING", _("You just bought %s")),
("GENERIC", _("You have a notification")), ("GENERIC", _("You have a notification")),

View File

@ -45,6 +45,10 @@ urlpatterns = [
path("trombi/", include(("trombi.urls", "trombi"), namespace="trombi")), path("trombi/", include(("trombi.urls", "trombi"), namespace="trombi")),
path("matmatronch/", include(("matmat.urls", "matmat"), namespace="matmat")), path("matmatronch/", include(("matmat.urls", "matmat"), namespace="matmat")),
path("pedagogy/", include(("pedagogy.urls", "pedagogy"), namespace="pedagogy")), path("pedagogy/", include(("pedagogy.urls", "pedagogy"), namespace="pedagogy")),
path(
"reservation/",
include(("reservation.urls", "reservation"), namespace="reservation"),
),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("i18n/", include("django.conf.urls.i18n")), path("i18n/", include("django.conf.urls.i18n")),
path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"), path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"),

View File

@ -4,7 +4,7 @@
"sourceMap": true, "sourceMap": true,
"noImplicitAny": true, "noImplicitAny": true,
"module": "esnext", "module": "esnext",
"target": "es2022", "target": "es2024",
"allowJs": true, "allowJs": true,
"moduleResolution": "node", "moduleResolution": "node",
"experimentalDecorators": true, "experimentalDecorators": true,
@ -17,7 +17,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/*"]
} }
} }
} }

2
uv.lock generated
View File

@ -1852,7 +1852,7 @@ dev = [
{ name = "ipython", specifier = ">=9.0.2,<10.0.0" }, { name = "ipython", specifier = ">=9.0.2,<10.0.0" },
{ name = "pre-commit", specifier = ">=4.1.0,<5.0.0" }, { name = "pre-commit", specifier = ">=4.1.0,<5.0.0" },
{ name = "rjsmin", specifier = ">=1.2.4,<2.0.0" }, { name = "rjsmin", specifier = ">=1.2.4,<2.0.0" },
{ name = "ruff", specifier = ">=0.11.11,<1.0.0" }, { name = "ruff", specifier = ">=0.11.13,<1.0.0" },
] ]
docs = [ docs = [
{ name = "mkdocs", specifier = ">=1.6.1,<2.0.0" }, { name = "mkdocs", specifier = ">=1.6.1,<2.0.0" },