23 Commits

Author SHA1 Message Date
c5193243a5 docs: AI guideline 2025-06-26 20:40:39 +02:00
eb4fbcbda4 Merge pull request #1140 from Juknum/feature/update-footer-on-mobile
Màj du footer sur mobile
2025-06-26 16:01:20 +02:00
570510f18d Merge pull request #1135 from ae-utbm/group
Small group tweak
2025-06-25 22:04:56 +02:00
7f371984d8 Merge pull request #1143 from ae-utbm/fix/mail-enumeration
fix: enumeration attack vector on login form
2025-06-25 17:53:53 +02:00
abf7bf6bfa rename location_admin to campus_admin 2025-06-25 17:13:24 +02:00
02ef8fdb88 fix: enumeration attack vector on login form 2025-06-25 17:03:53 +02:00
a7f4630d13 Merge pull request #1138 from ae-utbm/counter-admin
improve counter admin pages
2025-06-25 17:03:03 +02:00
c7087c6e7e Merge pull request #1137 from ae-utbm/fix-user-pictures
fix: user pictures ordering
2025-06-25 16:40:23 +02:00
f38926c4a3 fix: user pictures ordering 2025-06-25 16:25:51 +02:00
9a19f34ea2 Merge pull request #1141 from ae-utbm/fix-permanences
Fix permanences
2025-06-25 14:55:36 +02:00
67884017f8 fix old permanences having end replaced by activity 2025-06-25 01:22:13 +02:00
Sli
f474edc84f Style adjustment on the new footer 2025-06-24 17:04:52 +02:00
f5a8228358 Rework footer's UX on small devices 2025-06-22 20:01:22 +02:00
59a714af9f Merge pull request #1134 from ae-utbm/family
Add zoom controls to family graph
2025-06-21 15:20:47 +02:00
9049d8779c improve counter admin pages 2025-06-21 15:06:08 +02:00
Sli
d111023363 Apply review comments 2025-06-21 12:37:01 +02:00
cdfa76ad57 add missing "Respo site" group 2025-06-18 18:01:37 +02:00
88b70bf51f rename main groups to their real production version 2025-06-18 18:01:37 +02:00
Sli
ca593c7d81 Avoid click on graph when zooming 2025-06-18 16:24:53 +02:00
Sli
94bdc5e615 Remove useless closures 2025-06-18 14:13:06 +02:00
Sli
7d454749e0 Add style to zoom controls on family graph 2025-06-18 14:10:26 +02:00
Sli
10d5b9d63f Add zoom control of family graph 2025-06-18 12:22:30 +02:00
Sli
cc96c93d23 Convert family tree to typescript 2025-06-18 11:59:46 +02:00
28 changed files with 786 additions and 456 deletions

View File

@ -59,6 +59,7 @@ class PopulatedGroups(NamedTuple):
counter_admin: Group counter_admin: Group
accounting_admin: Group accounting_admin: Group
pedagogy_admin: Group pedagogy_admin: Group
campus_admin: Group
class Command(BaseCommand): class Command(BaseCommand):
@ -784,13 +785,13 @@ class Command(BaseCommand):
# public has no permission. # public has no permission.
# Its purpose is not to link users to permissions, # Its purpose is not to link users to permissions,
# but to other objects (like products) # but to other objects (like products)
public_group = Group.objects.create(name="Public") public_group = Group.objects.create(name="Publique")
subscribers = Group.objects.create(name="Subscribers") subscribers = Group.objects.create(name="Cotisants")
subscribers.permissions.add( subscribers.permissions.add(
*list(perms.filter(codename__in=["add_news", "add_uvcomment"])) *list(perms.filter(codename__in=["add_news", "add_uvcomment"]))
) )
old_subscribers = Group.objects.create(name="Old subscribers") old_subscribers = Group.objects.create(name="Anciens cotisants")
old_subscribers.permissions.add( old_subscribers.permissions.add(
*list( *list(
perms.filter( perms.filter(
@ -812,7 +813,7 @@ class Command(BaseCommand):
) )
) )
accounting_admin = Group.objects.create( accounting_admin = Group.objects.create(
name="Accounting admin", is_manually_manageable=True name="Admin comptabilité", is_manually_manageable=True
) )
accounting_admin.permissions.add( accounting_admin.permissions.add(
*list( *list(
@ -833,7 +834,7 @@ class Command(BaseCommand):
) )
) )
com_admin = Group.objects.create( com_admin = Group.objects.create(
name="Communication admin", is_manually_manageable=True name="Admin communication", is_manually_manageable=True
) )
com_admin.permissions.add( com_admin.permissions.add(
*list( *list(
@ -841,7 +842,7 @@ class Command(BaseCommand):
) )
) )
counter_admin = Group.objects.create( counter_admin = Group.objects.create(
name="Counter admin", is_manually_manageable=True name="Admin comptoirs", is_manually_manageable=True
) )
counter_admin.permissions.add( counter_admin.permissions.add(
*list( *list(
@ -851,14 +852,14 @@ class Command(BaseCommand):
) )
) )
) )
sas_admin = Group.objects.create(name="SAS admin", is_manually_manageable=True) sas_admin = Group.objects.create(name="Admin SAS", is_manually_manageable=True)
sas_admin.permissions.add( sas_admin.permissions.add(
*list( *list(
perms.filter(content_type__app_label="sas").values_list("pk", flat=True) perms.filter(content_type__app_label="sas").values_list("pk", flat=True)
) )
) )
forum_admin = Group.objects.create( forum_admin = Group.objects.create(
name="Forum admin", is_manually_manageable=True name="Admin forum", is_manually_manageable=True
) )
forum_admin.permissions.add( forum_admin.permissions.add(
*list( *list(
@ -868,7 +869,7 @@ class Command(BaseCommand):
) )
) )
pedagogy_admin = Group.objects.create( pedagogy_admin = Group.objects.create(
name="Pedagogy admin", is_manually_manageable=True name="Admin pédagogie", is_manually_manageable=True
) )
pedagogy_admin.permissions.add( pedagogy_admin.permissions.add(
*list( *list(
@ -877,6 +878,16 @@ class Command(BaseCommand):
.values_list("pk", flat=True) .values_list("pk", flat=True)
) )
) )
campus_admin = Group.objects.create(
name="Respo site", is_manually_manageable=True
)
campus_admin.permissions.add(
*counter_admin.permissions.values_list("pk", flat=True),
*perms.filter(content_type__app_label="reservation").values_list(
"pk", flat=True
),
)
self.reset_index("core", "auth") self.reset_index("core", "auth")
return PopulatedGroups( return PopulatedGroups(
@ -889,6 +900,7 @@ class Command(BaseCommand):
accounting_admin=accounting_admin, accounting_admin=accounting_admin,
sas_admin=sas_admin, sas_admin=sas_admin,
pedagogy_admin=pedagogy_admin, pedagogy_admin=pedagogy_admin,
campus_admin=campus_admin,
) )
def _create_ban_groups(self): def _create_ban_groups(self):

