43 Commits

Author SHA1 Message Date
5b57f75b4e custom django command for promo logos
added path vailidity verification and IOError handling

added option to overwrite existing logo and force flag

improved uppon suggestions

mistake correction

fixed string conversion bugs and logical error

corrected path conversion

f

better error handling and corrections

ajout d'une section de documentation pour la feature

copié coller

fixed documentation bullet points

added resampling clean up error handling

removed useless IOError
2025-07-03 14:28:16 +02:00
3e3c6631ff Merge pull request #1146 from ae-utbm/fix-ts
Fix ts
2025-07-02 09:01:24 +02:00
a3ac04fc9e fix TS types 2025-06-30 18:35:53 +02:00
6e724a9c74 extract AlertMessage to its own file 2025-06-30 18:17:29 +02:00
c177ef2a3a Merge pull request #1145 from ae-utbm/xapian
fix: xapian compilation flags
2025-06-30 13:46:02 +02:00
6cf8910626 fix: xapian compilation flags 2025-06-30 13:09:24 +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
06090e0cd9 Merge pull request #1133 from ae-utbm/api-fixes
fix: api title typo (again)
2025-06-18 12:25:31 +02:00
a1ae67da7d Merge pull request #1132 from ae-utbm/missing-perm
Missing SAS permission
2025-06-18 12:25:15 +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
8cc0b01e9c fix: api title typo (again) 2025-06-17 21:01:51 +02:00
88755358a6 fix: add missing sas permission 2025-06-17 21:00:38 +02:00
0e850e5486 Merge pull request #1131 from ae-utbm/api-fixes
Api fixes
2025-06-17 15:57:33 +02:00
af67c5fc27 Merge pull request #1130 from ae-utbm/navbar-keyboard-navigation
Fix click on navbar
2025-06-17 15:41:42 +02:00
Sli
30809a69c9 Move navbar script to dedicated file 2025-06-17 15:39:35 +02:00
0c442a8f03 fix: select only active club members on GET /club/{club_id} 2025-06-17 15:35:49 +02:00
f1b69dd47d fix: typo in API name 2025-06-17 15:35:49 +02:00
Sli
b5ebf09fcb Fix click on navbar 2025-06-17 15:31:51 +02:00
9d9ce5b30a Merge pull request #1129 from ae-utbm/fix-docs
fix: documentation CI/CD
2025-06-17 15:09:06 +02:00
a87460fa3e fix: documentation CI/CD 2025-06-17 14:45:51 +02:00
48fae33651 Merge pull request #1119 from ae-utbm/notifs
Improve notification on picture identification
2025-06-17 11:22:06 +02:00
6fec250658 display album name on picture identification notif 2025-06-16 18:36:08 +02:00
75b37cd6e3 fix album grouping on user pictures page 2025-06-16 18:36:08 +02:00
52 changed files with 984 additions and 593 deletions

View File

@ -1,15 +1,24 @@
name: "Setup project"
description: "Setup Python and Poetry"
inputs:
full:
description: >
If true, do a full setup, else install
only python, uv and non-xapian python deps
required: false
default: "false"
runs:
using: composite
steps:
- name: Install apt packages
if: ${{ inputs.full == 'true' }}
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
with:
packages: gettext
version: 1.0 # increment to reset cache
- name: Install Redis
if: ${{ inputs.full == 'true' }}
uses: shogo82148/actions-setup-redis@v1
with:
redis-version: "7.x"
@ -37,15 +46,20 @@ runs:
shell: bash
- name: Install Xapian
if: ${{ inputs.full == 'true' }}
run: uv run ./manage.py install_xapian
shell: bash
# compiling xapian accounts for almost the entirety of the virtualenv setup,
# so we save the virtual environment only on workflows where it has been installed
- name: Save cached virtualenv
if: ${{ inputs.full == 'true' }}
uses: actions/cache/save@v4
with:
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
path: .venv
- name: Compile gettext messages
if: ${{ inputs.full == 'true' }}
run: uv run ./manage.py compilemessages
shell: bash

View File

@ -37,6 +37,8 @@ jobs:
- name: Check out repository
uses: actions/checkout@v4
- uses: ./.github/actions/setup_project
with:
full: true
env:
# To avoid race conditions on environment cache
CACHE_SUFFIX: ${{ matrix.pytest-mark }}

View File

@ -2,11 +2,7 @@ name: deploy_docs
on:
push:
branches:
- master
env:
SECRET_KEY: notTheRealOne
DATABASE_URL: sqlite:///db.sqlite3
CACHE_URL: redis://127.0.0.1:6379/0
- taiste
permissions:
contents: write
jobs:

View File

