mirror of
https://github.com/ae-utbm/sith.git
synced 2025-07-12 12:59:24 +00:00
Compare commits
38 Commits
galaxy
...
room-reser
Author | SHA1 | Date | |
---|---|---|---|
dfb545b15e | |||
f9fa4c0643 | |||
72bb4788f2 | |||
0baaf69714 | |||
edd8b9a385 | |||
a322a0895a | |||
d4e853fa60 | |||
21416dc27a | |||
b2d97ab138 | |||
f092d44ef7 | |||
08abc62e56 | |||
5f2caf9d61 | |||
c45be81bb3 | |||
af014e419f | |||
c177ef2a3a | |||
6cf8910626 | |||
eb4fbcbda4 | |||
570510f18d | |||
7f371984d8 | |||
abf7bf6bfa | |||
02ef8fdb88 | |||
a7f4630d13 | |||
c7087c6e7e | |||
f38926c4a3 | |||
9a19f34ea2 | |||
67884017f8 | |||
f474edc84f
|
|||
f5a8228358 | |||
59a714af9f | |||
9049d8779c | |||
d111023363
|
|||
cdfa76ad57 | |||
88b70bf51f | |||
ca593c7d81
|
|||
94bdc5e615
|
|||
7d454749e0
|
|||
10d5b9d63f
|
|||
cc96c93d23
|
@ -1,25 +1,63 @@
|
||||
{% 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 %}
|
||||
<h3>{% trans %}Club tools{% endtrans %}</h3>
|
||||
<h3>{% trans %}Club tools{% endtrans %} ({{ club.name }})</h3>
|
||||
<div>
|
||||
<h4>{% trans %}Communication:{% endtrans %}</h4>
|
||||
<ul>
|
||||
<li> <a href="{{ url('com:news_new') }}?club={{ object.id }}">{% trans %}Create a news{% endtrans %}</a></li>
|
||||
<li> <a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">{% trans %}Post in the Weekmail{% endtrans %}</a></li>
|
||||
<li>
|
||||
<a href="{{ url('com:news_new') }}?club={{ object.id }}">
|
||||
{% trans %}Create a news{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">
|
||||
{% trans %}Post in the Weekmail{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
{% if object.trombi %}
|
||||
<li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li>
|
||||
<li>
|
||||
<a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">
|
||||
{% trans %}Edit Trombi{% endtrans %}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<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('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>
|
||||
{% endif %}
|
||||
</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>
|
||||
<ul>
|
||||
{% for c in object.counters.filter(type="OFFICE") %}
|
||||
<li>{{ c }}:
|
||||
<a href="{{ url('counter:details', counter_id=c.id) }}">View</a>
|
||||
<a href="{{ url('counter:admin', counter_id=c.id) }}">Edit</a>
|
||||
{% for counter in counters %}
|
||||
<li>{{ counter }}:
|
||||
<a href="{{ url('counter:details', counter_id=counter.id) }}">View</a>
|
||||
<a href="{{ url('counter:admin', counter_id=counter.id) }}">Edit</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
@ -241,6 +241,12 @@ class ClubToolsView(ClubTabsMixin, CanEditMixin, DetailView):
|
||||
template_name = "club/club_tools.jinja"
|
||||
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):
|
||||
"""View of a club's members."""
|
||||
|
@ -81,9 +81,8 @@
|
||||
}
|
||||
|
||||
#links_content {
|
||||
overflow: auto;
|
||||
box-shadow: $shadow-color 1px 1px 1px;
|
||||
height: 20em;
|
||||
padding: .5rem;
|
||||
|
||||
h4 {
|
||||
margin-left: 5px;
|
||||
|
@ -1,13 +1,11 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
{% from "com/macros.jinja" import news_moderation_alert %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}News{% endtrans %}
|
||||
{% endblock %}
|
||||
{% block title %}AE UTBM{% endblock %}
|
||||
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('com/components/ics-calendar.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/components/calendar.scss') }}">
|
||||
|
||||
{# Atom feed discovery, not really css but also goes there #}
|
||||
<link rel="alternate" type="application/rss+xml" title="{% trans %}News feed{% endtrans %}" href="{{ url("com:news_feed") }}">
|
||||
@ -213,6 +211,12 @@
|
||||
<i class="fa-solid fa-magnifying-glass fa-xl"></i>
|
||||
<a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
|
||||
</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>
|
||||
<i class="fa-solid fa-check-to-slot fa-xl"></i>
|
||||
<a href="{{ url("election:list") }}">{% trans %}Elections{% endtrans %}</a>
|
||||
|
@ -4,13 +4,13 @@
|
||||
VERSION="$1"
|
||||
|
||||
# Cleanup env vars for auto discovery mechanism
|
||||
export CPATH=
|
||||
export LIBRARY_PATH=
|
||||
export CFLAGS=
|
||||
export LDFLAGS=
|
||||
export CCFLAGS=
|
||||
export CXXFLAGS=
|
||||
export CPPFLAGS=
|
||||
unset CPATH
|
||||
unset LIBRARY_PATH
|
||||
unset CFLAGS
|
||||
unset LDFLAGS
|
||||
unset CCFLAGS
|
||||
unset CXXFLAGS
|
||||
unset CPPFLAGS
|
||||
|
||||
# prepare
|
||||
rm -rf "$VIRTUAL_ENV/packages"
|
||||
|
@ -59,6 +59,7 @@ class PopulatedGroups(NamedTuple):
|
||||
counter_admin: Group
|
||||
accounting_admin: Group
|
||||
pedagogy_admin: Group
|
||||
campus_admin: Group
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@ -784,13 +785,17 @@ class Command(BaseCommand):
|
||||
# public has no permission.
|
||||
# Its purpose is not to link users to permissions,
|
||||
# but to other objects (like products)
|
||||
public_group = Group.objects.create(name="Public")
|
||||
public_group = Group.objects.create(name="Publique")
|
||||
|
||||
subscribers = Group.objects.create(name="Subscribers")
|
||||
subscribers = Group.objects.create(name="Cotisants")
|
||||
subscribers.permissions.add(
|
||||
*list(perms.filter(codename__in=["add_news", "add_uvcomment"]))
|
||||
*list(
|
||||
perms.filter(
|
||||
codename__in=["add_news", "add_uvcomment", "view_reservationslot"]
|
||||
)
|
||||
)
|
||||
)
|
||||
old_subscribers = Group.objects.create(name="Old subscribers")
|
||||
old_subscribers = Group.objects.create(name="Anciens cotisants")
|
||||
old_subscribers.permissions.add(
|
||||
*list(
|
||||
perms.filter(
|
||||
@ -812,7 +817,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
)
|
||||
accounting_admin = Group.objects.create(
|
||||
name="Accounting admin", is_manually_manageable=True
|
||||
name="Admin comptabilité", is_manually_manageable=True
|
||||
)
|
||||
accounting_admin.permissions.add(
|
||||
*list(
|
||||
@ -833,7 +838,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
)
|
||||
com_admin = Group.objects.create(
|
||||
name="Communication admin", is_manually_manageable=True
|
||||
name="Admin communication", is_manually_manageable=True
|
||||
)
|
||||
com_admin.permissions.add(
|
||||
*list(
|
||||
@ -841,7 +846,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
)
|
||||
counter_admin = Group.objects.create(
|
||||
name="Counter admin", is_manually_manageable=True
|
||||
name="Admin comptoirs", is_manually_manageable=True
|
||||
)
|
||||
counter_admin.permissions.add(
|
||||
*list(
|
||||
@ -851,14 +856,14 @@ class Command(BaseCommand):
|
||||
)
|
||||
)
|
||||
)
|
||||
sas_admin = Group.objects.create(name="SAS admin", is_manually_manageable=True)
|
||||
sas_admin = Group.objects.create(name="Admin SAS", is_manually_manageable=True)
|
||||
sas_admin.permissions.add(
|
||||
*list(
|
||||
perms.filter(content_type__app_label="sas").values_list("pk", flat=True)
|
||||
)
|
||||
)
|
||||
forum_admin = Group.objects.create(
|
||||
name="Forum admin", is_manually_manageable=True
|
||||
name="Admin forum", is_manually_manageable=True
|
||||
)
|
||||
forum_admin.permissions.add(
|
||||
*list(
|
||||
@ -868,7 +873,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
)
|
||||
pedagogy_admin = Group.objects.create(
|
||||
name="Pedagogy admin", is_manually_manageable=True
|
||||
name="Admin pédagogie", is_manually_manageable=True
|
||||
)
|
||||
pedagogy_admin.permissions.add(
|
||||
*list(
|
||||
@ -877,6 +882,16 @@ class Command(BaseCommand):
|
||||
.values_list("pk", flat=True)
|
||||
)
|
||||
)
|
||||
campus_admin = Group.objects.create(
|
||||
name="Respo site", is_manually_manageable=True
|
||||
)
|
||||
campus_admin.permissions.add(
|
||||
*counter_admin.permissions.values_list("pk", flat=True),
|
||||
*perms.filter(content_type__app_label="reservation").values_list(
|
||||
"pk", flat=True
|
||||
),
|
||||
)
|
||||
|
||||
self.reset_index("core", "auth")
|
||||
|
||||
return PopulatedGroups(
|
||||
@ -889,6 +904,7 @@ class Command(BaseCommand):
|
||||
accounting_admin=accounting_admin,
|
||||
sas_admin=sas_admin,
|
||||
pedagogy_admin=pedagogy_admin,
|
||||
campus_admin=campus_admin,
|
||||
)
|
||||
|
||||
def _create_ban_groups(self):
|
||||
|
@ -1,6 +1,7 @@
|
||||
import random
|
||||
from datetime import date, timedelta
|
||||
from datetime import timezone as tz
|
||||
from math import ceil
|
||||
from typing import Iterator
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
@ -24,6 +25,7 @@ from counter.models import (
|
||||
)
|
||||
from forum.models import Forum, ForumMessage, ForumTopic
|
||||
from pedagogy.models import UV
|
||||
from reservation.models import ReservationSlot, Room
|
||||
from subscription.models import Subscription
|
||||
|
||||
|
||||
@ -40,45 +42,20 @@ class Command(BaseCommand):
|
||||
|
||||
self.stdout.write("Creating users...")
|
||||
users = self.create_users()
|
||||
# len(subscribers) is approximately 480
|
||||
subscribers = random.sample(users, k=int(0.8 * len(users)))
|
||||
self.stdout.write("Creating subscriptions...")
|
||||
self.create_subscriptions(subscribers)
|
||||
self.stdout.write("Creating club memberships...")
|
||||
users_qs = User.objects.filter(id__in=[s.id for s in subscribers])
|
||||
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))),
|
||||
)
|
||||
self.create_club_memberships(subscribers)
|
||||
self.stdout.write("Creating rooms and reservation...")
|
||||
self.create_resources_and_reservations(random.sample(subscribers, k=40))
|
||||
self.stdout.write("Creating uvs...")
|
||||
self.create_uvs()
|
||||
self.stdout.write("Creating products...")
|
||||
self.create_products()
|
||||
self.stdout.write("Creating sales and refills...")
|
||||
sellers = random.sample(list(User.objects.all()), 100)
|
||||
sellers = list(User.objects.order_by("?")[:100])
|
||||
self.create_sales(sellers)
|
||||
self.stdout.write("Creating permanences...")
|
||||
self.create_permanences(sellers)
|
||||
@ -188,6 +165,97 @@ class Command(BaseCommand):
|
||||
memberships = Membership.objects.bulk_create(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):
|
||||
root = User.objects.get(username="root")
|
||||
categories = ["CS", "TM", "OM", "QC", "EC"]
|
||||
@ -238,7 +306,13 @@ class Command(BaseCommand):
|
||||
ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID)
|
||||
other_clubs = random.sample(list(Club.objects.all()), k=3)
|
||||
groups = list(
|
||||
Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"])
|
||||
Group.objects.filter(
|
||||
id__in=[
|
||||
settings.SITH_GROUP_SUBSCRIBERS_ID,
|
||||
settings.SITH_GROUP_OLD_SUBSCRIBERS_ID,
|
||||
settings.SITH_GROUP_PUBLIC_ID,
|
||||
]
|
||||
)
|
||||
)
|
||||
counters = list(
|
||||
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"])
|
||||
@ -379,7 +453,7 @@ class Command(BaseCommand):
|
||||
Permanency.objects.bulk_create(perms)
|
||||
|
||||
def create_forums(self):
|
||||
forumers = random.sample(list(User.objects.all()), 100)
|
||||
forumers = list(User.objects.order_by("?")[:100])
|
||||
most_actives = random.sample(forumers, 10)
|
||||
categories = list(Forum.objects.filter(is_category=True))
|
||||
new_forums = [
|
||||
@ -397,7 +471,7 @@ class Command(BaseCommand):
|
||||
for _ in range(100)
|
||||
]
|
||||
ForumTopic.objects.bulk_create(new_topics)
|
||||
topics = list(ForumTopic.objects.all())
|
||||
topics = list(ForumTopic.objects.values_list("id", flat=True))
|
||||
|
||||
def get_author():
|
||||
if random.random() > 0.5:
|
||||
@ -405,7 +479,7 @@ class Command(BaseCommand):
|
||||
return random.choice(forumers)
|
||||
|
||||
messages = []
|
||||
for t in topics:
|
||||
for topic_id in topics:
|
||||
nb_messages = max(1, int(random.normalvariate(mu=90, sigma=50)))
|
||||
dates = sorted(
|
||||
[
|
||||
@ -417,7 +491,7 @@ class Command(BaseCommand):
|
||||
messages.extend(
|
||||
[
|
||||
ForumMessage(
|
||||
topic=t,
|
||||
topic_id=topic_id,
|
||||
author=get_author(),
|
||||
date=d,
|
||||
message="\n\n".join(
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { morph } from "@alpinejs/morph";
|
||||
import sort from "@alpinejs/sort";
|
||||
import Alpine from "alpinejs";
|
||||
|
||||
Alpine.plugin(sort);
|
||||
Alpine.plugin([sort, morph]);
|
||||
window.Alpine = Alpine;
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import htmx from "htmx.org";
|
||||
import "htmx-ext-alpine-morph";
|
||||
|
||||
document.body.addEventListener("htmx:beforeRequest", (event) => {
|
||||
event.target.ariaBusy = true;
|
||||
|
@ -1,274 +0,0 @@
|
||||
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
|
||||
import cytoscape from "cytoscape";
|
||||
import cxtmenu from "cytoscape-cxtmenu";
|
||||
import klay from "cytoscape-klay";
|
||||
import { familyGetFamilyGraph } from "#openapi";
|
||||
|
||||
cytoscape.use(klay);
|
||||
cytoscape.use(cxtmenu);
|
||||
|
||||
async function getGraphData(userId, godfathersDepth, godchildrenDepth) {
|
||||
const data = (
|
||||
await familyGetFamilyGraph({
|
||||
path: {
|
||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||
user_id: userId,
|
||||
},
|
||||
query: {
|
||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||
godfathers_depth: godfathersDepth,
|
||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||
godchildren_depth: godchildrenDepth,
|
||||
},
|
||||
})
|
||||
).data;
|
||||
return [
|
||||
...data.users.map((user) => {
|
||||
return { data: user };
|
||||
}),
|
||||
...data.relationships.map((rel) => {
|
||||
return {
|
||||
data: { source: rel.godfather, target: rel.godchild },
|
||||
};
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function createGraph(container, data, activeUserId) {
|
||||
const cy = cytoscape({
|
||||
boxSelectionEnabled: false,
|
||||
autounselectify: true,
|
||||
|
||||
container,
|
||||
elements: data,
|
||||
minZoom: 0.5,
|
||||
|
||||
style: [
|
||||
// the stylesheet for the graph
|
||||
{
|
||||
selector: "node",
|
||||
style: {
|
||||
label: "data(display_name)",
|
||||
"background-image": "data(profile_pict)",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
"background-fit": "cover",
|
||||
"background-repeat": "no-repeat",
|
||||
shape: "ellipse",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
selector: "edge",
|
||||
style: {
|
||||
width: 5,
|
||||
"line-color": "#ccc",
|
||||
"target-arrow-color": "#ccc",
|
||||
"target-arrow-shape": "triangle",
|
||||
"curve-style": "bezier",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
selector: ".traversed",
|
||||
style: {
|
||||
"border-width": "5px",
|
||||
"border-style": "solid",
|
||||
"border-color": "red",
|
||||
"target-arrow-color": "red",
|
||||
"line-color": "red",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
selector: ".not-traversed",
|
||||
style: {
|
||||
"line-opacity": "0.5",
|
||||
"background-opacity": "0.5",
|
||||
"background-image-opacity": "0.5",
|
||||
},
|
||||
},
|
||||
],
|
||||
layout: {
|
||||
name: "klay",
|
||||
nodeDimensionsIncludeLabels: true,
|
||||
fit: true,
|
||||
klay: {
|
||||
addUnnecessaryBendpoints: true,
|
||||
direction: "DOWN",
|
||||
nodePlacement: "INTERACTIVE",
|
||||
layoutHierarchy: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const activeUser = cy.getElementById(activeUserId).style("shape", "rectangle");
|
||||
/* Reset graph */
|
||||
const resetGraph = () => {
|
||||
cy.elements((element) => {
|
||||
if (element.hasClass("traversed")) {
|
||||
element.removeClass("traversed");
|
||||
}
|
||||
if (element.hasClass("not-traversed")) {
|
||||
element.removeClass("not-traversed");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onNodeTap = (el) => {
|
||||
resetGraph();
|
||||
/* Create path on graph if selected isn't the targeted user */
|
||||
if (el === activeUser) {
|
||||
return;
|
||||
}
|
||||
cy.elements((element) => {
|
||||
element.addClass("not-traversed");
|
||||
});
|
||||
|
||||
for (const traversed of cy.elements().aStar({
|
||||
root: el,
|
||||
goal: activeUser,
|
||||
}).path) {
|
||||
traversed.removeClass("not-traversed");
|
||||
traversed.addClass("traversed");
|
||||
}
|
||||
};
|
||||
|
||||
cy.on("tap", "node", (tapped) => {
|
||||
onNodeTap(tapped.target);
|
||||
});
|
||||
cy.zoomingEnabled(false);
|
||||
|
||||
/* Add context menu */
|
||||
cy.cxtmenu({
|
||||
selector: "node",
|
||||
|
||||
commands: [
|
||||
{
|
||||
content: '<i class="fa fa-external-link fa-2x"></i>',
|
||||
select: (el) => {
|
||||
window.open(el.data().profile_url, "_blank").focus();
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
content: '<span class="fa fa-mouse-pointer fa-2x"></span>',
|
||||
select: (el) => {
|
||||
onNodeTap(el);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
content: '<i class="fa fa-eraser fa-2x"></i>',
|
||||
select: (_) => {
|
||||
resetGraph();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return cy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef FamilyGraphConfig
|
||||
* @property {number} activeUser Id of the user to fetch the tree from
|
||||
* @property {number} depthMin Minimum tree depth for godfathers and godchildren
|
||||
* @property {number} depthMax Maximum tree depth for godfathers and godchildren
|
||||
**/
|
||||
|
||||
/**
|
||||
* Create a family graph of an user
|
||||
* @param {FamilyGraphConfig} config
|
||||
**/
|
||||
window.loadFamilyGraph = (config) => {
|
||||
document.addEventListener("alpine:init", () => {
|
||||
const defaultDepth = 2;
|
||||
|
||||
function getInitialDepth(prop) {
|
||||
const value = Number.parseInt(initialUrlParams.get(prop));
|
||||
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
|
||||
return defaultDepth;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
Alpine.data("graph", () => ({
|
||||
loading: false,
|
||||
godfathersDepth: getInitialDepth("godfathersDepth"),
|
||||
godchildrenDepth: getInitialDepth("godchildrenDepth"),
|
||||
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
|
||||
graph: undefined,
|
||||
graphData: {},
|
||||
|
||||
async init() {
|
||||
const delayedFetch = Alpine.debounce(async () => {
|
||||
await this.fetchGraphData();
|
||||
}, 100);
|
||||
for (const param of ["godfathersDepth", "godchildrenDepth"]) {
|
||||
this.$watch(param, async (value) => {
|
||||
if (value < config.depthMin || value > config.depthMax) {
|
||||
return;
|
||||
}
|
||||
updateQueryString(param, value, History.Replace);
|
||||
await delayedFetch();
|
||||
});
|
||||
}
|
||||
this.$watch("reverse", async (value) => {
|
||||
updateQueryString("reverse", value, History.Replace);
|
||||
await this.reverseGraph();
|
||||
});
|
||||
this.$watch("graphData", async () => {
|
||||
this.generateGraph();
|
||||
if (this.reverse) {
|
||||
await this.reverseGraph();
|
||||
}
|
||||
});
|
||||
await this.fetchGraphData();
|
||||
},
|
||||
|
||||
screenshot() {
|
||||
const link = document.createElement("a");
|
||||
link.href = this.graph.jpg();
|
||||
link.download = interpolate(
|
||||
gettext("family_tree.%(extension)s"),
|
||||
{ extension: "jpg" },
|
||||
true,
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.reverse = false;
|
||||
this.godfathersDepth = defaultDepth;
|
||||
this.godchildrenDepth = defaultDepth;
|
||||
},
|
||||
|
||||
async reverseGraph() {
|
||||
this.graph.elements((el) => {
|
||||
el.position({ x: -el.position().x, y: -el.position().y });
|
||||
});
|
||||
this.graph.center(this.graph.elements());
|
||||
},
|
||||
|
||||
async fetchGraphData() {
|
||||
this.graphData = await getGraphData(
|
||||
config.activeUser,
|
||||
this.godfathersDepth,
|
||||
this.godchildrenDepth,
|
||||
);
|
||||
},
|
||||
|
||||
generateGraph() {
|
||||
this.loading = true;
|
||||
this.graph = createGraph(
|
||||
$(this.$refs.graph),
|
||||
this.graphData,
|
||||
config.activeUser,
|
||||
);
|
||||
this.loading = false;
|
||||
},
|
||||
}));
|
||||
});
|
||||
};
|
287
core/static/bundled/user/family-graph-index.ts
Normal file
287
core/static/bundled/user/family-graph-index.ts
Normal file
@ -0,0 +1,287 @@
|
||||
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
|
||||
import cytoscape, {
|
||||
type ElementDefinition,
|
||||
type NodeSingular,
|
||||
type Singular,
|
||||
} from "cytoscape";
|
||||
import cxtmenu from "cytoscape-cxtmenu";
|
||||
import klay, { type KlayLayoutOptions } from "cytoscape-klay";
|
||||
import { type UserProfileSchema, familyGetFamilyGraph } from "#openapi";
|
||||
|
||||
cytoscape.use(klay);
|
||||
cytoscape.use(cxtmenu);
|
||||
|
||||
type GraphData = (
|
||||
| { data: UserProfileSchema }
|
||||
| { data: { source: number; target: number } }
|
||||
)[];
|
||||
|
||||
function isMobile() {
|
||||
return window.innerWidth < 500;
|
||||
}
|
||||
|
||||
async function getGraphData(
|
||||
userId: number,
|
||||
godfathersDepth: number,
|
||||
godchildrenDepth: number,
|
||||
): Promise<GraphData> {
|
||||
const data = (
|
||||
await familyGetFamilyGraph({
|
||||
path: {
|
||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||
user_id: userId,
|
||||
},
|
||||
query: {
|
||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||
godfathers_depth: godfathersDepth,
|
||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||
godchildren_depth: godchildrenDepth,
|
||||
},
|
||||
})
|
||||
).data;
|
||||
return [
|
||||
...data.users.map((user) => {
|
||||
return { data: user };
|
||||
}),
|
||||
...data.relationships.map((rel) => {
|
||||
return {
|
||||
data: { source: rel.godfather, target: rel.godchild },
|
||||
};
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function createGraph(container: HTMLDivElement, data: GraphData, activeUserId: number) {
|
||||
const cy = cytoscape({
|
||||
boxSelectionEnabled: false,
|
||||
autounselectify: true,
|
||||
|
||||
container,
|
||||
elements: data as ElementDefinition[],
|
||||
minZoom: 0.5,
|
||||
|
||||
style: [
|
||||
// the stylesheet for the graph
|
||||
{
|
||||
selector: "node",
|
||||
style: {
|
||||
label: "data(display_name)",
|
||||
"background-image": "data(profile_pict)",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
"background-fit": "cover",
|
||||
"background-repeat": "no-repeat",
|
||||
shape: "ellipse",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
selector: "edge",
|
||||
style: {
|
||||
width: 5,
|
||||
"line-color": "#ccc",
|
||||
"target-arrow-color": "#ccc",
|
||||
"target-arrow-shape": "triangle",
|
||||
"curve-style": "bezier",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
selector: ".traversed",
|
||||
style: {
|
||||
"border-width": "5px",
|
||||
"border-style": "solid",
|
||||
"border-color": "red",
|
||||
"target-arrow-color": "red",
|
||||
"line-color": "red",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
selector: ".not-traversed",
|
||||
style: {
|
||||
"line-opacity": 0.5,
|
||||
"background-opacity": 0.5,
|
||||
"background-image-opacity": 0.5,
|
||||
},
|
||||
},
|
||||
],
|
||||
layout: {
|
||||
name: "klay",
|
||||
nodeDimensionsIncludeLabels: true,
|
||||
fit: true,
|
||||
klay: {
|
||||
addUnnecessaryBendpoints: true,
|
||||
direction: "DOWN",
|
||||
nodePlacement: "INTERACTIVE",
|
||||
layoutHierarchy: true,
|
||||
},
|
||||
} as KlayLayoutOptions,
|
||||
});
|
||||
const activeUser = cy
|
||||
.getElementById(activeUserId.toString())
|
||||
.style("shape", "rectangle");
|
||||
/* Reset graph */
|
||||
const resetGraph = () => {
|
||||
cy.elements().removeClass("traversed not-traversed");
|
||||
};
|
||||
|
||||
const onNodeTap = (el: Singular) => {
|
||||
resetGraph();
|
||||
/* Create path on graph if selected isn't the targeted user */
|
||||
if (el === activeUser) {
|
||||
return;
|
||||
}
|
||||
cy.elements().addClass("not-traversed");
|
||||
|
||||
for (const traversed of cy.elements().aStar({
|
||||
root: el,
|
||||
goal: activeUser,
|
||||
}).path) {
|
||||
traversed.removeClass("not-traversed");
|
||||
traversed.addClass("traversed");
|
||||
}
|
||||
};
|
||||
|
||||
cy.on("tap", "node", (tapped) => {
|
||||
onNodeTap(tapped.target);
|
||||
});
|
||||
|
||||
/* Add context menu */
|
||||
cy.cxtmenu({
|
||||
selector: "node",
|
||||
|
||||
commands: [
|
||||
{
|
||||
content: '<i class="fa fa-external-link fa-2x"></i>',
|
||||
select: (el) => {
|
||||
window.open(el.data().profile_url, "_blank").focus();
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
content: '<span class="fa fa-mouse-pointer fa-2x"></span>',
|
||||
select: (el) => {
|
||||
onNodeTap(el);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
content: '<i class="fa fa-eraser fa-2x"></i>',
|
||||
select: (_) => {
|
||||
resetGraph();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return cy;
|
||||
}
|
||||
|
||||
interface FamilyGraphConfig {
|
||||
/**Id of the user to fetch the tree from*/
|
||||
activeUser: number;
|
||||
/**Minimum tree depth for godfathers and godchildren*/
|
||||
depthMin: number;
|
||||
/**Maximum tree depth for godfathers and godchildren*/
|
||||
depthMax: number;
|
||||
}
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
const defaultDepth = 2;
|
||||
|
||||
Alpine.data("graph", (config: FamilyGraphConfig) => ({
|
||||
loading: false,
|
||||
godfathersDepth: 0,
|
||||
godchildrenDepth: 0,
|
||||
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
|
||||
graph: undefined as cytoscape.Core,
|
||||
graphData: {},
|
||||
isZoomEnabled: !isMobile(),
|
||||
|
||||
getInitialDepth(prop: string) {
|
||||
const value = Number.parseInt(initialUrlParams.get(prop));
|
||||
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
|
||||
return defaultDepth;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
|
||||
async init() {
|
||||
this.godfathersDepth = this.getInitialDepth("godfathersDepth");
|
||||
this.godchildrenDepth = this.getInitialDepth("godchildrenDepth");
|
||||
|
||||
const delayedFetch = Alpine.debounce(async () => {
|
||||
await this.fetchGraphData();
|
||||
}, 100);
|
||||
for (const param of ["godfathersDepth", "godchildrenDepth"]) {
|
||||
this.$watch(param, async (value: number) => {
|
||||
if (value < config.depthMin || value > config.depthMax) {
|
||||
return;
|
||||
}
|
||||
updateQueryString(param, value.toString(), History.Replace);
|
||||
await delayedFetch();
|
||||
});
|
||||
}
|
||||
this.$watch("reverse", async (value: number) => {
|
||||
updateQueryString("reverse", value.toString(), History.Replace);
|
||||
await this.reverseGraph();
|
||||
});
|
||||
this.$watch("graphData", async () => {
|
||||
this.generateGraph();
|
||||
if (this.reverse) {
|
||||
await this.reverseGraph();
|
||||
}
|
||||
});
|
||||
this.$watch("isZoomEnabled", () => {
|
||||
this.graph.userZoomingEnabled(this.isZoomEnabled);
|
||||
});
|
||||
await this.fetchGraphData();
|
||||
},
|
||||
|
||||
screenshot() {
|
||||
const link = document.createElement("a");
|
||||
link.href = this.graph.jpg();
|
||||
link.download = interpolate(
|
||||
gettext("family_tree.%(extension)s"),
|
||||
{ extension: "jpg" },
|
||||
true,
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.reverse = false;
|
||||
this.godfathersDepth = defaultDepth;
|
||||
this.godchildrenDepth = defaultDepth;
|
||||
},
|
||||
|
||||
async reverseGraph() {
|
||||
this.graph.elements((el: NodeSingular) => {
|
||||
el.position({ x: -el.position().x, y: -el.position().y });
|
||||
});
|
||||
this.graph.center(this.graph.elements());
|
||||
},
|
||||
|
||||
async fetchGraphData() {
|
||||
this.graphData = await getGraphData(
|
||||
config.activeUser,
|
||||
this.godfathersDepth,
|
||||
this.godchildrenDepth,
|
||||
);
|
||||
},
|
||||
|
||||
generateGraph() {
|
||||
this.loading = true;
|
||||
this.graph = createGraph(
|
||||
this.$refs.graph as HTMLDivElement,
|
||||
this.graphData,
|
||||
config.activeUser,
|
||||
);
|
||||
this.graph.userZoomingEnabled(this.isZoomEnabled);
|
||||
this.loading = false;
|
||||
},
|
||||
}));
|
||||
});
|
38
core/static/bundled/utils/alert-message.ts
Normal file
38
core/static/bundled/utils/alert-message.ts
Normal file
@ -0,0 +1,38 @@
|
||||
interface AlertParams {
|
||||
success?: boolean;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export class AlertMessage {
|
||||
public open: boolean;
|
||||
public success: boolean;
|
||||
public content: string;
|
||||
private timeoutId?: number;
|
||||
private readonly defaultDuration: number;
|
||||
|
||||
constructor(params?: { defaultDuration: number }) {
|
||||
this.open = false;
|
||||
this.content = "";
|
||||
this.timeoutId = null;
|
||||
this.defaultDuration = params?.defaultDuration ?? 2000;
|
||||
}
|
||||
|
||||
public display(message: string, params: AlertParams) {
|
||||
this.clear();
|
||||
this.open = true;
|
||||
this.content = message;
|
||||
this.success = params.success ?? true;
|
||||
this.timeoutId = setTimeout(() => {
|
||||
this.open = false;
|
||||
this.timeoutId = null;
|
||||
}, params.duration ?? this.defaultDuration);
|
||||
}
|
||||
|
||||
public clear() {
|
||||
if (this.timeoutId !== null) {
|
||||
clearTimeout(this.timeoutId);
|
||||
this.timeoutId = null;
|
||||
}
|
||||
this.open = false;
|
||||
}
|
||||
}
|
@ -16,14 +16,74 @@
|
||||
--event-details-padding: 20px;
|
||||
--event-details-border: 1px solid #EEEEEE;
|
||||
--event-details-border-radius: 4px;
|
||||
--event-details-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%);
|
||||
--event-details-box-shadow: 0 6px 20px 4px rgb(0 0 0 / 16%);
|
||||
--event-details-max-width: 600px;
|
||||
}
|
||||
|
||||
ics-calendar {
|
||||
ics-calendar,
|
||||
room-scheduler {
|
||||
border: 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 {
|
||||
z-index: 10;
|
||||
max-width: 1151px;
|
||||
@ -60,82 +120,60 @@ ics-calendar {
|
||||
align-items: start;
|
||||
flex-direction: row;
|
||||
background-color: var(--event-details-background-color);
|
||||
margin-top: 0px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.fc-col-header-cell-cushion,
|
||||
a.fc-col-header-cell-cushion:hover {
|
||||
color: black;
|
||||
// Reset from style.scss
|
||||
thead {
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
// Reset from style.scss
|
||||
tbody > tr {
|
||||
&:nth-child(even):not(.highlight) {
|
||||
background: white;
|
||||
}
|
||||
}
|
||||
|
||||
a.fc-daygrid-day-number,
|
||||
a.fc-daygrid-day-number:hover {
|
||||
color: rgb(34, 34, 34);
|
||||
}
|
||||
.fc .fc-toolbar.fc-footer-toolbar {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
td {
|
||||
overflow: visible; // Show events on multiple days
|
||||
}
|
||||
button.text-copy,
|
||||
button.text-copy:focus,
|
||||
button.text-copy:hover {
|
||||
background-color: #67AE6E !important;
|
||||
transition: 500ms ease-in;
|
||||
}
|
||||
|
||||
//Reset from style.scss
|
||||
table {
|
||||
box-shadow: none;
|
||||
border-radius: 0px;
|
||||
-moz-border-radius: 0px;
|
||||
margin: 0px;
|
||||
}
|
||||
button.text-copied,
|
||||
button.text-copied:focus,
|
||||
button.text-copied:hover {
|
||||
transition: 500ms ease-out;
|
||||
}
|
||||
|
||||
// Reset from style.scss
|
||||
thead {
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
.fc .fc-getCalendarLink-button {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
.fc .fc-getCalendarLink-button {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.fc .fc-helpButton-button {
|
||||
border-radius: 70%;
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
transition: 100ms ease-out;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.fc .fc-helpButton-button {
|
||||
border-radius: 70%;
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
transition: 100ms ease-out;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
|
||||
.fc .fc-helpButton-button:hover {
|
||||
background-color: rgba(20, 20, 20, 0.6);
|
||||
}
|
||||
.fc .fc-helpButton-button:hover {
|
||||
background-color: rgba(20, 20, 20, 0.6);
|
||||
}
|
||||
|
||||
.tooltip.calendar-copy-tooltip {
|
@ -16,6 +16,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.card-group {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: $primary-neutral-light-color;
|
||||
border-radius: 5px;
|
||||
@ -92,13 +99,23 @@
|
||||
}
|
||||
|
||||
@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,
|
||||
// whatever the size of the screen.
|
||||
&.card-row {
|
||||
@include row-layout
|
||||
@include row-layout;
|
||||
|
||||
&.card-row-m {
|
||||
//width: 50%;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
&.card-row-s {
|
||||
//width: 33%;
|
||||
max-width: 33%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
89
core/static/core/footer.scss
Normal file
89
core/static/core/footer.scss
Normal file
@ -0,0 +1,89 @@
|
||||
@import "colors";
|
||||
@import "devices";
|
||||
|
||||
footer.bottom-links {
|
||||
@media (max-width: $small-devices) {
|
||||
margin-top: 0.6em;
|
||||
padding: 1.25em;
|
||||
background-color: $primary-neutral-dark-color;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
gap: 1.25em;
|
||||
|
||||
>section {
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.8em;
|
||||
|
||||
}
|
||||
|
||||
a {
|
||||
color: $white-color;
|
||||
width: auto;
|
||||
|
||||
&:hover {
|
||||
color: $white-color;
|
||||
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
|
||||
}
|
||||
}
|
||||
|
||||
.fa-github {
|
||||
color: $white-color;
|
||||
}
|
||||
|
||||
hr {
|
||||
width: 100%;
|
||||
height: 0px;
|
||||
border: none;
|
||||
border-top: 0.5px solid $white-color;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: $small-devices) {
|
||||
width: 90%;
|
||||
margin: 2em auto;
|
||||
|
||||
font-size: 90%;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
|
||||
section:first-of-type {
|
||||
margin: 0.6em 0;
|
||||
color: $white-color;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
background-color: $primary-neutral-dark-color;
|
||||
box-shadow: $shadow-color 0 0 15px;
|
||||
|
||||
a {
|
||||
color: $white-color;
|
||||
width: auto;
|
||||
padding: 0.8em;
|
||||
flex: 1;
|
||||
font-weight: bold;
|
||||
|
||||
&:hover {
|
||||
color: $white-color;
|
||||
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fa-github {
|
||||
color: $githubblack;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
height: 5px;
|
||||
}
|
||||
}
|
||||
}
|
@ -713,47 +713,6 @@ textarea {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/*--------------------------------FOOTER-------------------------------*/
|
||||
|
||||
footer {
|
||||
width: 90%;
|
||||
margin: 2em auto;
|
||||
|
||||
font-size: 90%;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
|
||||
div {
|
||||
margin: 0.6em 0;
|
||||
color: $white-color;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
background-color: $primary-neutral-dark-color;
|
||||
box-shadow: $shadow-color 0 0 15px;
|
||||
|
||||
a {
|
||||
padding: 0.8em;
|
||||
flex: 1;
|
||||
font-weight: bold;
|
||||
color: $white-color !important;
|
||||
|
||||
&:hover {
|
||||
color: $primary-dark-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
>.version {
|
||||
margin-top: 3px;
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.fa-github {
|
||||
color: $githubblack;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.ui-dialog .ui-dialog-buttonpane {
|
||||
|
@ -10,10 +10,9 @@
|
||||
border-radius: 5px;
|
||||
padding: 5px 10px;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transition: opacity 500ms ease-out;
|
||||
|
||||
width: max-content;
|
||||
white-space: normal;
|
||||
|
||||
left: 0;
|
||||
|
@ -4,6 +4,12 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.zoom-control {
|
||||
margin-right: 10px;
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
}
|
||||
|
||||
.graph-toolbar {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
@ -12,7 +18,7 @@
|
||||
justify-content: space-around;
|
||||
gap: 30px;
|
||||
|
||||
.toolbar-column{
|
||||
.toolbar-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
@ -34,31 +40,38 @@
|
||||
|
||||
.depth-choice {
|
||||
white-space: nowrap;
|
||||
|
||||
input[type="number"] {
|
||||
-webkit-appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
|
||||
&::-webkit-inner-spin-button,
|
||||
&::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
background: none;
|
||||
& > .fa {
|
||||
|
||||
&>.fa {
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
padding: 5px;
|
||||
}
|
||||
&:enabled > .fa {
|
||||
|
||||
&:enabled>.fa {
|
||||
background-color: #354a5f;
|
||||
color: white;
|
||||
}
|
||||
&:enabled:hover > .fa {
|
||||
|
||||
&:enabled:hover>.fa {
|
||||
color: white;
|
||||
background-color: #35405f; // just a bit darker
|
||||
}
|
||||
&:disabled > .fa {
|
||||
|
||||
&:disabled>.fa {
|
||||
background-color: gray;
|
||||
color: white;
|
||||
}
|
||||
@ -74,6 +87,7 @@
|
||||
@media screen and (max-width: 500px) {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
.toolbar-column {
|
||||
min-width: 100%;
|
||||
}
|
||||
@ -87,14 +101,16 @@
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
|
||||
> form {
|
||||
>form {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
#family-tree-link {
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
|
||||
@media (min-width: 450px) {
|
||||
margin-right: auto;
|
||||
}
|
||||
@ -122,10 +138,10 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
> div.mini_profile_link {
|
||||
>div.mini_profile_link {
|
||||
position: relative;
|
||||
|
||||
> a {
|
||||
>a {
|
||||
&.mini_profile_link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -140,7 +156,7 @@
|
||||
max-height: 65px;
|
||||
}
|
||||
|
||||
> span {
|
||||
>span {
|
||||
height: 150px;
|
||||
width: 100%;
|
||||
|
||||
@ -149,7 +165,7 @@
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
> img {
|
||||
>img {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
@ -163,7 +179,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
> em {
|
||||
>em {
|
||||
box-sizing: border-box;
|
||||
padding: 0 5px;
|
||||
text-align: center;
|
||||
@ -195,7 +211,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
> a.mini_profile_link {
|
||||
>a.mini_profile_link {
|
||||
display: none;
|
||||
}
|
||||
}
|
@ -11,6 +11,7 @@
|
||||
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/header.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/navbar.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/footer.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/pagination.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/accordion.scss') }}">
|
||||
|
||||
@ -89,22 +90,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
{% block footer %}
|
||||
<div>
|
||||
<a href="{{ url('core:page', 'contacts') }}">{% trans %}Contacts{% endtrans %}</a>
|
||||
<a href="{{ url('core:page', 'legals') }}">{% trans %}Legal notices{% endtrans %}</a>
|
||||
<a href="{{ url('core:page', 'copyright_agent') }}">{% trans %}Intellectual property{% endtrans %}</a>
|
||||
<a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
|
||||
<a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
|
||||
</div>
|
||||
<a rel="nofollow" href="https://github.com/ae-utbm/sith" target="#">
|
||||
<i class="fa-brands fa-github"></i>
|
||||
{% trans %}Site created by the IT Department of the AE{% endtrans %}
|
||||
</a>
|
||||
{% endblock %}
|
||||
<br>
|
||||
</footer>
|
||||
{% block footer %}
|
||||
{% include "core/base/footer.jinja" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
|
16
core/templates/core/base/footer.jinja
Normal file
16
core/templates/core/base/footer.jinja
Normal file
@ -0,0 +1,16 @@
|
||||
<footer class="bottom-links">
|
||||
<section>
|
||||
<a href="{{ url('core:page', 'contacts') }}">{% trans %}Contacts{% endtrans %}</a>
|
||||
<a href="{{ url('core:page', 'legals') }}">{% trans %}Legal notices{% endtrans %}</a>
|
||||
<a href="{{ url('core:page', 'copyright_agent') }}">{% trans %}Intellectual property{% endtrans %}</a>
|
||||
<a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
|
||||
<a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
|
||||
</section>
|
||||
<hr>
|
||||
<section>
|
||||
<a rel="nofollow" href="https://github.com/ae-utbm/sith" target="#">
|
||||
<i class="fa-brands fa-github"></i>
|
||||
{% trans %}Site created by the IT Department of the AE{% endtrans %}
|
||||
</a>
|
||||
</section>
|
||||
</footer>
|
@ -26,9 +26,11 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url('core:login') }}">
|
||||
<form method="post" action="{{ url('core:login') }}" id="login-form">
|
||||
{% if form.errors %}
|
||||
<p class="alert alert-red">{% trans %}Your username and password didn't match. Please try again.{% endtrans %}</p>
|
||||
<p class="alert alert-red">
|
||||
{% trans %}Your credentials didn't match. Please try again.{% endtrans %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% csrf_token %}
|
||||
|
@ -7,7 +7,7 @@
|
||||
{%- endblock -%}
|
||||
|
||||
{% block additional_js %}
|
||||
<script type="module" src="{{ static("bundled/user/family-graph-index.js") }}"></script>
|
||||
<script type="module" src="{{ static("bundled/user/family-graph-index.ts") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}
|
||||
@ -15,7 +15,14 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="graph" :aria-busy="loading">
|
||||
<div
|
||||
x-data="graph({
|
||||
activeUser: {{ object.id }},
|
||||
depthMin: {{ depth_min }},
|
||||
depthMax: {{ depth_max }},
|
||||
})"
|
||||
:aria-busy="loading"
|
||||
>
|
||||
<div class="graph-toolbar">
|
||||
<div class="toolbar-column">
|
||||
<div class="toolbar-input">
|
||||
@ -86,17 +93,36 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zoom-control" x-ref="zoomControl">
|
||||
<button
|
||||
@click="graph.zoom(graph.zoom() + 1)"
|
||||
:disabled="!isZoomEnabled"
|
||||
>
|
||||
<i class="fa-solid fa-magnifying-glass-plus"></i>
|
||||
</button>
|
||||
<button
|
||||
@click="graph.zoom(graph.zoom() - 1)"
|
||||
:disabled="!isZoomEnabled"
|
||||
>
|
||||
<i class="fa-solid fa-magnifying-glass-minus"></i>
|
||||
</button>
|
||||
<button
|
||||
x-show="isZoomEnabled"
|
||||
@click="isZoomEnabled = false"
|
||||
>
|
||||
<i class="fa-solid fa-unlock"></i>
|
||||
</button>
|
||||
<button
|
||||
x-show="!isZoomEnabled"
|
||||
@click="isZoomEnabled = true"
|
||||
>
|
||||
<i class="fa-solid fa-lock"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-ref="graph" class="graph"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
loadFamilyGraph({
|
||||
activeUser: {{ object.id }},
|
||||
depthMin: {{ depth_min }},
|
||||
depthMax: {{ depth_max }},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
@ -38,6 +38,7 @@ from core.markdown import markdown
|
||||
from core.models import AnonymousUser, Group, Page, User
|
||||
from core.utils import get_semester_code, get_start_of_semester
|
||||
from core.views import AllowFragment
|
||||
from counter.models import Customer
|
||||
from sith import settings
|
||||
|
||||
|
||||
@ -151,24 +152,44 @@ class TestUserLogin:
|
||||
def user(self) -> User:
|
||||
return baker.make(User, password=make_password("plop"))
|
||||
|
||||
def test_login_fail(self, client, user):
|
||||
@pytest.mark.parametrize(
|
||||
"identifier_getter",
|
||||
[
|
||||
lambda user: user.username,
|
||||
lambda user: user.email,
|
||||
lambda user: Customer.get_or_create(user)[0].account_id,
|
||||
],
|
||||
)
|
||||
def test_login_fail(self, client, user, identifier_getter):
|
||||
"""Should not login a user correctly."""
|
||||
identifier = identifier_getter(user)
|
||||
response = client.post(
|
||||
reverse("core:login"),
|
||||
{"username": user.username, "password": "wrong-password"},
|
||||
{"username": identifier, "password": "wrong-password"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert (
|
||||
'<p class="alert alert-red">Votre nom d\'utilisateur '
|
||||
"et votre mot de passe ne correspondent pas. Merci de réessayer.</p>"
|
||||
) in response.text
|
||||
assert response.wsgi_request.user.is_anonymous
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
form = soup.find(id="login-form")
|
||||
assert (
|
||||
form.find(class_="alert alert-red").get_text(strip=True)
|
||||
== "Vos identifiants ne correspondent pas. Veuillez réessayer."
|
||||
)
|
||||
assert form.find("input", attrs={"name": "username"}).get("value") == identifier
|
||||
|
||||
def test_login_success(self, client, user):
|
||||
@pytest.mark.parametrize(
|
||||
"identifier_getter",
|
||||
[
|
||||
lambda user: user.username,
|
||||
lambda user: user.email,
|
||||
lambda user: Customer.get_or_create(user)[0].account_id,
|
||||
],
|
||||
)
|
||||
def test_login_success(self, client, user, identifier_getter):
|
||||
"""Should login a user correctly."""
|
||||
response = client.post(
|
||||
reverse("core:login"),
|
||||
{"username": user.username, "password": "plop"},
|
||||
{"username": identifier_getter(user), "password": "plop"},
|
||||
)
|
||||
assertRedirects(response, reverse("core:index"))
|
||||
assert response.wsgi_request.user == user
|
||||
@ -361,17 +382,9 @@ class TestUserIsInGroup(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.root_group = Group.objects.get(name="Root")
|
||||
cls.public_group = Group.objects.get(name="Public")
|
||||
cls.public_group = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)
|
||||
cls.public_user = baker.make(User)
|
||||
cls.subscribers = Group.objects.get(name="Subscribers")
|
||||
cls.old_subscribers = Group.objects.get(name="Old subscribers")
|
||||
cls.accounting_admin = Group.objects.get(name="Accounting admin")
|
||||
cls.com_admin = Group.objects.get(name="Communication admin")
|
||||
cls.counter_admin = Group.objects.get(name="Counter admin")
|
||||
cls.sas_admin = Group.objects.get(name="SAS admin")
|
||||
cls.club = baker.make(Club)
|
||||
cls.main_club = Club.objects.get(id=1)
|
||||
|
||||
def assert_in_public_group(self, user):
|
||||
assert user.is_in_group(pk=self.public_group.id)
|
||||
@ -379,15 +392,7 @@ class TestUserIsInGroup(TestCase):
|
||||
|
||||
def assert_only_in_public_group(self, user):
|
||||
self.assert_in_public_group(user)
|
||||
for group in (
|
||||
self.root_group,
|
||||
self.accounting_admin,
|
||||
self.sas_admin,
|
||||
self.subscribers,
|
||||
self.old_subscribers,
|
||||
self.club.members_group,
|
||||
self.club.board_group,
|
||||
):
|
||||
for group in Group.objects.exclude(id=self.public_group.id):
|
||||
assert not user.is_in_group(pk=group.pk)
|
||||
assert not user.is_in_group(name=group.name)
|
||||
|
||||
|
@ -39,9 +39,8 @@ from django.forms import (
|
||||
DateInput,
|
||||
DateTimeInput,
|
||||
TextInput,
|
||||
Widget,
|
||||
)
|
||||
from django.utils.timezone import now
|
||||
from django.utils.timezone import localtime, now
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||
@ -115,7 +114,7 @@ class SelectUser(TextInput):
|
||||
|
||||
def validate_future_timestamp(value: date | datetime):
|
||||
if value <= now():
|
||||
raise ValueError(_("Ensure this timestamp is set in the future"))
|
||||
raise ValidationError(_("Ensure this timestamp is set in the future"))
|
||||
|
||||
|
||||
class FutureDateTimeField(forms.DateTimeField):
|
||||
@ -123,8 +122,8 @@ class FutureDateTimeField(forms.DateTimeField):
|
||||
|
||||
default_validators = [validate_future_timestamp]
|
||||
|
||||
def widget_attrs(self, widget: Widget) -> dict[str, str]:
|
||||
return {"min": widget.format_value(now())}
|
||||
def widget_attrs(self, widget: forms.Widget) -> dict[str, str]:
|
||||
return {"min": widget.format_value(localtime())}
|
||||
|
||||
|
||||
# Forms
|
||||
@ -132,29 +131,31 @@ class FutureDateTimeField(forms.DateTimeField):
|
||||
|
||||
class LoginForm(AuthenticationForm):
|
||||
def __init__(self, *arg, **kwargs):
|
||||
if "data" in kwargs:
|
||||
from counter.models import Customer
|
||||
|
||||
data = kwargs["data"].copy()
|
||||
account_code = re.compile(r"^[0-9]+[A-Za-z]$")
|
||||
try:
|
||||
if account_code.match(data["username"]):
|
||||
user = (
|
||||
Customer.objects.filter(account_id__iexact=data["username"])
|
||||
.first()
|
||||
.user
|
||||
)
|
||||
elif "@" in data["username"]:
|
||||
user = User.objects.filter(email__iexact=data["username"]).first()
|
||||
else:
|
||||
user = User.objects.filter(username=data["username"]).first()
|
||||
data["username"] = user.username
|
||||
except: # noqa E722 I don't know what error is supposed to be raised here
|
||||
pass
|
||||
kwargs["data"] = data
|
||||
super().__init__(*arg, **kwargs)
|
||||
self.fields["username"].label = _("Username, email, or account number")
|
||||
|
||||
def clean_username(self):
|
||||
identifier: str = self.cleaned_data["username"]
|
||||
account_code = re.compile(r"^[0-9]+[A-Za-z]$")
|
||||
if account_code.match(identifier):
|
||||
qs_filter = "customer__account_id__iexact"
|
||||
elif identifier.count("@") == 1:
|
||||
qs_filter = "email"
|
||||
else:
|
||||
qs_filter = None
|
||||
if qs_filter:
|
||||
# if the user gave an email or an account code instead of
|
||||
# a username, retrieve and return the corresponding username.
|
||||
# If there is no username, return an empty string, so that
|
||||
# Django will properly handle the error when failing the authentication
|
||||
identifier = (
|
||||
User.objects.filter(**{qs_filter: identifier})
|
||||
.values_list("username", flat=True)
|
||||
.first()
|
||||
or ""
|
||||
)
|
||||
return identifier
|
||||
|
||||
|
||||
class RegisteringForm(UserCreationForm):
|
||||
error_css_class = "error"
|
||||
|
@ -109,7 +109,7 @@ class FragmentMixin(TemplateResponseMixin, ContextMixin):
|
||||
return render(
|
||||
request,
|
||||
"app/template.jinja",
|
||||
context={"fragment": fragment(request)
|
||||
context={"fragment": fragment(request)}
|
||||
}
|
||||
|
||||
# in urls.py
|
||||
|
@ -41,6 +41,7 @@ class ProductAdmin(SearchModelAdmin):
|
||||
"profit",
|
||||
"archived",
|
||||
)
|
||||
list_select_related = ("product_type",)
|
||||
search_fields = ("name", "code")
|
||||
|
||||
|
||||
@ -81,20 +82,13 @@ class AccountDumpAdmin(admin.ModelAdmin):
|
||||
"customer",
|
||||
"warning_mail_sent_at",
|
||||
"warning_mail_error",
|
||||
"dump_operation",
|
||||
"dump_operation__date",
|
||||
"amount",
|
||||
)
|
||||
list_select_related = ("customer", "customer__user", "dump_operation")
|
||||
autocomplete_fields = ("customer", "dump_operation")
|
||||
list_filter = ("warning_mail_error",)
|
||||
|
||||
def get_queryset(self, request):
|
||||
# the `amount` property requires to know the customer and the dump_operation
|
||||
return (
|
||||
super()
|
||||
.get_queryset(request)
|
||||
.select_related("customer", "customer__user", "dump_operation")
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Counter)
|
||||
class CounterAdmin(admin.ModelAdmin):
|
||||
@ -113,11 +107,14 @@ class RefillingAdmin(SearchModelAdmin):
|
||||
"customer__account_id",
|
||||
"counter__name",
|
||||
)
|
||||
list_filter = (("counter", admin.RelatedOnlyFieldListFilter),)
|
||||
date_hierarchy = "date"
|
||||
|
||||
|
||||
@admin.register(Selling)
|
||||
class SellingAdmin(SearchModelAdmin):
|
||||
list_display = ("customer", "label", "unit_price", "quantity", "counter", "date")
|
||||
list_select_related = ("customer", "customer__user", "counter")
|
||||
search_fields = (
|
||||
"customer__user__username",
|
||||
"customer__user__first_name",
|
||||
@ -126,6 +123,8 @@ class SellingAdmin(SearchModelAdmin):
|
||||
"counter__name",
|
||||
)
|
||||
autocomplete_fields = ("customer", "seller")
|
||||
list_filter = (("counter", admin.RelatedOnlyFieldListFilter),)
|
||||
date_hierarchy = "date"
|
||||
|
||||
|
||||
@admin.register(Permanency)
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { AlertMessage } from "#core:utils/alert-message";
|
||||
import { BasketItem } from "#counter:counter/basket";
|
||||
import type { CounterConfig, ErrorMessage } from "#counter:counter/types";
|
||||
import type { CounterProductSelect } from "./components/counter-product-select-index.ts";
|
||||
@ -5,14 +6,9 @@ import type { CounterProductSelect } from "./components/counter-product-select-i
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("counter", (config: CounterConfig) => ({
|
||||
basket: {} as Record<string, BasketItem>,
|
||||
errors: [],
|
||||
customerBalance: config.customerBalance,
|
||||
codeField: null as CounterProductSelect | null,
|
||||
alertMessage: {
|
||||
content: "",
|
||||
show: false,
|
||||
timeout: null,
|
||||
},
|
||||
alertMessage: new AlertMessage({ defaultDuration: 2000 }),
|
||||
|
||||
init() {
|
||||
// Fill the basket with the initial data
|
||||
@ -77,22 +73,10 @@ document.addEventListener("alpine:init", () => {
|
||||
return total;
|
||||
},
|
||||
|
||||
showAlertMessage(message: string) {
|
||||
if (this.alertMessage.timeout !== null) {
|
||||
clearTimeout(this.alertMessage.timeout);
|
||||
}
|
||||
this.alertMessage.content = message;
|
||||
this.alertMessage.show = true;
|
||||
this.alertMessage.timeout = setTimeout(() => {
|
||||
this.alertMessage.show = false;
|
||||
this.alertMessage.timeout = null;
|
||||
}, 2000);
|
||||
},
|
||||
|
||||
addToBasketWithMessage(id: string, quantity: number) {
|
||||
const message = this.addToBasket(id, quantity);
|
||||
if (message.length > 0) {
|
||||
this.showAlertMessage(message);
|
||||
this.alertMessage.display(message, { success: false });
|
||||
}
|
||||
},
|
||||
|
||||
@ -109,7 +93,9 @@ document.addEventListener("alpine:init", () => {
|
||||
|
||||
finish() {
|
||||
if (this.getBasketSize() === 0) {
|
||||
this.showAlertMessage(gettext("You can't send an empty basket."));
|
||||
this.alertMessage.display(gettext("You can't send an empty basket."), {
|
||||
success: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.$refs.basketForm.submit();
|
||||
|
@ -1,15 +1,11 @@
|
||||
import { AlertMessage } from "#core:utils/alert-message";
|
||||
import Alpine from "alpinejs";
|
||||
import { producttypeReorder } from "#openapi";
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("productTypesList", () => ({
|
||||
loading: false,
|
||||
alertMessage: {
|
||||
open: false,
|
||||
success: true,
|
||||
content: "",
|
||||
timeout: null,
|
||||
},
|
||||
alertMessage: new AlertMessage({ defaultDuration: 2000 }),
|
||||
|
||||
async reorder(itemId: number, newPosition: number) {
|
||||
// The sort plugin of Alpine doesn't manage dynamic lists with x-sort
|
||||
@ -41,23 +37,14 @@ document.addEventListener("alpine:init", () => {
|
||||
},
|
||||
|
||||
openAlertMessage(response: Response) {
|
||||
if (response.ok) {
|
||||
this.alertMessage.success = true;
|
||||
this.alertMessage.content = gettext("Products types reordered!");
|
||||
} else {
|
||||
this.alertMessage.success = false;
|
||||
this.alertMessage.content = interpolate(
|
||||
gettext("Product type reorganisation failed with status code : %d"),
|
||||
[response.status],
|
||||
);
|
||||
}
|
||||
this.alertMessage.open = true;
|
||||
if (this.alertMessage.timeout !== null) {
|
||||
clearTimeout(this.alertMessage.timeout);
|
||||
}
|
||||
this.alertMessage.timeout = setTimeout(() => {
|
||||
this.alertMessage.open = false;
|
||||
}, 2000);
|
||||
const success = response.ok;
|
||||
const content = response.ok
|
||||
? gettext("Products types reordered!")
|
||||
: interpolate(
|
||||
gettext("Product type reorganisation failed with status code : %d"),
|
||||
[response.status],
|
||||
);
|
||||
this.alertMessage.display(content, { success: success });
|
||||
this.loading = false;
|
||||
},
|
||||
}));
|
||||
|
2
counter/static/bundled/counter/types.d.ts
vendored
2
counter/static/bundled/counter/types.d.ts
vendored
@ -1,4 +1,4 @@
|
||||
type ErrorMessage = string;
|
||||
declare type ErrorMessage = string;
|
||||
|
||||
export interface InitialFormData {
|
||||
/* Used to refill the form when the backend raises an error */
|
||||
|
@ -17,6 +17,7 @@ from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission, make_password
|
||||
from django.core.cache import cache
|
||||
@ -823,3 +824,53 @@ class TestClubCounterClickAccess(TestCase):
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.get(self.click_url)
|
||||
assert res.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCounterLogout:
|
||||
def test_logout_simple(self, client: Client):
|
||||
perm_counter = baker.make(Counter, type="BAR")
|
||||
permanence = baker.make(
|
||||
Permanency,
|
||||
counter=perm_counter,
|
||||
start=now() - timedelta(hours=1),
|
||||
activity=now() - timedelta(minutes=10),
|
||||
)
|
||||
with freeze_time():
|
||||
res = client.post(
|
||||
reverse("counter:logout", kwargs={"counter_id": permanence.counter_id}),
|
||||
data={"user_id": permanence.user_id},
|
||||
)
|
||||
assertRedirects(
|
||||
res,
|
||||
reverse(
|
||||
"counter:details", kwargs={"counter_id": permanence.counter_id}
|
||||
),
|
||||
)
|
||||
permanence.refresh_from_db()
|
||||
assert permanence.end == now()
|
||||
|
||||
def test_logout_doesnt_change_old_permanences(self, client: Client):
|
||||
perm_counter = baker.make(Counter, type="BAR")
|
||||
permanence = baker.make(
|
||||
Permanency,
|
||||
counter=perm_counter,
|
||||
start=now() - timedelta(hours=1),
|
||||
activity=now() - timedelta(minutes=10),
|
||||
)
|
||||
old_end = now() - relativedelta(year=10)
|
||||
old_permanence = baker.make(
|
||||
Permanency,
|
||||
counter=perm_counter,
|
||||
end=old_end,
|
||||
activity=now() - relativedelta(year=8),
|
||||
)
|
||||
with freeze_time():
|
||||
client.post(
|
||||
reverse("counter:logout", kwargs={"counter_id": permanence.counter_id}),
|
||||
data={"user_id": permanence.user_id},
|
||||
)
|
||||
permanence.refresh_from_db()
|
||||
assert permanence.end == now()
|
||||
old_permanence.refresh_from_db()
|
||||
assert old_permanence.end == old_end
|
||||
|
@ -13,10 +13,10 @@
|
||||
#
|
||||
#
|
||||
|
||||
from django.db.models import F
|
||||
from django.http import HttpRequest, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import now
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from core.views.forms import LoginForm
|
||||
@ -47,7 +47,7 @@ def counter_login(request: HttpRequest, counter_id: int) -> HttpResponseRedirect
|
||||
@require_POST
|
||||
def counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
|
||||
"""End the permanency of a user in this counter."""
|
||||
Permanency.objects.filter(counter=counter_id, user=request.POST["user_id"]).update(
|
||||
end=F("activity")
|
||||
)
|
||||
Permanency.objects.filter(
|
||||
counter=counter_id, user=request.POST["user_id"], end=None
|
||||
).update(end=now())
|
||||
return redirect("counter:details", counter_id=counter_id)
|
||||
|
363
galaxy/models.py
363
galaxy/models.py
@ -23,21 +23,20 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import NamedTuple, TypedDict
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Count, F, Q, QuerySet
|
||||
from django.db.models import Case, Count, F, Q, Value, When
|
||||
from django.db.models.functions import Concat
|
||||
from django.utils.timezone import localdate
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from club.models import Membership
|
||||
from club.models import Club
|
||||
from core.models import User
|
||||
from sas.models import PeoplePictureRelation, Picture
|
||||
from sas.models import Picture
|
||||
|
||||
|
||||
class GalaxyStar(models.Model):
|
||||
@ -115,9 +114,18 @@ class GalaxyLane(models.Model):
|
||||
default=0,
|
||||
help_text=_("Distance separating star1 and star2"),
|
||||
)
|
||||
family = models.PositiveIntegerField(_("family score"), default=0)
|
||||
pictures = models.PositiveIntegerField(_("pictures score"), default=0)
|
||||
clubs = models.PositiveIntegerField(_("clubs score"), default=0)
|
||||
family = models.PositiveIntegerField(
|
||||
_("family score"),
|
||||
default=0,
|
||||
)
|
||||
pictures = models.PositiveIntegerField(
|
||||
_("pictures score"),
|
||||
default=0,
|
||||
)
|
||||
clubs = models.PositiveIntegerField(
|
||||
_("clubs score"),
|
||||
default=0,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.star1} -> {self.star2} ({self.distance})"
|
||||
@ -166,7 +174,6 @@ class Galaxy(models.Model):
|
||||
logger = logging.getLogger("main")
|
||||
|
||||
GALAXY_SCALE_FACTOR = 2_000
|
||||
DEFAULT_PICTURE_COUNT_THRESHOLD = 10
|
||||
FAMILY_LINK_POINTS = 366 # Equivalent to a leap year together in a club, because.
|
||||
PICTURE_POINTS = 2 # Equivalent to two days as random members of a club.
|
||||
CLUBS_POINTS = 1 # One day together as random members in a club is one point.
|
||||
@ -180,13 +187,15 @@ class Galaxy(models.Model):
|
||||
stars_count = self.stars.count()
|
||||
s = f"GLX-ID{self.pk}-SC{stars_count}-"
|
||||
if self.state is None:
|
||||
s += "CHAOS"
|
||||
s += "CHS" # CHAOS
|
||||
else:
|
||||
s += "RULED"
|
||||
s += "RLD" # RULED
|
||||
return s
|
||||
|
||||
@classmethod
|
||||
def get_current_galaxy(cls) -> Galaxy:
|
||||
def get_current_galaxy(
|
||||
cls,
|
||||
) -> Galaxy: # __future__.annotations is required for this
|
||||
return Galaxy.objects.filter(state__isnull=False).last()
|
||||
|
||||
###################
|
||||
@ -194,18 +203,7 @@ class Galaxy(models.Model):
|
||||
###################
|
||||
|
||||
@classmethod
|
||||
def get_rulable_users(
|
||||
cls, picture_count_threshold: int = DEFAULT_PICTURE_COUNT_THRESHOLD
|
||||
) -> QuerySet[User]:
|
||||
return (
|
||||
User.objects.exclude(subscriptions=None)
|
||||
.annotate(pictures_count=Count("pictures"))
|
||||
.filter(pictures_count__gt=picture_count_threshold)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def compute_individual_scores(cls) -> dict[int, int]:
|
||||
def compute_user_score(cls, user: User) -> int:
|
||||
"""Compute an individual score for each citizen.
|
||||
|
||||
It will later be used by the graph algorithm to push
|
||||
@ -213,50 +211,87 @@ class Galaxy(models.Model):
|
||||
|
||||
Idea: This could be added to the computation:
|
||||
|
||||
- Forum posts
|
||||
- Picture count
|
||||
- Counter consumption
|
||||
- Barman time
|
||||
- ...
|
||||
"""
|
||||
users = (
|
||||
User.objects.annotate(
|
||||
score=(
|
||||
Count("godchildren", distinct=True) * cls.FAMILY_LINK_POINTS
|
||||
+ Count("godfathers", distinct=True) * cls.FAMILY_LINK_POINTS
|
||||
+ Count("pictures", distinct=True) * cls.PICTURE_POINTS
|
||||
+ Count("memberships", distinct=True) * cls.CLUBS_POINTS
|
||||
)
|
||||
)
|
||||
.filter(score__gt=0)
|
||||
.values("id", "score")
|
||||
)
|
||||
user_score = 1
|
||||
user_score += cls.query_user_score(user)
|
||||
|
||||
# TODO:
|
||||
# Scale that value with some magic number to accommodate to typical data
|
||||
# Really active galaxy citizen after 5 years typically have a score of about XXX
|
||||
# Citizen that were seen regularly without taking much part in organizations typically have a score of about XXX
|
||||
# Citizen that only went to a few events typically score about XXX
|
||||
res = {u["id"]: int(math.log2(u["score"] + 1)) for u in users}
|
||||
return res
|
||||
user_score = int(math.log2(user_score))
|
||||
|
||||
return user_score
|
||||
|
||||
@classmethod
|
||||
def query_user_score(cls, user: User) -> int:
|
||||
"""Get the individual score of the given user in the galaxy."""
|
||||
score_query = (
|
||||
User.objects.filter(id=user.id)
|
||||
.annotate(
|
||||
godchildren_count=Count("godchildren", distinct=True)
|
||||
* cls.FAMILY_LINK_POINTS,
|
||||
godfathers_count=Count("godfathers", distinct=True)
|
||||
* cls.FAMILY_LINK_POINTS,
|
||||
pictures_score=Count("pictures", distinct=True) * cls.PICTURE_POINTS,
|
||||
clubs_score=Count("memberships", distinct=True) * cls.CLUBS_POINTS,
|
||||
)
|
||||
.aggregate(
|
||||
score=models.Sum(
|
||||
F("godchildren_count")
|
||||
+ F("godfathers_count")
|
||||
+ F("pictures_score")
|
||||
+ F("clubs_score")
|
||||
)
|
||||
)
|
||||
)
|
||||
return score_query.get("score")
|
||||
|
||||
####################
|
||||
# Inter-user score #
|
||||
####################
|
||||
|
||||
@classmethod
|
||||
def compute_user_family_score(cls, user: User) -> defaultdict[int, int]:
|
||||
def compute_users_score(cls, user1: User, user2: User) -> RelationScore:
|
||||
"""Compute the relationship scores of the two given users.
|
||||
|
||||
The computation is done with the following fields :
|
||||
|
||||
- family: if they have some godfather/godchild relation
|
||||
- pictures: in how many pictures are both tagged
|
||||
- clubs: during how many days they were members of the same clubs
|
||||
"""
|
||||
family = cls.compute_users_family_score(user1, user2)
|
||||
pictures = cls.compute_users_pictures_score(user1, user2)
|
||||
clubs = cls.compute_users_clubs_score(user1, user2)
|
||||
return RelationScore(family=family, pictures=pictures, clubs=clubs)
|
||||
|
||||
@classmethod
|
||||
def compute_users_family_score(cls, user1: User, user2: User) -> int:
|
||||
"""Compute the family score of the relation between the given users.
|
||||
|
||||
This takes into account mutual godfathers.
|
||||
|
||||
Returns:
|
||||
366 if user1 is the godfather of user2 (or vice versa) else 0
|
||||
"""
|
||||
godchildren = User.objects.filter(godchildren=user).values_list("id", flat=True)
|
||||
godfathers = User.objects.filter(godfathers=user).values_list("id", flat=True)
|
||||
result = defaultdict(int)
|
||||
for parent in itertools.chain(godchildren, godfathers):
|
||||
result[parent] += cls.FAMILY_LINK_POINTS
|
||||
return result
|
||||
link_count = User.objects.filter(
|
||||
Q(id=user1.id, godfathers=user2) | Q(id=user2.id, godfathers=user1)
|
||||
).count()
|
||||
if link_count > 0:
|
||||
cls.logger.debug(
|
||||
f"\t\t- '{user1}' and '{user2}' have {link_count} direct family link"
|
||||
)
|
||||
return link_count * cls.FAMILY_LINK_POINTS
|
||||
|
||||
@classmethod
|
||||
def compute_user_pictures_score(cls, user: User) -> defaultdict[int, int]:
|
||||
def compute_users_pictures_score(cls, user1: User, user2: User) -> int:
|
||||
"""Compute the pictures score of the relation between the given users.
|
||||
|
||||
The pictures score is obtained by counting the number
|
||||
@ -266,19 +301,19 @@ class Galaxy(models.Model):
|
||||
Returns:
|
||||
The number of pictures both users have in common, times 2
|
||||
"""
|
||||
common_photos = (
|
||||
PeoplePictureRelation.objects.filter(
|
||||
picture__in=Picture.objects.filter(people__user=user)
|
||||
picture_count = (
|
||||
Picture.objects.filter(people__user__in=(user1,))
|
||||
.filter(people__user__in=(user2,))
|
||||
.count()
|
||||
)
|
||||
if picture_count:
|
||||
cls.logger.debug(
|
||||
f"\t\t- '{user1}' was pictured with '{user2}' {picture_count} times"
|
||||
)
|
||||
.values("user")
|
||||
.annotate(count=Count("user"))
|
||||
)
|
||||
return defaultdict(
|
||||
int, {p["user"]: p["count"] * cls.PICTURE_POINTS for p in common_photos}
|
||||
)
|
||||
return picture_count * cls.PICTURE_POINTS
|
||||
|
||||
@classmethod
|
||||
def compute_user_clubs_score(cls, user: User) -> defaultdict[int, int]:
|
||||
def compute_users_clubs_score(cls, user1: User, user2: User) -> int:
|
||||
"""Compute the clubs score of the relation between the given users.
|
||||
|
||||
The club score is obtained by counting the number of days
|
||||
@ -289,36 +324,54 @@ class Galaxy(models.Model):
|
||||
(two years) and user2 was a member of the same club from 01/01/2021 to
|
||||
31/12/2022 (also two years, but with an offset of one year), then their
|
||||
club score is 365.
|
||||
|
||||
Returns:
|
||||
the number of days during which both users were in the same club
|
||||
"""
|
||||
memberships = user.memberships.only("start_date", "end_date", "club_id")
|
||||
result = defaultdict(int)
|
||||
now = localdate()
|
||||
for membership in memberships:
|
||||
# This is a N+1 query, but 92% of galaxy users have less than 10 memberships.
|
||||
# Only 5 users have more than 30 memberships.
|
||||
common_memberships = (
|
||||
Membership.objects.exclude(user=user)
|
||||
.filter(
|
||||
Q( # start2 <= start1 <= end2
|
||||
start_date__lte=membership.start_date,
|
||||
end_date__gte=membership.start_date,
|
||||
)
|
||||
| Q( # start2 <= start1 <= now
|
||||
start_date__lte=membership.start_date, end_date=None
|
||||
)
|
||||
| Q( # start1 <= start2 <= end2
|
||||
start_date__gte=membership.start_date,
|
||||
start_date__lte=membership.end_date or now,
|
||||
),
|
||||
club_id=membership.club_id,
|
||||
)
|
||||
.only("start_date", "end_date", "user_id")
|
||||
common_clubs = Club.objects.filter(members__in=user1.memberships.all()).filter(
|
||||
members__in=user2.memberships.all()
|
||||
)
|
||||
user1_memberships = user1.memberships.filter(club__in=common_clubs)
|
||||
user2_memberships = user2.memberships.filter(club__in=common_clubs)
|
||||
|
||||
score = 0
|
||||
for user1_membership in user1_memberships:
|
||||
if user1_membership.end_date is None:
|
||||
# user1_membership.save() is not called in this function, hence this is safe
|
||||
user1_membership.end_date = localdate()
|
||||
query = Q( # start2 <= start1 <= end2
|
||||
start_date__lte=user1_membership.start_date,
|
||||
end_date__gte=user1_membership.start_date,
|
||||
)
|
||||
for other in common_memberships:
|
||||
start = max(membership.start_date, other.start_date)
|
||||
end = min(membership.end_date or now, other.end_date or now)
|
||||
result[other.user_id] += (end - start).days * cls.CLUBS_POINTS
|
||||
return result
|
||||
query |= Q( # start2 <= start1 <= now
|
||||
start_date__lte=user1_membership.start_date, end_date=None
|
||||
)
|
||||
query |= Q( # start1 <= start2 <= end2
|
||||
start_date__gte=user1_membership.start_date,
|
||||
start_date__lte=user1_membership.end_date,
|
||||
)
|
||||
for user2_membership in user2_memberships.filter(
|
||||
query, club=user1_membership.club
|
||||
):
|
||||
if user2_membership.end_date is None:
|
||||
user2_membership.end_date = localdate()
|
||||
latest_start = max(
|
||||
user1_membership.start_date, user2_membership.start_date
|
||||
)
|
||||
earliest_end = min(user1_membership.end_date, user2_membership.end_date)
|
||||
cls.logger.debug(
|
||||
"\t\t- '%s' was with '%s' in %s starting on %s until %s (%s days)"
|
||||
% (
|
||||
user1,
|
||||
user2,
|
||||
user2_membership.club,
|
||||
latest_start,
|
||||
earliest_end,
|
||||
(earliest_end - latest_start).days,
|
||||
)
|
||||
)
|
||||
score += cls.CLUBS_POINTS * (earliest_end - latest_start).days
|
||||
return score
|
||||
|
||||
###################
|
||||
# Rule the galaxy #
|
||||
@ -353,9 +406,7 @@ class Galaxy(models.Model):
|
||||
cls.logger.debug(f"\t\t> Scaled distance: {value}")
|
||||
return int(value)
|
||||
|
||||
def rule(
|
||||
self, picture_count_threshold: int = DEFAULT_PICTURE_COUNT_THRESHOLD
|
||||
) -> None:
|
||||
def rule(self, picture_count_threshold=10) -> None:
|
||||
"""Main function of the Galaxy.
|
||||
|
||||
Iterate over all the rulable users to promote them to citizens.
|
||||
@ -376,30 +427,41 @@ class Galaxy(models.Model):
|
||||
"""
|
||||
total_time = time.time()
|
||||
self.logger.info("Listing rulable citizen.")
|
||||
rulable_users = (
|
||||
User.objects.filter(subscriptions__isnull=False)
|
||||
.annotate(pictures_count=Count("pictures"))
|
||||
.filter(pictures_count__gt=picture_count_threshold)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
# force fetch of the whole query to make sure there won't
|
||||
# be any more db hits
|
||||
# this is memory expensive but prevents a lot of db hits, therefore
|
||||
# is far more time efficient
|
||||
|
||||
rulable_users = list(self.get_rulable_users(picture_count_threshold))
|
||||
rulable_users = list(rulable_users)
|
||||
rulable_users_count = len(rulable_users)
|
||||
user1_count = 0
|
||||
self.logger.info(
|
||||
f"{rulable_users_count} citizen have been listed. Starting to rule."
|
||||
)
|
||||
|
||||
stars = []
|
||||
self.logger.info("Creating stars for all citizen")
|
||||
individual_scores = self.compute_individual_scores()
|
||||
GalaxyStar.objects.bulk_create(
|
||||
[
|
||||
GalaxyStar(owner=user, galaxy=self, mass=individual_scores[user.id])
|
||||
for user in rulable_users
|
||||
]
|
||||
)
|
||||
stars = {star.owner_id: star for star in self.stars.all()}
|
||||
for user in rulable_users:
|
||||
star = GalaxyStar(
|
||||
owner=user, galaxy=self, mass=self.compute_user_score(user)
|
||||
)
|
||||
stars.append(star)
|
||||
GalaxyStar.objects.bulk_create(stars)
|
||||
|
||||
stars = {}
|
||||
for star in GalaxyStar.objects.filter(galaxy=self):
|
||||
stars[star.owner.id] = star
|
||||
|
||||
self.logger.info("Creating lanes between stars")
|
||||
# Display current speed every $speed_count_frequency users
|
||||
speed_count_frequency = max(rulable_users_count // 10, 1) # ten time at most
|
||||
global_avg_speed_accumulator = 0
|
||||
global_avg_speed_count = 0
|
||||
t_global_start = time.time()
|
||||
@ -410,19 +472,20 @@ class Galaxy(models.Model):
|
||||
|
||||
star1 = stars[user1.id]
|
||||
|
||||
lanes = []
|
||||
family_scores = self.compute_user_family_score(user1)
|
||||
picture_scores = self.compute_user_pictures_score(user1)
|
||||
club_scores = self.compute_user_clubs_score(user1)
|
||||
user_avg_speed = 0
|
||||
user_avg_speed_count = 0
|
||||
|
||||
tstart = time.time()
|
||||
lanes = []
|
||||
for user2_count, user2 in enumerate(rulable_users, start=1):
|
||||
self.logger.debug("")
|
||||
self.logger.debug(
|
||||
f"\t> Examining '{user1}' ({user1_count}/{rulable_users_count}) with '{user2}' ({user2_count}/{rulable_users_count2})"
|
||||
)
|
||||
|
||||
for user2 in rulable_users:
|
||||
star2 = stars[user2.id]
|
||||
|
||||
score = RelationScore(
|
||||
family=family_scores.get(user2.id, 0),
|
||||
pictures=picture_scores.get(user2.id, 0),
|
||||
clubs=club_scores.get(user2.id, 0),
|
||||
)
|
||||
score = Galaxy.compute_users_score(user1, user2)
|
||||
distance = self.scale_distance(sum(score))
|
||||
if distance < 30: # TODO: this needs tuning with real-world data
|
||||
lanes.append(
|
||||
@ -435,8 +498,22 @@ class Galaxy(models.Model):
|
||||
clubs=score.clubs,
|
||||
)
|
||||
)
|
||||
|
||||
if user2_count % speed_count_frequency == 0:
|
||||
tend = time.time()
|
||||
delta = tend - tstart
|
||||
speed = float(speed_count_frequency) / delta
|
||||
user_avg_speed += speed
|
||||
user_avg_speed_count += 1
|
||||
self.logger.debug(
|
||||
f"\tSpeed: {speed:.2f} users per second (time for last {speed_count_frequency} citizens: {delta:.2f} second)"
|
||||
)
|
||||
tstart = time.time()
|
||||
|
||||
GalaxyLane.objects.bulk_create(lanes)
|
||||
|
||||
self.logger.info("")
|
||||
|
||||
t_global_end = time.time()
|
||||
global_delta = t_global_end - t_global_start
|
||||
speed = 1.0 / global_delta
|
||||
@ -444,19 +521,21 @@ class Galaxy(models.Model):
|
||||
global_avg_speed_count += 1
|
||||
global_avg_speed = global_avg_speed_accumulator / global_avg_speed_count
|
||||
|
||||
if user1_count % 50 == 0:
|
||||
self.logger.info("")
|
||||
self.logger.info(f" Ruling of {self} ".center(60, "#"))
|
||||
self.logger.info(
|
||||
f"Progression: {user1_count}/{rulable_users_count} "
|
||||
f"citizen -- {rulable_users_count - user1_count} remaining"
|
||||
)
|
||||
self.logger.info(f"Speed: {global_avg_speed:.2f} citizen per second")
|
||||
eta = rulable_users_count2 // global_avg_speed
|
||||
self.logger.info(
|
||||
f"ETA: {int(eta // 60 % 60)} minutes {int(eta % 60)} seconds"
|
||||
)
|
||||
self.logger.info("#" * 60)
|
||||
self.logger.info(f" Ruling of {self} ".center(60, "#"))
|
||||
self.logger.info(
|
||||
f"Progression: {user1_count}/{rulable_users_count} citizen -- {rulable_users_count - user1_count} remaining"
|
||||
)
|
||||
self.logger.info(f"Speed: {60.0 * global_avg_speed:.2f} citizen per minute")
|
||||
|
||||
# We can divide the computed ETA by 2 because each loop, there is one citizen less to check, and maths tell
|
||||
# us that this averages to a division by two
|
||||
eta = rulable_users_count2 / global_avg_speed / 2
|
||||
eta_hours = int(eta // 3600)
|
||||
eta_minutes = int(eta // 60 % 60)
|
||||
self.logger.info(
|
||||
f"ETA: {eta_hours} hours {eta_minutes} minutes ({eta / 3600 / 24:.2f} days)"
|
||||
)
|
||||
self.logger.info("#" * 60)
|
||||
t_global_start = time.time()
|
||||
|
||||
# Here, we get the IDs of the old galaxies that we'll need to delete. In normal operation, only one galaxy
|
||||
@ -477,10 +556,11 @@ class Galaxy(models.Model):
|
||||
Galaxy.objects.filter(pk__in=old_galaxies_pks).delete()
|
||||
|
||||
total_time = time.time() - total_time
|
||||
total_time_hours = int(total_time // 3600)
|
||||
total_time_minutes = int(total_time // 60 % 60)
|
||||
total_time_seconds = int(total_time % 60)
|
||||
self.logger.info(
|
||||
f"{self} ruled in {total_time_minutes} minutes, {total_time_seconds} seconds"
|
||||
f"{self} ruled in {total_time:.2f} seconds ({total_time_hours} hours, {total_time_minutes} minutes, {total_time_seconds} seconds)"
|
||||
)
|
||||
|
||||
def make_state(self) -> None:
|
||||
@ -488,34 +568,59 @@ class Galaxy(models.Model):
|
||||
self.logger.info(
|
||||
"Caching current Galaxy state for a quicker display of the Empire's power."
|
||||
)
|
||||
|
||||
without_nickname = Concat(
|
||||
F("owner__first_name"), Value(" "), F("owner__last_name")
|
||||
)
|
||||
with_nickname = Concat(
|
||||
F("owner__first_name"),
|
||||
Value(" "),
|
||||
F("owner__last_name"),
|
||||
Value(" ("),
|
||||
F("owner__nick_name"),
|
||||
Value(")"),
|
||||
)
|
||||
stars = (
|
||||
GalaxyStar.objects.filter(galaxy=self)
|
||||
.order_by("owner_id")
|
||||
.select_related("owner")
|
||||
.order_by(
|
||||
"owner"
|
||||
) # This helps determinism for the tests and doesn't cost much
|
||||
.annotate(
|
||||
owner_name=Case(
|
||||
When(owner__nick_name=None, then=without_nickname),
|
||||
default=with_nickname,
|
||||
)
|
||||
)
|
||||
)
|
||||
lanes = (
|
||||
GalaxyLane.objects.filter(star1__galaxy=self)
|
||||
.order_by("star1")
|
||||
.order_by(
|
||||
"star1"
|
||||
) # This helps determinism for the tests and doesn't cost much
|
||||
.annotate(
|
||||
star1_owner=F("star1__owner_id"), star2_owner=F("star2__owner_id")
|
||||
star1_owner=F("star1__owner__id"),
|
||||
star2_owner=F("star2__owner__id"),
|
||||
)
|
||||
)
|
||||
json = GalaxyDict(
|
||||
nodes=[
|
||||
StarDict(
|
||||
id=star.owner_id, name=star.owner.get_display_name(), mass=star.mass
|
||||
id=star.owner_id,
|
||||
name=star.owner_name,
|
||||
mass=star.mass,
|
||||
)
|
||||
for star in stars
|
||||
],
|
||||
links=[
|
||||
links=[],
|
||||
)
|
||||
for path in lanes:
|
||||
json["links"].append(
|
||||
{
|
||||
"source": path.star1_owner,
|
||||
"target": path.star2_owner,
|
||||
"value": path.distance,
|
||||
}
|
||||
for path in lanes
|
||||
],
|
||||
)
|
||||
)
|
||||
self.state = json
|
||||
self.save()
|
||||
self.logger.info(f"{self} is now ready!")
|
||||
|
@ -33,7 +33,7 @@ from core.models import User
|
||||
from galaxy.models import Galaxy
|
||||
|
||||
|
||||
# @pytest.mark.skip(reason="Galaxy is disabled for now")
|
||||
@pytest.mark.skip(reason="Galaxy is disabled for now")
|
||||
class TestGalaxyModel(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@ -48,19 +48,15 @@ class TestGalaxyModel(TestCase):
|
||||
|
||||
def test_user_self_score(self):
|
||||
"""Test that individual user scores are correct."""
|
||||
with self.assertNumQueries(1):
|
||||
scores = Galaxy.compute_individual_scores()
|
||||
expected = {
|
||||
self.root.id: 9,
|
||||
self.skia.id: 10,
|
||||
self.sli.id: 8,
|
||||
self.krophil.id: 2,
|
||||
self.richard.id: 10,
|
||||
self.subscriber.id: 8,
|
||||
self.public.id: 8,
|
||||
self.com.id: 1,
|
||||
}
|
||||
assert scores.items() >= expected.items()
|
||||
with self.assertNumQueries(8):
|
||||
assert Galaxy.compute_user_score(self.root) == 9
|
||||
assert Galaxy.compute_user_score(self.skia) == 10
|
||||
assert Galaxy.compute_user_score(self.sli) == 8
|
||||
assert Galaxy.compute_user_score(self.krophil) == 2
|
||||
assert Galaxy.compute_user_score(self.richard) == 10
|
||||
assert Galaxy.compute_user_score(self.subscriber) == 8
|
||||
assert Galaxy.compute_user_score(self.public) == 8
|
||||
assert Galaxy.compute_user_score(self.com) == 1
|
||||
|
||||
def test_users_score(self):
|
||||
"""Test on the default dataset generated by the `populate` command
|
||||
@ -122,23 +118,17 @@ class TestGalaxyModel(TestCase):
|
||||
self.com,
|
||||
]
|
||||
|
||||
with self.assertNumQueries(44):
|
||||
with self.assertNumQueries(100):
|
||||
while len(users) > 0:
|
||||
user1 = users.pop(0)
|
||||
family_scores = Galaxy.compute_user_family_score(user1)
|
||||
picture_scores = Galaxy.compute_user_pictures_score(user1)
|
||||
club_scores = Galaxy.compute_user_clubs_score(user1)
|
||||
for user2 in users:
|
||||
score = Galaxy.compute_users_score(user1, user2)
|
||||
u1 = computed_scores.get(user1.username, {})
|
||||
u1[user2.username] = {
|
||||
"score": (
|
||||
family_scores[user2.id]
|
||||
+ picture_scores[user2.id]
|
||||
+ club_scores[user2.id]
|
||||
),
|
||||
"family": family_scores[user2.id],
|
||||
"pictures": picture_scores[user2.id],
|
||||
"clubs": club_scores[user2.id],
|
||||
"score": sum(score),
|
||||
"family": score.family,
|
||||
"pictures": score.pictures,
|
||||
"clubs": score.clubs,
|
||||
}
|
||||
computed_scores[user1.username] = u1
|
||||
|
||||
@ -150,12 +140,12 @@ class TestGalaxyModel(TestCase):
|
||||
that the number of queries to rule the galaxy is stable.
|
||||
"""
|
||||
galaxy = Galaxy.objects.create()
|
||||
with self.assertNumQueries(39):
|
||||
with self.assertNumQueries(58):
|
||||
galaxy.rule(0) # We want everybody here
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
# @pytest.mark.skip(reason="Galaxy is disabled for now")
|
||||
@pytest.mark.skip(reason="Galaxy is disabled for now")
|
||||
class TestGalaxyView(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
@ -6,7 +6,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-06-16 14:54+0200\n"
|
||||
"POT-Creation-Date: 2025-06-30 15:15+0200\n"
|
||||
"PO-Revision-Date: 2016-07-18\n"
|
||||
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||
@ -239,7 +239,7 @@ msgid "role"
|
||||
msgstr "rôle"
|
||||
|
||||
#: club/models.py core/models.py counter/models.py election/models.py
|
||||
#: forum/models.py
|
||||
#: forum/models.py reservation/models.py
|
||||
msgid "description"
|
||||
msgstr "description"
|
||||
|
||||
@ -515,6 +515,18 @@ msgstr "Nouveau Trombi"
|
||||
msgid "Posters"
|
||||
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
|
||||
msgid "Counters:"
|
||||
msgstr "Comptoirs : "
|
||||
@ -755,7 +767,7 @@ msgstr "Une description plus détaillée et exhaustive de l'évènement."
|
||||
msgid "The club which organizes the event."
|
||||
msgstr "Le club qui organise l'évènement."
|
||||
|
||||
#: com/models.py pedagogy/models.py trombi/models.py
|
||||
#: com/models.py pedagogy/models.py reservation/models.py trombi/models.py
|
||||
msgid "author"
|
||||
msgstr "auteur"
|
||||
|
||||
@ -901,7 +913,7 @@ msgid "News admin"
|
||||
msgstr "Administration des nouvelles"
|
||||
|
||||
#: com/templates/com/news_admin_list.jinja com/templates/com/news_detail.jinja
|
||||
#: com/templates/com/news_list.jinja com/views.py
|
||||
#: com/views.py
|
||||
msgid "News"
|
||||
msgstr "Nouvelles"
|
||||
|
||||
@ -1043,6 +1055,11 @@ msgstr "Guide des UVs"
|
||||
msgid "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
|
||||
#: core/templates/core/user_tools.jinja
|
||||
msgid "Elections"
|
||||
@ -1708,27 +1725,27 @@ msgstr "500, Erreur Serveur"
|
||||
msgid "Welcome!"
|
||||
msgstr "Bienvenue !"
|
||||
|
||||
#: core/templates/core/base.jinja core/templates/core/base/navbar.jinja
|
||||
#: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja
|
||||
msgid "Contacts"
|
||||
msgstr "Contacts"
|
||||
|
||||
#: core/templates/core/base.jinja
|
||||
#: core/templates/core/base/footer.jinja
|
||||
msgid "Legal notices"
|
||||
msgstr "Mentions légales"
|
||||
|
||||
#: core/templates/core/base.jinja
|
||||
#: core/templates/core/base/footer.jinja
|
||||
msgid "Intellectual property"
|
||||
msgstr "Propriété intellectuelle"
|
||||
|
||||
#: core/templates/core/base.jinja
|
||||
#: core/templates/core/base/footer.jinja
|
||||
msgid "Help & Documentation"
|
||||
msgstr "Aide & Documentation"
|
||||
|
||||
#: core/templates/core/base.jinja
|
||||
#: core/templates/core/base/footer.jinja
|
||||
msgid "R&D"
|
||||
msgstr "R&D"
|
||||
|
||||
#: core/templates/core/base.jinja
|
||||
#: core/templates/core/base/footer.jinja
|
||||
msgid "Site created by the IT Department of the AE"
|
||||
msgstr "Site réalisé par le Pôle Informatique de l'AE"
|
||||
|
||||
@ -1868,6 +1885,7 @@ msgstr "Confirmation"
|
||||
#: core/templates/core/file_delete_confirm.jinja
|
||||
#: counter/templates/counter/counter_click.jinja
|
||||
#: counter/templates/counter/fragments/delete_student_card.jinja
|
||||
#: reservation/templates/reservation/fragments/create_reservation.jinja
|
||||
#: sas/templates/sas/ask_picture_removal.jinja
|
||||
msgid "Cancel"
|
||||
msgstr "Annuler"
|
||||
@ -2015,10 +2033,8 @@ msgid "Please login or create an account to see this page."
|
||||
msgstr "Merci de vous identifier ou de créer un compte pour voir cette page."
|
||||
|
||||
#: core/templates/core/login.jinja
|
||||
msgid "Your username and password didn't match. Please try again."
|
||||
msgstr ""
|
||||
"Votre nom d'utilisateur et votre mot de passe ne correspondent pas. Merci de "
|
||||
"réessayer."
|
||||
msgid "Your credentials didn't match. Please try again."
|
||||
msgstr "Vos identifiants ne correspondent pas. Veuillez réessayer."
|
||||
|
||||
#: core/templates/core/login.jinja
|
||||
msgid "Lost password?"
|
||||
@ -2994,7 +3010,7 @@ msgstr "Mettre à True si le mail a reçu une erreur"
|
||||
msgid "The operation that emptied the account."
|
||||
msgstr "L'opération qui a vidé le compte."
|
||||
|
||||
#: counter/models.py pedagogy/models.py
|
||||
#: counter/models.py pedagogy/models.py reservation/models.py
|
||||
msgid "comment"
|
||||
msgstr "commentaire"
|
||||
|
||||
@ -4574,6 +4590,78 @@ msgstr "Autocomplétion réussite"
|
||||
msgid "An error occurred: "
|
||||
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
|
||||
msgid "User that will be kept"
|
||||
msgstr "Utilisateur qui sera conservé"
|
||||
|
@ -7,7 +7,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-05-18 12:17+0200\n"
|
||||
"POT-Creation-Date: 2025-06-30 22:48+0200\n"
|
||||
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
|
||||
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||
@ -193,7 +193,7 @@ msgstr "Montrer moins"
|
||||
msgid "Show more"
|
||||
msgstr "Montrer plus"
|
||||
|
||||
#: core/static/bundled/user/family-graph-index.js
|
||||
#: core/static/bundled/user/family-graph-index.ts
|
||||
msgid "family_tree.%(extension)s"
|
||||
msgstr "arbre_genealogique.%(extension)s"
|
||||
|
||||
@ -251,6 +251,14 @@ msgstr "Types de produits réordonnés !"
|
||||
msgid "Product type reorganisation failed with status code : %d"
|
||||
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
|
||||
msgid "pictures.%(extension)s"
|
||||
msgstr "photos.%(extension)s"
|
||||
|
194
package-lock.json
generated
194
package-lock.json
generated
@ -9,14 +9,18 @@
|
||||
"version": "3",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@alpinejs/morph": "^3.14.9",
|
||||
"@alpinejs/sort": "^3.14.7",
|
||||
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||
"@fullcalendar/core": "^6.1.15",
|
||||
"@fullcalendar/daygrid": "^6.1.15",
|
||||
"@fullcalendar/icalendar": "^6.1.15",
|
||||
"@fullcalendar/list": "^6.1.15",
|
||||
"@fullcalendar/core": "^6.1.17",
|
||||
"@fullcalendar/daygrid": "^6.1.17",
|
||||
"@fullcalendar/icalendar": "^6.1.17",
|
||||
"@fullcalendar/interaction": "^6.1.17",
|
||||
"@fullcalendar/list": "^6.1.17",
|
||||
"@fullcalendar/resource": "^6.1.17",
|
||||
"@fullcalendar/resource-timeline": "^6.1.17",
|
||||
"@sentry/browser": "^9.29.0",
|
||||
"@zip.js/zip.js": "^2.7.52",
|
||||
"3d-force-graph": "^1.73.4",
|
||||
@ -29,6 +33,7 @@
|
||||
"d3-force-3d": "^3.0.5",
|
||||
"easymde": "^2.19.0",
|
||||
"glob": "^11.0.0",
|
||||
"htmx-ext-alpine-morph": "^2.0.1",
|
||||
"htmx.org": "^2.0.3",
|
||||
"jquery": "^3.7.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
@ -45,12 +50,21 @@
|
||||
"@hey-api/openapi-ts": "^0.73.0",
|
||||
"@rollup/plugin-inject": "^5.0.5",
|
||||
"@types/alpinejs": "^3.13.10",
|
||||
"@types/cytoscape-cxtmenu": "^3.4.4",
|
||||
"@types/cytoscape-klay": "^3.1.4",
|
||||
"@types/jquery": "^3.5.31",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.2.5",
|
||||
"vite-bundle-visualizer": "^1.2.1",
|
||||
"vite-plugin-static-copy": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@alpinejs/morph": {
|
||||
"version": "3.14.9",
|
||||
"resolved": "https://registry.npmjs.org/@alpinejs/morph/-/morph-3.14.9.tgz",
|
||||
"integrity": "sha512-i1mrH5Gza/egszxnCVwQWypRhsKGq28RFWHWuW7aI0+rWo1pvFw+aPhJLImbpt7hx44DtDOr5m4l9Ah+JPFmFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@alpinejs/sort": {
|
||||
"version": "3.14.9",
|
||||
"resolved": "https://registry.npmjs.org/@alpinejs/sort/-/sort-3.14.9.tgz",
|
||||
@ -2221,6 +2235,15 @@
|
||||
"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": {
|
||||
"version": "6.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.17.tgz",
|
||||
@ -2230,6 +2253,67 @@
|
||||
"@fullcalendar/core": "~6.1.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/premium-common": {
|
||||
"version": "6.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/premium-common/-/premium-common-6.1.17.tgz",
|
||||
"integrity": "sha512-zoN7fMwGMcP6Xu+2YudRAGfdwD2J+V+A/xAieXgYDSZT+5ekCsjZiwb2rmvthjt+HVnuZcqs6sGp7rnJ8Ie/mA==",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/resource": {
|
||||
"version": "6.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/resource/-/resource-6.1.17.tgz",
|
||||
"integrity": "sha512-hWnbOWlroIN5Wt4NJmHAJh/F7ge2cV6S0PdGSmLFoZJZJA0hJX9GeYRzyz4MlUoj7f4dGzBlesy2RdC+t5FEMw==",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"dependencies": {
|
||||
"@fullcalendar/premium-common": "~6.1.17"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/resource-timeline": {
|
||||
"version": "6.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/resource-timeline/-/resource-timeline-6.1.17.tgz",
|
||||
"integrity": "sha512-QMrtc1mLs4c6DtlBNmWICef8Lr4CmzE47uWS/rcJBd9K2kBzvusTp7AQQ1qn3RX5UnjNHqT8pkKO/wE4yspJQw==",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"dependencies": {
|
||||
"@fullcalendar/premium-common": "~6.1.17",
|
||||
"@fullcalendar/scrollgrid": "~6.1.17",
|
||||
"@fullcalendar/timeline": "~6.1.17"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.17",
|
||||
"@fullcalendar/resource": "~6.1.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/scrollgrid": {
|
||||
"version": "6.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/scrollgrid/-/scrollgrid-6.1.17.tgz",
|
||||
"integrity": "sha512-lzphEKwxWMS4xQVEuimzZjKFLijlSn49ExvzkYZls0VLDwOa3BYHcRlDJBjQ0LP6kauz9aatg3MfRIde/LAazA==",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"dependencies": {
|
||||
"@fullcalendar/premium-common": "~6.1.17"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/timeline": {
|
||||
"version": "6.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/timeline/-/timeline-6.1.17.tgz",
|
||||
"integrity": "sha512-UhL2OOph/S0cEKs3lzbXjS2gTxmQwaNug2XFjdljvO/ERj10v7OBXj/zvJrPyhjvWR/CSgjNgBaUpngkCu4JtQ==",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"dependencies": {
|
||||
"@fullcalendar/premium-common": "~6.1.17",
|
||||
"@fullcalendar/scrollgrid": "~6.1.17"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@hey-api/json-schema-ref-parser": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.0.6.tgz",
|
||||
@ -2723,75 +2807,75 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@sentry-internal/browser-utils": {
|
||||
"version": "9.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.29.0.tgz",
|
||||
"integrity": "sha512-Wp6UJCDVV2KVK+TG8GwdLZyDy4GtUYDmVhGMpHKPS3G/Qgpf36cY/XHwChwaHZ5P9Bk1sjS9Ok698J59S8L2nw==",
|
||||
"version": "9.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.33.0.tgz",
|
||||
"integrity": "sha512-DT9J0jIamavygIvW6rapgFb4L+7VoATPfEaV0UnXfGNXpSq18x7+vj1CyGMc//GBqqgb9SCHxJHOSkfuDYX7ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "9.29.0"
|
||||
"@sentry/core": "9.33.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/feedback": {
|
||||
"version": "9.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.29.0.tgz",
|
||||
"integrity": "sha512-ADvetGrtr+RfYcQKrQxah4fHs/xDJ/VjbStVMSuaNllzwWPYNkWIGFE6YjQ7wZszj0DQIu5/H+B6lZKsFYk4xw==",
|
||||
"version": "9.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.33.0.tgz",
|
||||
"integrity": "sha512-NQ3Q3d1xvtagI2cYZnI6C1i6hmMkUxIXUMjfO5JFTYpWGNIkzhIaoaY0HFqbiZ94FWwWdfodlQlj6r8Y+M0bnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "9.29.0"
|
||||
"@sentry/core": "9.33.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/replay": {
|
||||
"version": "9.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.29.0.tgz",
|
||||
"integrity": "sha512-we/1JPRje8sNowQCyogOV1OYWuDOP/3XmDi48XoFG2HB0XMl2HfL5LI8AvgAvC/5nrqVAAo4ktbjoVLm1fb7rg==",
|
||||
"version": "9.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.33.0.tgz",
|
||||
"integrity": "sha512-xDFrN19hDkP6+yS4ARYBruI0RinGYD8FPm7JC0BaIMP5yNWAJ80LTT0Jq9Dh1hQfDwUX34dpHy/9Aa7qv+2bRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry-internal/browser-utils": "9.29.0",
|
||||
"@sentry/core": "9.29.0"
|
||||
"@sentry-internal/browser-utils": "9.33.0",
|
||||
"@sentry/core": "9.33.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/replay-canvas": {
|
||||
"version": "9.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.29.0.tgz",
|
||||
"integrity": "sha512-TrQYhSAVPhyenvu0fNkon7BznFibu1mzS5bCudxhgOWajZluUVrXcbp8Q3WZ3R+AogrcgA3Vy6aumP/+fMKdwg==",
|
||||
"version": "9.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.33.0.tgz",
|
||||
"integrity": "sha512-lFO5DYJ32K/mui5Ck7PbqcD7wzRxTyRKiy49gCGAp7x/mhLg5utf5vWPtegiUoCiiMB22rj+n2z0geZwiGKH4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry-internal/replay": "9.29.0",
|
||||
"@sentry/core": "9.29.0"
|
||||
"@sentry-internal/replay": "9.33.0",
|
||||
"@sentry/core": "9.33.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/browser": {
|
||||
"version": "9.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.29.0.tgz",
|
||||
"integrity": "sha512-+GFX/yb+rh6V1fSgTYM6ttAgledl2aUR3T3Rg86HNuegbdX8ym6lOtUOIZ0j9jPK015HR47KIPyIZVZZJ7Rj9g==",
|
||||
"version": "9.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.33.0.tgz",
|
||||
"integrity": "sha512-emlZlpE62lcpxMEzvrQzecnh0WeS36XLQlFLEUhGaYVOw7TBl5JPIoSB4mxPrzIn4GpW++3JrtKRpDAHQn/c4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry-internal/browser-utils": "9.29.0",
|
||||
"@sentry-internal/feedback": "9.29.0",
|
||||
"@sentry-internal/replay": "9.29.0",
|
||||
"@sentry-internal/replay-canvas": "9.29.0",
|
||||
"@sentry/core": "9.29.0"
|
||||
"@sentry-internal/browser-utils": "9.33.0",
|
||||
"@sentry-internal/feedback": "9.33.0",
|
||||
"@sentry-internal/replay": "9.33.0",
|
||||
"@sentry-internal/replay-canvas": "9.33.0",
|
||||
"@sentry/core": "9.33.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/core": {
|
||||
"version": "9.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.29.0.tgz",
|
||||
"integrity": "sha512-wDyNe45PM+RCGtUn1tK7LzJ08ksv8i8KRUHrst7lsinEfRm83YH+wbWrPmwkVNEngUZvYkHwGLbNXM7xgFUuDQ==",
|
||||
"version": "9.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.33.0.tgz",
|
||||
"integrity": "sha512-0mtJAU+x10+q5aV/txyeuPjJ0TmObcD701R0tY0s71yJJOltqqMrmgNpqyuMI/VOASuzTZesiMYdbG6xb3zeSw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@ -2819,6 +2903,33 @@
|
||||
"@types/tern": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cytoscape": {
|
||||
"version": "3.21.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.21.9.tgz",
|
||||
"integrity": "sha512-JyrG4tllI6jvuISPjHK9j2Xv/LTbnLekLke5otGStjFluIyA9JjgnvgZrSBsp8cEDpiTjwgZUZwpPv8TSBcoLw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/cytoscape-cxtmenu": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/cytoscape-cxtmenu/-/cytoscape-cxtmenu-3.4.4.tgz",
|
||||
"integrity": "sha512-cuv+IdbKekswDRBIrHn97IYOzWS2/UjVr0kDIHCOYvqWy3iZkuGGM4qmHNPQ+63Dn7JgtmD0l3MKW1moyhoaKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cytoscape": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cytoscape-klay": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/cytoscape-klay/-/cytoscape-klay-3.1.4.tgz",
|
||||
"integrity": "sha512-H+tIadpcVjmDGWKFUfibwzIpH/kddfwAFsuhPparjiC+bWBm+MeNqIwwY+19ofkJZWcqWqZL6Jp8lkp+sP8Aig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cytoscape": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@ -4119,6 +4230,14 @@
|
||||
"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": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz",
|
||||
@ -5558,7 +5677,6 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@ -5774,9 +5892,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-static-copy": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.0.2.tgz",
|
||||
"integrity": "sha512-/seLvhUg44s1oU9RhjTZZy/0NPbfNctozdysKcvPovxxXZdI5l19mGq6Ri3IaTf1Dy/qChS4BSR7ayxeu8o9aQ==",
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-3.1.0.tgz",
|
||||
"integrity": "sha512-ONFBaYoN1qIiCxMCfeHI96lqLza7ujx/QClIXp4kEULUbyH2qLgYoaL8JHhk3FWjSB4TpzoaN3iMCyCFldyXzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -5790,7 +5908,7 @@
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.0.0 || ^6.0.0"
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-static-copy/node_modules/chokidar": {
|
||||
|
19
package.json
19
package.json
@ -21,7 +21,8 @@
|
||||
"#core:*": "./core/static/bundled/*",
|
||||
"#pedagogy:*": "./pedagogy/static/bundled/*",
|
||||
"#counter:*": "./counter/static/bundled/*",
|
||||
"#com:*": "./com/static/bundled/*"
|
||||
"#com:*": "./com/static/bundled/*",
|
||||
"#reservation:*": "./reservation/static/bundled/*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
@ -31,19 +32,26 @@
|
||||
"@rollup/plugin-inject": "^5.0.5",
|
||||
"@types/alpinejs": "^3.13.10",
|
||||
"@types/jquery": "^3.5.31",
|
||||
"@types/cytoscape-cxtmenu": "^3.4.4",
|
||||
"@types/cytoscape-klay": "^3.1.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.2.5",
|
||||
"vite-bundle-visualizer": "^1.2.1",
|
||||
"vite-plugin-static-copy": "^3.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alpinejs/morph": "^3.14.9",
|
||||
"@alpinejs/sort": "^3.14.7",
|
||||
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||
"@fullcalendar/core": "^6.1.15",
|
||||
"@fullcalendar/daygrid": "^6.1.15",
|
||||
"@fullcalendar/icalendar": "^6.1.15",
|
||||
"@fullcalendar/list": "^6.1.15",
|
||||
"@fullcalendar/core": "^6.1.17",
|
||||
"@fullcalendar/daygrid": "^6.1.17",
|
||||
"@fullcalendar/icalendar": "^6.1.17",
|
||||
"@fullcalendar/interaction": "^6.1.17",
|
||||
"@fullcalendar/list": "^6.1.17",
|
||||
"@fullcalendar/resource": "^6.1.17",
|
||||
"@fullcalendar/resource-timeline": "^6.1.17",
|
||||
"@sentry/browser": "^9.29.0",
|
||||
"@zip.js/zip.js": "^2.7.52",
|
||||
"3d-force-graph": "^1.73.4",
|
||||
@ -56,6 +64,7 @@
|
||||
"d3-force-3d": "^3.0.5",
|
||||
"easymde": "^2.19.0",
|
||||
"glob": "^11.0.0",
|
||||
"htmx-ext-alpine-morph": "^2.0.1",
|
||||
"htmx.org": "^2.0.3",
|
||||
"jquery": "^3.7.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
|
@ -92,7 +92,7 @@ docs = [
|
||||
default-groups = ["dev", "tests", "docs"]
|
||||
|
||||
[tool.xapian]
|
||||
version = "1.4.25"
|
||||
version = "1.4.29"
|
||||
|
||||
[tool.ruff]
|
||||
output-format = "concise" # makes ruff error logs easier to read
|
||||
|
0
reservation/__init__.py
Normal file
0
reservation/__init__.py
Normal file
19
reservation/admin.py
Normal file
19
reservation/admin.py
Normal file
@ -0,0 +1,19 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from reservation.models import ReservationSlot, Room
|
||||
|
||||
|
||||
@admin.register(Room)
|
||||
class RoomAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "club")
|
||||
list_filter = (("club", admin.RelatedOnlyFieldListFilter), "location")
|
||||
autocomplete_fields = ("club",)
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
@admin.register(ReservationSlot)
|
||||
class ReservationSlotAdmin(admin.ModelAdmin):
|
||||
list_display = ("room", "start_at", "end_at", "author")
|
||||
autocomplete_fields = ("author",)
|
||||
list_filter = ("room",)
|
||||
date_hierarchy = "start_at"
|
64
reservation/api.py
Normal file
64
reservation/api.py
Normal file
@ -0,0 +1,64 @@
|
||||
from typing import Any, Literal
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from ninja import Query
|
||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||
from ninja_extra.schemas import PaginatedResponseSchema
|
||||
|
||||
from api.permissions import HasPerm
|
||||
from reservation.models import ReservationSlot, Room
|
||||
from reservation.schemas import (
|
||||
RoomFilterSchema,
|
||||
RoomSchema,
|
||||
SlotFilterSchema,
|
||||
SlotSchema,
|
||||
UpdateReservationSlotSchema,
|
||||
)
|
||||
|
||||
|
||||
@api_controller("/reservation/room")
|
||||
class ReservableRoomController(ControllerBase):
|
||||
@route.get(
|
||||
"",
|
||||
response=list[RoomSchema],
|
||||
permissions=[HasPerm("reservation.view_room")],
|
||||
url_name="fetch_reservable_rooms",
|
||||
)
|
||||
def fetch_rooms(self, filters: Query[RoomFilterSchema]):
|
||||
return filters.filter(Room.objects.select_related("club"))
|
||||
|
||||
|
||||
@api_controller("/reservation/slot")
|
||||
class ReservationSlotController(ControllerBase):
|
||||
@route.get(
|
||||
"",
|
||||
response=PaginatedResponseSchema[SlotSchema],
|
||||
permissions=[HasPerm("reservation.view_reservationslot")],
|
||||
url_name="fetch_reservation_slots",
|
||||
)
|
||||
@paginate(PageNumberPaginationExtra)
|
||||
def fetch_slots(self, filters: Query[SlotFilterSchema]):
|
||||
return filters.filter(
|
||||
ReservationSlot.objects.select_related("author").order_by("start_at")
|
||||
)
|
||||
|
||||
@route.patch(
|
||||
"/reservation/slot/{int:slot_id}",
|
||||
permissions=[HasPerm("reservation.change_reservationslot")],
|
||||
response={
|
||||
200: None,
|
||||
409: dict[Literal["detail"], dict[str, list[str]]],
|
||||
422: dict[Literal["detail"], list[dict[str, Any]]],
|
||||
},
|
||||
url_name="change_reservation_slot",
|
||||
)
|
||||
def update_slot(self, slot_id: int, params: UpdateReservationSlotSchema):
|
||||
slot = self.get_object_or_exception(ReservationSlot, id=slot_id)
|
||||
slot.start_at = params.start_at
|
||||
slot.end_at = params.end_at
|
||||
try:
|
||||
slot.full_clean()
|
||||
slot.save()
|
||||
except ValidationError as e:
|
||||
return self.create_response({"detail": dict(e)}, status_code=409)
|
6
reservation/apps.py
Normal file
6
reservation/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ReservationConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "reservation"
|
60
reservation/forms.py
Normal file
60
reservation/forms.py
Normal file
@ -0,0 +1,60 @@
|
||||
from django import forms
|
||||
from django.core.exceptions import NON_FIELD_ERRORS
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from club.widgets.ajax_select import AutoCompleteSelectClub
|
||||
from core.models import User
|
||||
from core.views.forms import FutureDateTimeField, SelectDateTime
|
||||
from reservation.models import ReservationSlot, Room
|
||||
|
||||
|
||||
class RoomCreateForm(forms.ModelForm):
|
||||
required_css_class = "required"
|
||||
error_css_class = "error"
|
||||
|
||||
class Meta:
|
||||
model = Room
|
||||
fields = ["name", "club", "location", "description"]
|
||||
widgets = {"club": AutoCompleteSelectClub}
|
||||
|
||||
|
||||
class RoomUpdateForm(forms.ModelForm):
|
||||
required_css_class = "required"
|
||||
error_css_class = "error"
|
||||
|
||||
class Meta:
|
||||
model = Room
|
||||
fields = ["name", "club", "location", "description"]
|
||||
widgets = {"club": AutoCompleteSelectClub}
|
||||
|
||||
def __init__(self, *args, request_user: User, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if not request_user.has_perm("reservation.change_room"):
|
||||
# if the user doesn't have the global edition permission
|
||||
# (i.e. it's a club board member, but not a sith admin)
|
||||
# some fields aren't editable
|
||||
del self.fields["club"]
|
||||
|
||||
|
||||
class ReservationForm(forms.ModelForm):
|
||||
required_css_class = "required"
|
||||
error_css_class = "error"
|
||||
|
||||
class Meta:
|
||||
model = ReservationSlot
|
||||
fields = ["room", "start_at", "end_at", "comment"]
|
||||
field_classes = {"start_at": FutureDateTimeField, "end_at": FutureDateTimeField}
|
||||
widgets = {"start_at": SelectDateTime(), "end_at": SelectDateTime()}
|
||||
error_messages = {
|
||||
NON_FIELD_ERRORS: {
|
||||
"start_after_end": _("The start must be set before the end")
|
||||
}
|
||||
}
|
||||
|
||||
def __init__(self, *args, author: User, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.author = author
|
||||
|
||||
def save(self, commit: bool = True): # noqa FBT001
|
||||
self.instance.author = self.author
|
||||
return super().save(commit)
|
117
reservation/migrations/0001_initial.py
Normal file
117
reservation/migrations/0001_initial.py
Normal file
@ -0,0 +1,117 @@
|
||||
# Generated by Django 5.2.1 on 2025-06-05 10:44
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("club", "0014_alter_club_options_rename_unix_name_club_slug_name_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Room",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=100, verbose_name="room name")),
|
||||
(
|
||||
"description",
|
||||
models.TextField(
|
||||
blank=True, default="", verbose_name="description"
|
||||
),
|
||||
),
|
||||
(
|
||||
"location",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("BELFORT", "Belfort"),
|
||||
("SEVENANS", "Sévenans"),
|
||||
("MONTBELIARD", "Montbéliard"),
|
||||
],
|
||||
verbose_name="site",
|
||||
),
|
||||
),
|
||||
(
|
||||
"club",
|
||||
models.ForeignKey(
|
||||
help_text="The club which manages this room",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="reservable_rooms",
|
||||
to="club.club",
|
||||
verbose_name="room owner",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "reservable room",
|
||||
"verbose_name_plural": "reservable rooms",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ReservationSlot",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"comment",
|
||||
models.TextField(blank=True, default="", verbose_name="comment"),
|
||||
),
|
||||
(
|
||||
"start_at",
|
||||
models.DateTimeField(db_index=True, verbose_name="slot start"),
|
||||
),
|
||||
("end_at", models.DateTimeField(verbose_name="slot end")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"author",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="author",
|
||||
),
|
||||
),
|
||||
(
|
||||
"room",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="slots",
|
||||
to="reservation.room",
|
||||
verbose_name="reserved room",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "reservation slot",
|
||||
"verbose_name_plural": "reservation slots",
|
||||
"constraints": [
|
||||
models.CheckConstraint(
|
||||
condition=models.Q(("end_at__gt", models.F("start_at"))),
|
||||
name="reservation_slot_end_after_start",
|
||||
violation_error_code="start_after_end",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
0
reservation/migrations/__init__.py
Normal file
0
reservation/migrations/__init__.py
Normal file
100
reservation/models.py
Normal file
100
reservation/models.py
Normal file
@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Self
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import F, Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from club.models import Club
|
||||
from core.models import User
|
||||
|
||||
|
||||
class Room(models.Model):
|
||||
name = models.CharField(_("room name"), max_length=100)
|
||||
description = models.TextField(_("description"), blank=True, default="")
|
||||
club = models.ForeignKey(
|
||||
Club,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="reservable_rooms",
|
||||
verbose_name=_("room owner"),
|
||||
help_text=_("The club which manages this room"),
|
||||
)
|
||||
location = models.CharField(
|
||||
_("site"),
|
||||
blank=True,
|
||||
choices=[
|
||||
("BELFORT", "Belfort"),
|
||||
("SEVENANS", "Sévenans"),
|
||||
("MONTBELIARD", "Montbéliard"),
|
||||
],
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("reservable room")
|
||||
verbose_name_plural = _("reservable rooms")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def can_be_edited_by(self, user: User) -> bool:
|
||||
# a user may edit a room if it has the global perm
|
||||
# or is in the owner club board
|
||||
return user.has_perm("reservation.change_room") or self.club.board_group_id in [
|
||||
g.id for g in user.cached_groups
|
||||
]
|
||||
|
||||
|
||||
class ReservationSlotQuerySet(models.QuerySet):
|
||||
def overlapping_with(self, slot: ReservationSlot) -> Self:
|
||||
return self.filter(
|
||||
Q(start_at__lt=slot.start_at, end_at__gt=slot.start_at)
|
||||
| Q(start_at__lt=slot.end_at, end_at__gt=slot.end_at)
|
||||
)
|
||||
|
||||
|
||||
class ReservationSlot(models.Model):
|
||||
room = models.ForeignKey(
|
||||
Room,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="slots",
|
||||
verbose_name=_("reserved room"),
|
||||
)
|
||||
author = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_("author"))
|
||||
comment = models.TextField(_("comment"), blank=True, default="")
|
||||
start_at = models.DateTimeField(_("slot start"), db_index=True)
|
||||
end_at = models.DateTimeField(_("slot end"))
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
objects = ReservationSlotQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("reservation slot")
|
||||
verbose_name_plural = _("reservation slots")
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=Q(end_at__gt=F("start_at")),
|
||||
name="reservation_slot_end_after_start",
|
||||
violation_error_code="start_after_end",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.room.name} : {self.start_at} - {self.end_at}"
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.end_at is None or self.start_at is None:
|
||||
# if there is no start or no end, then there is no
|
||||
# point to check if this perm overlap with another,
|
||||
# so in this case, don't do the overlap check and let
|
||||
# Django manage the non-null constraint error.
|
||||
return
|
||||
overlapping = ReservationSlot.objects.overlapping_with(self).filter(
|
||||
room_id=self.room_id
|
||||
)
|
||||
if self.id is not None:
|
||||
overlapping = overlapping.exclude(id=self.id)
|
||||
if overlapping.exists():
|
||||
raise ValidationError(_("There is already a reservation on this slot."))
|
46
reservation/schemas.py
Normal file
46
reservation/schemas.py
Normal file
@ -0,0 +1,46 @@
|
||||
from datetime import datetime
|
||||
|
||||
from ninja import FilterSchema, ModelSchema, Schema
|
||||
from pydantic import Field, FutureDatetime
|
||||
|
||||
from club.schemas import SimpleClubSchema
|
||||
from core.schemas import SimpleUserSchema
|
||||
from reservation.models import ReservationSlot, Room
|
||||
|
||||
|
||||
class RoomFilterSchema(FilterSchema):
|
||||
club: set[int] | None = Field(None, q="club_id__in")
|
||||
|
||||
|
||||
class RoomSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = Room
|
||||
fields = ["id", "name", "description", "location"]
|
||||
|
||||
club: SimpleClubSchema
|
||||
|
||||
@staticmethod
|
||||
def resolve_location(obj: Room):
|
||||
return obj.get_location_display()
|
||||
|
||||
|
||||
class SlotFilterSchema(FilterSchema):
|
||||
after: datetime = Field(default=None, q="end_at__gt")
|
||||
before: datetime = Field(default=None, q="start_at__lt")
|
||||
room: set[int] | None = None
|
||||
club: set[int] | None = None
|
||||
|
||||
|
||||
class SlotSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = ReservationSlot
|
||||
fields = ["id", "room", "comment"]
|
||||
|
||||
start: datetime = Field(alias="start_at")
|
||||
end: datetime = Field(alias="end_at")
|
||||
author: SimpleUserSchema
|
||||
|
||||
|
||||
class UpdateReservationSlotSchema(Schema):
|
||||
start_at: FutureDatetime
|
||||
end_at: FutureDatetime
|
@ -0,0 +1,137 @@
|
||||
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
|
||||
import {
|
||||
Calendar,
|
||||
type DateSelectArg,
|
||||
type EventDropArg,
|
||||
type EventSourceFuncArg,
|
||||
} from "@fullcalendar/core";
|
||||
import enLocale from "@fullcalendar/core/locales/en-gb";
|
||||
import frLocale from "@fullcalendar/core/locales/fr";
|
||||
|
||||
import {
|
||||
type ReservationslotFetchSlotsData,
|
||||
type SlotSchema,
|
||||
reservableroomFetchRooms,
|
||||
reservationslotFetchSlots,
|
||||
reservationslotUpdateSlot,
|
||||
} from "#openapi";
|
||||
|
||||
import { paginated } from "#core:utils/api";
|
||||
import type { SlotSelectedEventArg } from "#reservation:reservation/types";
|
||||
import interactionPlugin from "@fullcalendar/interaction";
|
||||
import resourceTimelinePlugin from "@fullcalendar/resource-timeline";
|
||||
|
||||
@registerComponent("room-scheduler")
|
||||
export class RoomScheduler extends inheritHtmlElement("div") {
|
||||
static observedAttributes = ["locale", "can_edit_slot", "can_create_slot"];
|
||||
private scheduler: Calendar;
|
||||
private locale = "en";
|
||||
private canEditSlot = false;
|
||||
private canBookSlot = false;
|
||||
private canDeleteSlot = false;
|
||||
|
||||
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
|
||||
if (name === "locale") {
|
||||
this.locale = newValue;
|
||||
}
|
||||
if (name === "can_edit_slot") {
|
||||
this.canEditSlot = newValue.toLowerCase() === "true";
|
||||
}
|
||||
if (name === "can_create_slot") {
|
||||
this.canBookSlot = newValue.toLowerCase() === "true";
|
||||
}
|
||||
if (name === "can_delete_slot") {
|
||||
this.canDeleteSlot = newValue.toLowerCase() === "true";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the events displayed in the timeline.
|
||||
* cf https://fullcalendar.io/docs/events-function
|
||||
*/
|
||||
async fetchEvents(fetchInfo: EventSourceFuncArg) {
|
||||
const res: SlotSchema[] = await paginated(reservationslotFetchSlots, {
|
||||
query: { after: fetchInfo.startStr, before: fetchInfo.endStr },
|
||||
} as ReservationslotFetchSlotsData);
|
||||
return res.map((i) =>
|
||||
Object.assign(i, {
|
||||
title: `${i.author.first_name} ${i.author.last_name}`,
|
||||
resourceId: i.room,
|
||||
editable: new Date(i.start) > new Date(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the resources which events are associated with.
|
||||
* cf https://fullcalendar.io/docs/resources-function
|
||||
*/
|
||||
async fetchResources() {
|
||||
const res = await reservableroomFetchRooms();
|
||||
return res.data.map((i) => Object.assign(i, { title: i.name, group: i.location }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to the API to change
|
||||
* the start and the duration of a reservation slot
|
||||
*/
|
||||
async changeReservation(args: EventDropArg) {
|
||||
const response = await reservationslotUpdateSlot({
|
||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||
path: { slot_id: Number.parseInt(args.event.id) },
|
||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||
body: { start_at: args.event.startStr, end_at: args.event.endStr },
|
||||
});
|
||||
if (response.response.ok) {
|
||||
document.dispatchEvent(new CustomEvent("reservationSlotChanged"));
|
||||
this.scheduler.refetchEvents();
|
||||
}
|
||||
}
|
||||
|
||||
selectFreeSlot(infos: DateSelectArg) {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent<SlotSelectedEventArg>("timeSlotSelected", {
|
||||
detail: {
|
||||
ressource: Number.parseInt(infos.resource.id),
|
||||
start: infos.startStr,
|
||||
end: infos.endStr,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.scheduler = new Calendar(this.node, {
|
||||
schedulerLicenseKey: "GPL-My-Project-Is-Open-Source",
|
||||
initialView: "resourceTimelineDay",
|
||||
headerToolbar: {
|
||||
left: "prev,next today",
|
||||
center: "title",
|
||||
right: "resourceTimelineDay,resourceTimelineWeek",
|
||||
},
|
||||
plugins: [resourceTimelinePlugin, interactionPlugin],
|
||||
locales: [frLocale, enLocale],
|
||||
height: "auto",
|
||||
locale: this.locale,
|
||||
resourceGroupField: "group",
|
||||
resourceAreaHeaderContent: gettext("Rooms"),
|
||||
editable: this.canEditSlot,
|
||||
snapDuration: "00:15",
|
||||
eventConstraint: { start: new Date() }, // forbid edition of past events
|
||||
eventOverlap: false,
|
||||
eventResourceEditable: false,
|
||||
refetchResourcesOnNavigate: true,
|
||||
resourceAreaWidth: "20%",
|
||||
resources: this.fetchResources,
|
||||
events: this.fetchEvents,
|
||||
select: this.selectFreeSlot,
|
||||
selectOverlap: false,
|
||||
selectable: this.canBookSlot,
|
||||
selectConstraint: { start: new Date() },
|
||||
nowIndicator: true,
|
||||
eventDrop: this.changeReservation,
|
||||
});
|
||||
this.scheduler.render();
|
||||
}
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
import { AlertMessage } from "#core:utils/alert-message";
|
||||
import type { SlotSelectedEventArg } from "#reservation:reservation/types";
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("slotReservation", () => ({
|
||||
start: null as string,
|
||||
end: null as string,
|
||||
room: null as number,
|
||||
showForm: false,
|
||||
|
||||
init() {
|
||||
document.addEventListener(
|
||||
"timeSlotSelected",
|
||||
(event: CustomEvent<SlotSelectedEventArg>) => {
|
||||
this.start = event.detail.start.split("+")[0];
|
||||
this.end = event.detail.end.split("+")[0];
|
||||
this.room = event.detail.ressource;
|
||||
this.showForm = true;
|
||||
this.$nextTick(() => this.$el.scrollIntoView({ behavior: "smooth" })).then();
|
||||
},
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* Component that will catch events sent from the scheduler
|
||||
* to display success messages accordingly.
|
||||
*/
|
||||
Alpine.data("scheduleMessages", () => ({
|
||||
alertMessage: new AlertMessage({ defaultDuration: 2000 }),
|
||||
init() {
|
||||
document.addEventListener("reservationSlotChanged", (_event: CustomEvent) => {
|
||||
this.alertMessage.display(gettext("This slot has been successfully moved"), {
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
},
|
||||
}));
|
||||
});
|
5
reservation/static/bundled/reservation/types.d.ts
vendored
Normal file
5
reservation/static/bundled/reservation/types.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
export interface SlotSelectedEventArg {
|
||||
start: string;
|
||||
end: string;
|
||||
ressource: number;
|
||||
}
|
39
reservation/static/reservation/reservation.scss
Normal file
39
reservation/static/reservation/reservation.scss
Normal file
@ -0,0 +1,39 @@
|
||||
#slot-reservation {
|
||||
margin-top: 3em;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
h3 {
|
||||
display: block;
|
||||
margin: auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.alert, .error {
|
||||
display: block;
|
||||
margin: 1em auto auto;
|
||||
max-width: 400px;
|
||||
word-wrap: break-word;
|
||||
text-wrap: wrap;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .5em;
|
||||
justify-content: center;
|
||||
|
||||
.buttons-row {
|
||||
input[type="submit"], button {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
textarea {
|
||||
max-width: unset;
|
||||
width: 100%;
|
||||
margin-top: unset;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,51 @@
|
||||
<section
|
||||
id="slot-reservation"
|
||||
x-data="slotReservation"
|
||||
x-show="showForm"
|
||||
hx-target="this"
|
||||
hx-ext="alpine-morph"
|
||||
hx-swap="morph"
|
||||
>
|
||||
<h3>{% trans %}Book a room{% endtrans %}</h3>
|
||||
{% set non_field_errors = form.non_field_errors() %}
|
||||
{% if non_field_errors %}
|
||||
<div class="alert alert-red">
|
||||
{% for error in non_field_errors %}
|
||||
<span>{{ error }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<form
|
||||
id="slot-reservation-form"
|
||||
hx-post="{{ url("reservation:make_reservation") }}"
|
||||
hx-disabled-elt="find input[type='submit']"
|
||||
>
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
{{ form.room.errors }}
|
||||
{{ form.room.label_tag() }}
|
||||
{{ form.room|add_attr("x-model=room") }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.start_at.errors }}
|
||||
{{ form.start_at.label_tag() }}
|
||||
{{ form.start_at|add_attr("x-model=start") }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.end_at.errors }}
|
||||
{{ form.end_at.label_tag() }}
|
||||
{{ form.end_at|add_attr("x-model=end") }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{{ form.comment.errors }}
|
||||
{{ form.comment.label_tag() }}
|
||||
{{ form.comment }}
|
||||
</div>
|
||||
<div class="row gap buttons-row">
|
||||
<button class="btn btn-grey grow" @click.prevent="showForm = false">
|
||||
{% trans %}Cancel{% endtrans %}
|
||||
</button>
|
||||
<input class="btn btn-blue grow" type="submit">
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
27
reservation/templates/reservation/macros.jinja
Normal file
27
reservation/templates/reservation/macros.jinja
Normal file
@ -0,0 +1,27 @@
|
||||
{% macro room_detail(room, can_edit, can_delete) %}
|
||||
<div class="card card-row card-row-m">
|
||||
<div class="card-content">
|
||||
<strong class="card-title">{{ room.name }}</strong>
|
||||
<em>{{ room.get_location_display() }}</em>
|
||||
<p>{{ room.description|truncate(250) }}</p>
|
||||
</div>
|
||||
<div class="card-top-left">
|
||||
{% if can_edit %}
|
||||
<a
|
||||
class="btn btn-grey btn-no-text"
|
||||
href="{{ url("reservation:room_edit", room_id=room.id) }}"
|
||||
>
|
||||
<i class="fa fa-edit"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if can_delete %}
|
||||
<a
|
||||
class="btn btn-red btn-no-text"
|
||||
href="{{ url("reservation:room_delete", room_id=room.id) }}"
|
||||
>
|
||||
<i class="fa fa-trash"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
33
reservation/templates/reservation/schedule.jinja
Normal file
33
reservation/templates/reservation/schedule.jinja
Normal file
@ -0,0 +1,33 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
|
||||
{% block additional_js %}
|
||||
<script type="module" src="{{ static("bundled/reservation/components/room-scheduler-index.ts") }}"></script>
|
||||
<script type="module" src="{{ static("bundled/reservation/slot-reservation-index.ts") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static('core/components/calendar.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('reservation/reservation.scss') }}">
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<h2 class="margin-bottom">{% trans %}Room reservation{% endtrans %}</h2>
|
||||
<p
|
||||
x-data="scheduleMessages"
|
||||
class="alert snackbar"
|
||||
:class="alertMessage.success ? 'alert-green' : 'alert-red'"
|
||||
x-show="alertMessage.open"
|
||||
x-transition.duration.500ms
|
||||
x-text="alertMessage.content"
|
||||
></p>
|
||||
<room-scheduler
|
||||
locale="{{ LANGUAGE_CODE }}"
|
||||
can_edit_slot="{{ user.has_perm("reservation.change_reservationslot") }}"
|
||||
can_create_slot="{{ user.has_perm("reservation.add_reservationslot") }}"
|
||||
></room-scheduler>
|
||||
{% if user.has_perm("reservation.add_reservationslot") %}
|
||||
<p><em>{% trans %}You can book a room by selecting a free slot in the calendar.{% endtrans %}</em></p>
|
||||
{{ add_slot_fragment }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
0
reservation/tests/__init__.py
Normal file
0
reservation/tests/__init__.py
Normal file
113
reservation/tests/test_room.py
Normal file
113
reservation/tests/test_room.py
Normal file
@ -0,0 +1,113 @@
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertNumQueries, assertRedirects
|
||||
|
||||
from club.models import Club
|
||||
from core.models import User
|
||||
from reservation.forms import RoomUpdateForm
|
||||
from reservation.models import Room
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestFetchRoom:
|
||||
@pytest.fixture
|
||||
def user(self):
|
||||
return baker.make(
|
||||
User,
|
||||
user_permissions=[Permission.objects.get(codename="view_room")],
|
||||
)
|
||||
|
||||
def test_fetch_simple(self, client: Client, user: User):
|
||||
rooms = baker.make(Room, _quantity=3, _bulk_create=True)
|
||||
client.force_login(user)
|
||||
response = client.get(reverse("api:fetch_reservable_rooms"))
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [
|
||||
{
|
||||
"id": room.id,
|
||||
"name": room.name,
|
||||
"description": room.description,
|
||||
"location": room.location,
|
||||
"club": {"id": room.club.id, "name": room.club.name},
|
||||
}
|
||||
for room in rooms
|
||||
]
|
||||
|
||||
def test_nb_queries(self, client: Client, user: User):
|
||||
client.force_login(user)
|
||||
with assertNumQueries(5):
|
||||
# 4 for authentication
|
||||
# 1 to fetch the actual data
|
||||
client.get(reverse("api:fetch_reservable_rooms"))
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCreateRoom:
|
||||
def test_ok(self, client: Client):
|
||||
perm = Permission.objects.get(codename="add_room")
|
||||
club = baker.make(Club)
|
||||
client.force_login(
|
||||
baker.make(User, user_permissions=[perm], groups=[club.board_group])
|
||||
)
|
||||
response = client.post(
|
||||
reverse("reservation:room_create"),
|
||||
data={"club": club.id, "name": "test", "location": "BELFORT"},
|
||||
)
|
||||
assertRedirects(response, reverse("club:tools", kwargs={"club_id": club.id}))
|
||||
room = Room.objects.last()
|
||||
assert room is not None
|
||||
assert room.club == club
|
||||
assert room.name == "test"
|
||||
assert room.location == "BELFORT"
|
||||
|
||||
def test_permission_denied(self, client: Client):
|
||||
club = baker.make(Club)
|
||||
client.force_login(baker.make(User))
|
||||
response = client.get(reverse("reservation:room_create"))
|
||||
assert response.status_code == 403
|
||||
response = client.post(
|
||||
reverse("reservation:room_create"),
|
||||
data={"club": club.id, "name": "test", "location": "BELFORT"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestUpdateRoom:
|
||||
def test_ok(self, client: Client):
|
||||
club = baker.make(Club)
|
||||
room = baker.make(Room, club=club)
|
||||
client.force_login(baker.make(User, groups=[club.board_group]))
|
||||
url = reverse("reservation:room_edit", kwargs={"room_id": room.id})
|
||||
response = client.post(url, data={"name": "test", "location": "BELFORT"})
|
||||
assertRedirects(response, url)
|
||||
room.refresh_from_db()
|
||||
assert room.club == club
|
||||
assert room.name == "test"
|
||||
assert room.location == "BELFORT"
|
||||
|
||||
def test_permission_denied(self, client: Client):
|
||||
club = baker.make(Club)
|
||||
room = baker.make(Room, club=club)
|
||||
client.force_login(baker.make(User))
|
||||
url = reverse("reservation:room_edit", kwargs={"room_id": room.id})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 403
|
||||
response = client.post(url, data={"name": "test", "location": "BELFORT"})
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestUpdateRoomForm:
|
||||
def test_form_club_edition_rights(self):
|
||||
"""The club field should appear only if the request user can edit it."""
|
||||
room = baker.make(Room)
|
||||
perm = Permission.objects.get(codename="change_room")
|
||||
user_authorized = baker.make(User, user_permissions=[perm])
|
||||
assert "club" in RoomUpdateForm(request_user=user_authorized).fields
|
||||
|
||||
user_forbidden = baker.make(User, groups=[room.club.board_group])
|
||||
assert "club" not in RoomUpdateForm(request_user=user_forbidden).fields
|
207
reservation/tests/test_slot.py
Normal file
207
reservation/tests/test_slot.py
Normal file
@ -0,0 +1,207 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from model_bakery import baker
|
||||
from pytest_django.asserts import assertNumQueries
|
||||
|
||||
from core.models import User
|
||||
from reservation.forms import ReservationForm
|
||||
from reservation.models import ReservationSlot, Room
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestFetchReservationSlotsApi:
|
||||
@pytest.fixture
|
||||
def user(self):
|
||||
perm = Permission.objects.get(codename="view_reservationslot")
|
||||
return baker.make(User, user_permissions=[perm])
|
||||
|
||||
def test_fetch_simple(self, client: Client, user: User):
|
||||
slots = baker.make(ReservationSlot, _quantity=5, _bulk_create=True)
|
||||
client.force_login(user)
|
||||
response = client.get(reverse("api:fetch_reservation_slots"))
|
||||
assert response.json()["results"] == [
|
||||
{
|
||||
"id": slot.id,
|
||||
"room": slot.room_id,
|
||||
"comment": slot.comment,
|
||||
"start": slot.start_at.isoformat(timespec="milliseconds").replace(
|
||||
"+00:00", "Z"
|
||||
),
|
||||
"end": slot.end_at.isoformat(timespec="milliseconds").replace(
|
||||
"+00:00", "Z"
|
||||
),
|
||||
"author": {
|
||||
"id": slot.author.id,
|
||||
"first_name": slot.author.first_name,
|
||||
"last_name": slot.author.last_name,
|
||||
"nick_name": slot.author.nick_name,
|
||||
},
|
||||
}
|
||||
for slot in slots
|
||||
]
|
||||
|
||||
def test_nb_queries(self, client: Client, user: User):
|
||||
client.force_login(user)
|
||||
with assertNumQueries(5):
|
||||
# 4 for authentication
|
||||
# 1 to fetch the actual data
|
||||
client.get(reverse("api:fetch_reservation_slots"))
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestUpdateReservationSlotApi:
|
||||
@pytest.fixture
|
||||
def user(self):
|
||||
perm = Permission.objects.get(codename="change_reservationslot")
|
||||
return baker.make(User, user_permissions=[perm])
|
||||
|
||||
@pytest.fixture
|
||||
def slot(self):
|
||||
return baker.make(
|
||||
ReservationSlot,
|
||||
start_at=now() + timedelta(hours=2),
|
||||
end_at=now() + timedelta(hours=4),
|
||||
)
|
||||
|
||||
def test_ok(self, client: Client, user: User, slot: ReservationSlot):
|
||||
client.force_login(user)
|
||||
new_start = (slot.start_at + timedelta(hours=1)).replace(microsecond=0)
|
||||
response = client.patch(
|
||||
reverse("api:change_reservation_slot", kwargs={"slot_id": slot.id}),
|
||||
{"start_at": new_start, "end_at": new_start + timedelta(hours=2)},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
slot.refresh_from_db()
|
||||
assert slot.start_at.replace(microsecond=0) == new_start
|
||||
assert slot.end_at.replace(microsecond=0) == new_start + timedelta(hours=2)
|
||||
|
||||
def test_change_past_event(self, client, user: User, slot: ReservationSlot):
|
||||
"""Test that moving a slot that already began is impossible."""
|
||||
client.force_login(user)
|
||||
new_start = now() - timedelta(hours=1)
|
||||
response = client.patch(
|
||||
reverse("api:change_reservation_slot", kwargs={"slot_id": slot.id}),
|
||||
{"start_at": new_start, "end_at": new_start + timedelta(hours=2)},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_move_event_to_occupied_slot(
|
||||
self, client: Client, user: User, slot: ReservationSlot
|
||||
):
|
||||
client.force_login(user)
|
||||
other_slot = baker.make(
|
||||
ReservationSlot,
|
||||
room=slot.room,
|
||||
start_at=slot.end_at + timedelta(hours=1),
|
||||
end_at=slot.end_at + timedelta(hours=3),
|
||||
)
|
||||
response = client.patch(
|
||||
reverse("api:change_reservation_slot", kwargs={"slot_id": slot.id}),
|
||||
{
|
||||
"start_at": other_slot.start_at - timedelta(hours=1),
|
||||
"end_at": other_slot.start_at + timedelta(hours=1),
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestReservationForm:
|
||||
def test_ok(self):
|
||||
start = now() + timedelta(hours=2)
|
||||
end = start + timedelta(hours=1)
|
||||
form = ReservationForm(
|
||||
author=baker.make(User),
|
||||
data={"room": baker.make(Room), "start_at": start, "end_at": end},
|
||||
)
|
||||
assert form.is_valid()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("start_date", "end_date", "errors"),
|
||||
[
|
||||
(
|
||||
now() - timedelta(hours=2),
|
||||
now() + timedelta(hours=2),
|
||||
{"start_at": ["Assurez-vous que cet horodatage est dans le futur"]},
|
||||
),
|
||||
(
|
||||
now() + timedelta(hours=3),
|
||||
now() + timedelta(hours=2),
|
||||
{"__all__": ["Le début doit être placé avant la fin"]},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_invalid_timedates(self, start_date, end_date, errors):
|
||||
form = ReservationForm(
|
||||
author=baker.make(User),
|
||||
data={"room": baker.make(Room), "start_at": start_date, "end_at": end_date},
|
||||
)
|
||||
assert not form.is_valid()
|
||||
assert form.errors == errors
|
||||
|
||||
def test_unavailable_room(self):
|
||||
room = baker.make(Room)
|
||||
baker.make(
|
||||
ReservationSlot,
|
||||
room=room,
|
||||
start_at=now() + timedelta(hours=2),
|
||||
end_at=now() + timedelta(hours=4),
|
||||
)
|
||||
form = ReservationForm(
|
||||
author=baker.make(User),
|
||||
data={
|
||||
"room": room,
|
||||
"start_at": now() + timedelta(hours=1),
|
||||
"end_at": now() + timedelta(hours=3),
|
||||
},
|
||||
)
|
||||
assert not form.is_valid()
|
||||
assert form.errors == {
|
||||
"__all__": ["Il y a déjà une réservation sur ce créneau."]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCreateReservationSlot:
|
||||
@pytest.fixture
|
||||
def user(self):
|
||||
perms = Permission.objects.filter(
|
||||
codename__in=["add_reservationslot", "view_reservationslot"]
|
||||
)
|
||||
return baker.make(User, user_permissions=list(perms))
|
||||
|
||||
def test_ok(self, client: Client, user: User):
|
||||
client.force_login(user)
|
||||
start = now() + timedelta(hours=2)
|
||||
end = start + timedelta(hours=1)
|
||||
room = baker.make(Room)
|
||||
response = client.post(
|
||||
reverse("reservation:make_reservation"),
|
||||
{"room": room.id, "start_at": start, "end_at": end},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers.get("HX-Redirect", "") == reverse("reservation:main")
|
||||
slot = ReservationSlot.objects.filter(room=room).last()
|
||||
assert slot is not None
|
||||
assert slot.start_at == start
|
||||
assert slot.end_at == end
|
||||
assert slot.author == user
|
||||
|
||||
def test_permissions_denied(self, client: Client):
|
||||
client.force_login(baker.make(User))
|
||||
start = now() + timedelta(hours=2)
|
||||
end = start + timedelta(hours=1)
|
||||
response = client.post(
|
||||
reverse("reservation:make_reservation"),
|
||||
{"room": baker.make(Room), "start_at": start, "end_at": end},
|
||||
)
|
||||
assert response.status_code == 403
|
19
reservation/urls.py
Normal file
19
reservation/urls.py
Normal file
@ -0,0 +1,19 @@
|
||||
from django.urls import path
|
||||
|
||||
from reservation.views import (
|
||||
ReservationFragment,
|
||||
ReservationScheduleView,
|
||||
RoomCreateView,
|
||||
RoomDeleteView,
|
||||
RoomUpdateView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("", ReservationScheduleView.as_view(), name="main"),
|
||||
path("room/create/", RoomCreateView.as_view(), name="room_create"),
|
||||
path("room/<int:room_id>/edit", RoomUpdateView.as_view(), name="room_edit"),
|
||||
path("room/<int:room_id>/delete", RoomDeleteView.as_view(), name="room_delete"),
|
||||
path(
|
||||
"fragment/reservation", ReservationFragment.as_view(), name="make_reservation"
|
||||
),
|
||||
]
|
72
reservation/views.py
Normal file
72
reservation/views.py
Normal file
@ -0,0 +1,72 @@
|
||||
# Create your views here.
|
||||
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.contrib.messages.views import SuccessMessageMixin
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import CreateView, DeleteView, TemplateView, UpdateView
|
||||
|
||||
from club.models import Club
|
||||
from core.auth.mixins import CanEditMixin
|
||||
from core.views import UseFragmentsMixin
|
||||
from core.views.mixins import FragmentMixin
|
||||
from reservation.forms import ReservationForm, RoomCreateForm, RoomUpdateForm
|
||||
from reservation.models import ReservationSlot, Room
|
||||
|
||||
|
||||
class ReservationFragment(PermissionRequiredMixin, FragmentMixin, CreateView):
|
||||
model = ReservationSlot
|
||||
form_class = ReservationForm
|
||||
permission_required = "reservation.add_reservationslot"
|
||||
template_name = "reservation/fragments/create_reservation.jinja"
|
||||
success_url = reverse_lazy("reservation:main")
|
||||
reload_on_redirect = True
|
||||
object = None
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {"author": self.request.user}
|
||||
|
||||
|
||||
class ReservationScheduleView(PermissionRequiredMixin, UseFragmentsMixin, TemplateView):
|
||||
template_name = "reservation/schedule.jinja"
|
||||
permission_required = "reservation.view_reservationslot"
|
||||
fragments = {"add_slot_fragment": ReservationFragment}
|
||||
|
||||
|
||||
class RoomCreateView(PermissionRequiredMixin, CreateView):
|
||||
form_class = RoomCreateForm
|
||||
template_name = "core/create.jinja"
|
||||
permission_required = "reservation.add_room"
|
||||
|
||||
def get_initial(self):
|
||||
init = super().get_initial()
|
||||
if "club" in self.request.GET:
|
||||
club_id = self.request.GET["club"]
|
||||
if club_id.isdigit() and int(club_id) > 0:
|
||||
init["club"] = Club.objects.filter(id=int(club_id)).first()
|
||||
return init
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("club:tools", kwargs={"club_id": self.object.club_id})
|
||||
|
||||
|
||||
class RoomUpdateView(SuccessMessageMixin, CanEditMixin, UpdateView):
|
||||
model = Room
|
||||
pk_url_kwarg = "room_id"
|
||||
form_class = RoomUpdateForm
|
||||
template_name = "core/edit.jinja"
|
||||
success_message = _("%(name)s was updated successfully")
|
||||
|
||||
def get_form_kwargs(self):
|
||||
return super().get_form_kwargs() | {"request_user": self.request.user}
|
||||
|
||||
def get_success_url(self):
|
||||
return self.request.path
|
||||
|
||||
|
||||
class RoomDeleteView(PermissionRequiredMixin, DeleteView):
|
||||
model = Room
|
||||
pk_url_kwarg = "room_id"
|
||||
template_name = "core/delete_confirm.jinja"
|
||||
success_url = reverse_lazy("reservation:room_list")
|
||||
permission_required = "reservation.delete_room"
|
@ -53,9 +53,9 @@ class TestMergeUser(TestCase):
|
||||
self.to_keep.address = "Jerusalem"
|
||||
self.to_delete.parent_address = "Rome"
|
||||
self.to_delete.address = "Rome"
|
||||
subscribers = Group.objects.get(name="Subscribers")
|
||||
subscribers = Group.objects.get(id=settings.SITH_GROUP_SUBSCRIBERS_ID)
|
||||
mde_admin = Group.objects.get(name="MDE admin")
|
||||
sas_admin = Group.objects.get(name="SAS admin")
|
||||
sas_admin = Group.objects.get(id=settings.SITH_GROUP_SAS_ADMIN_ID)
|
||||
self.to_keep.groups.add(subscribers.id)
|
||||
self.to_delete.groups.add(mde_admin.id)
|
||||
self.to_keep.groups.add(sas_admin.id)
|
||||
|
@ -9,28 +9,35 @@ interface PagePictureConfig {
|
||||
userId: number;
|
||||
}
|
||||
|
||||
interface Album {
|
||||
id: number;
|
||||
name: string;
|
||||
pictures: PictureSchema[];
|
||||
}
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("user_pictures", (config: PagePictureConfig) => ({
|
||||
loading: true,
|
||||
pictures: [] as PictureSchema[],
|
||||
albums: {} as Record<string, PictureSchema[]>,
|
||||
albums: [] as Album[],
|
||||
|
||||
async init() {
|
||||
this.pictures = await paginated(picturesFetchPictures, {
|
||||
const pictures = await paginated(picturesFetchPictures, {
|
||||
// biome-ignore lint/style/useNamingConvention: from python api
|
||||
query: { users_identified: [config.userId] },
|
||||
} as PicturesFetchPicturesData);
|
||||
|
||||
this.albums = this.pictures.reduce(
|
||||
(acc: Record<number, PictureSchema[]>, picture: PictureSchema) => {
|
||||
if (!acc[picture.album.id]) {
|
||||
acc[picture.album.id] = [];
|
||||
}
|
||||
acc[picture.album.id].push(picture);
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
const groupedAlbums = Object.groupBy(pictures, (i: PictureSchema) => i.album.id);
|
||||
this.albums = Object.values(groupedAlbums).map((pictures: PictureSchema[]) => {
|
||||
return {
|
||||
id: pictures[0].album.id,
|
||||
name: pictures[0].album.name,
|
||||
pictures: pictures,
|
||||
};
|
||||
});
|
||||
this.albums.sort((a: Album, b: Album) => b.id - a.id);
|
||||
const hash = document.location.hash.replace("#", "");
|
||||
if (hash.startsWith("album-")) {
|
||||
this.$nextTick(() => document.getElementById(hash)?.scrollIntoView()).then();
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
}));
|
||||
|
@ -50,7 +50,7 @@
|
||||
#}
|
||||
{% macro download_button(name) %}
|
||||
<div x-data="pictures_download">
|
||||
<div x-show="pictures.length > 0" x-cloak>
|
||||
<div x-show="albums.length > 0" x-cloak>
|
||||
<button
|
||||
:disabled="isDownloading"
|
||||
class="btn btn-blue {% if name == "" %}btn-no-text{% endif %}"
|
||||
|
@ -20,17 +20,17 @@
|
||||
{{ download_button(_("Download all my pictures")) }}
|
||||
{% endif %}
|
||||
|
||||
<template x-for="[album_id, pictures] in Object.entries(albums)" x-cloak>
|
||||
<template x-for="album in albums" x-cloak>
|
||||
<section>
|
||||
<br />
|
||||
<div class="row">
|
||||
<h4 x-text="pictures[0].album.name" :id="`album-${album_id}`"></h4>
|
||||
<h4 x-text="album.name" :id="`album-${album.id}`"></h4>
|
||||
{% if user.id == object.id %}
|
||||
{{ download_button("") }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="photos">
|
||||
<template x-for="picture in pictures">
|
||||
<template x-for="picture in album.pictures">
|
||||
<a :href="picture.sas_url">
|
||||
<div
|
||||
class="photo"
|
||||
|
@ -122,6 +122,7 @@ INSTALLED_APPS = (
|
||||
"trombi",
|
||||
"matmat",
|
||||
"pedagogy",
|
||||
"reservation",
|
||||
"galaxy",
|
||||
"antispam",
|
||||
"api",
|
||||
@ -273,7 +274,7 @@ LOGGING = {
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/1.8/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = "fr-FR"
|
||||
LANGUAGE_CODE = "fr"
|
||||
|
||||
LANGUAGES = [("en", _("English")), ("fr", _("French"))]
|
||||
|
||||
@ -381,10 +382,10 @@ SITH_GROUP_SAS_ADMIN_ID = env.int("SITH_GROUP_SAS_ADMIN_ID", default=8)
|
||||
SITH_GROUP_FORUM_ADMIN_ID = env.int("SITH_GROUP_FORUM_ADMIN_ID", default=9)
|
||||
SITH_GROUP_PEDAGOGY_ADMIN_ID = env.int("SITH_GROUP_PEDAGOGY_ADMIN_ID", default=10)
|
||||
|
||||
SITH_GROUP_BANNED_ALCOHOL_ID = env.int("SITH_GROUP_BANNED_ALCOHOL_ID", default=11)
|
||||
SITH_GROUP_BANNED_COUNTER_ID = env.int("SITH_GROUP_BANNED_COUNTER_ID", default=12)
|
||||
SITH_GROUP_BANNED_ALCOHOL_ID = env.int("SITH_GROUP_BANNED_ALCOHOL_ID", default=12)
|
||||
SITH_GROUP_BANNED_COUNTER_ID = env.int("SITH_GROUP_BANNED_COUNTER_ID", default=13)
|
||||
SITH_GROUP_BANNED_SUBSCRIPTION_ID = env.int(
|
||||
"SITH_GROUP_BANNED_SUBSCRIPTION_ID", default=13
|
||||
"SITH_GROUP_BANNED_SUBSCRIPTION_ID", default=14
|
||||
)
|
||||
|
||||
SITH_CLUB_REFOUND_ID = env.int("SITH_CLUB_REFOUND_ID", default=89)
|
||||
|
@ -45,6 +45,10 @@ urlpatterns = [
|
||||
path("trombi/", include(("trombi.urls", "trombi"), namespace="trombi")),
|
||||
path("matmatronch/", include(("matmat.urls", "matmat"), namespace="matmat")),
|
||||
path("pedagogy/", include(("pedagogy.urls", "pedagogy"), namespace="pedagogy")),
|
||||
path(
|
||||
"reservation/",
|
||||
include(("reservation.urls", "reservation"), namespace="reservation"),
|
||||
),
|
||||
path("admin/", admin.site.urls),
|
||||
path("i18n/", include("django.conf.urls.i18n")),
|
||||
path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"),
|
||||
|
@ -4,7 +4,7 @@
|
||||
"sourceMap": true,
|
||||
"noImplicitAny": true,
|
||||
"module": "esnext",
|
||||
"target": "es2022",
|
||||
"target": "es2024",
|
||||
"allowJs": true,
|
||||
"moduleResolution": "node",
|
||||
"experimentalDecorators": true,
|
||||
@ -17,7 +17,8 @@
|
||||
"#core:*": ["./core/static/bundled/*"],
|
||||
"#pedagogy:*": ["./pedagogy/static/bundled/*"],
|
||||
"#counter:*": ["./counter/static/bundled/*"],
|
||||
"#com:*": ["./com/static/bundled/*"]
|
||||
"#com:*": ["./com/static/bundled/*"],
|
||||
"#reservation:*": ["./reservation/static/bundled/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
2
uv.lock
generated
2
uv.lock
generated
@ -1852,7 +1852,7 @@ dev = [
|
||||
{ name = "ipython", specifier = ">=9.0.2,<10.0.0" },
|
||||
{ name = "pre-commit", specifier = ">=4.1.0,<5.0.0" },
|
||||
{ name = "rjsmin", specifier = ">=1.2.4,<2.0.0" },
|
||||
{ name = "ruff", specifier = ">=0.11.11,<1.0.0" },
|
||||
{ name = "ruff", specifier = ">=0.11.13,<1.0.0" },
|
||||
]
|
||||
docs = [
|
||||
{ name = "mkdocs", specifier = ">=1.6.1,<2.0.0" },
|
||||
|
Reference in New Issue
Block a user