6 Commits

Author SHA1 Message Date
5b57f75b4e custom django command for promo logos
added path vailidity verification and IOError handling

added option to overwrite existing logo and force flag

improved uppon suggestions

mistake correction

fixed string conversion bugs and logical error

corrected path conversion

f

better error handling and corrections

ajout d'une section de documentation pour la feature

copié coller

fixed documentation bullet points

added resampling clean up error handling

removed useless IOError
2025-07-03 14:28:16 +02:00
3e3c6631ff Merge pull request #1146 from ae-utbm/fix-ts
Fix ts
2025-07-02 09:01:24 +02:00
a3ac04fc9e fix TS types 2025-06-30 18:35:53 +02:00
6e724a9c74 extract AlertMessage to its own file 2025-06-30 18:17:29 +02:00
c177ef2a3a Merge pull request #1145 from ae-utbm/xapian
fix: xapian compilation flags
2025-06-30 13:46:02 +02:00
6cf8910626 fix: xapian compilation flags 2025-06-30 13:09:24 +02:00
29 changed files with 139 additions and 363 deletions

View File

@ -0,0 +1,41 @@
import pathlib
from django.apps import apps
from django.core.management.base import BaseCommand
from PIL import Image, UnidentifiedImageError
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument("number", type=int)
parser.add_argument("path", type=pathlib.Path)
parser.add_argument("-f", "--force", action="store_true")
def handle(self, number: int, path: pathlib.Path, force: int, *args, **options):
if not path.exists() or path.is_dir():
self.stderr.write(f"{path} is not a file or does not exist")
return
dest_path = (
pathlib.Path(apps.get_app_config("core").path)
/ "static"
/ "core"
/ "img"
/ f"promo_{number}.png"
)
if dest_path.exists() and not force:
over = input("File already exists, do you want to overwrite it? (y/N):")
if over.lower() != "y":
self.stdout.write("exiting")
return
try:
im = Image.open(path)
im.resize((120, 120), resample=Image.Resampling.LANCZOS).save(
dest_path, format="PNG"
)
self.stdout.write(
f"Promo logo moved and resized successfully at {dest_path}"
)
except UnidentifiedImageError:
self.stderr.write("image cannot be opened and identified.")

View File

@ -4,13 +4,13 @@
VERSION="$1"
# Cleanup env vars for auto discovery mechanism
export CPATH=
export LIBRARY_PATH=
export CFLAGS=
export LDFLAGS=
export CCFLAGS=
export CXXFLAGS=
export CPPFLAGS=
unset CPATH
unset LIBRARY_PATH
unset CFLAGS
unset LDFLAGS
unset CCFLAGS
unset CXXFLAGS
unset CPPFLAGS
# prepare
rm -rf "$VIRTUAL_ENV/packages"

View File

@ -636,6 +636,9 @@ class User(AbstractUser):
class AnonymousUser(AuthAnonymousUser):
def __init__(self):
super().__init__()
@property
def was_subscribed(self):
return False
@ -644,6 +647,10 @@ class AnonymousUser(AuthAnonymousUser):
def is_subscribed(self):
return False
@property
def subscribed(self):
return False
@property
def is_root(self):
return False

View File

@ -0,0 +1,38 @@
interface AlertParams {
success?: boolean;
duration?: number;
}
export class AlertMessage {
public open: boolean;
public success: boolean;
public content: string;
private timeoutId?: number;
private readonly defaultDuration: number;
constructor(params?: { defaultDuration: number }) {
this.open = false;
this.content = "";
this.timeoutId = null;
this.defaultDuration = params?.defaultDuration ?? 2000;
}
public display(message: string, params: AlertParams) {
this.clear();
this.open = true;
this.content = message;
this.success = params.success ?? true;
this.timeoutId = setTimeout(() => {
this.open = false;
this.timeoutId = null;
}, params.duration ?? this.defaultDuration);
}
public clear() {
if (this.timeoutId !== null) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
this.open = false;
}
}

View File

