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" | name: "Setup project" | ||||||
| description: "Setup Python and Poetry" | 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: | runs: | ||||||
|   using: composite |   using: composite | ||||||
|   steps: |   steps: | ||||||
|     - name: Install apt packages |     - name: Install apt packages | ||||||
|  |       if: ${{ inputs.full == 'true' }} | ||||||
|       uses: awalsh128/cache-apt-pkgs-action@v1.4.3 |       uses: awalsh128/cache-apt-pkgs-action@v1.4.3 | ||||||
|       with: |       with: | ||||||
|         packages: gettext |         packages: gettext | ||||||
|         version: 1.0  # increment to reset cache |         version: 1.0  # increment to reset cache | ||||||
|  |  | ||||||
|     - name: Install Redis |     - name: Install Redis | ||||||
|  |       if: ${{ inputs.full == 'true' }} | ||||||
|       uses: shogo82148/actions-setup-redis@v1 |       uses: shogo82148/actions-setup-redis@v1 | ||||||
|       with: |       with: | ||||||
|         redis-version: "7.x" |         redis-version: "7.x" | ||||||
| @@ -37,15 +46,20 @@ runs: | |||||||
|       shell: bash |       shell: bash | ||||||
|  |  | ||||||
|     - name: Install Xapian |     - name: Install Xapian | ||||||
|  |       if: ${{ inputs.full == 'true' }} | ||||||
|       run: uv run ./manage.py install_xapian |       run: uv run ./manage.py install_xapian | ||||||
|       shell: bash |       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 |     - name: Save cached virtualenv | ||||||
|  |       if: ${{ inputs.full == 'true' }} | ||||||
|       uses: actions/cache/save@v4 |       uses: actions/cache/save@v4 | ||||||
|       with: |       with: | ||||||
|         key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }} |         key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }} | ||||||
|         path: .venv |         path: .venv | ||||||
|  |  | ||||||
|     - name: Compile gettext messages |     - name: Compile gettext messages | ||||||
|  |       if: ${{ inputs.full == 'true' }} | ||||||
|       run: uv run ./manage.py compilemessages |       run: uv run ./manage.py compilemessages | ||||||
|       shell: bash |       shell: bash | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @@ -37,6 +37,8 @@ jobs: | |||||||
|       - name: Check out repository |       - name: Check out repository | ||||||
|         uses: actions/checkout@v4 |         uses: actions/checkout@v4 | ||||||
|       - uses: ./.github/actions/setup_project |       - uses: ./.github/actions/setup_project | ||||||
|  |         with: | ||||||
|  |           full: true | ||||||
|         env: |         env: | ||||||
|           # To avoid race conditions on environment cache |           # To avoid race conditions on environment cache | ||||||
|           CACHE_SUFFIX: ${{ matrix.pytest-mark }} |           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: | on: | ||||||
|   push: |   push: | ||||||
|     branches: |     branches: | ||||||
|       - master |       - taiste | ||||||
| env: |  | ||||||
|   SECRET_KEY: notTheRealOne |  | ||||||
|   DATABASE_URL: sqlite:///db.sqlite3 |  | ||||||
|   CACHE_URL: redis://127.0.0.1:6379/0 |  | ||||||
| permissions: | permissions: | ||||||
|   contents: write |   contents: write | ||||||
| jobs: | jobs: | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ from ninja_extra import NinjaExtraAPI | |||||||
|  |  | ||||||
| api = NinjaExtraAPI( | api = NinjaExtraAPI( | ||||||
|     title="PICON", |     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", |     version="0.2.0", | ||||||
|     urls_namespace="api", |     urls_namespace="api", | ||||||
|     csrf=True, |     csrf=True, | ||||||
|   | |||||||
							
								
								
									
										10
									
								
								club/api.py
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								club/api.py
									
									
									
									
									
								
							| @@ -1,6 +1,7 @@ | |||||||
| from typing import Annotated | from typing import Annotated | ||||||
|  |  | ||||||
| from annotated_types import MinLen | from annotated_types import MinLen | ||||||
|  | from django.db.models import Prefetch | ||||||
| from ninja.security import SessionAuth | from ninja.security import SessionAuth | ||||||
| from ninja_extra import ControllerBase, api_controller, paginate, route | from ninja_extra import ControllerBase, api_controller, paginate, route | ||||||
| from ninja_extra.pagination import PageNumberPaginationExtra | from ninja_extra.pagination import PageNumberPaginationExtra | ||||||
| @@ -8,7 +9,7 @@ from ninja_extra.schemas import PaginatedResponseSchema | |||||||
|  |  | ||||||
| from api.auth import ApiKeyAuth | from api.auth import ApiKeyAuth | ||||||
| from api.permissions import CanAccessLookup, HasPerm | from api.permissions import CanAccessLookup, HasPerm | ||||||
| from club.models import Club | from club.models import Club, Membership | ||||||
| from club.schemas import ClubSchema, SimpleClubSchema | from club.schemas import ClubSchema, SimpleClubSchema | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -33,6 +34,9 @@ class ClubController(ControllerBase): | |||||||
|         url_name="fetch_club", |         url_name="fetch_club", | ||||||
|     ) |     ) | ||||||
|     def fetch_club(self, club_id: int): |     def fetch_club(self, club_id: int): | ||||||
|         return self.get_object_or_exception( |         prefetch = Prefetch( | ||||||
|             Club.objects.prefetch_related("members", "members__user"), id=club_id |             "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 | import pytest | ||||||
| from django.test import Client | from django.test import Client | ||||||
| from django.urls import reverse | from django.urls import reverse | ||||||
| from model_bakery import baker | from model_bakery import baker | ||||||
|  | from model_bakery.recipe import Recipe | ||||||
| from pytest_django.asserts import assertNumQueries | from pytest_django.asserts import assertNumQueries | ||||||
|  |  | ||||||
| from club.models import Club, Membership | from club.models import Club, Membership | ||||||
| @@ -9,13 +12,32 @@ from core.baker_recipes import subscriber_user | |||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.mark.django_db | @pytest.mark.django_db | ||||||
| def test_fetch_club(client: Client): | class TestFetchClub: | ||||||
|     club = baker.make(Club) |     @pytest.fixture() | ||||||
|     baker.make(Membership, club=club, _quantity=10, _bulk_create=True) |     def club(self): | ||||||
|     user = subscriber_user.make() |         club = baker.make(Club) | ||||||
|     client.force_login(user) |         last_month = date.today() - timedelta(days=30) | ||||||
|     with assertNumQueries(7): |         yesterday = date.today() - timedelta(days=1) | ||||||
|         # - 4 queries for authentication |         membership_recipe = Recipe(Membership, club=club, start_date=last_month) | ||||||
|         # - 3 queries for the actual data |         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})) |         res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id})) | ||||||
|         assert res.status_code == 200 |         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" | VERSION="$1" | ||||||
|  |  | ||||||
| # Cleanup env vars for auto discovery mechanism | # Cleanup env vars for auto discovery mechanism | ||||||
| export CPATH= | unset CPATH | ||||||
| export LIBRARY_PATH= | unset LIBRARY_PATH | ||||||
| export CFLAGS= | unset CFLAGS | ||||||
| export LDFLAGS= | unset LDFLAGS | ||||||
| export CCFLAGS= | unset CCFLAGS | ||||||
| export CXXFLAGS= | unset CXXFLAGS | ||||||
| export CPPFLAGS= | unset CPPFLAGS | ||||||
|  |  | ||||||
| # prepare | # prepare | ||||||
| rm -rf "$VIRTUAL_ENV/packages" | rm -rf "$VIRTUAL_ENV/packages" | ||||||
|   | |||||||
| @@ -59,6 +59,7 @@ class PopulatedGroups(NamedTuple): | |||||||
|     counter_admin: Group |     counter_admin: Group | ||||||
|     accounting_admin: Group |     accounting_admin: Group | ||||||
|     pedagogy_admin: Group |     pedagogy_admin: Group | ||||||
|  |     campus_admin: Group | ||||||
|  |  | ||||||
|  |  | ||||||
| class Command(BaseCommand): | class Command(BaseCommand): | ||||||
| @@ -784,13 +785,13 @@ class Command(BaseCommand): | |||||||
|         # public has no permission. |         # public has no permission. | ||||||
|         # Its purpose is not to link users to permissions, |         # Its purpose is not to link users to permissions, | ||||||
|         # but to other objects (like products) |         # but to other objects (like products) | ||||||
|         public_group = Group.objects.create(name="Public") |         public_group = Group.objects.create(name="Publique") | ||||||
|  |  | ||||||
|         subscribers = Group.objects.create(name="Subscribers") |         subscribers = Group.objects.create(name="Cotisants") | ||||||
|         subscribers.permissions.add( |         subscribers.permissions.add( | ||||||
|             *list(perms.filter(codename__in=["add_news", "add_uvcomment"])) |             *list(perms.filter(codename__in=["add_news", "add_uvcomment"])) | ||||||
|         ) |         ) | ||||||
|         old_subscribers = Group.objects.create(name="Old subscribers") |         old_subscribers = Group.objects.create(name="Anciens cotisants") | ||||||
|         old_subscribers.permissions.add( |         old_subscribers.permissions.add( | ||||||
|             *list( |             *list( | ||||||
|                 perms.filter( |                 perms.filter( | ||||||
| @@ -812,7 +813,7 @@ class Command(BaseCommand): | |||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|         accounting_admin = Group.objects.create( |         accounting_admin = Group.objects.create( | ||||||
|             name="Accounting admin", is_manually_manageable=True |             name="Admin comptabilité", is_manually_manageable=True | ||||||
|         ) |         ) | ||||||
|         accounting_admin.permissions.add( |         accounting_admin.permissions.add( | ||||||
|             *list( |             *list( | ||||||
| @@ -833,7 +834,7 @@ class Command(BaseCommand): | |||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|         com_admin = Group.objects.create( |         com_admin = Group.objects.create( | ||||||
|             name="Communication admin", is_manually_manageable=True |             name="Admin communication", is_manually_manageable=True | ||||||
|         ) |         ) | ||||||
|         com_admin.permissions.add( |         com_admin.permissions.add( | ||||||
|             *list( |             *list( | ||||||
| @@ -841,7 +842,7 @@ class Command(BaseCommand): | |||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|         counter_admin = Group.objects.create( |         counter_admin = Group.objects.create( | ||||||
|             name="Counter admin", is_manually_manageable=True |             name="Admin comptoirs", is_manually_manageable=True | ||||||
|         ) |         ) | ||||||
|         counter_admin.permissions.add( |         counter_admin.permissions.add( | ||||||
|             *list( |             *list( | ||||||
| @@ -851,14 +852,14 @@ class Command(BaseCommand): | |||||||
|                 ) |                 ) | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|         sas_admin = Group.objects.create(name="SAS admin", is_manually_manageable=True) |         sas_admin = Group.objects.create(name="Admin SAS", is_manually_manageable=True) | ||||||
|         sas_admin.permissions.add( |         sas_admin.permissions.add( | ||||||
|             *list( |             *list( | ||||||
|                 perms.filter(content_type__app_label="sas").values_list("pk", flat=True) |                 perms.filter(content_type__app_label="sas").values_list("pk", flat=True) | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|         forum_admin = Group.objects.create( |         forum_admin = Group.objects.create( | ||||||
|             name="Forum admin", is_manually_manageable=True |             name="Admin forum", is_manually_manageable=True | ||||||
|         ) |         ) | ||||||
|         forum_admin.permissions.add( |         forum_admin.permissions.add( | ||||||
|             *list( |             *list( | ||||||
| @@ -868,7 +869,7 @@ class Command(BaseCommand): | |||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|         pedagogy_admin = Group.objects.create( |         pedagogy_admin = Group.objects.create( | ||||||
|             name="Pedagogy admin", is_manually_manageable=True |             name="Admin pédagogie", is_manually_manageable=True | ||||||
|         ) |         ) | ||||||
|         pedagogy_admin.permissions.add( |         pedagogy_admin.permissions.add( | ||||||
|             *list( |             *list( | ||||||
| @@ -877,6 +878,16 @@ class Command(BaseCommand): | |||||||
|                 .values_list("pk", flat=True) |                 .values_list("pk", flat=True) | ||||||
|             ) |             ) | ||||||
|         ) |         ) | ||||||
|  |         campus_admin = Group.objects.create( | ||||||
|  |             name="Respo site", is_manually_manageable=True | ||||||
|  |         ) | ||||||
|  |         campus_admin.permissions.add( | ||||||
|  |             *counter_admin.permissions.values_list("pk", flat=True), | ||||||
|  |             *perms.filter(content_type__app_label="reservation").values_list( | ||||||
|  |                 "pk", flat=True | ||||||
|  |             ), | ||||||
|  |         ) | ||||||
|  |  | ||||||
|         self.reset_index("core", "auth") |         self.reset_index("core", "auth") | ||||||
|  |  | ||||||
|         return PopulatedGroups( |         return PopulatedGroups( | ||||||
| @@ -889,6 +900,7 @@ class Command(BaseCommand): | |||||||
|             accounting_admin=accounting_admin, |             accounting_admin=accounting_admin, | ||||||
|             sas_admin=sas_admin, |             sas_admin=sas_admin, | ||||||
|             pedagogy_admin=pedagogy_admin, |             pedagogy_admin=pedagogy_admin, | ||||||
|  |             campus_admin=campus_admin, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     def _create_ban_groups(self): |     def _create_ban_groups(self): | ||||||
|   | |||||||
| @@ -238,7 +238,13 @@ class Command(BaseCommand): | |||||||
|         ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID) |         ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID) | ||||||
|         other_clubs = random.sample(list(Club.objects.all()), k=3) |         other_clubs = random.sample(list(Club.objects.all()), k=3) | ||||||
|         groups = list( |         groups = list( | ||||||
|             Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"]) |             Group.objects.filter( | ||||||
|  |                 id__in=[ | ||||||
|  |                     settings.SITH_GROUP_SUBSCRIBERS_ID, | ||||||
|  |                     settings.SITH_GROUP_OLD_SUBSCRIBERS_ID, | ||||||
|  |                     settings.SITH_GROUP_PUBLIC_ID, | ||||||
|  |                 ] | ||||||
|  |             ) | ||||||
|         ) |         ) | ||||||
|         counters = list( |         counters = list( | ||||||
|             Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"]) |             Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"]) | ||||||
|   | |||||||
							
								
								
									
										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 type { Client, RequestResult, TDataShape } from "#openapi:client"; | ||||||
| import { client } from "#openapi"; | import { type Options, client } from "#openapi"; | ||||||
|  |  | ||||||
| export interface PaginatedResponse<T> { | export interface PaginatedResponse<T> { | ||||||
|   count: number; |   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; |   margin-top: 10px; | ||||||
| } | } | ||||||
|  |  | ||||||
| /*--------------------------------FOOTER-------------------------------*/ |  | ||||||
|  |  | ||||||
| footer { |  | ||||||
|   width: 90%; |  | ||||||
|   margin: 2em auto; |  | ||||||
|  |  | ||||||
|   font-size: 90%; |  | ||||||
|   text-align: center; |  | ||||||
|   vertical-align: middle; |  | ||||||
|  |  | ||||||
|   div { |  | ||||||
|     margin: 0.6em 0; |  | ||||||
|     color: $white-color; |  | ||||||
|     border-radius: 5px; |  | ||||||
|     display: flex; |  | ||||||
|     flex-wrap: wrap; |  | ||||||
|     align-items: center; |  | ||||||
|     background-color: $primary-neutral-dark-color; |  | ||||||
|     box-shadow: $shadow-color 0 0 15px; |  | ||||||
|  |  | ||||||
|     a { |  | ||||||
|       padding: 0.8em; |  | ||||||
|       flex: 1; |  | ||||||
|       font-weight: bold; |  | ||||||
|       color: $white-color !important; |  | ||||||
|  |  | ||||||
|       &:hover { |  | ||||||
|         color: $primary-dark-color; |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   >.version { |  | ||||||
|     margin-top: 3px; |  | ||||||
|     color: rgba(0, 0, 0, 0.3); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .fa-github { |  | ||||||
|     color: $githubblack; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  |  | ||||||
| .ui-dialog .ui-dialog-buttonpane { | .ui-dialog .ui-dialog-buttonpane { | ||||||
|   | |||||||
| @@ -4,6 +4,12 @@ | |||||||
|   display: block; |   display: block; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .zoom-control { | ||||||
|  |   margin-right: 10px; | ||||||
|  |   display: flex; | ||||||
|  |   justify-content: right; | ||||||
|  | } | ||||||
|  |  | ||||||
| .graph-toolbar { | .graph-toolbar { | ||||||
|   margin-top: 10px; |   margin-top: 10px; | ||||||
|   margin-bottom: 10px; |   margin-bottom: 10px; | ||||||
| @@ -12,7 +18,7 @@ | |||||||
|   justify-content: space-around; |   justify-content: space-around; | ||||||
|   gap: 30px; |   gap: 30px; | ||||||
|  |  | ||||||
|   .toolbar-column{ |   .toolbar-column { | ||||||
|     display: flex; |     display: flex; | ||||||
|     flex-direction: column; |     flex-direction: column; | ||||||
|     gap: 20px; |     gap: 20px; | ||||||
| @@ -34,31 +40,38 @@ | |||||||
|  |  | ||||||
|     .depth-choice { |     .depth-choice { | ||||||
|       white-space: nowrap; |       white-space: nowrap; | ||||||
|  |  | ||||||
|       input[type="number"] { |       input[type="number"] { | ||||||
|         -webkit-appearance: textfield; |         -webkit-appearance: textfield; | ||||||
|         -moz-appearance: textfield; |         -moz-appearance: textfield; | ||||||
|         appearance: textfield; |         appearance: textfield; | ||||||
|  |  | ||||||
|         &::-webkit-inner-spin-button, |         &::-webkit-inner-spin-button, | ||||||
|         &::-webkit-outer-spin-button { |         &::-webkit-outer-spin-button { | ||||||
|           -webkit-appearance: none; |           -webkit-appearance: none; | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       button { |       button { | ||||||
|         background: none; |         background: none; | ||||||
|         & > .fa { |  | ||||||
|  |         &>.fa { | ||||||
|           border-radius: 50%; |           border-radius: 50%; | ||||||
|           font-size: 12px; |           font-size: 12px; | ||||||
|           padding: 5px; |           padding: 5px; | ||||||
|         } |         } | ||||||
|         &:enabled > .fa { |  | ||||||
|  |         &:enabled>.fa { | ||||||
|           background-color: #354a5f; |           background-color: #354a5f; | ||||||
|           color: white; |           color: white; | ||||||
|         } |         } | ||||||
|         &:enabled:hover > .fa { |  | ||||||
|  |         &:enabled:hover>.fa { | ||||||
|           color: white; |           color: white; | ||||||
|           background-color: #35405f; // just a bit darker |           background-color: #35405f; // just a bit darker | ||||||
|         } |         } | ||||||
|         &:disabled > .fa { |  | ||||||
|  |         &:disabled>.fa { | ||||||
|           background-color: gray; |           background-color: gray; | ||||||
|           color: white; |           color: white; | ||||||
|         } |         } | ||||||
| @@ -74,6 +87,7 @@ | |||||||
|   @media screen and (max-width: 500px) { |   @media screen and (max-width: 500px) { | ||||||
|     flex-direction: column; |     flex-direction: column; | ||||||
|     gap: 20px; |     gap: 20px; | ||||||
|  |  | ||||||
|     .toolbar-column { |     .toolbar-column { | ||||||
|       min-width: 100%; |       min-width: 100%; | ||||||
|     } |     } | ||||||
| @@ -87,14 +101,16 @@ | |||||||
|   padding: 10px; |   padding: 10px; | ||||||
|   box-sizing: border-box; |   box-sizing: border-box; | ||||||
|  |  | ||||||
|   > form { |   >form { | ||||||
|     margin: 0; |     margin: 0; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| #family-tree-link { | #family-tree-link { | ||||||
|   display: inline-block; |   display: inline-block; | ||||||
|   margin-top: 10px; |   margin-top: 10px; | ||||||
|   text-align: center; |   text-align: center; | ||||||
|  |  | ||||||
|   @media (min-width: 450px) { |   @media (min-width: 450px) { | ||||||
|     margin-right: auto; |     margin-right: auto; | ||||||
|   } |   } | ||||||
| @@ -122,10 +138,10 @@ | |||||||
|     width: 100%; |     width: 100%; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   > div.mini_profile_link { |   >div.mini_profile_link { | ||||||
|     position: relative; |     position: relative; | ||||||
|  |  | ||||||
|     > a { |     >a { | ||||||
|       &.mini_profile_link { |       &.mini_profile_link { | ||||||
|         display: flex; |         display: flex; | ||||||
|         flex-direction: column; |         flex-direction: column; | ||||||
| @@ -140,7 +156,7 @@ | |||||||
|           max-height: 65px; |           max-height: 65px; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         > span { |         >span { | ||||||
|           height: 150px; |           height: 150px; | ||||||
|           width: 100%; |           width: 100%; | ||||||
|  |  | ||||||
| @@ -149,7 +165,7 @@ | |||||||
|             width: 80px; |             width: 80px; | ||||||
|           } |           } | ||||||
|  |  | ||||||
|           > img { |           >img { | ||||||
|             width: 100%; |             width: 100%; | ||||||
|             max-width: 100%; |             max-width: 100%; | ||||||
|             max-height: 100%; |             max-height: 100%; | ||||||
| @@ -163,7 +179,7 @@ | |||||||
|           } |           } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         > em { |         >em { | ||||||
|           box-sizing: border-box; |           box-sizing: border-box; | ||||||
|           padding: 0 5px; |           padding: 0 5px; | ||||||
|           text-align: center; |           text-align: center; | ||||||
| @@ -195,7 +211,7 @@ | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   > a.mini_profile_link { |   >a.mini_profile_link { | ||||||
|     display: none; |     display: none; | ||||||
|   } |   } | ||||||
| } | } | ||||||
| @@ -11,6 +11,7 @@ | |||||||
|       <link rel="stylesheet" href="{{ static('core/markdown.scss') }}"> |       <link rel="stylesheet" href="{{ static('core/markdown.scss') }}"> | ||||||
|       <link rel="stylesheet" href="{{ static('core/header.scss') }}"> |       <link rel="stylesheet" href="{{ static('core/header.scss') }}"> | ||||||
|       <link rel="stylesheet" href="{{ static('core/navbar.scss') }}"> |       <link rel="stylesheet" href="{{ static('core/navbar.scss') }}"> | ||||||
|  |       <link rel="stylesheet" href="{{ static('core/footer.scss') }}"> | ||||||
|       <link rel="stylesheet" href="{{ static('core/pagination.scss') }}"> |       <link rel="stylesheet" href="{{ static('core/pagination.scss') }}"> | ||||||
|       <link rel="stylesheet" href="{{ static('core/accordion.scss') }}"> |       <link rel="stylesheet" href="{{ static('core/accordion.scss') }}"> | ||||||
|  |  | ||||||
| @@ -18,6 +19,7 @@ | |||||||
|       <noscript><link rel="stylesheet" href="{{ static('bundled/fontawesome-index.css') }}"></noscript> |       <noscript><link rel="stylesheet" href="{{ static('bundled/fontawesome-index.css') }}"></noscript> | ||||||
|  |  | ||||||
|       <script src="{{ url('javascript-catalog') }}"></script> |       <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/core/components/include-index.ts") }}></script> | ||||||
|       <script type="module" src="{{ static('bundled/alpine-index.js') }}"></script> |       <script type="module" src="{{ static('bundled/alpine-index.js') }}"></script> | ||||||
|       <script type="module" src="{{ static('bundled/htmx-index.js') }}"></script> |       <script type="module" src="{{ static('bundled/htmx-index.js') }}"></script> | ||||||
| @@ -88,58 +90,12 @@ | |||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|  |  | ||||||
|     <footer> |     {% block footer %} | ||||||
|       {% block footer %} |       {% include "core/base/footer.jinja" %} | ||||||
|         <div> |     {% endblock %} | ||||||
|           <a href="{{ url('core:page', 'contacts') }}">{% trans %}Contacts{% endtrans %}</a> |  | ||||||
|           <a href="{{ url('core:page', 'legals') }}">{% trans %}Legal notices{% endtrans %}</a> |  | ||||||
|           <a href="{{ url('core:page', 'copyright_agent') }}">{% trans %}Intellectual property{% endtrans %}</a> |  | ||||||
|           <a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a> |  | ||||||
|           <a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a> |  | ||||||
|         </div> |  | ||||||
|         <a rel="nofollow" href="https://github.com/ae-utbm/sith" target="#"> |  | ||||||
|           <i class="fa-brands fa-github"></i> |  | ||||||
|           {% trans %}Site created by the IT Department of the AE{% endtrans %} |  | ||||||
|         </a> |  | ||||||
|       {% endblock %} |  | ||||||
|       <br> |  | ||||||
|     </footer> |  | ||||||
|  |  | ||||||
|     {% block script %} |     {% block script %} | ||||||
|       <script> |       <script> | ||||||
|         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) => { |         document.addEventListener("keydown", (e) => { | ||||||
|           // Looking at the `s` key when not typing in a form |           // Looking at the `s` key when not typing in a form | ||||||
|           if (e.keyCode !== 83 || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) { |           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 %} | ||||||
|   {% endif %} |   {% endif %} | ||||||
|  |  | ||||||
|   <form method="post" action="{{ url('core:login') }}"> |   <form method="post" action="{{ url('core:login') }}" id="login-form"> | ||||||
|     {% if form.errors %} |     {% if form.errors %} | ||||||
|       <p class="alert alert-red">{% trans %}Your username and password didn't match. Please try again.{% endtrans %}</p> |       <p class="alert alert-red"> | ||||||
|  |         {% trans %}Your credentials didn't match. Please try again.{% endtrans %} | ||||||
|  |       </p> | ||||||
|     {% endif %} |     {% endif %} | ||||||
|  |  | ||||||
|     {% csrf_token %} |     {% csrf_token %} | ||||||
|   | |||||||
| @@ -7,7 +7,7 @@ | |||||||
| {%- endblock -%} | {%- endblock -%} | ||||||
|  |  | ||||||
| {% block additional_js %} | {% block additional_js %} | ||||||
|   <script type="module" src="{{ static("bundled/user/family-graph-index.js") }}"></script> |   <script type="module" src="{{ static("bundled/user/family-graph-index.ts") }}"></script> | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block title %} | {% block title %} | ||||||
| @@ -15,7 +15,14 @@ | |||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
|   <div x-data="graph" :aria-busy="loading"> |   <div | ||||||
|  |     x-data="graph({ | ||||||
|  |             activeUser: {{ object.id }}, | ||||||
|  |             depthMin: {{ depth_min }}, | ||||||
|  |             depthMax: {{ depth_max }}, | ||||||
|  |             })" | ||||||
|  |     :aria-busy="loading" | ||||||
|  |   > | ||||||
|     <div class="graph-toolbar"> |     <div class="graph-toolbar"> | ||||||
|       <div class="toolbar-column"> |       <div class="toolbar-column"> | ||||||
|         <div class="toolbar-input"> |         <div class="toolbar-input"> | ||||||
| @@ -86,17 +93,36 @@ | |||||||
|         </button> |         </button> | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|  |  | ||||||
|  |     <div class="zoom-control" x-ref="zoomControl"> | ||||||
|  |       <button | ||||||
|  |         @click="graph.zoom(graph.zoom() + 1)" | ||||||
|  |         :disabled="!isZoomEnabled" | ||||||
|  |       > | ||||||
|  |         <i class="fa-solid fa-magnifying-glass-plus"></i> | ||||||
|  |       </button> | ||||||
|  |       <button | ||||||
|  |         @click="graph.zoom(graph.zoom() - 1)" | ||||||
|  |         :disabled="!isZoomEnabled" | ||||||
|  |       > | ||||||
|  |         <i class="fa-solid fa-magnifying-glass-minus"></i> | ||||||
|  |       </button> | ||||||
|  |       <button | ||||||
|  |         x-show="isZoomEnabled" | ||||||
|  |         @click="isZoomEnabled = false" | ||||||
|  |       > | ||||||
|  |         <i class="fa-solid fa-unlock"></i> | ||||||
|  |       </button> | ||||||
|  |       <button | ||||||
|  |         x-show="!isZoomEnabled" | ||||||
|  |         @click="isZoomEnabled = true" | ||||||
|  |       > | ||||||
|  |         <i class="fa-solid fa-lock"></i> | ||||||
|  |       </button> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|     <div x-ref="graph" class="graph"></div> |     <div x-ref="graph" class="graph"></div> | ||||||
|   </div> |   </div> | ||||||
|  |  | ||||||
|   <script> |  | ||||||
|     window.addEventListener("DOMContentLoaded", () => { |  | ||||||
|       loadFamilyGraph({ |  | ||||||
|         activeUser: {{ object.id }}, |  | ||||||
|         depthMin: {{ depth_min }}, |  | ||||||
|         depthMax: {{ depth_max }}, |  | ||||||
|       }); |  | ||||||
|     }); |  | ||||||
|   </script> |  | ||||||
| {% endblock %} | {% endblock %} | ||||||
|  |  | ||||||
|   | |||||||
| @@ -38,6 +38,7 @@ from core.markdown import markdown | |||||||
| from core.models import AnonymousUser, Group, Page, User | from core.models import AnonymousUser, Group, Page, User | ||||||
| from core.utils import get_semester_code, get_start_of_semester | from core.utils import get_semester_code, get_start_of_semester | ||||||
| from core.views import AllowFragment | from core.views import AllowFragment | ||||||
|  | from counter.models import Customer | ||||||
| from sith import settings | from sith import settings | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -151,24 +152,44 @@ class TestUserLogin: | |||||||
|     def user(self) -> User: |     def user(self) -> User: | ||||||
|         return baker.make(User, password=make_password("plop")) |         return baker.make(User, password=make_password("plop")) | ||||||
|  |  | ||||||
|     def test_login_fail(self, client, user): |     @pytest.mark.parametrize( | ||||||
|  |         "identifier_getter", | ||||||
|  |         [ | ||||||
|  |             lambda user: user.username, | ||||||
|  |             lambda user: user.email, | ||||||
|  |             lambda user: Customer.get_or_create(user)[0].account_id, | ||||||
|  |         ], | ||||||
|  |     ) | ||||||
|  |     def test_login_fail(self, client, user, identifier_getter): | ||||||
|         """Should not login a user correctly.""" |         """Should not login a user correctly.""" | ||||||
|  |         identifier = identifier_getter(user) | ||||||
|         response = client.post( |         response = client.post( | ||||||
|             reverse("core:login"), |             reverse("core:login"), | ||||||
|             {"username": user.username, "password": "wrong-password"}, |             {"username": identifier, "password": "wrong-password"}, | ||||||
|         ) |         ) | ||||||
|         assert response.status_code == 200 |         assert response.status_code == 200 | ||||||
|         assert ( |  | ||||||
|             '<p class="alert alert-red">Votre nom d\'utilisateur ' |  | ||||||
|             "et votre mot de passe ne correspondent pas. Merci de réessayer.</p>" |  | ||||||
|         ) in response.text |  | ||||||
|         assert response.wsgi_request.user.is_anonymous |         assert response.wsgi_request.user.is_anonymous | ||||||
|  |         soup = BeautifulSoup(response.text, "lxml") | ||||||
|  |         form = soup.find(id="login-form") | ||||||
|  |         assert ( | ||||||
|  |             form.find(class_="alert alert-red").get_text(strip=True) | ||||||
|  |             == "Vos identifiants ne correspondent pas. Veuillez réessayer." | ||||||
|  |         ) | ||||||
|  |         assert form.find("input", attrs={"name": "username"}).get("value") == identifier | ||||||
|  |  | ||||||
|     def test_login_success(self, client, user): |     @pytest.mark.parametrize( | ||||||
|  |         "identifier_getter", | ||||||
|  |         [ | ||||||
|  |             lambda user: user.username, | ||||||
|  |             lambda user: user.email, | ||||||
|  |             lambda user: Customer.get_or_create(user)[0].account_id, | ||||||
|  |         ], | ||||||
|  |     ) | ||||||
|  |     def test_login_success(self, client, user, identifier_getter): | ||||||
|         """Should login a user correctly.""" |         """Should login a user correctly.""" | ||||||
|         response = client.post( |         response = client.post( | ||||||
|             reverse("core:login"), |             reverse("core:login"), | ||||||
|             {"username": user.username, "password": "plop"}, |             {"username": identifier_getter(user), "password": "plop"}, | ||||||
|         ) |         ) | ||||||
|         assertRedirects(response, reverse("core:index")) |         assertRedirects(response, reverse("core:index")) | ||||||
|         assert response.wsgi_request.user == user |         assert response.wsgi_request.user == user | ||||||
| @@ -361,17 +382,9 @@ class TestUserIsInGroup(TestCase): | |||||||
|  |  | ||||||
|     @classmethod |     @classmethod | ||||||
|     def setUpTestData(cls): |     def setUpTestData(cls): | ||||||
|         cls.root_group = Group.objects.get(name="Root") |         cls.public_group = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID) | ||||||
|         cls.public_group = Group.objects.get(name="Public") |  | ||||||
|         cls.public_user = baker.make(User) |         cls.public_user = baker.make(User) | ||||||
|         cls.subscribers = Group.objects.get(name="Subscribers") |  | ||||||
|         cls.old_subscribers = Group.objects.get(name="Old subscribers") |  | ||||||
|         cls.accounting_admin = Group.objects.get(name="Accounting admin") |  | ||||||
|         cls.com_admin = Group.objects.get(name="Communication admin") |  | ||||||
|         cls.counter_admin = Group.objects.get(name="Counter admin") |  | ||||||
|         cls.sas_admin = Group.objects.get(name="SAS admin") |  | ||||||
|         cls.club = baker.make(Club) |         cls.club = baker.make(Club) | ||||||
|         cls.main_club = Club.objects.get(id=1) |  | ||||||
|  |  | ||||||
|     def assert_in_public_group(self, user): |     def assert_in_public_group(self, user): | ||||||
|         assert user.is_in_group(pk=self.public_group.id) |         assert user.is_in_group(pk=self.public_group.id) | ||||||
| @@ -379,15 +392,7 @@ class TestUserIsInGroup(TestCase): | |||||||
|  |  | ||||||
|     def assert_only_in_public_group(self, user): |     def assert_only_in_public_group(self, user): | ||||||
|         self.assert_in_public_group(user) |         self.assert_in_public_group(user) | ||||||
|         for group in ( |         for group in Group.objects.exclude(id=self.public_group.id): | ||||||
|             self.root_group, |  | ||||||
|             self.accounting_admin, |  | ||||||
|             self.sas_admin, |  | ||||||
|             self.subscribers, |  | ||||||
|             self.old_subscribers, |  | ||||||
|             self.club.members_group, |  | ||||||
|             self.club.board_group, |  | ||||||
|         ): |  | ||||||
|             assert not user.is_in_group(pk=group.pk) |             assert not user.is_in_group(pk=group.pk) | ||||||
|             assert not user.is_in_group(name=group.name) |             assert not user.is_in_group(name=group.name) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -132,29 +132,31 @@ class FutureDateTimeField(forms.DateTimeField): | |||||||
|  |  | ||||||
| class LoginForm(AuthenticationForm): | class LoginForm(AuthenticationForm): | ||||||
|     def __init__(self, *arg, **kwargs): |     def __init__(self, *arg, **kwargs): | ||||||
|         if "data" in kwargs: |  | ||||||
|             from counter.models import Customer |  | ||||||
|  |  | ||||||
|             data = kwargs["data"].copy() |  | ||||||
|             account_code = re.compile(r"^[0-9]+[A-Za-z]$") |  | ||||||
|             try: |  | ||||||
|                 if account_code.match(data["username"]): |  | ||||||
|                     user = ( |  | ||||||
|                         Customer.objects.filter(account_id__iexact=data["username"]) |  | ||||||
|                         .first() |  | ||||||
|                         .user |  | ||||||
|                     ) |  | ||||||
|                 elif "@" in data["username"]: |  | ||||||
|                     user = User.objects.filter(email__iexact=data["username"]).first() |  | ||||||
|                 else: |  | ||||||
|                     user = User.objects.filter(username=data["username"]).first() |  | ||||||
|                 data["username"] = user.username |  | ||||||
|             except:  # noqa E722 I don't know what error is supposed to be raised here |  | ||||||
|                 pass |  | ||||||
|             kwargs["data"] = data |  | ||||||
|         super().__init__(*arg, **kwargs) |         super().__init__(*arg, **kwargs) | ||||||
|         self.fields["username"].label = _("Username, email, or account number") |         self.fields["username"].label = _("Username, email, or account number") | ||||||
|  |  | ||||||
|  |     def clean_username(self): | ||||||
|  |         identifier: str = self.cleaned_data["username"] | ||||||
|  |         account_code = re.compile(r"^[0-9]+[A-Za-z]$") | ||||||
|  |         if account_code.match(identifier): | ||||||
|  |             qs_filter = "customer__account_id__iexact" | ||||||
|  |         elif identifier.count("@") == 1: | ||||||
|  |             qs_filter = "email" | ||||||
|  |         else: | ||||||
|  |             qs_filter = None | ||||||
|  |         if qs_filter: | ||||||
|  |             # if the user gave an email or an account code instead of | ||||||
|  |             # a username, retrieve and return the corresponding username. | ||||||
|  |             # If there is no username, return an empty string, so that | ||||||
|  |             # Django will properly handle the error when failing the authentication | ||||||
|  |             identifier = ( | ||||||
|  |                 User.objects.filter(**{qs_filter: identifier}) | ||||||
|  |                 .values_list("username", flat=True) | ||||||
|  |                 .first() | ||||||
|  |                 or "" | ||||||
|  |             ) | ||||||
|  |         return identifier | ||||||
|  |  | ||||||
|  |  | ||||||
| class RegisteringForm(UserCreationForm): | class RegisteringForm(UserCreationForm): | ||||||
|     error_css_class = "error" |     error_css_class = "error" | ||||||
|   | |||||||
| @@ -41,6 +41,7 @@ class ProductAdmin(SearchModelAdmin): | |||||||
|         "profit", |         "profit", | ||||||
|         "archived", |         "archived", | ||||||
|     ) |     ) | ||||||
|  |     list_select_related = ("product_type",) | ||||||
|     search_fields = ("name", "code") |     search_fields = ("name", "code") | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -81,20 +82,13 @@ class AccountDumpAdmin(admin.ModelAdmin): | |||||||
|         "customer", |         "customer", | ||||||
|         "warning_mail_sent_at", |         "warning_mail_sent_at", | ||||||
|         "warning_mail_error", |         "warning_mail_error", | ||||||
|         "dump_operation", |         "dump_operation__date", | ||||||
|         "amount", |         "amount", | ||||||
|     ) |     ) | ||||||
|  |     list_select_related = ("customer", "customer__user", "dump_operation") | ||||||
|     autocomplete_fields = ("customer", "dump_operation") |     autocomplete_fields = ("customer", "dump_operation") | ||||||
|     list_filter = ("warning_mail_error",) |     list_filter = ("warning_mail_error",) | ||||||
|  |  | ||||||
|     def get_queryset(self, request): |  | ||||||
|         # the `amount` property requires to know the customer and the dump_operation |  | ||||||
|         return ( |  | ||||||
|             super() |  | ||||||
|             .get_queryset(request) |  | ||||||
|             .select_related("customer", "customer__user", "dump_operation") |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @admin.register(Counter) | @admin.register(Counter) | ||||||
| class CounterAdmin(admin.ModelAdmin): | class CounterAdmin(admin.ModelAdmin): | ||||||
| @@ -113,11 +107,14 @@ class RefillingAdmin(SearchModelAdmin): | |||||||
|         "customer__account_id", |         "customer__account_id", | ||||||
|         "counter__name", |         "counter__name", | ||||||
|     ) |     ) | ||||||
|  |     list_filter = (("counter", admin.RelatedOnlyFieldListFilter),) | ||||||
|  |     date_hierarchy = "date" | ||||||
|  |  | ||||||
|  |  | ||||||
| @admin.register(Selling) | @admin.register(Selling) | ||||||
| class SellingAdmin(SearchModelAdmin): | class SellingAdmin(SearchModelAdmin): | ||||||
|     list_display = ("customer", "label", "unit_price", "quantity", "counter", "date") |     list_display = ("customer", "label", "unit_price", "quantity", "counter", "date") | ||||||
|  |     list_select_related = ("customer", "customer__user", "counter") | ||||||
|     search_fields = ( |     search_fields = ( | ||||||
|         "customer__user__username", |         "customer__user__username", | ||||||
|         "customer__user__first_name", |         "customer__user__first_name", | ||||||
| @@ -126,6 +123,8 @@ class SellingAdmin(SearchModelAdmin): | |||||||
|         "counter__name", |         "counter__name", | ||||||
|     ) |     ) | ||||||
|     autocomplete_fields = ("customer", "seller") |     autocomplete_fields = ("customer", "seller") | ||||||
|  |     list_filter = (("counter", admin.RelatedOnlyFieldListFilter),) | ||||||
|  |     date_hierarchy = "date" | ||||||
|  |  | ||||||
|  |  | ||||||
| @admin.register(Permanency) | @admin.register(Permanency) | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | import { AlertMessage } from "#core:utils/alert-message"; | ||||||
| import { BasketItem } from "#counter:counter/basket"; | import { BasketItem } from "#counter:counter/basket"; | ||||||
| import type { CounterConfig, ErrorMessage } from "#counter:counter/types"; | import type { CounterConfig, ErrorMessage } from "#counter:counter/types"; | ||||||
| import type { CounterProductSelect } from "./components/counter-product-select-index.ts"; | 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", () => { | document.addEventListener("alpine:init", () => { | ||||||
|   Alpine.data("counter", (config: CounterConfig) => ({ |   Alpine.data("counter", (config: CounterConfig) => ({ | ||||||
|     basket: {} as Record<string, BasketItem>, |     basket: {} as Record<string, BasketItem>, | ||||||
|     errors: [], |  | ||||||
|     customerBalance: config.customerBalance, |     customerBalance: config.customerBalance, | ||||||
|     codeField: null as CounterProductSelect | null, |     codeField: null as CounterProductSelect | null, | ||||||
|     alertMessage: { |     alertMessage: new AlertMessage({ defaultDuration: 2000 }), | ||||||
|       content: "", |  | ||||||
|       show: false, |  | ||||||
|       timeout: null, |  | ||||||
|     }, |  | ||||||
|  |  | ||||||
|     init() { |     init() { | ||||||
|       // Fill the basket with the initial data |       // Fill the basket with the initial data | ||||||
| @@ -77,22 +73,10 @@ document.addEventListener("alpine:init", () => { | |||||||
|       return total; |       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) { |     addToBasketWithMessage(id: string, quantity: number) { | ||||||
|       const message = this.addToBasket(id, quantity); |       const message = this.addToBasket(id, quantity); | ||||||
|       if (message.length > 0) { |       if (message.length > 0) { | ||||||
|         this.showAlertMessage(message); |         this.alertMessage.display(message, { success: false }); | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
| @@ -109,7 +93,9 @@ document.addEventListener("alpine:init", () => { | |||||||
|  |  | ||||||
|     finish() { |     finish() { | ||||||
|       if (this.getBasketSize() === 0) { |       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; |         return; | ||||||
|       } |       } | ||||||
|       this.$refs.basketForm.submit(); |       this.$refs.basketForm.submit(); | ||||||
|   | |||||||
| @@ -167,7 +167,7 @@ document.addEventListener("alpine:init", () => { | |||||||
|       }); |       }); | ||||||
|       // if products to download are already in-memory, directly take them. |       // if products to download are already in-memory, directly take them. | ||||||
|       // If not, fetch them. |       // If not, fetch them. | ||||||
|       const products = |       const products: ProductSchema[] = | ||||||
|         this.nbPages > 1 |         this.nbPages > 1 | ||||||
|           ? await paginated(productSearchProductsDetailed, this.getQueryParams()) |           ? await paginated(productSearchProductsDetailed, this.getQueryParams()) | ||||||
|           : Object.values<ProductSchema[]>(this.products).flat(); |           : Object.values<ProductSchema[]>(this.products).flat(); | ||||||
|   | |||||||
| @@ -1,15 +1,11 @@ | |||||||
|  | import { AlertMessage } from "#core:utils/alert-message"; | ||||||
| import Alpine from "alpinejs"; | import Alpine from "alpinejs"; | ||||||
| import { producttypeReorder } from "#openapi"; | import { producttypeReorder } from "#openapi"; | ||||||
|  |  | ||||||
| document.addEventListener("alpine:init", () => { | document.addEventListener("alpine:init", () => { | ||||||
|   Alpine.data("productTypesList", () => ({ |   Alpine.data("productTypesList", () => ({ | ||||||
|     loading: false, |     loading: false, | ||||||
|     alertMessage: { |     alertMessage: new AlertMessage({ defaultDuration: 2000 }), | ||||||
|       open: false, |  | ||||||
|       success: true, |  | ||||||
|       content: "", |  | ||||||
|       timeout: null, |  | ||||||
|     }, |  | ||||||
|  |  | ||||||
|     async reorder(itemId: number, newPosition: number) { |     async reorder(itemId: number, newPosition: number) { | ||||||
|       // The sort plugin of Alpine doesn't manage dynamic lists with x-sort |       // The sort plugin of Alpine doesn't manage dynamic lists with x-sort | ||||||
| @@ -41,23 +37,14 @@ document.addEventListener("alpine:init", () => { | |||||||
|     }, |     }, | ||||||
|  |  | ||||||
|     openAlertMessage(response: Response) { |     openAlertMessage(response: Response) { | ||||||
|       if (response.ok) { |       const success = response.ok; | ||||||
|         this.alertMessage.success = true; |       const content = response.ok | ||||||
|         this.alertMessage.content = gettext("Products types reordered!"); |         ? gettext("Products types reordered!") | ||||||
|       } else { |         : interpolate( | ||||||
|         this.alertMessage.success = false; |             gettext("Product type reorganisation failed with status code : %d"), | ||||||
|         this.alertMessage.content = interpolate( |             [response.status], | ||||||
|           gettext("Product type reorganisation failed with status code : %d"), |           ); | ||||||
|           [response.status], |       this.alertMessage.display(content, { success: success }); | ||||||
|         ); |  | ||||||
|       } |  | ||||||
|       this.alertMessage.open = true; |  | ||||||
|       if (this.alertMessage.timeout !== null) { |  | ||||||
|         clearTimeout(this.alertMessage.timeout); |  | ||||||
|       } |  | ||||||
|       this.alertMessage.timeout = setTimeout(() => { |  | ||||||
|         this.alertMessage.open = false; |  | ||||||
|       }, 2000); |  | ||||||
|       this.loading = false; |       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 { | export interface InitialFormData { | ||||||
|   /* Used to refill the form when the backend raises an error */ |   /* Used to refill the form when the backend raises an error */ | ||||||
|   | |||||||
| @@ -17,6 +17,7 @@ from datetime import timedelta | |||||||
| from decimal import Decimal | from decimal import Decimal | ||||||
|  |  | ||||||
| import pytest | import pytest | ||||||
|  | from dateutil.relativedelta import relativedelta | ||||||
| from django.conf import settings | from django.conf import settings | ||||||
| from django.contrib.auth.models import Permission, make_password | from django.contrib.auth.models import Permission, make_password | ||||||
| from django.core.cache import cache | from django.core.cache import cache | ||||||
| @@ -823,3 +824,53 @@ class TestClubCounterClickAccess(TestCase): | |||||||
|         self.client.force_login(self.user) |         self.client.force_login(self.user) | ||||||
|         res = self.client.get(self.click_url) |         res = self.client.get(self.click_url) | ||||||
|         assert res.status_code == 200 |         assert res.status_code == 200 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.django_db | ||||||
|  | class TestCounterLogout: | ||||||
|  |     def test_logout_simple(self, client: Client): | ||||||
|  |         perm_counter = baker.make(Counter, type="BAR") | ||||||
|  |         permanence = baker.make( | ||||||
|  |             Permanency, | ||||||
|  |             counter=perm_counter, | ||||||
|  |             start=now() - timedelta(hours=1), | ||||||
|  |             activity=now() - timedelta(minutes=10), | ||||||
|  |         ) | ||||||
|  |         with freeze_time(): | ||||||
|  |             res = client.post( | ||||||
|  |                 reverse("counter:logout", kwargs={"counter_id": permanence.counter_id}), | ||||||
|  |                 data={"user_id": permanence.user_id}, | ||||||
|  |             ) | ||||||
|  |             assertRedirects( | ||||||
|  |                 res, | ||||||
|  |                 reverse( | ||||||
|  |                     "counter:details", kwargs={"counter_id": permanence.counter_id} | ||||||
|  |                 ), | ||||||
|  |             ) | ||||||
|  |             permanence.refresh_from_db() | ||||||
|  |             assert permanence.end == now() | ||||||
|  |  | ||||||
|  |     def test_logout_doesnt_change_old_permanences(self, client: Client): | ||||||
|  |         perm_counter = baker.make(Counter, type="BAR") | ||||||
|  |         permanence = baker.make( | ||||||
|  |             Permanency, | ||||||
|  |             counter=perm_counter, | ||||||
|  |             start=now() - timedelta(hours=1), | ||||||
|  |             activity=now() - timedelta(minutes=10), | ||||||
|  |         ) | ||||||
|  |         old_end = now() - relativedelta(year=10) | ||||||
|  |         old_permanence = baker.make( | ||||||
|  |             Permanency, | ||||||
|  |             counter=perm_counter, | ||||||
|  |             end=old_end, | ||||||
|  |             activity=now() - relativedelta(year=8), | ||||||
|  |         ) | ||||||
|  |         with freeze_time(): | ||||||
|  |             client.post( | ||||||
|  |                 reverse("counter:logout", kwargs={"counter_id": permanence.counter_id}), | ||||||
|  |                 data={"user_id": permanence.user_id}, | ||||||
|  |             ) | ||||||
|  |             permanence.refresh_from_db() | ||||||
|  |             assert permanence.end == now() | ||||||
|  |             old_permanence.refresh_from_db() | ||||||
|  |             assert old_permanence.end == old_end | ||||||
|   | |||||||
| @@ -13,10 +13,10 @@ | |||||||
| # | # | ||||||
| # | # | ||||||
|  |  | ||||||
| from django.db.models import F |  | ||||||
| from django.http import HttpRequest, HttpResponseRedirect | from django.http import HttpRequest, HttpResponseRedirect | ||||||
| from django.shortcuts import get_object_or_404, redirect | from django.shortcuts import get_object_or_404, redirect | ||||||
| from django.utils import timezone | from django.utils import timezone | ||||||
|  | from django.utils.timezone import now | ||||||
| from django.views.decorators.http import require_POST | from django.views.decorators.http import require_POST | ||||||
|  |  | ||||||
| from core.views.forms import LoginForm | from core.views.forms import LoginForm | ||||||
| @@ -47,7 +47,7 @@ def counter_login(request: HttpRequest, counter_id: int) -> HttpResponseRedirect | |||||||
| @require_POST | @require_POST | ||||||
| def counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect: | def counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect: | ||||||
|     """End the permanency of a user in this counter.""" |     """End the permanency of a user in this counter.""" | ||||||
|     Permanency.objects.filter(counter=counter_id, user=request.POST["user_id"]).update( |     Permanency.objects.filter( | ||||||
|         end=F("activity") |         counter=counter_id, user=request.POST["user_id"], end=None | ||||||
|     ) |     ).update(end=now()) | ||||||
|     return redirect("counter:details", counter_id=counter_id) |     return redirect("counter:details", counter_id=counter_id) | ||||||
|   | |||||||
| @@ -6,7 +6,7 @@ | |||||||
| msgid "" | msgid "" | ||||||
| msgstr "" | msgstr "" | ||||||
| "Report-Msgid-Bugs-To: \n" | "Report-Msgid-Bugs-To: \n" | ||||||
| "POT-Creation-Date: 2025-06-16 14:54+0200\n" | "POT-Creation-Date: 2025-06-25 16:29+0200\n" | ||||||
| "PO-Revision-Date: 2016-07-18\n" | "PO-Revision-Date: 2016-07-18\n" | ||||||
| "Last-Translator: Maréchal <thomas.girod@utbm.fr\n" | "Last-Translator: Maréchal <thomas.girod@utbm.fr\n" | ||||||
| "Language-Team: AE info <ae.info@utbm.fr>\n" | "Language-Team: AE info <ae.info@utbm.fr>\n" | ||||||
| @@ -2015,10 +2015,8 @@ msgid "Please login or create an account to see this page." | |||||||
| msgstr "Merci de vous identifier ou de créer un compte pour voir cette page." | msgstr "Merci de vous identifier ou de créer un compte pour voir cette page." | ||||||
|  |  | ||||||
| #: core/templates/core/login.jinja | #: core/templates/core/login.jinja | ||||||
| msgid "Your username and password didn't match. Please try again." | msgid "Your credentials didn't match. Please try again." | ||||||
| msgstr "" | msgstr "Vos identifiants ne correspondent pas. Veuillez réessayer." | ||||||
| "Votre nom d'utilisateur et votre mot de passe ne correspondent pas. Merci de " |  | ||||||
| "réessayer." |  | ||||||
|  |  | ||||||
| #: core/templates/core/login.jinja | #: core/templates/core/login.jinja | ||||||
| msgid "Lost password?" | msgid "Lost password?" | ||||||
|   | |||||||
							
								
								
									
										39
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										39
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -45,7 +45,11 @@ | |||||||
|         "@hey-api/openapi-ts": "^0.73.0", |         "@hey-api/openapi-ts": "^0.73.0", | ||||||
|         "@rollup/plugin-inject": "^5.0.5", |         "@rollup/plugin-inject": "^5.0.5", | ||||||
|         "@types/alpinejs": "^3.13.10", |         "@types/alpinejs": "^3.13.10", | ||||||
|  |         "@types/cytoscape-cxtmenu": "^3.4.4", | ||||||
|  |         "@types/cytoscape-klay": "^3.1.4", | ||||||
|         "@types/jquery": "^3.5.31", |         "@types/jquery": "^3.5.31", | ||||||
|  |         "@types/js-cookie": "^3.0.6", | ||||||
|  |         "typescript": "^5.8.3", | ||||||
|         "vite": "^6.2.5", |         "vite": "^6.2.5", | ||||||
|         "vite-bundle-visualizer": "^1.2.1", |         "vite-bundle-visualizer": "^1.2.1", | ||||||
|         "vite-plugin-static-copy": "^3.0.2" |         "vite-plugin-static-copy": "^3.0.2" | ||||||
| @@ -2819,6 +2823,33 @@ | |||||||
|         "@types/tern": "*" |         "@types/tern": "*" | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     "node_modules/@types/cytoscape": { | ||||||
|  |       "version": "3.21.9", | ||||||
|  |       "resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.21.9.tgz", | ||||||
|  |       "integrity": "sha512-JyrG4tllI6jvuISPjHK9j2Xv/LTbnLekLke5otGStjFluIyA9JjgnvgZrSBsp8cEDpiTjwgZUZwpPv8TSBcoLw==", | ||||||
|  |       "dev": true, | ||||||
|  |       "license": "MIT" | ||||||
|  |     }, | ||||||
|  |     "node_modules/@types/cytoscape-cxtmenu": { | ||||||
|  |       "version": "3.4.4", | ||||||
|  |       "resolved": "https://registry.npmjs.org/@types/cytoscape-cxtmenu/-/cytoscape-cxtmenu-3.4.4.tgz", | ||||||
|  |       "integrity": "sha512-cuv+IdbKekswDRBIrHn97IYOzWS2/UjVr0kDIHCOYvqWy3iZkuGGM4qmHNPQ+63Dn7JgtmD0l3MKW1moyhoaKw==", | ||||||
|  |       "dev": true, | ||||||
|  |       "license": "MIT", | ||||||
|  |       "dependencies": { | ||||||
|  |         "@types/cytoscape": "*" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "node_modules/@types/cytoscape-klay": { | ||||||
|  |       "version": "3.1.4", | ||||||
|  |       "resolved": "https://registry.npmjs.org/@types/cytoscape-klay/-/cytoscape-klay-3.1.4.tgz", | ||||||
|  |       "integrity": "sha512-H+tIadpcVjmDGWKFUfibwzIpH/kddfwAFsuhPparjiC+bWBm+MeNqIwwY+19ofkJZWcqWqZL6Jp8lkp+sP8Aig==", | ||||||
|  |       "dev": true, | ||||||
|  |       "license": "MIT", | ||||||
|  |       "dependencies": { | ||||||
|  |         "@types/cytoscape": "*" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     "node_modules/@types/estree": { |     "node_modules/@types/estree": { | ||||||
|       "version": "1.0.8", |       "version": "1.0.8", | ||||||
|       "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", |       "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", | ||||||
| @@ -2835,6 +2866,13 @@ | |||||||
|         "@types/sizzle": "*" |         "@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": { |     "node_modules/@types/json-schema": { | ||||||
|       "version": "7.0.15", |       "version": "7.0.15", | ||||||
|       "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", |       "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", | ||||||
| @@ -5558,7 +5596,6 @@ | |||||||
|       "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", |       "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", | ||||||
|       "dev": true, |       "dev": true, | ||||||
|       "license": "Apache-2.0", |       "license": "Apache-2.0", | ||||||
|       "peer": true, |  | ||||||
|       "bin": { |       "bin": { | ||||||
|         "tsc": "bin/tsc", |         "tsc": "bin/tsc", | ||||||
|         "tsserver": "bin/tsserver" |         "tsserver": "bin/tsserver" | ||||||
|   | |||||||
| @@ -10,7 +10,7 @@ | |||||||
|     "openapi": "openapi-ts", |     "openapi": "openapi-ts", | ||||||
|     "analyse-dev": "vite-bundle-visualizer --mode development", |     "analyse-dev": "vite-bundle-visualizer --mode development", | ||||||
|     "analyse-prod": "vite-bundle-visualizer --mode production", |     "analyse-prod": "vite-bundle-visualizer --mode production", | ||||||
|     "check": "biome check --write" |     "check": "tsc && biome check --write" | ||||||
|   }, |   }, | ||||||
|   "keywords": [], |   "keywords": [], | ||||||
|   "author": "", |   "author": "", | ||||||
| @@ -30,7 +30,11 @@ | |||||||
|     "@hey-api/openapi-ts": "^0.73.0", |     "@hey-api/openapi-ts": "^0.73.0", | ||||||
|     "@rollup/plugin-inject": "^5.0.5", |     "@rollup/plugin-inject": "^5.0.5", | ||||||
|     "@types/alpinejs": "^3.13.10", |     "@types/alpinejs": "^3.13.10", | ||||||
|  |     "@types/cytoscape-cxtmenu": "^3.4.4", | ||||||
|  |     "@types/cytoscape-klay": "^3.1.4", | ||||||
|     "@types/jquery": "^3.5.31", |     "@types/jquery": "^3.5.31", | ||||||
|  |     "@types/js-cookie": "^3.0.6", | ||||||
|  |     "typescript": "^5.8.3", | ||||||
|     "vite": "^6.2.5", |     "vite": "^6.2.5", | ||||||
|     "vite-bundle-visualizer": "^1.2.1", |     "vite-bundle-visualizer": "^1.2.1", | ||||||
|     "vite-plugin-static-copy": "^3.0.2" |     "vite-plugin-static-copy": "^3.0.2" | ||||||
|   | |||||||
| @@ -92,7 +92,7 @@ docs = [ | |||||||
| default-groups = ["dev", "tests", "docs"] | default-groups = ["dev", "tests", "docs"] | ||||||
|  |  | ||||||
| [tool.xapian] | [tool.xapian] | ||||||
| version = "1.4.25" | version = "1.4.29" | ||||||
|  |  | ||||||
| [tool.ruff] | [tool.ruff] | ||||||
| output-format = "concise" # makes ruff error logs easier to read | output-format = "concise" # makes ruff error logs easier to read | ||||||
|   | |||||||
| @@ -53,9 +53,9 @@ class TestMergeUser(TestCase): | |||||||
|         self.to_keep.address = "Jerusalem" |         self.to_keep.address = "Jerusalem" | ||||||
|         self.to_delete.parent_address = "Rome" |         self.to_delete.parent_address = "Rome" | ||||||
|         self.to_delete.address = "Rome" |         self.to_delete.address = "Rome" | ||||||
|         subscribers = Group.objects.get(name="Subscribers") |         subscribers = Group.objects.get(id=settings.SITH_GROUP_SUBSCRIBERS_ID) | ||||||
|         mde_admin = Group.objects.get(name="MDE admin") |         mde_admin = Group.objects.get(name="MDE admin") | ||||||
|         sas_admin = Group.objects.get(name="SAS admin") |         sas_admin = Group.objects.get(id=settings.SITH_GROUP_SAS_ADMIN_ID) | ||||||
|         self.to_keep.groups.add(subscribers.id) |         self.to_keep.groups.add(subscribers.id) | ||||||
|         self.to_delete.groups.add(mde_admin.id) |         self.to_delete.groups.add(mde_admin.id) | ||||||
|         self.to_keep.groups.add(sas_admin.id) |         self.to_keep.groups.add(sas_admin.id) | ||||||
|   | |||||||
							
								
								
									
										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: |     class Meta: | ||||||
|         proxy = True |         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): |     def can_be_viewed_by(self, user): | ||||||
|         if user.is_anonymous: |         if user.is_anonymous: | ||||||
| @@ -59,7 +63,7 @@ class SasFile(SithFile): | |||||||
|         return self.id in viewable |         return self.id in viewable | ||||||
|  |  | ||||||
|     def can_be_edited_by(self, user): |     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): | class PictureQuerySet(models.QuerySet): | ||||||
| @@ -69,7 +73,7 @@ class PictureQuerySet(models.QuerySet): | |||||||
|         Warning: |         Warning: | ||||||
|             Calling this queryset method may add several additional requests. |             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() |             return self.all() | ||||||
|         if user.was_subscribed: |         if user.was_subscribed: | ||||||
|             return self.filter(Q(is_moderated=True) | Q(owner=user)) |             return self.filter(Q(is_moderated=True) | Q(owner=user)) | ||||||
| @@ -182,7 +186,7 @@ class AlbumQuerySet(models.QuerySet): | |||||||
|         Warning: |         Warning: | ||||||
|             Calling this queryset method may add several additional requests. |             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() |             return self.all() | ||||||
|         if user.was_subscribed: |         if user.was_subscribed: | ||||||
|             return self.filter(Q(is_moderated=True) | Q(owner=user)) |             return self.filter(Q(is_moderated=True) | Q(owner=user)) | ||||||
|   | |||||||
| @@ -83,7 +83,6 @@ document.addEventListener("alpine:init", () => { | |||||||
|  |  | ||||||
|   Alpine.data("pictureUpload", (albumId: number) => ({ |   Alpine.data("pictureUpload", (albumId: number) => ({ | ||||||
|     errors: [] as string[], |     errors: [] as string[], | ||||||
|     pictures: [], |  | ||||||
|     sending: false, |     sending: false, | ||||||
|     progress: null as HTMLProgressElement, |     progress: null as HTMLProgressElement, | ||||||
|  |  | ||||||
|   | |||||||
| @@ -9,28 +9,35 @@ interface PagePictureConfig { | |||||||
|   userId: number; |   userId: number; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | interface Album { | ||||||
|  |   id: number; | ||||||
|  |   name: string; | ||||||
|  |   pictures: PictureSchema[]; | ||||||
|  | } | ||||||
|  |  | ||||||
| document.addEventListener("alpine:init", () => { | document.addEventListener("alpine:init", () => { | ||||||
|   Alpine.data("user_pictures", (config: PagePictureConfig) => ({ |   Alpine.data("user_pictures", (config: PagePictureConfig) => ({ | ||||||
|     loading: true, |     loading: true, | ||||||
|     pictures: [] as PictureSchema[], |     albums: [] as Album[], | ||||||
|     albums: {} as Record<string, PictureSchema[]>, |  | ||||||
|  |  | ||||||
|     async init() { |     async init() { | ||||||
|       this.pictures = await paginated(picturesFetchPictures, { |       const pictures = await paginated(picturesFetchPictures, { | ||||||
|         // biome-ignore lint/style/useNamingConvention: from python api |         // biome-ignore lint/style/useNamingConvention: from python api | ||||||
|         query: { users_identified: [config.userId] }, |         query: { users_identified: [config.userId] }, | ||||||
|       } as PicturesFetchPicturesData); |       } as PicturesFetchPicturesData); | ||||||
|  |       const groupedAlbums = Object.groupBy(pictures, (i: PictureSchema) => i.album.id); | ||||||
|       this.albums = this.pictures.reduce( |       this.albums = Object.values(groupedAlbums).map((pictures: PictureSchema[]) => { | ||||||
|         (acc: Record<number, PictureSchema[]>, picture: PictureSchema) => { |         return { | ||||||
|           if (!acc[picture.album.id]) { |           id: pictures[0].album.id, | ||||||
|             acc[picture.album.id] = []; |           name: pictures[0].album.name, | ||||||
|           } |           pictures: pictures, | ||||||
|           acc[picture.album.id].push(picture); |         }; | ||||||
|           return acc; |       }); | ||||||
|         }, |       this.albums.sort((a: Album, b: Album) => b.id - a.id); | ||||||
|         {}, |       const hash = document.location.hash.replace("#", ""); | ||||||
|       ); |       if (hash.startsWith("album-")) { | ||||||
|  |         this.$nextTick(() => document.getElementById(hash)?.scrollIntoView()).then(); | ||||||
|  |       } | ||||||
|       this.loading = false; |       this.loading = false; | ||||||
|     }, |     }, | ||||||
|   })); |   })); | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | import type { UserAjaxSelect } from "#core:core/components/ajax-select-index"; | ||||||
| import { paginated } from "#core:utils/api"; | import { paginated } from "#core:utils/api"; | ||||||
| import { exportToHtml } from "#core:utils/globals"; | import { exportToHtml } from "#core:utils/globals"; | ||||||
| import { History } from "#core:utils/history"; | import { History } from "#core:utils/history"; | ||||||
| @@ -130,7 +131,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => { | |||||||
|       currentPicture: { |       currentPicture: { | ||||||
|         // biome-ignore lint/style/useNamingConvention: api is in snake_case |         // biome-ignore lint/style/useNamingConvention: api is in snake_case | ||||||
|         is_moderated: true, |         is_moderated: true, | ||||||
|         id: null, |         id: null as number, | ||||||
|         name: "", |         name: "", | ||||||
|         // biome-ignore lint/style/useNamingConvention: api is in snake_case |         // biome-ignore lint/style/useNamingConvention: api is in snake_case | ||||||
|         display_name: "", |         display_name: "", | ||||||
| @@ -142,7 +143,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => { | |||||||
|         full_size_url: "", |         full_size_url: "", | ||||||
|         owner: "", |         owner: "", | ||||||
|         date: new Date(), |         date: new Date(), | ||||||
|         identifications: [], |         identifications: [] as IdentifiedUserSchema[], | ||||||
|       }, |       }, | ||||||
|       /** |       /** | ||||||
|        * The picture which will be displayed next if the user press the "next" button |        * 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 |        * The select2 component used to identify users | ||||||
|        **/ |        **/ | ||||||
|       selector: undefined, |       selector: undefined as UserAjaxSelect, | ||||||
|       /** |       /** | ||||||
|        * Error message when a moderation operation fails |        * Error message when a moderation operation fails | ||||||
|        **/ |        **/ | ||||||
|   | |||||||
| @@ -50,7 +50,7 @@ | |||||||
|  #} |  #} | ||||||
| {% macro download_button(name) %} | {% macro download_button(name) %} | ||||||
|   <div x-data="pictures_download"> |   <div x-data="pictures_download"> | ||||||
|     <div x-show="pictures.length > 0" x-cloak> |     <div x-show="albums.length > 0" x-cloak> | ||||||
|       <button |       <button | ||||||
|         :disabled="isDownloading" |         :disabled="isDownloading" | ||||||
|         class="btn btn-blue {% if name == "" %}btn-no-text{% endif %}" |         class="btn btn-blue {% if name == "" %}btn-no-text{% endif %}" | ||||||
|   | |||||||
| @@ -20,17 +20,17 @@ | |||||||
|       {{ download_button(_("Download all my pictures")) }} |       {{ download_button(_("Download all my pictures")) }} | ||||||
|     {% endif %} |     {% endif %} | ||||||
|  |  | ||||||
|     <template x-for="[album_id, pictures] in Object.entries(albums)" x-cloak> |     <template x-for="album in albums" x-cloak> | ||||||
|       <section> |       <section> | ||||||
|         <br /> |         <br /> | ||||||
|         <div class="row"> |         <div class="row"> | ||||||
|           <h4 x-text="pictures[0].album.name" :id="`album-${album_id}`"></h4> |           <h4 x-text="album.name" :id="`album-${album.id}`"></h4> | ||||||
|           {% if user.id == object.id %} |           {% if user.id == object.id %} | ||||||
|              {{ download_button("") }} |              {{ download_button("") }} | ||||||
|           {% endif %} |           {% endif %} | ||||||
|         </div> |         </div> | ||||||
|         <div class="photos"> |         <div class="photos"> | ||||||
|           <template x-for="picture in pictures"> |           <template x-for="picture in album.pictures"> | ||||||
|             <a :href="picture.sas_url"> |             <a :href="picture.sas_url"> | ||||||
|               <div |               <div | ||||||
|                 class="photo" |                 class="photo" | ||||||
|   | |||||||
| @@ -381,10 +381,10 @@ SITH_GROUP_SAS_ADMIN_ID = env.int("SITH_GROUP_SAS_ADMIN_ID", default=8) | |||||||
| SITH_GROUP_FORUM_ADMIN_ID = env.int("SITH_GROUP_FORUM_ADMIN_ID", default=9) | SITH_GROUP_FORUM_ADMIN_ID = env.int("SITH_GROUP_FORUM_ADMIN_ID", default=9) | ||||||
| SITH_GROUP_PEDAGOGY_ADMIN_ID = env.int("SITH_GROUP_PEDAGOGY_ADMIN_ID", default=10) | SITH_GROUP_PEDAGOGY_ADMIN_ID = env.int("SITH_GROUP_PEDAGOGY_ADMIN_ID", default=10) | ||||||
|  |  | ||||||
| SITH_GROUP_BANNED_ALCOHOL_ID = env.int("SITH_GROUP_BANNED_ALCOHOL_ID", default=11) | SITH_GROUP_BANNED_ALCOHOL_ID = env.int("SITH_GROUP_BANNED_ALCOHOL_ID", default=12) | ||||||
| SITH_GROUP_BANNED_COUNTER_ID = env.int("SITH_GROUP_BANNED_COUNTER_ID", default=12) | SITH_GROUP_BANNED_COUNTER_ID = env.int("SITH_GROUP_BANNED_COUNTER_ID", default=13) | ||||||
| SITH_GROUP_BANNED_SUBSCRIPTION_ID = env.int( | SITH_GROUP_BANNED_SUBSCRIPTION_ID = env.int( | ||||||
|     "SITH_GROUP_BANNED_SUBSCRIPTION_ID", default=13 |     "SITH_GROUP_BANNED_SUBSCRIPTION_ID", default=14 | ||||||
| ) | ) | ||||||
|  |  | ||||||
| SITH_CLUB_REFOUND_ID = env.int("SITH_CLUB_REFOUND_ID", default=89) | SITH_CLUB_REFOUND_ID = env.int("SITH_CLUB_REFOUND_ID", default=89) | ||||||
|   | |||||||
| @@ -4,7 +4,7 @@ | |||||||
|     "sourceMap": true, |     "sourceMap": true, | ||||||
|     "noImplicitAny": true, |     "noImplicitAny": true, | ||||||
|     "module": "esnext", |     "module": "esnext", | ||||||
|     "target": "es2022", |     "target": "es2024", | ||||||
|     "allowJs": true, |     "allowJs": true, | ||||||
|     "moduleResolution": "node", |     "moduleResolution": "node", | ||||||
|     "experimentalDecorators": true, |     "experimentalDecorators": true, | ||||||
| @@ -14,6 +14,7 @@ | |||||||
|     "types": ["jquery", "alpinejs"], |     "types": ["jquery", "alpinejs"], | ||||||
|     "paths": { |     "paths": { | ||||||
|       "#openapi": ["./staticfiles/generated/openapi/client/index.ts"], |       "#openapi": ["./staticfiles/generated/openapi/client/index.ts"], | ||||||
|  |       "#openapi:*": ["./staticfiles/generated/openapi/client/*"], | ||||||
|       "#core:*": ["./core/static/bundled/*"], |       "#core:*": ["./core/static/bundled/*"], | ||||||
|       "#pedagogy:*": ["./pedagogy/static/bundled/*"], |       "#pedagogy:*": ["./pedagogy/static/bundled/*"], | ||||||
|       "#counter:*": ["./counter/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 = "ipython", specifier = ">=9.0.2,<10.0.0" }, | ||||||
|     { name = "pre-commit", specifier = ">=4.1.0,<5.0.0" }, |     { name = "pre-commit", specifier = ">=4.1.0,<5.0.0" }, | ||||||
|     { name = "rjsmin", specifier = ">=1.2.4,<2.0.0" }, |     { name = "rjsmin", specifier = ">=1.2.4,<2.0.0" }, | ||||||
|     { name = "ruff", specifier = ">=0.11.11,<1.0.0" }, |     { name = "ruff", specifier = ">=0.11.13,<1.0.0" }, | ||||||
| ] | ] | ||||||
| docs = [ | docs = [ | ||||||
|     { name = "mkdocs", specifier = ">=1.6.1,<2.0.0" }, |     { name = "mkdocs", specifier = ">=1.6.1,<2.0.0" }, | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user