mirror of
https://github.com/ae-utbm/sith.git
synced 2026-10-05 18:34:15 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87d247dbb4 | ||
|
|
7ab412a59b | ||
|
|
12fa99eb50 | ||
|
|
5a9ed1342b | ||
|
|
7a0df3d8a7 | ||
|
|
7044a06546 |
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", "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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -799,6 +799,14 @@ class Preferences(models.Model):
|
|||||||
),
|
),
|
||||||
default=False,
|
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(
|
notify_on_click = models.BooleanField(
|
||||||
_("get a notification for every click"), default=False
|
_("get a notification for every click"), default=False
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,5 +29,13 @@
|
|||||||
</span>
|
</span>
|
||||||
{{ form.show_my_stats.errors }}
|
{{ form.show_my_stats.errors }}
|
||||||
</fieldset>
|
</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 %}">
|
<input type="submit" class="btn btn-blue" value="{% trans %}Save{% endtrans %}">
|
||||||
</form>
|
</form>
|
||||||
+10
-2
@@ -336,10 +336,14 @@ class UserVisibilityForm(forms.ModelForm):
|
|||||||
|
|
||||||
__preferences_fields = forms.fields_for_model(
|
__preferences_fields = forms.fields_for_model(
|
||||||
Preferences,
|
Preferences,
|
||||||
["show_my_stats"],
|
["show_my_stats", "show_my_timetable"],
|
||||||
widgets={"show_my_stats": forms.CheckboxInput(attrs={"class": "switch"})},
|
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_stats = __preferences_fields["show_my_stats"]
|
||||||
|
show_my_timetable = __preferences_fields["show_my_timetable"]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, *args, initial: dict | None = None, instance: User | None = None, **kwargs
|
self, *args, initial: dict | None = None, instance: User | None = None, **kwargs
|
||||||
@@ -347,12 +351,16 @@ class UserVisibilityForm(forms.ModelForm):
|
|||||||
if instance:
|
if instance:
|
||||||
initial = initial or {}
|
initial = initial or {}
|
||||||
initial["show_my_stats"] = instance.preferences.show_my_stats
|
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)
|
super().__init__(*args, initial=initial, instance=instance, **kwargs)
|
||||||
|
|
||||||
def save(self, commit=True) -> User: # noqa: FBT002
|
def save(self, commit=True) -> User: # noqa: FBT002
|
||||||
instance = super().save(commit=commit)
|
instance = super().save(commit=commit)
|
||||||
if commit:
|
if commit:
|
||||||
instance.preferences.show_my_stats = self.cleaned_data["show_my_stats"]
|
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()
|
instance.preferences.save()
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type TomSelect from "tom-select";
|
|||||||
import type { TomOption } from "tom-select/src/types";
|
import type { TomOption } from "tom-select/src/types";
|
||||||
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
|
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
|
||||||
import { paginated } from "#core:utils/api";
|
import { paginated } from "#core:utils/api";
|
||||||
|
import { History } from "#core:utils/history";
|
||||||
import {
|
import {
|
||||||
type IdentifiedUserSchema,
|
type IdentifiedUserSchema,
|
||||||
type ModerationRequestSchema,
|
type ModerationRequestSchema,
|
||||||
@@ -227,6 +228,11 @@ document.addEventListener("alpine:init", () => {
|
|||||||
* Error message when a moderation operation fails
|
* Error message when a moderation operation fails
|
||||||
**/
|
**/
|
||||||
moderationError: "",
|
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() {
|
async init() {
|
||||||
this.pictures = (
|
this.pictures = (
|
||||||
@@ -261,10 +267,12 @@ document.addEventListener("alpine:init", () => {
|
|||||||
if (!event.state || event.state.sasPictureId === undefined) {
|
if (!event.state || event.state.sasPictureId === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.pushstate = History.Replace;
|
||||||
this.currentPicture = this.pictures.find(
|
this.currentPicture = this.pictures.find(
|
||||||
(i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId, 10),
|
(i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId, 10),
|
||||||
) as PictureWithIdentifications;
|
) as PictureWithIdentifications;
|
||||||
});
|
});
|
||||||
|
this.pushstate = History.Replace; /* Avoid first url push */
|
||||||
await this.updatePicture();
|
await this.updatePicture();
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -277,11 +285,17 @@ document.addEventListener("alpine:init", () => {
|
|||||||
* the list of identified users are updated.
|
* the list of identified users are updated.
|
||||||
*/
|
*/
|
||||||
async updatePicture(): Promise<void> {
|
async updatePicture(): Promise<void> {
|
||||||
window.history.replaceState(
|
const updateArgs = {
|
||||||
{ sasPictureId: this.currentPicture.id },
|
data: { sasPictureId: this.currentPicture.id },
|
||||||
"",
|
unused: "",
|
||||||
this.currentPicture.sas_url,
|
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 = "";
|
this.moderationError = "";
|
||||||
const index: number = this.pictures.indexOf(this.currentPicture);
|
const index: number = this.pictures.indexOf(this.currentPicture);
|
||||||
|
|||||||
@@ -1 +1,17 @@
|
|||||||
# Register your models here.
|
# 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",)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from ninja.security import SessionAuth
|
||||||
|
from ninja_extra import ControllerBase, api_controller, route
|
||||||
|
|
||||||
|
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("", auth=SessionAuth(), response={200: TimetableSchema}, url_name="save")
|
||||||
|
def save_timetable(self, data: CreateTimetableSchema):
|
||||||
|
user = self.context.request.user
|
||||||
|
timetable, created = Timetable.objects.get_or_create(
|
||||||
|
semester=get_semester_code(), user=user
|
||||||
|
)
|
||||||
|
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
|
||||||
|
)
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1 +1,82 @@
|
|||||||
# Create your models here.
|
# 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)
|
||||||
@@ -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(Schema):
|
||||||
|
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"]
|
||||||
|
|
||||||
|
|
||||||
|
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 html2canvas from "html2canvas";
|
||||||
|
import { AlertMessage } from "#core:utils/alert-message";
|
||||||
|
import { timetableSaveTimetable } from "#openapi";
|
||||||
|
|
||||||
// see https://regex101.com/r/QHSaPM/3
|
// see https://regex101.com/r/QHSaPM/3
|
||||||
const TIMETABLE_ROW_RE: RegExp =
|
const TIMETABLE_ROW_RE: RegExp =
|
||||||
@@ -78,15 +80,15 @@ function parseSlots(s: string): TimetableSlot[] {
|
|||||||
document.addEventListener("alpine:init", () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
Alpine.data("timetableGenerator", () => ({
|
Alpine.data("timetableGenerator", () => ({
|
||||||
content: DEFAULT_TIMETABLE,
|
content: DEFAULT_TIMETABLE,
|
||||||
error: null as { incorrectRow?: string },
|
error: null as { incorrectRow?: string } | null,
|
||||||
displayedWeekdays: [] as WeekDay[],
|
displayedWeekdays: [] as WeekDay[],
|
||||||
courses: [] as TimetableSlot[],
|
courses: [] as TimetableSlot[],
|
||||||
startSlot: 0,
|
startSlot: 0,
|
||||||
endSlot: 0,
|
endSlot: 0,
|
||||||
table: {
|
table: { height: 0, width: 0 },
|
||||||
height: 0,
|
edtPublic: false,
|
||||||
width: 0,
|
saving: false,
|
||||||
},
|
timetableSavedMessage: new AlertMessage(),
|
||||||
|
|
||||||
colors: {} as Record<string, string>,
|
colors: {} as Record<string, string>,
|
||||||
colorPalette: [
|
colorPalette: [
|
||||||
@@ -108,7 +110,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.courses = parseSlots(this.content);
|
this.courses = parseSlots(this.content);
|
||||||
this.error = null;
|
this.error = null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.error = { incorrectRow: err?.cause?.row };
|
type ParseRowError = { cause: { row: string } };
|
||||||
|
this.error = { incorrectRow: (err as ParseRowError)?.cause?.row };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,5 +183,36 @@ document.addEventListener("alpine:init", () => {
|
|||||||
downloadLink.click();
|
downloadLink.click();
|
||||||
downloadLink.remove();
|
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;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
@@ -92,12 +92,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<template x-if="table.height > 0 && table.width > 0">
|
||||||
class="margin-bottom btn btn-blue"
|
<section>
|
||||||
@click="savePng"
|
{% if user.has_perm("timetable.add_self_timetable") %}
|
||||||
x-show="table.height > 0 && table.width > 0"
|
<div id="timetable-save" class="margin-bottom">
|
||||||
>
|
<h3>{% trans %}Save on your profile{% endtrans %}</h3>
|
||||||
{% trans %}Save to PNG{% endtrans %}
|
<form>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{% extends 'core/base.jinja' %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -1 +0,0 @@
|
|||||||
# Create your tests here.
|
|
||||||
Whitespace-only changes.
@@ -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})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import pytest
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
|
from django.test import Client
|
||||||
|
from django.urls import reverse
|
||||||
|
from model_bakery import baker
|
||||||
|
|
||||||
|
from core.baker_recipes import subscriber_user
|
||||||
|
from core.models import User
|
||||||
|
|
||||||
|
# class TestGenerator(TestCase)
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
@@ -1,6 +1,21 @@
|
|||||||
# Create your views here.
|
# Create your views here.
|
||||||
|
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
from django.views.generic.detail import DetailView
|
||||||
|
|
||||||
|
from core.models import User
|
||||||
|
|
||||||
|
|
||||||
class GeneratorView(TemplateView):
|
class GeneratorView(TemplateView):
|
||||||
template_name = "timetable/generator.jinja"
|
template_name = "timetable/generator.jinja"
|
||||||
|
|
||||||
|
|
||||||
|
class UserTimetableView(UserPassesTestMixin, DetailView):
|
||||||
|
model = User
|
||||||
|
pk_url_kwarg = "user_id"
|
||||||
|
template_name = "timetable/user_timetable.jinja"
|
||||||
|
|
||||||
|
def test_func(self) -> bool | None:
|
||||||
|
return self.request.user.id == self.kwargs[
|
||||||
|
"user_id"
|
||||||
|
] or self.request.user.has_perm("timetable.view_timetable")
|
||||||
Reference in new issue
Block a user