@ -2,7 +2,7 @@ from ninja_extra import NinjaExtraAPI
api = NinjaExtraAPI(
title="PICON",
description="Portail Interaction de Communication avec les Services Étudiants",
description="Portail Interactif de Communication avec les Outils Numériques",
version="0.2.0",
urls_namespace="api",
csrf=True,

View File

@ -1,6 +1,7 @@
from typing import Annotated
from annotated_types import MinLen
from django.db.models import Prefetch
from ninja.security import SessionAuth
from ninja_extra import ControllerBase, api_controller, paginate, route
from ninja_extra.pagination import PageNumberPaginationExtra
@ -8,7 +9,7 @@ from ninja_extra.schemas import PaginatedResponseSchema
from api.auth import ApiKeyAuth
from api.permissions import CanAccessLookup, HasPerm
from club.models import Club
from club.models import Club, Membership
from club.schemas import ClubSchema, SimpleClubSchema
@ -33,6 +34,9 @@ class ClubController(ControllerBase):
url_name="fetch_club",
)
def fetch_club(self, club_id: int):
return self.get_object_or_exception(
Club.objects.prefetch_related("members", "members__user"), id=club_id
prefetch = Prefetch(
"members", queryset=Membership.objects.ongoing().select_related("user")
)
return self.get_object_or_exception(
Club.objects.prefetch_related(prefetch), id=club_id
)

View File

@ -1,7 +1,10 @@
from datetime import date, timedelta
import pytest
from django.test import Client
from django.urls import reverse
from model_bakery import baker
from model_bakery.recipe import Recipe
from pytest_django.asserts import assertNumQueries
from club.models import Club, Membership
@ -9,13 +12,32 @@ from core.baker_recipes import subscriber_user
@pytest.mark.django_db
def test_fetch_club(client: Client):
club = baker.make(Club)
baker.make(Membership, club=club, _quantity=10, _bulk_create=True)
user = subscriber_user.make()
client.force_login(user)
with assertNumQueries(7):
# - 4 queries for authentication
# - 3 queries for the actual data
class TestFetchClub:
@pytest.fixture()
def club(self):
club = baker.make(Club)
last_month = date.today() - timedelta(days=30)
yesterday = date.today() - timedelta(days=1)
membership_recipe = Recipe(Membership, club=club, start_date=last_month)
membership_recipe.make(end_date=None, _quantity=10, _bulk_create=True)
membership_recipe.make(end_date=yesterday, _quantity=10, _bulk_create=True)
return club
def test_fetch_club_members(self, client: Client, club: Club):
user = subscriber_user.make()
client.force_login(user)
res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id}))
assert res.status_code == 200
member_ids = {member["user"]["id"] for member in res.json()["members"]}
assert member_ids == set(
club.members.ongoing().values_list("user_id", flat=True)
)
def test_fetch_club_nb_queries(self, client: Client, club: Club):
user = subscriber_user.make()
client.force_login(user)
with assertNumQueries(6):
# - 4 queries for authentication
# - 2 queries for the actual data
res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id}))
assert res.status_code == 200

View File

@ -170,7 +170,6 @@ def news_notification_callback(notif: Notification):
if count:
notif.viewed = False
notif.param = str(count)
notif.date = timezone.now()
else:
notif.viewed = True

View File

@ -0,0 +1,41 @@
import pathlib
from django.apps import apps
from django.core.management.base import BaseCommand
from PIL import Image, UnidentifiedImageError
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument("number", type=int)
parser.add_argument("path", type=pathlib.Path)
parser.add_argument("-f", "--force", action="store_true")
def handle(self, number: int, path: pathlib.Path, force: int, *args, **options):
if not path.exists() or path.is_dir():
self.stderr.write(f"{path} is not a file or does not exist")
return
dest_path = (
pathlib.Path(apps.get_app_config("core").path)
/ "static"
/ "core"
/ "img"
/ f"promo_{number}.png"
)
if dest_path.exists() and not force:
over = input("File already exists, do you want to overwrite it? (y/N):")
if over.lower() != "y":
self.stdout.write("exiting")
return
try:
im = Image.open(path)
im.resize((120, 120), resample=Image.Resampling.LANCZOS).save(
dest_path, format="PNG"
)
self.stdout.write(
f"Promo logo moved and resized successfully at {dest_path}"
)
except UnidentifiedImageError:
self.stderr.write("image cannot be opened and identified.")

View File

@ -4,13 +4,13 @@
VERSION="$1"
# Cleanup env vars for auto discovery mechanism
export CPATH=
export LIBRARY_PATH=
export CFLAGS=
export LDFLAGS=
export CCFLAGS=
export CXXFLAGS=
export CPPFLAGS=
unset CPATH
unset LIBRARY_PATH
unset CFLAGS
unset LDFLAGS
unset CCFLAGS
unset CXXFLAGS
unset CPPFLAGS
# prepare
rm -rf "$VIRTUAL_ENV/packages"

View File

