diff --git a/core/migrations/0052_preferences_show_my_timetable.py b/core/migrations/0052_preferences_show_my_timetable.py
new file mode 100644
index 00000000..eb9f37bf
--- /dev/null
+++ b/core/migrations/0052_preferences_show_my_timetable.py
@@ -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",
+ ),
+ ),
+ ]
diff --git a/core/models.py b/core/models.py
index 8b057a03..7baec8d1 100644
--- a/core/models.py
+++ b/core/models.py
@@ -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
)
diff --git a/core/templates/core/fragment/user_visibility.jinja b/core/templates/core/fragment/user_visibility.jinja
index 82e4f0ea..370665e5 100644
--- a/core/templates/core/fragment/user_visibility.jinja
+++ b/core/templates/core/fragment/user_visibility.jinja
@@ -29,5 +29,13 @@
{{ form.show_my_stats.errors }}
+
+ {{ form.show_my_timetable }}
+ {{ form.show_my_timetable.label_tag() }}
+
+ {{ form.show_my_timetable.help_text }}
+
+ {{ form.show_my_timetable.errors }}
+
\ No newline at end of file
diff --git a/core/urls.py b/core/urls.py
index 0dccdc2e..64930df4 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -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//stats/", UserStatsView.as_view(), name="user_stats"),
+ path(
+ "user//edt/",
+ UserTimetableView.as_view(),
+ name="user_timetable",
+ ),
path(
"user//gift/create/",
GiftCreateView.as_view(),
diff --git a/core/views/forms.py b/core/views/forms.py
index c0510c7d..cc235a2e 100644
--- a/core/views/forms.py
+++ b/core/views/forms.py
@@ -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
diff --git a/core/views/mixins.py b/core/views/mixins.py
index f0403662..974ea91f 100644
--- a/core/views/mixins.py
+++ b/core/views/mixins.py
@@ -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):
diff --git a/timetable/admin.py b/timetable/admin.py
index 846f6b40..772f47d8 100644
--- a/timetable/admin.py
+++ b/timetable/admin.py
@@ -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",)
diff --git a/timetable/api.py b/timetable/api.py
new file mode 100644
index 00000000..1a1a957d
--- /dev/null
+++ b/timetable/api.py
@@ -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
+ )
diff --git a/timetable/migrations/0001_initial.py b/timetable/migrations/0001_initial.py
new file mode 100644
index 00000000..a1edeb5a
--- /dev/null
+++ b/timetable/migrations/0001_initial.py
@@ -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",
+ ),
+ ),
+ ]
diff --git a/timetable/models.py b/timetable/models.py
index 6b202199..e20fb218 100644
--- a/timetable/models.py
+++ b/timetable/models.py
@@ -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)
diff --git a/timetable/schemas.py b/timetable/schemas.py
new file mode 100644
index 00000000..a5563b4f
--- /dev/null
+++ b/timetable/schemas.py
@@ -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"]
diff --git a/timetable/static/bundled/timetable/generator-index.ts b/timetable/static/bundled/timetable/timetable-index.ts
similarity index 64%
rename from timetable/static/bundled/timetable/generator-index.ts
rename to timetable/static/bundled/timetable/timetable-index.ts
index 51438094..a121a48a 100644
--- a/timetable/static/bundled/timetable/generator-index.ts
+++ b/timetable/static/bundled/timetable/timetable-index.ts
@@ -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,88 @@ function parseSlots(s: string): TimetableSlot[] {
}
document.addEventListener("alpine:init", () => {
- Alpine.data("timetableGenerator", () => ({
+ /**
+ * Alpine data that can parse a timetable, save it and export it to png.
+ */
+ Alpine.data("timetableGenerator", (userId: number) => ({
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({
+ // biome-ignore lint/style/useNamingConvention: api is snake case
+ path: { user_id: userId },
+ body: {
+ // biome-ignore lint/style/useNamingConvention: api is snake case
+ is_viewable: this.edtPublic,
+ slots: this.courses.map((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) as ApiWeekDay,
+ // biome-ignore lint/style/useNamingConvention: api is snake case
+ week_group: c.weekGroup,
+ // biome-ignore lint/style/useNamingConvention: api is snake case
+ course_type: c.courseType,
+ room: c.room,
+ ue: c.ueCode,
+ })),
+ },
+ });
+ 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,
colorPalette: [
@@ -103,18 +176,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 +192,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 +207,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 +219,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 +242,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();
- },
}));
});
diff --git a/timetable/static/timetable/css/generator.scss b/timetable/static/timetable/css/generator.scss
index 758892de..095ed30e 100644
--- a/timetable/static/timetable/css/generator.scss
+++ b/timetable/static/timetable/css/generator.scss
@@ -1,6 +1,6 @@
@import "core/static/core/colors";
-#timetable {
+.timetable {
--hour-side-width: 60px;
display: block;
diff --git a/timetable/templates/timetable/generator.jinja b/timetable/templates/timetable/generator.jinja
index 27bd83a2..7c2964f6 100644
--- a/timetable/templates/timetable/generator.jinja
+++ b/timetable/templates/timetable/generator.jinja
@@ -1,11 +1,13 @@
-{% extends 'core/base.jinja' %}
+{% extends "core/base.jinja" %}
+
+{% from "timetable/macros.jinja" import display_timetable %}
{%- block additional_css -%}
{%- endblock -%}
{%- block additional_js -%}
-
+
{%- endblock -%}
{% block title %}
@@ -32,7 +34,7 @@
{% endblock %}
{% block content %}
-
+
-
-
- {% trans %}Save to PNG{% endtrans %}
-
+ {{ display_timetable("courses") }}
+
+
+ {% if user.has_perm("timetable.add_self_timetable") %}
+
+ {% endif %}
+ {% trans %}Export{% endtrans %}
+
+ {% trans %}Export to PNG{% endtrans %}
+
+
+
{% endblock content %}
diff --git a/timetable/templates/timetable/macros.jinja b/timetable/templates/timetable/macros.jinja
new file mode 100644
index 00000000..f93bcf83
--- /dev/null
+++ b/timetable/templates/timetable/macros.jinja
@@ -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) %}
+
+{% endmacro %}
diff --git a/timetable/templates/timetable/user_timetable.jinja b/timetable/templates/timetable/user_timetable.jinja
new file mode 100644
index 00000000..870291a4
--- /dev/null
+++ b/timetable/templates/timetable/user_timetable.jinja
@@ -0,0 +1,52 @@
+{% extends "core/base.jinja" %}
+
+{% from "timetable/macros.jinja" import display_timetable %}
+
+
+{%- block additional_css -%}
+
+{%- endblock -%}
+
+{%- block additional_js -%}
+
+{%- endblock -%}
+
+{% block content %}
+ {% for timetable in timetables %}
+
+ {{ timetable.semester }}
+
+ {{ display_timetable(timetable.semester) }}
+
+
+ {% endfor %}
+{% endblock %}
+
+{%- block script -%}
+ {% set weekdays = ["lundi","mardi","mercredi","jeudi","vendredi","samedi","dimanche"] %}
+
+{%- endblock -%}
\ No newline at end of file
diff --git a/timetable/tests.py b/timetable/tests.py
deleted file mode 100644
index a39b155a..00000000
--- a/timetable/tests.py
+++ /dev/null
@@ -1 +0,0 @@
-# Create your tests here.
diff --git a/timetable/tests/__init__.py b/timetable/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/timetable/tests/test_api.py b/timetable/tests/test_api.py
new file mode 100644
index 00000000..167ac7d5
--- /dev/null
+++ b/timetable/tests/test_api.py
@@ -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})
diff --git a/timetable/tests/test_view.py b/timetable/tests/test_view.py
new file mode 100644
index 00000000..0c3646de
--- /dev/null
+++ b/timetable/tests/test_view.py
@@ -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
diff --git a/timetable/urls.py b/timetable/urls.py
index e079490c..6c9aadb7 100644
--- a/timetable/urls.py
+++ b/timetable/urls.py
@@ -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/", UserTimetableView.as_view(), name="user_timetable"),
+]
diff --git a/timetable/views.py b/timetable/views.py
index 549851b8..546fb0e4 100644
--- a/timetable/views.py
+++ b/timetable/views.py
@@ -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"))
+ }