mirror of
				https://github.com/ae-utbm/sith.git
				synced 2025-10-30 00:23:54 +00:00 
			
		
		
		
	
							
								
								
									
										14
									
								
								.github/actions/setup_project/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								.github/actions/setup_project/action.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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 | ||||
|   | ||||
							
								
								
									
										2
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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 }} | ||||
|   | ||||
							
								
								
									
										6
									
								
								.github/workflows/deploy_docs.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/deploy_docs.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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: | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
							
								
								
									
										10
									
								
								club/api.py
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								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 | ||||
|         ) | ||||
|   | ||||
| @@ -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): | ||||
| class TestFetchClub: | ||||
|     @pytest.fixture() | ||||
|     def club(self): | ||||
|         club = baker.make(Club) | ||||
|     baker.make(Membership, club=club, _quantity=10, _bulk_create=True) | ||||
|         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) | ||||
|     with assertNumQueries(7): | ||||
|         # - 4 queries for authentication | ||||
|         # - 3 queries for the actual data | ||||
|         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 | ||||
|   | ||||
| @@ -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" | ||||
|   | ||||
| @@ -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): | ||||
|   | ||||
| @@ -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"]) | ||||
|   | ||||
							
								
								
									
										36
									
								
								core/static/bundled/core/navbar-index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								core/static/bundled/core/navbar-index.ts
									
									
									
									
									
										Normal 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(); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| }); | ||||