View File

@ -238,7 +238,13 @@ class Command(BaseCommand):
ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID) ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID)
other_clubs = random.sample(list(Club.objects.all()), k=3) other_clubs = random.sample(list(Club.objects.all()), k=3)
groups = list( groups = list(
Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"]) Group.objects.filter(
id__in=[
settings.SITH_GROUP_SUBSCRIBERS_ID,
settings.SITH_GROUP_OLD_SUBSCRIBERS_ID,
settings.SITH_GROUP_PUBLIC_ID,
]
)
) )
counters = list( counters = list(
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"]) Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"])

View File

@ -1,274 +0,0 @@
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import cytoscape from "cytoscape";
import cxtmenu from "cytoscape-cxtmenu";
import klay from "cytoscape-klay";
import { familyGetFamilyGraph } from "#openapi";
cytoscape.use(klay);
cytoscape.use(cxtmenu);
async function getGraphData(userId, godfathersDepth, godchildrenDepth) {
const data = (
await familyGetFamilyGraph({
path: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
user_id: userId,
},
query: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
godfathers_depth: godfathersDepth,
// biome-ignore lint/style/useNamingConvention: api is snake_case
godchildren_depth: godchildrenDepth,
},
})
).data;
return [
...data.users.map((user) => {
return { data: user };
}),
...data.relationships.map((rel) => {
return {
data: { source: rel.godfather, target: rel.godchild },
};
}),
];
}
function createGraph(container, data, activeUserId) {
const cy = cytoscape({
boxSelectionEnabled: false,
autounselectify: true,
container,
elements: data,
minZoom: 0.5,
style: [
// the stylesheet for the graph
{
selector: "node",
style: {
label: "data(display_name)",
"background-image": "data(profile_pict)",
width: "100%",
height: "100%",
"background-fit": "cover",
"background-repeat": "no-repeat",
shape: "ellipse",
},
},
{
selector: "edge",
style: {
width: 5,
"line-color": "#ccc",
"target-arrow-color": "#ccc",
"target-arrow-shape": "triangle",
"curve-style": "bezier",
},
},
{
selector: ".traversed",
style: {
"border-width": "5px",
"border-style": "solid",
"border-color": "red",
"target-arrow-color": "red",
"line-color": "red",
},
},
{
selector: ".not-traversed",
style: {
"line-opacity": "0.5",
"background-opacity": "0.5",
"background-image-opacity": "0.5",
},
},
],
layout: {
name: "klay",
nodeDimensionsIncludeLabels: true,
fit: true,
klay: {
addUnnecessaryBendpoints: true,
direction: "DOWN",
nodePlacement: "INTERACTIVE",
layoutHierarchy: true,
},
},
});
const activeUser = cy.getElementById(activeUserId).style("shape", "rectangle");
/* Reset graph */
const resetGraph = () => {
cy.elements((element) => {
if (element.hasClass("traversed")) {
element.removeClass("traversed");
}
if (element.hasClass("not-traversed")) {
element.removeClass("not-traversed");
}
});
};
const onNodeTap = (el) => {
resetGraph();
/* Create path on graph if selected isn't the targeted user */
if (el === activeUser) {
return;
}
cy.elements((element) => {
element.addClass("not-traversed");
});
for (const traversed of cy.elements().aStar({
root: el,
goal: activeUser,
}).path) {
traversed.removeClass("not-traversed");
traversed.addClass("traversed");
}
};
cy.on("tap", "node", (tapped) => {
onNodeTap(tapped.target);
});
cy.zoomingEnabled(false);
/* Add context menu */
cy.cxtmenu({
selector: "node",
commands: [
{
content: '<i class="fa fa-external-link fa-2x"></i>',
select: (el) => {
window.open(el.data().profile_url, "_blank").focus();
},
},
{
content: '<span class="fa fa-mouse-pointer fa-2x"></span>',
select: (el) => {
onNodeTap(el);
},
},
{
content: '<i class="fa fa-eraser fa-2x"></i>',
select: (_) => {
resetGraph();
},
},
],
});
return cy;
}
/**
* @typedef FamilyGraphConfig
* @property {number} activeUser Id of the user to fetch the tree from
* @property {number} depthMin Minimum tree depth for godfathers and godchildren
* @property {number} depthMax Maximum tree depth for godfathers and godchildren
**/
/**
* Create a family graph of an user
* @param {FamilyGraphConfig} config
**/
window.loadFamilyGraph = (config) => {
document.addEventListener("alpine:init", () => {
const defaultDepth = 2;
function getInitialDepth(prop) {
const value = Number.parseInt(initialUrlParams.get(prop));
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
return defaultDepth;
}
return value;
}
Alpine.data("graph", () => ({
loading: false,
godfathersDepth: getInitialDepth("godfathersDepth"),
godchildrenDepth: getInitialDepth("godchildrenDepth"),
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
graph: undefined,
graphData: {},
async init() {
const delayedFetch = Alpine.debounce(async () => {
await this.fetchGraphData();
}, 100);
for (const param of ["godfathersDepth", "godchildrenDepth"]) {
this.$watch(param, async (value) => {
if (value < config.depthMin || value > config.depthMax) {
return;
}
updateQueryString(param, value, History.Replace);
await delayedFetch();
});
}
this.$watch("reverse", async (value) => {
updateQueryString("reverse", value, History.Replace);
await this.reverseGraph();
});
this.$watch("graphData", async () => {
this.generateGraph();
if (this.reverse) {
await this.reverseGraph();
}
});
await this.fetchGraphData();
},
screenshot() {
const link = document.createElement("a");
link.href = this.graph.jpg();
link.download = interpolate(
gettext("family_tree.%(extension)s"),
{ extension: "jpg" },
true,
);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
},
reset() {
this.reverse = false;
this.godfathersDepth = defaultDepth;
this.godchildrenDepth = defaultDepth;
},
async reverseGraph() {
this.graph.elements((el) => {
el.position({ x: -el.position().x, y: -el.position().y });
});
this.graph.center(this.graph.elements());
},
async fetchGraphData() {
this.graphData = await getGraphData(
config.activeUser,
this.godfathersDepth,
this.godchildrenDepth,
);
},
generateGraph() {
this.loading = true;
this.graph = createGraph(
$(this.$refs.graph),
this.graphData,
config.activeUser,
);
this.loading = false;
},
}));
});
};

View File

