Author SHA1 Message Date
imperosol 33289c1c04 TimetableTabMixin 2026-10-09 16:07:06 +02:00
imperosol ead76e49b9 add tests 2026-10-09 15:38:43 +02:00
imperosol e87a4755b9 view to see a user's timetables 2026-10-09 15:34:24 +02:00
imperosol c80f4ee2b8 split timetable parsing and display 2026-10-09 15:31:44 +02:00
imperosol a4964836e4 User timetable save 2026-10-09 15:26:49 +02:00
imperosol 0b5b3f6e38 add show_my_timetable to VisibilityForm 2026-10-09 15:26:49 +02:00
imperosol 32bc7e4061 add timetable visibility to UserVisibilityForm 2026-10-09 15:26:49 +02:00
imperosol f29010b5dc api to save timetable 2026-10-09 15:26:49 +02:00
imperosol 05d6c01957 add models 2026-10-09 15:04:54 +02:00
30 changed files with 835 additions and 200 deletions

No files matched your search

@@ -0,0 +1,22 @@
# Generated by Django 5.2.17 on 2026-09-24 14:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [("core", "0051_user_cgu_approved_at")]
operations = [
migrations.AddField(
model_name="preferences",
name="show_my_timetable",
field=models.BooleanField(
default=True,
help_text=(
"Allow subscribers to see your timetable and "
"to compare it to theirs (if you have filled it)"
),
verbose_name="show your timetable to others",
),
),
]
+8
View File
@@ -799,6 +799,14 @@ class Preferences(models.Model):
),
default=False,
)
show_my_timetable = models.BooleanField(
_("show your timetable to others"),
help_text=_(
"Allow subscribers to see your timetable and "
"to compare it to theirs (if you have filled it)"
),
default=True,
)
notify_on_click = models.BooleanField(
_("get a notification for every click"), default=False
)
@@ -29,5 +29,13 @@
</span>
{{ form.show_my_stats.errors }}
</fieldset>
<fieldset class="form-group">
{{ form.show_my_timetable }}
{{ form.show_my_timetable.label_tag() }}
<span class="helptext">
{{ form.show_my_timetable.help_text }}
</span>
{{ form.show_my_timetable.errors }}
</fieldset>
<input type="submit" class="btn btn-blue" value="{% trans %}Save{% endtrans %}">
</form>
+6
View File
@@ -84,6 +84,7 @@ from core.views import (
notification,
send_file,
)
from timetable.views import UserTimetableView
register_converter(FourDigitYearConverter, "yyyy")
register_converter(TwoDigitMonthConverter, "mm")
@@ -184,6 +185,11 @@ urlpatterns = [
name="user_account_detail",
),
path("user/<int:user_id>/stats/", UserStatsView.as_view(), name="user_stats"),
path(
"user/<int:user_id>/edt/",
UserTimetableView.as_view(),
name="user_timetable",
),
path(
"user/<int:user_id>/gift/create/",
GiftCreateView.as_view(),
+10 -2
View File
@@ -336,10 +336,14 @@ class UserVisibilityForm(forms.ModelForm):
__preferences_fields = forms.fields_for_model(
Preferences,
["show_my_stats"],
widgets={"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"})},
["show_my_stats", "show_my_timetable"],
widgets={
"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"}),
"show_my_timetable": forms.CheckboxInput(attrs={"class": "switch"}),
},
)
show_my_stats = __preferences_fields["show_my_stats"]
show_my_timetable = __preferences_fields["show_my_timetable"]
def __init__(
self, *args, initial: dict | None = None, instance: User | None = None, **kwargs
@@ -347,12 +351,16 @@ class UserVisibilityForm(forms.ModelForm):
if instance:
initial = initial or {}
initial["show_my_stats"] = instance.preferences.show_my_stats
initial["show_my_timetable"] = instance.preferences.show_my_timetable
super().__init__(*args, initial=initial, instance=instance, **kwargs)
def save(self, commit=True) -> User: # noqa: FBT002
instance = super().save(commit=commit)
if commit:
instance.preferences.show_my_stats = self.cleaned_data["show_my_stats"]
instance.preferences.show_my_timetable = self.cleaned_data[
"show_my_timetable"
]
instance.preferences.save()
return instance
+16 -2
View File
@@ -1,6 +1,14 @@
import copy
import inspect
from typing import TYPE_CHECKING, Any, ClassVar, LiteralString, Protocol, Unpack
from typing import (
TYPE_CHECKING,
Any,
ClassVar,
LiteralString,
Protocol,
TypedDict,
Unpack,
)
from django.core.exceptions import ImproperlyConfigured
from django.template.loader import render_to_string
@@ -12,11 +20,17 @@ if TYPE_CHECKING:
from django.utils.safestring import SafeString
class TabItem(TypedDict):
url: str
slug: str
name: str
class TabedViewMixin(View):
"""Basic functions for displaying tabs in the template."""
current_tab: ClassVar[str | None] = None
list_of_tabs: ClassVar[list | None] = None
list_of_tabs: ClassVar[list[TabItem] | None] = None
tabs_title: ClassVar[str | None] = None
def get_tabs_title(self):
+7 -19
View File
@@ -83,17 +83,8 @@
{% endmacro %}
{% block content %}
<div class="alert alert-yellow">
<div class="alert-main">
{%- trans trimmed -%}
You can see product details.
However, you cannot edit it, thus you won't be able
to submit this form.
{%- endtrans -%}
</div>
</div>
{% if object %}
<h2>{{ object }}</h2>
<h2>{% trans name=object %}Edit product {{ name }}{% endtrans %}</h2>
<p><i>{% trans %}Creation date{% endtrans %} : {{ object.created_at|date }}</i></p>
<p><i>{% trans %}Last update{% endtrans %} : {{ object.updated_at|date }}</i></p>
{% else %}
@@ -136,17 +127,19 @@
{{ form.price_formset.management_form }}
<div x-ref="formContainer">
{%- for form in form.price_formset.forms -%}
<div>{{ price_form(form) }}</div>
<div>
{{ price_form(form) }}
</div>
{%- endfor -%}
</div>
<template x-ref="formTemplate">
<div>{{ price_form(form.price_formset.empty_form) }}</div>
<div>
{{ price_form(form.price_formset.empty_form) }}
</div>
</template>
{% if user.has_perm("counter.change_product") %}
<button class="btn btn-grey" @click.prevent="addForm()">
<i class="fa fa-plus"></i> {% trans %}Add a price{% endtrans %}
</button>
{% endif %}
</div>
<br />
@@ -172,16 +165,11 @@
<template x-ref="formTemplate">
{{ action_form(form.action_formset.empty_form) }}
</template>
{% if user.has_perm("counter.change_product") %}
<button @click.prevent="addForm()" class="btn btn-grey">
<i class="fa fa-plus"></i>{% trans %}Add action{% endtrans %}
</button>
{% endif %}
</div>
<div class="row gap margin-bottom">{{ form.archived.as_field_group() }}</div>
{% if user.has_perm("counter.change_product") %}
<p><input class="btn btn-blue" type="submit" value="{% trans %}Save{% endtrans %}" /></p>
{% endif %}
</form>
{% endblock %}
@@ -80,11 +80,9 @@
<h3 class="margin-bottom">{% trans %}Product list{% endtrans %}</h3>
<div class="row margin-bottom">
{% if user.has_perm("counter.add_product") %}
<a href="{{ url('counter:new_product') }}" class="btn btn-blue">
{% trans %}New product{% endtrans %} <i class="fa fa-plus"></i>
</a>
{% endif %}
<button
class="btn btn-blue"
@click="downloadCsv()"
@@ -13,12 +13,12 @@
{% endblock %}
{% block content %}
{% if user.has_perm("counter.add_producttype") %}
<a href="{{ url('counter:new_product_type') }}" class="btn btn-blue margin-bottom">
<p>
<a href="{{ url('counter:new_product_type') }}" class="btn btn-blue">
{% trans %}New product type{% endtrans %}
<i class="fa fa-plus"></i>
</a>
{% endif %}
</p>
{% if product_types %}
<aside>
<p>
+21 -34
View File
@@ -48,7 +48,7 @@ from counter.models import (
Selling,
)
from counter.utils import is_logged_in_counter
from counter.views.mixins import CounterAdminTabsMixin
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
class CounterListView(CounterAdminTabsMixin, CanViewMixin, ListView):
@@ -84,18 +84,17 @@ class CounterEditView(
return reverse_lazy("counter:admin", kwargs={"counter_id": self.object.id})
class CounterEditPropView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
"""Edit a counter's main infos."""
class CounterEditPropView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
"""Edit a counter's main informations (for the counter's admin)."""
model = Counter
form_class = modelform_factory(Counter, fields=["name", "club", "type"])
pk_url_kwarg = "counter_id"
template_name = "core/edit.jinja"
current_tab = "counters"
permission_required = "counter.change_counter"
class CounterCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
class CounterCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
"""Create a counter (for the admins)."""
model = Counter
@@ -106,10 +105,9 @@ class CounterCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateVi
)
template_name = "core/create.jinja"
current_tab = "counters"
permission_required = "counter.add_counter"
class CounterDeleteView(CounterAdminTabsMixin, PermissionRequiredMixin, DeleteView):
class CounterDeleteView(CounterAdminTabsMixin, CounterAdminMixin, DeleteView):
"""Delete a counter (for the admins)."""
model = Counter
@@ -117,33 +115,30 @@ class CounterDeleteView(CounterAdminTabsMixin, PermissionRequiredMixin, DeleteVi
template_name = "core/delete_confirm.jinja"
success_url = reverse_lazy("counter:admin_list")
current_tab = "counters"
permission_required = "counter.delete_counter"
# Product management
class ProductTypeListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
class ProductTypeListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
"""A list view for the admins."""
model = ProductType
template_name = "counter/product_type_list.jinja"
current_tab = "product_types"
context_object_name = "product_types"
permission_required = "counter.view_producttype"
class ProductTypeCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
class ProductTypeCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
"""A create view for the admins."""
model = ProductType
fields = ["name", "description", "comment", "icon"]
template_name = "core/create.jinja"
current_tab = "products"
permission_required = "counter.add_producttype"
class ProductTypeEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
class ProductTypeEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
"""An edit view for the admins."""
model = ProductType
@@ -151,26 +146,23 @@ class ProductTypeEditView(CounterAdminTabsMixin, PermissionRequiredMixin, Update
fields = ["name", "description", "comment", "icon"]
pk_url_kwarg = "type_id"
current_tab = "products"
permission_required = "counter.change_producttype"
class ProductListView(CounterAdminTabsMixin, PermissionRequiredMixin, TemplateView):
class ProductListView(CounterAdminTabsMixin, CounterAdminMixin, TemplateView):
current_tab = "products"
template_name = "counter/product_list.jinja"
permission_required = "counter.view_product"
class ProductCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
class ProductCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
"""A create view for the admins."""
model = Product
form_class = ProductForm
template_name = "counter/product_form.jinja"
current_tab = "products"
permission_required = "counter.add_product"
class ProductEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
class ProductEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
"""An edit view for the admins."""
model = Product
@@ -178,13 +170,6 @@ class ProductEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView
pk_url_kwarg = "product_id"
template_name = "counter/product_form.jinja"
current_tab = "products"
permission_required = "counter.change_product"
def has_permission(self):
return self.request.user.has_perm("counter.change_product") or (
self.request.method == "GET"
and self.request.user.has_perm("counter.view_product")
)
class ProductFormulaListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
@@ -314,6 +299,7 @@ class RefillingDeleteView(DeleteView):
template_name = "core/delete_confirm.jinja"
def dispatch(self, request, *args, **kwargs):
"""We have here a very particular right handling, we can't inherit from CanEditPropMixin."""
self.object = self.get_object()
if timezone.now() - self.object.date <= timedelta(
minutes=settings.SITH_LAST_OPERATIONS_LIMIT
@@ -338,6 +324,7 @@ class SellingDeleteView(DeleteView):
template_name = "core/delete_confirm.jinja"
def dispatch(self, request, *args, **kwargs):
"""We have here a very particular right handling, we can't inherit from CanEditPropMixin."""
self.object = self.get_object()
if timezone.now() - self.object.date <= timedelta(
minutes=settings.SITH_LAST_OPERATIONS_LIMIT
@@ -389,23 +376,23 @@ class CounterStatView(PermissionRequiredMixin, DetailView):
return kwargs
class CounterRefillingListView(
CounterAdminTabsMixin, PermissionRequiredMixin, ListView
):
class CounterRefillingListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
"""List of refillings on a counter."""
model = Refilling
template_name = "counter/refilling_list.jinja"
current_tab = "counters"
paginate_by = 30
permission_required = "counter.view_refilling"
def get_queryset(self):
self.counter = get_object_or_404(Counter, pk=self.kwargs["counter_id"])
return Refilling.objects.filter(counter=self.counter).order_by("-date")
def dispatch(self, request, *args, **kwargs):
self.counter = get_object_or_404(Counter, pk=kwargs["counter_id"])
self.queryset = Refilling.objects.filter(counter__id=self.counter.id)
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {"counter": self.counter}
kwargs = super().get_context_data(**kwargs)
kwargs["counter"] = self.counter
return kwargs
class RefoundAccountView(UserPassesTestMixin, FormView):
+5 -8
View File
@@ -12,7 +12,7 @@
# OR WITHIN THE LOCAL FILE "LICENSE"
#
#
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.http import Http404, HttpResponse
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
@@ -21,30 +21,28 @@ from django.views.generic.edit import CreateView, UpdateView
from core.auth.mixins import CanViewMixin
from counter.forms import EticketForm
from counter.models import Eticket, Selling
from counter.views.mixins import CounterAdminTabsMixin
from counter.views.mixins import CounterAdminMixin, CounterAdminTabsMixin
class EticketListView(CounterAdminTabsMixin, PermissionRequiredMixin, ListView):
class EticketListView(CounterAdminTabsMixin, CounterAdminMixin, ListView):
"""A list view for the admins."""
model = Eticket
template_name = "counter/eticket_list.jinja"
ordering = ["id"]
current_tab = "etickets"
permission_required = "counter.view_eticket"
class EticketCreateView(CounterAdminTabsMixin, PermissionRequiredMixin, CreateView):
class EticketCreateView(CounterAdminTabsMixin, CounterAdminMixin, CreateView):
"""Create an eticket."""
model = Eticket
template_name = "core/create.jinja"
form_class = EticketForm
current_tab = "etickets"
permission_required = "counter.add_eticket"
class EticketEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView):
class EticketEditView(CounterAdminTabsMixin, CounterAdminMixin, UpdateView):
"""Edit an eticket."""
model = Eticket
@@ -52,7 +50,6 @@ class EticketEditView(CounterAdminTabsMixin, PermissionRequiredMixin, UpdateView
form_class = EticketForm
pk_url_kwarg = "eticket_id"
current_tab = "etickets"
permission_required = "counter.change_eticket"
class EticketPDFView(CanViewMixin, DetailView):
+35 -35
View File
@@ -13,13 +13,38 @@
#
#
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic.base import View
from core.views.mixins import TabedViewMixin
from counter.utils import is_logged_in_counter
class CounterAdminMixin(View):
"""Protect counter admin section."""
edit_group = [settings.SITH_GROUP_COUNTER_ADMIN_ID]
edit_club = []
def _test_group(self, user):
return any(user.is_in_group(pk=grp_id) for grp_id in self.edit_group)
def _test_club(self, user):
return any(c.can_be_edited_by(user) for c in self.edit_club)
def dispatch(self, request, *args, **kwargs):
if not (
request.user.is_root
or self._test_group(request.user)
or self._test_club(request.user)
):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class CounterTabsMixin(TabedViewMixin):
def get_tabs_title(self):
return self.object
@@ -64,70 +89,45 @@ class CounterTabsMixin(TabedViewMixin):
class CounterAdminTabsMixin(TabedViewMixin):
tabs_title = _("Counter administration")
def get_list_of_tabs(self):
user = self.request.user
res = [
list_of_tabs = [
{
"url": reverse_lazy("counter:admin_list"),
"slug": "counters",
"name": _("Counters"),
}
]
if user.has_perm("counter.view_product"):
res.append(
},
{
"url": reverse_lazy("counter:product_list"),
"slug": "products",
"name": _("Products"),
}
)
if user.has_perm("counter.view_productformula"):
res.append(
},
{
"url": reverse_lazy("counter:product_formula_list"),
"slug": "formulas",
"name": _("Formulas"),
}
)
if user.has_perm("counter.view_producttype"):
res.append(
},
{
"url": reverse_lazy("counter:product_type_list"),
"slug": "product_types",
"name": _("Product types"),
}
)
if user.has_perm("counter.view_returnableproduct"):
res.append(
},
{
"url": reverse_lazy("counter:returnable_list"),
"slug": "returnable_products",
"name": _("Returnable products"),
}
)
if user.has_perm("counter.view_cashregistersummary"):
res.append(
},
{
"url": reverse_lazy("counter:cash_summary_list"),
"slug": "cash_summary",
"name": _("Cash register summaries"),
}
)
if user.has_perm("counter.view_invoicecall"):
res.append(
},
{
"url": reverse_lazy("counter:invoices_call"),
"slug": "invoices_call",
"name": _("Invoices call"),
}
)
if user.has_perm("counter.view_eticket"):
res.append(
},
{
"url": reverse_lazy("counter:eticket_list"),
"slug": "etickets",
"name": _("Etickets"),
}
)
return res
},
]
+13 -11
View File
@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-10-09 21:44+0200\n"
"POT-Creation-Date: 2026-10-02 12:56+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"
@@ -236,7 +236,7 @@ msgstr "vous devez spécifier au moins un utilisateur ou une adresse email"
msgid "Begin date"
msgstr "Date de début"
#: club/forms.py com/forms.py counter/forms.py
#: club/forms.py com/forms.py counter/forms.py subscription/forms.py
msgid "End date"
msgstr "Date de fin"
@@ -1093,7 +1093,7 @@ msgstr "Prix d'achat"
msgid "Format: 16:9 | Resolution: 1920x1080"
msgstr "Format : 16:9 | Résolution : 1920x1080"
#: com/forms.py
#: com/forms.py subscription/forms.py
msgid "Start date"
msgstr "Date de début"
@@ -4102,6 +4102,7 @@ msgstr "Nouvelle formule"
#: counter/templates/counter/fragments/create_student_card.jinja
#: counter/templates/counter/invoices_call.jinja
#: sas/templates/sas/picture.jinja
#: subscription/templates/subscription/stats.jinja
msgid "Go"
msgstr "Valider"
@@ -4236,12 +4237,9 @@ msgid "Remove price"
msgstr "Retirer le prix"
#: counter/templates/counter/product_form.jinja
msgid ""
"You can see product details. However, you cannot edit it, thus you won't be "
"able to submit this form."
msgstr ""
"Vous pouvez voir ce produit. Cependant, vous ne pouvez pas l'éditer ; vous "
"ne serez donc pas en mesure de soumettre le formulaire."
#, python-format
msgid "Edit product %(name)s"
msgstr "Édition du produit %(name)s"
#: counter/templates/counter/product_form.jinja
msgid "Creation date"
@@ -5987,8 +5985,12 @@ msgid "Create another subscription"
msgstr "Créer une nouvelle cotisation"
#: subscription/templates/subscription/stats.jinja
msgid "All subscriptions"
msgstr "Toutes les cotisations"
msgid "Total subscriptions"
msgstr "Cotisations totales"
#: subscription/templates/subscription/stats.jinja
msgid "Subscriptions by type"
msgstr "Cotisations par type"
#: subscription/templates/subscription/subscription.jinja
msgid "Existing member"
+19 -5
View File
@@ -2,6 +2,7 @@ import type TomSelect from "tom-select";
import type { TomOption } from "tom-select/src/types";
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
import { paginated } from "#core:utils/api";
import { History } from "#core:utils/history";
import {
type IdentifiedUserSchema,
type ModerationRequestSchema,
@@ -227,6 +228,11 @@ document.addEventListener("alpine:init", () => {
* Error message when a moderation operation fails
**/
moderationError: "",
/**
* Method of pushing new url to the browser history
* Used by popstate event and always reset to it's default value when used
**/
pushstate: History.Push,
async init() {
this.pictures = (
@@ -261,10 +267,12 @@ document.addEventListener("alpine:init", () => {
if (!event.state || event.state.sasPictureId === undefined) {
return;
}
this.pushstate = History.Replace;
this.currentPicture = this.pictures.find(
(i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId, 10),
) as PictureWithIdentifications;
});
this.pushstate = History.Replace; /* Avoid first url push */
await this.updatePicture();
},
@@ -277,11 +285,17 @@ document.addEventListener("alpine:init", () => {
* the list of identified users are updated.
*/
async updatePicture(): Promise<void> {
window.history.replaceState(
{ sasPictureId: this.currentPicture.id },
"",
this.currentPicture.sas_url,
);
const updateArgs = {
data: { sasPictureId: this.currentPicture.id },
unused: "",
url: this.currentPicture.sas_url,
};
if (this.pushstate === History.Replace) {
window.history.replaceState(updateArgs.data, updateArgs.unused, updateArgs.url);
this.pushstate = History.Push;
} else {
window.history.pushState(updateArgs.data, updateArgs.unused, updateArgs.url);
}
this.moderationError = "";
const index: number = this.pictures.indexOf(this.currentPicture);
+16
View File
@@ -1 +1,17 @@
# Register your models here.
from django.contrib import admin
from timetable.models import Timetable, TimetableSlot
class TimetableSlotInline(admin.TabularInline):
model = TimetableSlot
@admin.register(Timetable)
class TimetableAdmin(admin.ModelAdmin):
list_display = ("user", "semester")
search_fields = ("user__nick_name", "user__first_name", "user__last_name")
autocomplete_fields = ("user",)
inlines = (TimetableSlotInline,)
list_select_related = ("user",)
+37
View File
@@ -0,0 +1,37 @@
from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, route
from ninja_extra.exceptions import PermissionDenied
from core.utils import get_semester_code
from timetable.models import Timetable, TimetableSlot
from timetable.schemas import CreateTimetableSchema, TimetableSchema
@api_controller("/edt", urls_namespace="timetable")
class TimetableController(ControllerBase):
@route.put(
"/{int:user_id}",
auth=SessionAuth(),
response={200: TimetableSchema},
url_name="save",
)
def save_timetable(self, user_id: int, data: CreateTimetableSchema):
user = self.context.request.user
if not user.has_perm("pedagogy.add_timetable") and not (
user.has_perm("timetable.add_self_timetable") and user.id == user_id
):
raise PermissionDenied
timetable, created = Timetable.objects.get_or_create(
semester=get_semester_code(), user_id=user_id
)
if not created:
timetable.slots.all().delete()
if user.preferences.show_my_timetable != data.is_viewable:
user.preferences.show_my_timetable = data.is_viewable
user.preferences.save()
slots = TimetableSlot.objects.bulk_create(
[TimetableSlot(timetable=timetable, **s.model_dump()) for s in data.slots]
)
return TimetableSchema(
id=timetable.id, semester=timetable.semester, slots=slots
)
+141
View File
@@ -0,0 +1,141 @@
# Generated by Django 5.2.17 on 2026-10-03 21:43
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import core.utils
class Migration(migrations.Migration):
initial = True
dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
operations = [
migrations.CreateModel(
name="Timetable",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"semester",
models.CharField(
blank=True,
default=core.utils.get_semester_code,
validators=[
django.core.validators.RegexValidator("(A|P)\\d{2}")
],
verbose_name="semester",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="timetables",
to=settings.AUTH_USER_MODEL,
verbose_name="user",
),
),
],
options={
"verbose_name": "timetable",
"verbose_name_plural": "timetables",
"permissions": [("add_self_timetable", "Can add its own timetable")],
},
),
migrations.CreateModel(
name="TimetableSlot",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"weekday",
models.SmallIntegerField(
choices=[
(1, "Monday"),
(2, "Tuesday"),
(3, "Wednesday"),
(4, "Thursday"),
(5, "Friday"),
(6, "Saturday"),
(7, "Sunday"),
],
verbose_name="weekday",
),
),
(
"start_at",
models.PositiveSmallIntegerField(
validators=[django.core.validators.MaxValueValidator(95)],
verbose_name="start at",
),
),
(
"end_at",
models.PositiveSmallIntegerField(
validators=[django.core.validators.MaxValueValidator(95)],
verbose_name="end at",
),
),
(
"course_type",
models.CharField(max_length=12, verbose_name="course type"),
),
("room", models.CharField(max_length=32, verbose_name="room")),
("ue", models.CharField(max_length=32, verbose_name="ue")),
(
"week_group",
models.CharField(
blank=True,
choices=[("A", "A"), ("B", "B")],
default="",
max_length=1,
verbose_name="week group",
),
),
(
"timetable",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="slots",
to="timetable.timetable",
verbose_name="timetable",
),
),
],
options={
"verbose_name": "timetable slot",
"verbose_name_plural": "timetable slots",
},
),
migrations.AddConstraint(
model_name="timetable",
constraint=models.UniqueConstraint(
fields=("user", "semester"), name="timetable_unique_user_semester"
),
),
migrations.AddConstraint(
model_name="timetableslot",
constraint=models.CheckConstraint(
condition=models.Q(("end_at__gt", models.F("start_at"))),
name="timetable_slot_end_after_start",
),
),
]
+101
View File
@@ -1 +1,102 @@
# Create your models here.
from datetime import timedelta
from django.core.validators import MaxValueValidator, RegexValidator
from django.db import models
from django.db.models import F, Q
from django.utils.translation import gettext_lazy as _
from core.models import User
from core.utils import get_semester_code
class Timetable(models.Model):
user = models.ForeignKey(
User,
verbose_name=_("user"),
related_name="timetables",
on_delete=models.CASCADE,
)
semester = models.CharField(
_("semester"),
default=get_semester_code,
validators=[RegexValidator(r"(A|P)\d{2}")],
blank=True,
)
class Meta:
verbose_name = _("timetable")
verbose_name_plural = _("timetables")
constraints = [
models.UniqueConstraint(
fields=("user", "semester"), name="timetable_unique_user_semester"
)
]
permissions = [("add_self_timetable", "Can add its own timetable")]
def __str__(self) -> str:
return f"{self.user} {self.semester}"
class TimetableSlot(models.Model):
MINUTES_PER_SLOT = 15
class WeekDay(models.IntegerChoices):
MONDAY = 1
TUESDAY = 2
WEDNESDAY = 3
THURSDAY = 4
FRIDAY = 5
SATURDAY = 6
SUNDAY = 7
class WeekGroup(models.TextChoices):
A = "A"
B = "B"
BOTH = ""
timetable = models.ForeignKey(
Timetable,
verbose_name=_("timetable"),
related_name="slots",
on_delete=models.CASCADE,
)
weekday = models.SmallIntegerField(_("weekday"), choices=WeekDay)
# there are 4 * 24 = 96 quarters in a day, and we start counting at 0
start_at = models.PositiveSmallIntegerField(
_("start at"), validators=[MaxValueValidator(95)]
)
end_at = models.PositiveSmallIntegerField(
_("end at"), validators=[MaxValueValidator(95)]
)
course_type = models.CharField(_("course type"), max_length=12)
room = models.CharField(_("room"), max_length=32)
ue = models.CharField(_("ue"), max_length=32)
week_group = models.CharField(
_("week group"),
max_length=1,
choices=WeekGroup,
default=WeekGroup.BOTH,
blank=True,
)
class Meta:
verbose_name = _("timetable slot")
verbose_name_plural = _("timetable slots")
constraints = [
models.CheckConstraint(
condition=Q(end_at__gt=F("start_at")),
name="timetable_slot_end_after_start",
)
]
def __str__(self) -> str:
return f"{self.start_hour} - {self.end_hour} - {self.get_weekday_display()}"
@property
def start_hour(self):
return timedelta(minutes=self.start_at * self.MINUTES_PER_SLOT)
@property
def end_hour(self):
return timedelta(minutes=self.end_at * self.MINUTES_PER_SLOT)
+35
View File
@@ -0,0 +1,35 @@
from typing import Annotated
from annotated_types import Ge, Le
from ninja import ModelSchema, Schema
from pydantic import Field
from timetable.models import Timetable, TimetableSlot
class TimetableSlotSchema(ModelSchema):
weekday: TimetableSlot.WeekDay = Field(
description=(
f"{TimetableSlot.WeekDay.MONDAY} is monday, "
f"{TimetableSlot.WeekDay.TUESDAY} is tuesday, etc."
)
)
start_at: Annotated[int, Ge(0), Le(96)]
end_at: Annotated[int, Ge(0), Le(96)]
class Meta:
model = TimetableSlot
fields = ["start_at", "end_at", "week_group", "ue", "course_type", "room"]
class CreateTimetableSchema(Schema):
is_viewable: bool
slots: list[TimetableSlotSchema]
class TimetableSchema(ModelSchema):
slots: list[TimetableSlotSchema]
class Meta:
model = Timetable
fields = ["id", "semester"]
@@ -1,4 +1,6 @@
import html2canvas from "html2canvas";
import { AlertMessage } from "#core:utils/alert-message";
import { type WeekDay as ApiWeekDay, timetableSaveTimetable } from "#openapi";
// see https://regex101.com/r/QHSaPM/3
const TIMETABLE_ROW_RE: RegExp =
@@ -76,17 +78,82 @@ function parseSlots(s: string): TimetableSlot[] {
}
document.addEventListener("alpine:init", () => {
/**
* Alpine data that can parse a timetable, save it and export it to png.
*/
Alpine.data("timetableGenerator", () => ({
content: DEFAULT_TIMETABLE,
error: null as { incorrectRow?: string },
displayedWeekdays: [] as WeekDay[],
error: null as { incorrectRow?: string } | null,
courses: [] as TimetableSlot[],
edtPublic: false,
saving: false,
timetableSavedMessage: new AlertMessage(),
generate() {
try {
this.courses = parseSlots(this.content);
this.error = null;
} catch (err) {
type ParseRowError = { cause: { row: string } };
this.error = { incorrectRow: (err as ParseRowError)?.cause?.row };
return;
}
},
async savePng() {
const elem = document.getElementById("timetable");
const img = (await html2canvas(elem as HTMLElement)).toDataURL();
const downloadLink = document.createElement("a");
downloadLink.href = img;
downloadLink.download = "edt.png";
document.body.appendChild(downloadLink);
downloadLink.click();
downloadLink.remove();
},
async saveSith() {
this.saving = true;
const res = await timetableSaveTimetable({
body: {
// biome-ignore lint/style/useNamingConvention: api is snake case
is_viewable: this.edtPublic,
slots: this.courses.map((c) =>
Object.assign({}, c, {
// biome-ignore lint/style/useNamingConvention: api is snake case
start_at: c.startSlot,
// biome-ignore lint/style/useNamingConvention: api is snake case
end_at: c.endSlot,
weekday: WEEKDAYS.indexOf(c.weekday) + 1,
}),
),
},
});
if (res.response?.ok) {
this.timetableSavedMessage.display(
gettext("This timetable has been saved in your profile"),
{ success: true },
);
} else {
this.timetableSavedMessage.display(
interpolate(gettext("Error %d: timetable save failed"), res.response?.status),
{ success: false },
);
}
this.saving = false;
},
}));
/**
* Alpine data dedicated to timetable display only.
* It can interact with the `timetableGenerator` data with x-model and x-modelable
* (for example of this, see timetable/generator.jinja)
*/
Alpine.data("timetableDisplay", (slots: TimetableSlot[]) => ({
displayedWeekdays: [] as WeekDay[],
startSlot: 0,
endSlot: 0,
table: {
height: 0,
width: 0,
},
table: { height: 0, width: 0 },
slots: slots,
colors: {} as Record<string, string>,
colorPalette: [
@@ -103,18 +170,14 @@ document.addEventListener("alpine:init", () => {
"#1b5e20",
],
generate() {
try {
this.courses = parseSlots(this.content);
this.error = null;
} catch (err) {
this.error = { incorrectRow: err?.cause?.row };
return;
}
init() {
this.$watch("slots", () => this.display());
},
display() {
// color each UE
let colorIndex = 0;
for (const slot of this.courses) {
for (const slot of this.slots) {
if (!this.colors[slot.ueCode]) {
this.colors[slot.ueCode] =
this.colorPalette[colorIndex % this.colorPalette.length];
@@ -123,13 +186,13 @@ document.addEventListener("alpine:init", () => {
}
this.displayedWeekdays = WEEKDAYS.filter((day) =>
this.courses.some((slot: TimetableSlot) => slot.weekday === day),
this.slots.some((slot: TimetableSlot) => slot.weekday === day),
);
this.startSlot = this.courses.reduce(
this.startSlot = this.slots.reduce(
(acc: number, curr: TimetableSlot) => Math.min(acc, curr.startSlot),
25 * 4,
);
this.endSlot = this.courses.reduce(
this.endSlot = this.slots.reduce(
(acc: number, curr: TimetableSlot) => Math.max(acc, curr.endSlot),
1,
);
@@ -138,7 +201,7 @@ document.addEventListener("alpine:init", () => {
},
getStyle(slot: TimetableSlot) {
const hasWeekGroup = slot.weekGroup !== undefined;
const hasWeekGroup = !!slot.weekGroup;
const width = hasWeekGroup ? SLOT_WIDTH / 2 : SLOT_WIDTH;
const leftOffset = slot.weekGroup === "B" ? SLOT_WIDTH / 2 : 0;
return {
@@ -150,15 +213,19 @@ document.addEventListener("alpine:init", () => {
};
},
getHours(): [string, object][] {
/**
* Return the hours that should be displayed on this timetable,
* with their `top` placing
*/
getHours(): [string, { top: `${number}px` }][] {
let hour: number = Number.parseInt(
this.courses
this.slots
.map((c: TimetableSlot) => c.startHour)
.reduce((res: string, hour: string) => (hour < res ? hour : res), "24:00")
.split(":")[0],
10,
);
const res: [string, object][] = [];
const res: [string, { top: `${number}px` }][] = [];
for (let i = 0; i <= this.endSlot - this.startSlot; i += 60 / MINUTES_PER_SLOT) {
res.push([`${hour}:00`, { top: `${i * SLOT_HEIGHT}px` }]);
hour += 1;
@@ -169,16 +236,5 @@ document.addEventListener("alpine:init", () => {
getWidth() {
return this.displayedWeekdays.length * SLOT_WIDTH + 20;
},
async savePng() {
const elem = document.getElementById("timetable");
const img = (await html2canvas(elem as HTMLElement)).toDataURL();
const downloadLink = document.createElement("a");
downloadLink.href = img;
downloadLink.download = "edt.png";
document.body.appendChild(downloadLink);
downloadLink.click();
downloadLink.remove();
},
}));
});
@@ -1,6 +1,6 @@
@import "core/static/core/colors";
#timetable {
.timetable {
--hour-side-width: 60px;
display: block;
+38 -40
View File
@@ -1,11 +1,13 @@
{% extends 'core/base.jinja' %}
{% extends "core/base.jinja" %}
{% from "timetable/macros.jinja" import display_timetable %}
{%- block additional_css -%}
<link rel="stylesheet" href="{{ static('timetable/css/generator.scss') }}">
{%- endblock -%}
{%- block additional_js -%}
<script type="module" src="{{ static('bundled/timetable/generator-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/timetable/timetable-index.ts') }}"></script>
{%- endblock -%}
{% block title %}
@@ -60,44 +62,40 @@
</div>
<input type="submit" class="btn btn-blue" value="{% trans %}Generate{% endtrans %}">
</form>
<div
id="timetable"
x-show="table.height > 0 && table.width > 0"
:style="{width: `${table.width+80}px`, height: `${table.height+40}px`}"
>
<div class="header">
<template x-for="weekday in displayedWeekdays">
<span x-text="weekday"></span>
</template>
</div>
<div class="content">
<div class="hours" :height="(endSlot - endSlot%4) - (startSlot - startSlot%4)">
<template x-for="[hour, style] in getHours()">
<div class="hour" :style="style">
<div x-text="hour"></div>
<div class="hour-bar" :style="{width: `${getWidth()}px`}"></div>
</div>
</template>
</div>
<div class="courses">
<template x-for="course in courses">
<div class="slot" :style="getStyle(course)">
<span class="course-type" x-text="course.courseType"></span>
<span x-text="course.ueCode"></span>
<span x-text="`${course.startHour} - ${course.endHour}`"></span>
<span x-text="(course.weekGroup ? `\nGroupe ${course.weekGroup}` : '')"></span>
<span x-text="course.room"></span>
</div>
</template>
</div>
</div>
</div>
<button
class="margin-bottom btn btn-blue"
@click="savePng"
x-show="table.height > 0 && table.width > 0"
>
{% trans %}Save to PNG{% endtrans %}
{{ display_timetable("courses") }}
<template x-if="courses.length > 0">
<section>
{% if user.has_perm("timetable.add_self_timetable") %}
<div id="timetable-save" class="margin-bottom">
<h3>{% trans %}Save on your profile{% endtrans %}</h3>
<form>
<fieldset>
<input type="checkbox" class="switch" id="edt-public-switch-id" x-model="edtPublic">
<label for="edt-public-switch-id">{% trans %}Make this timetable public{% endtrans %}</label>
<span class="helptext">
{% trans trimmed url=url("core:user_prefs", user_id=user.id) %}
You can change this at any time in <a href="{{ url }}">your preferences</a>
{% endtrans %}
</span>
</fieldset>
<button class="btn btn-blue" :disabled="saving" @click.prevent="saveSith">
{% trans %}Save timetable{% endtrans %}
</button>
<p
class="alert"
:class="timetableSavedMessage.success ? 'alert-green' : 'alert-red'"
x-show="timetableSavedMessage.open"
x-transition.duration.500ms
x-text="timetableSavedMessage.content"
></p>
</form>
</div>
{% endif %}
<h3 class="margin-bottom">{% trans %}Export{% endtrans %}</h3>
<button class="margin-bottom btn btn-blue" @click="savePng">
{% trans %}Export to PNG{% endtrans %}
</button>
</section>
</template>
</div>
{% endblock content %}
@@ -0,0 +1,46 @@
{# HTML+AlpineJS to display a timetable
Args:
ues: either a JS variable name or an array of object
satisfying the TimetableSlot interface
reactive: if True and `ues` is a variable used in an Alpine context,
then this timetable will react to modifications and
change its own display
#}
{% macro display_timetable(ues) %}
<div
x-data="timetableDisplay({{ ues }})"
x-model="{{ ues }}"
x-modelable="slots"
class="timetable"
x-show="slots.length > 0"
:style="{width: `${table.width+80}px`, height: `${table.height+40}px`}"
>
<div class="header">
<template x-for="weekday in displayedWeekdays">
<span x-text="weekday"></span>
</template>
</div>
<div class="content">
<div class="hours" :height="(endSlot - endSlot%4) - (startSlot - startSlot%4)">
<template x-for="[hour, style] in getHours()">
<div class="hour" :style="style">
<div x-text="hour"></div>
<div class="hour-bar" :style="{width: `${getWidth()}px`}"></div>
</div>
</template>
</div>
<div class="courses">
<template x-for="course in slots">
<div class="slot" :style="getStyle(course)">
<span class="course-type" x-text="course.courseType"></span>
<span x-text="course.ueCode"></span>
<span x-text="`${course.startHour} - ${course.endHour}`"></span>
<span x-text="(course.weekGroup ? `\nGroupe ${course.weekGroup}` : '')"></span>
<span x-text="course.room"></span>
</div>
</template>
</div>
</div>
</div>
{% endmacro %}
@@ -0,0 +1,52 @@
{% extends "core/base.jinja" %}
{% from "timetable/macros.jinja" import display_timetable %}
{%- block additional_css -%}
<link rel="stylesheet" href="{{ static('timetable/css/generator.scss') }}">
{%- endblock -%}
{%- block additional_js -%}
<script type="module" src="{{ static('bundled/timetable/timetable-index.ts') }}"></script>
{%- endblock -%}
{% block content %}
{% for timetable in timetables %}
<details
class="accordion"
name="{{ timetable.semester }}"
{% if loop.first %}open{% endif %}
>
<summary>{{ timetable.semester }}</summary>
<div class="accordion-content">
{{ display_timetable(timetable.semester) }}
</div>
</details>
{% endfor %}
{% endblock %}
{%- block script -%}
{% set weekdays = ["lundi","mardi","mercredi","jeudi","vendredi","samedi","dimanche"] %}
<script>
{%- for timetable in timetables -%}
const {{ timetable.semester }} = [
{%- for slot in timetable.slots.all() -%}
{
courseType: "{{ slot.course_type }}",
room: "{{ slot.room }}",
startHour: "{{ "%02d:%02d" % (slot.start_hour.seconds // 3600, (slot.start_hour.seconds % 3600 ) // 60) }}",
endHour: "{{ "%02d:%02d" % (slot.end_hour.seconds // 3600, (slot.end_hour.seconds % 3600 ) // 60) }}",
startSlot: {{ slot.start_at }},
endSlot: {{ slot.end_at }},
ueCode: "{{ slot.ue }}",
{%- if slot.week_group -%}
weekGroup: "{{ slot.week_group }}",
{%- endif -%}
weekday: "{{ weekdays[slot.weekday] }}"
},
{%- endfor -%}
];
{%- endfor -%}
</script>
{%- endblock -%}
-1
View File
@@ -1 +0,0 @@
# Create your tests here.
View File
Whitespace-only changes.
+20
View File
@@ -0,0 +1,20 @@
from django.test import TestCase
from django.urls import reverse
from core.baker_recipes import subscriber_user
from timetable.models import TimetableSlot
class TestSaveTimetable(TestCase):
@classmethod
def setUpTestData(cls) -> None:
cls.user = subscriber_user.make()
cls.payload = {
"is_viewable": True,
"slots": [
{"weekday": TimetableSlot.WeekDay.MONDAY, "start_at": 32, "end_at": 40},
{"weekday": TimetableSlot.WeekDay.MONDAY, "start_at": 41, "end_at": 49},
{"weekday": TimetableSlot.WeekDay.FRIDAY, "start_at": 52, "end_at": 64},
],
}
cls.url = reverse("api:timetable:save", kwargs={"user_id": cls.user.id})
+31
View File
@@ -0,0 +1,31 @@
from typing import TYPE_CHECKING
import pytest
from bs4 import BeautifulSoup
from django.contrib.auth.models import Permission
from django.urls import reverse
from model_bakery import baker
from core.models import User
if TYPE_CHECKING:
from django.test import Client
@pytest.mark.django_db
@pytest.mark.parametrize("can_save", [True, False])
def test_generator_ok(client: Client, can_save):
user = baker.make(User)
if can_save:
user.user_permissions.add(Permission.objects.get(codename="add_self_timetable"))
client.force_login(user)
res = client.get(reverse("timetable:generator"))
assert res.status_code == 200
soup = BeautifulSoup(res.text, "lxml")
elem = soup.find(id="timetable-save")
if can_save:
assert elem is not None
else:
assert elem is None
+5 -2
View File
@@ -1,5 +1,8 @@
from django.urls import path
from timetable.views import GeneratorView
from timetable.views import GeneratorView, UserTimetableView
urlpatterns = [path("", GeneratorView.as_view(), name="generator")]
urlpatterns = [
path("", GeneratorView.as_view(), name="generator"),
path("user/<int:user_id>", UserTimetableView.as_view(), name="user_timetable"),
]
+50 -2
View File
@@ -1,6 +1,54 @@
# Create your views here.
from django.contrib.auth.mixins import UserPassesTestMixin
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView
from django.views.generic.detail import DetailView
from core.models import User
from core.views import TabedViewMixin
class GeneratorView(TemplateView):
class TimetableTabsMixin(TabedViewMixin):
tabs_title = _("Timetables")
def get_list_of_tabs(self):
res = [
{
"url": reverse("timetable:generator"),
"slug": "generator",
"name": _("Generator"),
}
]
if self.request.user.timetables.exists():
res.append(
{
"url": reverse(
"core:user_timetable", kwargs={"user_id": self.request.user.id}
),
"slug": "saved",
"name": _("My timetables"),
}
)
return res
class GeneratorView(TimetableTabsMixin, TemplateView):
template_name = "timetable/generator.jinja"
current_tab = "generator"
class UserTimetableView(UserPassesTestMixin, TimetableTabsMixin, DetailView):
model = User
pk_url_kwarg = "user_id"
template_name = "timetable/user_timetable.jinja"
current_tab = "saved"
def test_func(self) -> bool | None:
return self.request.user.id == self.kwargs[
"user_id"
] or self.request.user.has_perm("timetable.view_timetable")
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"timetables": list(self.object.timetables.prefetch_related("slots"))
}