8 Commits

Author SHA1 Message Date
dependabot[bot]
79297b7a75 Bump vite from 6.3.5 to 6.3.6
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.3.5 to 6.3.6.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.3.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.3.6/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-10 02:04:00 +00:00
Kenneth Soares
e0702ce8be Merge pull request #1165 from ae-utbm/taiste
Commands, Galaxy, Buxfixes and other
2025-09-03 14:32:30 +02:00
thomas girod
f6683068ff Merge pull request #1147 from ae-utbm/taiste
Many fixes
2025-07-02 10:10:19 +02:00
thomas girod
81d1d1caca Merge pull request #1128 from ae-utbm/taiste
Api keys, better tabs, navbar and accordions, better notifications, fixes and dependencies updates
2025-06-17 14:08:05 +02:00
thomas girod
1cc2378476 Merge pull request #1112 from ae-utbm/taiste
Accordions, navbar and fixes
2025-06-05 19:51:13 +02:00
thomas girod
61e370cf73 Merge pull request #1107 from ae-utbm/taiste
Eboutic refactor, Celery, better tooltips, Python 3.13, bugfixes and other
2025-06-03 00:03:33 +02:00
thomas girod
6377acfffa Merge pull request #1084 from ae-utbm/taiste
Django 5.2, HTMX for billing infos form, eurocks widget consent message and new promo 24 logo
2025-04-14 12:42:19 +02:00
thomas girod
3c8933461a Merge pull request #1075 from ae-utbm/taiste
SAS and markdown pictures upload improval, google calendar removal, calendar export link, css fixes and more
2025-04-10 13:15:02 +02:00
55 changed files with 212 additions and 1928 deletions

View File

@@ -1,14 +1,6 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from 'core/macros.jinja' import user_profile_link %} {% from 'core/macros.jinja' import user_profile_link %}
{% block title -%}
{{ club.name }}
{%- endblock %}
{% block description -%}
{{ club.short_description }}
{%- endblock %}
{% block content %} {% block content %}
<div id="club_detail"> <div id="club_detail">
{% if club.logo %} {% if club.logo %}

View File

@@ -1,12 +1,8 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% block title -%} {% block title %}
{% trans %}Club list{% endtrans %} {% trans %}Club list{% endtrans %}
{%- endblock %} {% endblock %}
{% block description -%}
{% trans %}The list of all clubs existing at UTBM.{% endtrans %}
{%- endblock %}
{% macro display_club(club) -%} {% macro display_club(club) -%}
@@ -25,7 +21,7 @@
{%- if club.children.all()|length != 0 %} {%- if club.children.all()|length != 0 %}
<ul> <ul>
{%- for c in club.children.order_by('name').prefetch_related("children") %} {%- for c in club.children.order_by('name') %}
{{ display_club(c) }} {{ display_club(c) }}
{%- endfor %} {%- endfor %}
</ul> </ul>
@@ -40,8 +36,8 @@
{% if club_list %} {% if club_list %}
<h3>{% trans %}Club list{% endtrans %}</h3> <h3>{% trans %}Club list{% endtrans %}</h3>
<ul> <ul>
{%- for club in club_list %} {%- for c in club_list.all().order_by('name') if c.parent is none %}
{{ display_club(club) }} {{ display_club(c) }}
{%- endfor %} {%- endfor %}
</ul> </ul>
{% else %} {% else %}

View File

@@ -1,63 +1,25 @@
{% 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 %} ({{ club.name }})</h3> <h3>{% trans %}Club tools{% endtrans %}</h3>
<div> <div>
<h4>{% trans %}Communication:{% endtrans %}</h4> <h4>{% trans %}Communication:{% endtrans %}</h4>
<ul> <ul>
<li> <li> <a href="{{ url('com:news_new') }}?club={{ object.id }}">{% trans %}Create a news{% endtrans %}</a></li>
<a href="{{ url('com:news_new') }}?club={{ object.id }}"> <li> <a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">{% trans %}Post in the Weekmail{% endtrans %}</a></li>
{% 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> <li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></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 counter in counters %} {% for c in object.counters.filter(type="OFFICE") %}
<li>{{ counter }}: <li>{{ c }}:
<a href="{{ url('counter:details', counter_id=counter.id) }}">View</a> <a href="{{ url('counter:details', counter_id=c.id) }}">View</a>
<a href="{{ url('counter:admin', counter_id=counter.id) }}">Edit</a> <a href="{{ url('counter:admin', counter_id=c.id) }}">Edit</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@@ -171,10 +171,6 @@ class ClubListView(ListView):
model = Club model = Club
template_name = "club/club_list.jinja" template_name = "club/club_list.jinja"
queryset = (
Club.objects.filter(parent=None).order_by("name").prefetch_related("children")
)
context_object_name = "club_list"
class ClubView(ClubTabsMixin, DetailView): class ClubView(ClubTabsMixin, DetailView):
@@ -245,12 +241,6 @@ 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

