mirror of
https://github.com/ae-utbm/sith.git
synced 2025-07-13 05:19:26 +00:00
Compare commits
1 Commits
promo_add_
...
dependabot
Author | SHA1 | Date | |
---|---|---|---|
58589a4e9c |
14
.github/actions/setup_project/action.yml
vendored
14
.github/actions/setup_project/action.yml
vendored
@ -1,24 +1,15 @@
|
|||||||
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"
|
||||||
@ -46,20 +37,15 @@ 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,8 +37,6 @@ 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,7 +2,11 @@ name: deploy_docs
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- taiste
|
- master
|
||||||
|
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 Interactif de Communication avec les Outils Numériques",
|
description="Portail Interaction de Communication avec les Services Étudiants",
|
||||||
version="0.2.0",
|
version="0.2.0",
|
||||||
urls_namespace="api",
|
urls_namespace="api",
|
||||||
csrf=True,
|
csrf=True,
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
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
|
||||||
@ -9,7 +8,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, Membership
|
from club.models import Club
|
||||||
from club.schemas import ClubSchema, SimpleClubSchema
|
from club.schemas import ClubSchema, SimpleClubSchema
|
||||||
|
|
||||||
|
|
||||||
@ -34,9 +33,6 @@ 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):
|
||||||
prefetch = Prefetch(
|
|
||||||
"members", queryset=Membership.objects.ongoing().select_related("user")
|
|
||||||
)
|
|
||||||
return self.get_object_or_exception(
|
return self.get_object_or_exception(
|
||||||
Club.objects.prefetch_related(prefetch), id=club_id
|
Club.objects.prefetch_related("members", "members__user"), id=club_id
|
||||||
)
|
)
|
||||||
|
@ -1,10 +1,7 @@
|
|||||||
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
|
||||||
@ -12,32 +9,13 @@ from core.baker_recipes import subscriber_user
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestFetchClub:
|
def test_fetch_club(client: Client):
|
||||||
@pytest.fixture()
|
|
||||||
def club(self):
|
|
||||||
club = baker.make(Club)
|
club = baker.make(Club)
|
||||||
last_month = date.today() - timedelta(days=30)
|
baker.make(Membership, club=club, _quantity=10, _bulk_create=True)
|
||||||
yesterday = date.today() - timedelta(days=1)
|
|
||||||
membership_recipe = Recipe(Membership, club=club, start_date=last_month)
|
|
||||||
membership_recipe.make(end_date=None, _quantity=10, _bulk_create=True)
|
|
||||||
membership_recipe.make(end_date=yesterday, _quantity=10, _bulk_create=True)
|
|
||||||
return club
|
|
||||||
|
|
||||||
def test_fetch_club_members(self, client: Client, club: Club):
|
|
||||||
user = subscriber_user.make()
|
user = subscriber_user.make()
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id}))
|
with assertNumQueries(7):
|
||||||
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
|
# - 4 queries for authentication
|
||||||
# - 2 queries for the actual data
|
# - 3 queries for the actual data
|
||||||
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
|
||||||
|
@ -170,6 +170,7 @@ def news_notification_callback(notif: Notification):
|
|||||||
if count:
|
if count:
|
||||||
notif.viewed = False
|
notif.viewed = False
|
||||||
notif.param = str(count)
|
notif.param = str(count)
|
||||||
|
notif.date = timezone.now()
|
||||||
else:
|
else:
|
||||||
notif.viewed = True
|
notif.viewed = True
|
||||||
|
|
||||||
|
@ -1,41 +0,0 @@
|
|||||||
import pathlib
|
|
||||||
|
|
||||||
from django.apps import apps
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
from PIL import Image, UnidentifiedImageError
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument("number", type=int)
|
|
||||||
parser.add_argument("path", type=pathlib.Path)
|
|
||||||
parser.add_argument("-f", "--force", action="store_true")
|
|
||||||
|
|
||||||
def handle(self, number: int, path: pathlib.Path, force: int, *args, **options):
|
|
||||||
if not path.exists() or path.is_dir():
|
|
||||||
self.stderr.write(f"{path} is not a file or does not exist")
|
|
||||||
return
|
|
||||||
|
|
||||||
dest_path = (
|
|
||||||
pathlib.Path(apps.get_app_config("core").path)
|
|
||||||
/ "static"
|
|
||||||
/ "core"
|
|
||||||
/ "img"
|
|
||||||
/ f"promo_{number}.png"
|
|
||||||
)
|
|
||||||
|
|
||||||
if dest_path.exists() and not force:
|
|
||||||
over = input("File already exists, do you want to overwrite it? (y/N):")
|
|
||||||
if over.lower() != "y":
|
|
||||||
self.stdout.write("exiting")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
im = Image.open(path)
|
|
||||||
im.resize((120, 120), resample=Image.Resampling.LANCZOS).save(
|
|
||||||
dest_path, format="PNG"
|
|
||||||
)
|
|
||||||
self.stdout.write(
|
|
||||||
f"Promo logo moved and resized successfully at {dest_path}"
|
|
||||||
)
|
|
||||||
except UnidentifiedImageError:
|
|
||||||
self.stderr.write("image cannot be opened and identified.")
|
|
@ -4,13 +4,13 @@
|
|||||||
VERSION="$1"
|
VERSION="$1"
|
||||||
|
|
||||||
# Cleanup env vars for auto discovery mechanism
|
# Cleanup env vars for auto discovery mechanism
|
||||||
unset CPATH
|
export CPATH=
|
||||||
unset LIBRARY_PATH
|
export LIBRARY_PATH=
|
||||||
unset CFLAGS
|
export CFLAGS=
|
||||||
unset LDFLAGS
|
export LDFLAGS=
|
||||||
unset CCFLAGS
|
export CCFLAGS=
|
||||||
unset CXXFLAGS
|
export CXXFLAGS=
|
||||||
unset CPPFLAGS
|
export CPPFLAGS=
|
||||||
|
|
||||||
# prepare
|
# prepare
|
||||||
rm -rf "$VIRTUAL_ENV/packages"
|
rm -rf "$VIRTUAL_ENV/packages"
|
||||||
|
@ -59,7 +59,6 @@ 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):
|
||||||
@ -785,13 +784,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="Publique")
|
public_group = Group.objects.create(name="Public")
|
||||||
|
|
||||||
subscribers = Group.objects.create(name="Cotisants")
|
subscribers = Group.objects.create(name="Subscribers")
|
||||||
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="Anciens cotisants")
|
old_subscribers = Group.objects.create(name="Old subscribers")
|
||||||
old_subscribers.permissions.add(
|
old_subscribers.permissions.add(
|
||||||
*list(
|
*list(
|
||||||
perms.filter(
|
perms.filter(
|
||||||
@ -813,7 +812,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
accounting_admin = Group.objects.create(
|
accounting_admin = Group.objects.create(
|
||||||
name="Admin comptabilité", is_manually_manageable=True
|
name="Accounting admin", is_manually_manageable=True
|
||||||
)
|
)
|
||||||
accounting_admin.permissions.add(
|
accounting_admin.permissions.add(
|
||||||
*list(
|
*list(
|
||||||
@ -834,7 +833,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
com_admin = Group.objects.create(
|
com_admin = Group.objects.create(
|
||||||
name="Admin communication", is_manually_manageable=True
|
name="Communication admin", is_manually_manageable=True
|
||||||
)
|
)
|
||||||
com_admin.permissions.add(
|
com_admin.permissions.add(
|
||||||
*list(
|
*list(
|
||||||
@ -842,7 +841,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
counter_admin = Group.objects.create(
|
counter_admin = Group.objects.create(
|
||||||
name="Admin comptoirs", is_manually_manageable=True
|
name="Counter admin", is_manually_manageable=True
|
||||||
)
|
)
|
||||||
counter_admin.permissions.add(
|
counter_admin.permissions.add(
|
||||||
*list(
|
*list(
|
||||||
@ -852,14 +851,14 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
sas_admin = Group.objects.create(name="Admin SAS", is_manually_manageable=True)
|
sas_admin = Group.objects.create(name="SAS admin", 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="Admin forum", is_manually_manageable=True
|
name="Forum admin", is_manually_manageable=True
|
||||||
)
|
)
|
||||||
forum_admin.permissions.add(
|
forum_admin.permissions.add(
|
||||||
*list(
|
*list(
|
||||||
@ -869,7 +868,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
pedagogy_admin = Group.objects.create(
|
pedagogy_admin = Group.objects.create(
|
||||||
name="Admin pédagogie", is_manually_manageable=True
|
name="Pedagogy admin", is_manually_manageable=True
|
||||||
)
|
)
|
||||||
pedagogy_admin.permissions.add(
|
pedagogy_admin.permissions.add(
|
||||||
*list(
|
*list(
|
||||||
@ -878,16 +877,6 @@ 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(
|
||||||
@ -900,7 +889,6 @@ 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,13 +238,7 @@ 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(
|
Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"])
|
||||||
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"])
|
||||||
|
@ -1,27 +0,0 @@
|
|||||||
# Generated by Django 5.2.1 on 2025-06-11 16:10
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
import core.models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [("core", "0046_permissionrights")]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="notification",
|
|
||||||
name="date",
|
|
||||||
field=models.DateTimeField(auto_now=True, verbose_name="date"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="notification",
|
|
||||||
name="type",
|
|
||||||
field=models.CharField(
|
|
||||||
choices=core.models.get_notification_types,
|
|
||||||
default="GENERIC",
|
|
||||||
max_length=32,
|
|
||||||
verbose_name="type",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1451,10 +1451,6 @@ class PageRev(models.Model):
|
|||||||
return self.page.can_be_edited_by(user)
|
return self.page.can_be_edited_by(user)
|
||||||
|
|
||||||
|
|
||||||
def get_notification_types():
|
|
||||||
return settings.SITH_NOTIFICATIONS
|
|
||||||
|
|
||||||
|
|
||||||
class Notification(models.Model):
|
class Notification(models.Model):
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
User, related_name="notifications", on_delete=models.CASCADE
|
User, related_name="notifications", on_delete=models.CASCADE
|
||||||
@ -1462,9 +1458,9 @@ class Notification(models.Model):
|
|||||||
url = models.CharField(_("url"), max_length=255)
|
url = models.CharField(_("url"), max_length=255)
|
||||||
param = models.CharField(_("param"), max_length=128, default="")
|
param = models.CharField(_("param"), max_length=128, default="")
|
||||||
type = models.CharField(
|
type = models.CharField(
|
||||||
_("type"), max_length=32, choices=get_notification_types, default="GENERIC"
|
_("type"), max_length=32, choices=settings.SITH_NOTIFICATIONS, default="GENERIC"
|
||||||
)
|
)
|
||||||
date = models.DateTimeField(_("date"), auto_now=True)
|
date = models.DateTimeField(_("date"), default=timezone.now)
|
||||||
viewed = models.BooleanField(_("viewed"), default=False, db_index=True)
|
viewed = models.BooleanField(_("viewed"), default=False, db_index=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
@ -1,36 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
274
core/static/bundled/user/family-graph-index.js
Normal file
274
core/static/bundled/user/family-graph-index.js
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
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;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
};
|
@ -1,287 +0,0 @@
|
|||||||
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;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
});
|
|
@ -1,38 +0,0 @@
|
|||||||
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, RequestResult, TDataShape } from "#openapi:client";
|
import type { Client, Options, RequestResult, TDataShape } from "@hey-api/client-fetch";
|
||||||
import { type Options, client } from "#openapi";
|
import { client } from "#openapi";
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
count: number;
|
count: number;
|
||||||
|
@ -1,89 +0,0 @@
|
|||||||
@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,6 +713,47 @@ 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,12 +4,6 @@
|
|||||||
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;
|
||||||
@ -18,7 +12,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;
|
||||||
@ -40,38 +34,31 @@
|
|||||||
|
|
||||||
.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;
|
||||||
}
|
}
|
||||||
@ -87,7 +74,6 @@
|
|||||||
@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%;
|
||||||
}
|
}
|
||||||
@ -101,16 +87,14 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@ -138,10 +122,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;
|
||||||
@ -156,7 +140,7 @@
|
|||||||
max-height: 65px;
|
max-height: 65px;
|
||||||
}
|
}
|
||||||
|
|
||||||
>span {
|
> span {
|
||||||
height: 150px;
|
height: 150px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
@ -165,7 +149,7 @@
|
|||||||
width: 80px;
|
width: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
>img {
|
> img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
@ -179,7 +163,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
>em {
|
> em {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -211,7 +195,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
>a.mini_profile_link {
|
> a.mini_profile_link {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -11,7 +11,6 @@
|
|||||||
<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') }}">
|
||||||
|
|
||||||
@ -19,7 +18,6 @@
|
|||||||
<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>
|
||||||
@ -90,12 +88,58 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
{% block footer %}
|
{% block footer %}
|
||||||
{% include "core/base/footer.jinja" %}
|
<div>
|
||||||
|
<a href="{{ url('core:page', 'contacts') }}">{% trans %}Contacts{% endtrans %}</a>
|
||||||
|
<a href="{{ url('core:page', 'legals') }}">{% trans %}Legal notices{% endtrans %}</a>
|
||||||
|
<a href="{{ url('core:page', 'copyright_agent') }}">{% trans %}Intellectual property{% endtrans %}</a>
|
||||||
|
<a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
|
||||||
|
<a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
|
||||||
|
</div>
|
||||||
|
<a rel="nofollow" href="https://github.com/ae-utbm/sith" target="#">
|
||||||
|
<i class="fa-brands fa-github"></i>
|
||||||
|
{% trans %}Site created by the IT Department of the AE{% endtrans %}
|
||||||
|
</a>
|
||||||
{% endblock %}
|
{% 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)) {
|
||||||
|
@ -1,16 +0,0 @@
|
|||||||
<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,11 +26,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" action="{{ url('core:login') }}" id="login-form">
|
<form method="post" action="{{ url('core:login') }}">
|
||||||
{% if form.errors %}
|
{% if form.errors %}
|
||||||
<p class="alert alert-red">
|
<p class="alert alert-red">{% trans %}Your username and password didn't match. Please try again.{% endtrans %}</p>
|
||||||
{% 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.ts") }}"></script>
|
<script type="module" src="{{ static("bundled/user/family-graph-index.js") }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
@ -15,14 +15,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div
|
<div x-data="graph" :aria-busy="loading">
|
||||||
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">
|
||||||
@ -93,36 +86,17 @@
|
|||||||
</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,7 +38,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@ -152,44 +151,24 @@ 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"))
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
def test_login_fail(self, client, user):
|
||||||
"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": identifier, "password": "wrong-password"},
|
{"username": user.username, "password": "wrong-password"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.wsgi_request.user.is_anonymous
|
|
||||||
soup = BeautifulSoup(response.text, "lxml")
|
|
||||||
form = soup.find(id="login-form")
|
|
||||||
assert (
|
assert (
|
||||||
form.find(class_="alert alert-red").get_text(strip=True)
|
'<p class="alert alert-red">Votre nom d\'utilisateur '
|
||||||
== "Vos identifiants ne correspondent pas. Veuillez réessayer."
|
"et votre mot de passe ne correspondent pas. Merci de réessayer.</p>"
|
||||||
)
|
) in response.text
|
||||||
assert form.find("input", attrs={"name": "username"}).get("value") == identifier
|
assert response.wsgi_request.user.is_anonymous
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
def test_login_success(self, client, user):
|
||||||
"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": identifier_getter(user), "password": "plop"},
|
{"username": user.username, "password": "plop"},
|
||||||
)
|
)
|
||||||
assertRedirects(response, reverse("core:index"))
|
assertRedirects(response, reverse("core:index"))
|
||||||
assert response.wsgi_request.user == user
|
assert response.wsgi_request.user == user
|
||||||
@ -382,9 +361,17 @@ class TestUserIsInGroup(TestCase):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
cls.public_group = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)
|
cls.root_group = Group.objects.get(name="Root")
|
||||||
|
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)
|
||||||
@ -392,7 +379,15 @@ 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 Group.objects.exclude(id=self.public_group.id):
|
for group in (
|
||||||
|
self.root_group,
|
||||||
|
self.accounting_admin,
|
||||||
|
self.sas_admin,
|
||||||
|
self.subscribers,
|
||||||
|
self.old_subscribers,
|
||||||
|
self.club.members_group,
|
||||||
|
self.club.board_group,
|
||||||
|
):
|
||||||
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,31 +132,29 @@ 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,7 +41,6 @@ class ProductAdmin(SearchModelAdmin):
|
|||||||
"profit",
|
"profit",
|
||||||
"archived",
|
"archived",
|
||||||
)
|
)
|
||||||
list_select_related = ("product_type",)
|
|
||||||
search_fields = ("name", "code")
|
search_fields = ("name", "code")
|
||||||
|
|
||||||
|
|
||||||
@ -82,13 +81,20 @@ class AccountDumpAdmin(admin.ModelAdmin):
|
|||||||
"customer",
|
"customer",
|
||||||
"warning_mail_sent_at",
|
"warning_mail_sent_at",
|
||||||
"warning_mail_error",
|
"warning_mail_error",
|
||||||
"dump_operation__date",
|
"dump_operation",
|
||||||
"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):
|
||||||
@ -107,14 +113,11 @@ 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",
|
||||||
@ -123,8 +126,6 @@ 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,4 +1,3 @@
|
|||||||
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";
|
||||||
@ -6,9 +5,14 @@ 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: new AlertMessage({ defaultDuration: 2000 }),
|
alertMessage: {
|
||||||
|
content: "",
|
||||||
|
show: false,
|
||||||
|
timeout: null,
|
||||||
|
},
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Fill the basket with the initial data
|
// Fill the basket with the initial data
|
||||||
@ -73,10 +77,22 @@ 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.alertMessage.display(message, { success: false });
|
this.showAlertMessage(message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -93,9 +109,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
|
|
||||||
finish() {
|
finish() {
|
||||||
if (this.getBasketSize() === 0) {
|
if (this.getBasketSize() === 0) {
|
||||||
this.alertMessage.display(gettext("You can't send an empty basket."), {
|
this.showAlertMessage(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: ProductSchema[] =
|
const products =
|
||||||
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,11 +1,15 @@
|
|||||||
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: new AlertMessage({ defaultDuration: 2000 }),
|
alertMessage: {
|
||||||
|
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
|
||||||
@ -37,14 +41,23 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
openAlertMessage(response: Response) {
|
openAlertMessage(response: Response) {
|
||||||
const success = response.ok;
|
if (response.ok) {
|
||||||
const content = response.ok
|
this.alertMessage.success = true;
|
||||||
? gettext("Products types reordered!")
|
this.alertMessage.content = gettext("Products types reordered!");
|
||||||
: interpolate(
|
} else {
|
||||||
|
this.alertMessage.success = false;
|
||||||
|
this.alertMessage.content = interpolate(
|
||||||
gettext("Product type reorganisation failed with status code : %d"),
|
gettext("Product type reorganisation failed with status code : %d"),
|
||||||
[response.status],
|
[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 @@
|
|||||||
export type ErrorMessage = string;
|
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,7 +17,6 @@ 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
|
||||||
@ -824,53 +823,3 @@ 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(
|
Permanency.objects.filter(counter=counter_id, user=request.POST["user_id"]).update(
|
||||||
counter=counter_id, user=request.POST["user_id"], end=None
|
end=F("activity")
|
||||||
).update(end=now())
|
)
|
||||||
return redirect("counter:details", counter_id=counter_id)
|
return redirect("counter:details", counter_id=counter_id)
|
||||||
|
@ -12,15 +12,6 @@ nouveau logo d'une promo. C'est un processus manuel.
|
|||||||
de faire cette opération manuellement, ça prend quelques
|
de faire cette opération manuellement, ça prend quelques
|
||||||
minutes et on est certain de la qualité à la fin.
|
minutes et on est certain de la qualité à la fin.
|
||||||
|
|
||||||
### avec une commande django
|
|
||||||
```bash
|
|
||||||
./manage.py add_promo_logo numero_de_promo chemin_dacces_du_logo
|
|
||||||
```
|
|
||||||
options:
|
|
||||||
|
|
||||||
* `--force/-f` pour automatiquement écraser les logos de promo avec le même nom.
|
|
||||||
|
|
||||||
### manuellement
|
|
||||||
Les logos de promo sont à manuellement ajouter dans le projet.
|
Les logos de promo sont à manuellement ajouter dans le projet.
|
||||||
Ils se situent dans le dossier `core/static/core/img/`.
|
Ils se situent dans le dossier `core/static/core/img/`.
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2025-06-25 16:29+0200\n"
|
"POT-Creation-Date: 2025-06-16 14:54+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,8 +2015,10 @@ 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 credentials didn't match. Please try again."
|
msgid "Your username and password didn't match. Please try again."
|
||||||
msgstr "Vos identifiants ne correspondent pas. Veuillez réessayer."
|
msgstr ""
|
||||||
|
"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?"
|
||||||
@ -5101,9 +5103,8 @@ msgid "There are %s pictures to be moderated in the SAS"
|
|||||||
msgstr "Il y a %s photos à modérer dans le SAS"
|
msgstr "Il y a %s photos à modérer dans le SAS"
|
||||||
|
|
||||||
#: sith/settings.py
|
#: sith/settings.py
|
||||||
#, python-format
|
msgid "You've been identified on some pictures"
|
||||||
msgid "You've been identified in album %s"
|
msgstr "Vous avez été identifié sur des photos"
|
||||||
msgstr "Vous avez été identifié dans l'album %s"
|
|
||||||
|
|
||||||
#: sith/settings.py
|
#: sith/settings.py
|
||||||
#, python-format
|
#, python-format
|
||||||
|
39
package-lock.json
generated
39
package-lock.json
generated
@ -45,11 +45,7 @@
|
|||||||
"@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"
|
||||||
@ -2823,33 +2819,6 @@
|
|||||||
"@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",
|
||||||
@ -2866,13 +2835,6 @@
|
|||||||
"@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",
|
||||||
@ -5596,6 +5558,7 @@
|
|||||||
"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": "tsc && biome check --write"
|
"check": "biome check --write"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@ -30,11 +30,7 @@
|
|||||||
"@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"
|
||||||
|
@ -44,7 +44,7 @@ dependencies = [
|
|||||||
"django-honeypot>=1.3.0,<2",
|
"django-honeypot>=1.3.0,<2",
|
||||||
"pydantic-extra-types<3.0.0,>=2.10.3",
|
"pydantic-extra-types<3.0.0,>=2.10.3",
|
||||||
"ical>=10.0.3,<11",
|
"ical>=10.0.3,<11",
|
||||||
"redis[hiredis]<6.0.0,>=5.3.0",
|
"redis[hiredis]>=5.3.0,<7.0.0",
|
||||||
"environs[django]<15.0.0,>=14.1.1",
|
"environs[django]<15.0.0,>=14.1.1",
|
||||||
"requests>=2.32.3",
|
"requests>=2.32.3",
|
||||||
"honcho>=2.0.0",
|
"honcho>=2.0.0",
|
||||||
@ -92,7 +92,7 @@ docs = [
|
|||||||
default-groups = ["dev", "tests", "docs"]
|
default-groups = ["dev", "tests", "docs"]
|
||||||
|
|
||||||
[tool.xapian]
|
[tool.xapian]
|
||||||
version = "1.4.29"
|
version = "1.4.25"
|
||||||
|
|
||||||
[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(id=settings.SITH_GROUP_SUBSCRIBERS_ID)
|
subscribers = Group.objects.get(name="Subscribers")
|
||||||
mde_admin = Group.objects.get(name="MDE admin")
|
mde_admin = Group.objects.get(name="MDE admin")
|
||||||
sas_admin = Group.objects.get(id=settings.SITH_GROUP_SAS_ADMIN_ID)
|
sas_admin = Group.objects.get(name="SAS admin")
|
||||||
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)
|
||||||
|
16
sas/api.py
16
sas/api.py
@ -2,6 +2,7 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db.models import F
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from ninja import Body, File, Query
|
from ninja import Body, File, Query
|
||||||
from ninja.security import SessionAuth
|
from ninja.security import SessionAuth
|
||||||
@ -104,7 +105,8 @@ class PicturesController(ControllerBase):
|
|||||||
filters.filter(Picture.objects.viewable_by(user))
|
filters.filter(Picture.objects.viewable_by(user))
|
||||||
.distinct()
|
.distinct()
|
||||||
.order_by("-parent__date", "date")
|
.order_by("-parent__date", "date")
|
||||||
.select_related("owner", "parent")
|
.select_related("owner")
|
||||||
|
.annotate(album=F("parent__name"))
|
||||||
)
|
)
|
||||||
|
|
||||||
@route.post(
|
@route.post(
|
||||||
@ -151,9 +153,7 @@ class PicturesController(ControllerBase):
|
|||||||
|
|
||||||
@route.put("/{picture_id}/identified", permissions=[IsAuthenticated, CanView])
|
@route.put("/{picture_id}/identified", permissions=[IsAuthenticated, CanView])
|
||||||
def identify_users(self, picture_id: NonNegativeInt, users: set[NonNegativeInt]):
|
def identify_users(self, picture_id: NonNegativeInt, users: set[NonNegativeInt]):
|
||||||
picture = self.get_object_or_exception(
|
picture = self.get_object_or_exception(Picture, pk=picture_id)
|
||||||
Picture.objects.select_related("parent"), pk=picture_id
|
|
||||||
)
|
|
||||||
db_users = list(User.objects.filter(id__in=users))
|
db_users = list(User.objects.filter(id__in=users))
|
||||||
if len(users) != len(db_users):
|
if len(users) != len(db_users):
|
||||||
raise NotFound
|
raise NotFound
|
||||||
@ -166,15 +166,13 @@ class PicturesController(ControllerBase):
|
|||||||
]
|
]
|
||||||
PeoplePictureRelation.objects.bulk_create(relations)
|
PeoplePictureRelation.objects.bulk_create(relations)
|
||||||
for u in identified:
|
for u in identified:
|
||||||
html_id = f"album-{picture.parent_id}"
|
|
||||||
url = reverse(
|
|
||||||
"sas:user_pictures", kwargs={"user_id": u.id}, fragment=html_id
|
|
||||||
)
|
|
||||||
Notification.objects.get_or_create(
|
Notification.objects.get_or_create(
|
||||||
user=u,
|
user=u,
|
||||||
viewed=False,
|
viewed=False,
|
||||||
type="NEW_PICTURES",
|
type="NEW_PICTURES",
|
||||||
defaults={"url": url, "param": picture.parent.name},
|
defaults={
|
||||||
|
"url": reverse("sas:user_pictures", kwargs={"user_id": u.id})
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@route.delete("/{picture_id}", permissions=[IsSasAdmin])
|
@route.delete("/{picture_id}", permissions=[IsSasAdmin])
|
||||||
|
@ -1,19 +0,0 @@
|
|||||||
# 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"),
|
|
||||||
]
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
@ -25,10 +25,11 @@ from django.core.cache import cache
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Exists, OuterRef, Q
|
from django.db.models import Exists, OuterRef, Q
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from core.models import Notification, SithFile, User
|
from core.models import SithFile, User
|
||||||
from core.utils import exif_auto_rotate, resize_image
|
from core.utils import exif_auto_rotate, resize_image
|
||||||
|
|
||||||
|
|
||||||
@ -41,10 +42,6 @@ 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:
|
||||||
@ -63,7 +60,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.has_perm("sas.change_sasfile")
|
return user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID)
|
||||||
|
|
||||||
|
|
||||||
class PictureQuerySet(models.QuerySet):
|
class PictureQuerySet(models.QuerySet):
|
||||||
@ -73,7 +70,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.has_perm("sas.moderate_sasfile"):
|
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
|
||||||
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))
|
||||||
@ -186,7 +183,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.has_perm("sas.moderate_sasfile"):
|
if user.is_root or user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
|
||||||
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))
|
||||||
@ -259,10 +256,14 @@ class Album(SasFile):
|
|||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
def sas_notification_callback(notif: Notification):
|
def sas_notification_callback(notif):
|
||||||
count = Picture.objects.filter(is_moderated=False).count()
|
count = Picture.objects.filter(is_moderated=False).count()
|
||||||
notif.viewed = not bool(count)
|
if count:
|
||||||
notif.param = str(count)
|
notif.viewed = False
|
||||||
|
else:
|
||||||
|
notif.viewed = True
|
||||||
|
notif.param = "%s" % count
|
||||||
|
notif.date = timezone.now()
|
||||||
|
|
||||||
|
|
||||||
class PeoplePictureRelation(models.Model):
|
class PeoplePictureRelation(models.Model):
|
||||||
|
@ -18,12 +18,6 @@ class AlbumFilterSchema(FilterSchema):
|
|||||||
parent_id: int | None = Field(None, q="parent_id")
|
parent_id: int | None = Field(None, q="parent_id")
|
||||||
|
|
||||||
|
|
||||||
class SimpleAlbumSchema(ModelSchema):
|
|
||||||
class Meta:
|
|
||||||
model = Album
|
|
||||||
fields = ["id", "name"]
|
|
||||||
|
|
||||||
|
|
||||||
class AlbumSchema(ModelSchema):
|
class AlbumSchema(ModelSchema):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Album
|
model = Album
|
||||||
@ -76,7 +70,7 @@ class PictureSchema(ModelSchema):
|
|||||||
full_size_url: str
|
full_size_url: str
|
||||||
compressed_url: str
|
compressed_url: str
|
||||||
thumb_url: str
|
thumb_url: str
|
||||||
album: SimpleAlbumSchema = Field(alias="parent")
|
album: str
|
||||||
report_url: str
|
report_url: str
|
||||||
edit_url: str
|
edit_url: str
|
||||||
|
|
||||||
|
@ -83,6 +83,7 @@ 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,35 +9,28 @@ 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,
|
||||||
albums: [] as Album[],
|
pictures: [] as PictureSchema[],
|
||||||
|
albums: {} as Record<string, PictureSchema[]>,
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
const pictures = await paginated(picturesFetchPictures, {
|
this.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 = Object.values(groupedAlbums).map((pictures: PictureSchema[]) => {
|
this.albums = this.pictures.reduce(
|
||||||
return {
|
(acc: Record<string, PictureSchema[]>, picture: PictureSchema) => {
|
||||||
id: pictures[0].album.id,
|
if (!acc[picture.album]) {
|
||||||
name: pictures[0].album.name,
|
acc[picture.album] = [];
|
||||||
pictures: pictures,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
this.albums.sort((a: Album, b: Album) => b.id - a.id);
|
|
||||||
const hash = document.location.hash.replace("#", "");
|
|
||||||
if (hash.startsWith("album-")) {
|
|
||||||
this.$nextTick(() => document.getElementById(hash)?.scrollIntoView()).then();
|
|
||||||
}
|
}
|
||||||
|
acc[picture.album].push(picture);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
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";
|
||||||
@ -131,7 +130,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 as number,
|
id: null,
|
||||||
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: "",
|
||||||
@ -143,7 +142,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
|
|||||||
full_size_url: "",
|
full_size_url: "",
|
||||||
owner: "",
|
owner: "",
|
||||||
date: new Date(),
|
date: new Date(),
|
||||||
identifications: [] as IdentifiedUserSchema[],
|
identifications: [],
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@ -156,7 +155,7 @@ exportToHtml("loadViewer", (config: ViewerConfig) => {
|
|||||||
/**
|
/**
|
||||||
* The select2 component used to identify users
|
* The select2 component used to identify users
|
||||||
**/
|
**/
|
||||||
selector: undefined as UserAjaxSelect,
|
selector: undefined,
|
||||||
/**
|
/**
|
||||||
* 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="albums.length > 0" x-cloak>
|
<div x-show="pictures.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 in albums" x-cloak>
|
<template x-for="[album, pictures] in Object.entries(albums)" x-cloak>
|
||||||
<section>
|
<section>
|
||||||
<br />
|
<br />
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<h4 x-text="album.name" :id="`album-${album.id}`"></h4>
|
<h4 x-text="album"></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 album.pictures">
|
<template x-for="picture in 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=12)
|
SITH_GROUP_BANNED_ALCOHOL_ID = env.int("SITH_GROUP_BANNED_ALCOHOL_ID", default=11)
|
||||||
SITH_GROUP_BANNED_COUNTER_ID = env.int("SITH_GROUP_BANNED_COUNTER_ID", default=13)
|
SITH_GROUP_BANNED_COUNTER_ID = env.int("SITH_GROUP_BANNED_COUNTER_ID", default=12)
|
||||||
SITH_GROUP_BANNED_SUBSCRIPTION_ID = env.int(
|
SITH_GROUP_BANNED_SUBSCRIPTION_ID = env.int(
|
||||||
"SITH_GROUP_BANNED_SUBSCRIPTION_ID", default=14
|
"SITH_GROUP_BANNED_SUBSCRIPTION_ID", default=13
|
||||||
)
|
)
|
||||||
|
|
||||||
SITH_CLUB_REFOUND_ID = env.int("SITH_CLUB_REFOUND_ID", default=89)
|
SITH_CLUB_REFOUND_ID = env.int("SITH_CLUB_REFOUND_ID", default=89)
|
||||||
@ -677,7 +677,7 @@ SITH_NOTIFICATIONS = [
|
|||||||
("NEWS_MODERATION", _("There are %s fresh news to be moderated")),
|
("NEWS_MODERATION", _("There are %s fresh news to be moderated")),
|
||||||
("FILE_MODERATION", _("New files to be moderated")),
|
("FILE_MODERATION", _("New files to be moderated")),
|
||||||
("SAS_MODERATION", _("There are %s pictures to be moderated in the SAS")),
|
("SAS_MODERATION", _("There are %s pictures to be moderated in the SAS")),
|
||||||
("NEW_PICTURES", _("You've been identified in album %s")),
|
("NEW_PICTURES", _("You've been identified on some pictures")),
|
||||||
("REFILLING", _("You just refilled of %s €")),
|
("REFILLING", _("You just refilled of %s €")),
|
||||||
("SELLING", _("You just bought %s")),
|
("SELLING", _("You just bought %s")),
|
||||||
("GENERIC", _("You have a notification")),
|
("GENERIC", _("You have a notification")),
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"noImplicitAny": true,
|
"noImplicitAny": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"target": "es2024",
|
"target": "es2022",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
@ -14,7 +14,6 @@
|
|||||||
"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.13,<1.0.0" },
|
{ name = "ruff", specifier = ">=0.11.11,<1.0.0" },
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
{ name = "mkdocs", specifier = ">=1.6.1,<2.0.0" },
|
{ name = "mkdocs", specifier = ">=1.6.1,<2.0.0" },
|
||||||
|
Reference in New Issue
Block a user