@ -0,0 +1,287 @@
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
import cytoscape, {
type ElementDefinition,
type NodeSingular,
type Singular,
} from "cytoscape";
import cxtmenu from "cytoscape-cxtmenu";
import klay, { type KlayLayoutOptions } from "cytoscape-klay";
import { type UserProfileSchema, familyGetFamilyGraph } from "#openapi";
cytoscape.use(klay);
cytoscape.use(cxtmenu);
type GraphData = (
| { data: UserProfileSchema }
| { data: { source: number; target: number } }
)[];
function isMobile() {
return window.innerWidth < 500;
}
async function getGraphData(
userId: number,
godfathersDepth: number,
godchildrenDepth: number,
): Promise<GraphData> {
const data = (
await familyGetFamilyGraph({
path: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
user_id: userId,
},
query: {
// biome-ignore lint/style/useNamingConvention: api is snake_case
godfathers_depth: godfathersDepth,
// biome-ignore lint/style/useNamingConvention: api is snake_case
godchildren_depth: godchildrenDepth,
},
})
).data;
return [
...data.users.map((user) => {
return { data: user };
}),
...data.relationships.map((rel) => {
return {
data: { source: rel.godfather, target: rel.godchild },
};
}),
];
}
function createGraph(container: HTMLDivElement, data: GraphData, activeUserId: number) {
const cy = cytoscape({
boxSelectionEnabled: false,
autounselectify: true,
container,
elements: data as ElementDefinition[],
minZoom: 0.5,
style: [
// the stylesheet for the graph
{
selector: "node",
style: {
label: "data(display_name)",
"background-image": "data(profile_pict)",
width: "100%",
height: "100%",
"background-fit": "cover",
"background-repeat": "no-repeat",
shape: "ellipse",
},
},
{
selector: "edge",
style: {
width: 5,
"line-color": "#ccc",
"target-arrow-color": "#ccc",
"target-arrow-shape": "triangle",
"curve-style": "bezier",
},
},
{
selector: ".traversed",
style: {
"border-width": "5px",
"border-style": "solid",
"border-color": "red",
"target-arrow-color": "red",
"line-color": "red",
},
},
{
selector: ".not-traversed",
style: {
"line-opacity": 0.5,
"background-opacity": 0.5,
"background-image-opacity": 0.5,
},
},
],
layout: {
name: "klay",
nodeDimensionsIncludeLabels: true,
fit: true,
klay: {
addUnnecessaryBendpoints: true,
direction: "DOWN",
nodePlacement: "INTERACTIVE",
layoutHierarchy: true,
},
} as KlayLayoutOptions,
});
const activeUser = cy
.getElementById(activeUserId.toString())
.style("shape", "rectangle");
/* Reset graph */
const resetGraph = () => {
cy.elements().removeClass("traversed not-traversed");
};
const onNodeTap = (el: Singular) => {
resetGraph();
/* Create path on graph if selected isn't the targeted user */
if (el === activeUser) {
return;
}
cy.elements().addClass("not-traversed");
for (const traversed of cy.elements().aStar({
root: el,
goal: activeUser,
}).path) {
traversed.removeClass("not-traversed");
traversed.addClass("traversed");
}
};
cy.on("tap", "node", (tapped) => {
onNodeTap(tapped.target);
});
/* Add context menu */
cy.cxtmenu({
selector: "node",
commands: [
{
content: '<i class="fa fa-external-link fa-2x"></i>',
select: (el) => {
window.open(el.data().profile_url, "_blank").focus();
},
},
{
content: '<span class="fa fa-mouse-pointer fa-2x"></span>',
select: (el) => {
onNodeTap(el);
},
},
{
content: '<i class="fa fa-eraser fa-2x"></i>',
select: (_) => {
resetGraph();
},
},
],
});
return cy;
}
interface FamilyGraphConfig {
/**Id of the user to fetch the tree from*/
activeUser: number;
/**Minimum tree depth for godfathers and godchildren*/
depthMin: number;
/**Maximum tree depth for godfathers and godchildren*/
depthMax: number;
}
document.addEventListener("alpine:init", () => {
const defaultDepth = 2;
Alpine.data("graph", (config: FamilyGraphConfig) => ({
loading: false,
godfathersDepth: 0,
godchildrenDepth: 0,
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
graph: undefined as cytoscape.Core,
graphData: {},
isZoomEnabled: !isMobile(),
getInitialDepth(prop: string) {
const value = Number.parseInt(initialUrlParams.get(prop));
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
return defaultDepth;
}
return value;
},
async init() {
this.godfathersDepth = this.getInitialDepth("godfathersDepth");
this.godchildrenDepth = this.getInitialDepth("godchildrenDepth");
const delayedFetch = Alpine.debounce(async () => {
await this.fetchGraphData();
}, 100);
for (const param of ["godfathersDepth", "godchildrenDepth"]) {
this.$watch(param, async (value: number) => {
if (value < config.depthMin || value > config.depthMax) {
return;
}
updateQueryString(param, value.toString(), History.Replace);
await delayedFetch();
});
}
this.$watch("reverse", async (value: number) => {
updateQueryString("reverse", value.toString(), History.Replace);
await this.reverseGraph();
});
this.$watch("graphData", async () => {
this.generateGraph();
if (this.reverse) {
await this.reverseGraph();
}
});
this.$watch("isZoomEnabled", () => {
this.graph.userZoomingEnabled(this.isZoomEnabled);
});
await this.fetchGraphData();
},
screenshot() {
const link = document.createElement("a");
link.href = this.graph.jpg();
link.download = interpolate(
gettext("family_tree.%(extension)s"),
{ extension: "jpg" },
true,
);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
},
reset() {
this.reverse = false;
this.godfathersDepth = defaultDepth;
this.godchildrenDepth = defaultDepth;
},
async reverseGraph() {
this.graph.elements((el: NodeSingular) => {
el.position({ x: -el.position().x, y: -el.position().y });
});
this.graph.center(this.graph.elements());
},
async fetchGraphData() {
this.graphData = await getGraphData(
config.activeUser,
this.godfathersDepth,
this.godchildrenDepth,
);
},
generateGraph() {
this.loading = true;
this.graph = createGraph(
this.$refs.graph as HTMLDivElement,
this.graphData,
config.activeUser,
);
this.graph.userZoomingEnabled(this.isZoomEnabled);
this.loading = false;
},
}));
});

View File

@ -0,0 +1,89 @@
@import "colors";
@import "devices";
footer.bottom-links {
@media (max-width: $small-devices) {
margin-top: 0.6em;
padding: 1.25em;
background-color: $primary-neutral-dark-color;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.25em;
>section {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 0.8em;
}
a {
color: $white-color;
width: auto;
&:hover {
color: $white-color;
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
}
}
.fa-github {
color: $white-color;
}
hr {
width: 100%;
height: 0px;
border: none;
border-top: 0.5px solid $white-color;
}
}
@media (min-width: $small-devices) {
width: 90%;
margin: 2em auto;
font-size: 90%;
text-align: center;
vertical-align: middle;
section:first-of-type {
margin: 0.6em 0;
color: $white-color;
border-radius: 5px;
display: flex;
flex-wrap: wrap;
align-items: center;
background-color: $primary-neutral-dark-color;
box-shadow: $shadow-color 0 0 15px;
a {
color: $white-color;
width: auto;
padding: 0.8em;
flex: 1;
font-weight: bold;
&:hover {
color: $white-color;
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
}
}
}
.fa-github {
color: $githubblack;
}
hr {
border: none;
height: 5px;
}
}
}

