24 Commits
galaxy ... edt

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

View File

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

View File

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

View File

@ -636,9 +636,6 @@ class User(AbstractUser):
class AnonymousUser(AuthAnonymousUser): class AnonymousUser(AuthAnonymousUser):
def __init__(self):
super().__init__()
@property @property
def was_subscribed(self): def was_subscribed(self):
return False return False
@ -647,10 +644,6 @@ class AnonymousUser(AuthAnonymousUser):
def is_subscribed(self): def is_subscribed(self):
return False return False
@property
def subscribed(self):
return False
@property @property
def is_root(self): def is_root(self):
return False return False

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -23,21 +23,20 @@
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 Count, F, Q, QuerySet from django.db.models import Case, Count, F, Q, Value, When
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 Membership from club.models import Club
from core.models import User from core.models import User
from sas.models import PeoplePictureRelation, Picture from sas.models import Picture
class GalaxyStar(models.Model): class GalaxyStar(models.Model):
@ -115,9 +114,18 @@ 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 score"), default=0) family = models.PositiveIntegerField(
pictures = models.PositiveIntegerField(_("pictures score"), default=0) _("family score"),
clubs = models.PositiveIntegerField(_("clubs score"), default=0) 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})"
@ -166,7 +174,6 @@ 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.
@ -180,13 +187,15 @@ 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 += "CHAOS" s += "CHS" # CHAOS
else: else:
s += "RULED" s += "RLD" # RULED
return s return s
@classmethod @classmethod
def get_current_galaxy(cls) -> Galaxy: def get_current_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()
################### ###################
@ -194,18 +203,7 @@ class Galaxy(models.Model):
################### ###################
@classmethod @classmethod
def get_rulable_users( def compute_user_score(cls, user: User) -> int:
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
@ -213,50 +211,87 @@ 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
- ... - ...
""" """
users = ( user_score = 1
User.objects.annotate( user_score += cls.query_user_score(user)
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
res = {u["id"]: int(math.log2(u["score"] + 1)) for u in users} user_score = int(math.log2(user_score))
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_user_family_score(cls, user: User) -> defaultdict[int, int]: def compute_users_score(cls, user1: User, user2: User) -> RelationScore:
"""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
""" """
godchildren = User.objects.filter(godchildren=user).values_list("id", flat=True) link_count = User.objects.filter(
godfathers = User.objects.filter(godfathers=user).values_list("id", flat=True) Q(id=user1.id, godfathers=user2) | Q(id=user2.id, godfathers=user1)
result = defaultdict(int) ).count()
for parent in itertools.chain(godchildren, godfathers): if link_count > 0:
result[parent] += cls.FAMILY_LINK_POINTS cls.logger.debug(
return result f"\t\t- '{user1}' and '{user2}' have {link_count} direct family link"
)
return link_count * cls.FAMILY_LINK_POINTS
@classmethod @classmethod
def compute_user_pictures_score(cls, user: User) -> defaultdict[int, int]: def compute_users_pictures_score(cls, user1: User, user2: User) -> 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
@ -266,19 +301,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
""" """
common_photos = ( picture_count = (
PeoplePictureRelation.objects.filter( Picture.objects.filter(people__user__in=(user1,))
picture__in=Picture.objects.filter(people__user=user) .filter(people__user__in=(user2,))
.count()
) )
.values("user") if picture_count:
.annotate(count=Count("user")) cls.logger.debug(
) f"\t\t- '{user1}' was pictured with '{user2}' {picture_count} times"
return defaultdict(
int, {p["user"]: p["count"] * cls.PICTURE_POINTS for p in common_photos}
) )
return picture_count * cls.PICTURE_POINTS
@classmethod @classmethod
def compute_user_clubs_score(cls, user: User) -> defaultdict[int, int]: def compute_users_clubs_score(cls, user1: User, user2: User) -> 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
@ -289,36 +324,54 @@ 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
""" """
memberships = user.memberships.only("start_date", "end_date", "club_id") common_clubs = Club.objects.filter(members__in=user1.memberships.all()).filter(
result = defaultdict(int) members__in=user2.memberships.all()
now = localdate()
for membership in memberships:
# This is a N+1 query, but 92% of galaxy users have less than 10 memberships.
# Only 5 users have more than 30 memberships.
common_memberships = (
Membership.objects.exclude(user=user)
.filter(
Q( # start2 <= start1 <= end2
start_date__lte=membership.start_date,
end_date__gte=membership.start_date,
) )
| Q( # start2 <= start1 <= now user1_memberships = user1.memberships.filter(club__in=common_clubs)
start_date__lte=membership.start_date, end_date=None user2_memberships = user2.memberships.filter(club__in=common_clubs)
score = 0
for user1_membership in user1_memberships:
if user1_membership.end_date is None:
# user1_membership.save() is not called in this function, hence this is safe
user1_membership.end_date = localdate()
query = Q( # start2 <= start1 <= end2
start_date__lte=user1_membership.start_date,
end_date__gte=user1_membership.start_date,
) )
| Q( # start1 <= start2 <= end2 query |= Q( # start2 <= start1 <= now
start_date__gte=membership.start_date, start_date__lte=user1_membership.start_date, end_date=None
start_date__lte=membership.end_date or now,
),
club_id=membership.club_id,
) )
.only("start_date", "end_date", "user_id") query |= Q( # start1 <= start2 <= end2
start_date__gte=user1_membership.start_date,
start_date__lte=user1_membership.end_date,
) )
for other in common_memberships: for user2_membership in user2_memberships.filter(
start = max(membership.start_date, other.start_date) query, club=user1_membership.club
end = min(membership.end_date or now, other.end_date or now) ):
result[other.user_id] += (end - start).days * cls.CLUBS_POINTS if user2_membership.end_date is None:
return result 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,
)
)
score += cls.CLUBS_POINTS * (earliest_end - latest_start).days
return score
################### ###################
# Rule the galaxy # # Rule the galaxy #
@ -353,9 +406,7 @@ 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( def rule(self, picture_count_threshold=10) -> None:
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.
@ -376,30 +427,41 @@ 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(self.get_rulable_users(picture_count_threshold)) rulable_users = list(rulable_users)
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")
individual_scores = self.compute_individual_scores() for user in rulable_users:
GalaxyStar.objects.bulk_create( star = GalaxyStar(
[ owner=user, galaxy=self, mass=self.compute_user_score(user)
GalaxyStar(owner=user, galaxy=self, mass=individual_scores[user.id])
for user in rulable_users
]
) )
stars = {star.owner_id: star for star in self.stars.all()} stars.append(star)
GalaxyStar.objects.bulk_create(stars)
stars = {}
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()
@ -410,19 +472,20 @@ class Galaxy(models.Model):
star1 = stars[user1.id] star1 = stars[user1.id]
lanes = [] user_avg_speed = 0
family_scores = self.compute_user_family_score(user1) user_avg_speed_count = 0
picture_scores = self.compute_user_pictures_score(user1)
club_scores = self.compute_user_clubs_score(user1) tstart = time.time()
lanes = []
for user2_count, user2 in enumerate(rulable_users, start=1):
self.logger.debug("")
self.logger.debug(
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 = RelationScore( score = Galaxy.compute_users_score(user1, user2)
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(
@ -435,8 +498,22 @@ 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
@ -444,17 +521,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
if user1_count % 50 == 0:
self.logger.info("")
self.logger.info(f" Ruling of {self} ".center(60, "#")) self.logger.info(f" Ruling of {self} ".center(60, "#"))
self.logger.info( self.logger.info(
f"Progression: {user1_count}/{rulable_users_count} " f"Progression: {user1_count}/{rulable_users_count} citizen -- {rulable_users_count - user1_count} remaining"
f"citizen -- {rulable_users_count - user1_count} remaining"
) )
self.logger.info(f"Speed: {global_avg_speed:.2f} citizen per second") self.logger.info(f"Speed: {60.0 * global_avg_speed:.2f} citizen per minute")
eta = rulable_users_count2 // global_avg_speed
# 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
eta = rulable_users_count2 / global_avg_speed / 2
eta_hours = int(eta // 3600)
eta_minutes = int(eta // 60 % 60)
self.logger.info( self.logger.info(
f"ETA: {int(eta // 60 % 60)} minutes {int(eta % 60)} seconds" 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()
@ -477,10 +556,11 @@ 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_minutes} minutes, {total_time_seconds} seconds" f"{self} ruled in {total_time:.2f} seconds ({total_time_hours} hours, {total_time_minutes} minutes, {total_time_seconds} seconds)"
) )
def make_state(self) -> None: def make_state(self) -> None:
@ -488,33 +568,58 @@ 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("owner_id") .order_by(
.select_related("owner") "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("star1") .order_by(
"star1"
) # This helps determinism for the tests and doesn't cost much
.annotate( .annotate(
star1_owner=F("star1__owner_id"), star2_owner=F("star2__owner_id") star1_owner=F("star1__owner__id"),
star2_owner=F("star2__owner__id"),
) )
) )
json = GalaxyDict( json = GalaxyDict(
nodes=[ nodes=[
StarDict( StarDict(
id=star.owner_id, name=star.owner.get_display_name(), mass=star.mass id=star.owner_id,
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()

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,19 +48,15 @@ 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(1): with self.assertNumQueries(8):
scores = Galaxy.compute_individual_scores() assert Galaxy.compute_user_score(self.root) == 9
expected = { assert Galaxy.compute_user_score(self.skia) == 10
self.root.id: 9, assert Galaxy.compute_user_score(self.sli) == 8
self.skia.id: 10, assert Galaxy.compute_user_score(self.krophil) == 2
self.sli.id: 8, assert Galaxy.compute_user_score(self.richard) == 10
self.krophil.id: 2, assert Galaxy.compute_user_score(self.subscriber) == 8
self.richard.id: 10, assert Galaxy.compute_user_score(self.public) == 8
self.subscriber.id: 8, assert Galaxy.compute_user_score(self.com) == 1
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
@ -122,23 +118,17 @@ class TestGalaxyModel(TestCase):
self.com, self.com,
] ]
with self.assertNumQueries(44): with self.assertNumQueries(100):
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": ( "score": sum(score),
family_scores[user2.id] "family": score.family,
+ picture_scores[user2.id] "pictures": score.pictures,
+ club_scores[user2.id] "clubs": score.clubs,
),
"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
@ -150,12 +140,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(39): with self.assertNumQueries(58):
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-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?"

88
package-lock.json generated
View File

@ -29,6 +29,8 @@
"d3-force-3d": "^3.0.5", "d3-force-3d": "^3.0.5",
"easymde": "^2.19.0", "easymde": "^2.19.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"html-to-image": "^1.11.13",
"html2canvas": "^1.4.1",
"htmx.org": "^2.0.3", "htmx.org": "^2.0.3",
"jquery": "^3.7.1", "jquery": "^3.7.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
@ -45,7 +47,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 +2824,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",
@ -3053,6 +3085,15 @@
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
} }
}, },
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -3441,6 +3482,15 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/cytoscape": { "node_modules/cytoscape": {
"version": "3.32.0", "version": "3.32.0",
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.32.0.tgz", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.32.0.tgz",
@ -4119,6 +4169,25 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/htmx.org": { "node_modules/htmx.org": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz", "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz",
@ -5421,6 +5490,15 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/three": { "node_modules/three": {
"version": "0.177.0", "version": "0.177.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.177.0.tgz", "resolved": "https://registry.npmjs.org/three/-/three-0.177.0.tgz",
@ -5558,7 +5636,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"
@ -5679,6 +5756,15 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "6.3.5", "version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",

View File

@ -31,6 +31,9 @@
"@rollup/plugin-inject": "^5.0.5", "@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.10", "@types/alpinejs": "^3.13.10",
"@types/jquery": "^3.5.31", "@types/jquery": "^3.5.31",
"@types/cytoscape-cxtmenu": "^3.4.4",
"@types/cytoscape-klay": "^3.1.4",
"typescript": "^5.8.3",
"vite": "^6.2.5", "vite": "^6.2.5",
"vite-bundle-visualizer": "^1.2.1", "vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.0.2" "vite-plugin-static-copy": "^3.0.2"
@ -56,6 +59,7 @@
"d3-force-3d": "^3.0.5", "d3-force-3d": "^3.0.5",
"easymde": "^2.19.0", "easymde": "^2.19.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"html2canvas": "^1.4.1",
"htmx.org": "^2.0.3", "htmx.org": "^2.0.3",
"jquery": "^3.7.1", "jquery": "^3.7.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",

View File

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

View File

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

View File

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

View File

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

View File

@ -124,6 +124,7 @@ INSTALLED_APPS = (
"pedagogy", "pedagogy",
"galaxy", "galaxy",
"antispam", "antispam",
"timetable",
"api", "api",
) )
@ -381,10 +382,10 @@ SITH_GROUP_SAS_ADMIN_ID = env.int("SITH_GROUP_SAS_ADMIN_ID", default=8)
SITH_GROUP_FORUM_ADMIN_ID = env.int("SITH_GROUP_FORUM_ADMIN_ID", default=9) SITH_GROUP_FORUM_ADMIN_ID = env.int("SITH_GROUP_FORUM_ADMIN_ID", default=9)
SITH_GROUP_PEDAGOGY_ADMIN_ID = env.int("SITH_GROUP_PEDAGOGY_ADMIN_ID", default=10) SITH_GROUP_PEDAGOGY_ADMIN_ID = env.int("SITH_GROUP_PEDAGOGY_ADMIN_ID", default=10)
SITH_GROUP_BANNED_ALCOHOL_ID = env.int("SITH_GROUP_BANNED_ALCOHOL_ID", default=11) SITH_GROUP_BANNED_ALCOHOL_ID = env.int("SITH_GROUP_BANNED_ALCOHOL_ID", default=12)
SITH_GROUP_BANNED_COUNTER_ID = env.int("SITH_GROUP_BANNED_COUNTER_ID", default=12) SITH_GROUP_BANNED_COUNTER_ID = env.int("SITH_GROUP_BANNED_COUNTER_ID", default=13)
SITH_GROUP_BANNED_SUBSCRIPTION_ID = env.int( SITH_GROUP_BANNED_SUBSCRIPTION_ID = env.int(
"SITH_GROUP_BANNED_SUBSCRIPTION_ID", default=13 "SITH_GROUP_BANNED_SUBSCRIPTION_ID", default=14
) )
SITH_CLUB_REFOUND_ID = env.int("SITH_CLUB_REFOUND_ID", default=89) SITH_CLUB_REFOUND_ID = env.int("SITH_CLUB_REFOUND_ID", default=89)

View File

@ -49,6 +49,7 @@ urlpatterns = [
path("i18n/", include("django.conf.urls.i18n")), path("i18n/", include("django.conf.urls.i18n")),
path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"), path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"),
path("captcha/", include("captcha.urls")), path("captcha/", include("captcha.urls")),
path("timetable/", include(("timetable.urls", "timetable"), namespace="timetable")),
] ]
if settings.DEBUG: if settings.DEBUG:

0
timetable/__init__.py Normal file
View File

1
timetable/admin.py Normal file
View File

@ -0,0 +1 @@
# Register your models here.

6
timetable/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class TimetableConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "timetable"

View File

1
timetable/models.py Normal file
View File

@ -0,0 +1 @@
# Create your models here.

View File

@ -0,0 +1,134 @@
import html2canvas from "html2canvas";
// see https://regex101.com/r/QHSaPM/2
const TIMETABLE_ROW_RE: RegExp =
/^(?<ueCode>[A-Z\d]{4}(?:\+[A-Z\d]{4})?)\s+(?<courseType>[A-Z]{2}\d)\s+((?<weekGroup>[AB])\s+)?(?<weekday>(lundi)|(mardi)|(mercredi)|(jeudi)|(vendredi)|(samedi)|(dimanche))\s+(?<startHour>\d{2}:\d{2})\s+(?<endHour>\d{2}:\d{2})\s+[\dA-B]\s+(?:[\wé]*\s+)?(?<room>\w+(?:, \w+)?)$/;
const DEFAULT_TIMETABLE: string = `DS52\t\tCM1\t\tlundi\t08:00\t10:00\t1\tPrésentiel\tA113
DS53\t\tCM1\t\tlundi\t10:15\t12:15\t1\tPrésentiel\tA101
DS53\t\tTP1\t\tlundi\t13:00\t16:00\t1\tPrésentiel\tH010
SO03\t\tCM1\t\tlundi\t16:15\t17:45\t1\tPrésentiel\tA103
SO03\t\tTD1\t\tlundi\t17:45\t19:45\t1\tPrésentiel\tA103
DS50\t\tTP1\t\tmardi\t08:00\t10:00\t1\tPrésentiel\tA216
DS51\t\tCM1\t\tmardi\t10:15\t12:15\t1\tPrésentiel\tA216
DS51\t\tTP1\t\tmardi\t14:00\t18:00\t1\tPrésentiel\tH010
DS52\t\tTP2\tA\tjeudi\t08:00\t10:00\tA\tPrésentiel\tA110a, A110b
DS52\t\tTD1\t\tjeudi\t10:15\t12:15\t1\tPrésentiel\tA110a, A110b
LC02\t\tTP1\t\tjeudi\t15:00\t16:00\t1\tPrésentiel\tA209
LC02\t\tTD1\t\tjeudi\t16:15\t18:15\t1\tPrésentiel\tA206`;
type WeekDay =
| "lundi"
| "mardi"
| "mercredi"
| "jeudi"
| "vendredi"
| "samedi"
| "dimanche";
const WEEKDAYS = [
"lundi",
"mardi",
"mercredi",
"jeudi",
"vendredi",
"samedi",
"dimanche",
] as const;
const SLOT_HEIGHT = 20 as const; // Each 15min has a height of 20px in the timetable
const SLOT_WIDTH = 400 as const; // Each weekday ha a width of 400px in the timetable
const MINUTES_PER_SLOT = 15 as const;
interface TimetableSlot {
courseType: string;
room: string;
startHour: string;
endHour: string;
startSlot: number;
endSlot: number;
ueCode: string;
weekGroup?: string;
weekday: WeekDay;
}
function parseSlots(s: string): TimetableSlot[] {
return s
.split("\n")
.filter((s: string) => s.length > 0)
.map((row: string) => {
const parsed = TIMETABLE_ROW_RE.exec(row);
if (!parsed) {
throw new Error(`Couldn't parse row ${row}`);
}
const [startHour, startMin] = parsed.groups.startHour
.split(":")
.map((i) => Number.parseInt(i));
const [endHour, endMin] = parsed.groups.endHour
.split(":")
.map((i) => Number.parseInt(i));
return {
...parsed.groups,
startSlot: Math.floor((startHour * 60 + startMin) / MINUTES_PER_SLOT),
endSlot: Math.floor((endHour * 60 + endMin) / MINUTES_PER_SLOT),
} as unknown as TimetableSlot;
});
}
document.addEventListener("alpine:init", () => {
Alpine.data("timetableGenerator", () => ({
content: DEFAULT_TIMETABLE,
error: "",
displayedWeekdays: [] as WeekDay[],
courses: [] as TimetableSlot[],
startSlot: 0,
table: {
height: 0,
width: 0,
},
generate() {
try {
this.courses = parseSlots(this.content);
} catch {
this.error = gettext(
"Wrong timetable format. Make sure you copied if from your student folder.",
);
return;
}
this.displayedWeekdays = WEEKDAYS.filter((day) =>
this.courses.some((slot: TimetableSlot) => slot.weekday === day),
);
this.startSlot = this.courses.reduce(
(acc: number, curr: TimetableSlot) => Math.min(acc, curr.startSlot),
24 * 4,
);
this.endSlot = this.courses.reduce(
(acc: number, curr: TimetableSlot) => Math.max(acc, curr.endSlot),
0,
);
this.table.height = SLOT_HEIGHT * (this.endSlot - this.startSlot);
this.table.width = SLOT_WIDTH * this.displayedWeekdays.length;
},
getStyle(slot: TimetableSlot) {
return {
height: `${(slot.endSlot - slot.startSlot) * SLOT_HEIGHT}px`,
width: `${SLOT_WIDTH}px`,
top: `${(slot.startSlot - this.startSlot) * SLOT_HEIGHT}px`,
left: `${this.displayedWeekdays.indexOf(slot.weekday) * SLOT_WIDTH}px`,
};
},
async savePng() {
const elem = document.getElementById("timetable");
const img = (await html2canvas(elem)).toDataURL();
const downloadLink = document.createElement("a");
downloadLink.href = img;
downloadLink.download = "edt.png";
document.body.appendChild(downloadLink);
downloadLink.click();
downloadLink.remove();
},
}));
});

View File

@ -0,0 +1,28 @@
#timetable {
display: block;
margin: 2em auto ;
.header {
background-color: white;
box-shadow: none;
width: 100%;
display: flex;
flex-direction: row;
gap: 0;
span {
flex: 1;
text-align: center;
}
}
.content {
position: relative;
text-align: center;
.slot {
background-color: cadetblue;
position: absolute;
display: flex;
flex-direction: column;
justify-content: center;
}
}
}

View File

@ -0,0 +1,56 @@
{% extends 'core/base.jinja' %}
{%- block additional_css -%}
<link rel="stylesheet" href="{{ static('timetable/css/generator.scss') }}">
{%- endblock -%}
{%- block additional_js -%}
<script type="module" src="{{ static('bundled/timetable/generator-index.ts') }}"></script>
{%- endblock -%}
{% block title %}
{% trans %}Timeplan generator{% endtrans %}
{% endblock %}
{% block content %}
<div x-data="timetableGenerator">
<form @submit.prevent="generate()">
<h1>Générateur d'emploi du temps</h1>
<div class="alert alert-red" x-show="!!error" x-cloak>
<p class="alert-main" x-text="error"></p>
</div>
<div class="form-group">
<label for="timetable-input">Colle ton emploi du temps (sans l'entête)</label>
<textarea id="timetable-input" cols="30" rows="15" x-model="content"></textarea>
</div>
<input type="submit" class="btn btn-blue" value="{% trans %}Generate{% endtrans %}">
</form>
<div
id="timetable"
x-show="table.height > 0 && table.width > 0"
:style="{width: `${table.width}px`, height: `${table.height+40}px`}"
>
<div class="header">
<template x-for="weekday in displayedWeekdays">
<span x-text="weekday"></span>
</template>
</div>
<div class="content">
<template x-for="course in courses">
<div class="slot" :style="getStyle(course)">
<span x-text="`${course.ueCode} (${course.courseType})`"></span>
<span x-text="`${course.startHour} - ${course.endHour}`"></span>
<span x-text="course.room"></span>
</div>
</template>
</div>
</div>
<button
class="margin-bottom btn btn-blue"
@click="savePng"
x-show="table.height > 0 && table.width > 0"
>
{% trans %}Save to PNG{% endtrans %}
</button>
</div>
{% endblock content %}

1
timetable/tests.py Normal file
View File

@ -0,0 +1 @@
# Create your tests here.

5
timetable/urls.py Normal file
View File

@ -0,0 +1,5 @@
from django.urls import path
from timetable.views import GeneratorView
urlpatterns = [path("generator/", GeneratorView.as_view(), name="generator")]

10
timetable/views.py Normal file
View File

@ -0,0 +1,10 @@
# Create your views here.
from django.contrib.auth.mixins import UserPassesTestMixin
from django.views.generic import TemplateView
class GeneratorView(UserPassesTestMixin, TemplateView):
template_name = "timetable/generator.jinja"
def test_func(self):
return self.request.user.is_subscribed

View File

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

2
uv.lock generated
View File

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