mirror of
https://github.com/ae-utbm/sith.git
synced 2025-07-13 13:29:23 +00:00
Compare commits
23 Commits
galaxy
...
ia-explana
Author | SHA1 | Date | |
---|---|---|---|
c5193243a5 | |||
eb4fbcbda4 | |||
570510f18d | |||
7f371984d8 | |||
abf7bf6bfa | |||
02ef8fdb88 | |||
a7f4630d13 | |||
c7087c6e7e | |||
f38926c4a3 | |||
9a19f34ea2 | |||
67884017f8 | |||
f474edc84f
|
|||
f5a8228358 | |||
59a714af9f | |||
9049d8779c | |||
d111023363
|
|||
cdfa76ad57 | |||
88b70bf51f | |||
ca593c7d81
|
|||
94bdc5e615
|
|||
7d454749e0
|
|||
10d5b9d63f
|
|||
cc96c93d23
|
@ -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):
|
||||||
|
@ -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"])
|
||||||
|
@ -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;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
};
|
|
287
core/static/bundled/user/family-graph-index.ts
Normal file
287
core/static/bundled/user/family-graph-index.ts
Normal 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;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
89
core/static/core/footer.scss
Normal file
89
core/static/core/footer.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
@ -12,7 +18,7 @@
|
|||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
gap: 30px;
|
gap: 30px;
|
||||||
|
|
||||||
.toolbar-column{
|
.toolbar-column {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
@ -34,31 +40,38 @@
|
|||||||
|
|
||||||
.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%;
|
||||||
}
|
}
|
||||||
@ -87,14 +101,16 @@
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
> form {
|
>form {
|
||||||
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;
|
||||||
}
|
}
|
||||||
@ -122,10 +138,10 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
> div.mini_profile_link {
|
>div.mini_profile_link {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
> a {
|
>a {
|
||||||
&.mini_profile_link {
|
&.mini_profile_link {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -140,7 +156,7 @@
|
|||||||
max-height: 65px;
|
max-height: 65px;
|
||||||
}
|
}
|
||||||
|
|
||||||
> span {
|
>span {
|
||||||
height: 150px;
|
height: 150px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
@ -149,7 +165,7 @@
|
|||||||
width: 80px;
|
width: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
> img {
|
>img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
@ -163,7 +179,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
> em {
|
>em {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -195,7 +211,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
> a.mini_profile_link {
|
>a.mini_profile_link {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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 %}
|
{% include "core/base/footer.jinja" %}
|
||||||
<div>
|
{% endblock %}
|
||||||
<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 %}
|
|
||||||
<br>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
{% block script %}
|
{% block script %}
|
||||||
<script>
|
<script>
|
||||||
|
16
core/templates/core/base/footer.jinja
Normal file
16
core/templates/core/base/footer.jinja
Normal 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>
|
@ -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 %}
|
||||||
|
@ -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 %}
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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"
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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
108
docs/explanation/ia.md
Normal 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.
|
||||||
|
|
@ -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?"
|
||||||
|
@ -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
31
package-lock.json
generated
@ -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"
|
||||||
|
@ -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"
|
||||||
|
@ -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)
|
||||||
|
@ -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,
|
||||||
acc[picture.album.id].push(picture);
|
};
|
||||||
return acc;
|
});
|
||||||
},
|
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();
|
||||||
|
}
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
@ -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 %}"
|
||||||
|
@ -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 %}
|
||||||
{{ download_button("") }}
|
{{ 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"
|
||||||
|
@ -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)
|
||||||
|
@ -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
2
uv.lock
generated
@ -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" },
|
||||||
|
Reference in New Issue
Block a user