View File

@ -713,47 +713,6 @@ textarea {
margin-top: 10px; margin-top: 10px;
} }
/*--------------------------------FOOTER-------------------------------*/
footer {
width: 90%;
margin: 2em auto;
font-size: 90%;
text-align: center;
vertical-align: middle;
div {
margin: 0.6em 0;
color: $white-color;
border-radius: 5px;
display: flex;
flex-wrap: wrap;
align-items: center;
background-color: $primary-neutral-dark-color;
box-shadow: $shadow-color 0 0 15px;
a {
padding: 0.8em;
flex: 1;
font-weight: bold;
color: $white-color !important;
&:hover {
color: $primary-dark-color;
}
}
}
>.version {
margin-top: 3px;
color: rgba(0, 0, 0, 0.3);
}
.fa-github {
color: $githubblack;
}
}
.ui-dialog .ui-dialog-buttonpane { .ui-dialog .ui-dialog-buttonpane {

View File

@ -4,6 +4,12 @@
display: block; display: block;
} }
.zoom-control {
margin-right: 10px;
display: flex;
justify-content: right;
}
.graph-toolbar { .graph-toolbar {
margin-top: 10px; margin-top: 10px;
margin-bottom: 10px; margin-bottom: 10px;
@ -34,30 +40,37 @@
.depth-choice { .depth-choice {
white-space: nowrap; white-space: nowrap;
input[type="number"] { input[type="number"] {
-webkit-appearance: textfield; -webkit-appearance: textfield;
-moz-appearance: textfield; -moz-appearance: textfield;
appearance: textfield; appearance: textfield;
&::-webkit-inner-spin-button, &::-webkit-inner-spin-button,
&::-webkit-outer-spin-button { &::-webkit-outer-spin-button {
-webkit-appearance: none; -webkit-appearance: none;
} }
} }
button { button {
background: none; background: none;
&>.fa { &>.fa {
border-radius: 50%; border-radius: 50%;
font-size: 12px; font-size: 12px;
padding: 5px; padding: 5px;
} }
&:enabled>.fa { &:enabled>.fa {
background-color: #354a5f; background-color: #354a5f;
color: white; color: white;
} }
&:enabled:hover>.fa { &:enabled:hover>.fa {
color: white; color: white;
background-color: #35405f; // just a bit darker background-color: #35405f; // just a bit darker
} }
&:disabled>.fa { &:disabled>.fa {
background-color: gray; background-color: gray;
color: white; color: white;
@ -74,6 +87,7 @@
@media screen and (max-width: 500px) { @media screen and (max-width: 500px) {
flex-direction: column; flex-direction: column;
gap: 20px; gap: 20px;
.toolbar-column { .toolbar-column {
min-width: 100%; min-width: 100%;
} }
@ -91,10 +105,12 @@
margin: 0; margin: 0;
} }
} }
#family-tree-link { #family-tree-link {
display: inline-block; display: inline-block;
margin-top: 10px; margin-top: 10px;
text-align: center; text-align: center;
@media (min-width: 450px) { @media (min-width: 450px) {
margin-right: auto; margin-right: auto;
} }

View File

@ -11,6 +11,7 @@
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}"> <link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
<link rel="stylesheet" href="{{ static('core/header.scss') }}"> <link rel="stylesheet" href="{{ static('core/header.scss') }}">
<link rel="stylesheet" href="{{ static('core/navbar.scss') }}"> <link rel="stylesheet" href="{{ static('core/navbar.scss') }}">
<link rel="stylesheet" href="{{ static('core/footer.scss') }}">
<link rel="stylesheet" href="{{ static('core/pagination.scss') }}"> <link rel="stylesheet" href="{{ static('core/pagination.scss') }}">
<link rel="stylesheet" href="{{ static('core/accordion.scss') }}"> <link rel="stylesheet" href="{{ static('core/accordion.scss') }}">
@ -89,22 +90,9 @@
</div> </div>
</div> </div>
<footer>
{% block footer %} {% block footer %}
<div> {% include "core/base/footer.jinja" %}
<a href="{{ url('core:page', 'contacts') }}">{% trans %}Contacts{% endtrans %}</a>
<a href="{{ url('core:page', 'legals') }}">{% trans %}Legal notices{% endtrans %}</a>
<a href="{{ url('core:page', 'copyright_agent') }}">{% trans %}Intellectual property{% endtrans %}</a>
<a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
<a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
</div>
<a rel="nofollow" href="https://github.com/ae-utbm/sith" target="#">
<i class="fa-brands fa-github"></i>
{% trans %}Site created by the IT Department of the AE{% endtrans %}
</a>
{% endblock %} {% endblock %}
<br>
</footer>
{% block script %} {% block script %}
<script> <script>

View File

@ -0,0 +1,16 @@
<footer class="bottom-links">
<section>
<a href="{{ url('core:page', 'contacts') }}">{% trans %}Contacts{% endtrans %}</a>
<a href="{{ url('core:page', 'legals') }}">{% trans %}Legal notices{% endtrans %}</a>
<a href="{{ url('core:page', 'copyright_agent') }}">{% trans %}Intellectual property{% endtrans %}</a>
<a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
<a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
</section>
<hr>
<section>
<a rel="nofollow" href="https://github.com/ae-utbm/sith" target="#">
<i class="fa-brands fa-github"></i>
{% trans %}Site created by the IT Department of the AE{% endtrans %}
</a>
</section>
</footer>

View File

@ -26,9 +26,11 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
<form method="post" action="{{ url('core:login') }}"> <form method="post" action="{{ url('core:login') }}" id="login-form">
{% if form.errors %} {% if form.errors %}
<p class="alert alert-red">{% trans %}Your username and password didn't match. Please try again.{% endtrans %}</p> <p class="alert alert-red">
{% trans %}Your credentials didn't match. Please try again.{% endtrans %}
</p>
{% endif %} {% endif %}
{% csrf_token %} {% csrf_token %}

View File

@ -7,7 +7,7 @@
{%- endblock -%} {%- endblock -%}
{% block additional_js %} {% block additional_js %}
<script type="module" src="{{ static("bundled/user/family-graph-index.js") }}"></script> <script type="module" src="{{ static("bundled/user/family-graph-index.ts") }}"></script>
{% endblock %} {% endblock %}
{% block title %} {% block title %}
@ -15,7 +15,14 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div x-data="graph" :aria-busy="loading"> <div
x-data="graph({
activeUser: {{ object.id }},
depthMin: {{ depth_min }},
depthMax: {{ depth_max }},
})"
:aria-busy="loading"
>
<div class="graph-toolbar"> <div class="graph-toolbar">
<div class="toolbar-column"> <div class="toolbar-column">
<div class="toolbar-input"> <div class="toolbar-input">
@ -86,17 +93,36 @@
</button> </button>
</div> </div>
</div> </div>
<div class="zoom-control" x-ref="zoomControl">
<button
@click="graph.zoom(graph.zoom() + 1)"
:disabled="!isZoomEnabled"
>
<i class="fa-solid fa-magnifying-glass-plus"></i>
</button>
<button
@click="graph.zoom(graph.zoom() - 1)"
:disabled="!isZoomEnabled"
>
<i class="fa-solid fa-magnifying-glass-minus"></i>
</button>
<button
x-show="isZoomEnabled"
@click="isZoomEnabled = false"
>
<i class="fa-solid fa-unlock"></i>
</button>
<button
x-show="!isZoomEnabled"
@click="isZoomEnabled = true"
>
<i class="fa-solid fa-lock"></i>
</button>
</div>
<div x-ref="graph" class="graph"></div> <div x-ref="graph" class="graph"></div>
</div> </div>
<script>
window.addEventListener("DOMContentLoaded", () => {
loadFamilyGraph({
activeUser: {{ object.id }},
depthMin: {{ depth_min }},
depthMax: {{ depth_max }},
});
});
</script>
{% endblock %} {% endblock %}

View File

@ -38,6 +38,7 @@ from core.markdown import markdown
from core.models import AnonymousUser, Group, Page, User from core.models import AnonymousUser, Group, Page, User
from core.utils import get_semester_code, get_start_of_semester from core.utils import get_semester_code, get_start_of_semester
from core.views import AllowFragment from core.views import AllowFragment
from counter.models import Customer
from sith import settings from sith import settings
@ -151,24 +152,44 @@ class TestUserLogin:
def user(self) -> User: def user(self) -> User:
return baker.make(User, password=make_password("plop")) return baker.make(User, password=make_password("plop"))
def test_login_fail(self, client, user): @pytest.mark.parametrize(
"identifier_getter",
[
lambda user: user.username,
lambda user: user.email,
lambda user: Customer.get_or_create(user)[0].account_id,
],
)
def test_login_fail(self, client, user, identifier_getter):
"""Should not login a user correctly.""" """Should not login a user correctly."""
identifier = identifier_getter(user)
response = client.post( response = client.post(
reverse("core:login"), reverse("core:login"),
{"username": user.username, "password": "wrong-password"}, {"username": identifier, "password": "wrong-password"},
) )
assert response.status_code == 200 assert response.status_code == 200
assert (
'<p class="alert alert-red">Votre nom d\'utilisateur '
"et votre mot de passe ne correspondent pas. Merci de réessayer.</p>"
) in response.text
assert response.wsgi_request.user.is_anonymous assert response.wsgi_request.user.is_anonymous
soup = BeautifulSoup(response.text, "lxml")
form = soup.find(id="login-form")
assert (
form.find(class_="alert alert-red").get_text(strip=True)
== "Vos identifiants ne correspondent pas. Veuillez réessayer."
)
assert form.find("input", attrs={"name": "username"}).get("value") == identifier
def test_login_success(self, client, user): @pytest.mark.parametrize(
"identifier_getter",
[
lambda user: user.username,
lambda user: user.email,
lambda user: Customer.get_or_create(user)[0].account_id,
],
)
def test_login_success(self, client, user, identifier_getter):
"""Should login a user correctly.""" """Should login a user correctly."""
response = client.post( response = client.post(
reverse("core:login"), reverse("core:login"),
{"username": user.username, "password": "plop"}, {"username": identifier_getter(user), "password": "plop"},
) )
assertRedirects(response, reverse("core:index")) assertRedirects(response, reverse("core:index"))
assert response.wsgi_request.user == user assert response.wsgi_request.user == user
@ -361,17 +382,9 @@ class TestUserIsInGroup(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.root_group = Group.objects.get(name="Root") cls.public_group = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)
cls.public_group = Group.objects.get(name="Public")
cls.public_user = baker.make(User) cls.public_user = baker.make(User)
cls.subscribers = Group.objects.get(name="Subscribers")
cls.old_subscribers = Group.objects.get(name="Old subscribers")
cls.accounting_admin = Group.objects.get(name="Accounting admin")
cls.com_admin = Group.objects.get(name="Communication admin")
cls.counter_admin = Group.objects.get(name="Counter admin")
cls.sas_admin = Group.objects.get(name="SAS admin")
cls.club = baker.make(Club) cls.club = baker.make(Club)
cls.main_club = Club.objects.get(id=1)
def assert_in_public_group(self, user): def assert_in_public_group(self, user):
assert user.is_in_group(pk=self.public_group.id) assert user.is_in_group(pk=self.public_group.id)
@ -379,15 +392,7 @@ class TestUserIsInGroup(TestCase):
def assert_only_in_public_group(self, user): def assert_only_in_public_group(self, user):
self.assert_in_public_group(user) self.assert_in_public_group(user)
for group in ( for group in Group.objects.exclude(id=self.public_group.id):
self.root_group,
self.accounting_admin,
self.sas_admin,
self.subscribers,
self.old_subscribers,
self.club.members_group,
self.club.board_group,
):
assert not user.is_in_group(pk=group.pk) assert not user.is_in_group(pk=group.pk)
assert not user.is_in_group(name=group.name) assert not user.is_in_group(name=group.name)

View File

@ -132,29 +132,31 @@ class FutureDateTimeField(forms.DateTimeField):
class LoginForm(AuthenticationForm): class LoginForm(AuthenticationForm):
def __init__(self, *arg, **kwargs): def __init__(self, *arg, **kwargs):
if "data" in kwargs:
from counter.models import Customer
data = kwargs["data"].copy()
account_code = re.compile(r"^[0-9]+[A-Za-z]$")
try:
if account_code.match(data["username"]):
user = (
Customer.objects.filter(account_id__iexact=data["username"])
.first()
.user
)
elif "@" in data["username"]:
user = User.objects.filter(email__iexact=data["username"]).first()
else:
user = User.objects.filter(username=data["username"]).first()
data["username"] = user.username
except: # noqa E722 I don't know what error is supposed to be raised here
pass
kwargs["data"] = data
super().__init__(*arg, **kwargs) super().__init__(*arg, **kwargs)
self.fields["username"].label = _("Username, email, or account number") self.fields["username"].label = _("Username, email, or account number")
def clean_username(self):
identifier: str = self.cleaned_data["username"]
account_code = re.compile(r"^[0-9]+[A-Za-z]$")
if account_code.match(identifier):
qs_filter = "customer__account_id__iexact"
elif identifier.count("@") == 1:
qs_filter = "email"
else:
qs_filter = None
if qs_filter:
# if the user gave an email or an account code instead of
# a username, retrieve and return the corresponding username.
# If there is no username, return an empty string, so that
# Django will properly handle the error when failing the authentication
identifier = (
User.objects.filter(**{qs_filter: identifier})
.values_list("username", flat=True)
.first()
or ""
)
return identifier
class RegisteringForm(UserCreationForm): class RegisteringForm(UserCreationForm):
error_css_class = "error" error_css_class = "error"

View File

@ -41,6 +41,7 @@ class ProductAdmin(SearchModelAdmin):
"profit", "profit",
"archived", "archived",
) )
list_select_related = ("product_type",)
search_fields = ("name", "code") search_fields = ("name", "code")
@ -81,20 +82,13 @@ class AccountDumpAdmin(admin.ModelAdmin):
"customer", "customer",
"warning_mail_sent_at", "warning_mail_sent_at",
"warning_mail_error", "warning_mail_error",
"dump_operation", "dump_operation__date",
"amount", "amount",
) )
list_select_related = ("customer", "customer__user", "dump_operation")
autocomplete_fields = ("customer", "dump_operation") autocomplete_fields = ("customer", "dump_operation")
list_filter = ("warning_mail_error",) list_filter = ("warning_mail_error",)
def get_queryset(self, request):
# the `amount` property requires to know the customer and the dump_operation
return (
super()
.get_queryset(request)
.select_related("customer", "customer__user", "dump_operation")
)
@admin.register(Counter) @admin.register(Counter)
class CounterAdmin(admin.ModelAdmin): class CounterAdmin(admin.ModelAdmin):
@ -113,11 +107,14 @@ class RefillingAdmin(SearchModelAdmin):
"customer__account_id", "customer__account_id",
"counter__name", "counter__name",
) )
list_filter = (("counter", admin.RelatedOnlyFieldListFilter),)
date_hierarchy = "date"
@admin.register(Selling) @admin.register(Selling)
class SellingAdmin(SearchModelAdmin): class SellingAdmin(SearchModelAdmin):
list_display = ("customer", "label", "unit_price", "quantity", "counter", "date") list_display = ("customer", "label", "unit_price", "quantity", "counter", "date")
list_select_related = ("customer", "customer__user", "counter")
search_fields = ( search_fields = (
"customer__user__username", "customer__user__username",
"customer__user__first_name", "customer__user__first_name",
@ -126,6 +123,8 @@ class SellingAdmin(SearchModelAdmin):
"counter__name", "counter__name",
) )
autocomplete_fields = ("customer", "seller") autocomplete_fields = ("customer", "seller")
list_filter = (("counter", admin.RelatedOnlyFieldListFilter),)
date_hierarchy = "date"
@admin.register(Permanency) @admin.register(Permanency)