| @@ -1,274 +0,0 @@ | ||||
| import { History, initialUrlParams, updateQueryString } from "#core:utils/history"; | ||||
| import cytoscape from "cytoscape"; | ||||
| import cxtmenu from "cytoscape-cxtmenu"; | ||||
| import klay from "cytoscape-klay"; | ||||
| import { familyGetFamilyGraph } from "#openapi"; | ||||
|  | ||||
| cytoscape.use(klay); | ||||
| cytoscape.use(cxtmenu); | ||||
|  | ||||
| async function getGraphData(userId, godfathersDepth, godchildrenDepth) { | ||||
|   const data = ( | ||||
|     await familyGetFamilyGraph({ | ||||
|       path: { | ||||
|         // biome-ignore lint/style/useNamingConvention: api is snake_case | ||||
|         user_id: userId, | ||||
|       }, | ||||
|       query: { | ||||
|         // biome-ignore lint/style/useNamingConvention: api is snake_case | ||||
|         godfathers_depth: godfathersDepth, | ||||
|         // biome-ignore lint/style/useNamingConvention: api is snake_case | ||||
|         godchildren_depth: godchildrenDepth, | ||||
|       }, | ||||
|     }) | ||||
|   ).data; | ||||
|   return [ | ||||
|     ...data.users.map((user) => { | ||||
|       return { data: user }; | ||||
|     }), | ||||
|     ...data.relationships.map((rel) => { | ||||
|       return { | ||||
|         data: { source: rel.godfather, target: rel.godchild }, | ||||
|       }; | ||||
|     }), | ||||
|   ]; | ||||
| } | ||||
|  | ||||
| function createGraph(container, data, activeUserId) { | ||||
|   const cy = cytoscape({ | ||||
|     boxSelectionEnabled: false, | ||||
|     autounselectify: true, | ||||
|  | ||||
|     container, | ||||
|     elements: data, | ||||
|     minZoom: 0.5, | ||||
|  | ||||
|     style: [ | ||||
|       // the stylesheet for the graph | ||||
|       { | ||||
|         selector: "node", | ||||
|         style: { | ||||
|           label: "data(display_name)", | ||||
|           "background-image": "data(profile_pict)", | ||||
|           width: "100%", | ||||
|           height: "100%", | ||||
|           "background-fit": "cover", | ||||
|           "background-repeat": "no-repeat", | ||||
|           shape: "ellipse", | ||||
|         }, | ||||
|       }, | ||||
|  | ||||
|       { | ||||
|         selector: "edge", | ||||
|         style: { | ||||
|           width: 5, | ||||
|           "line-color": "#ccc", | ||||
|           "target-arrow-color": "#ccc", | ||||
|           "target-arrow-shape": "triangle", | ||||
|           "curve-style": "bezier", | ||||
|         }, | ||||
|       }, | ||||
|  | ||||
|       { | ||||
|         selector: ".traversed", | ||||
|         style: { | ||||
|           "border-width": "5px", | ||||
|           "border-style": "solid", | ||||
|           "border-color": "red", | ||||
|           "target-arrow-color": "red", | ||||
|           "line-color": "red", | ||||
|         }, | ||||
|       }, | ||||
|  | ||||
|       { | ||||
|         selector: ".not-traversed", | ||||
|         style: { | ||||
|           "line-opacity": "0.5", | ||||
|           "background-opacity": "0.5", | ||||
|           "background-image-opacity": "0.5", | ||||
|         }, | ||||
|       }, | ||||
|     ], | ||||
|     layout: { | ||||
|       name: "klay", | ||||
|       nodeDimensionsIncludeLabels: true, | ||||
|       fit: true, | ||||
|       klay: { | ||||
|         addUnnecessaryBendpoints: true, | ||||
|         direction: "DOWN", | ||||
|         nodePlacement: "INTERACTIVE", | ||||
|         layoutHierarchy: true, | ||||
|       }, | ||||
|     }, | ||||
|   }); | ||||
|   const activeUser = cy.getElementById(activeUserId).style("shape", "rectangle"); | ||||
|   /* Reset graph */ | ||||
|   const resetGraph = () => { | ||||
|     cy.elements((element) => { | ||||
|       if (element.hasClass("traversed")) { | ||||
|         element.removeClass("traversed"); | ||||
|       } | ||||
|       if (element.hasClass("not-traversed")) { | ||||
|         element.removeClass("not-traversed"); | ||||
|       } | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   const onNodeTap = (el) => { | ||||
|     resetGraph(); | ||||
|     /* Create path on graph if selected isn't the targeted user */ | ||||
|     if (el === activeUser) { | ||||
|       return; | ||||
|     } | ||||
|     cy.elements((element) => { | ||||
|       element.addClass("not-traversed"); | ||||
|     }); | ||||
|  | ||||
|     for (const traversed of cy.elements().aStar({ | ||||
|       root: el, | ||||
|       goal: activeUser, | ||||
|     }).path) { | ||||
|       traversed.removeClass("not-traversed"); | ||||
|       traversed.addClass("traversed"); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   cy.on("tap", "node", (tapped) => { | ||||
|     onNodeTap(tapped.target); | ||||
|   }); | ||||
|   cy.zoomingEnabled(false); | ||||
|  | ||||
|   /* Add context menu */ | ||||
|   cy.cxtmenu({ | ||||
|     selector: "node", | ||||
|  | ||||
|     commands: [ | ||||
|       { | ||||
|         content: '<i class="fa fa-external-link fa-2x"></i>', | ||||
|         select: (el) => { | ||||
|           window.open(el.data().profile_url, "_blank").focus(); | ||||
|         }, | ||||
|       }, | ||||
|  | ||||
|       { | ||||
|         content: '<span class="fa fa-mouse-pointer fa-2x"></span>', | ||||
|         select: (el) => { | ||||
|           onNodeTap(el); | ||||
|         }, | ||||
|       }, | ||||
|  | ||||
|       { | ||||
|         content: '<i class="fa fa-eraser fa-2x"></i>', | ||||
|         select: (_) => { | ||||
|           resetGraph(); | ||||
|         }, | ||||
|       }, | ||||
|     ], | ||||
|   }); | ||||
|  | ||||
|   return cy; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * @typedef FamilyGraphConfig | ||||
|  * @property {number} activeUser Id of the user to fetch the tree from | ||||
|  * @property {number} depthMin Minimum tree depth for godfathers and godchildren | ||||
|  * @property {number} depthMax Maximum tree depth for godfathers and godchildren | ||||
|  **/ | ||||
|  | ||||
| /** | ||||
|  * Create a family graph of an user | ||||
|  * @param {FamilyGraphConfig} config | ||||
|  **/ | ||||
| window.loadFamilyGraph = (config) => { | ||||
|   document.addEventListener("alpine:init", () => { | ||||
|     const defaultDepth = 2; | ||||
|  | ||||
|     function getInitialDepth(prop) { | ||||
|       const value = Number.parseInt(initialUrlParams.get(prop)); | ||||
|       if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) { | ||||
|         return defaultDepth; | ||||
|       } | ||||
|       return value; | ||||
|     } | ||||
|  | ||||
|     Alpine.data("graph", () => ({ | ||||
|       loading: false, | ||||
|       godfathersDepth: getInitialDepth("godfathersDepth"), | ||||
|       godchildrenDepth: getInitialDepth("godchildrenDepth"), | ||||
|       reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true", | ||||
|       graph: undefined, | ||||
|       graphData: {}, | ||||
|  | ||||
|       async init() { | ||||
|         const delayedFetch = Alpine.debounce(async () => { | ||||
|           await this.fetchGraphData(); | ||||
|         }, 100); | ||||
|         for (const param of ["godfathersDepth", "godchildrenDepth"]) { | ||||
|           this.$watch(param, async (value) => { | ||||
|             if (value < config.depthMin || value > config.depthMax) { | ||||
|               return; | ||||
|             } | ||||
|             updateQueryString(param, value, History.Replace); | ||||
|             await delayedFetch(); | ||||
|           }); | ||||
|         } | ||||
|         this.$watch("reverse", async (value) => { | ||||
|           updateQueryString("reverse", value, History.Replace); | ||||
|           await this.reverseGraph(); | ||||
|         }); | ||||
|         this.$watch("graphData", async () => { | ||||
|           this.generateGraph(); | ||||
|           if (this.reverse) { | ||||
|             await this.reverseGraph(); | ||||
|           } | ||||
|         }); | ||||
|         await this.fetchGraphData(); | ||||
|       }, | ||||
|  | ||||
|       screenshot() { | ||||
|         const link = document.createElement("a"); | ||||
|         link.href = this.graph.jpg(); | ||||
|         link.download = interpolate( | ||||
|           gettext("family_tree.%(extension)s"), | ||||
|           { extension: "jpg" }, | ||||
|           true, | ||||
|         ); | ||||
|         document.body.appendChild(link); | ||||
|         link.click(); | ||||
|         document.body.removeChild(link); | ||||
|       }, | ||||
|  | ||||
|       reset() { | ||||
|         this.reverse = false; | ||||
|         this.godfathersDepth = defaultDepth; | ||||
|         this.godchildrenDepth = defaultDepth; | ||||
|       }, | ||||
|  | ||||
|       async reverseGraph() { | ||||
|         this.graph.elements((el) => { | ||||
|           el.position({ x: -el.position().x, y: -el.position().y }); | ||||
|         }); | ||||
|         this.graph.center(this.graph.elements()); | ||||
|       }, | ||||
|  | ||||
|       async fetchGraphData() { | ||||
|         this.graphData = await getGraphData( | ||||
|           config.activeUser, | ||||
|           this.godfathersDepth, | ||||
|           this.godchildrenDepth, | ||||
|         ); | ||||
|       }, | ||||
|  | ||||
|       generateGraph() { | ||||
|         this.loading = true; | ||||
|         this.graph = createGraph( | ||||
|           $(this.$refs.graph), | ||||
|           this.graphData, | ||||
|           config.activeUser, | ||||
|         ); | ||||
|         this.loading = false; | ||||
|       }, | ||||
|     })); | ||||
|   }); | ||||
| }; | ||||
							
								
								
									
										287
									
								
								core/static/bundled/user/family-graph-index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										287
									
								
								core/static/bundled/user/family-graph-index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,287 @@ | ||||
| import { History, initialUrlParams, updateQueryString } from "#core:utils/history"; | ||||
| import cytoscape, { | ||||
|   type ElementDefinition, | ||||
|   type NodeSingular, | ||||
|   type Singular, | ||||
| } from "cytoscape"; | ||||
| import cxtmenu from "cytoscape-cxtmenu"; | ||||
| import klay, { type KlayLayoutOptions } from "cytoscape-klay"; | ||||
| import { type UserProfileSchema, familyGetFamilyGraph } from "#openapi"; | ||||
|  | ||||
| cytoscape.use(klay); | ||||
| cytoscape.use(cxtmenu); | ||||
|  | ||||
| type GraphData = ( | ||||
|   | { data: UserProfileSchema } | ||||
|   | { data: { source: number; target: number } } | ||||
| )[]; | ||||
|  | ||||
| function isMobile() { | ||||
|   return window.innerWidth < 500; | ||||
| } | ||||
|  | ||||
| async function getGraphData( | ||||
|   userId: number, | ||||
|   godfathersDepth: number, | ||||
|   godchildrenDepth: number, | ||||
| ): Promise<GraphData> { | ||||
|   const data = ( | ||||
|     await familyGetFamilyGraph({ | ||||
|       path: { | ||||
|         // biome-ignore lint/style/useNamingConvention: api is snake_case | ||||
|         user_id: userId, | ||||
|       }, | ||||
|       query: { | ||||
|         // biome-ignore lint/style/useNamingConvention: api is snake_case | ||||
|         godfathers_depth: godfathersDepth, | ||||
|         // biome-ignore lint/style/useNamingConvention: api is snake_case | ||||
|         godchildren_depth: godchildrenDepth, | ||||
|       }, | ||||
|     }) | ||||
|   ).data; | ||||
|   return [ | ||||
|     ...data.users.map((user) => { | ||||
|       return { data: user }; | ||||
|     }), | ||||
|     ...data.relationships.map((rel) => { | ||||
|       return { | ||||
|         data: { source: rel.godfather, target: rel.godchild }, | ||||
|       }; | ||||
|     }), | ||||
|   ]; | ||||
| } | ||||
|  | ||||
| function createGraph(container: HTMLDivElement, data: GraphData, activeUserId: number) { | ||||
|   const cy = cytoscape({ | ||||
|     boxSelectionEnabled: false, | ||||
|     autounselectify: true, | ||||
|  | ||||
|     container, | ||||
|     elements: data as ElementDefinition[], | ||||
|     minZoom: 0.5, | ||||
|  | ||||
|     style: [ | ||||
|       // the stylesheet for the graph | ||||
|       { | ||||
|         selector: "node", | ||||
|         style: { | ||||
|           label: "data(display_name)", | ||||
|           "background-image": "data(profile_pict)", | ||||
|           width: "100%", | ||||
|           height: "100%", | ||||
|           "background-fit": "cover", | ||||
|           "background-repeat": "no-repeat", | ||||
|           shape: "ellipse", | ||||
|         }, | ||||
|       }, | ||||
|  | ||||
|       { | ||||
|         selector: "edge", | ||||
|         style: { | ||||
|           width: 5, | ||||
|           "line-color": "#ccc", | ||||
|           "target-arrow-color": "#ccc", | ||||
|           "target-arrow-shape": "triangle", | ||||
|           "curve-style": "bezier", | ||||
|         }, | ||||
|       }, | ||||
|  | ||||
|       { | ||||
|         selector: ".traversed", | ||||
|         style: { | ||||
|           "border-width": "5px", | ||||
|           "border-style": "solid", | ||||
|           "border-color": "red", | ||||
|           "target-arrow-color": "red", | ||||
|           "line-color": "red", | ||||
|         }, | ||||
|       }, | ||||
|  | ||||
|       { | ||||
|         selector: ".not-traversed", | ||||
|         style: { | ||||
|           "line-opacity": 0.5, | ||||
|           "background-opacity": 0.5, | ||||
|           "background-image-opacity": 0.5, | ||||
|         }, | ||||
|       }, | ||||
|     ], | ||||
|     layout: { | ||||
|       name: "klay", | ||||
|       nodeDimensionsIncludeLabels: true, | ||||
|       fit: true, | ||||
|       klay: { | ||||
|         addUnnecessaryBendpoints: true, | ||||
|         direction: "DOWN", | ||||
|         nodePlacement: "INTERACTIVE", | ||||
|         layoutHierarchy: true, | ||||
|       }, | ||||
|     } as KlayLayoutOptions, | ||||
|   }); | ||||
|   const activeUser = cy | ||||
|     .getElementById(activeUserId.toString()) | ||||
|     .style("shape", "rectangle"); | ||||
|   /* Reset graph */ | ||||
|   const resetGraph = () => { | ||||
|     cy.elements().removeClass("traversed not-traversed"); | ||||
|   }; | ||||
|  | ||||
|   const onNodeTap = (el: Singular) => { | ||||
|     resetGraph(); | ||||
|     /* Create path on graph if selected isn't the targeted user */ | ||||
|     if (el === activeUser) { | ||||
|       return; | ||||
|     } | ||||
|     cy.elements().addClass("not-traversed"); | ||||
|  | ||||
|     for (const traversed of cy.elements().aStar({ | ||||
|       root: el, | ||||
|       goal: activeUser, | ||||
|     }).path) { | ||||
|       traversed.removeClass("not-traversed"); | ||||
|       traversed.addClass("traversed"); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   cy.on("tap", "node", (tapped) => { | ||||
|     onNodeTap(tapped.target); | ||||
|   }); | ||||
|  | ||||
|   /* Add context menu */ | ||||
|   cy.cxtmenu({ | ||||
|     selector: "node", | ||||
|  | ||||
|     commands: [ | ||||
|       { | ||||
|         content: '<i class="fa fa-external-link fa-2x"></i>', | ||||
|         select: (el) => { | ||||
|           window.open(el.data().profile_url, "_blank").focus(); | ||||
|         }, | ||||
|       }, | ||||
|  | ||||
|       { | ||||
|         content: '<span class="fa fa-mouse-pointer fa-2x"></span>', | ||||
|         select: (el) => { | ||||
|           onNodeTap(el); | ||||
|         }, | ||||
|       }, | ||||
|  | ||||
|       { | ||||
|         content: '<i class="fa fa-eraser fa-2x"></i>', | ||||
|         select: (_) => { | ||||
|           resetGraph(); | ||||
|         }, | ||||
|       }, | ||||
|     ], | ||||
|   }); | ||||
|  | ||||
|   return cy; | ||||
| } | ||||
|  | ||||
| interface FamilyGraphConfig { | ||||
|   /**Id of the user to fetch the tree from*/ | ||||
|   activeUser: number; | ||||
|   /**Minimum tree depth for godfathers and godchildren*/ | ||||
|   depthMin: number; | ||||
|   /**Maximum tree depth for godfathers and godchildren*/ | ||||
|   depthMax: number; | ||||
| } | ||||
|  | ||||
| document.addEventListener("alpine:init", () => { | ||||
|   const defaultDepth = 2; | ||||
|  | ||||
|   Alpine.data("graph", (config: FamilyGraphConfig) => ({ | ||||
|     loading: false, | ||||
|     godfathersDepth: 0, | ||||
|     godchildrenDepth: 0, | ||||
|     reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true", | ||||
|     graph: undefined as cytoscape.Core, | ||||
|     graphData: {}, | ||||
|     isZoomEnabled: !isMobile(), | ||||
|  | ||||
|     getInitialDepth(prop: string) { | ||||
|       const value = Number.parseInt(initialUrlParams.get(prop)); | ||||
|       if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) { | ||||
|         return defaultDepth; | ||||
|       } | ||||
|       return value; | ||||
|     }, | ||||
|  | ||||
|     async init() { | ||||
|       this.godfathersDepth = this.getInitialDepth("godfathersDepth"); | ||||
|       this.godchildrenDepth = this.getInitialDepth("godchildrenDepth"); | ||||
|  | ||||
|       const delayedFetch = Alpine.debounce(async () => { | ||||
|         await this.fetchGraphData(); | ||||
|       }, 100); | ||||
|       for (const param of ["godfathersDepth", "godchildrenDepth"]) { | ||||
|         this.$watch(param, async (value: number) => { | ||||
|           if (value < config.depthMin || value > config.depthMax) { | ||||
|             return; | ||||
|           } | ||||
|           updateQueryString(param, value.toString(), History.Replace); | ||||
|           await delayedFetch(); | ||||
|         }); | ||||
|       } | ||||
|       this.$watch("reverse", async (value: number) => { | ||||
|         updateQueryString("reverse", value.toString(), History.Replace); | ||||
|         await this.reverseGraph(); | ||||
|       }); | ||||
|       this.$watch("graphData", async () => { | ||||
|         this.generateGraph(); | ||||
|         if (this.reverse) { | ||||
|           await this.reverseGraph(); | ||||
|         } | ||||
|       }); | ||||
|       this.$watch("isZoomEnabled", () => { | ||||
|         this.graph.userZoomingEnabled(this.isZoomEnabled); | ||||
|       }); | ||||
|       await this.fetchGraphData(); | ||||
|     }, | ||||
|  | ||||
|     screenshot() { | ||||
|       const link = document.createElement("a"); | ||||
|       link.href = this.graph.jpg(); | ||||
|       link.download = interpolate( | ||||
|         gettext("family_tree.%(extension)s"), | ||||
|         { extension: "jpg" }, | ||||
|         true, | ||||
|       ); | ||||
|       document.body.appendChild(link); | ||||
|       link.click(); | ||||
|       document.body.removeChild(link); | ||||
|     }, | ||||
|  | ||||
|     reset() { | ||||
|       this.reverse = false; | ||||
|       this.godfathersDepth = defaultDepth; | ||||
|       this.godchildrenDepth = defaultDepth; | ||||
|     }, | ||||
|  | ||||
|     async reverseGraph() { | ||||
|       this.graph.elements((el: NodeSingular) => { | ||||
|         el.position({ x: -el.position().x, y: -el.position().y }); | ||||
|       }); | ||||
|       this.graph.center(this.graph.elements()); | ||||
|     }, | ||||
|  | ||||
|     async fetchGraphData() { | ||||
|       this.graphData = await getGraphData( | ||||
|         config.activeUser, | ||||
|         this.godfathersDepth, | ||||
|         this.godchildrenDepth, | ||||
|       ); | ||||
|     }, | ||||
|  | ||||
|     generateGraph() { | ||||
|       this.loading = true; | ||||
|       this.graph = createGraph( | ||||
|         this.$refs.graph as HTMLDivElement, | ||||
|         this.graphData, | ||||
|         config.activeUser, | ||||
|       ); | ||||
|       this.graph.userZoomingEnabled(this.isZoomEnabled); | ||||
|       this.loading = false; | ||||
|     }, | ||||
|   })); | ||||
| }); | ||||
							
								
								
									
										38
									
								
								core/static/bundled/utils/alert-message.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								core/static/bundled/utils/alert-message.ts
									
									
									
									
									
										Normal 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; | ||||
|   } | ||||
| } | ||||
| @@ -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; | ||||
|   | ||||
							
								
								
									
										89
									
								
								core/static/core/footer.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								core/static/core/footer.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,89 @@ | ||||
| @import "colors"; | ||||
| @import "devices"; | ||||
|  | ||||
| footer.bottom-links { | ||||
|   @media (max-width: $small-devices) { | ||||
|     margin-top: 0.6em; | ||||
|     padding: 1.25em; | ||||
|     background-color: $primary-neutral-dark-color; | ||||
|  | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     align-items: center; | ||||
|  | ||||
|     gap: 1.25em; | ||||
|  | ||||
|     >section { | ||||
|  | ||||
|       display: flex; | ||||
|       flex-direction: row; | ||||
|       flex-wrap: wrap; | ||||
|       justify-content: center; | ||||
|       gap: 0.8em; | ||||
|  | ||||
|     } | ||||
|  | ||||
|     a { | ||||
|       color: $white-color; | ||||
|       width: auto; | ||||
|  | ||||
|       &:hover { | ||||
|         color: $white-color; | ||||
|         text-shadow: 0.5px 0.5px 0.5px $shadow-color; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .fa-github { | ||||
|       color: $white-color; | ||||
|     } | ||||
|  | ||||
|     hr { | ||||
|       width: 100%; | ||||
|       height: 0px; | ||||
|       border: none; | ||||
|       border-top: 0.5px solid $white-color; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @media (min-width: $small-devices) { | ||||
|     width: 90%; | ||||
|     margin: 2em auto; | ||||
|  | ||||
|     font-size: 90%; | ||||
|     text-align: center; | ||||
|     vertical-align: middle; | ||||
|  | ||||
|     section:first-of-type { | ||||
|       margin: 0.6em 0; | ||||
|       color: $white-color; | ||||
|       border-radius: 5px; | ||||
|       display: flex; | ||||
|       flex-wrap: wrap; | ||||
|       align-items: center; | ||||
|       background-color: $primary-neutral-dark-color; | ||||
|       box-shadow: $shadow-color 0 0 15px; | ||||
|  | ||||
|       a { | ||||
|         color: $white-color; | ||||
|         width: auto; | ||||
|         padding: 0.8em; | ||||
|         flex: 1; | ||||
|         font-weight: bold; | ||||
|  | ||||
|         &:hover { | ||||
|           color: $white-color; | ||||
|           text-shadow: 0.5px 0.5px 0.5px $shadow-color; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .fa-github { | ||||
|       color: $githubblack; | ||||
|     } | ||||
|  | ||||
|     hr { | ||||
|       border: none; | ||||
|       height: 5px; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -713,47 +713,6 @@ textarea { | ||||
|   margin-top: 10px; | ||||
| } | ||||
|  | ||||
| /*--------------------------------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 { | ||||
|   | ||||
| @@ -4,6 +4,12 @@ | ||||
|   display: block; | ||||
| } | ||||
|  | ||||
| .zoom-control { | ||||
|   margin-right: 10px; | ||||
|   display: flex; | ||||
|   justify-content: right; | ||||
| } | ||||
|  | ||||
| .graph-toolbar { | ||||
|   margin-top: 10px; | ||||
|   margin-bottom: 10px; | ||||
| @@ -34,30 +40,37 @@ | ||||
|  | ||||
|     .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 { | ||||
|           border-radius: 50%; | ||||
|           font-size: 12px; | ||||
|           padding: 5px; | ||||
|         } | ||||
|  | ||||
|         &:enabled>.fa { | ||||
|           background-color: #354a5f; | ||||
|           color: white; | ||||
|         } | ||||
|  | ||||
|         &:enabled:hover>.fa { | ||||
|           color: white; | ||||
|           background-color: #35405f; // just a bit darker | ||||
|         } | ||||
|  | ||||
|         &: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%; | ||||
|     } | ||||
| @@ -91,10 +105,12 @@ | ||||
|     margin: 0; | ||||
|   } | ||||
| } | ||||
|  | ||||
| #family-tree-link { | ||||
|   display: inline-block; | ||||
|   margin-top: 10px; | ||||
|   text-align: center; | ||||
|  | ||||
|   @media (min-width: 450px) { | ||||
|     margin-right: auto; | ||||
|   } | ||||
|   | ||||
| @@ -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> | ||||
|       {% include "core/base/footer.jinja" %} | ||||
|     {% endblock %} | ||||
|       <br> | ||||
|     </footer> | ||||
|  | ||||
|     {% 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)) { | ||||
|   | ||||
							
								
								
									
										16
									
								
								core/templates/core/base/footer.jinja
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								core/templates/core/base/footer.jinja
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| <footer class="bottom-links"> | ||||
|   <section> | ||||
|     <a href="{{ url('core:page', 'contacts') }}">{% trans %}Contacts{% endtrans %}</a> | ||||
|     <a href="{{ url('core:page', 'legals') }}">{% trans %}Legal notices{% endtrans %}</a> | ||||
|     <a href="{{ url('core:page', 'copyright_agent') }}">{% trans %}Intellectual property{% endtrans %}</a> | ||||
|     <a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a> | ||||
|     <a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a> | ||||
|   </section> | ||||
|   <hr> | ||||
|   <section> | ||||
|     <a rel="nofollow" href="https://github.com/ae-utbm/sith" target="#"> | ||||
|       <i class="fa-brands fa-github"></i> | ||||
|       {% trans %}Site created by the IT Department of the AE{% endtrans %} | ||||
|     </a> | ||||
|   </section> | ||||
| </footer> | ||||
| @@ -26,9 +26,11 @@ | ||||
|     {% endif %} | ||||
|   {% endif %} | ||||
|  | ||||
|   <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 %} | ||||
|   | ||||
| @@ -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 %} | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
| @@ -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" | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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(); | ||||
|   | ||||
| @@ -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(); | ||||
|   | ||||
| @@ -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( | ||||
|       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.open = true; | ||||
|       if (this.alertMessage.timeout !== null) { | ||||
|         clearTimeout(this.alertMessage.timeout); | ||||
|       } | ||||
|       this.alertMessage.timeout = setTimeout(() => { | ||||
|         this.alertMessage.open = false; | ||||
|       }, 2000); | ||||
|       this.alertMessage.display(content, { success: success }); | ||||
|       this.loading = false; | ||||
|     }, | ||||
|   })); | ||||
|   | ||||
							
								
								
									
										2
									
								
								counter/static/bundled/counter/types.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								counter/static/bundled/counter/types.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -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 */ | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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?" | ||||
|   | ||||
							
								
								
									
										39
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										39
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -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" | ||||
|   | ||||
| @@ -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" | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
							
								
								
									
										19
									
								
								sas/migrations/0005_alter_sasfile_options.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								sas/migrations/0005_alter_sasfile_options.py
									
									
									
									
									
										Normal 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"), | ||||
|                 ] | ||||
|             }, | ||||
|         ), | ||||
|     ] | ||||
| @@ -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)) | ||||
|   | ||||
| @@ -83,7 +83,6 @@ document.addEventListener("alpine:init", () => { | ||||
|  | ||||
|   Alpine.data("pictureUpload", (albumId: number) => ({ | ||||
|     errors: [] as string[], | ||||
|     pictures: [], | ||||
|     sending: false, | ||||
|     progress: null as HTMLProgressElement, | ||||
|  | ||||
|   | ||||
| @@ -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<number, PictureSchema[]>, picture: PictureSchema) => { | ||||
|           if (!acc[picture.album.id]) { | ||||
|             acc[picture.album.id] = []; | ||||
|       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(); | ||||
|       } | ||||
|           acc[picture.album.id].push(picture); | ||||
|           return acc; | ||||
|         }, | ||||
|         {}, | ||||
|       ); | ||||
|       this.loading = false; | ||||
|     }, | ||||
|   })); | ||||
|   | ||||
| @@ -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 | ||||
|        **/ | ||||
|   | ||||
| @@ -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 %}" | ||||
|   | ||||
| @@ -20,17 +20,17 @@ | ||||
|       {{ download_button(_("Download all my pictures")) }} | ||||
|     {% endif %} | ||||
|  | ||||
|     <template x-for="[album_id, pictures] in Object.entries(albums)" x-cloak> | ||||
|     <template x-for="album in albums" x-cloak> | ||||
|       <section> | ||||
|         <br /> | ||||
|         <div class="row"> | ||||
|           <h4 x-text="pictures[0].album.name" :id="`album-${album_id}`"></h4> | ||||
|           <h4 x-text="album.name" :id="`album-${album.id}`"></h4> | ||||
|           {% if user.id == object.id %} | ||||
|              {{ 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" | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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
									
									
									
								
							
							
						
						
									
										2
									
								
								uv.lock
									
									
									
										generated
									
									
									
								
							| @@ -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" }, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user