@ -1,5 +1,5 @@
import type { Client, Options, RequestResult, TDataShape } from "@hey-api/client-fetch";
import { client } from "#openapi";
import type { Client, RequestResult, TDataShape } from "#openapi:client";
import { type Options, client } from "#openapi";
export interface PaginatedResponse<T> {
count: number;

View File

@ -1,3 +1,4 @@
import { AlertMessage } from "#core:utils/alert-message";
import { BasketItem } from "#counter:counter/basket";
import type { CounterConfig, ErrorMessage } from "#counter:counter/types";
import type { CounterProductSelect } from "./components/counter-product-select-index.ts";
@ -5,14 +6,9 @@ import type { CounterProductSelect } from "./components/counter-product-select-i
document.addEventListener("alpine:init", () => {
Alpine.data("counter", (config: CounterConfig) => ({
basket: {} as Record<string, BasketItem>,
errors: [],
customerBalance: config.customerBalance,
codeField: null as CounterProductSelect | null,
alertMessage: {
content: "",
show: false,
timeout: null,
},
alertMessage: new AlertMessage({ defaultDuration: 2000 }),
init() {
// Fill the basket with the initial data
@ -77,22 +73,10 @@ document.addEventListener("alpine:init", () => {
return total;
},
showAlertMessage(message: string) {
if (this.alertMessage.timeout !== null) {
clearTimeout(this.alertMessage.timeout);
}
this.alertMessage.content = message;
this.alertMessage.show = true;
this.alertMessage.timeout = setTimeout(() => {
this.alertMessage.show = false;
this.alertMessage.timeout = null;
}, 2000);
},
addToBasketWithMessage(id: string, quantity: number) {
const message = this.addToBasket(id, quantity);
if (message.length > 0) {
this.showAlertMessage(message);
this.alertMessage.display(message, { success: false });
}
},
@ -109,7 +93,9 @@ document.addEventListener("alpine:init", () => {
finish() {
if (this.getBasketSize() === 0) {
this.showAlertMessage(gettext("You can't send an empty basket."));
this.alertMessage.display(gettext("You can't send an empty basket."), {
success: false,
});
return;
}
this.$refs.basketForm.submit();

View File

@ -167,7 +167,7 @@ document.addEventListener("alpine:init", () => {
});
// if products to download are already in-memory, directly take them.
// If not, fetch them.
const products =
const products: ProductSchema[] =
this.nbPages > 1
? await paginated(productSearchProductsDetailed, this.getQueryParams())
: Object.values<ProductSchema[]>(this.products).flat();

View File

@ -1,15 +1,11 @@
import { AlertMessage } from "#core:utils/alert-message";
import Alpine from "alpinejs";
import { producttypeReorder } from "#openapi";
document.addEventListener("alpine:init", () => {
Alpine.data("productTypesList", () => ({
loading: false,
alertMessage: {
open: false,
success: true,
content: "",
timeout: null,
},
alertMessage: new AlertMessage({ defaultDuration: 2000 }),
async reorder(itemId: number, newPosition: number) {
// The sort plugin of Alpine doesn't manage dynamic lists with x-sort
@ -41,23 +37,14 @@ document.addEventListener("alpine:init", () => {
},
openAlertMessage(response: Response) {
if (response.ok) {
this.alertMessage.success = true;
this.alertMessage.content = gettext("Products types reordered!");
} else {
this.alertMessage.success = false;
this.alertMessage.content = interpolate(
gettext("Product type reorganisation failed with status code : %d"),
[response.status],
);
}
this.alertMessage.open = true;
if (this.alertMessage.timeout !== null) {
clearTimeout(this.alertMessage.timeout);
}
this.alertMessage.timeout = setTimeout(() => {
this.alertMessage.open = false;
}, 2000);
const success = response.ok;
const content = response.ok
? gettext("Products types reordered!")
: interpolate(
gettext("Product type reorganisation failed with status code : %d"),
[response.status],
);
this.alertMessage.display(content, { success: success });
this.loading = false;
},
}));

View File

@ -1,4 +1,4 @@
type ErrorMessage = string;
export type ErrorMessage = string;
export interface InitialFormData {
/* Used to refill the form when the backend raises an error */

View File

@ -12,6 +12,15 @@ nouveau logo d'une promo. C'est un processus manuel.
de faire cette opération manuellement, ça prend quelques
minutes et on est certain de la qualité à la fin.
### avec une commande django
```bash
./manage.py add_promo_logo numero_de_promo chemin_dacces_du_logo
```
options:
* `--force/-f` pour automatiquement écraser les logos de promo avec le même nom.
### manuellement
Les logos de promo sont à manuellement ajouter dans le projet.
Ils se situent dans le dossier `core/static/core/img/`.

65
package-lock.json generated
View File

@ -29,8 +29,6 @@
"d3-force-3d": "^3.0.5",
"easymde": "^2.19.0",
"glob": "^11.0.0",
"html-to-image": "^1.11.13",
"html2canvas": "^1.4.1",
"htmx.org": "^2.0.3",
"jquery": "^3.7.1",
"js-cookie": "^3.0.5",
@ -50,6 +48,7 @@
"@types/cytoscape-cxtmenu": "^3.4.4",
"@types/cytoscape-klay": "^3.1.4",
"@types/jquery": "^3.5.31",
"@types/js-cookie": "^3.0.6",
"typescript": "^5.8.3",
"vite": "^6.2.5",
"vite-bundle-visualizer": "^1.2.1",
@ -2867,6 +2866,13 @@
"@types/sizzle": "*"
}
},
"node_modules/@types/js-cookie": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
"integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -3085,15 +3091,6 @@
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -3482,15 +3479,6 @@
"node": ">= 8"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/cytoscape": {
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.32.0.tgz",
@ -4169,25 +4157,6 @@
"node": ">= 0.4"
}
},
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/htmx.org": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz",
@ -5490,15 +5459,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/three": {
"version": "0.177.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.177.0.tgz",
@ -5756,15 +5716,6 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vite": {
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",

View File

@ -10,7 +10,7 @@
"openapi": "openapi-ts",
"analyse-dev": "vite-bundle-visualizer --mode development",
"analyse-prod": "vite-bundle-visualizer --mode production",
"check": "biome check --write"
"check": "tsc && biome check --write"
},
"keywords": [],
"author": "",
@ -30,9 +30,10 @@
"@hey-api/openapi-ts": "^0.73.0",
"@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.10",
"@types/jquery": "^3.5.31",
"@types/cytoscape-cxtmenu": "^3.4.4",
"@types/cytoscape-klay": "^3.1.4",
"@types/jquery": "^3.5.31",
"@types/js-cookie": "^3.0.6",
"typescript": "^5.8.3",
"vite": "^6.2.5",
"vite-bundle-visualizer": "^1.2.1",
@ -59,7 +60,6 @@
"d3-force-3d": "^3.0.5",
"easymde": "^2.19.0",
"glob": "^11.0.0",
"html2canvas": "^1.4.1",
"htmx.org": "^2.0.3",
"jquery": "^3.7.1",
"js-cookie": "^3.0.5",

View File

@ -92,7 +92,7 @@ docs = [
default-groups = ["dev", "tests", "docs"]
[tool.xapian]
version = "1.4.25"
version = "1.4.29"
[tool.ruff]
output-format = "concise" # makes ruff error logs easier to read

View File

@ -83,7 +83,6 @@ document.addEventListener("alpine:init", () => {
Alpine.data("pictureUpload", (albumId: number) => ({
errors: [] as string[],
pictures: [],
sending: false,
progress: null as HTMLProgressElement,

View File

@ -1,3 +1,4 @@
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
import { paginated } from "#core:utils/api";
import { exportToHtml } from "#core:utils/globals";
import { History } from "#core:utils/history";
@ -130,7 +131,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
currentPicture: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
is_moderated: true,
id: null,
id: null as number,
name: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
display_name: "",
@ -142,7 +143,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
full_size_url: "",
owner: "",
date: new Date(),
identifications: [],
identifications: [] as IdentifiedUserSchema[],
},
/**
* The picture which will be displayed next if the user press the "next" button
@ -155,7 +156,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
/**
* The select2 component used to identify users
**/
selector: undefined,
selector: undefined as UserAjaxSelect,
/**
* Error message when a moderation operation fails
**/

View File

@ -124,7 +124,6 @@ INSTALLED_APPS = (
"pedagogy",
"galaxy",
"antispam",
"timetable",
"api",
)

View File

@ -49,7 +49,6 @@ urlpatterns = [
path("i18n/", include("django.conf.urls.i18n")),
path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"),
path("captcha/", include("captcha.urls")),
path("timetable/", include(("timetable.urls", "timetable"), namespace="timetable")),
]
if settings.DEBUG:

View File

View File

@ -1 +0,0 @@
# Register your models here.

View File

@ -1,6 +0,0 @@
from django.apps import AppConfig
class TimetableConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "timetable"

View File

@ -1 +0,0 @@
# Create your models here.

View File

@ -1,134 +0,0 @@
import html2canvas from "html2canvas";
// see https://regex101.com/r/QHSaPM/2
const TIMETABLE_ROW_RE: RegExp =
/^(?<ueCode>[A-Z\d]{4}(?:\+[A-Z\d]{4})?)\s+(?<courseType>[A-Z]{2}\d)\s+((?<weekGroup>[AB])\s+)?(?<weekday>(lundi)|(mardi)|(mercredi)|(jeudi)|(vendredi)|(samedi)|(dimanche))\s+(?<startHour>\d{2}:\d{2})\s+(?<endHour>\d{2}:\d{2})\s+[\dA-B]\s+(?:[\wé]*\s+)?(?<room>\w+(?:, \w+)?)$/;
const DEFAULT_TIMETABLE: string = `DS52\t\tCM1\t\tlundi\t08:00\t10:00\t1\tPrésentiel\tA113
DS53\t\tCM1\t\tlundi\t10:15\t12:15\t1\tPrésentiel\tA101
DS53\t\tTP1\t\tlundi\t13:00\t16:00\t1\tPrésentiel\tH010
SO03\t\tCM1\t\tlundi\t16:15\t17:45\t1\tPrésentiel\tA103
SO03\t\tTD1\t\tlundi\t17:45\t19:45\t1\tPrésentiel\tA103
DS50\t\tTP1\t\tmardi\t08:00\t10:00\t1\tPrésentiel\tA216
DS51\t\tCM1\t\tmardi\t10:15\t12:15\t1\tPrésentiel\tA216
DS51\t\tTP1\t\tmardi\t14:00\t18:00\t1\tPrésentiel\tH010
DS52\t\tTP2\tA\tjeudi\t08:00\t10:00\tA\tPrésentiel\tA110a, A110b
DS52\t\tTD1\t\tjeudi\t10:15\t12:15\t1\tPrésentiel\tA110a, A110b
LC02\t\tTP1\t\tjeudi\t15:00\t16:00\t1\tPrésentiel\tA209
LC02\t\tTD1\t\tjeudi\t16:15\t18:15\t1\tPrésentiel\tA206`;
type WeekDay =
| "lundi"
| "mardi"
| "mercredi"
| "jeudi"
| "vendredi"
| "samedi"
| "dimanche";
const WEEKDAYS = [
"lundi",
"mardi",
"mercredi",
"jeudi",
"vendredi",
"samedi",
"dimanche",
] as const;
const SLOT_HEIGHT = 20 as const; // Each 15min has a height of 20px in the timetable
const SLOT_WIDTH = 400 as const; // Each weekday ha a width of 400px in the timetable
const MINUTES_PER_SLOT = 15 as const;
interface TimetableSlot {
courseType: string;
room: string;
startHour: string;
endHour: string;
startSlot: number;
endSlot: number;
ueCode: string;
weekGroup?: string;
weekday: WeekDay;
}
function parseSlots(s: string): TimetableSlot[] {
return s
.split("\n")
.filter((s: string) => s.length > 0)
.map((row: string) => {
const parsed = TIMETABLE_ROW_RE.exec(row);
if (!parsed) {
throw new Error(`Couldn't parse row ${row}`);
}
const [startHour, startMin] = parsed.groups.startHour
.split(":")
.map((i) => Number.parseInt(i));
const [endHour, endMin] = parsed.groups.endHour
.split(":")
.map((i) => Number.parseInt(i));
return {
...parsed.groups,
startSlot: Math.floor((startHour * 60 + startMin) / MINUTES_PER_SLOT),
endSlot: Math.floor((endHour * 60 + endMin) / MINUTES_PER_SLOT),
} as unknown as TimetableSlot;
});
}
document.addEventListener("alpine:init", () => {
Alpine.data("timetableGenerator", () => ({
content: DEFAULT_TIMETABLE,
error: "",
displayedWeekdays: [] as WeekDay[],
courses: [] as TimetableSlot[],
startSlot: 0,
table: {
height: 0,
width: 0,
},
generate() {
try {
this.courses = parseSlots(this.content);
} catch {
this.error = gettext(
"Wrong timetable format. Make sure you copied if from your student folder.",
);
return;
}
this.displayedWeekdays = WEEKDAYS.filter((day) =>
this.courses.some((slot: TimetableSlot) => slot.weekday === day),
);
this.startSlot = this.courses.reduce(
(acc: number, curr: TimetableSlot) => Math.min(acc, curr.startSlot),
24 * 4,
);
this.endSlot = this.courses.reduce(
(acc: number, curr: TimetableSlot) => Math.max(acc, curr.endSlot),
0,
);
this.table.height = SLOT_HEIGHT * (this.endSlot - this.startSlot);
this.table.width = SLOT_WIDTH * this.displayedWeekdays.length;
},
getStyle(slot: TimetableSlot) {
return {
height: `${(slot.endSlot - slot.startSlot) * SLOT_HEIGHT}px`,
width: `${SLOT_WIDTH}px`,
top: `${(slot.startSlot - this.startSlot) * SLOT_HEIGHT}px`,
left: `${this.displayedWeekdays.indexOf(slot.weekday) * SLOT_WIDTH}px`,
};
},
async savePng() {
const elem = document.getElementById("timetable");
const img = (await html2canvas(elem)).toDataURL();
const downloadLink = document.createElement("a");
downloadLink.href = img;
downloadLink.download = "edt.png";
document.body.appendChild(downloadLink);
downloadLink.click();
downloadLink.remove();
},
}));
});

View File

@ -1,28 +0,0 @@
#timetable {
display: block;
margin: 2em auto ;
.header {
background-color: white;
box-shadow: none;
width: 100%;
display: flex;
flex-direction: row;
gap: 0;
span {
flex: 1;
text-align: center;
}
}
.content {
position: relative;
text-align: center;
.slot {
background-color: cadetblue;
position: absolute;
display: flex;
flex-direction: column;
justify-content: center;
}
}
}

View File

@ -1,56 +0,0 @@
{% extends 'core/base.jinja' %}
{%- 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>
{%- endblock -%}
{% block title %}
{% trans %}Timeplan generator{% endtrans %}
{% endblock %}
{% block content %}
<div x-data="timetableGenerator">
<form @submit.prevent="generate()">
<h1>Générateur d'emploi du temps</h1>
<div class="alert alert-red" x-show="!!error" x-cloak>
<p class="alert-main" x-text="error"></p>
</div>
<div class="form-group">
<label for="timetable-input">Colle ton emploi du temps (sans l'entête)</label>
<textarea id="timetable-input" cols="30" rows="15" x-model="content"></textarea>
</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}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">
<template x-for="course in courses">
<div class="slot" :style="getStyle(course)">
<span x-text="`${course.ueCode} (${course.courseType})`"></span>
<span x-text="`${course.startHour} - ${course.endHour}`"></span>
<span x-text="course.room"></span>
</div>
</template>
</div>
</div>
<button
class="margin-bottom btn btn-blue"
@click="savePng"
x-show="table.height > 0 && table.width > 0"
>
{% trans %}Save to PNG{% endtrans %}
</button>
</div>
{% endblock content %}

View File

@ -1 +0,0 @@
# Create your tests here.

View File

@ -1,5 +0,0 @@
from django.urls import path
from timetable.views import GeneratorView
urlpatterns = [path("generator/", GeneratorView.as_view(), name="generator")]

View File

@ -1,10 +0,0 @@
# Create your views here.
from django.contrib.auth.mixins import UserPassesTestMixin
from django.views.generic import TemplateView
class GeneratorView(UserPassesTestMixin, TemplateView):
template_name = "timetable/generator.jinja"
def test_func(self):
return self.request.user.is_subscribed

View File

@ -14,6 +14,7 @@
"types": ["jquery", "alpinejs"],
"paths": {
"#openapi": ["./staticfiles/generated/openapi/client/index.ts"],
"#openapi:*": ["./staticfiles/generated/openapi/client/*"],
"#core:*": ["./core/static/bundled/*"],
"#pedagogy:*": ["./pedagogy/static/bundled/*"],
"#counter:*": ["./counter/static/bundled/*"],