View File

@ -17,6 +17,7 @@ from datetime import timedelta
from decimal import Decimal from decimal import Decimal
import pytest import pytest
from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Permission, make_password from django.contrib.auth.models import Permission, make_password
from django.core.cache import cache from django.core.cache import cache
@ -823,3 +824,53 @@ class TestClubCounterClickAccess(TestCase):
self.client.force_login(self.user) self.client.force_login(self.user)
res = self.client.get(self.click_url) res = self.client.get(self.click_url)
assert res.status_code == 200 assert res.status_code == 200
@pytest.mark.django_db
class TestCounterLogout:
def test_logout_simple(self, client: Client):
perm_counter = baker.make(Counter, type="BAR")
permanence = baker.make(
Permanency,
counter=perm_counter,
start=now() - timedelta(hours=1),
activity=now() - timedelta(minutes=10),
)
with freeze_time():
res = client.post(
reverse("counter:logout", kwargs={"counter_id": permanence.counter_id}),
data={"user_id": permanence.user_id},
)
assertRedirects(
res,
reverse(
"counter:details", kwargs={"counter_id": permanence.counter_id}
),
)
permanence.refresh_from_db()
assert permanence.end == now()
def test_logout_doesnt_change_old_permanences(self, client: Client):
perm_counter = baker.make(Counter, type="BAR")
permanence = baker.make(
Permanency,
counter=perm_counter,
start=now() - timedelta(hours=1),
activity=now() - timedelta(minutes=10),
)
old_end = now() - relativedelta(year=10)
old_permanence = baker.make(
Permanency,
counter=perm_counter,
end=old_end,
activity=now() - relativedelta(year=8),
)
with freeze_time():
client.post(
reverse("counter:logout", kwargs={"counter_id": permanence.counter_id}),
data={"user_id": permanence.user_id},
)
permanence.refresh_from_db()
assert permanence.end == now()
old_permanence.refresh_from_db()
assert old_permanence.end == old_end