@ -59,6 +59,7 @@ class PopulatedGroups(NamedTuple):
counter_admin: Group
accounting_admin: Group
pedagogy_admin: Group
campus_admin: Group
class Command(BaseCommand):
@ -784,13 +785,13 @@ class Command(BaseCommand):
# public has no permission.
# Its purpose is not to link users to permissions,
# 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(
*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(
*list(
perms.filter(
@ -812,7 +813,7 @@ class Command(BaseCommand):
)
)
accounting_admin = Group.objects.create(
name="Accounting admin", is_manually_manageable=True
name="Admin comptabilité", is_manually_manageable=True
)
accounting_admin.permissions.add(
*list(
@ -833,7 +834,7 @@ class Command(BaseCommand):
)
)
com_admin = Group.objects.create(
name="Communication admin", is_manually_manageable=True
name="Admin communication", is_manually_manageable=True
)
com_admin.permissions.add(
*list(
@ -841,7 +842,7 @@ class Command(BaseCommand):
)
)
counter_admin = Group.objects.create(
name="Counter admin", is_manually_manageable=True
name="Admin comptoirs", is_manually_manageable=True
)
counter_admin.permissions.add(
*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(
*list(
perms.filter(content_type__app_label="sas").values_list("pk", flat=True)
)
)
forum_admin = Group.objects.create(
name="Forum admin", is_manually_manageable=True
name="Admin forum", is_manually_manageable=True
)
forum_admin.permissions.add(
*list(
@ -868,7 +869,7 @@ class Command(BaseCommand):
)
)
pedagogy_admin = Group.objects.create(
name="Pedagogy admin", is_manually_manageable=True
name="Admin pédagogie", is_manually_manageable=True
)
pedagogy_admin.permissions.add(
*list(
@ -877,6 +878,16 @@ class Command(BaseCommand):
.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")
return PopulatedGroups(
@ -889,6 +900,7 @@ class Command(BaseCommand):
accounting_admin=accounting_admin,
sas_admin=sas_admin,
pedagogy_admin=pedagogy_admin,
campus_admin=campus_admin,
)
def _create_ban_groups(self):

View File

@ -238,7 +238,13 @@ class Command(BaseCommand):
ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID)
other_clubs = random.sample(list(Club.objects.all()), k=3)
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(
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"])

View File

@ -0,0 +1,27 @@
# Generated by Django 5.2.1 on 2025-06-11 16:10
from django.db import migrations, models
import core.models
class Migration(migrations.Migration):
dependencies = [("core", "0046_permissionrights")]
operations = [
migrations.AlterField(
model_name="notification",
name="date",
field=models.DateTimeField(auto_now=True, verbose_name="date"),
),
migrations.AlterField(
model_name="notification",
name="type",
field=models.CharField(
choices=core.models.get_notification_types,
default="GENERIC",
max_length=32,
verbose_name="type",
),
),
]

View File

@ -1451,6 +1451,10 @@ class PageRev(models.Model):
return self.page.can_be_edited_by(user)
def get_notification_types():
return settings.SITH_NOTIFICATIONS
class Notification(models.Model):
user = models.ForeignKey(
User, related_name="notifications", on_delete=models.CASCADE
@ -1458,9 +1462,9 @@ class Notification(models.Model):
url = models.CharField(_("url"), max_length=255)
param = models.CharField(_("param"), max_length=128, default="")
type = models.CharField(
_("type"), max_length=32, choices=settings.SITH_NOTIFICATIONS, default="GENERIC"
_("type"), max_length=32, choices=get_notification_types, default="GENERIC"
)
date = models.DateTimeField(_("date"), default=timezone.now)
date = models.DateTimeField(_("date"), auto_now=True)
viewed = models.BooleanField(_("viewed"), default=False, db_index=True)
def __str__(self):

View File

@ -0,0 +1,36 @@
import { exportToHtml } from "#core:utils/globals";
exportToHtml("showMenu", () => {
const navbar = document.getElementById("navbar-content");
const current = navbar.getAttribute("mobile-display");
navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden");
});
document.addEventListener("alpine:init", () => {
const menuItems = document.querySelectorAll(".navbar details[name='navbar'].menu");
const isDesktop = () => {
return window.innerWidth >= 500;
};
for (const item of menuItems) {
item.addEventListener("mouseover", () => {
if (isDesktop()) {
item.setAttribute("open", "");
}
});
item.addEventListener("mouseout", () => {
if (isDesktop()) {
item.removeAttribute("open");
}
});
item.addEventListener("click", (event: MouseEvent) => {
// Don't close when clicking on desktop mode
if ((event.target as HTMLElement).nodeName !== "SUMMARY" || event.detail === 0) {
return;
}
if (isDesktop()) {
event.preventDefault();
}
});
}
});

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,38 @@
interface AlertParams {
success?: boolean;
duration?: number;
}
export class AlertMessage {
public open: boolean;
public success: boolean;
public content: string;
private timeoutId?: number;
private readonly defaultDuration: number;
constructor(params?: { defaultDuration: number }) {
this.open = false;
this.content = "";
this.timeoutId = null;
this.defaultDuration = params?.defaultDuration ?? 2000;
}
public display(message: string, params: AlertParams) {
this.clear();
this.open = true;
this.content = message;
this.success = params.success ?? true;
this.timeoutId = setTimeout(() => {
this.open = false;
this.timeoutId = null;
}, params.duration ?? this.defaultDuration);
}
public clear() {
if (this.timeoutId !== null) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
this.open = false;
}
}

View File

@ -1,5 +1,5 @@
import type { Client, Options, RequestResult, TDataShape } from "@hey-api/client-fetch";
import { client } from "#openapi";
import type { Client, RequestResult, TDataShape } from "#openapi:client";
import { type Options, client } from "#openapi";
export interface PaginatedResponse<T> {
count: number;

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;
}
/*--------------------------------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 {

View File

@ -4,6 +4,12 @@
display: block;
}
.zoom-control {
margin-right: 10px;
display: flex;
justify-content: right;
}
.graph-toolbar {
margin-top: 10px;
margin-bottom: 10px;
@ -12,7 +18,7 @@
justify-content: space-around;
gap: 30px;
.toolbar-column{
.toolbar-column {
display: flex;
flex-direction: column;
gap: 20px;
@ -34,31 +40,38 @@
.depth-choice {
white-space: nowrap;
input[type="number"] {
-webkit-appearance: textfield;
-moz-appearance: textfield;
appearance: textfield;
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
}
}
button {
background: none;
& > .fa {
&>.fa {
border-radius: 50%;
font-size: 12px;
padding: 5px;
}
&:enabled > .fa {
&:enabled>.fa {
background-color: #354a5f;
color: white;
}
&:enabled:hover > .fa {
&:enabled:hover>.fa {
color: white;
background-color: #35405f; // just a bit darker
}
&:disabled > .fa {
&:disabled>.fa {
background-color: gray;
color: white;
}
@ -74,6 +87,7 @@
@media screen and (max-width: 500px) {
flex-direction: column;
gap: 20px;
.toolbar-column {
min-width: 100%;
}
@ -87,14 +101,16 @@
padding: 10px;
box-sizing: border-box;
> form {
>form {
margin: 0;
}
}
#family-tree-link {
display: inline-block;
margin-top: 10px;
text-align: center;
@media (min-width: 450px) {
margin-right: auto;
}
@ -122,10 +138,10 @@
width: 100%;
}
> div.mini_profile_link {
>div.mini_profile_link {
position: relative;
> a {
>a {
&.mini_profile_link {
display: flex;
flex-direction: column;
@ -140,7 +156,7 @@
max-height: 65px;
}
> span {
>span {
height: 150px;
width: 100%;
@ -149,7 +165,7 @@
width: 80px;
}
> img {
>img {
width: 100%;
max-width: 100%;
max-height: 100%;
@ -163,7 +179,7 @@
}
}
> em {
>em {
box-sizing: border-box;
padding: 0 5px;
text-align: center;
@ -195,7 +211,7 @@
}
}
> a.mini_profile_link {
>a.mini_profile_link {
display: none;
}
}

View File

@ -11,6 +11,7 @@
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
<link rel="stylesheet" href="{{ static('core/header.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/accordion.scss') }}">
@ -18,6 +19,7 @@
<noscript><link rel="stylesheet" href="{{ static('bundled/fontawesome-index.css') }}"></noscript>
<script src="{{ url('javascript-catalog') }}"></script>
<script type="module" src={{ static("bundled/core/navbar-index.ts") }}></script>
<script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script>
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
@ -88,58 +90,12 @@
</div>
</div>
<footer>
{% block footer %}
<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 footer %}
{% include "core/base/footer.jinja" %}
{% endblock %}
{% block script %}
<script>
const menuItems = document.querySelectorAll(".navbar details[name='navbar'].menu");
const isDesktop = () => {
return window.innerWidth >= 500;
}
for (const item of menuItems){
item.addEventListener("mouseover", () => {
if (isDesktop()){
item.setAttribute("open", "");
}
})
item.addEventListener("mouseout", () => {
if (isDesktop()){
item.removeAttribute("open");
}
})
item.addEventListener("click", (event) => {
// Ignore keyboard clicks
if (event.detail === 0){
return;
}
if (isDesktop()){
event.preventDefault();
}
})
}
function showMenu() {
let navbar = document.getElementById("navbar-content");
const current = navbar.getAttribute("mobile-display");
navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden")
}
document.addEventListener("keydown", (e) => {
// Looking at the `s` key when not typing in a form
if (e.keyCode !== 83 || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) {

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 %}
<form method="post" action="{{ url('core:login') }}">
<form method="post" action="{{ url('core:login') }}" id="login-form">
{% 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 %}
{% csrf_token %}

View File

@ -7,7 +7,7 @@
{%- endblock -%}
{% 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 %}
{% block title %}
@ -15,7 +15,14 @@
{% endblock %}
{% 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="toolbar-column">
<div class="toolbar-input">
@ -86,17 +93,36 @@
</button>
</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>
<script>
window.addEventListener("DOMContentLoaded", () => {
loadFamilyGraph({
activeUser: {{ object.id }},
depthMin: {{ depth_min }},
depthMax: {{ depth_max }},
});
});
</script>
{% endblock %}

View File

@ -38,6 +38,7 @@ from core.markdown import markdown
from core.models import AnonymousUser, Group, Page, User
from core.utils import get_semester_code, get_start_of_semester
from core.views import AllowFragment
from counter.models import Customer
from sith import settings
@ -151,24 +152,44 @@ class TestUserLogin:
def user(self) -> User:
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."""
identifier = identifier_getter(user)
response = client.post(
reverse("core:login"),
{"username": user.username, "password": "wrong-password"},
{"username": identifier, "password": "wrong-password"},
)
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
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."""
response = client.post(
reverse("core:login"),
{"username": user.username, "password": "plop"},
{"username": identifier_getter(user), "password": "plop"},
)
assertRedirects(response, reverse("core:index"))
assert response.wsgi_request.user == user
@ -361,17 +382,9 @@ class TestUserIsInGroup(TestCase):
@classmethod
def setUpTestData(cls):
cls.root_group = Group.objects.get(name="Root")
cls.public_group = Group.objects.get(name="Public")
cls.public_group = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)
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.main_club = Club.objects.get(id=1)
def assert_in_public_group(self, user):
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):
self.assert_in_public_group(user)
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,
):
for group in Group.objects.exclude(id=self.public_group.id):
assert not user.is_in_group(pk=group.pk)
assert not user.is_in_group(name=group.name)

View File

@ -132,29 +132,31 @@ class FutureDateTimeField(forms.DateTimeField):
class LoginForm(AuthenticationForm):
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)
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):
error_css_class = "error"

View File

@ -41,6 +41,7 @@ class ProductAdmin(SearchModelAdmin):
"profit",
"archived",
)
list_select_related = ("product_type",)
search_fields = ("name", "code")
@ -81,20 +82,13 @@ class AccountDumpAdmin(admin.ModelAdmin):
"customer",
"warning_mail_sent_at",
"warning_mail_error",
"dump_operation",
"dump_operation__date",
"amount",
)
list_select_related = ("customer", "customer__user", "dump_operation")
autocomplete_fields = ("customer", "dump_operation")
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)
class CounterAdmin(admin.ModelAdmin):
@ -113,11 +107,14 @@ class RefillingAdmin(SearchModelAdmin):
"customer__account_id",
"counter__name",
)
list_filter = (("counter", admin.RelatedOnlyFieldListFilter),)
date_hierarchy = "date"
@admin.register(Selling)
class SellingAdmin(SearchModelAdmin):
list_display = ("customer", "label", "unit_price", "quantity", "counter", "date")
list_select_related = ("customer", "customer__user", "counter")
search_fields = (
"customer__user__username",
"customer__user__first_name",
@ -126,6 +123,8 @@ class SellingAdmin(SearchModelAdmin):
"counter__name",
)
autocomplete_fields = ("customer", "seller")
list_filter = (("counter", admin.RelatedOnlyFieldListFilter),)
date_hierarchy = "date"
@admin.register(Permanency)

View File

@ -1,3 +1,4 @@
import { AlertMessage } from "#core:utils/alert-message";
import { BasketItem } from "#counter:counter/basket";
import type { CounterConfig, ErrorMessage } from "#counter:counter/types";
import type { CounterProductSelect } from "./components/counter-product-select-index.ts";
@ -5,14 +6,9 @@ import type { CounterProductSelect } from "./components/counter-product-select-i
document.addEventListener("alpine:init", () => {
Alpine.data("counter", (config: CounterConfig) => ({
basket: {} as Record<string, BasketItem>,
errors: [],
customerBalance: config.customerBalance,
codeField: null as CounterProductSelect | null,
alertMessage: {
content: "",
show: false,
timeout: null,
},
alertMessage: new AlertMessage({ defaultDuration: 2000 }),
init() {
// Fill the basket with the initial data
@ -77,22 +73,10 @@ document.addEventListener("alpine:init", () => {
return total;
},
showAlertMessage(message: string) {
if (this.alertMessage.timeout !== null) {
clearTimeout(this.alertMessage.timeout);
}
this.alertMessage.content = message;
this.alertMessage.show = true;
this.alertMessage.timeout = setTimeout(() => {
this.alertMessage.show = false;
this.alertMessage.timeout = null;
}, 2000);
},
addToBasketWithMessage(id: string, quantity: number) {
const message = this.addToBasket(id, quantity);
if (message.length > 0) {
this.showAlertMessage(message);
this.alertMessage.display(message, { success: false });
}
},
@ -109,7 +93,9 @@ document.addEventListener("alpine:init", () => {
finish() {
if (this.getBasketSize() === 0) {
this.showAlertMessage(gettext("You can't send an empty basket."));
this.alertMessage.display(gettext("You can't send an empty basket."), {
success: false,
});
return;
}
this.$refs.basketForm.submit();

View File

@ -167,7 +167,7 @@ document.addEventListener("alpine:init", () => {
});
// if products to download are already in-memory, directly take them.
// If not, fetch them.
const products =
const products: ProductSchema[] =
this.nbPages > 1
? await paginated(productSearchProductsDetailed, this.getQueryParams())
: Object.values<ProductSchema[]>(this.products).flat();

View File

@ -1,15 +1,11 @@
import { AlertMessage } from "#core:utils/alert-message";
import Alpine from "alpinejs";
import { producttypeReorder } from "#openapi";
document.addEventListener("alpine:init", () => {
Alpine.data("productTypesList", () => ({
loading: false,
alertMessage: {
open: false,
success: true,
content: "",
timeout: null,
},
alertMessage: new AlertMessage({ defaultDuration: 2000 }),
async reorder(itemId: number, newPosition: number) {
// The sort plugin of Alpine doesn't manage dynamic lists with x-sort
@ -41,23 +37,14 @@ document.addEventListener("alpine:init", () => {
},
openAlertMessage(response: Response) {
if (response.ok) {
this.alertMessage.success = true;
this.alertMessage.content = gettext("Products types reordered!");
} else {
this.alertMessage.success = false;
this.alertMessage.content = interpolate(
gettext("Product type reorganisation failed with status code : %d"),
[response.status],
);
}
this.alertMessage.open = true;
if (this.alertMessage.timeout !== null) {
clearTimeout(this.alertMessage.timeout);
}
this.alertMessage.timeout = setTimeout(() => {
this.alertMessage.open = false;
}, 2000);
const success = response.ok;
const content = response.ok
? gettext("Products types reordered!")
: interpolate(
gettext("Product type reorganisation failed with status code : %d"),
[response.status],
);
this.alertMessage.display(content, { success: success });
this.loading = false;
},
}));

View File

@ -1,4 +1,4 @@
type ErrorMessage = string;
export type ErrorMessage = string;
export interface InitialFormData {
/* Used to refill the form when the backend raises an error */

View File

@ -17,6 +17,7 @@ from datetime import timedelta
from decimal import Decimal
import pytest
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib.auth.models import Permission, make_password
from django.core.cache import cache
@ -823,3 +824,53 @@ class TestClubCounterClickAccess(TestCase):
self.client.force_login(self.user)
res = self.client.get(self.click_url)
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.shortcuts import get_object_or_404, redirect
from django.utils import timezone
from django.utils.timezone import now
from django.views.decorators.http import require_POST
from core.views.forms import LoginForm
@ -47,7 +47,7 @@ def counter_login(request: HttpRequest, counter_id: int) -> HttpResponseRedirect
@require_POST
def counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
"""End the permanency of a user in this counter."""
Permanency.objects.filter(counter=counter_id, user=request.POST["user_id"]).update(
end=F("activity")
)
Permanency.objects.filter(
counter=counter_id, user=request.POST["user_id"], end=None
).update(end=now())
return redirect("counter:details", counter_id=counter_id)

View File

@ -12,6 +12,15 @@ nouveau logo d'une promo. C'est un processus manuel.
de faire cette opération manuellement, ça prend quelques
minutes et on est certain de la qualité à la fin.
### avec une commande django
```bash
./manage.py add_promo_logo numero_de_promo chemin_dacces_du_logo
```
options:
* `--force/-f` pour automatiquement écraser les logos de promo avec le même nom.
### manuellement
Les logos de promo sont à manuellement ajouter dans le projet.
Ils se situent dans le dossier `core/static/core/img/`.

View File

@ -6,7 +6,7 @@
msgid ""
msgstr ""
"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"
"Last-Translator: Maréchal <thomas.girod@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."
#: core/templates/core/login.jinja
msgid "Your username and password didn't match. Please try again."
msgstr ""
"Votre nom d'utilisateur et votre mot de passe ne correspondent pas. Merci de "
"réessayer."
msgid "Your credentials didn't match. Please try again."
msgstr "Vos identifiants ne correspondent pas. Veuillez réessayer."
#: core/templates/core/login.jinja
msgid "Lost password?"
@ -5103,8 +5101,9 @@ msgid "There are %s pictures to be moderated in the SAS"
msgstr "Il y a %s photos à modérer dans le SAS"
#: sith/settings.py
msgid "You've been identified on some pictures"
msgstr "Vous avez été identifié sur des photos"
#, python-format
msgid "You've been identified in album %s"
msgstr "Vous avez été identifié dans l'album %s"
#: sith/settings.py
#, python-format

39
package-lock.json generated
View File

@ -45,7 +45,11 @@
"@hey-api/openapi-ts": "^0.73.0",
"@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.10",
"@types/cytoscape-cxtmenu": "^3.4.4",
"@types/cytoscape-klay": "^3.1.4",
"@types/jquery": "^3.5.31",
"@types/js-cookie": "^3.0.6",
"typescript": "^5.8.3",
"vite": "^6.2.5",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.0.2"
@ -2819,6 +2823,33 @@
"@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": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -2835,6 +2866,13 @@
"@types/sizzle": "*"
}
},
"node_modules/@types/js-cookie": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
"integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -5558,7 +5596,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@ -10,7 +10,7 @@
"openapi": "openapi-ts",
"analyse-dev": "vite-bundle-visualizer --mode development",
"analyse-prod": "vite-bundle-visualizer --mode production",
"check": "biome check --write"
"check": "tsc && biome check --write"
},
"keywords": [],
"author": "",
@ -30,7 +30,11 @@
"@hey-api/openapi-ts": "^0.73.0",
"@rollup/plugin-inject": "^5.0.5",
"@types/alpinejs": "^3.13.10",
"@types/cytoscape-cxtmenu": "^3.4.4",
"@types/cytoscape-klay": "^3.1.4",
"@types/jquery": "^3.5.31",
"@types/js-cookie": "^3.0.6",
"typescript": "^5.8.3",
"vite": "^6.2.5",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-static-copy": "^3.0.2"

View File

@ -44,7 +44,7 @@ dependencies = [
"django-honeypot>=1.3.0,<2",
"pydantic-extra-types<3.0.0,>=2.10.3",
"ical>=10.0.3,<11",
"redis[hiredis]>=5.3.0,<7.0.0",
"redis[hiredis]<6.0.0,>=5.3.0",
"environs[django]<15.0.0,>=14.1.1",
"requests>=2.32.3",
"honcho>=2.0.0",
@ -92,7 +92,7 @@ docs = [
default-groups = ["dev", "tests", "docs"]
[tool.xapian]
version = "1.4.25"
version = "1.4.29"
[tool.ruff]
output-format = "concise" # makes ruff error logs easier to read

View File

@ -53,9 +53,9 @@ class TestMergeUser(TestCase):
self.to_keep.address = "Jerusalem"
self.to_delete.parent_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")
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_delete.groups.add(mde_admin.id)
self.to_keep.groups.add(sas_admin.id)

View File

@ -2,7 +2,6 @@ from typing import Any, Literal
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db.models import F
from django.urls import reverse
from ninja import Body, File, Query
from ninja.security import SessionAuth
@ -105,8 +104,7 @@ class PicturesController(ControllerBase):
filters.filter(Picture.objects.viewable_by(user))
.distinct()
.order_by("-parent__date", "date")
.select_related("owner")
.annotate(album=F("parent__name"))
.select_related("owner", "parent")
)
@route.post(
@ -153,7 +151,9 @@ class PicturesController(ControllerBase):
@route.put("/{picture_id}/identified", permissions=[IsAuthenticated, CanView])
def identify_users(self, picture_id: NonNegativeInt, users: set[NonNegativeInt]):
picture = self.get_object_or_exception(Picture, pk=picture_id)
picture = self.get_object_or_exception(
Picture.objects.select_related("parent"), pk=picture_id
)
db_users = list(User.objects.filter(id__in=users))
if len(users) != len(db_users):
raise NotFound
@ -166,13 +166,15 @@ class PicturesController(ControllerBase):
]
PeoplePictureRelation.objects.bulk_create(relations)
for u in identified:
html_id = f"album-{picture.parent_id}"
url = reverse(
"sas:user_pictures", kwargs={"user_id": u.id}, fragment=html_id
)
Notification.objects.get_or_create(
user=u,
viewed=False,
type="NEW_PICTURES",
defaults={
"url": reverse("sas:user_pictures", kwargs={"user_id": u.id})
},
defaults={"url": url, "param": picture.parent.name},
)
@route.delete("/{picture_id}", permissions=[IsSasAdmin])

View File

@ -0,0 +1,19 @@
# Generated by Django 5.2.3 on 2025-06-17 18:53
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("sas", "0004_picturemoderationrequest_and_more")]
operations = [
migrations.AlterModelOptions(
name="sasfile",
options={
"permissions": [
("moderate_sasfile", "Can moderate SAS files"),
("view_unmoderated_sasfile", "Can view not moderated SAS files"),
]
},
),
]

View File

@ -25,11 +25,10 @@ from django.core.cache import cache
from django.db import models
from django.db.models import Exists, OuterRef, Q
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from PIL import Image
from core.models import SithFile, User
from core.models import Notification, SithFile, User
from core.utils import exif_auto_rotate, resize_image
@ -42,6 +41,10 @@ class SasFile(SithFile):
class Meta:
proxy = True
permissions = [
("moderate_sasfile", "Can moderate SAS files"),
("view_unmoderated_sasfile", "Can view not moderated SAS files"),
]
def can_be_viewed_by(self, user):
if user.is_anonymous:
@ -60,7 +63,7 @@ class SasFile(SithFile):
return self.id in viewable
def can_be_edited_by(self, user):
return user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
return user.has_perm("sas.change_sasfile")
class PictureQuerySet(models.QuerySet):
@ -70,7 +73,7 @@ class PictureQuerySet(models.QuerySet):
Warning:
Calling this queryset method may add several additional requests.
"""
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
if user.has_perm("sas.moderate_sasfile"):
return self.all()
if user.was_subscribed:
return self.filter(Q(is_moderated=True) | Q(owner=user))
@ -183,7 +186,7 @@ class AlbumQuerySet(models.QuerySet):
Warning:
Calling this queryset method may add several additional requests.
"""
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
if user.has_perm("sas.moderate_sasfile"):
return self.all()
if user.was_subscribed:
return self.filter(Q(is_moderated=True) | Q(owner=user))
@ -256,14 +259,10 @@ class Album(SasFile):
self.save()
def sas_notification_callback(notif):
def sas_notification_callback(notif: Notification):
count = Picture.objects.filter(is_moderated=False).count()
if count:
notif.viewed = False
else:
notif.viewed = True
notif.param = "%s" % count
notif.date = timezone.now()
notif.viewed = not bool(count)
notif.param = str(count)
class PeoplePictureRelation(models.Model):

View File

@ -18,6 +18,12 @@ class AlbumFilterSchema(FilterSchema):
parent_id: int | None = Field(None, q="parent_id")
class SimpleAlbumSchema(ModelSchema):
class Meta:
model = Album
fields = ["id", "name"]
class AlbumSchema(ModelSchema):
class Meta:
model = Album
@ -70,7 +76,7 @@ class PictureSchema(ModelSchema):
full_size_url: str
compressed_url: str
thumb_url: str
album: str
album: SimpleAlbumSchema = Field(alias="parent")
report_url: str
edit_url: str

View File

@ -83,7 +83,6 @@ document.addEventListener("alpine:init", () => {
Alpine.data("pictureUpload", (albumId: number) => ({
errors: [] as string[],
pictures: [],
sending: false,
progress: null as HTMLProgressElement,

View File

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

View File

@ -1,3 +1,4 @@
import type { UserAjaxSelect } from "#core:core/components/ajax-select-index";
import { paginated } from "#core:utils/api";
import { exportToHtml } from "#core:utils/globals";
import { History } from "#core:utils/history";
@ -130,7 +131,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
currentPicture: {
// biome-ignore lint/style/useNamingConvention: api is in snake_case
is_moderated: true,
id: null,
id: null as number,
name: "",
// biome-ignore lint/style/useNamingConvention: api is in snake_case
display_name: "",
@ -142,7 +143,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
full_size_url: "",
owner: "",
date: new Date(),
identifications: [],
identifications: [] as IdentifiedUserSchema[],
},
/**
* The picture which will be displayed next if the user press the "next" button
@ -155,7 +156,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
/**
* The select2 component used to identify users
**/
selector: undefined,
selector: undefined as UserAjaxSelect,
/**
* Error message when a moderation operation fails
**/

View File

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

View File

@ -20,17 +20,17 @@
{{ download_button(_("Download all my pictures")) }}
{% endif %}
<template x-for="[album, pictures] in Object.entries(albums)" x-cloak>
<template x-for="album in albums" x-cloak>
<section>
<br />
<div class="row">
<h4 x-text="album"></h4>
<h4 x-text="album.name" :id="`album-${album.id}`"></h4>
{% if user.id == object.id %}
&nbsp;{{ download_button("") }}
{% endif %}
</div>
<div class="photos">
<template x-for="picture in pictures">
<template x-for="picture in album.pictures">
<a :href="picture.sas_url">
<div
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_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_COUNTER_ID = env.int("SITH_GROUP_BANNED_COUNTER_ID", default=12)
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=13)
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)
@ -677,7 +677,7 @@ SITH_NOTIFICATIONS = [
("NEWS_MODERATION", _("There are %s fresh news to be moderated")),
("FILE_MODERATION", _("New files to be moderated")),
("SAS_MODERATION", _("There are %s pictures to be moderated in the SAS")),
("NEW_PICTURES", _("You've been identified on some pictures")),
("NEW_PICTURES", _("You've been identified in album %s")),
("REFILLING", _("You just refilled of %s")),
("SELLING", _("You just bought %s")),
("GENERIC", _("You have a notification")),

View File

@ -4,7 +4,7 @@
"sourceMap": true,
"noImplicitAny": true,
"module": "esnext",
"target": "es2022",
"target": "es2024",
"allowJs": true,
"moduleResolution": "node",
"experimentalDecorators": true,
@ -14,6 +14,7 @@
"types": ["jquery", "alpinejs"],
"paths": {
"#openapi": ["./staticfiles/generated/openapi/client/index.ts"],
"#openapi:*": ["./staticfiles/generated/openapi/client/*"],
"#core:*": ["./core/static/bundled/*"],
"#pedagogy:*": ["./pedagogy/static/bundled/*"],
"#counter:*": ["./counter/static/bundled/*"],

2
uv.lock generated
View File

@ -1852,7 +1852,7 @@ dev = [
{ name = "ipython", specifier = ">=9.0.2,<10.0.0" },
{ name = "pre-commit", specifier = ">=4.1.0,<5.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 = [
{ name = "mkdocs", specifier = ">=1.6.1,<2.0.0" },