@@ -68,7 +68,7 @@ class IcsCalendar:
start=news_date.start_date, start=news_date.start_date,
end=news_date.end_date, end=news_date.end_date,
url=as_absolute_url( url=as_absolute_url(
reverse("com:news_detail", kwargs={"news_id": news_date.news_id}) reverse("com:news_detail", kwargs={"news_id": news_date.news.id})
), ),
) )
calendar.events.append(event) calendar.events.append(event)

View File

@@ -27,7 +27,7 @@ from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
from django.db import models, transaction from django.db import models, transaction
from django.db.models import Exists, F, OuterRef, Q from django.db.models import F, Q
from django.shortcuts import render from django.shortcuts import render
from django.templatetags.static import static from django.templatetags.static import static
from django.urls import reverse from django.urls import reverse
@@ -55,17 +55,9 @@ class Sith(models.Model):
class NewsQuerySet(models.QuerySet): class NewsQuerySet(models.QuerySet):
def published(self) -> Self: def moderated(self) -> Self:
return self.filter(is_published=True) return self.filter(is_published=True)
def waiting_moderation(self) -> Self:
"""Filter all non-finished non-published news"""
# Because of the way News and NewsDates are created,
# there may be some cases where this method is called before
# the NewsDates linked to a Date are actually persisted in db.
# Thus, it's important to filter by "not past date" rather than by "future date"
return self.filter(~Q(dates__start_date__lt=timezone.now()), is_published=False)
def viewable_by(self, user: User) -> Self: def viewable_by(self, user: User) -> Self:
"""Filter news that the given user can view. """Filter news that the given user can view.
@@ -135,28 +127,20 @@ class News(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
super().save(*args, **kwargs) super().save(*args, **kwargs)
if not self.is_published: if self.is_published:
admins_without_notif = User.objects.filter( return
~Exists( for user in User.objects.filter(
Notification.objects.filter( groups__id__in=[settings.SITH_GROUP_COM_ADMIN_ID]
user=OuterRef("pk"), type="NEWS_MODERATION" ):
Notification.objects.create(
user=user, url=reverse("com:news_admin_list"), type="NEWS_MODERATION"
) )
),
groups__id=settings.SITH_GROUP_COM_ADMIN_ID,
)
notif_url = reverse("com:news_admin_list")
new_notifs = [
Notification(user=user, url=notif_url, type="NEWS_MODERATION")
for user in admins_without_notif
]
Notification.objects.bulk_create(new_notifs)
self.update_moderation_notifs()
def get_absolute_url(self): def get_absolute_url(self):
return reverse("com:news_detail", kwargs={"news_id": self.id}) return reverse("com:news_detail", kwargs={"news_id": self.id})
def get_full_url(self): def get_full_url(self):
return f"https://{settings.SITH_URL}{self.get_absolute_url()}" return "https://%s%s" % (settings.SITH_URL, self.get_absolute_url())
def is_owned_by(self, user): def is_owned_by(self, user):
if user.is_anonymous: if user.is_anonymous:
@@ -175,16 +159,19 @@ class News(models.Model):
or (user.is_authenticated and self.author_id == user.id) or (user.is_authenticated and self.author_id == user.id)
) )
@staticmethod
def update_moderation_notifs(): def news_notification_callback(notif: Notification):
count = News.objects.waiting_moderation().count() # the NewsDate linked to the News
notifs_qs = Notification.objects.filter( # which creation triggered this callback may not exist yet,
type="NEWS_MODERATION", user__groups__id=settings.SITH_GROUP_COM_ADMIN_ID # so it's important to filter by "not past date" rather than by "future date"
) count = News.objects.filter(
~Q(dates__start_date__gt=timezone.now()), is_published=False
).count()
if count: if count:
notifs_qs.update(viewed=False, param=str(count)) notif.viewed = False
notif.param = str(count)
else: else:
notifs_qs.update(viewed=True) notif.viewed = True
class NewsDateQuerySet(models.QuerySet): class NewsDateQuerySet(models.QuerySet):

View File

