diff --git a/core/migrations/0051_preferences_show_my_timetable.py b/core/migrations/0051_preferences_show_my_timetable.py new file mode 100644 index 00000000..9fa4d83a --- /dev/null +++ b/core/migrations/0051_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", "0050_alter_sithfile_moderator")] + + 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/sas/static/bundled/sas/viewer-index.ts b/sas/static/bundled/sas/viewer-index.ts index 320afb6b..6ae80fc7 100644 --- a/sas/static/bundled/sas/viewer-index.ts +++ b/sas/static/bundled/sas/viewer-index.ts @@ -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 { - 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); 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/migrations/0001_initial.py b/timetable/migrations/0001_initial.py new file mode 100644 index 00000000..0f534e7b --- /dev/null +++ b/timetable/migrations/0001_initial.py @@ -0,0 +1,121 @@ +# Generated by Django 5.2.17 on 2026-09-24 21:03 + +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, + to=settings.AUTH_USER_MODEL, + verbose_name="user", + ), + ), + ], + options={"verbose_name": "timetable", "verbose_name_plural": "timetables"}, + ), + 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", + ), + ), + ( + "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", + "permissions": [("add_self_timetable", "Can add its own timetable")], + }, + ), + 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..97808735 100644 --- a/timetable/models.py +++ b/timetable/models.py @@ -1 +1,82 @@ # 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"), 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 + + 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)] + ) + + 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)