Author SHA1 Message Date
klmp200 881943220b Htmx callback plugin and htmx based report view 2026-08-27 18:38:17 +02:00
klmp200 c84cef8e55 Make ue comments a fragment 2026-08-26 21:56:39 +02:00
klmp200 13829c0945 Use HTMX in UE details 2026-08-26 21:56:39 +02:00
thomas girodandGitHub 7808ca3688 Merge pull request #1464 from ae-utbm/tsc6
Tsc6
2026-08-25 22:28:21 +02:00
imperosol 7e42ad138c fix: deprecation warnings in vite config 2026-08-25 22:23:37 +02:00
imperosol 3c4e7defb8 fix: build error with HeyAPI because of TS7 2026-08-25 21:40:21 +02:00
thomas girodandGitHub 2fab0ae96d Merge pull request #1461 from ae-utbm/update-deps
update deps
2026-08-24 12:49:15 +02:00
imperosol 68c5231138 update JS deps 2026-08-23 19:41:43 +02:00
klmp200andGitHub 17708e19c5 Merge pull request #1460 from ae-utbm/pedagogy
Fix star width and rewrite star widget with nice hovering
2026-08-23 18:00:45 +02:00
imperosol e1be017877 update python deps 2026-08-23 17:14:46 +02:00
klmp200 4a8471983f Apply review comments 2026-08-23 15:17:03 +02:00
thomas girodandGitHub c9523b0730 Merge pull request #1449 from ae-utbm/ts
Typescript strict mode
2026-08-22 11:38:31 +02:00
klmp200 82e71263dd StarList uses proper inheritance and properly passes initial values and attributes + add empty comment clause 2026-08-21 14:22:46 +02:00
klmp200 12135b5c22 Fix star width and rewrite star widget with nice hovering 2026-08-21 00:14:04 +02:00
imperosol 58c2406ac5 fix: Uncaught RangeError: Invalid time value is SAS 2026-08-20 16:18:06 +02:00
imperosol b8178e3534 update doc in js-import-paths.md 2026-08-20 16:17:23 +02:00
imperosol 236860d0f5 adapt typescript to strict mode 2026-08-20 16:17:23 +02:00
imperosol 3291a78d2b split node and bundled TS config 2026-08-20 16:17:23 +02:00
thomas girodandGitHub d6677b1fbd Merge pull request #1459 from ae-utbm/master
Merge back master into taiste
2026-08-20 16:17:02 +02:00
thomas girodandGitHub e52fd32e06 Merge pull request #1445 from ae-utbm/pedagogy
Update pedagogy
2026-08-20 16:14:16 +02:00
thomas girodandGitHub 1ae52b3bc9 Merge pull request #1454 from ae-utbm/sas-click
Change sas picture on picture click
2026-08-19 18:06:29 +02:00
klmp200 1765d70911 Move image click to frame and remove image selection 2026-08-19 18:05:18 +02:00
imperosol 4d07b94fee change sas picture on picture click 2026-08-06 19:33:51 +02:00
thomas girodandGitHub b8c8768479 Merge pull request #1451 from ae-utbm/ruff
bump ruff to 0.16
2026-07-27 09:45:28 +02:00
imperosol 3be4c583f1 bump ruff to 0.16 2026-07-26 19:46:03 +02:00
imperosol 5177427549 adapt pedagogy to new UTBM UE API 2026-07-25 11:45:19 +02:00
thomas girodandGitHub 2d75a3e6c2 Merge pull request #1448 from ae-utbm/remove-eurock
remove eurockéennes widget
2026-07-09 16:49:40 +02:00
imperosol 8cdb74e90f remove eurockéennes widget 2026-07-09 12:40:15 +02:00
thomas girodandGitHub 79ec036b70 Merge pull request #1439 from ae-utbm/taiste
AEMark, max account balance, membership API and bugfixes
2026-06-24 13:13:39 +02:00
thomas girodandGitHub 455b81cff6 Merge pull request #1424 from ae-utbm/taiste
Basket timeout, clic limit, club-election link, CGV, counter barmen and other
2026-06-06 09:46:41 +02:00
thomas girodandGitHub 9ceb51a54e Merge pull request #1404 from ae-utbm/taiste
Club roles, club links, notifications and other
2026-05-22 09:43:08 +02:00
TitouanandGitHub 790a1e15b1 Merge pull request #1383 from ae-utbm/taiste
MAJ cotisation, CI, style et fix
2026-05-11 13:03:22 +02:00
thomas girodandGitHub b2ffcd3a37 Merge pull request #1365 from ae-utbm/taiste
Product prices, club list page rework and bug fixes
2026-05-01 19:14:03 +02:00
TitouanandGitHub ca37996d6a Merge pull request #1332 from ae-utbm/taiste
Stats & Whitelist, Eurockéenne, fix pagination, Vite 8, delete unused settings
2026-03-29 16:35:16 +02:00
TitouanandGitHub 173311c1d5 Merge pull request #1315 from ae-utbm/taiste
Product history, formula management, test election
2026-03-12 11:33:45 +01:00
thomas girodandGitHub 2995823d6e Merge pull request #1293 from ae-utbm/taiste
Refactors, updates and db optimisations
2026-02-13 15:25:04 +01:00
84 changed files with 2922 additions and 1933 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.19
rev: v0.16.0
hooks:
- id: ruff-check # just check the code, and print the errors
- id: ruff-check # actually fix the fixable errors, but print nothing
@@ -1,7 +1,6 @@
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
import type { escape_html } from "tom-select/src/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import { type ClubSchema, clubSearchClub } from "#openapi";
@registerComponent("club-ajax-select")
@@ -10,7 +9,7 @@ export class ClubAjaxSelect extends AjaxSelect {
protected labelField = "name";
protected searchField = ["code", "name"];
protected async search(query: string): Promise<TomOption[]> {
protected async search(query: string) {
const resp = await clubSearchClub({ query: { search: query } });
if (resp.data) {
return resp.data.results;
@@ -1,4 +1,9 @@
import { Calendar, type EventClickArg, type EventContentArg } from "@fullcalendar/core";
import {
Calendar,
type EventClickArg,
type EventContentArg,
type EventInput,
} from "@fullcalendar/core";
import type { EventImpl } from "@fullcalendar/core/internal";
import enLocale from "@fullcalendar/core/locales/en-gb";
import frLocale from "@fullcalendar/core/locales/fr";
@@ -6,8 +11,8 @@ import dayGridPlugin from "@fullcalendar/daygrid";
import iCalendarPlugin from "@fullcalendar/icalendar";
import listPlugin from "@fullcalendar/list";
import { type HTMLTemplateResult, html, render } from "lit-html";
import { makeUrl } from "#core:utils/api.ts";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
import { type GenericEndpoint, makeUrl } from "#core:utils/api";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import {
calendarCalendarInternal,
calendarCalendarUnpublished,
@@ -19,11 +24,11 @@ import {
@registerComponent("ics-calendar")
export class IcsCalendar extends inheritHtmlElement("div") {
static observedAttributes = ["locale", "can_moderate", "can_delete", "ics-help-url"];
private calendar: Calendar;
private locale = "en";
private canModerate = false;
private canDelete = false;
private helpUrl = "";
private calendar!: Calendar;
private locale? = "en";
private canModerate? = false;
private canDelete? = false;
private helpUrl? = "";
// Hack variable to detect recurring events
// The underlying ics library doesn't include any info about rrules
@@ -35,10 +40,12 @@ export class IcsCalendar extends inheritHtmlElement("div") {
this.locale = newValue;
}
if (name === "can_moderate") {
this.canModerate = newValue.toLowerCase() === "true";
this.canModerate =
typeof newValue === "string" && newValue.toLowerCase() === "true";
}
if (name === "can_delete") {
this.canDelete = newValue.toLowerCase() === "true";
this.canDelete =
typeof newValue === "string" && newValue.toLowerCase() === "true";
}
if (name === "ics-help-url") {
@@ -94,7 +101,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
.toString()
.split("/")
.filter((s) => s) // Remove blank characters
.pop(),
.pop() as string,
10,
);
}
@@ -159,7 +166,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
this.refreshEvents();
}
async getEventSources() {
async getEventSources(): Promise<EventInput[]> {
const tagRecurringEvents = (eventData: EventImpl) => {
// This functions tags events with a similar event url
// We rely on the fact that the event url is always the same
@@ -173,14 +180,14 @@ export class IcsCalendar extends inheritHtmlElement("div") {
};
return [
{
url: `${await makeUrl(calendarCalendarInternal)}`,
url: `${await makeUrl(calendarCalendarInternal as GenericEndpoint)}`,
format: "ics",
className: "internal",
cache: false,
eventDataTransform: tagRecurringEvents,
},
{
url: `${await makeUrl(calendarCalendarUnpublished)}`,
url: `${await makeUrl(calendarCalendarUnpublished as GenericEndpoint)}`,
format: "ics",
color: "red",
className: "unpublished",
@@ -213,7 +220,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
${event.title}
</h4>
<span class="event-details-row-content">
${this.formatDate(event.start)} - ${this.formatDate(event.end)}
${this.formatDate(event.start as Date)} - ${this.formatDate(event.end as Date)}
</span>
</div>
`;
@@ -251,7 +258,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
const buttons = [] as HTMLTemplateResult[];
if (this.canModerate) {
if (event.source.internalEventSource.ui.classNames.includes("unpublished")) {
if (event.source?.internalEventSource.ui.classNames.includes("unpublished")) {
const button = html`
<button class="btn btn-green" @click="${() => this.publishNews(newsId)}">
<i class="fa fa-check"></i>${gettext("Publish")}
@@ -338,9 +345,9 @@ export class IcsCalendar extends inheritHtmlElement("div") {
button.classList.remove("text-copied");
}
button.setAttribute("tooltip", gettext("Link copied"));
navigator.clipboard.writeText(
await navigator.clipboard.writeText(
new URL(
await makeUrl(calendarCalendarInternal),
await makeUrl(calendarCalendarInternal as GenericEndpoint),
window.location.origin,
).toString(),
);
@@ -1,4 +1,9 @@
import { newsDeleteNews, newsFetchNewsDates, newsPublishNews } from "#openapi";
import {
newsDeleteNews,
newsFetchNewsDates,
newsPublishNews,
type PaginatedResponseSchemaNewsDateSchema,
} from "#openapi";
// This will be used in jinja templates,
// so we cannot use real enums as those are purely an abstraction of Typescript
@@ -64,7 +69,8 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is snake-case
query: { news_id: this.newsId, page: 1, page_size: 1 },
});
return response.data.count;
return (response.data as PaginatedResponseSchemaNewsDateSchema).count;
},
weeklyEventWarningMessage(nbEvents: number): string {
@@ -1,11 +1,11 @@
import { type NewsDateSchema, newsFetchNewsDates } from "#openapi";
interface ParsedNewsDateSchema extends Omit<NewsDateSchema, "start_date" | "end_date"> {
type ParsedNewsDateSchema = Omit<NewsDateSchema, "start_date" | "end_date"> & {
// biome-ignore lint/style/useNamingConvention: api is snake_case
start_date: Date;
// biome-ignore lint/style/useNamingConvention: api is snake_case
end_date: Date;
}
};
document.addEventListener("alpine:init", () => {
Alpine.data("upcomingNewsLoader", (startDate: Date, locale: string) => ({
@@ -32,6 +32,11 @@ document.addEventListener("alpine:init", () => {
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) {
this.hasNext = false;
} else if (response.data.next === null) {
@@ -44,7 +49,7 @@ document.addEventListener("alpine:init", () => {
this.loading = false;
},
groupedDates(): Record<string, NewsDateSchema[]> {
groupedDates(): Record<string, ParsedNewsDateSchema[]> {
return this.newsDates
.map(
(date: NewsDateSchema): ParsedNewsDateSchema => ({
+3 -2
View File
@@ -1,4 +1,4 @@
from typing import Annotated, Any, Literal
from typing import Annotated, Literal
from annotated_types import Ge, Le, MinLen
from django.conf import settings
@@ -26,6 +26,7 @@ from core.schemas import (
UserFilterSchema,
UserProfileSchema,
UserSchema,
ValidationErrorSchema,
)
from core.templatetags.renderer import markdown
from counter.utils import is_logged_in_counter
@@ -45,7 +46,7 @@ class UploadController(ControllerBase):
"/image",
response={
200: UploadedFileSchema,
422: dict[Literal["detail"], list[dict[str, Any]]],
422: ValidationErrorSchema,
403: dict[Literal["detail"], str],
},
permissions=[HasPerm("core.add_quickuploadimage")],
+10
View File
@@ -19,6 +19,16 @@ from core.utils import get_last_promo, is_image
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):
@classmethod
def _validate(cls, v: Any, info: ValidationInfo) -> Any:
@@ -25,7 +25,7 @@ export function limitedChoices(Alpine: AlpineType) {
Alpine.directive(
"limited-choices",
(el, { expression }, { evaluateLater, effect }) => {
const getMaxChoices = evaluateLater(expression);
const getMaxChoices = evaluateLater<string>(expression);
let maxChoices: number;
const inputs: HTMLInputElement[] = Array.from(
el.querySelectorAll("input[type='checkbox']"),
@@ -54,7 +54,7 @@ export function limitedChoices(Alpine: AlpineType) {
});
}
effect(() => {
getMaxChoices((value: string) => {
getMaxChoices((value) => {
const previousValue = maxChoices;
maxChoices = Number.parseInt(value, 10);
if (maxChoices < previousValue) {
+19 -6
View File
@@ -10,6 +10,10 @@ import sort from "@alpinejs/sort";
import Alpine from "alpinejs";
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
import htmx from "htmx.org";
import { getErrorCallbacksExt } from "#core:htmx/error-callback";
("#core:htmx/error-callback");
import { limitedChoices } from "#core:alpine/limited-choices";
import { expireOldStorage } from "#core:core/localstorage";
import { default as navbar } from "#core:core/navbar";
@@ -43,13 +47,22 @@ polyfillCountryFlagEmojis();
/**
* HTMX
*/
document.body.addEventListener("htmx:beforeRequest", (event: CustomEvent) => {
event.detail.target.ariaBusy = true;
});
document.body.addEventListener(
"htmx:beforeRequest" as keyof HTMLElementEventMap,
(event) => {
(event as CustomEvent).detail.target.ariaBusy = true;
},
);
document.body.addEventListener("htmx:beforeSwap", (event: CustomEvent) => {
event.detail.target.ariaBusy = null;
});
document.body.addEventListener(
"htmx:beforeSwap" as keyof HTMLElementEventMap,
(event) => {
(event as CustomEvent).detail.target.ariaBusy = null;
},
);
const errorCallbackExt = getErrorCallbacksExt();
htmx.defineExtension(errorCallbackExt.name, errorCallbackExt.extension);
Object.assign(window, { htmx });
@@ -4,9 +4,9 @@ import type {
TomLoadCallback,
TomOption,
TomSettings,
} from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { inheritHtmlElement } from "#core:utils/web-components.ts";
} from "tom-select/src/types";
import type { escape_html } from "tom-select/src/utils";
import { inheritHtmlElement } from "#core:utils/web-components";
export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
static observedAttributes = [
@@ -15,7 +15,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
"max",
"min-characters-for-search",
];
public widget: TomSelect;
public widget!: TomSelect;
protected minCharNumberForSearch = 0;
protected delay: number | null = null;
@@ -24,8 +24,8 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
protected attributeChangedCallback(
name: string,
_oldValue?: string,
newValue?: string,
_oldValue: string,
newValue: string,
) {
switch (name) {
case "delay": {
@@ -73,7 +73,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
persist: false,
maxItems: this.node.multiple ? this.max : 1,
closeAfterSelect: true,
loadThrottle: this.delay,
loadThrottle: this.delay ?? undefined,
placeholder: this.placeholder,
shouldLoad: (query: string) => this.shouldLoad(query), // wraps the method to avoid shadowing `this` by the one from tom-select
render: {
@@ -103,7 +103,7 @@ export class AutoCompleteSelectBase extends inheritHtmlElement("select") {
}
export abstract class AjaxSelect extends AutoCompleteSelectBase {
protected filter?: (items: TomOption[]) => TomOption[] = null;
protected filter?: (items: TomOption[]) => TomOption[];
protected minCharNumberForSearch = 2;
/**
* A cache of researches that have been made using this input.
@@ -1,11 +1,12 @@
// @ts-expect-error TS2882
import "tom-select/dist/css/tom-select.default.css";
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import type { TomOption } from "tom-select/src/types";
import type { escape_html } from "tom-select/src/utils";
import {
AjaxSelect,
AutoCompleteSelectBase,
} from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
} from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import {
type GroupSchema,
groupSearchGroup,
@@ -1,11 +1,13 @@
// @ts-expect-error 2307
// biome-ignore lint/correctness/noUndeclaredDependencies: shipped by easymde
import "codemirror/lib/codemirror.css";
// @ts-expect-error 2307
import "easymde/src/css/easymde.css";
// biome-ignore lint/correctness/noUndeclaredDependencies: Imported by EasyMDE
import type CodeMirror from "codemirror";
// biome-ignore lint/style/useNamingConvention: This is how they called their namespace
import EasyMDE from "easymde";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components.ts";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
import {
markdownRenderMarkdown,
type UploadUploadImageErrors,
@@ -25,11 +27,11 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
file: file,
},
});
if (!response.response.ok) {
if (response.response.status === 422) {
if (response.response !== undefined && !response.response.ok) {
if (response?.response.status === 422) {
onError(
(response.error as UploadUploadImageErrors[422]).detail
.map((err: Record<"ctx", Record<"error", string>>) => err.ctx.error)
.map((err) => err.ctx.error)
.join(" ; "),
);
} else if (response.response.status === 403) {
@@ -39,6 +41,10 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
}
return;
}
if (response.data === undefined) {
// this can't happen, it's just for the type checker to know
return;
}
onSuccess(response.data.href);
// Workaround function to add an image name to uploaded image
// Without this, you get ![](url) instead of ![name](url)
@@ -58,21 +64,18 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
});
easymde.codemirror.replaceSelection("\n");
},
previewRender: (plainText: string, preview: MarkdownInput) => {
previewRender: (plainText, preview) => {
/* This is wrapped this way to allow time for Alpine to be loaded on the page */
return Alpine.debounce((plainText: string, preview: MarkdownInput) => {
const func = async (
plainText: string,
preview: MarkdownInput,
): Promise<null> => {
return Alpine.debounce(() => {
const func = async () => {
preview.innerHTML = (
await markdownRenderMarkdown({ body: { text: plainText } })
).data as string;
return null;
};
func(plainText, preview);
func().then();
return null;
}, 300)(plainText, preview);
}, 300)();
},
forceSync: true, // Avoid validation error on generic create view
imageTexts: {
@@ -222,9 +225,11 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
});
const submits: HTMLInputElement[] = Array.from(
textarea.closest("form").querySelectorAll('input[type="submit"]'),
(textarea.closest("form") as HTMLFormElement).querySelectorAll(
'input[type="submit"]',
),
);
const parentDiv = textarea.parentElement.parentElement;
const parentDiv = textarea.parentElement?.parentElement as HTMLElement;
function checkMarkdownInput(event: Event) {
// an attribute is null if it does not exist, else a string
@@ -249,6 +254,7 @@ const loadEasyMde = (textarea: HTMLTextAreaElement) => {
};
@registerComponent("markdown-input")
// biome-ignore lint/correctness/noUnusedVariables: it is used in jinja
class MarkdownInput extends inheritHtmlElement("textarea") {
connectedCallback() {
super.connectedCallback();
@@ -2,7 +2,7 @@ import {
type InheritedHtmlElement,
inheritHtmlElement,
registerComponent,
} from "#core:utils/web-components.ts";
} from "#core:utils/web-components";
/**
* ElementOnce web components
@@ -28,7 +28,7 @@ export function elementOnce<K extends keyof HTMLElementTagNameMap>(tagName: K) {
clearNode() {
while (this.firstChild) {
this.removeChild(this.lastChild);
this.removeChild(this.lastChild as ChildNode);
}
}
@@ -130,7 +130,7 @@ startObserver(observer);
export class LinkOnce extends elementOnce("link") {
getElementQuerySelector(): string {
// 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") {
getElementQuerySelector(): string {
// 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.ts";
import { inheritHtmlElement, registerComponent } from "#core:utils/web-components";
@registerComponent("nfc-input")
export class NfcInput extends inheritHtmlElement("input") {
@@ -26,9 +26,11 @@ export class NfcInput extends inheritHtmlElement("input") {
window.alert(gettext("Unsupported NFC card"));
});
ndef.addEventListener("reading", (event: NDEFReadingEvent) => {
ndef.addEventListener("reading", (event) => {
this.removeAttribute("scan");
this.node.value = event.serialNumber.replace(/:/g, "").toUpperCase();
this.node.value = (event as NDEFReadingEvent).serialNumber
.replace(/:/g, "")
.toUpperCase();
/* Auto submit form, we need another button to not trigger our previously defined click event */
const submit = document.createElement("button");
this.node.appendChild(submit);
@@ -1,6 +1,6 @@
import { html, render } from "lit-html";
import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
import { registerComponent } from "#core:utils/web-components.ts";
import { registerComponent } from "#core:utils/web-components";
@registerComponent("ui-tab")
export class Tab extends HTMLElement {
@@ -19,7 +19,7 @@ export class Tab extends HTMLElement {
}
if (name === "title") {
this.description = newValue;
this.description = newValue ?? "";
}
this.dispatchEvent(new CustomEvent("ui-tab-updated", { bubbles: true }));
}
@@ -79,15 +79,15 @@ export class Tab extends HTMLElement {
@registerComponent("ui-tab-group")
export class TabGroup extends HTMLElement {
private node: HTMLDivElement;
private node!: HTMLDivElement;
connectedCallback() {
this.node = document.createElement("div");
this.node.classList.add("tabs", "shadow");
this.appendChild(this.node);
this.addEventListener("ui-tab-activated", (event: CustomEvent) => {
const target = event.detail as Tab;
this.addEventListener("ui-tab-activated", (event) => {
const target = (event as CustomEvent).detail as Tab;
for (const tab of this.getElementsByTagName("ui-tab") as HTMLCollectionOf<Tab>) {
if (tab !== target) {
tab.setActive(false);
@@ -26,19 +26,23 @@ document.addEventListener("alpine:init", () => {
* `counter/templates/counter/product_form.jinja`
*/
Alpine.data("dynamicFormSet", (config?: Config) => ({
formContainer: undefined as unknown as HTMLElement,
nbForms: 0,
template: undefined as unknown as HTMLTemplateElement,
init() {
this.formContainer = this.$refs.formContainer as HTMLElement;
this.nbForms = this.formContainer.children.length as number;
this.template = this.$refs.formTemplate as HTMLTemplateElement;
const prefix = config?.prefix ?? "form";
this.$root
.querySelector(`#id_${prefix}-TOTAL_FORMS`)
.setAttribute(":value", "nbForms");
(
this.$root.querySelector(`#id_${prefix}-TOTAL_FORMS`) as HTMLFormElement
).setAttribute(":value", "nbForms");
},
addForm() {
this.formContainer.appendChild(document.importNode(this.template.content, true));
const newForm = this.formContainer.lastElementChild;
const newForm = this.formContainer.lastElementChild as Element;
const inputs: NodeListOf<HTMLFormInputElement> = newForm.querySelectorAll(
"input, select, textarea",
);
@@ -59,7 +63,7 @@ document.addEventListener("alpine:init", () => {
this.nbForms -= 1;
// adjust the id of remaining forms
for (let i = 0; i < this.nbForms; i++) {
const form: HTMLDivElement = this.formContainer.children[i];
const form = this.formContainer.children[i];
const inputs: NodeListOf<HTMLFormInputElement> = form.querySelectorAll(
"input, select, textarea",
);
+6 -3
View File
@@ -1,5 +1,5 @@
function showMenu() {
const navbar = document.getElementById("navbar-content");
const navbar = document.getElementById("navbar-content") as HTMLElement;
const current = navbar.getAttribute("mobile-display");
navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden");
}
@@ -20,9 +20,12 @@ function navbarInit() {
item.removeAttribute("open");
}
});
item.addEventListener("click", (event: MouseEvent) => {
item.addEventListener("click", (event) => {
// Don't close when clicking on desktop mode
if ((event.target as HTMLElement).nodeName !== "SUMMARY" || event.detail === 0) {
if (
(event.target as HTMLElement).nodeName !== "SUMMARY" ||
(event as MouseEvent).detail === 0
) {
return;
}
@@ -1,3 +1,4 @@
// @ts-expect-error 2307 this dependency does exist, but it's a little bit wacky
import clip from "@arendjr/text-clipper";
/*
+8 -7
View File
@@ -70,15 +70,16 @@ function createTooltip(element: HTMLElement) {
function updateTooltip(element: HTMLElement, tooltip: HTMLElement, status: Status) {
// Update tooltip status and set it's attributes and content
tooltip.setAttribute("tooltip-status", status);
tooltip.innerText = element.getAttribute("tooltip");
tooltip.innerText = element.getAttribute("tooltip") as string;
for (const attributes of [
{ src: "tooltip-class", dst: "class", default: ["tooltip"] },
{ src: "tooltip-id", dst: "id", default: [] },
]) {
const populated = attributes.default;
if (element.hasAttribute(attributes.src)) {
populated.push(...element.getAttribute(attributes.src).split(" "));
const attr = element.getAttribute(attributes.src);
if (attr !== null) {
populated.push(...attr.split(" "));
}
tooltip.setAttribute(attributes.dst, populated.join(" "));
}
@@ -93,7 +94,7 @@ function getTooltip(element: HTMLElement) {
return tooltip;
}
function tooltipMouseover(event: MouseEvent) {
function tooltipMouseover(event: Event) {
// We get the closest tooltip to have a consistent behavior
// when hovering over a child element of a tooltip marked element
const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
@@ -111,7 +112,7 @@ function tooltipMouseover(event: MouseEvent) {
});
}
function tooltipMouseout(event: MouseEvent) {
function tooltipMouseout(event: Event) {
// We get the closest tooltip to have a consistent behavior
// when hovering over a child element of a tooltip marked element
const target = (event.target as HTMLElement).closest("[tooltip]") as HTMLElement;
@@ -141,7 +142,7 @@ new MutationObserver((mutations: MutationRecord[]) => {
}
} else if (tooltips.has(target)) {
// Remove corresponding tooltip
tooltips.get(target).remove();
tooltips.get(target)?.remove();
tooltips.delete(target);
}
}
@@ -163,7 +164,7 @@ new MutationObserver((mutations: MutationRecord[]) => {
continue;
}
if (tooltips.has(target)) {
tooltips.get(target).remove();
tooltips.get(target)?.remove();
tooltips.delete(target);
}
}
@@ -0,0 +1,80 @@
import type { HtmxExtension } from "htmx.org";
interface CustomHtmxExtension {
name: string;
extension: Partial<HtmxExtension>;
}
export const getErrorCallbacksExt = () => {
const attrPrefix = "hx-callback-";
let htmxApi: {
[x: string]: any;
getClosestAttributeValue: any;
};
const getCallback = (elt: HTMLElement, responseCode: number) => {
if (!elt || !responseCode) {
return () => {};
}
const code = responseCode.toString();
// '*' is the original syntax, as the obvious character for a wildcard.
// The 'x' alternative was added for maximum compatibility with HTML
// templating engines, due to ambiguity around which characters are
// supported in HTML attributes.
//
// Start with the most specific possible attribute and generalize from
// there.
const suffixes = [
code,
code.substring(0, 2) + "*",
code.substring(0, 2) + "x",
code.substring(0, 1) + "*",
code.substring(0, 1) + "x",
code.substring(0, 1) + "**",
code.substring(0, 1) + "xx",
"*",
"x",
"***",
"xxx",
];
if (code.startsWith("4") || code.startsWith("5")) {
suffixes.push("error");
}
for (const suffix of suffixes) {
const attr = attrPrefix + suffix;
const callback = htmxApi?.getClosestAttributeValue(elt, attr);
if (callback) {
return Function("src", "target", callback);
}
}
return () => {};
};
return {
name: "error-callbacks",
extension: {
init: (api: any) => {
htmxApi = api;
},
onEvent: (name: string, event: CustomEvent) => {
if (name !== "htmx:responseError") {
return true;
}
getCallback(event.detail.requestConfig.elt, event.detail.xhr.status)(
event.detail.requestConfig.elt,
htmxApi.getTarget(event.detail.requestConfig.elt),
);
return true;
},
},
} as CustomHtmxExtension;
};
+17 -22
View File
@@ -1,20 +1,13 @@
import cytoscape, {
type ElementDefinition,
type NodeSingular,
type Singular,
} from "cytoscape";
import cytoscape, { type ElementDefinition, type Singular } from "cytoscape";
import cxtmenu from "cytoscape-cxtmenu";
import klay, { type KlayLayoutOptions } from "cytoscape-klay";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history.ts";
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import { familyGetFamilyGraph, type UserProfileSchema } from "#openapi";
cytoscape.use(klay);
cytoscape.use(cxtmenu);
type GraphData = (
| { data: UserProfileSchema }
| { data: { source: number; target: number } }
)[];
type GraphData = { data: UserProfileSchema | { source: number; target: number } }[];
function isMobile() {
return window.innerWidth < 500;
@@ -27,10 +20,8 @@ async function getGraphData(
): Promise<GraphData> {
const data = (
await familyGetFamilyGraph({
path: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
user_id: userId,
},
path: { user_id: userId },
query: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
godfathers_depth: godfathersDepth,
@@ -39,6 +30,10 @@ async function getGraphData(
},
})
).data;
if (data === undefined) {
console.error("Family graph request failed");
return [];
}
return [
...data.users.map((user) => {
return { data: user };
@@ -155,7 +150,7 @@ function createGraph(container: HTMLDivElement, data: GraphData, activeUserId: n
{
content: '<i class="fa fa-external-link fa-2x"></i>',
select: (el) => {
window.open(el.data().profile_url, "_blank").focus();
window.open(el.data().profile_url, "_blank")?.focus();
},
},
@@ -195,12 +190,12 @@ document.addEventListener("alpine:init", () => {
godfathersDepth: 0,
godchildrenDepth: 0,
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
graph: undefined as cytoscape.Core,
graphData: {},
graph: undefined as unknown as cytoscape.Core,
graphData: {} as GraphData,
isZoomEnabled: !isMobile(),
getInitialDepth(prop: string) {
const value = Number.parseInt(initialUrlParams.get(prop), 10);
const value = Number.parseInt(initialUrlParams.get(prop) as string, 10);
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
return defaultDepth;
}
@@ -223,8 +218,8 @@ document.addEventListener("alpine:init", () => {
await delayedFetch();
});
}
this.$watch("reverse", async (value: number) => {
updateQueryString("reverse", value.toString(), History.Replace);
this.$watch("reverse", async (newValue, _oldValue) => {
updateQueryString("reverse", newValue.toString(), History.Replace);
await this.reverseGraph();
});
this.$watch("graphData", async () => {
@@ -259,9 +254,9 @@ document.addEventListener("alpine:init", () => {
},
async reverseGraph() {
this.graph.elements((el: NodeSingular) => {
el.position({ x: -el.position().x, y: -el.position().y });
});
this.graph
.elements()
.positions((el, _) => ({ x: -el.position().x, y: -el.position().y }));
this.graph.center(this.graph.elements());
},
+2 -2
View File
@@ -5,9 +5,9 @@ interface AlertParams {
export class AlertMessage {
public open: boolean;
public success: boolean;
public success!: boolean;
public content: string;
private timeoutId?: number;
private timeoutId: number | null;
private readonly defaultDuration: number;
constructor(params?: { defaultDuration: number }) {
+8 -5
View File
@@ -37,6 +37,10 @@ export const paginated = async <T>(
queryParams.query.page = 1;
const firstPage = (await endpoint(queryParams)).data;
if (firstPage === undefined) {
console.error(`Request to "${options?.url}" failed`);
return [];
}
const results = firstPage.results;
const nbElements = firstPage.count;
@@ -45,9 +49,9 @@ export const paginated = async <T>(
if (nbPages > 1) {
const promises: Promise<T[]>[] = [];
for (let i = 2; i <= nbPages; i++) {
const nextPage = structuredClone(queryParams);
const nextPage = structuredClone(queryParams) as Required<PaginatedRequest>;
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());
}
@@ -61,8 +65,7 @@ interface Request extends TDataShape {
interface InterceptorOptions {
url: string;
}
type GenericEndpoint = <ThrowOnError extends boolean = false>(
export type GenericEndpoint = <ThrowOnError extends boolean = false>(
options?: Options<Request, ThrowOnError>,
) => RequestResult<unknown, unknown, ThrowOnError>;
@@ -71,7 +74,7 @@ type GenericEndpoint = <ThrowOnError extends boolean = false>(
**/
export const makeUrl = async (endpoint: GenericEndpoint) => {
let url = "";
const interceptor = (_request: undefined, options: InterceptorOptions) => {
const interceptor = (_request: Request, options: InterceptorOptions) => {
url = options.url;
throw new Error("We don't want to send the request");
};
+12 -9
View File
@@ -7,14 +7,14 @@ interface StringifyOptions<T extends object> {
titleRow?: readonly string[];
}
function getNested<T extends object>(obj: T, key: NestedKeyOf<T>) {
const path: (keyof object)[] = key.split(".") as (keyof unknown)[];
let res = obj[path.shift() as keyof T];
function getNested<T extends { [key: string]: unknown }>(obj: T, key: NestedKeyOf<T>) {
const path = key.split(".");
let res = obj[path.shift() as string] as { [key: string]: unknown } | undefined;
for (const node of path) {
if (res === null) {
if (res === undefined) {
break;
}
res = res[node];
res = res[node] as { [key: string]: unknown } | undefined;
}
return res;
}
@@ -29,18 +29,21 @@ function sanitizeCell(content: string): string {
}
export const csv = {
stringify: <T extends object>(objs: T[], options?: StringifyOptions<T>) => {
const columns = options.columns;
stringify: <T extends { [key: string]: unknown }>(
objs: T[],
options?: StringifyOptions<T>,
) => {
const columns = options?.columns;
const content = objs
.map((obj) => {
return columns
return (columns ?? [])
.map((col) => {
return sanitizeCell((getNested(obj, col) ?? "").toString());
})
.join(",");
})
.join("\n");
if (!options.titleRow) {
if (!options?.titleRow) {
return content;
}
const firstRow = options.titleRow.map(sanitizeCell).join(",");
+3 -2
View File
@@ -29,6 +29,7 @@ export function registerComponent(name: string, options?: ElementDefinitionOptio
export interface InheritedHtmlElement<K extends keyof HTMLElementTagNameMap>
extends HTMLElement {
readonly inheritedTagName: K;
// readonly initializedAttribute: "component-initialized";
node: HTMLElementTagNameMap[K];
}
@@ -47,8 +48,8 @@ export function inheritHtmlElement<K extends keyof HTMLElementTagNameMap>(tagNam
implements InheritedHtmlElement<K>
{
readonly inheritedTagName = tagName;
private readonly initializedAttribute = "component-initialized";
node: HTMLElementTagNameMap[K];
readonly initializedAttribute = "component-initialized";
node!: HTMLElementTagNameMap[K];
connectedCallback(autoAddNode?: boolean) {
// When nesting inherited elements, we might trigger the wrapping twice
+1 -1
View File
@@ -473,7 +473,7 @@ class UserClubView(UserTabsMixin, CanViewMixin, DetailView):
class UserVisibilityFormFragment(FragmentMixin, SuccessMessageMixin, UpdateView):
model = User
form_class = UserVisibilityForm
template_name = "core/fragment/user_visibility.jinja"
template_name = "core/fragments/user_visibility.jinja"
pk_url_kwarg = "user_id"
def get_form_kwargs(self):
@@ -1,7 +1,7 @@
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
import type { TomOption } from "tom-select/src/types";
import type { escape_html } from "tom-select/src/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import {
type CounterSchema,
counterSearchCounter,
@@ -28,14 +28,18 @@ export class ProductAjaxSelect extends AjaxSelect {
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) {
return `<div class="select-item">
<span class="select-item-text">${sanitize(item.code)} - ${sanitize(item.name)}</span>
<span class="select-item-text">${this.getName(item, sanitize)}</span>
</div>`;
}
protected renderItem(item: SimpleProductSchema, sanitize: typeof escape_html) {
return `<span>${sanitize(item.code)} - ${sanitize(item.name)}</span>`;
return `<span>${this.getName(item, sanitize)}</span>`;
}
}
@@ -44,7 +48,7 @@ export class ProductTypeAjaxSelect extends AjaxSelect {
protected valueField = "id";
protected labelField = "name";
protected searchField = ["name"];
private productTypes = null as ProductTypeSchema[];
private productTypes = null as ProductTypeSchema[] | null;
protected async search(query: string): Promise<TomOption[]> {
// The production database has a grand total of 26 product types
@@ -52,7 +56,7 @@ export class ProductTypeAjaxSelect extends AjaxSelect {
// Thus, it's appropriate to fetch all product types during first use,
// then to reuse the result again and again.
if (this.productTypes === null) {
this.productTypes = (await producttypeFetchAll()).data || null;
this.productTypes = (await producttypeFetchAll()).data || [];
}
return this.productTypes.filter((t) =>
t.name.toLowerCase().includes(query.toLowerCase()),
@@ -59,7 +59,7 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
return onOptionSelect.call(
this.widget,
evt,
this.widget.getOption(value, true),
this.widget.getOption(value, true) as HTMLElement,
);
},
);
@@ -70,12 +70,15 @@ export class CounterProductSelect extends AutoCompleteSelectBase {
...super.tomSelectSettings(),
openOnFocus: false,
// 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
//
// SearchField can be an object array (according to the docs),
// but it is unproperly typed, so we must force-cast to trick TS
searchField: [
{ field: "code", weight: 2 },
{ field: "text", weight: 0.5 },
],
] as unknown as string[],
};
}
}
@@ -1,9 +1,14 @@
import { showSaveFilePicker } from "native-file-system-adapter";
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 { paginated } from "#core:utils/api";
import { type PaginatedRequest, paginated } from "#core:utils/api";
import { csv } from "#core:utils/csv";
import { getCurrentUrlParams, History, updateQueryString } from "#core:utils/history";
import type {
CounterAjaxSelect,
ProductTypeAjaxSelect,
} from "#counter:counter/components/ajax-select-index";
import {
type ProductSchema,
type ProductSearchProductsDetailedData,
@@ -51,6 +56,8 @@ const csvColumnTitles = [
gettext("archived"),
];
type ProductStatus = "active" | "archived" | "both";
document.addEventListener("alpine:init", () => {
Alpine.data("productList", () => ({
loading: false,
@@ -59,8 +66,7 @@ document.addEventListener("alpine:init", () => {
/** Total number of elements corresponding to the current query. */
nbPages: 0,
productStatus: "" as "active" | "archived" | "both",
productStatus: "" as ProductStatus,
search: "",
productTypes: [] as string[],
clubs: [] as string[],
@@ -71,16 +77,18 @@ document.addEventListener("alpine:init", () => {
async init() {
const url = getCurrentUrlParams();
this.search = url.get("search") || "";
this.productStatus = url.get("productStatus") ?? "active";
const productTypesWidget = this.$refs.productTypesInput.widget as TomSelect;
this.productStatus = (url.get("productStatus") ?? "active") as ProductStatus;
const productTypesWidget = (this.$refs.productTypesInput as ProductTypeAjaxSelect)
.widget as TomSelect;
productTypesWidget.on("change", (items: string[]) => {
this.productTypes = [...items];
});
const clubsWidget = this.$refs.clubsInput.widget as TomSelect;
const clubsWidget = (this.$refs.clubsInput as ClubAjaxSelect).widget as TomSelect;
clubsWidget.on("change", (items: string[]) => {
this.clubs = [...items];
});
const countersWidget = this.$refs.countersInput.widget as TomSelect;
const countersWidget = (this.$refs.countersInput as CounterAjaxSelect)
.widget as TomSelect;
countersWidget.on("change", (items: string[]) => {
this.counters = [...items];
});
@@ -127,9 +135,9 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
is_archived: isArchived,
// biome-ignore lint/style/useNamingConvention: api is in snake_case
product_type: [...this.productTypes],
club: [...this.clubs],
counter: [...this.counters],
product_type: [...this.productTypes.map(Number.parseInt)],
club: [...this.clubs.map(Number.parseInt)],
counter: [...this.counters.map(Number.parseInt)],
},
};
},
@@ -141,6 +149,10 @@ document.addEventListener("alpine:init", () => {
this.loading = true;
const options = this.getQueryParams();
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.products = resp.data.results.reduce<GroupedProducts>(
(acc: GroupedProducts, curr: ProductSchema) => {
@@ -172,7 +184,10 @@ document.addEventListener("alpine:init", () => {
// If not, fetch them.
const products: ProductSchema[] =
this.nbPages > 1
? await paginated(productSearchProductsDetailed, this.getQueryParams())
? await paginated(
productSearchProductsDetailed,
this.getQueryParams() as PaginatedRequest,
)
: Object.values<ProductSchema[]>(this.products).flat();
// CSV cannot represent nested data
// so we create a row for each price of each product.
@@ -1,5 +1,5 @@
import Alpine from "alpinejs";
import { AlertMessage } from "#core:utils/alert-message.ts";
import { AlertMessage } from "#core:utils/alert-message";
import { producttypeReorder } from "#openapi";
document.addEventListener("alpine:init", () => {
@@ -22,7 +22,7 @@ document.addEventListener("alpine:init", () => {
const productTypes = this.$refs.productTypes
.childNodes as NodeListOf<HTMLLIElement>;
const getId = (elem: HTMLLIElement) =>
Number.parseInt(elem.getAttribute("x-sort:item"), 10);
Number.parseInt(elem.getAttribute("x-sort:item") as string, 10);
const query =
newPosition === 0
? { above: getId(productTypes.item(1)) }
@@ -32,6 +32,10 @@ document.addEventListener("alpine:init", () => {
path: { type_id: itemId },
query: query,
});
if (response.response === undefined) {
console.error("Product type reordering request failed");
return;
}
this.openAlertMessage(response.response);
this.loading = false;
},
+1 -3
View File
@@ -17,18 +17,16 @@ D'abord, il faut ajouter dans node via `package.json`:
}
```
Ensuite, pour faire fonctionne l'auto-complétion, il faut configurer `tsconfig.json`:
Ensuite, pour faire fonctionne l'auto-complétion, il faut configurer `tsconfig.bundled.json`:
```json
{
"compilerOptions": {
// ...
"paths": {
// ...
"#mon_app:*": ["./mon_app/static/bundled/*"]
}
}
}
```
Et c'est tout !
+6 -2
View File
@@ -63,7 +63,6 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
# liste des autres migrations à appliquer avant celle-ci
]
@@ -117,14 +116,16 @@ Par exemple :
```python
from django.db import migrations
def forwards_func(apps, schema_editor):
print("Appplication de la migration")
def reverse_func(apps, schema_editor):
print("Annulation de la migration")
class Migration(migrations.Migration):
class Migration(migrations.Migration):
dependencies = []
operations = [
@@ -164,6 +165,7 @@ On écrirait donc, dans l'application `pedagogy` :
from django.db import models
from core.models import User
class UserUe(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
ue = models.CharField(max_length=10)
@@ -174,6 +176,7 @@ Et nous aurions le fichier de migration suivant :
from django.db import migrations, models
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
@@ -289,6 +292,7 @@ La commande vous donnera ceci :
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
replaces = [("pedagogy", "0004_userue"), ("pedagogy", "0005_alter_userue_ue")]
+1 -5
View File
@@ -191,7 +191,6 @@ for user in richest.annotate(amount=F("customer__amount"))[:100]:
On aurait même pu réorganiser ça :
```python
from core.models import User
from django.db.models import F
@@ -239,10 +238,7 @@ nous écrivons donc instinctivement :
from counter.models import Counter
foyer = Counter.objects.get(name="Foyer")
total_amount = sum(
sale.amount * sale.unit_price
for sale in foyer.sellings.all()
)
total_amount = sum(sale.amount * sale.unit_price for sale in foyer.sellings.all())
```
On pourrait penser qu'il n'y a pas de problème.
+1 -2
View File
@@ -27,12 +27,11 @@ SITH_SUBSCRIPTIONS = {
"cursus-alternant": {"name": _("Alternating cursus"), "price": 30, "duration": 6},
"membre-honoraire": {"name": _("Honorary member"), "price": 0, "duration": 666},
"un-jour": {"name": _("One day"), "price": 0, "duration": 0.00555333},
# On rajoute ici notre cotisation
# Elle se nomme "Un mois"
# Coûte 6€
# Dure 1 mois (on raisonne en semestre, ici, c'est 1/6 de semestre)
"un-mois": {"name": _("One month"), "price": 6, "duration": 0.166}
"un-mois": {"name": _("One month"), "price": 6, "duration": 0.166},
}
```
+1
View File
@@ -26,6 +26,7 @@ from django.templatetags.static import static
# Sélectionnez le fichier de bannière pour le weekmail de l'automne 2018
def get_banner(self):
return "http://" + settings.SITH_URL + static("com/img/weekmail_bannerA18.jpg")
```
+3 -2
View File
@@ -147,14 +147,15 @@ Voici quelques exemples :
env = Env()
env.read_env()
async def main():
async with aiohttp.ClientSession(
base_url="https://ae.utbm.fr/api/",
headers={"X-APIKey": env.str("API_KEY")}
base_url="https://ae.utbm.fr/api/", headers={"X-APIKey": env.str("API_KEY")}
) as session:
async with session.get("club/1") as res:
print(await res.json())
asyncio.run(main())
```
+4 -8
View File
@@ -182,7 +182,7 @@ qui seront alors injectés.
def get_context_data(**kwargs):
return super().get_context_data(**kwargs) | {
"create_fragment": FooCreateFragment.as_fragment()(),
"update_fragment": FooUpdateFragment.as_fragment()(foo_id=1)
"update_fragment": FooUpdateFragment.as_fragment()(foo_id=1),
}
```
@@ -319,11 +319,9 @@ from core.views.mixins import UseFragmentsMixin
class FooCompositeFormView(UseFragmentsMixin, TemplateView):
fragments = {
"create_fragment": FooCreateFragment,
"update_fragment": FooUpdateFragment
}
fragment_data = {
"update_fragment": {"foo": 4}
"update_fragment": FooUpdateFragment,
}
fragment_data = {"update_fragment": {"foo": 4}}
template_name = "app/foo.jinja"
```
@@ -342,9 +340,7 @@ from core.views.mixins import UseFragmentsMixin
class FooCompositeFormView(UseFragmentsMixin, TemplateView):
fragments = {
"create_fragment": FooCreateFragment
}
fragments = {"create_fragment": FooCreateFragment}
template_name = "app/foo.jinja"
def get_fragment_context_data(self):
+7 -5
View File
@@ -123,6 +123,7 @@ Par exemple, prenons le modèle suivant :
```python
from django.db import models
class News(models.Model):
# ...
@@ -199,6 +200,7 @@ Pour les vues sous forme de fonction, il y a le décorateur
from django.views import View
from django.views.generic.detail import SingleObjectMixin
class NewsModerateView(PermissionRequiredMixin, SingleObjectMixin, View):
model = News
pk_url_kwarg = "news_id"
@@ -225,6 +227,7 @@ Pour les vues sous forme de fonction, il y a le décorateur
from django.urls import reverse
from django.views.decorators.http import require_POST
@permission_required("com.moderate_news")
@require_POST
def moderate_news(request, news_id: int):
@@ -263,6 +266,7 @@ from core.auth.mixins import PermissionOrAuthorRequiredMixin
from django.views.generic import UpdateView
class NewsUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
model = News
pk_url_kwarg = "news_id"
@@ -324,8 +328,8 @@ Voici un exemple d'implémentation de ce système :
from core.models import User, Group
class Article(models.Model):
class Article(models.Model):
title = models.CharField(_("title"), max_length=100)
content = models.TextField(_("content"))
@@ -370,6 +374,7 @@ Voici un exemple d'implémentation de ce système :
from core.models import User, Group
class Article(models.Model):
title = models.CharField(_("title"), max_length=100)
content = models.TextField(_("content"))
@@ -529,10 +534,7 @@ class NewsQuerySet(models.QuerySet): # (1)!
return self
# sinon, on retourne les nouvelles modérées ou dont l'utilisateur
# est l'auteur
return self.filter(
models.Q(is_moderated=True)
| models.Q(author=user)
)
return self.filter(models.Q(is_moderated=True) | models.Q(author=user))
class News(models.Model):
@@ -61,10 +61,10 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { basket_id: basket.id },
});
if (res.response.ok) {
if (res.response?.ok) {
this.data = res.data as Record<string, string>;
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.
// This shouldn't happen, because we don't send the request
// when the timeout is passed, but we are better safe than sorry
@@ -148,56 +148,6 @@
</span>
</div>
{% endif %}
<section>
<div class="category-header">
<h3 class="margin-bottom">{% trans %}Eurockéennes 2025 partnership{% endtrans %}</h3>
{% if user.is_subscribed %}
<div id="eurock-partner" style="
min-height: 600px;
background-color: lightgrey;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 10px;
">
<p style="text-align: center;">
{% trans trimmed %}
Our partner uses Weezevent to sell tickets.
Weezevent may collect user info according to
its own privacy policy.
By clicking the accept button you consent to
their terms of services.
{% endtrans %}
</p>
<a href="https://weezevent.com/fr/politique-de-confidentialite/">{% trans %}Privacy policy{% endtrans %}</a>
<button
hx-get="{{ url("eboutic:eurock") }}"
hx-target="#eurock-partner"
hx-swap="outerHTML"
hx-trigger="click, load[document.cookie.includes('weezevent_accept=true')]"
@htmx:after-request="document.cookie = 'weezevent_accept=true'"
>{% trans %}Accept{% endtrans %}
</button>
</div>
{% else %}
<p>
{%- trans trimmed %}
You must be subscribed to benefit from the partnership with the Eurockéennes.
{% endtrans -%}
</p>
<p>
{%- trans trimmed %}
This partnership offers a discount of up to 33%
on tickets for Friday, Saturday and Sunday,
as well as the 3-day package from Friday to Sunday.
{% endtrans -%}
</p>
{% endif %}
</div>
</section>
{% for prices in categories %}
{% set category = prices[0].product.product_type %}
<section>
@@ -1,16 +0,0 @@
<a title="Logiciel billetterie en ligne"
href="https://www.weezevent.com?c=sys_widget"
class="weezevent-widget-integration"
target="_blank"
data-src="https://widget.weezevent.com/ticket/8aaba226-f7a3-4192-a64e-72ff8f5b35b7?id_evenement=1419869&locale=fr-FR&code=28747"
data-width="650"
data-height="600"
data-resize="1"
data-nopb="0"
data-type="neo"
data-width_auto="1"
data-noscroll="0"
data-id="1419869">
Billetterie Weezevent
</a>
<script type="text/javascript" src="https://widget.weezevent.com/weez.js" async defer></script>
-2
View File
@@ -31,7 +31,6 @@ from eboutic.views import (
EbouticMainView,
EbouticPayWithSith,
EtransactionAutoAnswer,
EurockPartnerFragment,
payment_result,
)
@@ -51,5 +50,4 @@ urlpatterns = [
EtransactionAutoAnswer.as_view(),
name="etransation_autoanswer",
),
path("eurock/", EurockPartnerFragment.as_view(), name="eurock"),
]
+2 -6
View File
@@ -43,11 +43,11 @@ from django.utils.formats import localize
from django.utils.timezone import localtime
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_GET
from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View
from django.views.generic import DetailView, FormView, UpdateView, View
from django.views.generic.edit import SingleObjectMixin
from django_countries.fields import Country
from core.auth.mixins import CanViewMixin, IsSubscriberMixin
from core.auth.mixins import CanViewMixin
from core.views.mixins import FragmentMixin, UseFragmentsMixin
from counter.forms import BaseBasketForm, BasketItemForm, BillingInfoForm
from counter.models import (
@@ -359,7 +359,3 @@ class EtransactionAutoAnswer(View):
return HttpResponse(
"Payment failed with error: " + request.GET["Error"], status=202
)
class EurockPartnerFragment(IsSubscriberMixin, TemplateView):
template_name = "eboutic/eurock_fragment.jinja"
+1 -1
View File
@@ -1,6 +1,5 @@
from typing import TYPE_CHECKING
from cryptography.utils import cached_property
from django.contrib import messages
from django.contrib.auth.mixins import (
LoginRequiredMixin,
@@ -12,6 +11,7 @@ from django.db import transaction
from django.db.models import QuerySet
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, FormView, UpdateView
+9 -42
View File
@@ -6,7 +6,7 @@
msgid ""
msgstr ""
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-06-10 20:18+0200\n"
"POT-Creation-Date: 2026-08-21 14:10+0200\n"
"PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n"
@@ -4465,47 +4465,6 @@ msgstr ""
msgid "this page"
msgstr "cette page"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Eurockéennes 2025 partnership"
msgstr "Partenariat Eurockéennes 2025"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"Our partner uses Weezevent to sell tickets. Weezevent may collect user info "
"according to its own privacy policy. By clicking the accept button you "
"consent to their terms of services."
msgstr ""
"Notre partenaire utilises Wezevent pour vendre ses billets. Weezevent peut "
"collecter des informations utilisateur conformément à sa propre politique de "
"confidentialité. En cliquant sur le bouton d'acceptation vous consentez à "
"leurs termes de service."
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Privacy policy"
msgstr "Politique de confidentialité"
#: eboutic/templates/eboutic/eboutic_main.jinja
#: trombi/templates/trombi/comment_moderation.jinja
msgid "Accept"
msgstr "Accepter"
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid ""
"You must be subscribed to benefit from the partnership with the Eurockéennes."
msgstr ""
"Vous devez être cotisant pour bénéficier du partenariat avec les "
"Eurockéennes."
#: eboutic/templates/eboutic/eboutic_main.jinja
#, python-format
msgid ""
"This partnership offers a discount of up to 33%% on tickets for Friday, "
"Saturday and Sunday, as well as the 3-day package from Friday to Sunday."
msgstr ""
"Ce partenariat permet de profiter d'une réduction jusqu'à 33%% sur les "
"billets du vendredi, du samedi et du dimanche, ainsi qu'au forfait 3 jours, "
"du vendredi au dimanche."
#: eboutic/templates/eboutic/eboutic_main.jinja
msgid "Product sold out"
msgstr "Produit épuisé"
@@ -5010,6 +4969,10 @@ msgstr "Ne pas voter"
msgid "This user has already commented on this UE"
msgstr "Cet utilisateur a déjà commenté cette UE"
#: pedagogy/forms.py
msgid "UE comment can't be empty."
msgstr "Un commentaire d'UE ne peut pas être vide."
#: pedagogy/forms.py
msgid "Accepted reports"
msgstr "Signalements acceptés"
@@ -5949,6 +5912,10 @@ msgstr "fin"
msgid "Moderate Trombi comments"
msgstr "Modérer les commentaires du Trombi"
#: trombi/templates/trombi/comment_moderation.jinja
msgid "Accept"
msgstr "Accepter"
#: trombi/templates/trombi/comment_moderation.jinja
msgid "Reject"
msgstr "Refuser"
+859 -448
View File
File diff suppressed because it is too large Load Diff
+16 -13
View File
@@ -33,26 +33,28 @@
"@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6",
"rollup-plugin-visualizer": "^7.0.1",
"typescript": "^6.0.3",
"vite": "^8.1.0"
"@types/node": "^26.2.0",
"rollup-plugin-visualizer": "^7.1.1",
"@typescript/native": "npm:typescript@^7.0.2",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"vite": "^8.2.2"
},
"dependencies": {
"@alpinejs/sort": "^3.15.12",
"@alpinejs/sort": "^3.16.2",
"@arendjr/text-clipper": "npm:@jsr/arendjr__text-clipper@^3.0.0",
"@floating-ui/dom": "^1.7.6",
"@fortawesome/fontawesome-free": "^7.2.0",
"@floating-ui/dom": "^1.8.0",
"@fortawesome/fontawesome-free": "^7.3.1",
"@fullcalendar/core": "^6.1.21",
"@fullcalendar/daygrid": "^6.1.21",
"@fullcalendar/icalendar": "^6.1.21",
"@fullcalendar/list": "^6.1.21",
"@sentry/browser": "^10.60.0",
"@zip.js/zip.js": "^2.8.26",
"@sentry/browser": "^10.70.0",
"@zip.js/zip.js": "^2.8.57",
"3d-force-graph": "^1.80.0",
"alpinejs": "^3.15.12",
"alpinejs": "^3.16.2",
"chart.js": "^4.5.1",
"country-flag-emoji-polyfill": "^0.1.8",
"cytoscape": "^3.34.0",
"country-flag-emoji-polyfill": "^0.1.10",
"cytoscape": "^3.34.1",
"cytoscape-cxtmenu": "^3.5.0",
"cytoscape-klay": "^3.1.4",
"d3-force-3d": "^3.0.6",
@@ -63,8 +65,9 @@
"js-cookie": "^3.0.8",
"lit-html": "^3.3.3",
"native-file-system-adapter": "^3.0.1",
"three": "^0.184.0",
"temporal-polyfill": "^1.0.4",
"three": "^0.185.1",
"three-spritetext": "^1.10.0",
"tom-select": "^2.6.1"
"tom-select": "^2.6.2"
}
}
+29 -10
View File
@@ -22,6 +22,8 @@
#
from django import forms
from django.contrib.staticfiles.storage import staticfiles_storage
from django.forms.fields import ValidationError
from django.utils.translation import gettext_lazy as _
from core.models import User
@@ -30,7 +32,7 @@ from pedagogy.models import UE, UEComment, UECommentReport
class UEForm(forms.ModelForm):
"""Form handeling creation and edit of an UE."""
"""Form handling creation and edit of an UE."""
class Meta:
model = UE
@@ -68,22 +70,28 @@ class UEForm(forms.ModelForm):
self.fields["author"].initial = author_id
class StarList(forms.NumberInput):
class StarList(forms.RadioSelect):
template_name = "pedagogy/starlist.jinja"
def __init__(self, nubmer_of_stars=0):
super().__init__(None)
self.number_of_stars = nubmer_of_stars
def __init__(self, number_of_stars=0, attrs=None):
super().__init__(
attrs=attrs,
choices=(
(choice, _("Do not vote") if choice == -1 else choice)
for choice in range(-1, number_of_stars)
),
)
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context["number_of_stars"] = range(0, self.number_of_stars)
context["translations"] = {"do_not_vote": _("Do not vote")}
context["statics"] = {
"css": staticfiles_storage.url("pedagogy/css/starlist.scss"),
}
return context
class UECommentForm(forms.ModelForm):
"""Form handeling creation and edit of an UEComment."""
"""Form handling creation and edit of an UEComment."""
class Meta:
model = UEComment
@@ -129,11 +137,22 @@ class UECommentForm(forms.ModelForm):
),
)
# Ensure that at least one value is exists
if (
all(
grade == -1
for key, grade in self.cleaned_data.items()
if key.startswith("grade_")
)
and not self.cleaned_data["comment"]
):
raise ValidationError(message=_("UE comment can't be empty."))
return self.cleaned_data
class UECommentReportForm(forms.ModelForm):
"""Form handeling creation and edit of an UEReport."""
"""Form handling creation and edit of an UEReport."""
class Meta:
model = UECommentReport
@@ -153,7 +172,7 @@ class UECommentReportForm(forms.ModelForm):
class UECommentModerationForm(forms.Form):
"""Form handeling bulk comment deletion."""
"""Form handling bulk comment deletion."""
accepted_reports = forms.ModelMultipleChoiceField(
UECommentReport.objects.all(),
@@ -18,7 +18,12 @@ class Command(BaseCommand):
"Fetching UEs from the UTBM API.\n"
"This may take a few minutes to complete."
)
i = 0
for ue in client.fetch_ues():
if (i := (i + 1)) % 10 == 0:
self.stdout.write(f"{i} UEs processed")
# 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()
if db_ue is None:
db_ue = UE(code=ue.code, author=root_user)
+5 -1
View File
@@ -126,7 +126,11 @@ class UE(models.Model):
Returns:
True if the user has already posted a comment on this UE, else False.
"""
return self.comments.filter(author=user).exists()
self._has_user_commented = getattr(self, "_has_user_commented", {})
self._has_user_commented[user] = self._has_user_commented.get(
user, self.comments.filter(author=user).exists()
)
return self._has_user_commented[user]
@cached_property
def grade_global_average(self):
+34 -18
View File
@@ -5,12 +5,22 @@ from django.urls import reverse
from django.utils import html
from haystack.query import SearchQuerySet
from ninja import FilterLookup, FilterSchema, ModelSchema, Schema
from pydantic import AliasPath, ConfigDict, Field, TypeAdapter
from pydantic import AliasPath, BeforeValidator, ConfigDict, Field, TypeAdapter
from pydantic.alias_generators import to_camel
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):
"""Short representation of an UE in the UTBM API.
@@ -19,19 +29,17 @@ class UtbmShortUeSchema(Schema):
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_formation: str
code_categorie: str | None
code_langue: str
ouvert_automne: bool
ouvert_printemps: bool
category: str = Field(alias="codeCategorie")
formation: FormationSchema
lang: str = Field(alias="codeLangue")
open_autumn: bool = Field(alias="ouvertAutomne")
open_spring: bool = Field(alias="ouvertPrintemps")
class WorkloadSchema(Schema):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
code: Literal["TD", "TP", "CM", "THE", "TE"]
nbh: int
@@ -48,22 +56,30 @@ class SemesterUeState(Schema):
ShortUeList = TypeAdapter(list[UtbmShortUeSchema])
class SyllabusItemSchema(Schema):
model_config = ConfigDict(validate_by_name=True)
label: str = Field(alias="libelle")
# We want only strings, but the UTBM API can return null,
# so convert null values to empty strings
value: Annotated[str, BeforeValidator(lambda x: x or "")] = Field(alias="valeur")
class UtbmFullUeSchema(Schema):
"""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
departement: str = "NA"
libelle: str | None
objectifs: str | None
programme: str | None
acquisition_competences: str | None
acquisition_notions: str | None
langue: str
code_langue: str
label: str = Field(alias="libelle")
syllabus: list[SyllabusItemSchema] = Field(
default=[],
validation_alias=AliasPath("listeElementConstitutif", 0, "listeSaisieSyllabus"),
)
lang: str = Field(None, validation_alias=AliasPath("langueEnseignement", "code"))
credits_ects: int
activites: list[WorkloadSchema]
activites: list[WorkloadSchema] = Field(alias="listeActivite")
respo_automne: str | None = Field(
None, validation_alias=AliasPath("automne", "responsable")
)
+11 -12
View File
@@ -1,3 +1,4 @@
@import "core/static/core/devices";
@import "core/static/core/colors";
@@ -7,10 +8,6 @@ $pedagogy-hover-blue: #0e97ce;
$pedagogy-light-blue: #caf0ff;
$pedagogy-white-text: #f0f0f0;
$small-devices: 576px;
$medium-devices: 768px;
$large-devices: 992px;
.pedagogy {
&.star-not-checked {
color: #f7f7f7;
@@ -208,14 +205,14 @@ $large-devices: 992px;
grid-area: hours-the;
}
#leave_comment_not_allowed {
.leave-comment-not-allowed {
p {
text-align: center;
color: red;
}
}
#leave_comment {
.leave-comment {
.leave-comment-grid-container {
display: grid;
grid-template-columns: 270px auto;
@@ -248,15 +245,11 @@ $large-devices: 992px;
.input-stars {
margin-top: 20px;
}
input[type="submit"] {
float: right;
}
}
.ue-details-container {
display: grid;
grid-template-columns: 150px 100px auto;
grid-template-columns: 150px 130px auto;
grid-template-rows: 156px 1fr;
grid-template-areas:
"grade grade-stars ue-infos"
@@ -413,8 +406,9 @@ $large-devices: 992px;
padding-left: 10px;
}
.actions {
.action {
float: right;
margin-top: 0;
}
}
@@ -524,3 +518,8 @@ details.accordion>.accordion-content {
border-color: $pedagogy-orange;
border-right: none;
}
.right {
display: flex;
justify-content: flex-end;
}
@@ -0,0 +1,38 @@
fieldset.star {
border: none;
label {
display: inline;
cursor: pointer;
}
.checked {
color: orange;
}
.unchecked {
color: gray;
}
.unchecked.hovered {
color: #FFD700;
}
.removed {
color: red;
}
input[type="radio"] {
display: none;
}
label:first-child {
margin-right: 10px;
}
&:has(input:required:invalid) {
border: 1px solid;
border-color: #c00000;
}
}
@@ -0,0 +1,30 @@
<form
hx-post="{{ request.get_full_path() }}"
hx-ext="error-callbacks"
hx-target="this"
hx-swap="outerHTML"
hx-disabled-elt="input[type='submit']"
hx-trigger="submit"
hx-callback-404="target.remove()"
>
{% csrf_token %}
{{ form.non_field_errors() }}
{{ form.reason.errors }}
{{ form.reason }}
{# Hidden fields #}
{{ form.reporter }}
{{ form.comment }}
<button
hx-get="{{ url('pedagogy:comment_detail', comment_id=comment_id) }}"
hx-target="closest form"
hx-swap="outerHTML"
>
{% trans %}Cancel{% endtrans %}
</button>
<p class="right" id="nique">
<input type="submit" value="{% trans %}Report{% endtrans %}" />
</p>
</form>
@@ -0,0 +1,83 @@
{% from "pedagogy/macros.jinja" import display_star %}
{% from "core/macros.jinja" import user_profile_link %}
<div id="comment-{{ comment.id }}" class="comment-container">
<div class="grade-block">
<div class="grade-type">
<p>{% trans %}Global grade{% endtrans %}</p>
<p>{% trans %}Utility{% endtrans %}</p>
<p>{% trans %}Interest{% endtrans %}</p>
<p>{% trans %}Teaching{% endtrans %}</p>
<p>{% trans %}Work load{% endtrans %}</p>
</div>
<div class="grade-stars">
<p>{{ display_star(comment.grade_global) }}</p>
<p>{{ display_star(comment.grade_utility) }}</p>
<p>{{ display_star(comment.grade_interest) }}</p>
<p>{{ display_star(comment.grade_teaching) }}</p>
<p>{{ display_star(comment.grade_work_load) }}</p>
</div>
<div class="grade-extension"></div>
</div>
<div class="comment">
<div class="anchor">
<a href="{{ url('pedagogy:ue_detail', ue_id=ue.id) }}#comment-{{ comment.id }}"><i class="fa fa-paragraph"></i></a>
</div>
{{ comment.comment|markdown }}
</div>
<div class="info">
{% if comment.is_reported %}
<p class="status-reported">
{% trans %}This comment has been reported{% endtrans %}
</p>
{% endif %}
{% if comment.author_id == user.id or user.has_perm("pedagogy.change_comment") %}
<button
class="btn btn-orange action"
hx-get="{{ url('pedagogy:comment_update', comment_id=comment.id) }}"
hx-swap="outerHTML"
hx-target="#comment-{{ comment.id }}"
>
<i class="fa fa-pencil"></i> {% trans %}Edit{% endtrans %}
</button>
{% endif %}
{% if comment.author_id == user.id or user.has_perm("pedagogy.delete_comment") %}
<form class="action"
hx-ext="error-callbacks"
hx-post="{{ url('pedagogy:comment_delete', comment_id=comment.id) }}"
hx-confirm='{% trans obj=object %}Are you sure you want to delete "{{ obj }}"?{% endtrans %}'
hx-swap="outerHTML"
hx-target="#comment-{{ comment.id }}"
hx-callback-404="document.getElementsByTagName('body')[0].dispatchEvent(new CustomEvent('CommentUpdate'));target.remove()"
>
{% csrf_token %}
<button class="btn btn-red action">
<i class="fa fa-trash-can"></i> {% trans %}Delete{% endtrans %}
</button>
</form>
{% endif %}
</div>
<div class="comment-end-bar">
<div class="report">
<p>
<a
hx-get="{{ url('pedagogy:comment_report', comment_id=comment.id) }}"
hx-swap="outerHTML"
hx-target="#comment-{{ comment.id }}"
>
{% trans %}Report this comment{% endtrans %}
</a>
</p>
</div>
<div class="date"><p>{{ comment.publish_date.strftime('%d/%m/%Y') }}</p></div>
<div class="author"><p>{{ user_profile_link(comment.author) }}</p></div>
</div>
</div>
@@ -0,0 +1,71 @@
<div class="leave-comment">
{% if form.is_creation %}
<details class="accordion" id="leave_comment" {% if form.errors %}open{% endif %}>
<summary>{% trans %}Leave comment{% endtrans %}</summary>
<div class="accordion-content">
{% endif %}
<form
hx-post="{{ action }}"
hx-target="closest .leave-comment"
hx-swap="outerHTML"
hx-disabled-elt="find input[type='submit']"
>
{% csrf_token %}
<div class="leave-comment-grid-container">
<div class="form-stars">
{{ form.non_field_errors() }}
{{ form.author.errors }}
{{ form.ue.errors }}
{{ form.author }}
{{ form.ue }}
<div class="input-stars">
<label for="{{ form.grade_global.id_for_label }}">{{ form.grade_global.label }} :</label>
{{ form.grade_global.errors }}
{{ form.grade_global }}
</div>
<div class="input-stars">
<label for="{{ form.grade_utility.id_for_label }}">{{ form.grade_utility.label }} :</label>
{{ form.grade_utility.errors }}
{{ form.grade_utility }}
</div>
<div class="input-stars">
<label for="{{ form.grade_interest.id_for_label }}">{{ form.grade_interest.label }} :</label>
{{ form.grade_interest.errors }}
{{ form.grade_interest }}
</div>
<div class="input-stars">
<label for="{{ form.grade_teaching.id_for_label }}">{{ form.grade_teaching.label }} :</label>
{{ form.grade_teaching.errors }}
{{ form.grade_teaching }}
</div>
<div class="input-stars">
<label for="{{ form.grade_work_load.id_for_label }}">{{ form.grade_work_load.label }} :</label>
{{ form.grade_work_load.errors }}
{{ form.grade_work_load }}
</div>
</div>
<div class="form-comment">
<label for="{{ form.comment.id_for_label }}">{{ form.comment.label }} :</label>
{{ form.comment.errors }}
{{ form.comment }}
</div>
</div>
<p class="right">
<input type="submit" value="{% trans %}Comment{% endtrans %}" />
</p>
</form>
{% if form.is_creation %}
</div>
</details>
{% endif %}
<br>
</div>
@@ -0,0 +1,17 @@
<section
hx-get="{{ url("pedagogy:ue_comments", ue_id=object.id) }}"
hx-swap="outerHTML"
hx-target="this"
hx-trigger="NewComment from:body"
>
{% if comments %}
<h2>{% trans %}Comments{% endtrans %}</h2>
<br>
{% endif %}
{% for comment in comments %}
{% include "pedagogy/fragments/ue_comment.jinja" %}
{% endfor %}
</section>
@@ -0,0 +1,47 @@
<div
hx-get="{{ request.get_full_path() }}"
hx-swap="outerHTML"
hx-target="this"
hx-trigger="CommentUpdate from:body"
>
<div class="ue-details-container">
<div class="grade">
<p>{% trans %}Global grade{% endtrans %}</p>
<p>{% trans %}Utility{% endtrans %}</p>
<p>{% trans %}Interest{% endtrans %}</p>
<p>{% trans %}Teaching{% endtrans %}</p>
<p>{% trans %}Work load{% endtrans %}</p>
</div>
<div class="grade-stars">
<p>{{ display_star(object.grade_global_average) }}</p>
<p>{{ display_star(object.grade_utility_average) }}</p>
<p>{{ display_star(object.grade_interest_average) }}</p>
<p>{{ display_star(object.grade_teaching_average) }}</p>
<p>{{ display_star(object.grade_work_load_average) }}</p>
</div>
<div class="ue-infos">
<p><b>{% trans %}Objectives{% endtrans %}</b></p>
<p>{{ object.objectives|markdown }}</p>
<p><b>{% trans %}Program{% endtrans %}</b></p>
<p>{{ object.program|markdown }}</p>
<p><b>{% trans %}Earned skills{% endtrans %}</b></p>
<p>{{ object.skills|markdown }}</p>
<p><b>{% trans %}Key concepts{% endtrans %}</b></p>
<p>{{ object.key_concepts|markdown }}</p>
<p><b>{% trans %}UE manager: {% endtrans %}</b>{{ object.manager }}</p>
</div>
</div>
<br>
{% if object.has_user_already_commented(user) %}
<div class="leave-comment-not-allowed">
<p>{% trans %}You already posted a comment on this UE. If you want to comment again, please modify or delete your previous comment.{% endtrans %}</p>
</div>
<br>
{% endif %}
{% if not object.has_user_already_commented(user) and user.has_perm("pedagogy.add_uecomment") %}
{{ add_comment_form }}
{% endif %}
</div>
+46 -52
View File
@@ -1,58 +1,52 @@
<div>
<style>
.checked {
color : orange;
}
.unchecked {
color : gray;
}
.star input[type="radio"] {
display : none;
}
.star {
display: inline;
}
</style>
<link-once rel="stylesheet" type="text/css" href="{{ statics.css }}" defer></link-once>
<fieldset
class="star"
x-data="{ 'value': {{ widget.value.0 }}, 'hover': -1 }"
@mouseover.outside="hover = -1"
{% include "django/forms/widgets/attrs.html" %}
>
{# Do not vote button #}
<label class="star">
<input type="radio" name="{{ widget.name }}" value="-1" onclick='
var stars = document.getElementsByClassName("{{ widget.name }}");
for (var i = 0; i < stars.length; i++){
var attrs = stars[i].getAttribute("class");
attrs = attrs.replace("unchecked", "");
attrs = attrs.replace("checked", "");
stars[i].setAttribute("class", attrs + " unchecked");
}
' checked>
<span class="fa fa-times-circle"> {{ translations.do_not_vote }}</span>
<label
@mouseover="hover = -1"
for="{{ widget.optgroups.0.1.0.attrs.id }}"
>
<input
type="radio"
name="{{ widget.name }}"
value="{{ widget.optgroups.0.1.0.value }}"
@click="value = -1"
{% with widget=widget.optgroups.0.1.0 %}
{% include "django/forms/widgets/attrs.html" %}
{% endwith %}
>
<i class="fa fa-times-circle"></i>
{{ widget.optgroups.0.1.0.label }}
</label>
{# Star widget #}
{% for i in number_of_stars %}
<label class="star">
<input type="radio" name="{{ widget.name }}" value="{{ forloop.counter0 }}" onclick='
var stars = document.getElementsByClassName("{{ widget.name }}");
for (var i = 0; i < stars.length; i++){
var attrs = stars[i].getAttribute("class");
attrs = attrs.replace("unchecked", "");
attrs = attrs.replace("checked", "");
if (i > {{ forloop.counter0 }}){
stars[i].setAttribute("class", attrs + " unchecked");
} else {
stars[i].setAttribute("class", attrs + " checked");
}
}
'>
<i class="{{ widget.name }} fa fa-star unchecked"></i>
{% for opt in widget.optgroups|slice:"1:" %}
<label
@mouseover="hover = {{ opt.1.0.value }}"
for="{{ opt.1.0.attrs.id }}"
>
<input
type="radio"
name="{{ widget.name }}"
value="{{ opt.1.0.value }}"
@click="value = {{ opt.1.0.value }}"
{% with widget=opt.1.0 %}
{% include "django/forms/widgets/attrs.html" %}
{% endwith %}
>
<i
class="{{ widget.name }} fa fa-star"
:class="{
'checked': value >= {{ opt.1.0.value }},
'unchecked': value < {{ opt.1.0.value }},
'hovered': hover >= {{ opt.1.0.value }},
'removed': hover >= 0 && value >= {{ opt.1.0.value }} && hover < {{ opt.1.0.value }},
}"
></i>
</label>
{% endfor %}
{# Restaure previous (-1 is default) #}
<script type="text/javascript">
document.querySelector("input[name='{{ widget.name }}'][value='{{ widget.value }}']").click()
</script>
</div>
</fieldset>
+10 -159
View File
@@ -1,7 +1,10 @@
{% extends "core/base.jinja" %}
{% from "core/macros.jinja" import user_profile_link %}
{% from "pedagogy/macros.jinja" import display_star %}
{% if is_fragment %}
{% include "pedagogy/fragments/ue_detail.jinja" %}
{% else %}
{% extends "core/base.jinja" %}
{% block additional_css %}
<link rel="stylesheet" href="{{ static('pedagogy/css/pedagogy.scss') }}">
{% endblock %}
@@ -11,6 +14,7 @@
{% endblock %}
{% block content %}
<div class="pedagogy">
<div id="ue_detail">
<button onclick='(function(){
@@ -55,165 +59,12 @@
<br>
<div class="ue-details-container">
<div class="grade">
<p>{% trans %}Global grade{% endtrans %}</p>
<p>{% trans %}Utility{% endtrans %}</p>
<p>{% trans %}Interest{% endtrans %}</p>
<p>{% trans %}Teaching{% endtrans %}</p>
<p>{% trans %}Work load{% endtrans %}</p>
</div>
<div class="grade-stars">
<p>{{ display_star(object.grade_global_average) }}</p>
<p>{{ display_star(object.grade_utility_average) }}</p>
<p>{{ display_star(object.grade_interest_average) }}</p>
<p>{{ display_star(object.grade_teaching_average) }}</p>
<p>{{ display_star(object.grade_work_load_average) }}</p>
</div>
<div class="ue-infos">
<p><b>{% trans %}Objectives{% endtrans %}</b></p>
<p>{{ object.objectives|markdown }}</p>
<p><b>{% trans %}Program{% endtrans %}</b></p>
<p>{{ object.program|markdown }}</p>
<p><b>{% trans %}Earned skills{% endtrans %}</b></p>
<p>{{ object.skills|markdown }}</p>
<p><b>{% trans %}Key concepts{% endtrans %}</b></p>
<p>{{ object.key_concepts|markdown }}</p>
<p><b>{% trans %}UE manager: {% endtrans %}</b>{{ object.manager }}</p>
</div>
</div>
{% include "pedagogy/fragments/ue_detail.jinja" %}
<br>
{% if object.has_user_already_commented(user) %}
<div id="leave_comment_not_allowed">
<p>{% trans %}You already posted a comment on this UE. If you want to comment again, please modify or delete your previous comment.{% endtrans %}</p>
</div>
{% elif user.has_perm("pedagogy.add_uecomment") %}
<details class="accordion" id="leave_comment">
<summary>{% trans %}Leave comment{% endtrans %}</summary>
<div class="accordion-content">
<form action="{{ url('pedagogy:ue_detail', ue_id=object.id) }}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="leave-comment-grid-container">
<div class="form-stars">
{{ form.author.errors }}
{{ form.ue.errors }}
{{ form.author }}
{{ form.ue }}
<div class="input-stars">
<label for="{{ form.grade_global.id_for_label }}">{{ form.grade_global.label }} :</label>
{{ form.grade_global.errors }}
{{ form.grade_global }}
</div>
<div class="input-stars">
<label for="{{ form.grade_utility.id_for_label }}">{{ form.grade_utility.label }} :</label>
{{ form.grade_utility.errors }}
{{ form.grade_utility }}
</div>
<div class="input-stars">
<label for="{{ form.grade_interest.id_for_label }}">{{ form.grade_interest.label }} :</label>
{{ form.grade_interest.errors }}
{{ form.grade_interest }}
</div>
<div class="input-stars">
<label for="{{ form.grade_teaching.id_for_label }}">{{ form.grade_teaching.label }} :</label>
{{ form.grade_teaching.errors }}
{{ form.grade_teaching }}
</div>
<div class="input-stars">
<label for="{{ form.grade_work_load.id_for_label }}">{{ form.grade_work_load.label }} :</label>
{{ form.grade_work_load.errors }}
{{ form.grade_work_load }}
</div>
</div>
<div class="form-comment">
<label for="{{ form.comment.id_for_label }}">{{ form.comment.label }} :</label>
{{ form.comment.errors }}
{{ form.comment }}
</div>
</div>
<p><input type="submit" value="{% trans %}Comment{% endtrans %}" /></p>
</form>
</div>
</details>
{% endif %}
<br>
{% if comments %}
<h2>{% trans %}Comments{% endtrans %}</h2>
{% for comment in comments %}
<div id="{{ comment.id }}" class="comment-container">
<div class="grade-block">
<div class="grade-type">
<p>{% trans %}Global grade{% endtrans %}</p>
<p>{% trans %}Utility{% endtrans %}</p>
<p>{% trans %}Interest{% endtrans %}</p>
<p>{% trans %}Teaching{% endtrans %}</p>
<p>{% trans %}Work load{% endtrans %}</p>
</div>
<div class="grade-stars">
<p>{{ display_star(comment.grade_global) }}</p>
<p>{{ display_star(comment.grade_utility) }}</p>
<p>{{ display_star(comment.grade_interest) }}</p>
<p>{{ display_star(comment.grade_teaching) }}</p>
<p>{{ display_star(comment.grade_work_load) }}</p>
</div>
<div class="grade-extension"></div>
</div>
<div class="comment">
<div class="anchor">
<a href="{{ url('pedagogy:ue_detail', ue_id=ue.id) }}#{{ comment.id }}"><i class="fa fa-paragraph"></i></a>
</div>
{{ comment.comment|markdown }}
</div>
<div class="info">
{% if comment.is_reported %}
<p class="status-reported">
{% trans %}This comment has been reported{% endtrans %}
</p>
{% endif %}
{% if comment.author_id == user.id or user.has_perm("pedagogy.change_comment") %}
<p class="actions">
<a href="{{ url('pedagogy:comment_update', comment_id=comment.id) }}">
{% trans %}Edit{% endtrans %}
</a>
{% endif %}
{% if comment.author_id == user.id or user.has_perm("pedagogy.delete_comment") %}
<a href="{{ url('pedagogy:comment_delete', comment_id=comment.id) }}">
{% trans %}Delete{% endtrans %}
</a>
</p>
{% endif %}
</div>
<div class="comment-end-bar">
<div class="report">
<p>
<a href="{{ url('pedagogy:comment_report', comment_id=comment.id) }}">
{% trans %}Report this comment{% endtrans %}
</a>
</p>
</div>
<div class="date"><p>{{ comment.publish_date.strftime('%d/%m/%Y') }}</p></div>
<div class="author"><p>{{ user_profile_link(comment.author) }}</p></div>
</div>
</div>
{% endfor %}
{% endif %}
{{ comments }}
</div>
</div>
{% endblock %}
{% endif %}
+18
View File
@@ -339,6 +339,24 @@ class TestUVCommentCreationAndDisplay(TestCase):
response = self.client.get(self.ue_url)
self.assertContains(response, text="Superbe UE")
def test_create_ue_empty_comment_fail(self):
self.client.force_login(self.tutu)
response = self.client.post(
self.ue_url,
{
"author": self.tutu.id,
"ue": UE.objects.get(code="PA00").id,
"grade_global": -1,
"grade_utility": -1,
"grade_interest": -1,
"grade_teaching": -1,
"grade_work_load": -1,
"comment": "",
},
)
assert response.status_code == 200
self.assertInHTML("Un commentaire d'UE ne peut pas être vide.", response.text)
def test_create_ue_comment_unauthorized_fail(self):
nb_comments = self.ue.comments.count()
# Test with anonymous user
+20 -2
View File
@@ -24,12 +24,15 @@
from django.urls import path
from pedagogy.views import (
UECommentCreateView,
UECommentDeleteView,
UECommentDetailView,
UECommentReportCreateView,
UECommentUpdateView,
UECreateView,
UEDeleteView,
UEDetailFormView,
UEDetailCommentsView,
UEDetailView,
UEGuideView,
UEModerationFormView,
UEUpdateView,
@@ -38,7 +41,22 @@ from pedagogy.views import (
urlpatterns = [
# Urls displaying the actual application for visitors
path("", UEGuideView.as_view(), name="guide"),
path("ue/<int:ue_id>/", UEDetailFormView.as_view(), name="ue_detail"),
path("ue/<int:ue_id>/", UEDetailView.as_view(), name="ue_detail"),
path(
"ue/<int:ue_id>/comments",
UEDetailCommentsView.as_view(),
name="ue_comments",
),
path(
"ue/<int:ue_id>/comment",
UECommentCreateView.as_view(),
name="comment_create",
),
path(
"comment/<int:comment_id>/",
UECommentDetailView.as_view(),
name="comment_detail",
),
path(
"comment/<int:comment_id>/edit/",
UECommentUpdateView.as_view(),
+81 -36
View File
@@ -5,8 +5,16 @@ from typing import Iterator
import requests
from django.conf import settings
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):
@@ -18,28 +26,44 @@ class UtbmApiClient(requests.Session):
@cached_property
def current_year(self) -> int:
"""Fetch from the API the latest existing year"""
url = f"{self.BASE_URL}/guides/fr"
response = self.get(url)
return response.json()[-1]["annee"]
url = f"{self.BASE_URL}/guide/"
response = self.get(url, {"langue": "fr"})
return max(i["annee"] for i in response.json())
def fetch_short_ues(
self, lang: str = "fr", year: int | None = None
) -> list[UtbmShortUeSchema]:
@cached_property
def formations(self) -> list[FormationSchema]:
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"""
if year is None:
year = self.current_year
if lang not in self._cache["short_ues"]:
self._cache["short_ues"][lang] = {}
if year not in self._cache["short_ues"][lang]:
url = f"{self.BASE_URL}/uvs/{lang}/{year}"
response = self.get(url)
ues = ShortUeList.validate_json(response.content)
self._cache["short_ues"][lang][year] = ues
return self._cache["short_ues"][lang][year]
if year not in self._cache["short_ues"]:
ues = []
for formation in self.formations:
response = self.get(
f"{self.BASE_URL}/ue/",
{
"langue": "fr",
"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(
self, lang: str = "fr", year: int | None = None
) -> Iterator[UeSchema]:
def fetch_ues(self, year: int | None = None) -> Iterator[UeSchema]:
"""Fetch all UEs from the UTBM API, parsed in a format that we can use.
Warning:
@@ -52,7 +76,7 @@ class UtbmApiClient(requests.Session):
"""
if year is None:
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)
# the UTBM API duplicates them for every branch.
# We have no way in our db to link a UE to multiple formations,
@@ -65,27 +89,40 @@ class UtbmApiClient(requests.Session):
if ue.code not in unique_short_ues:
unique_short_ues[ue.code] = ue
for ue in unique_short_ues.values():
ue_url = f"{self.BASE_URL}/uv/{lang}/{year}/{ue.code}/{ue.code_formation}"
response = requests.get(ue_url)
if ue.lang.upper() != "FR":
continue
response = requests.get(
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)
yield make_clean_ue(ue, full_ue)
def find_uu(self, lang: str, code: str, year: int | None = None) -> UeSchema | None:
"""Find an UE from the UTBM API."""
# query the UE list
def find_ue(self, code: str, year: int | None = None) -> UeSchema | None:
"""Find a UE from the UTBM API."""
if not year:
year = self.current_year
# the UTBM API has no way to fetch a single short ue,
# and short ues contain infos that we need and are not
# 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)
if short_ue is None:
return None
# get detailed information about the UE
ue_url = f"{self.BASE_URL}/uv/{lang}/{year}/{code}/{short_ue.code_formation}"
response = requests.get(ue_url)
response = requests.get(
f"{self.BASE_URL}/ue/{code}",
{
"langue": "fr",
"annee_univ": year,
"codeFormation": short_ue.formation.code,
},
)
full_ue = UtbmFullUeSchema.model_validate_json(response.content)
return make_clean_ue(short_ue, full_ue)
@@ -112,9 +149,9 @@ def make_clean_ue(short_ue: UtbmShortUeSchema, full_ue: UtbmFullUeSchema) -> UeS
"ED": "EDIM",
"AI": "GI",
"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:
semester = "AUTUMN_AND_SPRING"
case True, False:
@@ -125,11 +162,11 @@ def make_clean_ue(short_ue: UtbmShortUeSchema, full_ue: UtbmFullUeSchema) -> UeS
semester = "CLOSED"
return UeSchema(
title=full_ue.libelle or "",
title=full_ue.label or "",
code=full_ue.code,
credit_type=short_ue.code_categorie or "FREE",
credit_type=short_ue.category or "FREE",
semester=semester,
language=short_ue.code_langue.upper(),
language=short_ue.lang.upper(),
credits=full_ue.credits_ects,
department=department,
hours_THE=next((i.nbh for i in full_ue.activites if i.code == "THE"), 0) // 60,
@@ -138,8 +175,16 @@ 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_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 "",
objectives=full_ue.objectifs or "",
program=full_ue.programme or "",
skills=full_ue.acquisition_competences or "",
key_concepts=full_ue.acquisition_notions or "",
objectives=next(
(i.value for i in full_ue.syllabus if i.label == "Objectifs"), ""
),
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 == "Acquis d'apprentissage"),
"",
),
key_concepts=next(
(i.value for i in full_ue.syllabus if i.label == "Notions-clés"),
"",
),
)
+112 -33
View File
@@ -24,11 +24,13 @@
from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Exists, OuterRef
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from django.urls import reverse, reverse_lazy
from django.views.generic import (
CreateView,
DeleteView,
DetailView,
FormView,
TemplateView,
UpdateView,
@@ -36,7 +38,7 @@ from django.views.generic import (
from core.auth.mixins import PermissionOrAuthorRequiredMixin
from core.models import Notification, User
from core.views import DetailFormView
from core.views.mixins import AllowFragment, FragmentMixin, UseFragmentsMixin
from pedagogy.forms import (
UECommentForm,
UECommentModerationForm,
@@ -46,61 +48,120 @@ from pedagogy.forms import (
from pedagogy.models import UE, UEComment, UECommentReport
class UEDetailFormView(PermissionRequiredMixin, DetailFormView):
"""Display every comment of an UE and detailed infos about it.
Allow to comment the UE.
"""
model = UE
pk_url_kwarg = "ue_id"
template_name = "pedagogy/ue_detail.jinja"
class UECommentCreateView(PermissionRequiredMixin, FragmentMixin, CreateView):
model = UEComment
template_name = "pedagogy/fragments/ue_comment_form.jinja"
form_class = UECommentForm
permission_required = "pedagogy.view_ue"
permission_required = "pedagogy.add_uecomment"
object = None # Avoid initialisation bug with FragmentMixin
@property
def ue(self):
if hasattr(self, "_ue"):
return self._ue
self._ue = get_object_or_404(UE, id=self.kwargs.get("ue_id"))
return self._ue
def has_permission(self):
if self.request.method == "POST" and not self.request.user.has_perm(
"pedagogy.add_uecomment"
):
# if it's a POST request, the user is trying to add a new UEComment
# thus he also needs the "add_uecomment" permission
if self.ue.has_user_already_commented(self.request.user):
return False
return super().has_permission()
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["author_id"] = self.request.user.id
kwargs["ue_id"] = self.object.id
kwargs["ue_id"] = self.ue.id
kwargs["is_creation"] = True
return kwargs
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"action": reverse("pedagogy:comment_create", kwargs={"ue_id": self.ue.id})
}
def form_valid(self, form):
form.save()
return super().form_valid(form)
"""If the form is valid, save the associated model."""
self.object = form.save()
response = HttpResponse(status=200)
response.headers["HX-Trigger"] = "NewComment, CommentUpdate"
return response
class UEDetailCommentsView(
PermissionRequiredMixin,
FragmentMixin,
DetailView,
):
"""Fragment view that display all comments"""
model = UE
pk_url_kwarg = "ue_id"
template_name = "pedagogy/fragments/ue_comments.jinja"
permission_required = "pedagogy.view_ue"
def get_context_data(self, **kwargs):
self.object = self.get_object() # Needed if loaded with .as_fragment()
return super().get_context_data(**kwargs) | {
"comments": list(
self.object.comments.viewable_by(self.request.user)
.annotate_is_reported()
.select_related("author")
.order_by("-publish_date")
)
),
}
def get_success_url(self):
# once the new ue comment has been saved
# redirect to the same page we are currently
return self.request.path
class UEDetailView(
PermissionRequiredMixin, UseFragmentsMixin, AllowFragment, DetailView
):
"""Display every comment of an UE and detailed infos about it."""
model = UE
pk_url_kwarg = "ue_id"
template_name = "pedagogy/ue_detail.jinja"
permission_required = "pedagogy.view_ue"
fragments = {
"add_comment_form": UECommentCreateView,
"comments": UEDetailCommentsView,
}
def get_fragment_data(self):
return {
"add_comment_form": {"ue_id": self.object.id},
"comments": {"ue_id": self.object.id},
}
class UECommentUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
class UECommentDetailView(PermissionRequiredMixin, DetailView):
model = UEComment
pk_url_kwarg = "comment_id"
template_name = "pedagogy/fragments/ue_comment.jinja"
permission_required = "pedagogy.view_ue"
context_object_name = "comment"
def get_queryset(self):
return (
super().get_queryset().viewable_by(self.request.user).annotate_is_reported()
)
def dispatch(self, *args, **kwargs):
res: HttpResponse = super().dispatch(*args, **kwargs)
res.headers["HX-Trigger"] = "CommentUpdate"
return res
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"ue": getattr(self.object, "ue", None)
}
class UECommentUpdateView(PermissionOrAuthorRequiredMixin, AllowFragment, UpdateView):
"""Allow edit of a given comment."""
model = UEComment
form_class = UECommentForm
pk_url_kwarg = "comment_id"
template_name = "core/edit.jinja"
template_name = "pedagogy/fragments/ue_comment_form.jinja"
permission_required = "pedagogy.change_uecomment"
author_field = "author"
@@ -111,11 +172,18 @@ class UECommentUpdateView(PermissionOrAuthorRequiredMixin, UpdateView):
kwargs["is_creation"] = False
return kwargs
def get_context_data(self, **kwargs):
return super().get_context_data(**kwargs) | {
"action": reverse(
"pedagogy:comment_update", kwargs={"comment_id": self.object.id}
)
}
def get_success_url(self):
return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.object.ue_id})
return reverse("pedagogy:comment_detail", kwargs={"comment_id": self.object.id})
class UECommentDeleteView(PermissionOrAuthorRequiredMixin, DeleteView):
class UECommentDeleteView(PermissionOrAuthorRequiredMixin, AllowFragment, DeleteView):
"""Allow to delete a given comment."""
model = UEComment
@@ -124,8 +192,13 @@ class UECommentDeleteView(PermissionOrAuthorRequiredMixin, DeleteView):
permission_required = "pedagogy.delete_uecomment"
author_field = "author"
def form_valid(self, form):
response = super().form_valid(form)
response.headers["HX-Trigger"] = "CommentUpdate"
return response
def get_success_url(self):
return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.object.ue_id})
return reverse("pedagogy:comment_detail", kwargs={"comment_id": self.object.id})
class UEGuideView(PermissionRequiredMixin, TemplateView):
@@ -135,12 +208,12 @@ class UEGuideView(PermissionRequiredMixin, TemplateView):
permission_required = "pedagogy.view_ue"
class UECommentReportCreateView(PermissionRequiredMixin, CreateView):
class UECommentReportCreateView(PermissionRequiredMixin, AllowFragment, CreateView):
"""Create a new report for an inappropriate comment."""
model = UECommentReport
form_class = UECommentReportForm
template_name = "core/edit.jinja"
template_name = "pedagogy/fragments/comment_report.jinja"
permission_required = "pedagogy.add_uecommentreport"
def dispatch(self, request, *args, **kwargs):
@@ -153,6 +226,11 @@ class UECommentReportCreateView(PermissionRequiredMixin, CreateView):
kwargs["comment_id"] = self.ue_comment.id
return kwargs
def get_context_data(self, **kwargs):
return super().get_context_data() | {
"comment_id": self.ue_comment.id,
}
def form_valid(self, form):
resp = super().form_valid(form)
# Send a message to moderation admins
@@ -168,11 +246,12 @@ class UECommentReportCreateView(PermissionRequiredMixin, CreateView):
url=reverse("pedagogy:moderation"),
type="PEDAGOGY_MODERATION",
)
return resp
def get_success_url(self):
return reverse("pedagogy:ue_detail", kwargs={"ue_id": self.ue_comment.ue_id})
return reverse(
"pedagogy:comment_detail", kwargs={"comment_id": self.ue_comment.id}
)
class UEModerationFormView(PermissionRequiredMixin, FormView):
+21 -21
View File
@@ -19,23 +19,23 @@ authors = [
license = { text = "GPL-3.0-only" }
requires-python = "<4.0,>=3.12"
dependencies = [
"django>=5.2.15,<6.0.0",
"django-ninja>=1.6.2,<2.0.0",
"django-ninja-extra>=0.31.5",
"Pillow>=12.2.0,<13.0.0",
"django>=5.2.17,<6.0.0",
"django-ninja>=1.6.3,<2.0.0",
"django-ninja-extra>=0.31.7",
"Pillow>=12.3.0,<13.0.0",
"aemark>=0.1.1",
"django-jinja<3.0.0,>=2.11.0",
"cryptography>=49.0.0,<50.0.0",
"django-phonenumber-field>=8.4.0,<9.0.0",
"phonenumbers>=9.0.33,<10.0.0",
"reportlab>=5.0.0,<6.0.0",
"cryptography>=50.0.0,<51.0.0",
"django-phonenumber-field>=8.5.0,<9.0.0",
"phonenumbers>=9.0.37,<10.0.0",
"reportlab>=5.0.1,<6.0.0",
"django-haystack>=3.4.0,<4.0.0",
"xapian-haystack>=4.0.0,<5.0.0",
"libsass>=0.23.0,<1.0.0",
"django-ordered-model>=3.7.4,<4.0.0",
"django-simple-captcha>=0.6.3,<1.0.0",
"django-simple-captcha>=0.7.0,<1.0.0",
"python-dateutil>=2.9.0.post0,<3.0.0.0",
"sentry-sdk>=2.64.0,<3.0.0",
"sentry-sdk>=2.68.0,<3.0.0",
"jinja2>=3.1.6,<4.0.0",
"django-countries>=9.0.0,<10.0.0",
"dict2xml>=1.7.8,<2.0.0",
@@ -43,9 +43,9 @@ dependencies = [
"tomli>=2.4.1,<3.0.0",
"django-honeypot>=1.3.0,<2",
"pydantic-extra-types>=2.11.1,<3.0.0",
"ical>=12.0.0,<14.0.0",
"ical>=12.0.0",
"redis[hiredis]>=6.4.0,<9.0.0",
"environs[django]>=15.0.1,<16",
"environs[django]>=15.1.0,<16",
"requests>=2.34.2,<3.0.0",
"honcho>=2.0.0",
"psutil>=7.2.2,<8.0.0",
@@ -63,28 +63,28 @@ prod = [
"psycopg[c]>=3.3.4,<4.0.0",
]
dev = [
"django-debug-toolbar>=7.0.0,<8",
"ipython>=9.14.1,<10.0.0",
"django-debug-toolbar>=7.1.1,<8",
"ipython>=9.16.1,<10.0.0",
"pre-commit>=4.6.0,<5.0.0",
"ruff>=0.15.19,<1.0.0",
"ruff>=0.16.0,<1.0.0",
"djhtml>=3.0.11,<4.0.0",
"faker>=40.23.0,<41.0.0",
"faker>=40.37.0,<41.0.0",
"rjsmin>=1.2.5,<2.0.0",
]
tests = [
"freezegun>=1.5.5,<2.0.0",
"pytest>=9.1.1,<10.0.0",
"pytest-cov>=7.1.0,<8.0.0",
"pytest-django>=4.12.0,<5.0.0",
"pytest-django>=4.14.0,<5.0.0",
"model-bakery>=1.24.0,<2.0.0",
"beautifulsoup4>=4.15.0,<5",
"lxml>=6.1.1,<7",
"lxml>=6.1.2,<7",
]
docs = [
"mkdocs>=1.6.1,<2.0.0",
"mkdocs-material>=9.7.6,<10.0.0",
"mkdocstrings>=1.0.4,<2.0.0",
"mkdocstrings-python>=2.0.4,<3.0.0",
"mkdocs-material>=9.7.7,<10.0.0",
"mkdocstrings>=1.0.6,<2.0.0",
"mkdocstrings-python>=2.0.7,<3.0.0",
"mkdocs-include-markdown-plugin>=7.3.0,<8.0.0",
]
+3 -3
View File
@@ -1,4 +1,4 @@
from typing import Any, Literal
from typing import Literal
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -20,7 +20,7 @@ from api.permissions import (
IsRoot,
)
from core.models import Notification, User
from core.schemas import UploadedImage
from core.schemas import UploadedImage, ValidationErrorSchema
from sas.models import Album, PeoplePictureRelation, Picture
from sas.schemas import (
AlbumAutocompleteSchema,
@@ -106,7 +106,7 @@ class PicturesController(ControllerBase):
response={
200: None,
409: dict[Literal["detail"], dict[str, list[str]]],
422: dict[Literal["detail"], list[dict[str, Any]]],
422: ValidationErrorSchema,
},
url_name="upload_picture",
)
@@ -1,7 +1,7 @@
import type { TomOption } from "tom-select/dist/types/types";
import type { escape_html } from "tom-select/dist/types/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base.ts";
import { registerComponent } from "#core:utils/web-components.ts";
import type { TomOption } from "tom-select/src/types";
import type { escape_html } from "tom-select/src/utils";
import { AjaxSelect } from "#core:core/components/ajax-select-base";
import { registerComponent } from "#core:utils/web-components";
import { type AlbumAutocompleteSchema, albumAutocompleteAlbum } from "#openapi";
@registerComponent("album-ajax-select")
@@ -9,7 +9,7 @@ document.addEventListener("alpine:init", () => {
async downloadZip() {
this.isDownloading = true;
const bar = this.$refs.progress;
const bar = this.$refs.progress as HTMLProgressElement;
bar.value = 0;
bar.max = this.downloadPictures.length;
@@ -31,10 +31,11 @@ document.addEventListener("alpine:init", () => {
await Promise.all(
this.downloadPictures.map((p: PictureSchema) => {
const imgName = `${p.album.name}/IMG_${p.id}_${p.date.replace(/[:-]/g, "_")}${p.name.slice(p.name.lastIndexOf("."))}`;
const dateStr = (p.date as string).replace(/[:-]/g, "_");
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), {
level: 9,
lastModDate: new Date(p.date),
lastModDate: new Date(p.date as string),
onstart: incrementProgressBar,
});
}),
@@ -57,14 +57,16 @@ document.addEventListener("alpine:init", () => {
async init() {
const pictures = await this.fetchPictures();
const groupedAlbums = Object.groupBy(pictures, (i: PictureSchema) => i.album.id);
this.albums = Object.values(groupedAlbums).map((pictures: PictureSchema[]) => {
const groupedAlbums = Object.groupBy(pictures, (i) => i.album.id as number);
this.albums = Object.values(groupedAlbums as Record<number, PictureSchema[]>).map(
(pictures) => {
return {
id: pictures[0].album.id,
id: pictures[0].album.id as number,
name: pictures[0].album.name,
pictures: pictures,
};
});
},
);
this.albums.sort((a: Album, b: Album) => b.id - a.id);
const hash = document.location.hash.replace("#", "");
if (hash.startsWith("album-")) {
+63 -28
View File
@@ -1,4 +1,5 @@
import type TomSelect from "tom-select";
import type { TomOption } from "tom-select/src/types";
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
import { paginated } from "#core:utils/api";
import { History } from "#core:utils/history";
@@ -6,7 +7,6 @@ import {
type IdentifiedUserSchema,
type ModerationRequestSchema,
type PictureSchema,
type PicturesFetchIdentificationsResponse,
type PicturesFetchPicturesData,
picturesDeletePicture,
picturesFetchIdentifications,
@@ -15,6 +15,7 @@ import {
picturesIdentifyUsers,
picturesModeratePicture,
picturesRotatePicture,
type SimpleAlbumSchema,
type UserProfileSchema,
usersidentifiedDeleteRelation,
} from "#openapi";
@@ -23,8 +24,8 @@ import {
* A container for a picture with the users identified on it
* able to prefetch its data.
*/
class PictureWithIdentifications {
identifications: PicturesFetchIdentificationsResponse = null;
class PictureWithIdentifications implements PictureSchema {
identifications?: IdentifiedUserSchema[];
imageLoading = false;
identificationsLoading = false;
moderationLoading = false;
@@ -32,10 +33,43 @@ class PictureWithIdentifications {
compressedUrl: string = "";
thumbUrl: string = "";
fullSizeUrl: string = "";
moderationRequests: ModerationRequestSchema[] = null;
moderationRequests: ModerationRequestSchema[] = [];
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) {
Object.assign(this, picture);
if (picture.id === null || picture.id === undefined) {
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.thumbUrl = picture.thumb_url;
this.fullSizeUrl = picture.full_size_url;
@@ -69,12 +103,13 @@ class PictureWithIdentifications {
return;
}
this.identificationsLoading = true;
this.identifications = (
this.identifications =
(
await picturesFetchIdentifications({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.id },
})
).data;
).data ?? [];
this.identificationsLoading = false;
}
@@ -88,12 +123,13 @@ class PictureWithIdentifications {
return;
}
this.moderationLoading = true;
this.moderationRequests = (
this.moderationRequests =
(
await picturesFetchModerationRequests({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.id },
})
).data;
).data ?? [];
this.moderationLoading = false;
}
@@ -103,6 +139,10 @@ class PictureWithIdentifications {
// biome-ignore lint/style/useNamingConvention: api is snake case
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
this.fullSizeUrl = res.data.full_size_url;
this.compressedUrl = res.data.compressed_url;
@@ -157,7 +197,7 @@ document.addEventListener("alpine:init", () => {
currentPicture: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
is_moderated: true,
id: null as number,
id: null as number | null,
name: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
display_name: "",
@@ -171,19 +211,19 @@ document.addEventListener("alpine:init", () => {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
created_at: new Date(),
identifications: [] as IdentifiedUserSchema[],
},
} as unknown as PictureWithIdentifications,
/**
* The picture which will be displayed next if the user press the "next" button
**/
nextPicture: null as PictureWithIdentifications,
nextPicture: null as PictureWithIdentifications | null,
/**
* The picture which will be displayed next if the user press the "previous" button
**/
previousPicture: null as PictureWithIdentifications,
previousPicture: null as PictureWithIdentifications | null,
/**
* The select2 component used to identify users
**/
selector: undefined as UserAjaxSelect,
selector: undefined as UserAjaxSelect | undefined,
/**
* Error message when a moderation operation fails
**/
@@ -201,24 +241,18 @@ document.addEventListener("alpine:init", () => {
query: { album_id: config.albumId },
} as PicturesFetchPicturesData)
).map(PictureWithIdentifications.fromPicture);
this.selector = this.$refs.search;
this.selector.setFilter((users: UserProfileSchema[]) => {
const resp: UserProfileSchema[] = [];
this.selector = this.$refs.search as UserAjaxSelect;
this.selector.setFilter((users: TomOption[]) => {
const ids = [
...(this.currentPicture.identifications || []).map(
(i: IdentifiedUserSchema) => i.user.id,
),
];
for (const user of users) {
if (!ids.includes(user.id)) {
resp.push(user);
}
}
return resp;
return users.filter((user) => !ids.includes(user.id));
});
this.currentPicture = this.pictures.find(
(i: PictureSchema) => i.id === config.firstPictureId,
);
(i) => i.id === config.firstPictureId,
) as PictureWithIdentifications;
this.$watch(
"currentPicture",
(current: PictureSchema, previous: PictureSchema) => {
@@ -236,7 +270,7 @@ document.addEventListener("alpine:init", () => {
this.pushstate = History.Replace;
this.currentPicture = this.pictures.find(
(i: PictureSchema) => i.id === Number.parseInt(event.state.sasPictureId, 10),
);
) as PictureWithIdentifications;
});
this.pushstate = History.Replace; /* Avoid first url push */
await this.updatePicture();
@@ -311,14 +345,15 @@ document.addEventListener("alpine:init", () => {
// As the album is now empty, go back to the parent page
document.location.href = config.albumUrl;
}
this.currentPicture = this.nextPicture || this.previousPicture;
this.currentPicture = (this.nextPicture ||
this.previousPicture) as PictureWithIdentifications;
},
/**
* Send the identification request and update the list of identified users.
*/
async submitIdentification(): Promise<void> {
const widget: TomSelect = this.selector.widget;
const widget: TomSelect = (this.selector as UserAjaxSelect).widget;
await picturesIdentifyUsers({
// biome-ignore lint/style/useNamingConvention: api is in snake_case
path: { picture_id: this.currentPicture.id },
+3
View File
@@ -48,6 +48,9 @@
height: 100%;
max-width: 100%;
object-fit: contain;
// Prevent ugly selection when
// clicking to change the image
user-select: none;
}
}
}
+11 -7
View File
@@ -82,7 +82,11 @@
<div class="container" id="pict">
<div class="main">
<div class="photo" :aria-busy="currentPicture.imageLoading">
<div
class="photo"
:aria-busy="currentPicture.imageLoading"
@click="currentPicture = (($event.offsetX > $event.target.getBoundingClientRect().width / 2) ? nextPicture : previousPicture) ?? currentPicture"
>
<img
:src="currentPicture.compressedUrl"
:alt="currentPicture.name"
@@ -95,13 +99,13 @@
<div class="infos">
<h5>{% trans %}Infos{% endtrans %}</h5>
<div>
<div>
<span>{% trans %}Date: {% endtrans %}</span>
<span
x-text="Intl.DateTimeFormat(
'{{ LANGUAGE_CODE }}', {dateStyle: 'long'}
).format(Date.parse(currentPicture.date))"
<div
x-data="{formatter: Intl.DateTimeFormat('{{ LANGUAGE_CODE }}', {dateStyle: 'long'})}"
>
<span>{% trans %}Date: {% endtrans %}</span>
{# The `currentPicture.date || 0` part is to avoid having
a console error while the picture date is not yet fetched #}
<span x-text="formatter.format(new Date(currentPicture.date || 0))">
</span>
</div>
<div>
+1 -1
View File
@@ -493,7 +493,7 @@ SITH_LOG_OPERATION_TYPE = [
("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
SITH_COUNTER_CASH_SUMMARY_LENGTH = 50
@@ -1,10 +1,11 @@
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
import { userFetchUser } from "#openapi";
Alpine.data("existing_user_subscription_form", () => ({
loading: false,
selectedUser: "",
profileFragment: "",
dateOfBirth: "",
dateOfBirth: "" as string | null,
dateOfBirthHidden: true,
init() {
@@ -14,7 +15,9 @@ Alpine.data("existing_user_subscription_form", () => ({
this.$nextTick(() => {
// Force to detect the initial value
this.selectedUser = this.$refs.userSelect.widget.getValue();
this.selectedUser = (
this.$refs.userSelect as UserAjaxSelect
).widget.getValue() as string;
});
},
@@ -31,11 +34,15 @@ Alpine.data("existing_user_subscription_form", () => ({
// biome-ignore lint/style/useNamingConvention: api is snake_case
userFetchUser({ path: { user_id: userId } }),
]);
if (userInfos.data === undefined) {
console.error("User infos request failed");
return;
}
this.profileFragment = await miniProfile.text();
// If the user has no birthdate yet, show the form input
// to fill this info.
// Else keep the input hidden and change its value to the user birthdate
this.dateOfBirth = userInfos.data.date_of_birth;
this.dateOfBirth = userInfos.data.date_of_birth ?? null;
this.dateOfBirthHidden = userInfos.data.date_of_birth !== null;
this.loading = false;
},
@@ -17,7 +17,10 @@ function getRandomColorUniq(list: string[]) {
}
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")
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
const hexrgb = hex.replace(shorthandRegex, (_m, r, g, b) => {
@@ -37,32 +40,29 @@ function hexToRgb(hex: string) {
document.addEventListener("DOMContentLoaded", () => {
const ctx = (document.getElementById("statsChart") as HTMLCanvasElement).getContext(
"2d",
);
) as CanvasRenderingContext2D;
const labels: string[] = [];
const total: string[] = [];
const colors: string[] = [];
const colorStrings: string[] = [];
const colorsDimmed: string[] = [];
for (const element of Array.from(document.getElementsByClassName("types"))) {
labels.push(element.childNodes[0].textContent);
labels.push(element.childNodes[0].textContent as string);
}
for (const element of Array.from(document.getElementsByClassName("total"))) {
total.push(element.childNodes[0].childNodes[0].textContent);
total.push(element.childNodes[0].childNodes[0].textContent as string);
}
for (const _ of labels) {
colors.push(getRandomColorUniq(colors));
colorStrings.push(getRandomColorUniq(colorStrings));
}
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 element of colors) {
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)`,
);
for (const color of colors) {
colorsDimmed.push(`rgba(${color.r}, ${color.g}, ${color.b}, 0.2)`);
}
new Chart(ctx, {
@@ -71,10 +71,11 @@ document.addEventListener("DOMContentLoaded", () => {
labels: labels,
datasets: [
{
label: document.getElementById("graphLabel").childNodes[0].textContent,
label: document.getElementById("graphLabel")?.childNodes[0]
.textContent as string,
data: total,
backgroundColor: colorsDimmed,
borderColor: colors,
borderColor: colorStrings,
borderWidth: 1,
},
],
@@ -58,7 +58,7 @@ function parseSlots(s: string): TimetableSlot[] {
.filter((s: string) => s.length > 0)
.map((row: string) => {
const parsed = TIMETABLE_ROW_RE.exec(row);
if (!parsed) {
if (!parsed?.groups) {
throw new Error(`Couldn't parse row ${row}`);
}
const [startHour, startMin] = parsed.groups.startHour
@@ -173,7 +173,7 @@ document.addEventListener("alpine:init", () => {
async savePng() {
const elem = document.getElementById("timetable");
const img = (await html2canvas(elem)).toDataURL();
const img = (await html2canvas(elem as HTMLElement)).toDataURL();
const downloadLink = document.createElement("a");
downloadLink.href = img;
downloadLink.download = "edt.png";
+27
View File
@@ -0,0 +1,27 @@
{
"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/*"]
}
}
}
+4 -22
View File
@@ -1,24 +1,6 @@
{
"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,
"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/*"]
}
}
"references": [
{"path": "tsconfig.bundled.json"},
{"path": "tsconfig.node.json"}
],
}
+10
View File
@@ -0,0 +1,10 @@
{
"include": ["vite.config.*"],
"compilerOptions": {
"module": "preserve",
"moduleResolution": "bundler",
"types": ["node"],
"emitDeclarationOnly": true,
"composite": true
}
}
Generated
+752 -640
View File
File diff suppressed because it is too large Load Diff
+5 -7
View File
@@ -8,9 +8,9 @@ import {
type Rollup,
type UserConfig,
} from "vite";
import tsconfig from "./tsconfig.json";
import { compilerOptions } from "./tsconfig.bundled.json" with { type: "json" };
const outDir = resolve(__dirname, "./staticfiles/generated/bundled");
const outDir = resolve(import.meta.dirname, "./staticfiles/generated/bundled");
const collectedFiles = glob.sync(
"./!(static)/static/bundled/**/*?(-)index.?(m)[j|t]s?(x)",
);
@@ -20,8 +20,8 @@ const collectedFiles = glob.sync(
**/
function getAliases(): AliasOptions {
const aliases: AliasOptions = {};
for (const [key, value] of Object.entries(tsconfig.compilerOptions.paths)) {
aliases[key] = resolve(__dirname, value[0]);
for (const [key, value] of Object.entries(compilerOptions.paths)) {
aliases[key] = resolve(import.meta.dirname, value[0]);
}
return aliases;
}
@@ -59,7 +59,7 @@ export default defineConfig((config: UserConfig) => {
// Mirror architecture of static folders in generated .js and .css
entryFileNames: (chunkInfo: Rollup.PreRenderedChunk) => {
if (chunkInfo.facadeModuleId !== null) {
return `${getRelativeAssetPath(chunkInfo.facadeModuleId)}.[hash].js`;
return `${getRelativeAssetPath(chunkInfo.facadeModuleId as string)}.[hash].js`;
}
return "[name].[hash].js";
},
@@ -80,8 +80,6 @@ export default defineConfig((config: UserConfig) => {
resolve: {
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],
} satisfies UserConfig;
});