@@ -16,74 +16,14 @@
--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: 0 6px 20px 4px rgb(0 0 0 / 16%); --event-details-box-shadow: 0px 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;
@@ -120,47 +60,68 @@ 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: 0; margin-top: 0px;
margin-bottom: 4px; margin-bottom: 4px;
} }
} }
}
// Reset from style.scss a.fc-col-header-cell-cushion,
thead { 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
thead {
background-color: white; background-color: white;
color: black; color: black;
} }
// Reset from style.scss // Reset from style.scss
tbody > tr { tbody>tr {
&:nth-child(even):not(.highlight) { &:nth-child(even):not(.highlight) {
background: white; background: white;
} }
} }
.fc .fc-toolbar.fc-footer-toolbar { .fc .fc-toolbar.fc-footer-toolbar {
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
button.text-copy, button.text-copy,
button.text-copy:focus, button.text-copy:focus,
button.text-copy:hover { button.text-copy:hover {
background-color: #67AE6E !important; background-color: #67AE6E !important;
transition: 500ms ease-in; transition: 500ms ease-in;
} }
button.text-copied, button.text-copied,
button.text-copied:focus, button.text-copied:focus,
button.text-copied:hover { button.text-copied:hover {
transition: 500ms ease-out; transition: 500ms ease-out;
} }
.fc .fc-getCalendarLink-button { .fc .fc-getCalendarLink-button {
margin-right: 0.5rem; margin-right: 0.5rem;
} }
.fc .fc-helpButton-button { .fc .fc-helpButton-button {
border-radius: 70%; border-radius: 70%;
padding-left: 0.5rem; padding-left: 0.5rem;
padding-right: 0.5rem; padding-right: 0.5rem;
@@ -169,11 +130,12 @@ button.text-copied:hover {
width: 30px; width: 30px;
height: 30px; height: 30px;
font-size: 11px; font-size: 11px;
} }
.fc .fc-helpButton-button:hover { .fc .fc-helpButton-button:hover {
background-color: rgba(20, 20, 20, 0.6); background-color: rgba(20, 20, 20, 0.6);
}
} }
.tooltip.calendar-copy-tooltip { .tooltip.calendar-copy-tooltip {

View File

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

View File

@@ -1,11 +1,13 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% from "com/macros.jinja" import news_moderation_alert %} {% from "com/macros.jinja" import news_moderation_alert %}
{% block title %}AE UTBM{% endblock %} {% block title %}
{% 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('core/components/calendar.scss') }}"> <link rel="stylesheet" href="{{ static('com/components/ics-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") }}">
@@ -211,12 +213,6 @@
<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

@@ -1,22 +1,13 @@
from datetime import timedelta
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.utils.timezone import now
from model_bakery import baker from model_bakery import baker
from com.models import News, NewsDate from com.models import News
from core.baker_recipes import subscriber_user
from core.models import Group, Notification, User from core.models import Group, Notification, User
@pytest.mark.django_db @pytest.mark.django_db
def test_notification_created(): def test_notification_created():
# this news is unpublished, but is set in the past
# it shouldn't be taken into account when counting the number
# of news that are to be moderated
past_news = baker.make(News, is_published=False)
baker.make(NewsDate, news=past_news, start_date=now() - timedelta(days=1))
com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID) com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
com_admin_group.users.all().delete() com_admin_group.users.all().delete()
Notification.objects.all().delete() Notification.objects.all().delete()
@@ -24,28 +15,9 @@ def test_notification_created():
for i in range(2): for i in range(2):
# news notifications are permanent, so the notification created # news notifications are permanent, so the notification created
# during the first iteration should be reused during the second one. # during the first iteration should be reused during the second one.
baker.make(News, is_published=False) baker.make(News)
notifications = list(Notification.objects.all()) notifications = list(Notification.objects.all())
assert len(notifications) == 1 assert len(notifications) == 1
assert notifications[0].user == com_admin assert notifications[0].user == com_admin
assert notifications[0].type == "NEWS_MODERATION" assert notifications[0].type == "NEWS_MODERATION"
assert notifications[0].param == str(i + 1) assert notifications[0].param == str(i + 1)
@pytest.mark.django_db
def test_notification_edited_when_moderating_news():
com_admin_group = Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)
com_admins = subscriber_user.make(_quantity=3)
com_admin_group.users.set(com_admins)
Notification.objects.all().delete()
news = baker.make(News, is_published=False)
assert Notification.objects.count() == 3
assert Notification.objects.filter(viewed=False).count() == 3
news.is_published = True
news.moderator = com_admins[0]
news.save()
# when the news is moderated, the notification should be marked as read
# for all admins
assert Notification.objects.count() == 3
assert Notification.objects.filter(viewed=False).count() == 0

View File

@@ -789,11 +789,7 @@ class Command(BaseCommand):
subscribers = Group.objects.create(name="Cotisants") subscribers = Group.objects.create(name="Cotisants")
subscribers.permissions.add( subscribers.permissions.add(
*list( *list(perms.filter(codename__in=["add_news", "add_uvcomment"]))
perms.filter(
codename__in=["add_news", "add_uvcomment", "view_reservationslot"]
)
)
) )
old_subscribers = Group.objects.create(name="Anciens cotisants") old_subscribers = Group.objects.create(name="Anciens cotisants")
old_subscribers.permissions.add( old_subscribers.permissions.add(

View File

@@ -1,7 +1,6 @@
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
@@ -25,7 +24,6 @@ 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
@@ -42,20 +40,45 @@ 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...")
self.create_club_memberships(subscribers) users_qs = User.objects.filter(id__in=[s.id for s in subscribers])
self.stdout.write("Creating rooms and reservation...") subscribers_now = list(
self.create_resources_and_reservations(random.sample(subscribers, k=40)) 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))),
)
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 = list(User.objects.order_by("?")[:100]) sellers = random.sample(list(User.objects.all()), 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)
@@ -165,97 +188,6 @@ 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"]
@@ -453,7 +385,7 @@ class Command(BaseCommand):
Permanency.objects.bulk_create(perms) Permanency.objects.bulk_create(perms)
def create_forums(self): def create_forums(self):
forumers = list(User.objects.order_by("?")[:100]) forumers = random.sample(list(User.objects.all()), 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 = [
@@ -471,7 +403,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.values_list("id", flat=True)) topics = list(ForumTopic.objects.all())
def get_author(): def get_author():
if random.random() > 0.5: if random.random() > 0.5:
@@ -479,7 +411,7 @@ class Command(BaseCommand):
return random.choice(forumers) return random.choice(forumers)
messages = [] messages = []
for topic_id in topics: for t 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(
[ [
@@ -491,7 +423,7 @@ class Command(BaseCommand):
messages.extend( messages.extend(
[ [
ForumMessage( ForumMessage(
topic_id=topic_id, topic=t,
author=get_author(), author=get_author(),
date=d, date=d,
message="\n\n".join( message="\n\n".join(

View File

@@ -560,7 +560,7 @@ class User(AbstractUser):
"""Determine if the object is owned by the user.""" """Determine if the object is owned by the user."""
if hasattr(obj, "is_owned_by") and obj.is_owned_by(self): if hasattr(obj, "is_owned_by") and obj.is_owned_by(self):
return True return True
if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group_id): if hasattr(obj, "owner_group") and self.is_in_group(pk=obj.owner_group.id):
return True return True
return self.is_root return self.is_root
@@ -569,14 +569,8 @@ class User(AbstractUser):
if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self): if hasattr(obj, "can_be_edited_by") and obj.can_be_edited_by(self):
return True return True
if hasattr(obj, "edit_groups"): if hasattr(obj, "edit_groups"):
if ( for pk in obj.edit_groups.values_list("pk", flat=True):
hasattr(obj, "_prefetched_objects_cache") if self.is_in_group(pk=pk):
and "edit_groups" in obj._prefetched_objects_cache
):
pks = [g.id for g in obj.edit_groups.all()]
else:
pks = list(obj.edit_groups.values_list("id", flat=True))
if any(self.is_in_group(pk=pk) for pk in pks):
return True return True
if isinstance(obj, User) and obj == self: if isinstance(obj, User) and obj == self:
return True return True
@@ -587,17 +581,8 @@ class User(AbstractUser):
if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self): if hasattr(obj, "can_be_viewed_by") and obj.can_be_viewed_by(self):
return True return True
if hasattr(obj, "view_groups"): if hasattr(obj, "view_groups"):
# if "view_groups" has already been prefetched, use for pk in obj.view_groups.values_list("pk", flat=True):
# the prefetch cache, else fetch only the ids, to make if self.is_in_group(pk=pk):
# the query lighter.
if (
hasattr(obj, "_prefetched_objects_cache")
and "view_groups" in obj._prefetched_objects_cache
):
pks = [g.id for g in obj.view_groups.all()]
else:
pks = list(obj.view_groups.values_list("id", flat=True))
if any(self.is_in_group(pk=pk) for pk in pks):
return True return True
return self.can_edit(obj) return self.can_edit(obj)
@@ -1399,9 +1384,9 @@ class Page(models.Model):
@cached_property @cached_property
def is_club_page(self): def is_club_page(self):
return ( club_root_page = Page.objects.filter(name=settings.SITH_CLUB_ROOT_PAGE).first()
self.name == settings.SITH_CLUB_ROOT_PAGE return club_root_page is not None and (
or settings.SITH_CLUB_ROOT_PAGE in [p.name for p in self.get_parent_list()] self == club_root_page or club_root_page in self.get_parent_list()
) )
@cached_property @cached_property

View File

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

View File

@@ -1,5 +1,4 @@
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

@@ -16,13 +16,6 @@
} }
} }
.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;
@@ -99,23 +92,13 @@
} }
@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

@@ -10,9 +10,10 @@
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

@@ -2,14 +2,8 @@
<html lang="fr"> <html lang="fr">
<head> <head>
{% block head %} {% block head %}
<title>{% block title %}Association des Étudiants de l'UTBM{% endblock %}</title> <title>{% block title %}{% trans %}Welcome!{% endtrans %}{% endblock %} - Association des Étudiants UTBM</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{% block description -%}
{% trans trimmed %}
AE UTBM is a voluntary organisation run by UTBM students.
It organises student life at UTBM and manages its student facilities.
{% endtrans %}
{%- endblock %}">
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}"> <link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link rel="stylesheet" href="{{ static('core/base.css') }}"> <link rel="stylesheet" href="{{ static('core/base.css') }}">
<link rel="stylesheet" href="{{ static('core/style.scss') }}"> <link rel="stylesheet" href="{{ static('core/style.scss') }}">

View File

@@ -5,12 +5,16 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% if page_list %}
<h3>{% trans %}Page list{% endtrans %}</h3> <h3>{% trans %}Page list{% endtrans %}</h3>
<ul> <ul>
{% for p in page_list %} {% for p in page_list %}
<li><a href="{{ p.get_absolute_url() }}">{{ p.display_name }}</a></li> <li><a href="{{ p.get_absolute_url() }}">{{ p.get_display_name() }}</a></li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %}
{% trans %}There is no page in this website.{% endtrans %}
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -39,8 +39,9 @@ from django.forms import (
DateInput, DateInput,
DateTimeInput, DateTimeInput,
TextInput, TextInput,
Widget,
) )
from django.utils.timezone import localtime, now from django.utils.timezone import 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
@@ -114,7 +115,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 ValidationError(_("Ensure this timestamp is set in the future")) raise ValueError(_("Ensure this timestamp is set in the future"))
class FutureDateTimeField(forms.DateTimeField): class FutureDateTimeField(forms.DateTimeField):
@@ -122,8 +123,8 @@ class FutureDateTimeField(forms.DateTimeField):
default_validators = [validate_future_timestamp] default_validators = [validate_future_timestamp]
def widget_attrs(self, widget: forms.Widget) -> dict[str, str]: def widget_attrs(self, widget: Widget) -> dict[str, str]:
return {"min": widget.format_value(localtime())} return {"min": widget.format_value(now())}
# Forms # Forms

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

@@ -12,10 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE" # OR WITHIN THE LOCAL FILE "LICENSE"
# #
# #
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import F, OuterRef, Subquery
from django.db.models.functions import Coalesce
# This file contains all the views that concern the page model # This file contains all the views that concern the page model
from django.forms.models import modelform_factory from django.forms.models import modelform_factory
@@ -46,20 +43,6 @@ class CanEditPagePropMixin(CanEditPropMixin):
class PageListView(CanViewMixin, ListView): class PageListView(CanViewMixin, ListView):
model = Page model = Page
template_name = "core/page_list.jinja" template_name = "core/page_list.jinja"
queryset = (
Page.objects.annotate(
display_name=Coalesce(
Subquery(
PageRev.objects.filter(page=OuterRef("id"))
.order_by("-date")
.values("title")[:1]
),
F("name"),
)
)
.prefetch_related("view_groups")
.select_related("parent")
)
class PageView(CanViewMixin, DetailView): class PageView(CanViewMixin, DetailView):

View File

@@ -1,12 +1,8 @@
{% extends "core/base.jinja" %} {% extends "core/base.jinja" %}
{% block title -%} {% block title %}
{% trans %}Eboutic{% endtrans %} {% trans %}Eboutic{% endtrans %}
{%- endblock %} {% endblock %}
{% block description -%}
{% trans %}The online shop of the association.{% endtrans %}
{%- endblock %}
{% block additional_js %} {% block additional_js %}
{# This script contains the code to perform requests to manipulate the {# This script contains the code to perform requests to manipulate the

View File

@@ -2,13 +2,9 @@
{% from 'core/macros.jinja' import user_profile_link %} {% from 'core/macros.jinja' import user_profile_link %}
{% from 'forum/macros.jinja' import display_forum, display_search_bar %} {% from 'forum/macros.jinja' import display_forum, display_search_bar %}
{% block title -%} {% block title %}
{% trans %}Forum{% endtrans %} {% trans %}Forum{% endtrans %}
{%- endblock %} {% endblock %}
{% block description -%}
{% trans %}A forum dedicated to the UTBM students.{% endtrans %}
{%- endblock %}
{% block additional_css %} {% block additional_css %}
<link rel="stylesheet" href="{{ static('forum/css/forum.scss') }}"> <link rel="stylesheet" href="{{ static('forum/css/forum.scss') }}">

View File

@@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-01 18:18+0200\n" "POT-Creation-Date: 2025-08-23 15:30+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 reservation/models.py #: forum/models.py
msgid "description" msgid "description"
msgstr "description" msgstr "description"
@@ -306,10 +306,6 @@ msgstr "Utilisateur non enregistré"
msgid "Club list" msgid "Club list"
msgstr "Liste des clubs" msgstr "Liste des clubs"
#: club/templates/club/club_list.jinja
msgid "The list of all clubs existing at UTBM."
msgstr "La liste de tous les clubs existants à l'UTBM"
#: club/templates/club/club_list.jinja #: club/templates/club/club_list.jinja
msgid "inactive" msgid "inactive"
msgstr "inactif" msgstr "inactif"
@@ -519,18 +515,6 @@ 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 : "
@@ -771,7 +755,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 reservation/models.py trombi/models.py #: com/models.py pedagogy/models.py trombi/models.py
msgid "author" msgid "author"
msgstr "auteur" msgstr "auteur"
@@ -917,7 +901,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/views.py #: com/templates/com/news_list.jinja com/views.py
msgid "News" msgid "News"
msgstr "Nouvelles" msgstr "Nouvelles"
@@ -1051,7 +1035,7 @@ msgstr "Liens"
msgid "Our services" msgid "Our services"
msgstr "Nos services" msgstr "Nos services"
#: com/templates/com/news_list.jinja #: com/templates/com/news_list.jinja pedagogy/templates/pedagogy/guide.jinja
msgid "UV Guide" msgid "UV Guide"
msgstr "Guide des UVs" msgstr "Guide des UVs"
@@ -1059,11 +1043,6 @@ 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"
@@ -1726,12 +1705,8 @@ msgid "500, Server Error"
msgstr "500, Erreur Serveur" msgstr "500, Erreur Serveur"
#: core/templates/core/base.jinja #: core/templates/core/base.jinja
msgid "" msgid "Welcome!"
"AE UTBM is a voluntary organisation run by UTBM students. It organises " msgstr "Bienvenue !"
"student life at UTBM and manages its student facilities."
msgstr ""
"L'AE UTBM est une association bénévole gérée par les étudiants de "
"l'UTBM. Elle organise la vie étudiante de l'UTBM et gère ses lieux de vie."
#: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja #: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja
msgid "Contacts" msgid "Contacts"
@@ -1893,7 +1868,6 @@ 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"
@@ -3018,7 +2992,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 reservation/models.py #: counter/models.py pedagogy/models.py
msgid "comment" msgid "comment"
msgstr "commentaire" msgstr "commentaire"
@@ -3845,10 +3819,6 @@ msgstr ""
msgid "Pay with Sith account" msgid "Pay with Sith account"
msgstr "Payer avec un compte AE" msgstr "Payer avec un compte AE"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "The online shop of the association."
msgstr "La boutique en ligne de l'association."
#: eboutic/templates/eboutic/eboutic_main.jinja #: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Clear" msgid "Clear"
msgstr "Vider" msgstr "Vider"
@@ -4178,10 +4148,6 @@ msgstr "Message supprimé ou non-visible."
msgid "Order by date" msgid "Order by date"
msgstr "Trier par date" msgstr "Trier par date"
#: forum/templates/forum/main.jinja
msgid "A forum dedicated to the UTBM students."
msgstr "Un forum dédié aux étudiants de l'UTBM."
#: forum/templates/forum/main.jinja #: forum/templates/forum/main.jinja
msgid "View last unread messages" msgid "View last unread messages"
msgstr "Voir les derniers messages non lus" msgstr "Voir les derniers messages non lus"
@@ -4408,14 +4374,6 @@ msgstr "signaler"
msgid "reporter" msgid "reporter"
msgstr "signalant" msgstr "signalant"
#: pedagogy/templates/pedagogy/guide.jinja
msgid "UE Guide"
msgstr "Guide des UEs"
#: pedagogy/templates/pedagogy/guide.jinja
msgid "A guide of courses available at UTBM."
msgstr "Un guide de tous les cours disponibles à l'UTBM."
#: pedagogy/templates/pedagogy/guide.jinja #: pedagogy/templates/pedagogy/guide.jinja
#, python-format #, python-format
msgid "%(display_name)s" msgid "%(display_name)s"
@@ -4573,78 +4531,6 @@ 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é"
@@ -4780,11 +4666,6 @@ msgstr "Demande de retrait d'image"
msgid "Request removal" msgid "Request removal"
msgstr "Demander le retrait" msgstr "Demander le retrait"
#: sas/templates/sas/main.jinja
msgid "See all the photos taken during events organised by the AE."
msgstr ""
"Retrouvez toutes les photos prises lors des événements organisés par l'AE."
#: sas/templates/sas/main.jinja #: sas/templates/sas/main.jinja
msgid "You must be logged in to see the SAS." msgid "You must be logged in to see the SAS."
msgstr "Vous devez être connecté pour voir les photos." msgstr "Vous devez être connecté pour voir les photos."

View File

@@ -251,14 +251,6 @@ 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"

105
package-lock.json generated
View File

@@ -9,18 +9,14 @@
"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.17", "@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.17", "@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/icalendar": "^6.1.17", "@fullcalendar/icalendar": "^6.1.15",
"@fullcalendar/interaction": "^6.1.17", "@fullcalendar/list": "^6.1.15",
"@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",
@@ -33,7 +29,6 @@
"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",
@@ -55,17 +50,11 @@
"@types/jquery": "^3.5.31", "@types/jquery": "^3.5.31",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.2.6", "vite": "^6.3.6",
"vite-bundle-visualizer": "^1.2.1", "vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.1.2" "vite-plugin-static-copy": "^3.1.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",
@@ -2283,15 +2272,6 @@
"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.19", "version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.19.tgz", "resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.19.tgz",
@@ -2301,67 +2281,6 @@
"@fullcalendar/core": "~6.1.19" "@fullcalendar/core": "~6.1.19"
} }
}, },
"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",
@@ -4265,14 +4184,6 @@
"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.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.6.tgz", "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.6.tgz",
@@ -5826,9 +5737,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.3.5", "version": "6.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@@ -21,8 +21,7 @@
"#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",
@@ -36,23 +35,19 @@
"@types/jquery": "^3.5.31", "@types/jquery": "^3.5.31",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.2.6", "vite": "^6.3.6",
"vite-bundle-visualizer": "^1.2.1", "vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.1.2" "vite-plugin-static-copy": "^3.1.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.17", "@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.17", "@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/icalendar": "^6.1.17", "@fullcalendar/icalendar": "^6.1.15",
"@fullcalendar/interaction": "^6.1.17", "@fullcalendar/list": "^6.1.15",
"@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",
@@ -65,7 +60,6 @@
"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

