1 Commits

Author SHA1 Message Date
b812d7bcdd Optimize galaxy generation
En réorganisant les requêtes à la db, on diminue par 100 le temps d'exécution de la commande `rule_galaxy` (~6h => ~2min)
2025-06-19 22:46:20 +02:00
30 changed files with 611 additions and 1036 deletions

View File

@ -59,7 +59,6 @@ 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):
@ -785,13 +784,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="Publique") public_group = Group.objects.create(name="Public")
subscribers = Group.objects.create(name="Cotisants") subscribers = Group.objects.create(name="Subscribers")
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="Anciens cotisants") old_subscribers = Group.objects.create(name="Old subscribers")
old_subscribers.permissions.add( old_subscribers.permissions.add(
*list( *list(
perms.filter( perms.filter(
@ -813,7 +812,7 @@ class Command(BaseCommand):
) )
) )
accounting_admin = Group.objects.create( accounting_admin = Group.objects.create(
name="Admin comptabilité", is_manually_manageable=True name="Accounting admin", is_manually_manageable=True
) )
accounting_admin.permissions.add( accounting_admin.permissions.add(
*list( *list(
@ -834,7 +833,7 @@ class Command(BaseCommand):
) )
) )
com_admin = Group.objects.create( com_admin = Group.objects.create(
name="Admin communication", is_manually_manageable=True name="Communication admin", is_manually_manageable=True
) )
com_admin.permissions.add( com_admin.permissions.add(
*list( *list(
@ -842,7 +841,7 @@ class Command(BaseCommand):
) )
) )
counter_admin = Group.objects.create( counter_admin = Group.objects.create(
name="Admin comptoirs", is_manually_manageable=True name="Counter admin", is_manually_manageable=True
) )
counter_admin.permissions.add( counter_admin.permissions.add(
*list( *list(
@ -852,14 +851,14 @@ class Command(BaseCommand):
) )
) )
) )
sas_admin = Group.objects.create(name="Admin SAS", is_manually_manageable=True) sas_admin = Group.objects.create(name="SAS admin", 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="Admin forum", is_manually_manageable=True name="Forum admin", is_manually_manageable=True
) )
forum_admin.permissions.add( forum_admin.permissions.add(
*list( *list(
@ -869,7 +868,7 @@ class Command(BaseCommand):
) )
) )
pedagogy_admin = Group.objects.create( pedagogy_admin = Group.objects.create(
name="Admin pédagogie", is_manually_manageable=True name="Pedagogy admin", is_manually_manageable=True
) )
pedagogy_admin.permissions.add( pedagogy_admin.permissions.add(
*list( *list(
@ -878,16 +877,6 @@ 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(
@ -900,7 +889,6 @@ 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,13 +238,7 @@ 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( Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"])
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

@ -0,0 +1,274 @@
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

@ -1,287 +0,0 @@
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

@ -1,89 +0,0 @@
@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,6 +713,47 @@ 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,12 +4,6 @@
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;
@ -18,7 +12,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;
@ -40,38 +34,31 @@
.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;
} }
@ -87,7 +74,6 @@
@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%;
} }
@ -101,16 +87,14 @@
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;
} }
@ -138,10 +122,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;
@ -156,7 +140,7 @@
max-height: 65px; max-height: 65px;
} }
>span { > span {
height: 150px; height: 150px;
width: 100%; width: 100%;
@ -165,7 +149,7 @@
width: 80px; width: 80px;
} }
>img { > img {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
@ -179,7 +163,7 @@
} }
} }
>em { > em {
box-sizing: border-box; box-sizing: border-box;
padding: 0 5px; padding: 0 5px;
text-align: center; text-align: center;
@ -211,7 +195,7 @@
} }
} }
>a.mini_profile_link { > a.mini_profile_link {
display: none; display: none;
} }
} }

View File

@ -11,7 +11,6 @@
<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') }}">
@ -90,9 +89,22 @@
</div> </div>
</div> </div>
{% block footer %} <footer>
{% include "core/base/footer.jinja" %} {% block footer %}
{% endblock %} <div>
<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>

View File

