diff --git a/.github/actions/setup_project/action.yml b/.github/actions/setup_project/action.yml index bb10a2f5..0ec83445 100644 --- a/.github/actions/setup_project/action.yml +++ b/.github/actions/setup_project/action.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbe35a0a..8919d04c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }} diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index a90d84b0..236917a3 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -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: diff --git a/api/urls.py b/api/urls.py index ed58c790..2c3f12ff 100644 --- a/api/urls.py +++ b/api/urls.py @@ -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, diff --git a/club/api.py b/club/api.py index ef46e4c7..2e59d3e5 100644 --- a/club/api.py +++ b/club/api.py @@ -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 ) diff --git a/club/tests/test_club_controller.py b/club/tests/test_club_controller.py index ade8eb4d..18a3aef1 100644 --- a/club/tests/test_club_controller.py +++ b/club/tests/test_club_controller.py @@ -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 diff --git a/core/management/commands/install_xapian.sh b/core/management/commands/install_xapian.sh index 3ca2ac17..2c97f120 100755 --- a/core/management/commands/install_xapian.sh +++ b/core/management/commands/install_xapian.sh @@ -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" diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index 8f101d9f..659dd5e9 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -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): diff --git a/core/management/commands/populate_more.py b/core/management/commands/populate_more.py index 447b6b98..f9456712 100644 --- a/core/management/commands/populate_more.py +++ b/core/management/commands/populate_more.py @@ -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"]) diff --git a/core/static/bundled/core/navbar-index.ts b/core/static/bundled/core/navbar-index.ts new file mode 100644 index 00000000..b5330d42 --- /dev/null +++ b/core/static/bundled/core/navbar-index.ts @@ -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(); + } + }); + } +}); diff --git a/core/static/bundled/user/family-graph-index.js b/core/static/bundled/user/family-graph-index.js deleted file mode 100644 index 706697b1..00000000 --- a/core/static/bundled/user/family-graph-index.js +++ /dev/null @@ -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: '', - select: (el) => { - window.open(el.data().profile_url, "_blank").focus(); - }, - }, - - { - content: '', - select: (el) => { - onNodeTap(el); - }, - }, - - { - content: '', - 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; - }, - })); - }); -}; diff --git a/core/static/bundled/user/family-graph-index.ts b/core/static/bundled/user/family-graph-index.ts new file mode 100644 index 00000000..d8179c07 --- /dev/null +++ b/core/static/bundled/user/family-graph-index.ts @@ -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 { + 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: '', + select: (el) => { + window.open(el.data().profile_url, "_blank").focus(); + }, + }, + + { + content: '', + select: (el) => { + onNodeTap(el); + }, + }, + + { + content: '', + 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; + }, + })); +}); diff --git a/core/static/bundled/utils/alert-message.ts b/core/static/bundled/utils/alert-message.ts new file mode 100644 index 00000000..85d72a2e --- /dev/null +++ b/core/static/bundled/utils/alert-message.ts @@ -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; + } +} diff --git a/core/static/bundled/utils/api.ts b/core/static/bundled/utils/api.ts index 95a86e1c..6ed4a53a 100644 --- a/core/static/bundled/utils/api.ts +++ b/core/static/bundled/utils/api.ts @@ -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 { count: number; diff --git a/core/static/core/footer.scss b/core/static/core/footer.scss new file mode 100644 index 00000000..52cd317d --- /dev/null +++ b/core/static/core/footer.scss @@ -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; + } + } +} \ No newline at end of file diff --git a/core/static/core/style.scss b/core/static/core/style.scss index 2cff3dff..2baf42a6 100644 --- a/core/static/core/style.scss +++ b/core/static/core/style.scss @@ -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 { diff --git a/core/static/user/user_godfathers.scss b/core/static/user/user_godfathers.scss index 9764ee3e..7c69def7 100644 --- a/core/static/user/user_godfathers.scss +++ b/core/static/user/user_godfathers.scss @@ -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; } } \ No newline at end of file diff --git a/core/templates/core/base.jinja b/core/templates/core/base.jinja index cbfe4ffb..225abcfd 100644 --- a/core/templates/core/base.jinja +++ b/core/templates/core/base.jinja @@ -11,6 +11,7 @@ + @@ -18,6 +19,7 @@ + @@ -88,58 +90,12 @@ - + {% block footer %} + {% include "core/base/footer.jinja" %} + {% endblock %} {% block script %} + {% endblock %} {% block title %} @@ -15,7 +15,14 @@ {% endblock %} {% block content %} -
+
@@ -86,17 +93,36 @@
+ +
+ + + + +
+
- {% endblock %} diff --git a/core/tests/test_core.py b/core/tests/test_core.py index 72cde11c..4523e147 100644 --- a/core/tests/test_core.py +++ b/core/tests/test_core.py @@ -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 ( - '

Votre nom d\'utilisateur ' - "et votre mot de passe ne correspondent pas. Merci de réessayer.

" - ) 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) diff --git a/core/views/forms.py b/core/views/forms.py index 02f2ae26..a8bbdfd6 100644 --- a/core/views/forms.py +++ b/core/views/forms.py @@ -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" diff --git a/counter/admin.py b/counter/admin.py index 10f04c8d..de1d9d0b 100644 --- a/counter/admin.py +++ b/counter/admin.py @@ -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) diff --git a/counter/static/bundled/counter/counter-click-index.ts b/counter/static/bundled/counter/counter-click-index.ts index 99f6bdb7..f594cdd8 100644 --- a/counter/static/bundled/counter/counter-click-index.ts +++ b/counter/static/bundled/counter/counter-click-index.ts @@ -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, - 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(); diff --git a/counter/static/bundled/counter/product-list-index.ts b/counter/static/bundled/counter/product-list-index.ts index a7cd3f86..de0381b9 100644 --- a/counter/static/bundled/counter/product-list-index.ts +++ b/counter/static/bundled/counter/product-list-index.ts @@ -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(this.products).flat(); diff --git a/counter/static/bundled/counter/product-type-index.ts b/counter/static/bundled/counter/product-type-index.ts index f200e9b2..37de445d 100644 --- a/counter/static/bundled/counter/product-type-index.ts +++ b/counter/static/bundled/counter/product-type-index.ts @@ -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; }, })); diff --git a/counter/static/bundled/counter/types.d.ts b/counter/static/bundled/counter/types.d.ts index 4a22a916..18fea258 100644 --- a/counter/static/bundled/counter/types.d.ts +++ b/counter/static/bundled/counter/types.d.ts @@ -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 */ diff --git a/counter/tests/test_counter.py b/counter/tests/test_counter.py index 5b96a471..d90f9510 100644 --- a/counter/tests/test_counter.py +++ b/counter/tests/test_counter.py @@ -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 diff --git a/counter/views/auth.py b/counter/views/auth.py index 87cce72c..eba165d0 100644 --- a/counter/views/auth.py +++ b/counter/views/auth.py @@ -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) diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 01b3f706..d6549184 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -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 \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?" diff --git a/package-lock.json b/package-lock.json index a0228831..44e71934 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 9d7cf43a..0e986ab9 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 15c75eb6..e8ea292e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/rootplace/tests/test_merge_users.py b/rootplace/tests/test_merge_users.py index baaa8ca9..294e2bae 100644 --- a/rootplace/tests/test_merge_users.py +++ b/rootplace/tests/test_merge_users.py @@ -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) diff --git a/sas/migrations/0005_alter_sasfile_options.py b/sas/migrations/0005_alter_sasfile_options.py new file mode 100644 index 00000000..426c9bdc --- /dev/null +++ b/sas/migrations/0005_alter_sasfile_options.py @@ -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"), + ] + }, + ), + ] diff --git a/sas/models.py b/sas/models.py index 3a0a8428..0355c7da 100644 --- a/sas/models.py +++ b/sas/models.py @@ -41,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: @@ -59,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): @@ -69,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)) @@ -182,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)) diff --git a/sas/static/bundled/sas/album-index.ts b/sas/static/bundled/sas/album-index.ts index 32f0f02f..b56b8e02 100644 --- a/sas/static/bundled/sas/album-index.ts +++ b/sas/static/bundled/sas/album-index.ts @@ -83,7 +83,6 @@ document.addEventListener("alpine:init", () => { Alpine.data("pictureUpload", (albumId: number) => ({ errors: [] as string[], - pictures: [], sending: false, progress: null as HTMLProgressElement, diff --git a/sas/static/bundled/sas/user/pictures-index.ts b/sas/static/bundled/sas/user/pictures-index.ts index f5f2fcbc..0a77159b 100644 --- a/sas/static/bundled/sas/user/pictures-index.ts +++ b/sas/static/bundled/sas/user/pictures-index.ts @@ -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, + 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, picture: PictureSchema) => { - if (!acc[picture.album.id]) { - acc[picture.album.id] = []; - } - acc[picture.album.id].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; }, })); diff --git a/sas/static/bundled/sas/viewer-index.ts b/sas/static/bundled/sas/viewer-index.ts index 59718b26..0eec9d36 100644 --- a/sas/static/bundled/sas/viewer-index.ts +++ b/sas/static/bundled/sas/viewer-index.ts @@ -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 **/ diff --git a/sas/templates/sas/macros.jinja b/sas/templates/sas/macros.jinja index aa4afa48..a00c2b6c 100644 --- a/sas/templates/sas/macros.jinja +++ b/sas/templates/sas/macros.jinja @@ -50,7 +50,7 @@ #} {% macro download_button(name) %}
-
+