@@ -2,13 +2,9 @@
{% from 'core/macros.jinja' import paginate_alpine %} {% from 'core/macros.jinja' import paginate_alpine %}
{% block title %} {% block title %}
{% trans %}UE Guide{% endtrans %} {% trans %}UV Guide{% endtrans %}
{% endblock %} {% endblock %}
{% block description -%}
{% trans %}A guide of courses available at UTBM.{% endtrans %}
{%- endblock %}
{% block additional_css %} {% block additional_css %}
<link rel="stylesheet" href="{{ static('pedagogy/css/pedagogy.scss') }}"> <link rel="stylesheet" href="{{ static('pedagogy/css/pedagogy.scss') }}">
{% endblock %} {% endblock %}

View File

@@ -1,19 +0,0 @@
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"

View File

@@ -1,64 +0,0 @@
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)

View File

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

View File

@@ -1,60 +0,0 @@
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

@@ -1,117 +0,0 @@
# 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

@@ -1,100 +0,0 @@
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."))

View File

@@ -1,46 +0,0 @@
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

@@ -1,137 +0,0 @@
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

@@ -1,39 +0,0 @@
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

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

View File

@@ -1,39 +0,0 @@
#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

@@ -1,51 +0,0 @@
<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

@@ -1,27 +0,0 @@
{% 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

@@ -1,33 +0,0 @@
{% 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

@@ -1,113 +0,0 @@
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

@@ -1,207 +0,0 @@
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

View File

@@ -1,19 +0,0 @@
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"
),
]