@ -1,16 +0,0 @@
<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,11 +26,9 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
<form method="post" action="{{ url('core:login') }}" id="login-form"> <form method="post" action="{{ url('core:login') }}">
{% if form.errors %} {% if form.errors %}
<p class="alert alert-red"> <p class="alert alert-red">{% trans %}Your username and password didn't match. Please try again.{% endtrans %}</p>
{% 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.ts") }}"></script> <script type="module" src="{{ static("bundled/user/family-graph-index.js") }}"></script>
{% endblock %} {% endblock %}
{% block title %} {% block title %}
@ -15,14 +15,7 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div <div x-data="graph" :aria-busy="loading">
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">
@ -93,36 +86,17 @@
</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,7 +38,6 @@ 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
@ -152,44 +151,24 @@ 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"))
@pytest.mark.parametrize( def test_login_fail(self, client, user):
"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": identifier, "password": "wrong-password"}, {"username": user.username, "password": "wrong-password"},
) )
assert response.status_code == 200 assert response.status_code == 200
assert response.wsgi_request.user.is_anonymous
soup = BeautifulSoup(response.text, "lxml")
form = soup.find(id="login-form")
assert ( assert (
form.find(class_="alert alert-red").get_text(strip=True) '<p class="alert alert-red">Votre nom d\'utilisateur '
== "Vos identifiants ne correspondent pas. Veuillez réessayer." "et votre mot de passe ne correspondent pas. Merci de réessayer.</p>"
) ) in response.text
assert form.find("input", attrs={"name": "username"}).get("value") == identifier assert response.wsgi_request.user.is_anonymous
@pytest.mark.parametrize( def test_login_success(self, client, user):
"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": identifier_getter(user), "password": "plop"}, {"username": user.username, "password": "plop"},
) )
assertRedirects(response, reverse("core:index")) assertRedirects(response, reverse("core:index"))
assert response.wsgi_request.user == user assert response.wsgi_request.user == user
@ -382,9 +361,17 @@ class TestUserIsInGroup(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.public_group = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID) cls.root_group = Group.objects.get(name="Root")
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)
@ -392,7 +379,15 @@ 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 Group.objects.exclude(id=self.public_group.id): for group in (
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,31 +132,29 @@ 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,7 +41,6 @@ class ProductAdmin(SearchModelAdmin):
"profit", "profit",
"archived", "archived",
) )
list_select_related = ("product_type",)
search_fields = ("name", "code") search_fields = ("name", "code")
@ -82,13 +81,20 @@ class AccountDumpAdmin(admin.ModelAdmin):
"customer", "customer",
"warning_mail_sent_at", "warning_mail_sent_at",
"warning_mail_error", "warning_mail_error",
"dump_operation__date", "dump_operation",
"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):
@ -107,14 +113,11 @@ 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",
@ -123,8 +126,6 @@ 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,7 +17,6 @@ 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
@ -824,53 +823,3 @@ 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( Permanency.objects.filter(counter=counter_id, user=request.POST["user_id"]).update(
counter=counter_id, user=request.POST["user_id"], end=None end=F("activity")
).update(end=now()) )
return redirect("counter:details", counter_id=counter_id) return redirect("counter:details", counter_id=counter_id)

View File

@ -1,108 +0,0 @@
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