View File

@ -13,10 +13,10 @@
# #
# #
from django.db.models import F
from django.http import HttpRequest, HttpResponseRedirect from django.http import HttpRequest, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.utils import timezone from django.utils import timezone
from django.utils.timezone import now
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from core.views.forms import LoginForm from core.views.forms import LoginForm
@ -47,7 +47,7 @@ def counter_login(request: HttpRequest, counter_id: int) -> HttpResponseRedirect
@require_POST @require_POST
def counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect: def counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
"""End the permanency of a user in this counter.""" """End the permanency of a user in this counter."""
Permanency.objects.filter(counter=counter_id, user=request.POST["user_id"]).update( Permanency.objects.filter(
end=F("activity") counter=counter_id, user=request.POST["user_id"], end=None
) ).update(end=now())
return redirect("counter:details", counter_id=counter_id) return redirect("counter:details", counter_id=counter_id)

108
docs/explanation/ia.md Normal file
View File

@ -0,0 +1,108 @@
Cette page expose la politique du Pôle informatique de l'AE
en ce qui concerne l'usage et l'implémentation de systèmes d'IA
dans le cadre de l'AE et du développement de ses outils.
## Cadre
En accord avec le règlement européen sur
l'intelligence artificielle du 13 juin 2024,
nous définissons comme IA :
> Un système basé sur une machine qui est
> conçu pour fonctionner avec différents niveaux d'autonomie
> et qui peut faire preuve d'adaptabilité après son déploiement,
> et qui, pour des objectifs explicites ou implicites, déduit,
> à partir des données qu'il reçoit,
> comment générer des résultats tels que des prédictions,
> du contenu, des recommandations ou des décisions
> qui peuvent influencer des environnements physiques ou virtuels.
Cette définition recouvre toutes les IAs génératives, ce qui inclut
ChatGPT, DeepSeek, Claude, Copilot, Llama et autres outils similaires.
## Utilisation dans le développement
!!!danger
La soumission de code généré par IA est strictement interdite.
Aucune contribution contenant du code généré par IA n'est acceptée.
Toute PR contenant en proportion significative du code duquel
on peut raisonnablement penser qu'il a été généré par IA
pourra être refusée sans aucun autre motif.
Bien que nous ne puissions pas l'interdire,
nous déconseillons également fortement l'usage de tout
recours à un système d'IA dans le processus de développement,
quel que soit son usage (debug, recherche d'information ou autres).
Référez-vous en priorité à la documentation du site,
à celle de Django et à l'aide des autres développeurs,
mais par pitié, ne faites jamais appel à l'IA.
## Intégration dans le site
L'intégration sur le site AE de systèmes d'IA
et de toute fonctionnalité basée sur des systèmes d'IA
est strictement prohibée, quel qu'en soit l'objectif.
Toute tâche de modération, de génération
ou de détection de contenu ne doit être accomplie
par des êtres humains ou par des algorithmes
déterministes, testés et compris.
L'usage des données du site a des fins d'entrainement d'IA,
ainsi que la transmission de ces données à un système d'IA
est strictement interdit.
Tout acte de cette nature sera considéré comme une violation
grave de la politique de gestion des données de l'AE.
## Motifs de cette politique
Le site AE est un programme écrit par des humains, pour des humains.
C'est un logiciel dont la complexité nécessite des connaissances
plus approfondies que ce qui est attendu de la part d'un
étudiant en TC ou en base branche.
À ce titre, l'interdiction de l'IA dans le cadre de son
développement est pensée avant tout dans une optique
de formation des développeurs, de stabilité de la base de code
et de transmission des connaissances.
Nous ferons ici abstraction de l'impact écologique néfaste de l'IA,
qui n'en reste pas moins préoccupant et qui renforce
les autres motifs ayant poussé à interdire l'IA dans le cadre de l'AE.
### Formation des développeurs
Travailler sur le site AE est possiblement le meilleur moyen de
monter en compétences en informatique pour un étudiant de l'UTBM.
Automatisation des tests, gestion des données et de la sécurité,
infrastructure, maintenance du code existant...
Le site AE est un logiciel complet, dont le développement
possède une dimension pédagogique réelle.
En utilisant l'IA, le développement n'est plus un moyen efficace
de se former.
### Stabilité de la base de code
Les développeurs du site AE sont pour la plupart en cours de formation,
sans compréhension globale de la base de code du site,
des outils logiciels sur lesquels il se base et des bonnes
pratiques permettant d'écrire du code viable.
En se reposant sur un système d'IA sans être capacité
de comprendre intégralement le code proposé ni de le mettre
en perspective avec le reste de la base de code,
c'est toute la maintenance de la base de code qui se retrouve compromise.
### Transmission des connaissances
L'équipe du pôle informatique se renouvelle très souvent.
À ce titre, les nouveaux développeurs se doivent d'hériter
d'une base de code viable.
Quant aux anciens développeurs, ils se doivent d'en avoir
compris le fonctionnement, afin d'être en mesure
de guider et d'aider leurs successeurs.
Comme développé dans les deux points précédents,
cet objectif est incompatible avec l'usage de systèmes d'IA.

View File

@ -6,7 +6,7 @@
msgid "" msgid ""
msgstr "" msgstr ""
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-06-16 14:54+0200\n" "POT-Creation-Date: 2025-06-25 16:29+0200\n"
"PO-Revision-Date: 2016-07-18\n" "PO-Revision-Date: 2016-07-18\n"
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n" "Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
"Language-Team: AE info <ae.info@utbm.fr>\n" "Language-Team: AE info <ae.info@utbm.fr>\n"
@ -2015,10 +2015,8 @@ msgid "Please login or create an account to see this page."
msgstr "Merci de vous identifier ou de créer un compte pour voir cette page." msgstr "Merci de vous identifier ou de créer un compte pour voir cette page."
#: core/templates/core/login.jinja #: core/templates/core/login.jinja
msgid "Your username and password didn't match. Please try again." msgid "Your credentials didn't match. Please try again."
msgstr "" msgstr "Vos identifiants ne correspondent pas. Veuillez réessayer."
"Votre nom d'utilisateur et votre mot de passe ne correspondent pas. Merci de "
"réessayer."
#: core/templates/core/login.jinja #: core/templates/core/login.jinja
msgid "Lost password?" msgid "Lost password?"

View File

@ -57,6 +57,7 @@ nav:
- Accueil: explanation/index.md - Accueil: explanation/index.md
- Technologies utilisées: explanation/technos.md - Technologies utilisées: explanation/technos.md
- Conventions: explanation/conventions.md - Conventions: explanation/conventions.md
- Politique IA: explanation/ia.md
- Archives: explanation/archives.md - Archives: explanation/archives.md
- Tutoriels: - Tutoriels:
- Installer le projet: tutorial/install.md - Installer le projet: tutorial/install.md

31
package-lock.json generated
View File

@ -45,7 +45,10 @@
"@hey-api/openapi-ts": "^0.73.0", "@hey-api/openapi-ts": "^0.73.0",
"@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.10", "@types/alpinejs": "^3.13.10",
"@types/cytoscape-cxtmenu": "^3.4.4",
"@types/cytoscape-klay": "^3.1.4",
"@types/jquery": "^3.5.31", "@types/jquery": "^3.5.31",
"typescript": "^5.8.3",
"vite": "^6.2.5", "vite": "^6.2.5",
"vite-bundle-visualizer": "^1.2.1", "vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.0.2" "vite-plugin-static-copy": "^3.0.2"
@ -2819,6 +2822,33 @@
"@types/tern": "*" "@types/tern": "*"
} }
}, },
"node_modules/@types/cytoscape": {
"version": "3.21.9",
"resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.21.9.tgz",
"integrity": "sha512-JyrG4tllI6jvuISPjHK9j2Xv/LTbnLekLke5otGStjFluIyA9JjgnvgZrSBsp8cEDpiTjwgZUZwpPv8TSBcoLw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/cytoscape-cxtmenu": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/@types/cytoscape-cxtmenu/-/cytoscape-cxtmenu-3.4.4.tgz",
"integrity": "sha512-cuv+IdbKekswDRBIrHn97IYOzWS2/UjVr0kDIHCOYvqWy3iZkuGGM4qmHNPQ+63Dn7JgtmD0l3MKW1moyhoaKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/cytoscape": "*"
}
},
"node_modules/@types/cytoscape-klay": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@types/cytoscape-klay/-/cytoscape-klay-3.1.4.tgz",
"integrity": "sha512-H+tIadpcVjmDGWKFUfibwzIpH/kddfwAFsuhPparjiC+bWBm+MeNqIwwY+19ofkJZWcqWqZL6Jp8lkp+sP8Aig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/cytoscape": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -5558,7 +5588,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"