View File

@@ -1,72 +0,0 @@
# 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

@@ -8,10 +8,6 @@
{% trans %}SAS{% endtrans %} {% trans %}SAS{% endtrans %}
{% endblock %} {% endblock %}
{% block description -%}
{% trans %}See all the photos taken during events organised by the AE.{% endtrans %}
{%- endblock %}
{% set is_sas_admin = user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %} {% set is_sas_admin = user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID) %}
{% from "sas/macros.jinja" import display_album %} {% from "sas/macros.jinja" import display_album %}

View File

@@ -99,10 +99,9 @@ INSTALLED_APPS = (
"django.contrib.auth", "django.contrib.auth",
"django.contrib.contenttypes", "django.contrib.contenttypes",
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.sitemaps",
"django.contrib.sites",
"django.contrib.messages", "django.contrib.messages",
"staticfiles", "staticfiles",
"django.contrib.sites",
"honeypot", "honeypot",
"django_jinja", "django_jinja",
"ninja_extra", "ninja_extra",
@@ -123,7 +122,6 @@ INSTALLED_APPS = (
"trombi", "trombi",
"matmat", "matmat",
"pedagogy", "pedagogy",
"reservation",
"galaxy", "galaxy",
"antispam", "antispam",
"api", "api",
@@ -275,7 +273,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" LANGUAGE_CODE = "fr-FR"
LANGUAGES = [("en", _("English")), ("fr", _("French"))] LANGUAGES = [("en", _("English")), ("fr", _("French"))]
@@ -688,10 +686,8 @@ SITH_NOTIFICATIONS = [
# The keys are the notification names as found in SITH_NOTIFICATIONS, and the # The keys are the notification names as found in SITH_NOTIFICATIONS, and the
# values are the callback function to update the notifs. # values are the callback function to update the notifs.
# The callback must take the notif object as first and single argument. # The callback must take the notif object as first and single argument.
# If a notification is permanent but requires no post-action, set the
# callback import string as None
SITH_PERMANENT_NOTIFICATIONS = { SITH_PERMANENT_NOTIFICATIONS = {
"NEWS_MODERATION": None, "NEWS_MODERATION": "com.models.news_notification_callback",
"SAS_MODERATION": "sas.models.sas_notification_callback", "SAS_MODERATION": "sas.models.sas_notification_callback",
} }

View File

@@ -1,46 +0,0 @@
from django.conf import settings
from django.contrib.sitemaps import Sitemap
from django.db.models import OuterRef, Subquery
from django.urls import reverse
from club.models import Club
from core.models import Page, PageRev
class SithSitemap(Sitemap):
def items(self):
return [
"core:index",
"eboutic:main",
"sas:main",
"forum:main",
"club:club_list",
"election:list",
]
def location(self, item):
return reverse(item)
class PagesSitemap(Sitemap):
def items(self):
return (
Page.objects.filter(view_groups=settings.SITH_GROUP_PUBLIC_ID)
.exclude(revisions=None, _full_name__startswith="club")
.annotate(
lastmod=Subquery(
PageRev.objects.filter(page=OuterRef("pk"))
.values("date")
.order_by("-date")[:1]
)
)
.all()
)
def lastmod(self, item: Page):
return item.lastmod
class ClubSitemap(Sitemap):
def items(self):
return Club.objects.filter(is_active=True)

View File

@@ -15,24 +15,20 @@
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.contrib.sitemaps.views import sitemap
from django.http import Http404 from django.http import Http404
from django.urls import include, path from django.urls import include, path
from django.views.decorators.cache import cache_page
from django.views.i18n import JavaScriptCatalog from django.views.i18n import JavaScriptCatalog
from api.urls import api from api.urls import api
from sith.sitemap import ClubSitemap, PagesSitemap, SithSitemap
js_info_dict = {"packages": ("sith",)} js_info_dict = {"packages": ("sith",)}
handler403 = "core.views.forbidden" handler403 = "core.views.forbidden"
handler404 = "core.views.not_found" handler404 = "core.views.not_found"
handler500 = "core.views.internal_servor_error" handler500 = "core.views.internal_servor_error"
sitemaps = {"sith": SithSitemap, "pages": PagesSitemap, "clubs": ClubSitemap}
urlpatterns = [ urlpatterns = [
path("", include(("core.urls", "core"), namespace="core")), path("", include(("core.urls", "core"), namespace="core")),
path("sitemap.xml", cache_page(86400)(sitemap), {"sitemaps": sitemaps}),
path("api/", api.urls), path("api/", api.urls),
path("rootplace/", include(("rootplace.urls", "rootplace"), namespace="rootplace")), path("rootplace/", include(("rootplace.urls", "rootplace"), namespace="rootplace")),
path( path(
@@ -49,10 +45,6 @@ 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

@@ -18,8 +18,7 @@
"#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/*"]
} }
} }
} }