mirror of
https://github.com/ae-utbm/sith.git
synced 2025-07-03 16:45:18 +00:00
commit
f6683068ff
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" },
|
||||||
|
Loading…
x
Reference in New Issue
Block a user