View File

@ -31,6 +31,9 @@
"@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.10", "@types/alpinejs": "^3.13.10",
"@types/jquery": "^3.5.31", "@types/jquery": "^3.5.31",
"@types/cytoscape-cxtmenu": "^3.4.4",
"@types/cytoscape-klay": "^3.1.4",
"typescript": "^5.8.3",
"vite": "^6.2.5", "vite": "^6.2.5",
"vite-bundle-visualizer": "^1.2.1", "vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.0.2" "vite-plugin-static-copy": "^3.0.2"

View File

@ -53,9 +53,9 @@ class TestMergeUser(TestCase):
self.to_keep.address = "Jerusalem" self.to_keep.address = "Jerusalem"
self.to_delete.parent_address = "Rome" self.to_delete.parent_address = "Rome"
self.to_delete.address = "Rome" self.to_delete.address = "Rome"
subscribers = Group.objects.get(name="Subscribers") subscribers = Group.objects.get(id=settings.SITH_GROUP_SUBSCRIBERS_ID)
mde_admin = Group.objects.get(name="MDE admin") mde_admin = Group.objects.get(name="MDE admin")
sas_admin = Group.objects.get(name="SAS admin") sas_admin = Group.objects.get(id=settings.SITH_GROUP_SAS_ADMIN_ID)
self.to_keep.groups.add(subscribers.id) self.to_keep.groups.add(subscribers.id)
self.to_delete.groups.add(mde_admin.id) self.to_delete.groups.add(mde_admin.id)
self.to_keep.groups.add(sas_admin.id) self.to_keep.groups.add(sas_admin.id)