@ -23,20 +23,21 @@
from __future__ import annotations from __future__ import annotations
import itertools
import logging import logging
import math import math
import time import time
from collections import defaultdict
from typing import NamedTuple, TypedDict from typing import NamedTuple, TypedDict
from django.db import models from django.db import models
from django.db.models import Case, Count, F, Q, Value, When from django.db.models import Count, F, Q, QuerySet
from django.db.models.functions import Concat
from django.utils.timezone import localdate from django.utils.timezone import localdate
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from club.models import Club from club.models import Membership
from core.models import User from core.models import User
from sas.models import Picture from sas.models import PeoplePictureRelation, Picture
class GalaxyStar(models.Model): class GalaxyStar(models.Model):
@ -114,18 +115,9 @@ class GalaxyLane(models.Model):
default=0, default=0,
help_text=_("Distance separating star1 and star2"), help_text=_("Distance separating star1 and star2"),
) )
family = models.PositiveIntegerField( family = models.PositiveIntegerField(_("family score"), default=0)
_("family score"), pictures = models.PositiveIntegerField(_("pictures score"), default=0)
default=0, clubs = models.PositiveIntegerField(_("clubs score"), default=0)
)
pictures = models.PositiveIntegerField(
_("pictures score"),
default=0,
)
clubs = models.PositiveIntegerField(
_("clubs score"),
default=0,
)
def __str__(self): def __str__(self):
return f"{self.star1} -> {self.star2} ({self.distance})" return f"{self.star1} -> {self.star2} ({self.distance})"
@ -174,6 +166,7 @@ class Galaxy(models.Model):
logger = logging.getLogger("main") logger = logging.getLogger("main")
GALAXY_SCALE_FACTOR = 2_000 GALAXY_SCALE_FACTOR = 2_000
DEFAULT_PICTURE_COUNT_THRESHOLD = 10
FAMILY_LINK_POINTS = 366 # Equivalent to a leap year together in a club, because. FAMILY_LINK_POINTS = 366 # Equivalent to a leap year together in a club, because.
PICTURE_POINTS = 2 # Equivalent to two days as random members of a club. PICTURE_POINTS = 2 # Equivalent to two days as random members of a club.
CLUBS_POINTS = 1 # One day together as random members in a club is one point. CLUBS_POINTS = 1 # One day together as random members in a club is one point.
@ -187,15 +180,13 @@ class Galaxy(models.Model):
stars_count = self.stars.count() stars_count = self.stars.count()
s = f"GLX-ID{self.pk}-SC{stars_count}-" s = f"GLX-ID{self.pk}-SC{stars_count}-"
if self.state is None: if self.state is None:
s += "CHS" # CHAOS s += "CHAOS"
else: else:
s += "RLD" # RULED s += "RULED"
return s return s
@classmethod @classmethod
def get_current_galaxy( def get_current_galaxy(cls) -> Galaxy:
cls,
) -> Galaxy: # __future__.annotations is required for this
return Galaxy.objects.filter(state__isnull=False).last() return Galaxy.objects.filter(state__isnull=False).last()
################### ###################
@ -203,7 +194,18 @@ class Galaxy(models.Model):
################### ###################
@classmethod @classmethod
def compute_user_score(cls, user: User) -> int: def get_rulable_users(
cls, picture_count_threshold: int = DEFAULT_PICTURE_COUNT_THRESHOLD
) -> QuerySet[User]:
return (
User.objects.exclude(subscriptions=None)
.annotate(pictures_count=Count("pictures"))
.filter(pictures_count__gt=picture_count_threshold)
.distinct()
)
@classmethod
def compute_individual_scores(cls) -> dict[int, int]:
"""Compute an individual score for each citizen. """Compute an individual score for each citizen.
It will later be used by the graph algorithm to push It will later be used by the graph algorithm to push
@ -211,87 +213,50 @@ class Galaxy(models.Model):
Idea: This could be added to the computation: Idea: This could be added to the computation:
- Forum posts
- Picture count - Picture count
- Counter consumption - Counter consumption
- Barman time - Barman time
- ... - ...
""" """
user_score = 1 users = (
user_score += cls.query_user_score(user) User.objects.annotate(
score=(
Count("godchildren", distinct=True) * cls.FAMILY_LINK_POINTS
+ Count("godfathers", distinct=True) * cls.FAMILY_LINK_POINTS
+ Count("pictures", distinct=True) * cls.PICTURE_POINTS
+ Count("memberships", distinct=True) * cls.CLUBS_POINTS
)
)
.filter(score__gt=0)
.values("id", "score")
)
# TODO: # TODO:
# Scale that value with some magic number to accommodate to typical data # Scale that value with some magic number to accommodate to typical data
# Really active galaxy citizen after 5 years typically have a score of about XXX # Really active galaxy citizen after 5 years typically have a score of about XXX
# Citizen that were seen regularly without taking much part in organizations typically have a score of about XXX # Citizen that were seen regularly without taking much part in organizations typically have a score of about XXX
# Citizen that only went to a few events typically score about XXX # Citizen that only went to a few events typically score about XXX
user_score = int(math.log2(user_score)) res = {u["id"]: int(math.log2(u["score"] + 1)) for u in users}
return res
return user_score
@classmethod
def query_user_score(cls, user: User) -> int:
"""Get the individual score of the given user in the galaxy."""
score_query = (
User.objects.filter(id=user.id)
.annotate(
godchildren_count=Count("godchildren", distinct=True)
* cls.FAMILY_LINK_POINTS,
godfathers_count=Count("godfathers", distinct=True)
* cls.FAMILY_LINK_POINTS,
pictures_score=Count("pictures", distinct=True) * cls.PICTURE_POINTS,
clubs_score=Count("memberships", distinct=True) * cls.CLUBS_POINTS,
)
.aggregate(
score=models.Sum(
F("godchildren_count")
+ F("godfathers_count")
+ F("pictures_score")
+ F("clubs_score")
)
)
)
return score_query.get("score")
#################### ####################
# Inter-user score # # Inter-user score #
#################### ####################
@classmethod @classmethod
def compute_users_score(cls, user1: User, user2: User) -> RelationScore: def compute_user_family_score(cls, user: User) -> defaultdict[int, int]:
"""Compute the relationship scores of the two given users.
The computation is done with the following fields :
- family: if they have some godfather/godchild relation
- pictures: in how many pictures are both tagged
- clubs: during how many days they were members of the same clubs
"""
family = cls.compute_users_family_score(user1, user2)
pictures = cls.compute_users_pictures_score(user1, user2)
clubs = cls.compute_users_clubs_score(user1, user2)
return RelationScore(family=family, pictures=pictures, clubs=clubs)
@classmethod
def compute_users_family_score(cls, user1: User, user2: User) -> int:
"""Compute the family score of the relation between the given users. """Compute the family score of the relation between the given users.
This takes into account mutual godfathers. This takes into account mutual godfathers.
Returns:
366 if user1 is the godfather of user2 (or vice versa) else 0
""" """
link_count = User.objects.filter( godchildren = User.objects.filter(godchildren=user).values_list("id", flat=True)
Q(id=user1.id, godfathers=user2) | Q(id=user2.id, godfathers=user1) godfathers = User.objects.filter(godfathers=user).values_list("id", flat=True)
).count() result = defaultdict(int)
if link_count > 0: for parent in itertools.chain(godchildren, godfathers):
cls.logger.debug( result[parent] += cls.FAMILY_LINK_POINTS
f"\t\t- '{user1}' and '{user2}' have {link_count} direct family link" return result
)
return link_count * cls.FAMILY_LINK_POINTS
@classmethod @classmethod
def compute_users_pictures_score(cls, user1: User, user2: User) -> int: def compute_user_pictures_score(cls, user: User) -> defaultdict[int, int]:
"""Compute the pictures score of the relation between the given users. """Compute the pictures score of the relation between the given users.
The pictures score is obtained by counting the number The pictures score is obtained by counting the number
@ -301,19 +266,19 @@ class Galaxy(models.Model):
Returns: Returns:
The number of pictures both users have in common, times 2 The number of pictures both users have in common, times 2
""" """
picture_count = ( common_photos = (
Picture.objects.filter(people__user__in=(user1,)) PeoplePictureRelation.objects.filter(
.filter(people__user__in=(user2,)) picture__in=Picture.objects.filter(people__user=user)
.count()
)
if picture_count:
cls.logger.debug(
f"\t\t- '{user1}' was pictured with '{user2}' {picture_count} times"
) )
return picture_count * cls.PICTURE_POINTS .values("user")
.annotate(count=Count("user"))
)
return defaultdict(
int, {p["user"]: p["count"] * cls.PICTURE_POINTS for p in common_photos}
)
@classmethod @classmethod
def compute_users_clubs_score(cls, user1: User, user2: User) -> int: def compute_user_clubs_score(cls, user: User) -> defaultdict[int, int]:
"""Compute the clubs score of the relation between the given users. """Compute the clubs score of the relation between the given users.
The club score is obtained by counting the number of days The club score is obtained by counting the number of days
@ -324,54 +289,36 @@ class Galaxy(models.Model):
(two years) and user2 was a member of the same club from 01/01/2021 to (two years) and user2 was a member of the same club from 01/01/2021 to
31/12/2022 (also two years, but with an offset of one year), then their 31/12/2022 (also two years, but with an offset of one year), then their
club score is 365. club score is 365.
Returns:
the number of days during which both users were in the same club
""" """
common_clubs = Club.objects.filter(members__in=user1.memberships.all()).filter( memberships = user.memberships.only("start_date", "end_date", "club_id")
members__in=user2.memberships.all() result = defaultdict(int)
) now = localdate()
user1_memberships = user1.memberships.filter(club__in=common_clubs) for membership in memberships:
user2_memberships = user2.memberships.filter(club__in=common_clubs) # This is a N+1 query, but 92% of galaxy users have less than 10 memberships.
# Only 5 users have more than 30 memberships.
score = 0 common_memberships = (
for user1_membership in user1_memberships: Membership.objects.exclude(user=user)
if user1_membership.end_date is None: .filter(
# user1_membership.save() is not called in this function, hence this is safe Q( # start2 <= start1 <= end2
user1_membership.end_date = localdate() start_date__lte=membership.start_date,
query = Q( # start2 <= start1 <= end2 end_date__gte=membership.start_date,
start_date__lte=user1_membership.start_date,
end_date__gte=user1_membership.start_date,
)
query |= Q( # start2 <= start1 <= now
start_date__lte=user1_membership.start_date, end_date=None
)
query |= Q( # start1 <= start2 <= end2
start_date__gte=user1_membership.start_date,
start_date__lte=user1_membership.end_date,
)
for user2_membership in user2_memberships.filter(
query, club=user1_membership.club
):
if user2_membership.end_date is None:
user2_membership.end_date = localdate()
latest_start = max(
user1_membership.start_date, user2_membership.start_date
)
earliest_end = min(user1_membership.end_date, user2_membership.end_date)
cls.logger.debug(
"\t\t- '%s' was with '%s' in %s starting on %s until %s (%s days)"
% (
user1,
user2,
user2_membership.club,
latest_start,
earliest_end,
(earliest_end - latest_start).days,
) )
| Q( # start2 <= start1 <= now
start_date__lte=membership.start_date, end_date=None
)
| Q( # start1 <= start2 <= end2
start_date__gte=membership.start_date,
start_date__lte=membership.end_date or now,
),
club_id=membership.club_id,
) )
score += cls.CLUBS_POINTS * (earliest_end - latest_start).days .only("start_date", "end_date", "user_id")
return score )
for other in common_memberships:
start = max(membership.start_date, other.start_date)
end = min(membership.end_date or now, other.end_date or now)
result[other.user_id] += (end - start).days * cls.CLUBS_POINTS
return result
################### ###################
# Rule the galaxy # # Rule the galaxy #
@ -406,7 +353,9 @@ class Galaxy(models.Model):
cls.logger.debug(f"\t\t> Scaled distance: {value}") cls.logger.debug(f"\t\t> Scaled distance: {value}")
return int(value) return int(value)
def rule(self, picture_count_threshold=10) -> None: def rule(
self, picture_count_threshold: int = DEFAULT_PICTURE_COUNT_THRESHOLD
) -> None:
"""Main function of the Galaxy. """Main function of the Galaxy.
Iterate over all the rulable users to promote them to citizens. Iterate over all the rulable users to promote them to citizens.
@ -427,41 +376,30 @@ class Galaxy(models.Model):
""" """
total_time = time.time() total_time = time.time()
self.logger.info("Listing rulable citizen.") self.logger.info("Listing rulable citizen.")
rulable_users = (
User.objects.filter(subscriptions__isnull=False)
.annotate(pictures_count=Count("pictures"))
.filter(pictures_count__gt=picture_count_threshold)
.distinct()
)
# force fetch of the whole query to make sure there won't # force fetch of the whole query to make sure there won't
# be any more db hits # be any more db hits
# this is memory expensive but prevents a lot of db hits, therefore # this is memory expensive but prevents a lot of db hits, therefore
# is far more time efficient # is far more time efficient
rulable_users = list(rulable_users) rulable_users = list(self.get_rulable_users(picture_count_threshold))
rulable_users_count = len(rulable_users) rulable_users_count = len(rulable_users)
user1_count = 0 user1_count = 0
self.logger.info( self.logger.info(
f"{rulable_users_count} citizen have been listed. Starting to rule." f"{rulable_users_count} citizen have been listed. Starting to rule."
) )
stars = []
self.logger.info("Creating stars for all citizen") self.logger.info("Creating stars for all citizen")
for user in rulable_users: individual_scores = self.compute_individual_scores()
star = GalaxyStar( GalaxyStar.objects.bulk_create(
owner=user, galaxy=self, mass=self.compute_user_score(user) [
) GalaxyStar(owner=user, galaxy=self, mass=individual_scores[user.id])
stars.append(star) for user in rulable_users
GalaxyStar.objects.bulk_create(stars) ]
)
stars = {} stars = {star.owner_id: star for star in self.stars.all()}
for star in GalaxyStar.objects.filter(galaxy=self):
stars[star.owner.id] = star
self.logger.info("Creating lanes between stars") self.logger.info("Creating lanes between stars")
# Display current speed every $speed_count_frequency users
speed_count_frequency = max(rulable_users_count // 10, 1) # ten time at most
global_avg_speed_accumulator = 0 global_avg_speed_accumulator = 0
global_avg_speed_count = 0 global_avg_speed_count = 0
t_global_start = time.time() t_global_start = time.time()
@ -472,20 +410,19 @@ class Galaxy(models.Model):
star1 = stars[user1.id] star1 = stars[user1.id]
user_avg_speed = 0
user_avg_speed_count = 0
tstart = time.time()
lanes = [] lanes = []
for user2_count, user2 in enumerate(rulable_users, start=1): family_scores = self.compute_user_family_score(user1)
self.logger.debug("") picture_scores = self.compute_user_pictures_score(user1)
self.logger.debug( club_scores = self.compute_user_clubs_score(user1)
f"\t> Examining '{user1}' ({user1_count}/{rulable_users_count}) with '{user2}' ({user2_count}/{rulable_users_count2})"
)
for user2 in rulable_users:
star2 = stars[user2.id] star2 = stars[user2.id]
score = Galaxy.compute_users_score(user1, user2) score = RelationScore(
family=family_scores.get(user2.id, 0),
pictures=picture_scores.get(user2.id, 0),
clubs=club_scores.get(user2.id, 0),
)
distance = self.scale_distance(sum(score)) distance = self.scale_distance(sum(score))
if distance < 30: # TODO: this needs tuning with real-world data if distance < 30: # TODO: this needs tuning with real-world data
lanes.append( lanes.append(
@ -498,22 +435,8 @@ class Galaxy(models.Model):
clubs=score.clubs, clubs=score.clubs,
) )
) )
if user2_count % speed_count_frequency == 0:
tend = time.time()
delta = tend - tstart
speed = float(speed_count_frequency) / delta
user_avg_speed += speed
user_avg_speed_count += 1
self.logger.debug(
f"\tSpeed: {speed:.2f} users per second (time for last {speed_count_frequency} citizens: {delta:.2f} second)"
)
tstart = time.time()
GalaxyLane.objects.bulk_create(lanes) GalaxyLane.objects.bulk_create(lanes)
self.logger.info("")
t_global_end = time.time() t_global_end = time.time()
global_delta = t_global_end - t_global_start global_delta = t_global_end - t_global_start
speed = 1.0 / global_delta speed = 1.0 / global_delta
@ -521,21 +444,19 @@ class Galaxy(models.Model):
global_avg_speed_count += 1 global_avg_speed_count += 1
global_avg_speed = global_avg_speed_accumulator / global_avg_speed_count global_avg_speed = global_avg_speed_accumulator / global_avg_speed_count
self.logger.info(f" Ruling of {self} ".center(60, "#")) if user1_count % 50 == 0:
self.logger.info( self.logger.info("")
f"Progression: {user1_count}/{rulable_users_count} citizen -- {rulable_users_count - user1_count} remaining" self.logger.info(f" Ruling of {self} ".center(60, "#"))
) self.logger.info(
self.logger.info(f"Speed: {60.0 * global_avg_speed:.2f} citizen per minute") f"Progression: {user1_count}/{rulable_users_count} "
f"citizen -- {rulable_users_count - user1_count} remaining"
# We can divide the computed ETA by 2 because each loop, there is one citizen less to check, and maths tell )
# us that this averages to a division by two self.logger.info(f"Speed: {global_avg_speed:.2f} citizen per second")
eta = rulable_users_count2 / global_avg_speed / 2 eta = rulable_users_count2 // global_avg_speed
eta_hours = int(eta // 3600) self.logger.info(
eta_minutes = int(eta // 60 % 60) f"ETA: {int(eta // 60 % 60)} minutes {int(eta % 60)} seconds"
self.logger.info( )
f"ETA: {eta_hours} hours {eta_minutes} minutes ({eta / 3600 / 24:.2f} days)" self.logger.info("#" * 60)
)
self.logger.info("#" * 60)
t_global_start = time.time() t_global_start = time.time()
# Here, we get the IDs of the old galaxies that we'll need to delete. In normal operation, only one galaxy # Here, we get the IDs of the old galaxies that we'll need to delete. In normal operation, only one galaxy
@ -556,11 +477,10 @@ class Galaxy(models.Model):
Galaxy.objects.filter(pk__in=old_galaxies_pks).delete() Galaxy.objects.filter(pk__in=old_galaxies_pks).delete()
total_time = time.time() - total_time total_time = time.time() - total_time
total_time_hours = int(total_time // 3600)
total_time_minutes = int(total_time // 60 % 60) total_time_minutes = int(total_time // 60 % 60)
total_time_seconds = int(total_time % 60) total_time_seconds = int(total_time % 60)
self.logger.info( self.logger.info(
f"{self} ruled in {total_time:.2f} seconds ({total_time_hours} hours, {total_time_minutes} minutes, {total_time_seconds} seconds)" f"{self} ruled in {total_time_minutes} minutes, {total_time_seconds} seconds"
) )
def make_state(self) -> None: def make_state(self) -> None:
@ -568,59 +488,34 @@ class Galaxy(models.Model):
self.logger.info( self.logger.info(
"Caching current Galaxy state for a quicker display of the Empire's power." "Caching current Galaxy state for a quicker display of the Empire's power."
) )
without_nickname = Concat(
F("owner__first_name"), Value(" "), F("owner__last_name")
)
with_nickname = Concat(
F("owner__first_name"),
Value(" "),
F("owner__last_name"),
Value(" ("),
F("owner__nick_name"),
Value(")"),
)
stars = ( stars = (
GalaxyStar.objects.filter(galaxy=self) GalaxyStar.objects.filter(galaxy=self)
.order_by( .order_by("owner_id")
"owner" .select_related("owner")
) # This helps determinism for the tests and doesn't cost much
.annotate(
owner_name=Case(
When(owner__nick_name=None, then=without_nickname),
default=with_nickname,
)
)
) )
lanes = ( lanes = (
GalaxyLane.objects.filter(star1__galaxy=self) GalaxyLane.objects.filter(star1__galaxy=self)
.order_by( .order_by("star1")
"star1"
) # This helps determinism for the tests and doesn't cost much
.annotate( .annotate(
star1_owner=F("star1__owner__id"), star1_owner=F("star1__owner_id"), star2_owner=F("star2__owner_id")
star2_owner=F("star2__owner__id"),
) )
) )
json = GalaxyDict( json = GalaxyDict(
nodes=[ nodes=[
StarDict( StarDict(
id=star.owner_id, id=star.owner_id, name=star.owner.get_display_name(), mass=star.mass
name=star.owner_name,
mass=star.mass,
) )
for star in stars for star in stars
], ],
links=[], links=[
)
for path in lanes:
json["links"].append(
{ {
"source": path.star1_owner, "source": path.star1_owner,
"target": path.star2_owner, "target": path.star2_owner,
"value": path.distance, "value": path.distance,
} }
) for path in lanes
],
)
self.state = json self.state = json
self.save() self.save()
self.logger.info(f"{self} is now ready!") self.logger.info(f"{self} is now ready!")

View File

@ -33,7 +33,7 @@ from core.models import User
from galaxy.models import Galaxy from galaxy.models import Galaxy
@pytest.mark.skip(reason="Galaxy is disabled for now") # @pytest.mark.skip(reason="Galaxy is disabled for now")
class TestGalaxyModel(TestCase): class TestGalaxyModel(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -48,15 +48,19 @@ class TestGalaxyModel(TestCase):
def test_user_self_score(self): def test_user_self_score(self):
"""Test that individual user scores are correct.""" """Test that individual user scores are correct."""
with self.assertNumQueries(8): with self.assertNumQueries(1):
assert Galaxy.compute_user_score(self.root) == 9 scores = Galaxy.compute_individual_scores()
assert Galaxy.compute_user_score(self.skia) == 10 expected = {
assert Galaxy.compute_user_score(self.sli) == 8 self.root.id: 9,
assert Galaxy.compute_user_score(self.krophil) == 2 self.skia.id: 10,
assert Galaxy.compute_user_score(self.richard) == 10 self.sli.id: 8,
assert Galaxy.compute_user_score(self.subscriber) == 8 self.krophil.id: 2,
assert Galaxy.compute_user_score(self.public) == 8 self.richard.id: 10,
assert Galaxy.compute_user_score(self.com) == 1 self.subscriber.id: 8,
self.public.id: 8,
self.com.id: 1,
}
assert scores.items() >= expected.items()
def test_users_score(self): def test_users_score(self):
"""Test on the default dataset generated by the `populate` command """Test on the default dataset generated by the `populate` command
@ -118,17 +122,23 @@ class TestGalaxyModel(TestCase):
self.com, self.com,
] ]
with self.assertNumQueries(100): with self.assertNumQueries(44):
while len(users) > 0: while len(users) > 0:
user1 = users.pop(0) user1 = users.pop(0)
family_scores = Galaxy.compute_user_family_score(user1)
picture_scores = Galaxy.compute_user_pictures_score(user1)
club_scores = Galaxy.compute_user_clubs_score(user1)
for user2 in users: for user2 in users:
score = Galaxy.compute_users_score(user1, user2)
u1 = computed_scores.get(user1.username, {}) u1 = computed_scores.get(user1.username, {})
u1[user2.username] = { u1[user2.username] = {
"score": sum(score), "score": (
"family": score.family, family_scores[user2.id]
"pictures": score.pictures, + picture_scores[user2.id]
"clubs": score.clubs, + club_scores[user2.id]
),
"family": family_scores[user2.id],
"pictures": picture_scores[user2.id],
"clubs": club_scores[user2.id],
} }
computed_scores[user1.username] = u1 computed_scores[user1.username] = u1
@ -140,12 +150,12 @@ class TestGalaxyModel(TestCase):
that the number of queries to rule the galaxy is stable. that the number of queries to rule the galaxy is stable.
""" """
galaxy = Galaxy.objects.create() galaxy = Galaxy.objects.create()
with self.assertNumQueries(58): with self.assertNumQueries(39):
galaxy.rule(0) # We want everybody here galaxy.rule(0) # We want everybody here
@pytest.mark.slow @pytest.mark.slow
@pytest.mark.skip(reason="Galaxy is disabled for now") # @pytest.mark.skip(reason="Galaxy is disabled for now")
class TestGalaxyView(TestCase): class TestGalaxyView(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

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-25 16:29+0200\n" "POT-Creation-Date: 2025-06-16 14:54+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,8 +2015,10 @@ 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 credentials didn't match. Please try again." msgid "Your username and password didn't match. Please try again."
msgstr "Vos identifiants ne correspondent pas. Veuillez réessayer." msgstr ""
"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,7 +57,6 @@ 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,10 +45,7 @@
"@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"
@ -2822,33 +2819,6 @@
"@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",
@ -5588,6 +5558,7 @@
"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,9 +31,6 @@
"@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(id=settings.SITH_GROUP_SUBSCRIBERS_ID) subscribers = Group.objects.get(name="Subscribers")
mde_admin = Group.objects.get(name="MDE admin") mde_admin = Group.objects.get(name="MDE admin")
sas_admin = Group.objects.get(id=settings.SITH_GROUP_SAS_ADMIN_ID) sas_admin = Group.objects.get(name="SAS admin")
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,35 +9,28 @@ 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,
albums: [] as Album[], pictures: [] as PictureSchema[],
albums: {} as Record<string, PictureSchema[]>,
async init() { async init() {
const pictures = await paginated(picturesFetchPictures, { this.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 = Object.values(groupedAlbums).map((pictures: PictureSchema[]) => { this.albums = this.pictures.reduce(
return { (acc: Record<number, PictureSchema[]>, picture: PictureSchema) => {
id: pictures[0].album.id, if (!acc[picture.album.id]) {
name: pictures[0].album.name, acc[picture.album.id] = [];
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;
}, },
})); }));

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="albums.length > 0" x-cloak> <div x-show="pictures.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 in albums" x-cloak> <template x-for="[album_id, pictures] in Object.entries(albums)" x-cloak>
<section> <section>
<br /> <br />
<div class="row"> <div class="row">
<h4 x-text="album.name" :id="`album-${album.id}`"></h4> <h4 x-text="pictures[0].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 album.pictures"> <template x-for="picture in 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=12) SITH_GROUP_BANNED_ALCOHOL_ID = env.int("SITH_GROUP_BANNED_ALCOHOL_ID", default=11)
SITH_GROUP_BANNED_COUNTER_ID = env.int("SITH_GROUP_BANNED_COUNTER_ID", default=13) SITH_GROUP_BANNED_COUNTER_ID = env.int("SITH_GROUP_BANNED_COUNTER_ID", default=12)
SITH_GROUP_BANNED_SUBSCRIPTION_ID = env.int( SITH_GROUP_BANNED_SUBSCRIPTION_ID = env.int(
"SITH_GROUP_BANNED_SUBSCRIPTION_ID", default=14 "SITH_GROUP_BANNED_SUBSCRIPTION_ID", default=13
) )
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": "es2024", "target": "es2022",
"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.13,<1.0.0" }, { name = "ruff", specifier = ">=0.11.11,<1.0.0" },
] ]
docs = [ docs = [
{ name = "mkdocs", specifier = ">=1.6.1,<2.0.0" }, { name = "mkdocs", specifier = ">=1.6.1,<2.0.0" },