1 Commits
Author SHA1 Message Date
imperosol 53e454a04a wip: update pedagogy 2026-07-02 19:21:34 +02:00
48 changed files with 371 additions and 501 deletions
@@ -1,6 +1,7 @@
import type { escape_html } from "tom-select/src/utils"; import type { TomOption } from "tom-select/dist/types/types";
import { AjaxSelect } from "#core:core/components/ajax-select-base"; import type { escape_html } from "tom-select/dist/types/utils";
import { registerComponent } from "#core:utils/web-components"; import { AjaxSelect } from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
import { type ClubSchema, clubSearchClub } from "#openapi"; import { type ClubSchema, clubSearchClub } from "#openapi";
@registerComponent("club-ajax-select") @registerComponent("club-ajax-select")
@@ -9,7 +10,7 @@ export class ClubAjaxSelect extends AjaxSelect {
protected labelField = "name"; protected labelField = "name";
protected searchField = ["code", "name"]; protected searchField = ["code", "name"];
protected async search(query: string) { protected async search(query: string): Promise<TomOption[]> {
const resp = await clubSearchClub({ query: { search: query } }); const resp = await clubSearchClub({ query: { search: query } });
if (resp.data) { if (resp.data) {
return resp.data.results; return resp.data.results;
@@ -1,9 +1,4 @@
import { import { Calendar, type EventClickArg, type EventContentArg } from "@fullcalendar/core";
Calendar,
type EventClickArg,
type EventContentArg,
type EventInput,
} from "@fullcalendar/core";
import type { EventImpl } from "@fullcalendar/core/internal"; import type { EventImpl } from "@fullcalendar/core/internal";
import enLocale from "@fullcalendar/core/locales/en-gb"; import enLocale from "@fullcalendar/core/locales/en-gb";
import frLocale from "@fullcalendar/core/locales/fr"; import frLocale from "@fullcalendar/core/locales/fr";
@@ -11,8 +6,8 @@ import dayGridPlugin from "@fullcalendar/daygrid";
import iCalendarPlugin from "@fullcalendar/icalendar"; import iCalendarPlugin from "@fullcalendar/icalendar";
import listPlugin from "@fullcalendar/list"; import listPlugin from "@fullcalendar/list";
import { type HTMLTemplateResult, html, render } from "lit-html"; import { type HTMLTemplateResult, html, render } from "lit-html";
import { type GenericEndpoint, makeUrl } from "#core:utils/api"; import { makeUrl } from "#core:utils/api.ts";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components"; import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
import { import {
calendarCalendarInternal, calendarCalendarInternal,
calendarCalendarUnpublished, calendarCalendarUnpublished,
@@ -24,11 +19,11 @@ import {
@registerComponent("ics-calendar") @registerComponent("ics-calendar")
export class IcsCalendar extends inheritHtmlElement("div") { export class IcsCalendar extends inheritHtmlElement("div") {
static observedAttributes = ["locale", "can_moderate", "can_delete", "ics-help-url"]; static observedAttributes = ["locale", "can_moderate", "can_delete", "ics-help-url"];
private calendar!: Calendar; private calendar: Calendar;
private locale? = "en"; private locale = "en";
private canModerate? = false; private canModerate = false;
private canDelete? = false; private canDelete = false;
private helpUrl? = ""; private helpUrl = "";
// Hack variable to detect recurring events // Hack variable to detect recurring events
// The underlying ics library doesn't include any info about rrules // The underlying ics library doesn't include any info about rrules
@@ -40,12 +35,10 @@ export class IcsCalendar extends inheritHtmlElement("div") {
this.locale = newValue; this.locale = newValue;
} }
if (name === "can_moderate") { if (name === "can_moderate") {
this.canModerate = this.canModerate = newValue.toLowerCase() === "true";
typeof newValue === "string" && newValue.toLowerCase() === "true";
} }
if (name === "can_delete") { if (name === "can_delete") {
this.canDelete = this.canDelete = newValue.toLowerCase() === "true";
typeof newValue === "string" && newValue.toLowerCase() === "true";
} }
if (name === "ics-help-url") { if (name === "ics-help-url") {
@@ -101,7 +94,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
.toString() .toString()
.split("/") .split("/")
.filter((s) => s) // Remove blank characters .filter((s) => s) // Remove blank characters
.pop() as string, .pop(),
10, 10,
); );
} }
@@ -166,7 +159,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
this.refreshEvents(); this.refreshEvents();
} }
async getEventSources(): Promise<EventInput[]> { async getEventSources() {
const tagRecurringEvents = (eventData: EventImpl) => { const tagRecurringEvents = (eventData: EventImpl) => {
// This functions tags events with a similar event url // This functions tags events with a similar event url
// We rely on the fact that the event url is always the same // We rely on the fact that the event url is always the same
@@ -180,14 +173,14 @@ export class IcsCalendar extends inheritHtmlElement("div") {
}; };
return [ return [
{ {
url: `${await makeUrl(calendarCalendarInternal as GenericEndpoint)}`, url: `${await makeUrl(calendarCalendarInternal)}`,
format: "ics", format: "ics",
className: "internal", className: "internal",
cache: false, cache: false,
eventDataTransform: tagRecurringEvents, eventDataTransform: tagRecurringEvents,
}, },
{ {
url: `${await makeUrl(calendarCalendarUnpublished as GenericEndpoint)}`, url: `${await makeUrl(calendarCalendarUnpublished)}`,
format: "ics", format: "ics",
color: "red", color: "red",
className: "unpublished", className: "unpublished",
@@ -220,7 +213,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
${event.title} ${event.title}
</h4> </h4>
<span class="event-details-row-content"> <span class="event-details-row-content">
${this.formatDate(event.start as Date)} - ${this.formatDate(event.end as Date)} ${this.formatDate(event.start)} - ${this.formatDate(event.end)}
</span> </span>
</div> </div>
`; `;
@@ -258,7 +251,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
const buttons = [] as HTMLTemplateResult[]; const buttons = [] as HTMLTemplateResult[];
if (this.canModerate) { if (this.canModerate) {
if (event.source?.internalEventSource.ui.classNames.includes("unpublished")) { if (event.source.internalEventSource.ui.classNames.includes("unpublished")) {
const button = html` const button = html`
<button class="btn btn-green" @click="${() => this.publishNews(newsId)}"> <button class="btn btn-green" @click="${() => this.publishNews(newsId)}">
<i class="fa fa-check"></i>${gettext("Publish")} <i class="fa fa-check"></i>${gettext("Publish")}
@@ -345,9 +338,9 @@ export class IcsCalendar extends inheritHtmlElement("div") {
button.classList.remove("text-copied"); button.classList.remove("text-copied");
} }
button.setAttribute("tooltip", gettext("Link copied")); button.setAttribute("tooltip", gettext("Link copied"));
await navigator.clipboard.writeText( navigator.clipboard.writeText(
new URL( new URL(
await makeUrl(calendarCalendarInternal as GenericEndpoint), await makeUrl(calendarCalendarInternal),
window.location.origin, window.location.origin,
).toString(), ).toString(),
); );
@@ -1,9 +1,4 @@
import { import { newsDeleteNews, newsFetchNewsDates, newsPublishNews } from "#openapi";
newsDeleteNews,
newsFetchNewsDates,
newsPublishNews,
type PaginatedResponseSchemaNewsDateSchema,
} from "#openapi";
// This will be used in jinja templates, // This will be used in jinja templates,
// so we cannot use real enums as those are purely an abstraction of Typescript // so we cannot use real enums as those are purely an abstraction of Typescript
@@ -69,8 +64,7 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is snake-case // biome-ignore lint/style/useNamingConvention: api is snake-case
query: { news_id: this.newsId, page: 1, page_size: 1 }, query: { news_id: this.newsId, page: 1, page_size: 1 },
}); });
return response.data.count;
return (response.data as PaginatedResponseSchemaNewsDateSchema).count;
}, },
weeklyEventWarningMessage(nbEvents: number): string { weeklyEventWarningMessage(nbEvents: number): string {
@@ -1,11 +1,11 @@
import { type NewsDateSchema, newsFetchNewsDates } from "#openapi"; import { type NewsDateSchema, newsFetchNewsDates } from "#openapi";
type ParsedNewsDateSchema = Omit<NewsDateSchema, "start_date" | "end_date"> & { interface ParsedNewsDateSchema extends Omit<NewsDateSchema, "start_date" | "end_date"> {
// biome-ignore lint/style/useNamingConvention: api is snake_case // biome-ignore lint/style/useNamingConvention: api is snake_case
start_date: Date; start_date: Date;
// biome-ignore lint/style/useNamingConvention: api is snake_case // biome-ignore lint/style/useNamingConvention: api is snake_case
end_date: Date; end_date: Date;
}; }
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("upcomingNewsLoader", (startDate: Date, locale: string) => ({ Alpine.data("upcomingNewsLoader", (startDate: Date, locale: string) => ({
@@ -32,11 +32,6 @@ document.addEventListener("alpine:init", () => {
page_size: this.pageSize, page_size: this.pageSize,
}, },
}); });
if (response.response === undefined || response.data === undefined) {
// response may be undefined, because error may be
// from building the request object itself or from a network error
return;
}
if (response.response.status === 404) { if (response.response.status === 404) {
this.hasNext = false; this.hasNext = false;
} else if (response.data.next === null) { } else if (response.data.next === null) {
@@ -49,7 +44,7 @@ document.addEventListener("alpine:init", () => {
this.loading = false; this.loading = false;
}, },
groupedDates(): Record<string, ParsedNewsDateSchema[]> { groupedDates(): Record<string, NewsDateSchema[]> {
return this.newsDates return this.newsDates
.map( .map(
(date: NewsDateSchema): ParsedNewsDateSchema => ({ (date: NewsDateSchema): ParsedNewsDateSchema => ({
+2 -3
View File
@@ -1,4 +1,4 @@
from typing import Annotated, Literal from typing import Annotated, Any, Literal
from annotated_types import Ge, Le, MinLen from annotated_types import Ge, Le, MinLen
from django.conf import settings from django.conf import settings
@@ -26,7 +26,6 @@ from core.schemas import (
UserFilterSchema, UserFilterSchema,
UserProfileSchema, UserProfileSchema,
UserSchema, UserSchema,
ValidationErrorSchema,
) )
from core.templatetags.renderer import markdown from core.templatetags.renderer import markdown
from counter.utils import is_logged_in_counter from counter.utils import is_logged_in_counter
@@ -46,7 +45,7 @@ class UploadController(ControllerBase):
"/image", "/image",
response={ response={
200: UploadedFileSchema, 200: UploadedFileSchema,
422: ValidationErrorSchema, 422: dict[Literal["detail"], list[dict[str, Any]]],
403: dict[Literal["detail"], str], 403: dict[Literal["detail"], str],
}, },
permissions=[HasPerm("core.add_quickuploadimage")], permissions=[HasPerm("core.add_quickuploadimage")],
-10
View File
@@ -19,16 +19,6 @@ from core.utils import get_last_promo, is_image
NonEmptyStr = Annotated[str, MinLen(1)] NonEmptyStr = Annotated[str, MinLen(1)]
class ValidationErrorSchema(Schema):
class ValidationErrorItem(Schema):
loc: list[str | int]
msg: str
type: str
ctx: dict[str, str]
detail: list[ValidationErrorItem]
class UploadedImage(UploadedFile): class UploadedImage(UploadedFile):
@classmethod @classmethod
def _validate(cls, v: Any, info: ValidationInfo) -> Any: def _validate(cls, v: Any, info: ValidationInfo) -> Any:
@@ -25,7 +25,7 @@ export function limitedChoices(Alpine: AlpineType) {
Alpine.directive( Alpine.directive(
"limited-choices", "limited-choices",
(el, { expression }, { evaluateLater, effect }) => { (el, { expression }, { evaluateLater, effect }) => {
const getMaxChoices = evaluateLater<string>(expression); const getMaxChoices = evaluateLater(expression);
let maxChoices: number; let maxChoices: number;
const inputs: HTMLInputElement[] = Array.from( const inputs: HTMLInputElement[] = Array.from(
el.querySelectorAll("input[type='checkbox']"), el.querySelectorAll("input[type='checkbox']"),
@@ -54,7 +54,7 @@ export function limitedChoices(Alpine: AlpineType) {
}); });
} }
effect(() => { effect(() => {
getMaxChoices((value) => { getMaxChoices((value: string) => {
const previousValue = maxChoices; const previousValue = maxChoices;
maxChoices = Number.parseInt(value, 10); maxChoices = Number.parseInt(value, 10);
if (maxChoices < previousValue) { if (maxChoices < previousValue) {
+6 -12
View File
@@ -43,19 +43,13 @@ polyfillCountryFlagEmojis();
/** /**
* HTMX * HTMX
*/ */
document.body.addEventListener( document.body.addEventListener("htmx:beforeRequest", (event: CustomEvent) => {
"htmx:beforeRequest" as keyof HTMLElementEventMap, event.detail.target.ariaBusy = true;
(event) => { });
(event as CustomEvent).detail.target.ariaBusy = true;
},
);
document.body.addEventListener( document.body.addEventListener("htmx:beforeSwap", (event: CustomEvent) => {
"htmx:beforeSwap" as keyof HTMLElementEventMap, event.detail.target.ariaBusy = null;
(event) => { });
(event as CustomEvent).detail.target.ariaBusy = null;
},
);
Object.assign(window, { htmx }); Object.assign(window, { htmx });
@@ -4,9 +4,9 @@ import type {
TomLoadCallback, TomLoadCallback,
TomOption, TomOption,
TomSettings, TomSettings,
} from "tom-select/src/types"; } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/src/utils"; import type { escape_html } from "tom-select/dist/types/utils";
import { inheritHtmlElement } from "#core:utils/web-components"; import { inheritHtmlElement } from "#core:utils/web-components.ts";
export class AutoCompleteSelectBase extends inheritHtmlElement("select") { export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
static observedAttributes = [ static observedAttributes = [
@@ -15,7 +15,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
"max", "max",
"min-characters-for-search", "min-characters-for-search",
]; ];
public widget!: TomSelect; public widget: TomSelect;
protected minCharNumberForSearch = 0; protected minCharNumberForSearch = 0;
protected delay: number | null = null; protected delay: number | null = null;
@@ -24,8 +24,8 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
protected attributeChangedCallback( protected attributeChangedCallback(
name: string, name: string,
_oldValue: string, _oldValue?: string,
newValue: string, newValue?: string,
) { ) {
switch (name) { switch (name) {
case "delay": { case "delay": {
@@ -73,7 +73,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
persist: false, persist: false,
maxItems: this.node.multiple ? this.max : 1, maxItems: this.node.multiple ? this.max : 1,
closeAfterSelect: true, closeAfterSelect: true,
loadThrottle: this.delay ?? undefined, loadThrottle: this.delay,
placeholder: this.placeholder, placeholder: this.placeholder,
shouldLoad: (query: string) => this.shouldLoad(query), // wraps the method to avoid shadowing `this` by the one from tom-select shouldLoad: (query: string) => this.shouldLoad(query), // wraps the method to avoid shadowing `this` by the one from tom-select
render: { render: {
@@ -103,7 +103,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
} }
export abstract class AjaxSelect extends AutoCompleteSelectBase { export abstract class AjaxSelect extends AutoCompleteSelectBase {
protected filter?: (items: TomOption[]) => TomOption[]; protected filter?: (items: TomOption[]) => TomOption[] = null;
protected minCharNumberForSearch = 2; protected minCharNumberForSearch = 2;
/** /**
* A cache of researches that have been made using this input. * A cache of researches that have been made using this input.
@@ -1,12 +1,11 @@
// @ts-expect-error TS2882
import "tom-select/dist/css/tom-select.default.css"; import "tom-select/dist/css/tom-select.default.css";
import type { TomOption } from "tom-select/src/types"; import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/src/utils"; import type { escape_html } from "tom-select/dist/types/utils";
import { import {
AjaxSelect, AjaxSelect,
AutoCompleteSelectBase, AutoCompleteSelectBase,
} from "#core:core/components/ajax-select-base"; } from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components"; import { registerComponent } from "#core:utils/web-components.ts";
import { import {
type GroupSchema, type GroupSchema,
groupSearchGroup, groupSearchGroup,
@@ -1,13 +1,11 @@
// @ts-expect-error 2307
// biome-ignore lint/correctness/noUndeclaredDependencies: shipped by easymde // biome-ignore lint/correctness/noUndeclaredDependencies: shipped by easymde
import "codemirror/lib/codemirror.css"; import "codemirror/lib/codemirror.css";
// @ts-expect-error 2307
import "easymde/src/css/easymde.css"; import "easymde/src/css/easymde.css";
// biome-ignore lint/correctness/noUndeclaredDependencies: Imported by EasyMDE // biome-ignore lint/correctness/noUndeclaredDependencies: Imported by EasyMDE
import type CodeMirror from "codemirror"; import type CodeMirror from "codemirror";
// biome-ignore lint/style/useNamingConvention: This is how they called their namespace // biome-ignore lint/style/useNamingConvention: This is how they called their namespace
import EasyMDE from "easymde"; import EasyMDE from "easymde";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components"; import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
import { import {
markdownRenderMarkdown, markdownRenderMarkdown,
type UploadUploadImageErrors, type UploadUploadImageErrors,
@@ -27,11 +25,11 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
file: file, file: file,
}, },
}); });
if (response.response !== undefined && !response.response.ok) { if (!response.response.ok) {
if (response?.response.status === 422) { if (response.response.status === 422) {
onError( onError(
(response.error as UploadUploadImageErrors[422]).detail (response.error as UploadUploadImageErrors[422]).detail
.map((err) => err.ctx.error) .map((err: Record<"ctx", Record<"error", string>>) => err.ctx.error)
.join(" ; "), .join(" ; "),
); );
} else if (response.response.status === 403) { } else if (response.response.status === 403) {
@@ -41,10 +39,6 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
} }
return; return;
} }
if (response.data === undefined) {
// this can't happen, it's just for the type checker to know
return;
}
onSuccess(response.data.href); onSuccess(response.data.href);
// Workaround function to add an image name to uploaded image // Workaround function to add an image name to uploaded image
// Without this, you get ![](url) instead of ![name](url) // Without this, you get ![](url) instead of ![name](url)
@@ -64,18 +58,21 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
}); });
easymde.codemirror.replaceSelection("\n"); easymde.codemirror.replaceSelection("\n");
}, },
previewRender: (plainText, preview) => { previewRender: (plainText: string, preview: MarkdownInput) => {
/* This is wrapped this way to allow time for Alpine to be loaded on the page */ /* This is wrapped this way to allow time for Alpine to be loaded on the page */
return Alpine.debounce(() => { return Alpine.debounce((plainText: string, preview: MarkdownInput) => {
const func = async () => { const func = async (
plainText: string,
preview: MarkdownInput,
): Promise<null> => {
preview.innerHTML = ( preview.innerHTML = (
await markdownRenderMarkdown({ body: { text: plainText } }) await markdownRenderMarkdown({ body: { text: plainText } })
).data as string; ).data as string;
return null; return null;
}; };
func().then(); func(plainText, preview);
return null; return null;
}, 300)(); }, 300)(plainText, preview);
}, },
forceSync: true, // Avoid validation error on generic create view forceSync: true, // Avoid validation error on generic create view
imageTexts: { imageTexts: {
@@ -225,11 +222,9 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
}); });
const submits: HTMLInputElement[] = Array.from( const submits: HTMLInputElement[] = Array.from(
(textarea.closest("form") as HTMLFormElement).querySelectorAll( textarea.closest("form").querySelectorAll('input[type="submit"]'),
'input[type="submit"]',
),
); );
const parentDiv = textarea.parentElement?.parentElement as HTMLElement; const parentDiv = textarea.parentElement.parentElement;
function checkMarkdownInput(event: Event) { function checkMarkdownInput(event: Event) {
// an attribute is null if it does not exist, else a string // an attribute is null if it does not exist, else a string
@@ -254,7 +249,6 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
}; };
@registerComponent("markdown-input") @registerComponent("markdown-input")
// biome-ignore lint/correctness/noUnusedVariables: it is used in jinja
class MarkdownInput extends inheritHtmlElement("textarea") { class MarkdownInput extends inheritHtmlElement("textarea") {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
@@ -2,7 +2,7 @@ import {
type InheritedHtmlElement, type InheritedHtmlElement,
inheritHtmlElement, inheritHtmlElement,
registerComponent, registerComponent,
} from "#core:utils/web-components"; } from "#core:utils/web-components.ts";
/** /**
* ElementOnce web components * ElementOnce web components
@@ -28,7 +28,7 @@ export function elementOnce<K extends keyof HTMLElementTagNameMap>(tagName: K) {
clearNode() { clearNode() {
while (this.firstChild) { while (this.firstChild) {
this.removeChild(this.lastChild as ChildNode); this.removeChild(this.lastChild);
} }
} }
@@ -130,7 +130,7 @@ startObserver(observer);
export class LinkOnce extends elementOnce("link") { export class LinkOnce extends elementOnce("link") {
getElementQuerySelector(): string { getElementQuerySelector(): string {
// We get href from node.attributes instead of node.href to avoid getting the domain part // We get href from node.attributes instead of node.href to avoid getting the domain part
return `link[href='${this.node.attributes.getNamedItem("href")?.nodeValue}']`; return `link[href='${this.node.attributes.getNamedItem("href").nodeValue}']`;
} }
} }
@@ -142,6 +142,6 @@ export class LinkOnce extends elementOnce("link") {
export class ScriptOnce extends inheritHtmlElement("script") { export class ScriptOnce extends inheritHtmlElement("script") {
getElementQuerySelector(): string { getElementQuerySelector(): string {
// We get href from node.attributes instead of node.src to avoid getting the domain part // We get href from node.attributes instead of node.src to avoid getting the domain part
return `script[src='${this.node.attributes.getNamedItem("src")?.nodeValue}']`; return `script[src='${this.node.attributes.getNamedItem("src").nodeValue}']`;
} }
} }
@@ -1,4 +1,4 @@
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components"; import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
@registerComponent("nfc-input") @registerComponent("nfc-input")
export class NfcInput extends inheritHtmlElement("input") { export class NfcInput extends inheritHtmlElement("input") {
@@ -26,11 +26,9 @@ export class NfcInput extends inheritHtmlElement("input") {
window.alert(gettext("Unsupported NFC card")); window.alert(gettext("Unsupported NFC card"));
}); });
ndef.addEventListener("reading", (event) => { ndef.addEventListener("reading", (event: NDEFReadingEvent) => {
this.removeAttribute("scan"); this.removeAttribute("scan");
this.node.value = (event as NDEFReadingEvent).serialNumber this.node.value = event.serialNumber.replace(/:/g, "").toUpperCase();
.replace(/:/g, "")
.toUpperCase();
/* Auto submit form, we need another button to not trigger our previously defined click event */ /* Auto submit form, we need another button to not trigger our previously defined click event */
const submit = document.createElement("button"); const submit = document.createElement("button");
this.node.appendChild(submit); this.node.appendChild(submit);
@@ -1,6 +1,6 @@
import { html, render } from "lit-html"; import { html, render } from "lit-html";
import { unsafeHTML } from "lit-html/directives/unsafe-html.js"; import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
import { registerComponent } from "#core:utils/web-components"; import { registerComponent } from "#core:utils/web-components.ts";
@registerComponent("ui-tab") @registerComponent("ui-tab")
export class Tab extends HTMLElement { export class Tab extends HTMLElement {
@@ -19,7 +19,7 @@ export class Tab extends HTMLElement {
} }
if (name === "title") { if (name === "title") {
this.description = newValue ?? ""; this.description = newValue;
} }
this.dispatchEvent(new CustomEvent("ui-tab-updated", { bubbles: true })); this.dispatchEvent(new CustomEvent("ui-tab-updated", { bubbles: true }));
} }
@@ -79,15 +79,15 @@ export class Tab extends HTMLElement {
@registerComponent("ui-tab-group") @registerComponent("ui-tab-group")
export class TabGroup extends HTMLElement { export class TabGroup extends HTMLElement {
private node!: HTMLDivElement; private node: HTMLDivElement;
connectedCallback() { connectedCallback() {
this.node = document.createElement("div"); this.node = document.createElement("div");
this.node.classList.add("tabs", "shadow"); this.node.classList.add("tabs", "shadow");
this.appendChild(this.node); this.appendChild(this.node);
this.addEventListener("ui-tab-activated", (event) => { this.addEventListener("ui-tab-activated", (event: CustomEvent) => {
const target = (event as CustomEvent).detail as Tab; const target = event.detail as Tab;
for (const tab of this.getElementsByTagName("ui-tab") as HTMLCollectionOf<Tab>) { for (const tab of this.getElementsByTagName("ui-tab") as HTMLCollectionOf<Tab>) {
if (tab !== target) { if (tab !== target) {
tab.setActive(false); tab.setActive(false);
@@ -26,23 +26,19 @@ document.addEventListener("alpine:init", () => {
* `counter/templates/counter/product_form.jinja` * `counter/templates/counter/product_form.jinja`
*/ */
Alpine.data("dynamicFormSet", (config?: Config) => ({ Alpine.data("dynamicFormSet", (config?: Config) => ({
formContainer: undefined as unknown as HTMLElement,
nbForms: 0,
template: undefined as unknown as HTMLTemplateElement,
init() { init() {
this.formContainer = this.$refs.formContainer as HTMLElement; this.formContainer = this.$refs.formContainer as HTMLElement;
this.nbForms = this.formContainer.children.length as number; this.nbForms = this.formContainer.children.length as number;
this.template = this.$refs.formTemplate as HTMLTemplateElement; this.template = this.$refs.formTemplate as HTMLTemplateElement;
const prefix = config?.prefix ?? "form"; const prefix = config?.prefix ?? "form";
( this.$root
this.$root.querySelector(`#id_${prefix}-TOTAL_FORMS`) as HTMLFormElement .querySelector(`#id_${prefix}-TOTAL_FORMS`)
).setAttribute(":value", "nbForms"); .setAttribute(":value", "nbForms");
}, },
addForm() { addForm() {
this.formContainer.appendChild(document.importNode(this.template.content, true)); this.formContainer.appendChild(document.importNode(this.template.content, true));
const newForm = this.formContainer.lastElementChild as Element; const newForm = this.formContainer.lastElementChild;
const inputs: NodeListOf<HTMLFormInputElement> = newForm.querySelectorAll( const inputs: NodeListOf<HTMLFormInputElement> = newForm.querySelectorAll(
"input, select, textarea", "input, select, textarea",
); );
@@ -63,7 +59,7 @@ document.addEventListener("alpine:init", () => {
this.nbForms -= 1; this.nbForms -= 1;
// adjust the id of remaining forms // adjust the id of remaining forms
for (let i = 0; i < this.nbForms; i++) { for (let i = 0; i < this.nbForms; i++) {
const form = this.formContainer.children[i]; const form: HTMLDivElement = this.formContainer.children[i];
const inputs: NodeListOf<HTMLFormInputElement> = form.querySelectorAll( const inputs: NodeListOf<HTMLFormInputElement> = form.querySelectorAll(
"input, select, textarea", "input, select, textarea",
); );
+3 -6
View File
@@ -1,5 +1,5 @@
function showMenu() { function showMenu() {
const navbar = document.getElementById("navbar-content") as HTMLElement; const navbar = document.getElementById("navbar-content");
const current = navbar.getAttribute("mobile-display"); const current = navbar.getAttribute("mobile-display");
navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden"); navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden");
} }
@@ -20,12 +20,9 @@ function navbarInit() {
item.removeAttribute("open"); item.removeAttribute("open");
} }
}); });
item.addEventListener("click", (event) => { item.addEventListener("click", (event: MouseEvent) => {
// Don't close when clicking on desktop mode // Don't close when clicking on desktop mode
if ( if ((event.target as HTMLElement).nodeName !== "SUMMARY" || event.detail === 0) {
(event.target as HTMLElement).nodeName !== "SUMMARY" ||
(event as MouseEvent).detail === 0
) {
return; return;
} }
@@ -1,4 +1,3 @@
// @ts-expect-error 2307 this dependency does exist, but it's a little bit wacky
import clip from "@arendjr/text-clipper"; import clip from "@arendjr/text-clipper";
/* /*
+7 -8
View File
@@ -70,16 +70,15 @@ function createTooltip(element: HTMLElement) {
function updateTooltip(element: HTMLElement, tooltip: HTMLElement, status: Status) { function updateTooltip(element: HTMLElement, tooltip: HTMLElement, status: Status) {
// Update tooltip status and set it's attributes and content // Update tooltip status and set it's attributes and content
tooltip.setAttribute("tooltip-status", status); tooltip.setAttribute("tooltip-status", status);
tooltip.innerText = element.getAttribute("tooltip") as string; tooltip.innerText = element.getAttribute("tooltip");
for (const attributes of [ for (const attributes of [
{ src: "tooltip-class", dst: "class", default: ["tooltip"] }, { src: "tooltip-class", dst: "class", default: ["tooltip"] },
{ src: "tooltip-id", dst: "id", default: [] }, { src: "tooltip-id", dst: "id", default: [] },
]) { ]) {
const populated = attributes.default; const populated = attributes.default;
const attr = element.getAttribute(attributes.src); if (element.hasAttribute(attributes.src)) {
if (attr !== null) { populated.push(...element.getAttribute(attributes.src).split(" "));
populated.push(...attr.split(" "));
} }
tooltip.setAttribute(attributes.dst, populated.join(" ")); tooltip.setAttribute(attributes.dst, populated.join(" "));
} }
@@ -94,7 +93,7 @@ function getTooltip(element: HTMLElement) {
return tooltip; return tooltip;
} }
function tooltipMouseover(event: Event) { function tooltipMouseover(event: MouseEvent) {
// We get the closest tooltip to have a consistent behavior // We get the closest tooltip to have a consistent behavior
// when hovering over a child element of a tooltip marked element // when hovering over a child element of a tooltip marked element
const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement; const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
@@ -112,7 +111,7 @@ function tooltipMouseover(event: Event) {
}); });
} }
function tooltipMouseout(event: Event) { function tooltipMouseout(event: MouseEvent) {
// We get the closest tooltip to have a consistent behavior // We get the closest tooltip to have a consistent behavior
// when hovering over a child element of a tooltip marked element // when hovering over a child element of a tooltip marked element
const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement; const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
@@ -142,7 +141,7 @@ new MutationObserver((mutations: MutationRecord[]) => {
} }
} else if (tooltips.has(target)) { } else if (tooltips.has(target)) {
// Remove corresponding tooltip // Remove corresponding tooltip
tooltips.get(target)?.remove(); tooltips.get(target).remove();
tooltips.delete(target); tooltips.delete(target);
} }
} }
@@ -164,7 +163,7 @@ new MutationObserver((mutations: MutationRecord[]) => {
continue; continue;
} }
if (tooltips.has(target)) { if (tooltips.has(target)) {
tooltips.get(target)?.remove(); tooltips.get(target).remove();
tooltips.delete(target); tooltips.delete(target);
} }
} }
+22 -17
View File
@@ -1,13 +1,20 @@
import cytoscape, { type ElementDefinition, type Singular } from "cytoscape"; import cytoscape, {
type ElementDefinition,
type NodeSingular,
type Singular,
} from "cytoscape";
import cxtmenu from "cytoscape-cxtmenu"; import cxtmenu from "cytoscape-cxtmenu";
import klay, { type KlayLayoutOptions } from "cytoscape-klay"; import klay, { type KlayLayoutOptions } from "cytoscape-klay";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history"; import { History, initialUrlParams, updateQueryString } from "#core:utils/history.ts";
import { familyGetFamilyGraph, type UserProfileSchema } from "#openapi"; import { familyGetFamilyGraph, type UserProfileSchema } from "#openapi";
cytoscape.use(klay); cytoscape.use(klay);
cytoscape.use(cxtmenu); cytoscape.use(cxtmenu);
type GraphData = { data: UserProfileSchema | { source: number; target: number } }[]; type GraphData = (
| { data: UserProfileSchema }
| { data: { source: number; target: number } }
)[];
function isMobile() { function isMobile() {
return window.innerWidth < 500; return window.innerWidth < 500;
@@ -20,8 +27,10 @@ async function getGraphData(
): Promise<GraphData> { ): Promise<GraphData> {
const data = ( const data = (
await familyGetFamilyGraph({ await familyGetFamilyGraph({
path: {
// biome-ignore lint/style/useNamingConvention: api is snake_case // biome-ignore lint/style/useNamingConvention: api is snake_case
path: { user_id: userId }, user_id: userId,
},
query: { query: {
// biome-ignore lint/style/useNamingConvention: api is snake_case // biome-ignore lint/style/useNamingConvention: api is snake_case
godfathers_depth: godfathersDepth, godfathers_depth: godfathersDepth,
@@ -30,10 +39,6 @@ async function getGraphData(
}, },
}) })
).data; ).data;
if (data === undefined) {
console.error("Family graph request failed");
return [];
}
return [ return [
...data.users.map((user) => { ...data.users.map((user) => {
return { data: user }; return { data: user };
@@ -150,7 +155,7 @@ function createGraph(container: HTMLDivElement, data: GraphData, activeUserId: n
{ {
content: '<i class="fa fa-external-link fa-2x"></i>', content: '<i class="fa fa-external-link fa-2x"></i>',
select: (el) => { select: (el) => {
window.open(el.data().profile_url, "_blank")?.focus(); window.open(el.data().profile_url, "_blank").focus();
}, },
}, },
@@ -190,12 +195,12 @@ document.addEventListener("alpine:init", () => {
godfathersDepth: 0, godfathersDepth: 0,
godchildrenDepth: 0, godchildrenDepth: 0,
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true", reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
graph: undefined as unknown as cytoscape.Core, graph: undefined as cytoscape.Core,
graphData: {} as GraphData, graphData: {},
isZoomEnabled: !isMobile(), isZoomEnabled: !isMobile(),
getInitialDepth(prop: string) { getInitialDepth(prop: string) {
const value = Number.parseInt(initialUrlParams.get(prop) as string, 10); const value = Number.parseInt(initialUrlParams.get(prop), 10);
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) { if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
return defaultDepth; return defaultDepth;
} }
@@ -218,8 +223,8 @@ document.addEventListener("alpine:init", () => {
await delayedFetch(); await delayedFetch();
}); });
} }
this.$watch("reverse", async (newValue, _oldValue) => { this.$watch("reverse", async (value: number) => {
updateQueryString("reverse", newValue.toString(), History.Replace); updateQueryString("reverse", value.toString(), History.Replace);
await this.reverseGraph(); await this.reverseGraph();
}); });
this.$watch("graphData", async () => { this.$watch("graphData", async () => {
@@ -254,9 +259,9 @@ document.addEventListener("alpine:init", () => {
}, },
async reverseGraph() { async reverseGraph() {
this.graph this.graph.elements((el: NodeSingular) => {
.elements() el.position({ x: -el.position().x, y: -el.position().y });
.positions((el, _) => ({ x: -el.position().x, y: -el.position().y })); });
this.graph.center(this.graph.elements()); this.graph.center(this.graph.elements());
}, },
+2 -2
View File
@@ -5,9 +5,9 @@ interface AlertParams {
export class AlertMessage { export class AlertMessage {
public open: boolean; public open: boolean;
public success!: boolean; public success: boolean;
public content: string; public content: string;
private timeoutId: number | null; private timeoutId?: number;
private readonly defaultDuration: number; private readonly defaultDuration: number;
constructor(params?: { defaultDuration: number }) { constructor(params?: { defaultDuration: number }) {
+5 -8
View File
@@ -37,10 +37,6 @@ export const paginated = async <T>(
queryParams.query.page = 1; queryParams.query.page = 1;
const firstPage = (await endpoint(queryParams)).data; const firstPage = (await endpoint(queryParams)).data;
if (firstPage === undefined) {
console.error(`Request to "${options?.url}" failed`);
return [];
}
const results = firstPage.results; const results = firstPage.results;
const nbElements = firstPage.count; const nbElements = firstPage.count;
@@ -49,9 +45,9 @@ export const paginated = async <T>(
if (nbPages > 1) { if (nbPages > 1) {
const promises: Promise<T[]>[] = []; const promises: Promise<T[]>[] = [];
for (let i = 2; i <= nbPages; i++) { for (let i = 2; i <= nbPages; i++) {
const nextPage = structuredClone(queryParams) as Required<PaginatedRequest>; const nextPage = structuredClone(queryParams);
nextPage.query.page = i; nextPage.query.page = i;
promises.push(endpoint(nextPage).then((res) => res.data?.results ?? [])); promises.push(endpoint(nextPage).then((res) => res.data.results));
} }
results.push(...(await Promise.all(promises)).flat()); results.push(...(await Promise.all(promises)).flat());
} }
@@ -65,7 +61,8 @@ interface Request extends TDataShape {
interface InterceptorOptions { interface InterceptorOptions {
url: string; url: string;
} }
export type GenericEndpoint = <ThrowOnError extends boolean = false>(
type GenericEndpoint = <ThrowOnError extends boolean = false>(
options?: Options<Request, ThrowOnError>, options?: Options<Request, ThrowOnError>,
) => RequestResult<unknown, unknown, ThrowOnError>; ) => RequestResult<unknown, unknown, ThrowOnError>;
@@ -74,7 +71,7 @@ export type GenericEndpoint = <ThrowOnError extends boolean = false>(
**/ **/
export const makeUrl = async (endpoint: GenericEndpoint) => { export const makeUrl = async (endpoint: GenericEndpoint) => {
let url = ""; let url = "";
const interceptor = (_request: Request, options: InterceptorOptions) => { const interceptor = (_request: undefined, options: InterceptorOptions) => {
url = options.url; url = options.url;
throw new Error("We don't want to send the request"); throw new Error("We don't want to send the request");
}; };
+9 -12
View File
@@ -7,14 +7,14 @@ interface StringifyOptions<T extends object> {
titleRow?: readonly string[]; titleRow?: readonly string[];
} }
function getNested<T extends { [key: string]: unknown }>(obj: T, key: NestedKeyOf<T>) { function getNested<T extends object>(obj: T, key: NestedKeyOf<T>) {
const path = key.split("."); const path: (keyof object)[] = key.split(".") as (keyof unknown)[];
let res = obj[path.shift() as string] as { [key: string]: unknown } | undefined; let res = obj[path.shift() as keyof T];
for (const node of path) { for (const node of path) {
if (res === undefined) { if (res === null) {
break; break;
} }
res = res[node] as { [key: string]: unknown } | undefined; res = res[node];
} }
return res; return res;
} }
@@ -29,21 +29,18 @@ function sanitizeCell(content: string): string {
} }
export const csv = { export const csv = {
stringify: <T extends { [key: string]: unknown }>( stringify: <T extends object>(objs: T[], options?: StringifyOptions<T>) => {
objs: T[], const columns = options.columns;
options?: StringifyOptions<T>,
) => {
const columns = options?.columns;
const content = objs const content = objs
.map((obj) => { .map((obj) => {
return (columns ?? []) return columns
.map((col) => { .map((col) => {
return sanitizeCell((getNested(obj, col) ?? "").toString()); return sanitizeCell((getNested(obj, col) ?? "").toString());
}) })
.join(","); .join(",");
}) })
.join("\n"); .join("\n");
if (!options?.titleRow) { if (!options.titleRow) {
return content; return content;
} }
const firstRow = options.titleRow.map(sanitizeCell).join(","); const firstRow = options.titleRow.map(sanitizeCell).join(",");
+2 -3
View File
@@ -29,7 +29,6 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio
export interface InheritedHtmlElement<K extends keyof HTMLElementTagNameMap> export interface InheritedHtmlElement<K extends keyof HTMLElementTagNameMap>
extends HTMLElement { extends HTMLElement {
readonly inheritedTagName: K; readonly inheritedTagName: K;
// readonly initializedAttribute: "component-initialized";
node: HTMLElementTagNameMap[K]; node: HTMLElementTagNameMap[K];
} }
@@ -48,8 +47,8 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
implements InheritedHtmlElement<K> implements InheritedHtmlElement<K>
{ {
readonly inheritedTagName = tagName; readonly inheritedTagName = tagName;
readonly initializedAttribute = "component-initialized"; private readonly initializedAttribute = "component-initialized";
node!: HTMLElementTagNameMap[K]; node: HTMLElementTagNameMap[K];
connectedCallback(autoAddNode?: boolean) { connectedCallback(autoAddNode?: boolean) {
// When nesting inherited elements, we might trigger the wrapping twice // When nesting inherited elements, we might trigger the wrapping twice
@@ -1,7 +1,7 @@
import type { TomOption } from "tom-select/src/types"; import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/src/utils"; import type { escape_html } from "tom-select/dist/types/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base"; import { AjaxSelect } from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components"; import { registerComponent } from "#core:utils/web-components.ts";
import { import {
type CounterSchema, type CounterSchema,
counterSearchCounter, counterSearchCounter,
@@ -28,18 +28,14 @@ export class ProductAjaxSelect extends AjaxSelect {
return []; return [];
} }
private getName(item: SimpleProductSchema, sanitize: typeof escape_html): string {
return item.code ? `${sanitize(item.code)} - ${sanitize(item.name)}` : item.name;
}
protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) { protected renderOption(item: SimpleProductSchema, sanitize: typeof escape_html) {
return `<div class="select-item"> return `<div class="select-item">
<span class="select-item-text">${this.getName(item, sanitize)}</span> <span class="select-item-text">${sanitize(item.code)} - ${sanitize(item.name)}</span>
</div>`; </div>`;
} }
protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) { protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) {
return `<span>${this.getName(item, sanitize)}</span>`; return `<span>${sanitize(item.code)} - ${sanitize(item.name)}</span>`;
} }
} }
@@ -48,7 +44,7 @@ export class ProductTypeAjaxSelect extends AjaxSelect {
protected valueField = "id"; protected valueField = "id";
protected labelField = "name"; protected labelField = "name";
protected searchField = ["name"]; protected searchField = ["name"];
private productTypes = null as ProductTypeSchema[] | null; private productTypes = null as ProductTypeSchema[];
protected async search(query: string): Promise<TomOption[]> { protected async search(query: string): Promise<TomOption[]> {
// The production database has a grand total of 26 product types // The production database has a grand total of 26 product types
@@ -56,7 +52,7 @@ export class ProductTypeAjaxSelect extends AjaxSelect {
// Thus, it's appropriate to fetch all product types during first use, // Thus, it's appropriate to fetch all product types during first use,
// then to reuse the result again and again. // then to reuse the result again and again.
if (this.productTypes === null) { if (this.productTypes === null) {
this.productTypes = (await producttypeFetchAll()).data || []; this.productTypes = (await producttypeFetchAll()).data || null;
} }
return this.productTypes.filter((t) => return this.productTypes.filter((t) =>
t.name.toLowerCase().includes(query.toLowerCase()), t.name.toLowerCase().includes(query.toLowerCase()),
@@ -59,7 +59,7 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
return onOptionSelect.call( return onOptionSelect.call(
this.widget, this.widget,
evt, evt,
this.widget.getOption(value, true) as HTMLElement, this.widget.getOption(value, true),
); );
}, },
); );
@@ -70,15 +70,12 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
...super.tomSelectSettings(), ...super.tomSelectSettings(),
openOnFocus: false, openOnFocus: false,
// We make searching on exact code matching a higher priority // We make searching on exact code matching a higher priority
// We need to manually set weights, or it results on an inconsistent // We need to manually set weights or it results on an inconsistent
// behavior between production and development environment // behavior between production and development environment
//
// SearchField can be an object array (according to the docs),
// but it is unproperly typed, so we must force-cast to trick TS
searchField: [ searchField: [
{ field: "code", weight: 2 }, { field: "code", weight: 2 },
{ field: "text", weight: 0.5 }, { field: "text", weight: 0.5 },
] as unknown as string[], ],
}; };
} }
} }
@@ -1,14 +1,9 @@
import { showSaveFilePicker } from "native-file-system-adapter"; import { showSaveFilePicker } from "native-file-system-adapter";
import type TomSelect from "tom-select"; import type TomSelect from "tom-select";
import type { ClubAjaxSelect } from "#club:club/components/ajax-select-index";
import type { NestedKeyOf } from "#core:types/nested-key"; import type { NestedKeyOf } from "#core:types/nested-key";
import { type PaginatedRequest, paginated } from "#core:utils/api"; import { paginated } from "#core:utils/api";
import { csv } from "#core:utils/csv"; import { csv } from "#core:utils/csv";
import { getCurrentUrlParams, History, updateQueryString } from "#core:utils/history"; import { getCurrentUrlParams, History, updateQueryString } from "#core:utils/history";
import type {
CounterAjaxSelect,
ProductTypeAjaxSelect,
} from "#counter:counter/components/ajax-select-index";
import { import {
type ProductSchema, type ProductSchema,
type ProductSearchProductsDetailedData, type ProductSearchProductsDetailedData,
@@ -56,8 +51,6 @@ const csvColumnTitles = [
gettext("archived"), gettext("archived"),
]; ];
type ProductStatus = "active" | "archived" | "both";
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("productList", () => ({ Alpine.data("productList", () => ({
loading: false, loading: false,
@@ -66,7 +59,8 @@ document.addEventListener("alpine:init", () => {
/** Total number of elements corresponding to the current query. */ /** Total number of elements corresponding to the current query. */
nbPages: 0, nbPages: 0,
productStatus: "" as ProductStatus,
productStatus: "" as "active" | "archived" | "both",
search: "", search: "",
productTypes: [] as string[], productTypes: [] as string[],
clubs: [] as string[], clubs: [] as string[],
@@ -77,18 +71,16 @@ document.addEventListener("alpine:init", () => {
async init() { async init() {
const url = getCurrentUrlParams(); const url = getCurrentUrlParams();
this.search = url.get("search") || ""; this.search = url.get("search") || "";
this.productStatus = (url.get("productStatus") ?? "active") as ProductStatus; this.productStatus = url.get("productStatus") ?? "active";
const productTypesWidget = (this.$refs.productTypesInput as ProductTypeAjaxSelect) const productTypesWidget = this.$refs.productTypesInput.widget as TomSelect;
.widget as TomSelect;
productTypesWidget.on("change", (items: string[]) => { productTypesWidget.on("change", (items: string[]) => {
this.productTypes = [...items]; this.productTypes = [...items];
}); });
const clubsWidget = (this.$refs.clubsInput as ClubAjaxSelect).widget as TomSelect; const clubsWidget = this.$refs.clubsInput.widget as TomSelect;
clubsWidget.on("change", (items: string[]) => { clubsWidget.on("change", (items: string[]) => {
this.clubs = [...items]; this.clubs = [...items];
}); });
const countersWidget = (this.$refs.countersInput as CounterAjaxSelect) const countersWidget = this.$refs.countersInput.widget as TomSelect;
.widget as TomSelect;
countersWidget.on("change", (items: string[]) => { countersWidget.on("change", (items: string[]) => {
this.counters = [...items]; this.counters = [...items];
}); });
@@ -135,9 +127,9 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
is_archived: isArchived, is_archived: isArchived,
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
product_type: [...this.productTypes.map(Number.parseInt)], product_type: [...this.productTypes],
club: [...this.clubs.map(Number.parseInt)], club: [...this.clubs],
counter: [...this.counters.map(Number.parseInt)], counter: [...this.counters],
}, },
}; };
}, },
@@ -149,10 +141,6 @@ document.addEventListener("alpine:init", () => {
this.loading = true; this.loading = true;
const options = this.getQueryParams(); const options = this.getQueryParams();
const resp = await productSearchProductsDetailed(options); const resp = await productSearchProductsDetailed(options);
if (resp.data === undefined) {
console.error("Product search request failed");
return;
}
this.nbPages = Math.ceil(resp.data.count / defaultPageSize); this.nbPages = Math.ceil(resp.data.count / defaultPageSize);
this.products = resp.data.results.reduce<GroupedProducts>( this.products = resp.data.results.reduce<GroupedProducts>(
(acc: GroupedProducts, curr: ProductSchema) => { (acc: GroupedProducts, curr: ProductSchema) => {
@@ -184,10 +172,7 @@ document.addEventListener("alpine:init", () => {
// If not, fetch them. // If not, fetch them.
const products: ProductSchema[] = const products: ProductSchema[] =
this.nbPages > 1 this.nbPages > 1
? await paginated( ? await paginated(productSearchProductsDetailed, this.getQueryParams())
productSearchProductsDetailed,
this.getQueryParams() as PaginatedRequest,
)
: Object.values<ProductSchema[]>(this.products).flat(); : Object.values<ProductSchema[]>(this.products).flat();
// CSV cannot represent nested data // CSV cannot represent nested data
// so we create a row for each price of each product. // so we create a row for each price of each product.
@@ -1,5 +1,5 @@
import Alpine from "alpinejs"; import Alpine from "alpinejs";
import { AlertMessage } from "#core:utils/alert-message"; import { AlertMessage } from "#core:utils/alert-message.ts";
import { producttypeReorder } from "#openapi"; import { producttypeReorder } from "#openapi";
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
@@ -22,7 +22,7 @@ document.addEventListener("alpine:init", () => {
const productTypes = this.$refs.productTypes const productTypes = this.$refs.productTypes
.childNodes as NodeListOf<HTMLLIElement>; .childNodes as NodeListOf<HTMLLIElement>;
const getId = (elem: HTMLLIElement) => const getId = (elem: HTMLLIElement) =>
Number.parseInt(elem.getAttribute("x-sort:item") as string, 10); Number.parseInt(elem.getAttribute("x-sort:item"), 10);
const query = const query =
newPosition === 0 newPosition === 0
? { above: getId(productTypes.item(1)) } ? { above: getId(productTypes.item(1)) }
@@ -32,10 +32,6 @@ document.addEventListener("alpine:init", () => {
path: { type_id: itemId }, path: { type_id: itemId },
query: query, query: query,
}); });
if (response.response === undefined) {
console.error("Product type reordering request failed");
return;
}
this.openAlertMessage(response.response); this.openAlertMessage(response.response);
this.loading = false; this.loading = false;
}, },
-22
View File
@@ -886,28 +886,6 @@ class TestBarmanConnection(TestCase):
) )
assert response.status_code == 403 assert response.status_code == 403
def test_logout_then_login(self):
"""Test that the login is successful if it is after a previous logout.
This is a regression test for #1440
"""
self.client.post( # login
self.login_url, {"username": self.barman.username, "password": "plop"}
)
self.client.post( # logout
reverse("counter:logout", kwargs={"counter_id": self.counter.id}),
data={"user_id": self.barman.id},
)
response = self.client.post( # and re-login
self.login_url, {"username": self.barman.username, "password": "plop"}
)
assert response.status_code == 200
assert response.headers["HX-Redirect"] == self.detail_url
response = self.client.get(
self.detail_url, {"username": self.barman.username, "password": "plop"}
)
assert self.barman in response.wsgi_request.barmen
@pytest.mark.django_db @pytest.mark.django_db
def test_barman_timeout(client: Client): def test_barman_timeout(client: Client):
-1
View File
@@ -61,7 +61,6 @@ class CounterLoginFragment(FragmentMixin, SingleObjectMixin, FormView):
user = form.get_user() user = form.get_user()
perm = self.object.permanencies.create(user=user, start=timezone.now()) perm = self.object.permanencies.create(user=user, start=timezone.now())
self.request.session.setdefault(SESSION_PERMANENCES_KEY, []).append(perm.id) self.request.session.setdefault(SESSION_PERMANENCES_KEY, []).append(perm.id)
self.request.session.modified = True
self.success_url = reverse( self.success_url = reverse(
"counter:details", kwargs={"counter_id": self.object.id} "counter:details", kwargs={"counter_id": self.object.id}
) )
@@ -61,10 +61,10 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { basket_id: basket.id }, path: { basket_id: basket.id },
}); });
if (res.response?.ok) { if (res.response.ok) {
this.data = res.data as Record<string, string>; this.data = res.data as Record<string, string>;
this.isCbAvailable = true; this.isCbAvailable = true;
} else if (res.response?.status === 410) { } else if (res.response.status === 410) {
// The basket is expired, so no payment method should be available at all. // The basket is expired, so no payment method should be available at all.
// This shouldn't happen, because we don't send the request // This shouldn't happen, because we don't send the request
// when the timeout is passed, but we are better safe than sorry // when the timeout is passed, but we are better safe than sorry
-18
View File
@@ -48,7 +48,6 @@
"@types/cytoscape-cxtmenu": "^3.4.5", "@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-klay": "^3.1.5", "@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/node": "^26.1.1",
"rollup-plugin-visualizer": "^7.0.1", "rollup-plugin-visualizer": "^7.0.1",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.1.0" "vite": "^8.1.0"
@@ -2512,16 +2511,6 @@
"integrity": "sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w==", "integrity": "sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/tern": { "node_modules/@types/tern": {
"version": "0.23.9", "version": "0.23.9",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
@@ -4665,13 +4654,6 @@
"integrity": "sha512-Z1YkJ7IIYNrFeOxAlHUercY4Q2I+PhYD/3VkWpJGy/Oqudy3bFpNcQxnv6Oa9fTSXCHPGz1eDoX1bZYm2Z891A==", "integrity": "sha512-Z1YkJ7IIYNrFeOxAlHUercY4Q2I+PhYD/3VkWpJGy/Oqudy3bFpNcQxnv6Oa9fTSXCHPGz1eDoX1bZYm2Z891A==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unicode-canonical-property-names-ecmascript": { "node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
-1
View File
@@ -33,7 +33,6 @@
"@types/cytoscape-cxtmenu": "^3.4.5", "@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-klay": "^3.1.5", "@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/node": "^26.1.1",
"rollup-plugin-visualizer": "^7.0.1", "rollup-plugin-visualizer": "^7.0.1",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.1.0" "vite": "^8.1.0"
@@ -19,6 +19,8 @@ class Command(BaseCommand):
"This may take a few minutes to complete." "This may take a few minutes to complete."
) )
for ue in client.fetch_ues(): for ue in client.fetch_ues():
# this is a N+1 query, but it isn't a problem,
# as fetching a UE from the UTBM API is taking most of the time anyway
db_ue = UE.objects.filter(code=ue.code).first() db_ue = UE.objects.filter(code=ue.code).first()
if db_ue is None: if db_ue is None:
db_ue = UE(code=ue.code, author=root_user) db_ue = UE(code=ue.code, author=root_user)
+28 -17
View File
@@ -11,6 +11,16 @@ from pydantic.alias_generators import to_camel
from pedagogy.models import UE from pedagogy.models import UE
class FormationSchema(Schema):
model_config = ConfigDict(validate_by_name=True, validate_by_alias=True)
code: str
label: str = Field(alias="libelle")
FormationListSchema = TypeAdapter(list[FormationSchema])
class UtbmShortUeSchema(Schema): class UtbmShortUeSchema(Schema):
"""Short representation of an UE in the UTBM API. """Short representation of an UE in the UTBM API.
@@ -19,19 +29,17 @@ class UtbmShortUeSchema(Schema):
The UTBM API returns more data than that. The UTBM API returns more data than that.
""" """
model_config = ConfigDict(alias_generator=to_camel) model_config = ConfigDict(validate_by_name=True)
code: str code: str
code_formation: str category: str = Field(alias="codeCategorie")
code_categorie: str | None formation: FormationSchema
code_langue: str lang: str = Field(alias="codeLangue")
ouvert_automne: bool open_autumn: bool = Field(alias="ouvertAutomne")
ouvert_printemps: bool open_spring: bool = Field(alias="ouvertPrintemps")
class WorkloadSchema(Schema): class WorkloadSchema(Schema):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
code: Literal["TD", "TP", "CM", "THE", "TE"] code: Literal["TD", "TP", "CM", "THE", "TE"]
nbh: int nbh: int
@@ -48,22 +56,25 @@ class SemesterUeState(Schema):
ShortUeList = TypeAdapter(list[UtbmShortUeSchema]) ShortUeList = TypeAdapter(list[UtbmShortUeSchema])
class SyllabusItemSchema(Schema):
model_config = ConfigDict(validate_by_name=True)
label: str = Field(alias="libelle")
value: str | None = Field(alias="valeur")
class UtbmFullUeSchema(Schema): class UtbmFullUeSchema(Schema):
"""Long representation of an UE in the UTBM API.""" """Long representation of an UE in the UTBM API."""
model_config = ConfigDict(alias_generator=to_camel) model_config = ConfigDict(validate_by_name=True, alias_generator=to_camel)
code: str code: str
departement: str = "NA" departement: str = "NA"
libelle: str | None label: str | None = Field(alias="libelle")
objectifs: str | None syllabus: list[SyllabusItemSchema] = Field(alias="listeSaisieSyllabus")
programme: str | None lang: str = Field(None, validation_alias=AliasPath("langueEnseignement", "code"))
acquisition_competences: str | None
acquisition_notions: str | None
langue: str
code_langue: str
credits_ects: int credits_ects: int
activites: list[WorkloadSchema] activites: list[WorkloadSchema] = Field(alias="listeActivite")
respo_automne: str | None = Field( respo_automne: str | None = Field(
None, validation_alias=AliasPath("automne", "responsable") None, validation_alias=AliasPath("automne", "responsable")
) )
+78 -36
View File
@@ -5,8 +5,16 @@ from typing import Iterator
import requests import requests
from django.conf import settings from django.conf import settings
from django.utils.functional import cached_property from django.utils.functional import cached_property
from pydantic import TypeAdapter
from pedagogy.schemas import ShortUeList, UeSchema, UtbmFullUeSchema, UtbmShortUeSchema from pedagogy.schemas import (
FormationSchema,
UeSchema,
UtbmFullUeSchema,
UtbmShortUeSchema,
)
FormationListSchema = TypeAdapter(list[FormationSchema])
class UtbmApiClient(requests.Session): class UtbmApiClient(requests.Session):
@@ -18,28 +26,44 @@ class UtbmApiClient(requests.Session):
@cached_property @cached_property
def current_year(self) -> int: def current_year(self) -> int:
"""Fetch from the API the latest existing year""" """Fetch from the API the latest existing year"""
url = f"{self.BASE_URL}/guides/fr" url = f"{self.BASE_URL}/guide/"
response = self.get(url) response = self.get(url, {"langue": "fr"})
return response.json()[-1]["annee"] return max(i["annee"] for i in response.json())
def fetch_short_ues( @cached_property
self, lang: str = "fr", year: int | None = None def formations(self) -> list[FormationSchema]:
) -> list[UtbmShortUeSchema]: response = self.get(
f"{self.BASE_URL}/formation/",
{"langue": "fr", "annee_univ": self.current_year, "typeFormation": "ING"},
)
return FormationListSchema.validate_json(response.text, by_alias=True)
def fetch_short_ues(self, year: int | None = None) -> list[UtbmShortUeSchema]:
"""Get the list of UEs in their short format from the UTBM API""" """Get the list of UEs in their short format from the UTBM API"""
if year is None: if year is None:
year = self.current_year year = self.current_year
if lang not in self._cache["short_ues"]: if year not in self._cache["short_ues"]:
self._cache["short_ues"][lang] = {} ues = []
if year not in self._cache["short_ues"][lang]: for formation in self.formations:
url = f"{self.BASE_URL}/uvs/{lang}/{year}" response = self.get(
response = self.get(url) f"{self.BASE_URL}/ue/",
ues = ShortUeList.validate_json(response.content) {
self._cache["short_ues"][lang][year] = ues "langue": "fr",
return self._cache["short_ues"][lang][year] "annee_univ": year,
"codeFormation": formation.code,
},
)
ues.extend(
[
UtbmShortUeSchema.model_validate({**ue, "formation": formation})
for ue in response.json()
if ue["codeCategorie"] is not None
]
)
self._cache["short_ues"][year] = ues
return self._cache["short_ues"][year]
def fetch_ues( def fetch_ues(self, year: int | None = None) -> Iterator[UeSchema]:
self, lang: str = "fr", year: int | None = None
) -> Iterator[UeSchema]:
"""Fetch all UEs from the UTBM API, parsed in a format that we can use. """Fetch all UEs from the UTBM API, parsed in a format that we can use.
Warning: Warning:
@@ -52,7 +76,7 @@ class UtbmApiClient(requests.Session):
""" """
if year is None: if year is None:
year = self.current_year year = self.current_year
shorts_ues = self.fetch_short_ues(lang, year) shorts_ues = self.fetch_short_ues(year)
# When UEs are common to multiple branches (like most HUMA) # When UEs are common to multiple branches (like most HUMA)
# the UTBM API duplicates them for every branch. # the UTBM API duplicates them for every branch.
# We have no way in our db to link a UE to multiple formations, # We have no way in our db to link a UE to multiple formations,
@@ -65,27 +89,38 @@ class UtbmApiClient(requests.Session):
if ue.code not in unique_short_ues: if ue.code not in unique_short_ues:
unique_short_ues[ue.code] = ue unique_short_ues[ue.code] = ue
for ue in unique_short_ues.values(): for ue in unique_short_ues.values():
ue_url = f"{self.BASE_URL}/uv/{lang}/{year}/{ue.code}/{ue.code_formation}" response = requests.get(
response = requests.get(ue_url) f"{self.BASE_URL}/ue/{ue.code}",
{
"langue": ue.lang,
"annee_univ": year,
"codeFormation": ue.formation.code,
},
)
full_ue = UtbmFullUeSchema.model_validate_json(response.content) full_ue = UtbmFullUeSchema.model_validate_json(response.content)
yield make_clean_ue(ue, full_ue) yield make_clean_ue(ue, full_ue)
def find_uu(self, lang: str, code: str, year: int | None = None) -> UeSchema | None: def find_ue(self, code: str, year: int | None = None) -> UeSchema | None:
"""Find an UE from the UTBM API.""" """Find a UE from the UTBM API."""
# query the UE list
if not year: if not year:
year = self.current_year year = self.current_year
# the UTBM API has no way to fetch a single short ue, # the UTBM API has no way to fetch a single short ue,
# and short ues contain infos that we need and are not # and short ues contain infos that we need and are not
# in the full ue schema, so we must fetch everything. # in the full ue schema, so we must fetch everything.
short_ues = self.fetch_short_ues(lang, year) short_ues = self.fetch_short_ues(year)
short_ue = next((ue for ue in short_ues if ue.code == code), None) short_ue = next((ue for ue in short_ues if ue.code == code), None)
if short_ue is None: if short_ue is None:
return None return None
# get detailed information about the UE # get detailed information about the UE
ue_url = f"{self.BASE_URL}/uv/{lang}/{year}/{code}/{short_ue.code_formation}" response = requests.get(
response = requests.get(ue_url) f"{self.BASE_URL}/ue/{code}",
{
"langue": "fr",
"annee_univ": year,
"codeFormation": short_ue.formation.code,
},
)
full_ue = UtbmFullUeSchema.model_validate_json(response.content) full_ue = UtbmFullUeSchema.model_validate_json(response.content)
return make_clean_ue(short_ue, full_ue) return make_clean_ue(short_ue, full_ue)
@@ -112,9 +147,9 @@ def make_clean_ue(short_ue: UtbmShortUeSchema, full_ue: UtbmFullUeSchema) -> UeS
"ED": "EDIM", "ED": "EDIM",
"AI": "GI", "AI": "GI",
"AM": "MC", "AM": "MC",
}.get(short_ue.code_formation, "NA") }.get(short_ue.formation.code, "NA")
match short_ue.ouvert_printemps, short_ue.ouvert_automne: match short_ue.open_spring, short_ue.open_autumn:
case True, True: case True, True:
semester = "AUTUMN_AND_SPRING" semester = "AUTUMN_AND_SPRING"
case True, False: case True, False:
@@ -125,11 +160,11 @@ def make_clean_ue(short_ue: UtbmShortUeSchema, full_ue: UtbmFullUeSchema) -> UeS
semester = "CLOSED" semester = "CLOSED"
return UeSchema( return UeSchema(
title=full_ue.libelle or "", title=full_ue.label or "",
code=full_ue.code, code=full_ue.code,
credit_type=short_ue.code_categorie or "FREE", credit_type=short_ue.code or "FREE",
semester=semester, semester=semester,
language=short_ue.code_langue.upper(), language=short_ue.lang.upper(),
credits=full_ue.credits_ects, credits=full_ue.credits_ects,
department=department, department=department,
hours_THE=next((i.nbh for i in full_ue.activites if i.code == "THE"), 0) // 60, hours_THE=next((i.nbh for i in full_ue.activites if i.code == "THE"), 0) // 60,
@@ -138,8 +173,15 @@ def make_clean_ue(short_ue: UtbmShortUeSchema, full_ue: UtbmFullUeSchema) -> UeS
hours_TE=next((i.nbh for i in full_ue.activites if i.code == "TE"), 0) // 60, hours_TE=next((i.nbh for i in full_ue.activites if i.code == "TE"), 0) // 60,
hours_CM=next((i.nbh for i in full_ue.activites if i.code == "CM"), 0) // 60, hours_CM=next((i.nbh for i in full_ue.activites if i.code == "CM"), 0) // 60,
manager=full_ue.respo_automne or full_ue.respo_printemps or "", manager=full_ue.respo_automne or full_ue.respo_printemps or "",
objectives=full_ue.objectifs or "", objectives=next(
program=full_ue.programme or "", (i.value for i in full_ue.syllabus if i.label == "Objectifs"), ""
skills=full_ue.acquisition_competences or "", ),
key_concepts=full_ue.acquisition_notions or "", program=next((i.value for i in full_ue.syllabus if i.label == "Programme"), ""),
skills=next(
(i.value for i in full_ue.syllabus if i.label == "Notions-clefs"), ""
),
key_concepts=next(
(i.value for i in full_ue.syllabus if i.label == "Acquis d'apprentissage"),
"",
),
) )
+3 -3
View File
@@ -1,4 +1,4 @@
from typing import Literal from typing import Any, Literal
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@@ -20,7 +20,7 @@ from api.permissions import (
IsRoot, IsRoot,
) )
from core.models import Notification, User from core.models import Notification, User
from core.schemas import UploadedImage, ValidationErrorSchema from core.schemas import UploadedImage
from sas.models import Album, PeoplePictureRelation, Picture from sas.models import Album, PeoplePictureRelation, Picture
from sas.schemas import ( from sas.schemas import (
AlbumAutocompleteSchema, AlbumAutocompleteSchema,
@@ -106,7 +106,7 @@ class PicturesController(ControllerBase):
response={ response={
200: None, 200: None,
409: dict[Literal["detail"], dict[str, list[str]]], 409: dict[Literal["detail"], dict[str, list[str]]],
422: ValidationErrorSchema, 422: dict[Literal["detail"], list[dict[str, Any]]],
}, },
url_name="upload_picture", url_name="upload_picture",
) )
@@ -1,7 +1,7 @@
import type { TomOption } from "tom-select/src/types"; import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/src/utils"; import type { escape_html } from "tom-select/dist/types/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base"; import { AjaxSelect } from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components"; import { registerComponent } from "#core:utils/web-components.ts";
import { type AlbumAutocompleteSchema, albumAutocompleteAlbum } from "#openapi"; import { type AlbumAutocompleteSchema, albumAutocompleteAlbum } from "#openapi";
@registerComponent("album-ajax-select") @registerComponent("album-ajax-select")
@@ -9,7 +9,7 @@ document.addEventListener("alpine:init", () => {
async downloadZip() { async downloadZip() {
this.isDownloading = true; this.isDownloading = true;
const bar = this.$refs.progress as HTMLProgressElement; const bar = this.$refs.progress;
bar.value = 0; bar.value = 0;
bar.max = this.downloadPictures.length; bar.max = this.downloadPictures.length;
@@ -31,11 +31,10 @@ document.addEventListener("alpine:init", () => {
await Promise.all( await Promise.all(
this.downloadPictures.map((p: PictureSchema) => { this.downloadPictures.map((p: PictureSchema) => {
const dateStr = (p.date as string).replace(/[:-]/g, "_"); const imgName = `${p.album.name}/IMG_${p.id}_${p.date.replace(/[:-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
const imgName = `${p.album.name}/IMG_${p.id}_${dateStr}${p.name.slice(p.name.lastIndexOf("."))}`;
return zipWriter.add(imgName, new HttpReader(p.full_size_url), { return zipWriter.add(imgName, new HttpReader(p.full_size_url), {
level: 9, level: 9,
lastModDate: new Date(p.date as string), lastModDate: new Date(p.date),
onstart: incrementProgressBar, onstart: incrementProgressBar,
}); });
}), }),
@@ -57,16 +57,14 @@ document.addEventListener("alpine:init", () => {
async init() { async init() {
const pictures = await this.fetchPictures(); const pictures = await this.fetchPictures();
const groupedAlbums = Object.groupBy(pictures, (i) => i.album.id as number); const groupedAlbums = Object.groupBy(pictures, (i: PictureSchema) => i.album.id);
this.albums = Object.values(groupedAlbums as Record<number, PictureSchema[]>).map( this.albums = Object.values(groupedAlbums).map((pictures: PictureSchema[]) => {
(pictures) => {
return { return {
id: pictures[0].album.id as number, id: pictures[0].album.id,
name: pictures[0].album.name, name: pictures[0].album.name,
pictures: pictures, pictures: pictures,
}; };
}, });
);
this.albums.sort((a: Album, b: Album) => b.id - a.id); this.albums.sort((a: Album, b: Album) => b.id - a.id);
const hash = document.location.hash.replace("#", ""); const hash = document.location.hash.replace("#", "");
if (hash.startsWith("album-")) { if (hash.startsWith("album-")) {
+28 -63
View File
@@ -1,5 +1,4 @@
import type TomSelect from "tom-select"; 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 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 { History } from "#core:utils/history";
@@ -7,6 +6,7 @@ import {
type IdentifiedUserSchema, type IdentifiedUserSchema,
type ModerationRequestSchema, type ModerationRequestSchema,
type PictureSchema, type PictureSchema,
type PicturesFetchIdentificationsResponse,
type PicturesFetchPicturesData, type PicturesFetchPicturesData,
picturesDeletePicture, picturesDeletePicture,
picturesFetchIdentifications, picturesFetchIdentifications,
@@ -15,7 +15,6 @@ import {
picturesIdentifyUsers, picturesIdentifyUsers,
picturesModeratePicture, picturesModeratePicture,
picturesRotatePicture, picturesRotatePicture,
type SimpleAlbumSchema,
type UserProfileSchema, type UserProfileSchema,
usersidentifiedDeleteRelation, usersidentifiedDeleteRelation,
} from "#openapi"; } from "#openapi";
@@ -24,8 +23,8 @@ import {
* A container for a picture with the users identified on it * A container for a picture with the users identified on it
* able to prefetch its data. * able to prefetch its data.
*/ */
class PictureWithIdentifications implements PictureSchema { class PictureWithIdentifications {
identifications?: IdentifiedUserSchema[]; identifications: PicturesFetchIdentificationsResponse = null;
imageLoading = false; imageLoading = false;
identificationsLoading = false; identificationsLoading = false;
moderationLoading = false; moderationLoading = false;
@@ -33,43 +32,10 @@ class PictureWithIdentifications implements PictureSchema {
compressedUrl: string = ""; compressedUrl: string = "";
thumbUrl: string = ""; thumbUrl: string = "";
fullSizeUrl: string = ""; fullSizeUrl: string = "";
moderationRequests: ModerationRequestSchema[] = []; moderationRequests: ModerationRequestSchema[] = null;
owner: UserProfileSchema;
// biome-ignore-start lint/style/useNamingConvention: PictureSchema has CamelCase properties
sas_url: string;
full_size_url: string;
compressed_url: string;
thumb_url: string;
album: SimpleAlbumSchema;
report_url: string;
edit_url: string;
name: string;
date?: string | undefined;
updated_at: string;
size?: number | undefined;
is_moderated?: boolean | undefined;
asked_for_removal?: boolean | undefined;
// biome-ignore-end lint/style/useNamingConvention: PictureSchema has CamelCase properties
constructor(picture: PictureSchema) { constructor(picture: PictureSchema) {
if (picture.id === null || picture.id === undefined) { Object.assign(this, picture);
throw new Error("picture id expected a value");
}
this.owner = picture.owner;
this.sas_url = picture.sas_url;
this.full_size_url = picture.full_size_url;
this.compressed_url = picture.compressed_url;
this.thumb_url = picture.thumb_url;
this.album = picture.album;
this.report_url = picture.report_url;
this.edit_url = picture.edit_url;
this.name = picture.name;
this.date = picture.date;
this.updated_at = picture.updated_at;
this.size = picture.size;
this.is_moderated = picture.is_moderated;
this.asked_for_removal = picture.asked_for_removal;
this.id = picture.id;
this.compressedUrl = picture.compressed_url; this.compressedUrl = picture.compressed_url;
this.thumbUrl = picture.thumb_url; this.thumbUrl = picture.thumb_url;
this.fullSizeUrl = picture.full_size_url; this.fullSizeUrl = picture.full_size_url;
@@ -103,13 +69,12 @@ class PictureWithIdentifications implements PictureSchema {
return; return;
} }
this.identificationsLoading = true; this.identificationsLoading = true;
this.identifications = this.identifications = (
(
await picturesFetchIdentifications({ await picturesFetchIdentifications({
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.id }, path: { picture_id: this.id },
}) })
).data ?? []; ).data;
this.identificationsLoading = false; this.identificationsLoading = false;
} }
@@ -123,13 +88,12 @@ class PictureWithIdentifications implements PictureSchema {
return; return;
} }
this.moderationLoading = true; this.moderationLoading = true;
this.moderationRequests = this.moderationRequests = (
(
await picturesFetchModerationRequests({ await picturesFetchModerationRequests({
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.id }, path: { picture_id: this.id },
}) })
).data ?? []; ).data;
this.moderationLoading = false; this.moderationLoading = false;
} }
@@ -139,10 +103,6 @@ class PictureWithIdentifications implements PictureSchema {
// biome-ignore lint/style/useNamingConvention: api is snake case // biome-ignore lint/style/useNamingConvention: api is snake case
path: { picture_id: this.id, direction: direction }, path: { picture_id: this.id, direction: direction },
}); });
if (!res.data) {
console.error("The data o the rotated were expected, but nothing was returned");
return;
}
// urls returned by the api include a timestamp for cache busting // urls returned by the api include a timestamp for cache busting
this.fullSizeUrl = res.data.full_size_url; this.fullSizeUrl = res.data.full_size_url;
this.compressedUrl = res.data.compressed_url; this.compressedUrl = res.data.compressed_url;
@@ -197,7 +157,7 @@ document.addEventListener("alpine:init", () => {
currentPicture: { currentPicture: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
is_moderated: true, is_moderated: true,
id: null as number | null, id: null as number,
name: "", name: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
display_name: "", display_name: "",
@@ -211,19 +171,19 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
created_at: new Date(), created_at: new Date(),
identifications: [] as IdentifiedUserSchema[], identifications: [] as IdentifiedUserSchema[],
} as unknown as PictureWithIdentifications, },
/** /**
* The picture which will be displayed next if the user press the "next" button * The picture which will be displayed next if the user press the "next" button
**/ **/
nextPicture: null as PictureWithIdentifications | null, nextPicture: null as PictureWithIdentifications,
/** /**
* The picture which will be displayed next if the user press the "previous" button * The picture which will be displayed next if the user press the "previous" button
**/ **/
previousPicture: null as PictureWithIdentifications | null, previousPicture: null as PictureWithIdentifications,
/** /**
* The select2 component used to identify users * The select2 component used to identify users
**/ **/
selector: undefined as UserAjaxSelect | undefined, selector: undefined as UserAjaxSelect,
/** /**
* Error message when a moderation operation fails * Error message when a moderation operation fails
**/ **/
@@ -241,18 +201,24 @@ document.addEventListener("alpine:init", () => {
query: { album_id: config.albumId }, query: { album_id: config.albumId },
} as PicturesFetchPicturesData) } as PicturesFetchPicturesData)
).map(PictureWithIdentifications.fromPicture); ).map(PictureWithIdentifications.fromPicture);
this.selector = this.$refs.search as UserAjaxSelect; this.selector = this.$refs.search;
this.selector.setFilter((users: TomOption[]) => { this.selector.setFilter((users: UserProfileSchema[]) => {
const resp: UserProfileSchema[] = [];
const ids = [ const ids = [
...(this.currentPicture.identifications || []).map( ...(this.currentPicture.identifications || []).map(
(i: IdentifiedUserSchema) => i.user.id, (i: IdentifiedUserSchema) => i.user.id,
), ),
]; ];
return users.filter((user) => !ids.includes(user.id)); for (const user of users) {
if (!ids.includes(user.id)) {
resp.push(user);
}
}
return resp;
}); });
this.currentPicture = this.pictures.find( this.currentPicture = this.pictures.find(
(i) => i.id === config.firstPictureId, (i: PictureSchema) => i.id === config.firstPictureId,
) as PictureWithIdentifications; );
this.$watch( this.$watch(
"currentPicture", "currentPicture",
(current: PictureSchema, previous: PictureSchema) => { (current: PictureSchema, previous: PictureSchema) => {
@@ -270,7 +236,7 @@ document.addEventListener("alpine:init", () => {
this.pushstate = History.Replace; 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; );
}); });
this.pushstate = History.Replace; /* Avoid first url push */ this.pushstate = History.Replace; /* Avoid first url push */
await this.updatePicture(); await this.updatePicture();
@@ -345,15 +311,14 @@ document.addEventListener("alpine:init", () => {
// As the album is now empty, go back to the parent page // As the album is now empty, go back to the parent page
document.location.href = config.albumUrl; document.location.href = config.albumUrl;
} }
this.currentPicture = (this.nextPicture || this.currentPicture = this.nextPicture || this.previousPicture;
this.previousPicture) as PictureWithIdentifications;
}, },
/** /**
* Send the identification request and update the list of identified users. * Send the identification request and update the list of identified users.
*/ */
async submitIdentification(): Promise<void> { async submitIdentification(): Promise<void> {
const widget: TomSelect = (this.selector as UserAjaxSelect).widget; const widget: TomSelect = this.selector.widget;
await picturesIdentifyUsers({ await picturesIdentifyUsers({
// biome-ignore lint/style/useNamingConvention: api is in snake_case // biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.currentPicture.id }, path: { picture_id: this.currentPicture.id },
+1 -1
View File
@@ -493,7 +493,7 @@ SITH_LOG_OPERATION_TYPE = [
("REFILLING_DELETION", _("Refilling deletion")), ("REFILLING_DELETION", _("Refilling deletion")),
] ]
SITH_PEDAGOGY_UTBM_API = "https://extranet1.utbm.fr/gpedago/api/guide" SITH_PEDAGOGY_UTBM_API = "https://api.utbm.fr/offre-formation/public"
# Defines pagination for cash summary # Defines pagination for cash summary
SITH_COUNTER_CASH_SUMMARY_LENGTH = 50 SITH_COUNTER_CASH_SUMMARY_LENGTH = 50
@@ -1,11 +1,10 @@
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
import { userFetchUser } from "#openapi"; import { userFetchUser } from "#openapi";
Alpine.data("existing_user_subscription_form", () => ({ Alpine.data("existing_user_subscription_form", () => ({
loading: false, loading: false,
selectedUser: "", selectedUser: "",
profileFragment: "", profileFragment: "",
dateOfBirth: "" as string | null, dateOfBirth: "",
dateOfBirthHidden: true, dateOfBirthHidden: true,
init() { init() {
@@ -15,9 +14,7 @@ Alpine.data("existing_user_subscription_form", () => ({
this.$nextTick(() => { this.$nextTick(() => {
// Force to detect the initial value // Force to detect the initial value
this.selectedUser = ( this.selectedUser = this.$refs.userSelect.widget.getValue();
this.$refs.userSelect as UserAjaxSelect
).widget.getValue() as string;
}); });
}, },
@@ -34,15 +31,11 @@ Alpine.data("existing_user_subscription_form", () => ({
// biome-ignore lint/style/useNamingConvention: api is snake_case // biome-ignore lint/style/useNamingConvention: api is snake_case
userFetchUser({ path: { user_id: userId } }), userFetchUser({ path: { user_id: userId } }),
]); ]);
if (userInfos.data === undefined) {
console.error("User infos request failed");
return;
}
this.profileFragment = await miniProfile.text(); this.profileFragment = await miniProfile.text();
// If the user has no birthdate yet, show the form input // If the user has no birthdate yet, show the form input
// to fill this info. // to fill this info.
// Else keep the input hidden and change its value to the user birthdate // Else keep the input hidden and change its value to the user birthdate
this.dateOfBirth = userInfos.data.date_of_birth ?? null; this.dateOfBirth = userInfos.data.date_of_birth;
this.dateOfBirthHidden = userInfos.data.date_of_birth !== null; this.dateOfBirthHidden = userInfos.data.date_of_birth !== null;
this.loading = false; this.loading = false;
}, },
@@ -17,10 +17,7 @@ function getRandomColorUniq(list: string[]) {
} }
return color; return color;
} }
function hexToRgb(hex: string) {
type Rgb = { r: number; g: number; b: number };
function hexToRgb(hex: string): Rgb | null {
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
const hexrgb = hex.replace(shorthandRegex, (_m, r, g, b) => { const hexrgb = hex.replace(shorthandRegex, (_m, r, g, b) => {
@@ -40,29 +37,32 @@ function hexToRgb(hex: string): Rgb | null {
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const ctx = (document.getElementById("statsChart") as HTMLCanvasElement).getContext( const ctx = (document.getElementById("statsChart") as HTMLCanvasElement).getContext(
"2d", "2d",
) as CanvasRenderingContext2D; );
const labels: string[] = []; const labels: string[] = [];
const total: string[] = []; const total: string[] = [];
const colorStrings: string[] = []; const colors: string[] = [];
const colorsDimmed: string[] = []; const colorsDimmed: string[] = [];
for (const element of Array.from(document.getElementsByClassName("types"))) { for (const element of Array.from(document.getElementsByClassName("types"))) {
labels.push(element.childNodes[0].textContent as string); labels.push(element.childNodes[0].textContent);
} }
for (const element of Array.from(document.getElementsByClassName("total"))) { for (const element of Array.from(document.getElementsByClassName("total"))) {
total.push(element.childNodes[0].childNodes[0].textContent as string); total.push(element.childNodes[0].childNodes[0].textContent);
} }
for (const _ of labels) { for (const _ of labels) {
colorStrings.push(getRandomColorUniq(colorStrings)); colors.push(getRandomColorUniq(colors));
}
const colors = colorStrings.map(hexToRgb) as Rgb[];
for (const color of colors) {
colorsDimmed.push(`rgba(${color.r}, ${color.g}, ${color.b}, 0.2)`);
} }
for (const color of colors) { for (const element of colors) {
colorsDimmed.push(`rgba(${color.r}, ${color.g}, ${color.b}, 0.2)`); const rgbColor = hexToRgb(element);
colorsDimmed.push(`rgba(${rgbColor.r}, ${rgbColor.g}, ${rgbColor.b}, 0.2)`);
}
for (const element of colors) {
const rgbColorDimmed = hexToRgb(element);
colorsDimmed.push(
`rgba(${rgbColorDimmed.r}, ${rgbColorDimmed.g}, ${rgbColorDimmed.b}, 0.2)`,
);
} }
new Chart(ctx, { new Chart(ctx, {
@@ -71,11 +71,10 @@ document.addEventListener("DOMContentLoaded", () => {
labels: labels, labels: labels,
datasets: [ datasets: [
{ {
label: document.getElementById("graphLabel")?.childNodes[0] label: document.getElementById("graphLabel").childNodes[0].textContent,
.textContent as string,
data: total, data: total,
backgroundColor: colorsDimmed, backgroundColor: colorsDimmed,
borderColor: colorStrings, borderColor: colors,
borderWidth: 1, borderWidth: 1,
}, },
], ],
@@ -58,7 +58,7 @@ function parseSlots(s: string): TimetableSlot[] {
.filter((s: string) => s.length > 0) .filter((s: string) => s.length > 0)
.map((row: string) => { .map((row: string) => {
const parsed = TIMETABLE_ROW_RE.exec(row); const parsed = TIMETABLE_ROW_RE.exec(row);
if (!parsed?.groups) { if (!parsed) {
throw new Error(`Couldn't parse row ${row}`); throw new Error(`Couldn't parse row ${row}`);
} }
const [startHour, startMin] = parsed.groups.startHour const [startHour, startMin] = parsed.groups.startHour
@@ -173,7 +173,7 @@ document.addEventListener("alpine:init", () => {
async savePng() { async savePng() {
const elem = document.getElementById("timetable"); const elem = document.getElementById("timetable");
const img = (await html2canvas(elem as HTMLElement)).toDataURL(); const img = (await html2canvas(elem)).toDataURL();
const downloadLink = document.createElement("a"); const downloadLink = document.createElement("a");
downloadLink.href = img; downloadLink.href = img;
downloadLink.download = "edt.png"; downloadLink.download = "edt.png";
-27
View File
@@ -1,27 +0,0 @@
{
"exclude": ["staticfiles/generated/**/*", "*.mts", "node_modules/**/*"],
"compilerOptions": {
"outDir": "./staticfiles/generated/bundled/",
"sourceMap": true,
"noImplicitAny": true,
"module": "esnext",
"target": "es2024",
"allowJs": true,
"moduleResolution": "node",
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"composite": true,
"types": ["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/*"],
"#com:*": ["./com/static/bundled/*"],
"#club:*": ["./club/static/bundled/*"]
}
}
}
+22 -4
View File
@@ -1,6 +1,24 @@
{ {
"references": [ "compilerOptions": {
{"path": "tsconfig.bundled.json"}, "outDir": "./staticfiles/generated/bundled/",
{"path": "tsconfig.node.json"} "sourceMap": true,
], "noImplicitAny": true,
"module": "esnext",
"target": "es2024",
"allowJs": true,
"moduleResolution": "node",
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"types": ["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/*"],
"#com:*": ["./com/static/bundled/*"]
}
}
} }
-10
View File
@@ -1,10 +0,0 @@
{
"include": ["vite.config.*"],
"compilerOptions": {
"module": "preserve",
"moduleResolution": "bundler",
"types": ["node"],
"emitDeclarationOnly": true,
"composite": true
}
}
+5 -3
View File
@@ -8,7 +8,7 @@ import {
type Rollup, type Rollup,
type UserConfig, type UserConfig,
} from "vite"; } from "vite";
import { compilerOptions } from "./tsconfig.bundled.json"; import tsconfig from "./tsconfig.json";
const outDir = resolve(__dirname, "./staticfiles/generated/bundled"); const outDir = resolve(__dirname, "./staticfiles/generated/bundled");
const collectedFiles = glob.sync( const collectedFiles = glob.sync(
@@ -20,7 +20,7 @@ const collectedFiles = glob.sync(
**/ **/
function getAliases(): AliasOptions { function getAliases(): AliasOptions {
const aliases: AliasOptions = {}; const aliases: AliasOptions = {};
for (const [key, value] of Object.entries(compilerOptions.paths)) { for (const [key, value] of Object.entries(tsconfig.compilerOptions.paths)) {
aliases[key] = resolve(__dirname, value[0]); aliases[key] = resolve(__dirname, value[0]);
} }
return aliases; return aliases;
@@ -59,7 +59,7 @@ export default defineConfig((config: UserConfig) => {
// Mirror architecture of static folders in generated .js and .css // Mirror architecture of static folders in generated .js and .css
entryFileNames: (chunkInfo: Rollup.PreRenderedChunk) => { entryFileNames: (chunkInfo: Rollup.PreRenderedChunk) => {
if (chunkInfo.facadeModuleId !== null) { if (chunkInfo.facadeModuleId !== null) {
return `${getRelativeAssetPath(chunkInfo.facadeModuleId as string)}.[hash].js`; return `${getRelativeAssetPath(chunkInfo.facadeModuleId)}.[hash].js`;
} }
return "[name].[hash].js"; return "[name].[hash].js";
}, },
@@ -80,6 +80,8 @@ export default defineConfig((config: UserConfig) => {
resolve: { resolve: {
alias: getAliases(), alias: getAliases(),
}, },
// biome-ignore lint/style/useNamingConvention: that's how it's called
inject: { Alpine: "alpinejs", htmx: "htmx.org" },
plugins: [visualizer({ filename: ".bundle-size-report.html" }) as PluginOption], plugins: [visualizer({ filename: ".bundle-size-report.html" }) as PluginOption],
} satisfies UserConfig; } satisfies UserConfig;
}); });