View File

@ -9,28 +9,35 @@ interface PagePictureConfig {
userId: number; userId: number;
} }
interface Album {
id: number;
name: string;
pictures: PictureSchema[];
}
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("user_pictures", (config: PagePictureConfig) => ({ Alpine.data("user_pictures", (config: PagePictureConfig) => ({
loading: true, loading: true,
pictures: [] as PictureSchema[], albums: [] as Album[],
albums: {} as Record<string, PictureSchema[]>,
async init() { async init() {
this.pictures = await paginated(picturesFetchPictures, { const pictures = await paginated(picturesFetchPictures, {
// biome-ignore lint/style/useNamingConvention: from python api // biome-ignore lint/style/useNamingConvention: from python api
query: { users_identified: [config.userId] }, query: { users_identified: [config.userId] },
} as PicturesFetchPicturesData); } as PicturesFetchPicturesData);
const groupedAlbums = Object.groupBy(pictures, (i: PictureSchema) => i.album.id);
this.albums = this.pictures.reduce( this.albums = Object.values(groupedAlbums).map((pictures: PictureSchema[]) => {
(acc: Record<number, PictureSchema[]>, picture: PictureSchema) => { return {
if (!acc[picture.album.id]) { id: pictures[0].album.id,
acc[picture.album.id] = []; 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-")) {
this.$nextTick(() => document.getElementById(hash)?.scrollIntoView()).then();
} }
acc[picture.album.id].push(picture);
return acc;
},
{},
);
this.loading = false; this.loading = false;
}, },
})); }));

View File

@ -50,7 +50,7 @@
#} #}
{% macro download_button(name) %} {% macro download_button(name) %}
<div x-data="pictures_download"> <div x-data="pictures_download">
<div x-show="pictures.length > 0" x-cloak> <div x-show="albums.length > 0" x-cloak>
<button <button
:disabled="isDownloading" :disabled="isDownloading"
class="btn btn-blue {% if name == "" %}btn-no-text{% endif %}" class="btn btn-blue {% if name == "" %}btn-no-text{% endif %}"

View File

@ -20,17 +20,17 @@
{{ download_button(_("Download all my pictures")) }} {{ download_button(_("Download all my pictures")) }}
{% endif %} {% endif %}
<template x-for="[album_id, pictures] in Object.entries(albums)" x-cloak> <template x-for="album in albums" x-cloak>
<section> <section>
<br /> <br />
<div class="row"> <div class="row">
<h4 x-text="pictures[0].album.name" :id="`album-${album_id}`"></h4> <h4 x-text="album.name" :id="`album-${album.id}`"></h4>
{% if user.id == object.id %} {% if user.id == object.id %}
&nbsp;{{ download_button("") }} &nbsp;{{ download_button("") }}
{% endif %} {% endif %}
</div> </div>
<div class="photos"> <div class="photos">
<template x-for="picture in pictures"> <template x-for="picture in album.pictures">
<a :href="picture.sas_url"> <a :href="picture.sas_url">
<div <div
class="photo" class="photo"

View File

@ -381,10 +381,10 @@ SITH_GROUP_SAS_ADMIN_ID = env.int("SITH_GROUP_SAS_ADMIN_ID", default=8)
SITH_GROUP_FORUM_ADMIN_ID = env.int("SITH_GROUP_FORUM_ADMIN_ID", default=9) SITH_GROUP_FORUM_ADMIN_ID = env.int("SITH_GROUP_FORUM_ADMIN_ID", default=9)
SITH_GROUP_PEDAGOGY_ADMIN_ID = env.int("SITH_GROUP_PEDAGOGY_ADMIN_ID", default=10) SITH_GROUP_PEDAGOGY_ADMIN_ID = env.int("SITH_GROUP_PEDAGOGY_ADMIN_ID", default=10)
SITH_GROUP_BANNED_ALCOHOL_ID = env.int("SITH_GROUP_BANNED_ALCOHOL_ID", default=11) SITH_GROUP_BANNED_ALCOHOL_ID = env.int("SITH_GROUP_BANNED_ALCOHOL_ID", default=12)
SITH_GROUP_BANNED_COUNTER_ID = env.int("SITH_GROUP_BANNED_COUNTER_ID", default=12) SITH_GROUP_BANNED_COUNTER_ID = env.int("SITH_GROUP_BANNED_COUNTER_ID", default=13)
SITH_GROUP_BANNED_SUBSCRIPTION_ID = env.int( SITH_GROUP_BANNED_SUBSCRIPTION_ID = env.int(
"SITH_GROUP_BANNED_SUBSCRIPTION_ID", default=13 "SITH_GROUP_BANNED_SUBSCRIPTION_ID", default=14
) )
SITH_CLUB_REFOUND_ID = env.int("SITH_CLUB_REFOUND_ID", default=89) SITH_CLUB_REFOUND_ID = env.int("SITH_CLUB_REFOUND_ID", default=89)

View File

@ -4,7 +4,7 @@
"sourceMap": true, "sourceMap": true,
"noImplicitAny": true, "noImplicitAny": true,
"module": "esnext", "module": "esnext",
"target": "es2022", "target": "es2024",
"allowJs": true, "allowJs": true,
"moduleResolution": "node", "moduleResolution": "node",
"experimentalDecorators": true, "experimentalDecorators": true,

2
uv.lock generated
View File

@ -1852,7 +1852,7 @@ dev = [
{ name = "ipython", specifier = ">=9.0.2,<10.0.0" }, { name = "ipython", specifier = ">=9.0.2,<10.0.0" },
{ name = "pre-commit", specifier = ">=4.1.0,<5.0.0" }, { name = "pre-commit", specifier = ">=4.1.0,<5.0.0" },
{ name = "rjsmin", specifier = ">=1.2.4,<2.0.0" }, { name = "rjsmin", specifier = ">=1.2.4,<2.0.0" },
{ name = "ruff", specifier = ">=0.11.11,<1.0.0" }, { name = "ruff", specifier = ">=0.11.13,<1.0.0" },
] ]
docs = [ docs = [
{ name = "mkdocs", specifier = ">=1.6.1,<2.0.0" }, { name = "mkdocs", specifier = ">=1.6.1,<2.0.0" },