mirror of
https://github.com/ae-utbm/sith.git
synced 2025-07-13 13:29:23 +00:00
Compare commits
87 Commits
photos
...
room-reser
Author | SHA1 | Date | |
---|---|---|---|
dfb545b15e | |||
f9fa4c0643 | |||
72bb4788f2 | |||
0baaf69714 | |||
edd8b9a385 | |||
a322a0895a | |||
d4e853fa60 | |||
21416dc27a | |||
b2d97ab138 | |||
f092d44ef7 | |||
08abc62e56 | |||
5f2caf9d61 | |||
c45be81bb3 | |||
af014e419f | |||
c177ef2a3a | |||
6cf8910626 | |||
eb4fbcbda4 | |||
570510f18d | |||
7f371984d8 | |||
abf7bf6bfa | |||
02ef8fdb88 | |||
a7f4630d13 | |||
c7087c6e7e | |||
f38926c4a3 | |||
9a19f34ea2 | |||
67884017f8 | |||
f474edc84f
|
|||
f5a8228358 | |||
59a714af9f | |||
9049d8779c | |||
d111023363
|
|||
cdfa76ad57 | |||
88b70bf51f | |||
ca593c7d81
|
|||
94bdc5e615
|
|||
7d454749e0
|
|||
06090e0cd9 | |||
a1ae67da7d | |||
10d5b9d63f
|
|||
cc96c93d23
|
|||
8cc0b01e9c | |||
88755358a6 | |||
0e850e5486 | |||
af67c5fc27 | |||
30809a69c9
|
|||
0c442a8f03 | |||
f1b69dd47d | |||
b5ebf09fcb
|
|||
9d9ce5b30a | |||
a87460fa3e | |||
48fae33651 | |||
6fec250658 | |||
75b37cd6e3 | |||
9c3820f986 | |||
28b60c7bae | |||
efbbfcda76 | |||
9e1fe7a296 | |||
50d7b7e731 | |||
ae7784a973 | |||
a23604383b | |||
80866086a8 | |||
2c7eb99f31 | |||
189081f5a8 | |||
52e53da9ef | |||
b5d65133f3 | |||
44e1902693 | |||
1d55a5c2da | |||
853aa34c18 | |||
dc72789c14 | |||
2f0454355f | |||
1c14bb22a0 | |||
d1f11216c7 | |||
2299e3f966
|
|||
0f55bcc513
|
|||
b19973ec9c
|
|||
17129af1bb
|
|||
42434d10ca
|
|||
c904e41ea3
|
|||
2dd4fd5c71
|
|||
dad09deab7 | |||
6782638a5d | |||
c7e4de7df2 | |||
dcc84894e5 | |||
9d841cd606 | |||
9f54e8362d | |||
c62c09f603 | |||
7b8102c242
|
14
.github/actions/setup_project/action.yml
vendored
14
.github/actions/setup_project/action.yml
vendored
@ -1,15 +1,24 @@
|
||||
name: "Setup project"
|
||||
description: "Setup Python and Poetry"
|
||||
inputs:
|
||||
full:
|
||||
description: >
|
||||
If true, do a full setup, else install
|
||||
only python, uv and non-xapian python deps
|
||||
required: false
|
||||
default: "false"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install apt packages
|
||||
if: ${{ inputs.full == 'true' }}
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.4.3
|
||||
with:
|
||||
packages: gettext
|
||||
version: 1.0 # increment to reset cache
|
||||
|
||||
- name: Install Redis
|
||||
if: ${{ inputs.full == 'true' }}
|
||||
uses: shogo82148/actions-setup-redis@v1
|
||||
with:
|
||||
redis-version: "7.x"
|
||||
@ -37,15 +46,20 @@ runs:
|
||||
shell: bash
|
||||
|
||||
- name: Install Xapian
|
||||
if: ${{ inputs.full == 'true' }}
|
||||
run: uv run ./manage.py install_xapian
|
||||
shell: bash
|
||||
|
||||
# compiling xapian accounts for almost the entirety of the virtualenv setup,
|
||||
# so we save the virtual environment only on workflows where it has been installed
|
||||
- name: Save cached virtualenv
|
||||
if: ${{ inputs.full == 'true' }}
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('pyproject.toml') }}-${{ env.CACHE_SUFFIX }}
|
||||
path: .venv
|
||||
|
||||
- name: Compile gettext messages
|
||||
if: ${{ inputs.full == 'true' }}
|
||||
run: uv run ./manage.py compilemessages
|
||||
shell: bash
|
||||
|
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -37,6 +37,8 @@ jobs:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup_project
|
||||
with:
|
||||
full: true
|
||||
env:
|
||||
# To avoid race conditions on environment cache
|
||||
CACHE_SUFFIX: ${{ matrix.pytest-mark }}
|
||||
|
6
.github/workflows/deploy_docs.yml
vendored
6
.github/workflows/deploy_docs.yml
vendored
@ -2,11 +2,7 @@ name: deploy_docs
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
env:
|
||||
SECRET_KEY: notTheRealOne
|
||||
DATABASE_URL: sqlite:///db.sqlite3
|
||||
CACHE_URL: redis://127.0.0.1:6379/0
|
||||
- taiste
|
||||
permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
|
@ -1,7 +1,7 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.11.11
|
||||
rev: v0.11.13
|
||||
hooks:
|
||||
- id: ruff-check # just check the code, and print the errors
|
||||
- id: ruff-check # actually fix the fixable errors, but print nothing
|
||||
|
0
api/__init__.py
Normal file
0
api/__init__.py
Normal file
55
api/admin.py
Normal file
55
api/admin.py
Normal file
@ -0,0 +1,55 @@
|
||||
from django.contrib import admin, messages
|
||||
from django.db.models import QuerySet
|
||||
from django.http import HttpRequest
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from api.hashers import generate_key
|
||||
from api.models import ApiClient, ApiKey
|
||||
|
||||
|
||||
@admin.register(ApiClient)
|
||||
class ApiClientAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "owner", "created_at", "updated_at")
|
||||
search_fields = (
|
||||
"name",
|
||||
"owner__first_name",
|
||||
"owner__last_name",
|
||||
"owner__nick_name",
|
||||
)
|
||||
autocomplete_fields = ("owner", "groups", "client_permissions")
|
||||
|
||||
|
||||
@admin.register(ApiKey)
|
||||
class ApiKeyAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "client", "created_at", "revoked")
|
||||
list_filter = ("revoked",)
|
||||
date_hierarchy = "created_at"
|
||||
|
||||
readonly_fields = ("prefix", "hashed_key")
|
||||
actions = ("revoke_keys",)
|
||||
|
||||
def save_model(self, request: HttpRequest, obj: ApiKey, form, change):
|
||||
if not change:
|
||||
key, hashed = generate_key()
|
||||
obj.prefix = key[: ApiKey.PREFIX_LENGTH]
|
||||
obj.hashed_key = hashed
|
||||
self.message_user(
|
||||
request,
|
||||
_(
|
||||
"The API key for %(name)s is: %(key)s. "
|
||||
"Please store it somewhere safe: "
|
||||
"you will not be able to see it again."
|
||||
)
|
||||
% {"name": obj.name, "key": key},
|
||||
level=messages.WARNING,
|
||||
)
|
||||
return super().save_model(request, obj, form, change)
|
||||
|
||||
def get_readonly_fields(self, request, obj: ApiKey | None = None):
|
||||
if obj is None or obj.revoked:
|
||||
return ["revoked", *self.readonly_fields]
|
||||
return self.readonly_fields
|
||||
|
||||
@admin.action(description=_("Revoke selected API keys"))
|
||||
def revoke_keys(self, _request: HttpRequest, queryset: QuerySet[ApiKey]):
|
||||
queryset.update(revoked=True)
|
6
api/apps.py
Normal file
6
api/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "api"
|
20
api/auth.py
Normal file
20
api/auth.py
Normal file
@ -0,0 +1,20 @@
|
||||
from django.http import HttpRequest
|
||||
from ninja.security import APIKeyHeader
|
||||
|
||||
from api.hashers import get_hasher
|
||||
from api.models import ApiClient, ApiKey
|
||||
|
||||
|
||||
class ApiKeyAuth(APIKeyHeader):
|
||||
param_name = "X-APIKey"
|
||||
|
||||
def authenticate(self, request: HttpRequest, key: str | None) -> ApiClient | None:
|
||||
if not key or len(key) != ApiKey.KEY_LENGTH:
|
||||
return None
|
||||
hasher = get_hasher()
|
||||
hashed_key = hasher.encode(key)
|
||||
try:
|
||||
key_obj = ApiKey.objects.get(revoked=False, hashed_key=hashed_key)
|
||||
except ApiKey.DoesNotExist:
|
||||
return None
|
||||
return key_obj.client
|
43
api/hashers.py
Normal file
43
api/hashers.py
Normal file
@ -0,0 +1,43 @@
|
||||
import functools
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from django.contrib.auth.hashers import BasePasswordHasher
|
||||
from django.utils.crypto import constant_time_compare
|
||||
|
||||
|
||||
class Sha512ApiKeyHasher(BasePasswordHasher):
|
||||
"""
|
||||
An API key hasher using the sha256 algorithm.
|
||||
|
||||
This hasher shouldn't be used in Django's `PASSWORD_HASHERS` setting.
|
||||
It is insecure for use in hashing passwords, but is safe for hashing
|
||||
high entropy, randomly generated API keys.
|
||||
"""
|
||||
|
||||
algorithm = "sha512"
|
||||
|
||||
def salt(self) -> str:
|
||||
# No need for a salt on a high entropy key.
|
||||
return ""
|
||||
|
||||
def encode(self, password: str, salt: str = "") -> str:
|
||||
hashed = hashlib.sha512(password.encode()).hexdigest()
|
||||
return f"{self.algorithm}$${hashed}"
|
||||
|
||||
def verify(self, password: str, encoded: str) -> bool:
|
||||
encoded_2 = self.encode(password, "")
|
||||
return constant_time_compare(encoded, encoded_2)
|
||||
|
||||
|
||||
@functools.cache
|
||||
def get_hasher():
|
||||
return Sha512ApiKeyHasher()
|
||||
|
||||
|
||||
def generate_key() -> tuple[str, str]:
|
||||
"""Generate a [key, hash] couple."""
|
||||
# this will result in key with a length of 72
|
||||
key = str(secrets.token_urlsafe(54))
|
||||
hasher = get_hasher()
|
||||
return key, hasher.encode(key)
|
113
api/migrations/0001_initial.py
Normal file
113
api/migrations/0001_initial.py
Normal file
@ -0,0 +1,113 @@
|
||||
# Generated by Django 5.2 on 2025-06-01 08:53
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
("core", "0046_permissionrights"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ApiClient",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=64, verbose_name="name")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"client_permissions",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Specific permissions for this api client.",
|
||||
related_name="clients",
|
||||
to="auth.permission",
|
||||
verbose_name="client permissions",
|
||||
),
|
||||
),
|
||||
(
|
||||
"groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="api_clients",
|
||||
to="core.group",
|
||||
verbose_name="groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"owner",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="api_clients",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="owner",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "api client",
|
||||
"verbose_name_plural": "api clients",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ApiKey",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(blank=True, default="", verbose_name="name")),
|
||||
(
|
||||
"prefix",
|
||||
models.CharField(
|
||||
editable=False, max_length=5, verbose_name="prefix"
|
||||
),
|
||||
),
|
||||
(
|
||||
"hashed_key",
|
||||
models.CharField(
|
||||
db_index=True,
|
||||
editable=False,
|
||||
max_length=136,
|
||||
verbose_name="hashed key",
|
||||
),
|
||||
),
|
||||
("revoked", models.BooleanField(default=False, verbose_name="revoked")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"client",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="api_keys",
|
||||
to="api.apiclient",
|
||||
verbose_name="api client",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "api key",
|
||||
"verbose_name_plural": "api keys",
|
||||
"permissions": [("revoke_apikey", "Revoke API keys")],
|
||||
},
|
||||
),
|
||||
]
|
0
api/migrations/__init__.py
Normal file
0
api/migrations/__init__.py
Normal file
94
api/models.py
Normal file
94
api/models.py
Normal file
@ -0,0 +1,94 @@
|
||||
from typing import Iterable
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import pgettext_lazy
|
||||
|
||||
from core.models import Group, User
|
||||
|
||||
|
||||
class ApiClient(models.Model):
|
||||
name = models.CharField(_("name"), max_length=64)
|
||||
owner = models.ForeignKey(
|
||||
User,
|
||||
verbose_name=_("owner"),
|
||||
related_name="api_clients",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
groups = models.ManyToManyField(
|
||||
Group, verbose_name=_("groups"), related_name="api_clients", blank=True
|
||||
)
|
||||
client_permissions = models.ManyToManyField(
|
||||
Permission,
|
||||
verbose_name=_("client permissions"),
|
||||
blank=True,
|
||||
help_text=_("Specific permissions for this api client."),
|
||||
related_name="clients",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
_perm_cache: set[str] | None = None
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("api client")
|
||||
verbose_name_plural = _("api clients")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def has_perm(self, perm: str):
|
||||
"""Return True if the client has the specified permission."""
|
||||
|
||||
if self._perm_cache is None:
|
||||
group_permissions = (
|
||||
Permission.objects.filter(group__group__in=self.groups.all())
|
||||
.values_list("content_type__app_label", "codename")
|
||||
.order_by()
|
||||
)
|
||||
client_permissions = self.client_permissions.values_list(
|
||||
"content_type__app_label", "codename"
|
||||
).order_by()
|
||||
self._perm_cache = {
|
||||
f"{content_type}.{name}"
|
||||
for content_type, name in (*group_permissions, *client_permissions)
|
||||
}
|
||||
return perm in self._perm_cache
|
||||
|
||||
def has_perms(self, perm_list):
|
||||
"""
|
||||
Return True if the client has each of the specified permissions. If
|
||||
object is passed, check if the client has all required perms for it.
|
||||
"""
|
||||
if not isinstance(perm_list, Iterable) or isinstance(perm_list, str):
|
||||
raise ValueError("perm_list must be an iterable of permissions.")
|
||||
return all(self.has_perm(perm) for perm in perm_list)
|
||||
|
||||
|
||||
class ApiKey(models.Model):
|
||||
PREFIX_LENGTH = 5
|
||||
KEY_LENGTH = 72
|
||||
HASHED_KEY_LENGTH = 136
|
||||
|
||||
name = models.CharField(_("name"), blank=True, default="")
|
||||
prefix = models.CharField(_("prefix"), max_length=PREFIX_LENGTH, editable=False)
|
||||
hashed_key = models.CharField(
|
||||
_("hashed key"), max_length=HASHED_KEY_LENGTH, db_index=True, editable=False
|
||||
)
|
||||
client = models.ForeignKey(
|
||||
ApiClient,
|
||||
verbose_name=_("api client"),
|
||||
related_name="api_keys",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
revoked = models.BooleanField(pgettext_lazy("api key", "revoked"), default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("api key")
|
||||
verbose_name_plural = _("api keys")
|
||||
permissions = [("revoke_apikey", "Revoke API keys")]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.prefix}***)"
|
@ -39,7 +39,7 @@ Example:
|
||||
|
||||
import operator
|
||||
from functools import reduce
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.http import HttpRequest
|
||||
@ -67,6 +67,8 @@ class HasPerm(BasePermission):
|
||||
|
||||
Example:
|
||||
```python
|
||||
@api_controller("/foo")
|
||||
class FooController(ControllerBase):
|
||||
# this route will require both permissions
|
||||
@route.put("/foo", permissions=[HasPerm(["foo.change_foo", "foo.add_foo"])]
|
||||
def foo(self): ...
|
||||
@ -78,10 +80,13 @@ class HasPerm(BasePermission):
|
||||
permissions=[HasPerm(["foo.change_bar", "foo.add_bar"], op=operator.or_)],
|
||||
)
|
||||
def bar(self): ...
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, perms: str | Permission | list[str | Permission], op=operator.and_
|
||||
self,
|
||||
perms: str | Permission | list[str | Permission],
|
||||
op: Callable[[bool, bool], bool] = operator.and_,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
@ -96,7 +101,16 @@ class HasPerm(BasePermission):
|
||||
self._perms = perms
|
||||
|
||||
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
|
||||
return reduce(self._operator, (request.user.has_perm(p) for p in self._perms))
|
||||
# if the request has the `auth` property,
|
||||
# it means that the user has been explicitly authenticated
|
||||
# using a django-ninja authentication backend
|
||||
# (whether it is SessionAuth or ApiKeyAuth).
|
||||
# If not, this authentication has not been done, but the user may
|
||||
# still be implicitly authenticated through AuthenticationMiddleware
|
||||
user = request.auth if hasattr(request, "auth") else request.user
|
||||
# `user` may either be a `core.User` or an `api.ApiClient` ;
|
||||
# they are not the same model, but they both implement the `has_perm` method
|
||||
return reduce(self._operator, (user.has_perm(p) for p in self._perms))
|
||||
|
||||
|
||||
class IsRoot(BasePermission):
|
||||
@ -180,4 +194,4 @@ class IsLoggedInCounter(BasePermission):
|
||||
return Counter.objects.filter(token=token).exists()
|
||||
|
||||
|
||||
CanAccessLookup = IsOldSubscriber | IsRoot | IsLoggedInCounter
|
||||
CanAccessLookup = IsLoggedInCounter | HasPerm("core.access_lookup")
|
0
api/tests/__init__.py
Normal file
0
api/tests/__init__.py
Normal file
29
api/tests/test_api_key.py
Normal file
29
api/tests/test_api_key.py
Normal file
@ -0,0 +1,29 @@
|
||||
import pytest
|
||||
from django.test import RequestFactory
|
||||
from model_bakery import baker
|
||||
|
||||
from api.auth import ApiKeyAuth
|
||||
from api.hashers import generate_key
|
||||
from api.models import ApiClient, ApiKey
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_api_key_auth():
|
||||
key, hashed = generate_key()
|
||||
client = baker.make(ApiClient)
|
||||
baker.make(ApiKey, client=client, hashed_key=hashed)
|
||||
auth = ApiKeyAuth()
|
||||
|
||||
assert auth.authenticate(RequestFactory().get(""), key) == client
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize(
|
||||
("key", "hashed"), [(generate_key()[0], generate_key()[1]), (generate_key()[0], "")]
|
||||
)
|
||||
def test_api_key_auth_invalid(key, hashed):
|
||||
client = baker.make(ApiClient)
|
||||
baker.make(ApiKey, client=client, hashed_key=hashed)
|
||||
auth = ApiKeyAuth()
|
||||
|
||||
assert auth.authenticate(RequestFactory().get(""), key) is None
|
10
api/urls.py
Normal file
10
api/urls.py
Normal file
@ -0,0 +1,10 @@
|
||||
from ninja_extra import NinjaExtraAPI
|
||||
|
||||
api = NinjaExtraAPI(
|
||||
title="PICON",
|
||||
description="Portail Interactif de Communication avec les Outils Numériques",
|
||||
version="0.2.0",
|
||||
urls_namespace="api",
|
||||
csrf=True,
|
||||
)
|
||||
api.auto_discover_controllers()
|
28
club/api.py
28
club/api.py
@ -1,22 +1,42 @@
|
||||
from typing import Annotated
|
||||
|
||||
from annotated_types import MinLen
|
||||
from django.db.models import Prefetch
|
||||
from ninja.security import SessionAuth
|
||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||
from ninja_extra.schemas import PaginatedResponseSchema
|
||||
|
||||
from club.models import Club
|
||||
from club.schemas import ClubSchema
|
||||
from core.auth.api_permissions import CanAccessLookup
|
||||
from api.auth import ApiKeyAuth
|
||||
from api.permissions import CanAccessLookup, HasPerm
|
||||
from club.models import Club, Membership
|
||||
from club.schemas import ClubSchema, SimpleClubSchema
|
||||
|
||||
|
||||
@api_controller("/club")
|
||||
class ClubController(ControllerBase):
|
||||
@route.get(
|
||||
"/search",
|
||||
response=PaginatedResponseSchema[ClubSchema],
|
||||
response=PaginatedResponseSchema[SimpleClubSchema],
|
||||
auth=[SessionAuth(), ApiKeyAuth()],
|
||||
permissions=[CanAccessLookup],
|
||||
url_name="search_club",
|
||||
)
|
||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||
def search_club(self, search: Annotated[str, MinLen(1)]):
|
||||
return Club.objects.filter(name__icontains=search).values()
|
||||
|
||||
@route.get(
|
||||
"/{int:club_id}",
|
||||
response=ClubSchema,
|
||||
auth=[SessionAuth(), ApiKeyAuth()],
|
||||
permissions=[HasPerm("club.view_club")],
|
||||
url_name="fetch_club",
|
||||
)
|
||||
def fetch_club(self, club_id: int):
|
||||
prefetch = Prefetch(
|
||||
"members", queryset=Membership.objects.ongoing().select_related("user")
|
||||
)
|
||||
return self.get_object_or_exception(
|
||||
Club.objects.prefetch_related(prefetch), id=club_id
|
||||
)
|
||||
|
@ -163,15 +163,16 @@ class SellingsForm(forms.Form):
|
||||
|
||||
def __init__(self, club, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
counters_qs = (
|
||||
Counter.objects.filter(
|
||||
Q(club=club)
|
||||
| Q(products__club=club)
|
||||
| Exists(Selling.objects.filter(counter=OuterRef("pk"), club=club))
|
||||
)
|
||||
.distinct()
|
||||
.order_by(Lower("name"))
|
||||
# postgres struggles really hard with a single query having three WHERE conditions,
|
||||
# but deals perfectly fine with UNION of multiple queryset with their own WHERE clause,
|
||||
# so we do this to get the ids, which we use to build another queryset that can be used by django.
|
||||
club_sales_subquery = Selling.objects.filter(counter=OuterRef("pk"), club=club)
|
||||
ids = (
|
||||
Counter.objects.filter(Q(club=club) | Q(products__club=club))
|
||||
.union(Counter.objects.filter(Exists(club_sales_subquery)))
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
counters_qs = Counter.objects.filter(id__in=ids).order_by(Lower("name"))
|
||||
self.fields["counters"] = forms.ModelMultipleChoiceField(
|
||||
counters_qs, label=_("Counter"), required=False
|
||||
)
|
||||
|
@ -1,9 +1,10 @@
|
||||
from ninja import ModelSchema
|
||||
|
||||
from club.models import Club
|
||||
from club.models import Club, Membership
|
||||
from core.schemas import SimpleUserSchema
|
||||
|
||||
|
||||
class ClubSchema(ModelSchema):
|
||||
class SimpleClubSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = Club
|
||||
fields = ["id", "name"]
|
||||
@ -21,3 +22,19 @@ class ClubProfileSchema(ModelSchema):
|
||||
@staticmethod
|
||||
def resolve_url(obj: Club) -> str:
|
||||
return obj.get_absolute_url()
|
||||
|
||||
|
||||
class ClubMemberSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = Membership
|
||||
fields = ["start_date", "end_date", "role", "description"]
|
||||
|
||||
user: SimpleUserSchema
|
||||
|
||||
|
||||
class ClubSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = Club
|
||||
fields = ["id", "name", "logo", "is_active", "short_description", "address"]
|
||||
|
||||
members: list[ClubMemberSchema]
|
||||
|
@ -1,25 +1,63 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
{% from "reservation/macros.jinja" import room_detail %}
|
||||
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h3>{% trans %}Club tools{% endtrans %}</h3>
|
||||
<h3>{% trans %}Club tools{% endtrans %} ({{ club.name }})</h3>
|
||||
<div>
|
||||
<h4>{% trans %}Communication:{% endtrans %}</h4>
|
||||
<ul>
|
||||
<li> <a href="{{ url('com:news_new') }}?club={{ object.id }}">{% trans %}Create a news{% endtrans %}</a></li>
|
||||
<li> <a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">{% trans %}Post in the Weekmail{% endtrans %}</a></li>
|
||||
<li>
|
||||
<a href="{{ url('com:news_new') }}?club={{ object.id }}">
|
||||
{% trans %}Create a news{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url('com:weekmail_article') }}?club={{ object.id }}">
|
||||
{% trans %}Post in the Weekmail{% endtrans %}
|
||||
</a>
|
||||
</li>
|
||||
{% if object.trombi %}
|
||||
<li> <a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">{% trans %}Edit Trombi{% endtrans %}</a></li>
|
||||
<li>
|
||||
<a href="{{ url('trombi:detail', trombi_id=object.trombi.id) }}">
|
||||
{% trans %}Edit Trombi{% endtrans %}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li><a href="{{ url('trombi:create', club_id=object.id) }}">{% trans %}New Trombi{% endtrans %}</a></li>
|
||||
<li><a href="{{ url('club:poster_list', club_id=object.id) }}">{% trans %}Posters{% endtrans %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<h4>{% trans %}Reservable rooms{% endtrans %}</h4>
|
||||
<a
|
||||
href="{{ url("reservation:room_create") }}?club={{ object.id }}"
|
||||
class="btn btn-blue"
|
||||
>
|
||||
{% trans %}Add a room{% endtrans %}
|
||||
</a>
|
||||
{%- if reservable_rooms|length > 0 -%}
|
||||
<ul class="card-group">
|
||||
{%- for room in reservable_rooms -%}
|
||||
{{ room_detail(
|
||||
room,
|
||||
can_edit=user.can_edit(room),
|
||||
can_delete=request.user.has_perm("reservation.delete_room")
|
||||
) }}
|
||||
{%- endfor -%}
|
||||
</ul>
|
||||
{%- else -%}
|
||||
<p>
|
||||
{% trans %}This club manages no reservable room{% endtrans %}
|
||||
</p>
|
||||
{%- endif -%}
|
||||
<h4>{% trans %}Counters:{% endtrans %}</h4>
|
||||
<ul>
|
||||
{% for c in object.counters.filter(type="OFFICE") %}
|
||||
<li>{{ c }}:
|
||||
<a href="{{ url('counter:details', counter_id=c.id) }}">View</a>
|
||||
<a href="{{ url('counter:admin', counter_id=c.id) }}">Edit</a>
|
||||
{% for counter in counters %}
|
||||
<li>{{ counter }}:
|
||||
<a href="{{ url('counter:details', counter_id=counter.id) }}">View</a>
|
||||
<a href="{{ url('counter:admin', counter_id=counter.id) }}">Edit</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
43
club/tests/test_club_controller.py
Normal file
43
club/tests/test_club_controller.py
Normal file
@ -0,0 +1,43 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from model_bakery import baker
|
||||
from model_bakery.recipe import Recipe
|
||||
from pytest_django.asserts import assertNumQueries
|
||||
|
||||
from club.models import Club, Membership
|
||||
from core.baker_recipes import subscriber_user
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestFetchClub:
|
||||
@pytest.fixture()
|
||||
def club(self):
|
||||
club = baker.make(Club)
|
||||
last_month = date.today() - timedelta(days=30)
|
||||
yesterday = date.today() - timedelta(days=1)
|
||||
membership_recipe = Recipe(Membership, club=club, start_date=last_month)
|
||||
membership_recipe.make(end_date=None, _quantity=10, _bulk_create=True)
|
||||
membership_recipe.make(end_date=yesterday, _quantity=10, _bulk_create=True)
|
||||
return club
|
||||
|
||||
def test_fetch_club_members(self, client: Client, club: Club):
|
||||
user = subscriber_user.make()
|
||||
client.force_login(user)
|
||||
res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id}))
|
||||
assert res.status_code == 200
|
||||
member_ids = {member["user"]["id"] for member in res.json()["members"]}
|
||||
assert member_ids == set(
|
||||
club.members.ongoing().values_list("user_id", flat=True)
|
||||
)
|
||||
|
||||
def test_fetch_club_nb_queries(self, client: Client, club: Club):
|
||||
user = subscriber_user.make()
|
||||
client.force_login(user)
|
||||
with assertNumQueries(6):
|
||||
# - 4 queries for authentication
|
||||
# - 2 queries for the actual data
|
||||
res = client.get(reverse("api:fetch_club", kwargs={"club_id": club.id}))
|
||||
assert res.status_code == 200
|
@ -241,6 +241,12 @@ class ClubToolsView(ClubTabsMixin, CanEditMixin, DetailView):
|
||||
template_name = "club/club_tools.jinja"
|
||||
current_tab = "tools"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(**kwargs) | {
|
||||
"reservable_rooms": list(self.object.reservable_rooms.all()),
|
||||
"counters": list(self.object.counters.filter(type="OFFICE")),
|
||||
}
|
||||
|
||||
|
||||
class ClubMembersView(ClubTabsMixin, CanViewMixin, DetailFormView):
|
||||
"""View of a club's members."""
|
||||
|
@ -1,7 +1,7 @@
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from club.models import Club
|
||||
from club.schemas import ClubSchema
|
||||
from club.schemas import SimpleClubSchema
|
||||
from core.views.widgets.ajax_select import (
|
||||
AutoCompleteSelect,
|
||||
AutoCompleteSelectMultiple,
|
||||
@ -13,7 +13,7 @@ _js = ["bundled/club/components/ajax-select-index.ts"]
|
||||
class AutoCompleteSelectClub(AutoCompleteSelect):
|
||||
component_name = "club-ajax-select"
|
||||
model = Club
|
||||
adapter = TypeAdapter(list[ClubSchema])
|
||||
adapter = TypeAdapter(list[SimpleClubSchema])
|
||||
|
||||
js = _js
|
||||
|
||||
@ -21,6 +21,6 @@ class AutoCompleteSelectClub(AutoCompleteSelect):
|
||||
class AutoCompleteSelectMultipleClub(AutoCompleteSelectMultiple):
|
||||
component_name = "club-ajax-select"
|
||||
model = Club
|
||||
adapter = TypeAdapter(list[ClubSchema])
|
||||
adapter = TypeAdapter(list[SimpleClubSchema])
|
||||
|
||||
js = _js
|
||||
|
@ -8,10 +8,10 @@ from ninja_extra.pagination import PageNumberPaginationExtra
|
||||
from ninja_extra.permissions import IsAuthenticated
|
||||
from ninja_extra.schemas import PaginatedResponseSchema
|
||||
|
||||
from api.permissions import HasPerm
|
||||
from com.ics_calendar import IcsCalendar
|
||||
from com.models import News, NewsDate
|
||||
from com.schemas import NewsDateFilterSchema, NewsDateSchema
|
||||
from core.auth.api_permissions import HasPerm
|
||||
from core.views.files import send_raw_file
|
||||
|
||||
|
||||
|
@ -170,7 +170,6 @@ def news_notification_callback(notif: Notification):
|
||||
if count:
|
||||
notif.viewed = False
|
||||
notif.param = str(count)
|
||||
notif.date = timezone.now()
|
||||
else:
|
||||
notif.viewed = True
|
||||
|
||||
@ -194,7 +193,7 @@ class NewsDateQuerySet(models.QuerySet):
|
||||
class NewsDate(models.Model):
|
||||
"""A date associated with news.
|
||||
|
||||
A [News][] can have multiple dates, for example if it is a recurring event.
|
||||
A [News][com.models.News] can have multiple dates, for example if it is a recurring event.
|
||||
"""
|
||||
|
||||
news = models.ForeignKey(
|
||||
|
@ -7,6 +7,7 @@ import frLocale from "@fullcalendar/core/locales/fr";
|
||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||
import iCalendarPlugin from "@fullcalendar/icalendar";
|
||||
import listPlugin from "@fullcalendar/list";
|
||||
import { type HTMLTemplateResult, html, render } from "lit-html";
|
||||
import {
|
||||
calendarCalendarInternal,
|
||||
calendarCalendarUnpublished,
|
||||
@ -176,29 +177,25 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
oldPopup.remove();
|
||||
}
|
||||
|
||||
const makePopupInfo = (info: HTMLElement, iconClass: string) => {
|
||||
const row = document.createElement("div");
|
||||
const icon = document.createElement("i");
|
||||
|
||||
row.setAttribute("class", "event-details-row");
|
||||
|
||||
icon.setAttribute("class", `event-detail-row-icon fa-xl ${iconClass}`);
|
||||
|
||||
row.appendChild(icon);
|
||||
row.appendChild(info);
|
||||
|
||||
return row;
|
||||
const makePopupInfo = (info: HTMLTemplateResult, iconClass: string) => {
|
||||
return html`
|
||||
<div class="event-details-row">
|
||||
<i class="event-detail-row-icon fa-xl ${iconClass}"></i>
|
||||
${info}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const makePopupTitle = (event: EventImpl) => {
|
||||
const row = document.createElement("div");
|
||||
row.innerHTML = `
|
||||
const row = html`
|
||||
<div>
|
||||
<h4 class="event-details-row-content">
|
||||
${event.title}
|
||||
</h4>
|
||||
<span class="event-details-row-content">
|
||||
${this.formatDate(event.start)} - ${this.formatDate(event.end)}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
return makePopupInfo(
|
||||
row,
|
||||
@ -210,9 +207,11 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
if (event.extendedProps.location === null) {
|
||||
return null;
|
||||
}
|
||||
const info = document.createElement("div");
|
||||
info.innerText = event.extendedProps.location;
|
||||
|
||||
const info = html`
|
||||
<div>
|
||||
${event.extendedProps.location}
|
||||
</div>
|
||||
`;
|
||||
return makePopupInfo(info, "fa-solid fa-location-dot");
|
||||
};
|
||||
|
||||
@ -220,10 +219,7 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
if (event.url === "") {
|
||||
return null;
|
||||
}
|
||||
const url = document.createElement("a");
|
||||
url.href = event.url;
|
||||
url.textContent = gettext("More info");
|
||||
|
||||
const url = html`<a href="${event.url}">${gettext("More info")}</a>`;
|
||||
return makePopupInfo(url, "fa-solid fa-link");
|
||||
};
|
||||
|
||||
@ -232,64 +228,59 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
return null;
|
||||
}
|
||||
const newsId = this.getNewsId(event);
|
||||
const div = document.createElement("div");
|
||||
const buttons = [] as HTMLTemplateResult[];
|
||||
|
||||
if (this.canModerate) {
|
||||
if (event.source.internalEventSource.ui.classNames.includes("unpublished")) {
|
||||
const button = document.createElement("button");
|
||||
button.innerHTML = `<i class="fa fa-check"></i>${gettext("Publish")}`;
|
||||
button.setAttribute("class", "btn btn-green");
|
||||
button.onclick = () => {
|
||||
this.publishNews(newsId);
|
||||
};
|
||||
div.appendChild(button);
|
||||
const button = html`
|
||||
<button class="btn btn-green" @click="${() => this.publishNews(newsId)}">
|
||||
<i class="fa fa-check"></i>${gettext("Publish")}
|
||||
</button>
|
||||
`;
|
||||
buttons.push(button);
|
||||
} else {
|
||||
const button = document.createElement("button");
|
||||
button.innerHTML = `<i class="fa fa-times"></i>${gettext("Unpublish")}`;
|
||||
button.setAttribute("class", "btn btn-orange");
|
||||
button.onclick = () => {
|
||||
this.unpublishNews(newsId);
|
||||
};
|
||||
div.appendChild(button);
|
||||
const button = html`
|
||||
<button class="btn btn-orange" @click="${() => this.unpublishNews(newsId)}">
|
||||
<i class="fa fa-times"></i>${gettext("Unpublish")}
|
||||
</button>
|
||||
`;
|
||||
buttons.push(button);
|
||||
}
|
||||
}
|
||||
if (this.canDelete) {
|
||||
const button = document.createElement("button");
|
||||
button.innerHTML = `<i class="fa fa-trash-can"></i>${gettext("Delete")}`;
|
||||
button.setAttribute("class", "btn btn-red");
|
||||
button.onclick = () => {
|
||||
this.deleteNews(newsId);
|
||||
};
|
||||
div.appendChild(button);
|
||||
const button = html`
|
||||
<button class="btn btn-red" @click="${() => this.deleteNews(newsId)}">
|
||||
<i class="fa fa-trash-can"></i>${gettext("Delete")}
|
||||
</button>
|
||||
`;
|
||||
buttons.push(button);
|
||||
}
|
||||
|
||||
return makePopupInfo(div, "fa-solid fa-toolbox");
|
||||
return makePopupInfo(html`<div>${buttons}</div>`, "fa-solid fa-toolbox");
|
||||
};
|
||||
|
||||
// Create new popup
|
||||
const popup = document.createElement("div");
|
||||
const popupContainer = document.createElement("div");
|
||||
|
||||
popup.setAttribute("id", "event-details");
|
||||
popupContainer.setAttribute("class", "event-details-container");
|
||||
|
||||
popupContainer.appendChild(makePopupTitle(event.event));
|
||||
const infos = [] as HTMLTemplateResult[];
|
||||
infos.push(makePopupTitle(event.event));
|
||||
|
||||
const location = makePopupLocation(event.event);
|
||||
if (location !== null) {
|
||||
popupContainer.appendChild(location);
|
||||
infos.push(location);
|
||||
}
|
||||
|
||||
const url = makePopupUrl(event.event);
|
||||
if (url !== null) {
|
||||
popupContainer.appendChild(url);
|
||||
infos.push(url);
|
||||
}
|
||||
|
||||
const tools = makePopupTools(event.event);
|
||||
if (tools !== null) {
|
||||
popupContainer.appendChild(tools);
|
||||
infos.push(tools);
|
||||
}
|
||||
|
||||
popup.appendChild(popupContainer);
|
||||
const popup = document.createElement("div");
|
||||
popup.setAttribute("id", "event-details");
|
||||
render(html`<div class="event-details-container">${infos}</div>`, popup);
|
||||
|
||||
// We can't just add the element relative to the one we want to appear under
|
||||
// Otherwise, it either gets clipped by the boundaries of the calendar or resize cells
|
||||
|
@ -8,13 +8,17 @@ interface ParsedNewsDateSchema extends Omit<NewsDateSchema, "start_date" | "end_
|
||||
}
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("upcomingNewsLoader", (startDate: Date) => ({
|
||||
Alpine.data("upcomingNewsLoader", (startDate: Date, locale: string) => ({
|
||||
startDate: startDate,
|
||||
currentPage: 1,
|
||||
pageSize: 6,
|
||||
hasNext: true,
|
||||
loading: false,
|
||||
newsDates: [] as NewsDateSchema[],
|
||||
dateFormat: new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}),
|
||||
|
||||
async loadMore() {
|
||||
this.loading = true;
|
@ -81,9 +81,8 @@
|
||||
}
|
||||
|
||||
#links_content {
|
||||
overflow: auto;
|
||||
box-shadow: $shadow-color 1px 1px 1px;
|
||||
height: 20em;
|
||||
padding: .5rem;
|
||||
|
||||
h4 {
|
||||
margin-left: 5px;
|
||||
|
@ -18,7 +18,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_js %}
|
||||
<script type="module" src={{ static("bundled/com/components/moderation-alert-index.ts") }}></script>
|
||||
<script type="module" src={{ static("bundled/com/moderation-alert-index.ts") }}></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
@ -1,13 +1,11 @@
|
||||
{% extends "core/base.jinja" %}
|
||||
{% from "com/macros.jinja" import news_moderation_alert %}
|
||||
|
||||
{% block title %}
|
||||
{% trans %}News{% endtrans %}
|
||||
{% endblock %}
|
||||
{% block title %}AE UTBM{% endblock %}
|
||||
|
||||
{% block additional_css %}
|
||||
<link rel="stylesheet" href="{{ static('com/css/news-list.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('com/components/ics-calendar.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/components/calendar.scss') }}">
|
||||
|
||||
{# Atom feed discovery, not really css but also goes there #}
|
||||
<link rel="alternate" type="application/rss+xml" title="{% trans %}News feed{% endtrans %}" href="{{ url("com:news_feed") }}">
|
||||
@ -15,8 +13,8 @@
|
||||
|
||||
{% block additional_js %}
|
||||
<script type="module" src={{ static("bundled/com/components/ics-calendar-index.ts") }}></script>
|
||||
<script type="module" src={{ static("bundled/com/components/moderation-alert-index.ts") }}></script>
|
||||
<script type="module" src={{ static("bundled/com/components/upcoming-news-loader-index.ts") }}></script>
|
||||
<script type="module" src={{ static("bundled/com/moderation-alert-index.ts") }}></script>
|
||||
<script type="module" src={{ static("bundled/com/upcoming-news-loader-index.ts") }}></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@ -84,11 +82,11 @@
|
||||
<a href="{{ date.news.club.get_absolute_url() }}">{{ date.news.club }}</a>
|
||||
<div class="news_date">
|
||||
<time datetime="{{ date.start_date.isoformat(timespec="seconds") }}">
|
||||
{{ date.start_date|localtime|date(DATETIME_FORMAT) }}
|
||||
{{ date.start_date|localtime|date(DATETIME_FORMAT) }},
|
||||
{{ date.start_date|localtime|time(DATETIME_FORMAT) }}
|
||||
</time> -
|
||||
<time datetime="{{ date.end_date.isoformat(timespec="seconds") }}">
|
||||
{{ date.end_date|localtime|date(DATETIME_FORMAT) }}
|
||||
{{ date.end_date|localtime|date(DATETIME_FORMAT) }},
|
||||
{{ date.end_date|localtime|time(DATETIME_FORMAT) }}
|
||||
</time>
|
||||
</div>
|
||||
@ -103,7 +101,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div x-data="upcomingNewsLoader(new Date('{{ last_day + timedelta(days=1) }}'))">
|
||||
<div x-data="upcomingNewsLoader(new Date('{{ last_day + timedelta(days=1) }}'), '{{ get_language() }}')">
|
||||
<template x-for="newsList in Object.values(groupedDates())">
|
||||
<div class="news_events_group">
|
||||
<div class="news_events_group_date">
|
||||
@ -139,11 +137,11 @@
|
||||
<div class="news_date">
|
||||
<time
|
||||
:datetime="newsDate.start_date.toISOString()"
|
||||
x-text="`${newsDate.start_date.getHours()}:${newsDate.start_date.getMinutes()}`"
|
||||
x-text="dateFormat.format(newsDate.start_date)"
|
||||
></time> -
|
||||
<time
|
||||
:datetime="newsDate.end_date.toISOString()"
|
||||
x-text="`${newsDate.end_date.getHours()}:${newsDate.end_date.getMinutes()}`"
|
||||
x-text="dateFormat.format(newsDate.end_date)"
|
||||
></time>
|
||||
</div>
|
||||
</div>
|
||||
@ -213,6 +211,12 @@
|
||||
<i class="fa-solid fa-magnifying-glass fa-xl"></i>
|
||||
<a href="{{ url("matmat:search_clear") }}">{% trans %}Matmatronch{% endtrans %}</a>
|
||||
</li>
|
||||
{% if user.has_perm("reservation.view_reservationslot") %}
|
||||
<li>
|
||||
<i class="fa-solid fa-thumbtack fa-xl"></i>
|
||||
<a href="{{ url("reservation:main") }}">{% trans %}Room reservation{% endtrans %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<i class="fa-solid fa-check-to-slot fa-xl"></i>
|
||||
<a href="{{ url("election:list") }}">{% trans %}Elections{% endtrans %}</a>
|
||||
|
@ -5,13 +5,15 @@ from django.conf import settings
|
||||
from django.db.models import F
|
||||
from django.http import HttpResponse
|
||||
from ninja import File, Query
|
||||
from ninja.security import SessionAuth
|
||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||
from ninja_extra.exceptions import PermissionDenied
|
||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||
from ninja_extra.schemas import PaginatedResponseSchema
|
||||
|
||||
from api.auth import ApiKeyAuth
|
||||
from api.permissions import CanAccessLookup, CanView, HasPerm
|
||||
from club.models import Mailing
|
||||
from core.auth.api_permissions import CanAccessLookup, CanView, HasPerm
|
||||
from core.models import Group, QuickUploadImage, SithFile, User
|
||||
from core.schemas import (
|
||||
FamilyGodfatherSchema,
|
||||
@ -90,6 +92,7 @@ class SithFileController(ControllerBase):
|
||||
@route.get(
|
||||
"/search",
|
||||
response=PaginatedResponseSchema[SithFileSchema],
|
||||
auth=[SessionAuth(), ApiKeyAuth()],
|
||||
permissions=[CanAccessLookup],
|
||||
)
|
||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||
@ -102,6 +105,7 @@ class GroupController(ControllerBase):
|
||||
@route.get(
|
||||
"/search",
|
||||
response=PaginatedResponseSchema[GroupSchema],
|
||||
auth=[SessionAuth(), ApiKeyAuth()],
|
||||
permissions=[CanAccessLookup],
|
||||
)
|
||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||
|
@ -4,13 +4,13 @@
|
||||
VERSION="$1"
|
||||
|
||||
# Cleanup env vars for auto discovery mechanism
|
||||
export CPATH=
|
||||
export LIBRARY_PATH=
|
||||
export CFLAGS=
|
||||
export LDFLAGS=
|
||||
export CCFLAGS=
|
||||
export CXXFLAGS=
|
||||
export CPPFLAGS=
|
||||
unset CPATH
|
||||
unset LIBRARY_PATH
|
||||
unset CFLAGS
|
||||
unset LDFLAGS
|
||||
unset CCFLAGS
|
||||
unset CXXFLAGS
|
||||
unset CPPFLAGS
|
||||
|
||||
# prepare
|
||||
rm -rf "$VIRTUAL_ENV/packages"
|
||||
|
@ -59,6 +59,7 @@ class PopulatedGroups(NamedTuple):
|
||||
counter_admin: Group
|
||||
accounting_admin: Group
|
||||
pedagogy_admin: Group
|
||||
campus_admin: Group
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@ -784,13 +785,17 @@ class Command(BaseCommand):
|
||||
# public has no permission.
|
||||
# Its purpose is not to link users to permissions,
|
||||
# but to other objects (like products)
|
||||
public_group = Group.objects.create(name="Public")
|
||||
public_group = Group.objects.create(name="Publique")
|
||||
|
||||
subscribers = Group.objects.create(name="Subscribers")
|
||||
subscribers = Group.objects.create(name="Cotisants")
|
||||
subscribers.permissions.add(
|
||||
*list(perms.filter(codename__in=["add_news", "add_uvcomment"]))
|
||||
*list(
|
||||
perms.filter(
|
||||
codename__in=["add_news", "add_uvcomment", "view_reservationslot"]
|
||||
)
|
||||
old_subscribers = Group.objects.create(name="Old subscribers")
|
||||
)
|
||||
)
|
||||
old_subscribers = Group.objects.create(name="Anciens cotisants")
|
||||
old_subscribers.permissions.add(
|
||||
*list(
|
||||
perms.filter(
|
||||
@ -805,12 +810,14 @@ class Command(BaseCommand):
|
||||
"add_peoplepicturerelation",
|
||||
"add_page",
|
||||
"add_quickuploadimage",
|
||||
"view_club",
|
||||
"access_lookup",
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
accounting_admin = Group.objects.create(
|
||||
name="Accounting admin", is_manually_manageable=True
|
||||
name="Admin comptabilité", is_manually_manageable=True
|
||||
)
|
||||
accounting_admin.permissions.add(
|
||||
*list(
|
||||
@ -831,7 +838,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
)
|
||||
com_admin = Group.objects.create(
|
||||
name="Communication admin", is_manually_manageable=True
|
||||
name="Admin communication", is_manually_manageable=True
|
||||
)
|
||||
com_admin.permissions.add(
|
||||
*list(
|
||||
@ -839,7 +846,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
)
|
||||
counter_admin = Group.objects.create(
|
||||
name="Counter admin", is_manually_manageable=True
|
||||
name="Admin comptoirs", is_manually_manageable=True
|
||||
)
|
||||
counter_admin.permissions.add(
|
||||
*list(
|
||||
@ -849,14 +856,14 @@ class Command(BaseCommand):
|
||||
)
|
||||
)
|
||||
)
|
||||
sas_admin = Group.objects.create(name="SAS admin", is_manually_manageable=True)
|
||||
sas_admin = Group.objects.create(name="Admin SAS", is_manually_manageable=True)
|
||||
sas_admin.permissions.add(
|
||||
*list(
|
||||
perms.filter(content_type__app_label="sas").values_list("pk", flat=True)
|
||||
)
|
||||
)
|
||||
forum_admin = Group.objects.create(
|
||||
name="Forum admin", is_manually_manageable=True
|
||||
name="Admin forum", is_manually_manageable=True
|
||||
)
|
||||
forum_admin.permissions.add(
|
||||
*list(
|
||||
@ -866,7 +873,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
)
|
||||
pedagogy_admin = Group.objects.create(
|
||||
name="Pedagogy admin", is_manually_manageable=True
|
||||
name="Admin pédagogie", is_manually_manageable=True
|
||||
)
|
||||
pedagogy_admin.permissions.add(
|
||||
*list(
|
||||
@ -875,6 +882,16 @@ class Command(BaseCommand):
|
||||
.values_list("pk", flat=True)
|
||||
)
|
||||
)
|
||||
campus_admin = Group.objects.create(
|
||||
name="Respo site", is_manually_manageable=True
|
||||
)
|
||||
campus_admin.permissions.add(
|
||||
*counter_admin.permissions.values_list("pk", flat=True),
|
||||
*perms.filter(content_type__app_label="reservation").values_list(
|
||||
"pk", flat=True
|
||||
),
|
||||
)
|
||||
|
||||
self.reset_index("core", "auth")
|
||||
|
||||
return PopulatedGroups(
|
||||
@ -887,6 +904,7 @@ class Command(BaseCommand):
|
||||
accounting_admin=accounting_admin,
|
||||
sas_admin=sas_admin,
|
||||
pedagogy_admin=pedagogy_admin,
|
||||
campus_admin=campus_admin,
|
||||
)
|
||||
|
||||
def _create_ban_groups(self):
|
||||
|
@ -1,6 +1,7 @@
|
||||
import random
|
||||
from datetime import date, timedelta
|
||||
from datetime import timezone as tz
|
||||
from math import ceil
|
||||
from typing import Iterator
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
@ -24,6 +25,7 @@ from counter.models import (
|
||||
)
|
||||
from forum.models import Forum, ForumMessage, ForumTopic
|
||||
from pedagogy.models import UV
|
||||
from reservation.models import ReservationSlot, Room
|
||||
from subscription.models import Subscription
|
||||
|
||||
|
||||
@ -40,45 +42,20 @@ class Command(BaseCommand):
|
||||
|
||||
self.stdout.write("Creating users...")
|
||||
users = self.create_users()
|
||||
# len(subscribers) is approximately 480
|
||||
subscribers = random.sample(users, k=int(0.8 * len(users)))
|
||||
self.stdout.write("Creating subscriptions...")
|
||||
self.create_subscriptions(subscribers)
|
||||
self.stdout.write("Creating club memberships...")
|
||||
users_qs = User.objects.filter(id__in=[s.id for s in subscribers])
|
||||
subscribers_now = list(
|
||||
users_qs.annotate(
|
||||
filter=Exists(
|
||||
Subscription.objects.filter(
|
||||
member_id=OuterRef("pk"), subscription_end__gte=now()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
old_subscribers = list(
|
||||
users_qs.annotate(
|
||||
filter=Exists(
|
||||
Subscription.objects.filter(
|
||||
member_id=OuterRef("pk"), subscription_end__lt=now()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
self.make_club(
|
||||
Club.objects.get(id=settings.SITH_MAIN_CLUB_ID),
|
||||
random.sample(subscribers_now, k=min(30, len(subscribers_now))),
|
||||
random.sample(old_subscribers, k=min(60, len(old_subscribers))),
|
||||
)
|
||||
self.make_club(
|
||||
Club.objects.get(name="Troll Penché"),
|
||||
random.sample(subscribers_now, k=min(20, len(subscribers_now))),
|
||||
random.sample(old_subscribers, k=min(80, len(old_subscribers))),
|
||||
)
|
||||
self.create_club_memberships(subscribers)
|
||||
self.stdout.write("Creating rooms and reservation...")
|
||||
self.create_resources_and_reservations(random.sample(subscribers, k=40))
|
||||
self.stdout.write("Creating uvs...")
|
||||
self.create_uvs()
|
||||
self.stdout.write("Creating products...")
|
||||
self.create_products()
|
||||
self.stdout.write("Creating sales and refills...")
|
||||
sellers = random.sample(list(User.objects.all()), 100)
|
||||
sellers = list(User.objects.order_by("?")[:100])
|
||||
self.create_sales(sellers)
|
||||
self.stdout.write("Creating permanences...")
|
||||
self.create_permanences(sellers)
|
||||
@ -188,6 +165,97 @@ class Command(BaseCommand):
|
||||
memberships = Membership.objects.bulk_create(memberships)
|
||||
Membership._add_club_groups(memberships)
|
||||
|
||||
def create_club_memberships(self, users: list[User]):
|
||||
users_qs = User.objects.filter(id__in=[s.id for s in users])
|
||||
subscribers_now = list(
|
||||
users_qs.annotate(
|
||||
filter=Exists(
|
||||
Subscription.objects.filter(
|
||||
member_id=OuterRef("pk"), subscription_end__gte=now()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
old_subscribers = list(
|
||||
users_qs.annotate(
|
||||
filter=Exists(
|
||||
Subscription.objects.filter(
|
||||
member_id=OuterRef("pk"), subscription_end__lt=now()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
self.make_club(
|
||||
Club.objects.get(id=settings.SITH_MAIN_CLUB_ID),
|
||||
random.sample(subscribers_now, k=min(30, len(subscribers_now))),
|
||||
random.sample(old_subscribers, k=min(60, len(old_subscribers))),
|
||||
)
|
||||
self.make_club(
|
||||
Club.objects.get(name="Troll Penché"),
|
||||
random.sample(subscribers_now, k=min(20, len(subscribers_now))),
|
||||
random.sample(old_subscribers, k=min(80, len(old_subscribers))),
|
||||
)
|
||||
|
||||
def create_resources_and_reservations(self, users: list[User]):
|
||||
"""Generate reservable rooms and reservations slots for those rooms.
|
||||
|
||||
Contrary to the other data generator,
|
||||
this one generates more data than what is expected on the real db.
|
||||
"""
|
||||
ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID)
|
||||
pdf = Club.objects.get(id=settings.SITH_PDF_CLUB_ID)
|
||||
troll = Club.objects.get(name="Troll Penché")
|
||||
rooms = [
|
||||
Room(
|
||||
name=name,
|
||||
club=club,
|
||||
location=location,
|
||||
description=self.faker.text(100),
|
||||
)
|
||||
for name, club, location in [
|
||||
("Champi", ae, "BELFORT"),
|
||||
("Muzik", ae, "BELFORT"),
|
||||
("Pôle Tech", ae, "BELFORT"),
|
||||
("Jolly", troll, "BELFORT"),
|
||||
("Cookut", pdf, "BELFORT"),
|
||||
("Lucky", pdf, "BELFORT"),
|
||||
("Potards", pdf, "SEVENANS"),
|
||||
("Bureau AE", ae, "SEVENANS"),
|
||||
]
|
||||
]
|
||||
rooms = Room.objects.bulk_create(rooms)
|
||||
reservations = []
|
||||
for room in rooms:
|
||||
# how much people use this room.
|
||||
# The higher the number, the more reservations exist,
|
||||
# the smaller the interval between two slot is,
|
||||
# and the more future reservations have already been made ahead of time
|
||||
affluence = random.randint(2, 6)
|
||||
slot_start = make_aware(self.faker.past_datetime("-5y").replace(minute=0))
|
||||
generate_until = make_aware(
|
||||
self.faker.future_datetime(timedelta(days=1) * affluence**2)
|
||||
)
|
||||
while slot_start < generate_until:
|
||||
if slot_start.hour < 8:
|
||||
# if a reservation would start in the middle of the night
|
||||
# make it start the next morning instead
|
||||
slot_start += timedelta(hours=10 - slot_start.hour)
|
||||
duration = timedelta(minutes=15) * (1 + int(random.gammavariate(3, 2)))
|
||||
reservations.append(
|
||||
ReservationSlot(
|
||||
room=room,
|
||||
author=random.choice(users),
|
||||
start_at=slot_start,
|
||||
end_at=slot_start + duration,
|
||||
created_at=slot_start - self.faker.time_delta("+7d"),
|
||||
)
|
||||
)
|
||||
slot_start += duration + (
|
||||
timedelta(minutes=15) * ceil(random.expovariate(affluence / 192))
|
||||
)
|
||||
reservations.sort(key=lambda slot: slot.created_at)
|
||||
ReservationSlot.objects.bulk_create(reservations)
|
||||
|
||||
def create_uvs(self):
|
||||
root = User.objects.get(username="root")
|
||||
categories = ["CS", "TM", "OM", "QC", "EC"]
|
||||
@ -238,7 +306,13 @@ class Command(BaseCommand):
|
||||
ae = Club.objects.get(id=settings.SITH_MAIN_CLUB_ID)
|
||||
other_clubs = random.sample(list(Club.objects.all()), k=3)
|
||||
groups = list(
|
||||
Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"])
|
||||
Group.objects.filter(
|
||||
id__in=[
|
||||
settings.SITH_GROUP_SUBSCRIBERS_ID,
|
||||
settings.SITH_GROUP_OLD_SUBSCRIBERS_ID,
|
||||
settings.SITH_GROUP_PUBLIC_ID,
|
||||
]
|
||||
)
|
||||
)
|
||||
counters = list(
|
||||
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"])
|
||||
@ -379,7 +453,7 @@ class Command(BaseCommand):
|
||||
Permanency.objects.bulk_create(perms)
|
||||
|
||||
def create_forums(self):
|
||||
forumers = random.sample(list(User.objects.all()), 100)
|
||||
forumers = list(User.objects.order_by("?")[:100])
|
||||
most_actives = random.sample(forumers, 10)
|
||||
categories = list(Forum.objects.filter(is_category=True))
|
||||
new_forums = [
|
||||
@ -397,7 +471,7 @@ class Command(BaseCommand):
|
||||
for _ in range(100)
|
||||
]
|
||||
ForumTopic.objects.bulk_create(new_topics)
|
||||
topics = list(ForumTopic.objects.all())
|
||||
topics = list(ForumTopic.objects.values_list("id", flat=True))
|
||||
|
||||
def get_author():
|
||||
if random.random() > 0.5:
|
||||
@ -405,7 +479,7 @@ class Command(BaseCommand):
|
||||
return random.choice(forumers)
|
||||
|
||||
messages = []
|
||||
for t in topics:
|
||||
for topic_id in topics:
|
||||
nb_messages = max(1, int(random.normalvariate(mu=90, sigma=50)))
|
||||
dates = sorted(
|
||||
[
|
||||
@ -417,7 +491,7 @@ class Command(BaseCommand):
|
||||
messages.extend(
|
||||
[
|
||||
ForumMessage(
|
||||
topic=t,
|
||||
topic_id=topic_id,
|
||||
author=get_author(),
|
||||
date=d,
|
||||
message="\n\n".join(
|
||||
|
28
core/migrations/0046_permissionrights.py
Normal file
28
core/migrations/0046_permissionrights.py
Normal file
@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2 on 2025-05-20 17:50
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("core", "0045_quickuploadimage")]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="GlobalPermissionRights",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"permissions": [("access_lookup", "Can access any lookup in the sith")],
|
||||
"managed": False,
|
||||
"default_permissions": [],
|
||||
},
|
||||
),
|
||||
]
|
@ -0,0 +1,27 @@
|
||||
# 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",
|
||||
),
|
||||
),
|
||||
]
|
@ -754,6 +754,23 @@ class UserBan(models.Model):
|
||||
return f"Ban of user {self.user.id}"
|
||||
|
||||
|
||||
class GlobalPermissionRights(models.Model):
|
||||
"""Little hack to have permissions not linked to a specific db table."""
|
||||
|
||||
class Meta:
|
||||
# No database table creation or deletion
|
||||
# operations will be performed for this model.
|
||||
managed = False
|
||||
|
||||
# disable "add", "change", "delete" and "view" default permissions
|
||||
default_permissions = []
|
||||
|
||||
permissions = [("access_lookup", "Can access any lookup in the sith")]
|
||||
|
||||
def __str__(self):
|
||||
return self.__class__.__name__
|
||||
|
||||
|
||||
class Preferences(models.Model):
|
||||
user = models.OneToOneField(
|
||||
User, related_name="_preferences", on_delete=models.CASCADE
|
||||
@ -1434,6 +1451,10 @@ class PageRev(models.Model):
|
||||
return self.page.can_be_edited_by(user)
|
||||
|
||||
|
||||
def get_notification_types():
|
||||
return settings.SITH_NOTIFICATIONS
|
||||
|
||||
|
||||
class Notification(models.Model):
|
||||
user = models.ForeignKey(
|
||||
User, related_name="notifications", on_delete=models.CASCADE
|
||||
@ -1441,9 +1462,9 @@ class Notification(models.Model):
|
||||
url = models.CharField(_("url"), max_length=255)
|
||||
param = models.CharField(_("param"), max_length=128, default="")
|
||||
type = models.CharField(
|
||||
_("type"), max_length=32, choices=settings.SITH_NOTIFICATIONS, default="GENERIC"
|
||||
_("type"), max_length=32, choices=get_notification_types, default="GENERIC"
|
||||
)
|
||||
date = models.DateTimeField(_("date"), default=timezone.now)
|
||||
date = models.DateTimeField(_("date"), auto_now=True)
|
||||
viewed = models.BooleanField(_("viewed"), default=False, db_index=True)
|
||||
|
||||
def __str__(self):
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { morph } from "@alpinejs/morph";
|
||||
import sort from "@alpinejs/sort";
|
||||
import Alpine from "alpinejs";
|
||||
|
||||
Alpine.plugin(sort);
|
||||
Alpine.plugin([sort, morph]);
|
||||
window.Alpine = Alpine;
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
|
120
core/static/bundled/core/components/tabs-index.ts
Normal file
120
core/static/bundled/core/components/tabs-index.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { registerComponent } from "#core:utils/web-components";
|
||||
import { html, render } from "lit-html";
|
||||
import { unsafeHTML } from "lit-html/directives/unsafe-html.js";
|
||||
|
||||
@registerComponent("ui-tab")
|
||||
export class Tab extends HTMLElement {
|
||||
static observedAttributes = ["title", "active"];
|
||||
private description = "";
|
||||
private inner = "";
|
||||
private active = false;
|
||||
|
||||
attributeChangedCallback(name: string, _oldValue?: string, newValue?: string) {
|
||||
const activeOld = this.active;
|
||||
this.active = this.hasAttribute("active");
|
||||
if (this.active !== activeOld && this.active) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("ui-tab-activated", { detail: this, bubbles: true }),
|
||||
);
|
||||
}
|
||||
|
||||
if (name === "title") {
|
||||
this.description = newValue;
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent("ui-tab-updated", { bubbles: true }));
|
||||
}
|
||||
|
||||
getButtonTemplate() {
|
||||
return html`
|
||||
<button
|
||||
role="tab"
|
||||
?aria-selected=${this.active}
|
||||
class="tab-header clickable ${this.active ? "active" : ""}"
|
||||
@click="${() => this.setActive(true)}"
|
||||
>
|
||||
${this.description}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
getContentTemplate() {
|
||||
return html`
|
||||
<section
|
||||
class="tab-section"
|
||||
?hidden=${!this.active}
|
||||
>
|
||||
${unsafeHTML(this.getContentHtml())}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
setActive(value: boolean) {
|
||||
if (value) {
|
||||
this.setAttribute("active", "");
|
||||
} else {
|
||||
this.removeAttribute("active");
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.inner = this.innerHTML;
|
||||
this.innerHTML = "";
|
||||
}
|
||||
|
||||
getContentHtml() {
|
||||
const content = this.getElementsByClassName("tab-section")[0];
|
||||
if (content !== undefined) {
|
||||
return content.innerHTML;
|
||||
}
|
||||
return this.inner;
|
||||
}
|
||||
|
||||
setContentHtml(value: string) {
|
||||
const content = this.getElementsByClassName("tab-section")[0];
|
||||
if (content !== undefined) {
|
||||
content.innerHTML = value;
|
||||
}
|
||||
this.inner = value;
|
||||
}
|
||||
}
|
||||
|
||||
@registerComponent("ui-tab-group")
|
||||
export class TabGroup extends HTMLElement {
|
||||
private node: HTMLDivElement;
|
||||
|
||||
connectedCallback() {
|
||||
this.node = document.createElement("div");
|
||||
this.node.classList.add("tabs", "shadow");
|
||||
this.appendChild(this.node);
|
||||
|
||||
this.addEventListener("ui-tab-activated", (event: CustomEvent) => {
|
||||
const target = event.detail as Tab;
|
||||
for (const tab of this.getElementsByTagName("ui-tab") as HTMLCollectionOf<Tab>) {
|
||||
if (tab !== target) {
|
||||
tab.setActive(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.addEventListener("ui-tab-updated", () => {
|
||||
this.render();
|
||||
});
|
||||
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
const tabs = Array.prototype.slice.call(
|
||||
this.getElementsByTagName("ui-tab"),
|
||||
) as Tab[];
|
||||
render(
|
||||
html`
|
||||
<div class="tab-headers">
|
||||
${tabs.map((tab) => tab.getButtonTemplate())}
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
${tabs.map((tab) => tab.getContentTemplate())}
|
||||
</div>
|
||||
`,
|
||||
this.node,
|
||||
);
|
||||
}
|
||||
}
|
36
core/static/bundled/core/navbar-index.ts
Normal file
36
core/static/bundled/core/navbar-index.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { exportToHtml } from "#core:utils/globals";
|
||||
|
||||
exportToHtml("showMenu", () => {
|
||||
const navbar = document.getElementById("navbar-content");
|
||||
const current = navbar.getAttribute("mobile-display");
|
||||
navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden");
|
||||
});
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
const menuItems = document.querySelectorAll(".navbar details[name='navbar'].menu");
|
||||
const isDesktop = () => {
|
||||
return window.innerWidth >= 500;
|
||||
};
|
||||
for (const item of menuItems) {
|
||||
item.addEventListener("mouseover", () => {
|
||||
if (isDesktop()) {
|
||||
item.setAttribute("open", "");
|
||||
}
|
||||
});
|
||||
item.addEventListener("mouseout", () => {
|
||||
if (isDesktop()) {
|
||||
item.removeAttribute("open");
|
||||
}
|
||||
});
|
||||
item.addEventListener("click", (event: MouseEvent) => {
|
||||
// Don't close when clicking on desktop mode
|
||||
if ((event.target as HTMLElement).nodeName !== "SUMMARY" || event.detail === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDesktop()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
@ -1,4 +1,5 @@
|
||||
import htmx from "htmx.org";
|
||||
import "htmx-ext-alpine-morph";
|
||||
|
||||
document.body.addEventListener("htmx:beforeRequest", (event) => {
|
||||
event.target.ariaBusy = true;
|
||||
|
2
core/static/bundled/jquery-ui-index.js
vendored
2
core/static/bundled/jquery-ui-index.js
vendored
@ -1,2 +0,0 @@
|
||||
// This is only used to import jquery-ui css files
|
||||
import "jquery-ui/themes/base/all.css";
|
@ -1,274 +0,0 @@
|
||||
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
|
||||
import cytoscape from "cytoscape";
|
||||
import cxtmenu from "cytoscape-cxtmenu";
|
||||
import klay from "cytoscape-klay";
|
||||
import { familyGetFamilyGraph } from "#openapi";
|
||||
|
||||
cytoscape.use(klay);
|
||||
cytoscape.use(cxtmenu);
|
||||
|
||||
async function getGraphData(userId, godfathersDepth, godchildrenDepth) {
|
||||
const data = (
|
||||
await familyGetFamilyGraph({
|
||||
path: {
|
||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||
user_id: userId,
|
||||
},
|
||||
query: {
|
||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||
godfathers_depth: godfathersDepth,
|
||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||
godchildren_depth: godchildrenDepth,
|
||||
},
|
||||
})
|
||||
).data;
|
||||
return [
|
||||
...data.users.map((user) => {
|
||||
return { data: user };
|
||||
}),
|
||||
...data.relationships.map((rel) => {
|
||||
return {
|
||||
data: { source: rel.godfather, target: rel.godchild },
|
||||
};
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function createGraph(container, data, activeUserId) {
|
||||
const cy = cytoscape({
|
||||
boxSelectionEnabled: false,
|
||||
autounselectify: true,
|
||||
|
||||
container,
|
||||
elements: data,
|
||||
minZoom: 0.5,
|
||||
|
||||
style: [
|
||||
// the stylesheet for the graph
|
||||
{
|
||||
selector: "node",
|
||||
style: {
|
||||
label: "data(display_name)",
|
||||
"background-image": "data(profile_pict)",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
"background-fit": "cover",
|
||||
"background-repeat": "no-repeat",
|
||||
shape: "ellipse",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
selector: "edge",
|
||||
style: {
|
||||
width: 5,
|
||||
"line-color": "#ccc",
|
||||
"target-arrow-color": "#ccc",
|
||||
"target-arrow-shape": "triangle",
|
||||
"curve-style": "bezier",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
selector: ".traversed",
|
||||
style: {
|
||||
"border-width": "5px",
|
||||
"border-style": "solid",
|
||||
"border-color": "red",
|
||||
"target-arrow-color": "red",
|
||||
"line-color": "red",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
selector: ".not-traversed",
|
||||
style: {
|
||||
"line-opacity": "0.5",
|
||||
"background-opacity": "0.5",
|
||||
"background-image-opacity": "0.5",
|
||||
},
|
||||
},
|
||||
],
|
||||
layout: {
|
||||
name: "klay",
|
||||
nodeDimensionsIncludeLabels: true,
|
||||
fit: true,
|
||||
klay: {
|
||||
addUnnecessaryBendpoints: true,
|
||||
direction: "DOWN",
|
||||
nodePlacement: "INTERACTIVE",
|
||||
layoutHierarchy: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const activeUser = cy.getElementById(activeUserId).style("shape", "rectangle");
|
||||
/* Reset graph */
|
||||
const resetGraph = () => {
|
||||
cy.elements((element) => {
|
||||
if (element.hasClass("traversed")) {
|
||||
element.removeClass("traversed");
|
||||
}
|
||||
if (element.hasClass("not-traversed")) {
|
||||
element.removeClass("not-traversed");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onNodeTap = (el) => {
|
||||
resetGraph();
|
||||
/* Create path on graph if selected isn't the targeted user */
|
||||
if (el === activeUser) {
|
||||
return;
|
||||
}
|
||||
cy.elements((element) => {
|
||||
element.addClass("not-traversed");
|
||||
});
|
||||
|
||||
for (const traversed of cy.elements().aStar({
|
||||
root: el,
|
||||
goal: activeUser,
|
||||
}).path) {
|
||||
traversed.removeClass("not-traversed");
|
||||
traversed.addClass("traversed");
|
||||
}
|
||||
};
|
||||
|
||||
cy.on("tap", "node", (tapped) => {
|
||||
onNodeTap(tapped.target);
|
||||
});
|
||||
cy.zoomingEnabled(false);
|
||||
|
||||
/* Add context menu */
|
||||
cy.cxtmenu({
|
||||
selector: "node",
|
||||
|
||||
commands: [
|
||||
{
|
||||
content: '<i class="fa fa-external-link fa-2x"></i>',
|
||||
select: (el) => {
|
||||
window.open(el.data().profile_url, "_blank").focus();
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
content: '<span class="fa fa-mouse-pointer fa-2x"></span>',
|
||||
select: (el) => {
|
||||
onNodeTap(el);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
content: '<i class="fa fa-eraser fa-2x"></i>',
|
||||
select: (_) => {
|
||||
resetGraph();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return cy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef FamilyGraphConfig
|
||||
* @property {number} activeUser Id of the user to fetch the tree from
|
||||
* @property {number} depthMin Minimum tree depth for godfathers and godchildren
|
||||
* @property {number} depthMax Maximum tree depth for godfathers and godchildren
|
||||
**/
|
||||
|
||||
/**
|
||||
* Create a family graph of an user
|
||||
* @param {FamilyGraphConfig} config
|
||||
**/
|
||||
window.loadFamilyGraph = (config) => {
|
||||
document.addEventListener("alpine:init", () => {
|
||||
const defaultDepth = 2;
|
||||
|
||||
function getInitialDepth(prop) {
|
||||
const value = Number.parseInt(initialUrlParams.get(prop));
|
||||
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
|
||||
return defaultDepth;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
Alpine.data("graph", () => ({
|
||||
loading: false,
|
||||
godfathersDepth: getInitialDepth("godfathersDepth"),
|
||||
godchildrenDepth: getInitialDepth("godchildrenDepth"),
|
||||
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
|
||||
graph: undefined,
|
||||
graphData: {},
|
||||
|
||||
async init() {
|
||||
const delayedFetch = Alpine.debounce(async () => {
|
||||
await this.fetchGraphData();
|
||||
}, 100);
|
||||
for (const param of ["godfathersDepth", "godchildrenDepth"]) {
|
||||
this.$watch(param, async (value) => {
|
||||
if (value < config.depthMin || value > config.depthMax) {
|
||||
return;
|
||||
}
|
||||
updateQueryString(param, value, History.Replace);
|
||||
await delayedFetch();
|
||||
});
|
||||
}
|
||||
this.$watch("reverse", async (value) => {
|
||||
updateQueryString("reverse", value, History.Replace);
|
||||
await this.reverseGraph();
|
||||
});
|
||||
this.$watch("graphData", async () => {
|
||||
this.generateGraph();
|
||||
if (this.reverse) {
|
||||
await this.reverseGraph();
|
||||
}
|
||||
});
|
||||
await this.fetchGraphData();
|
||||
},
|
||||
|
||||
screenshot() {
|
||||
const link = document.createElement("a");
|
||||
link.href = this.graph.jpg();
|
||||
link.download = interpolate(
|
||||
gettext("family_tree.%(extension)s"),
|
||||
{ extension: "jpg" },
|
||||
true,
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.reverse = false;
|
||||
this.godfathersDepth = defaultDepth;
|
||||
this.godchildrenDepth = defaultDepth;
|
||||
},
|
||||
|
||||
async reverseGraph() {
|
||||
this.graph.elements((el) => {
|
||||
el.position({ x: -el.position().x, y: -el.position().y });
|
||||
});
|
||||
this.graph.center(this.graph.elements());
|
||||
},
|
||||
|
||||
async fetchGraphData() {
|
||||
this.graphData = await getGraphData(
|
||||
config.activeUser,
|
||||
this.godfathersDepth,
|
||||
this.godchildrenDepth,
|
||||
);
|
||||
},
|
||||
|
||||
generateGraph() {
|
||||
this.loading = true;
|
||||
this.graph = createGraph(
|
||||
$(this.$refs.graph),
|
||||
this.graphData,
|
||||
config.activeUser,
|
||||
);
|
||||
this.loading = false;
|
||||
},
|
||||
}));
|
||||
});
|
||||
};
|
287
core/static/bundled/user/family-graph-index.ts
Normal file
287
core/static/bundled/user/family-graph-index.ts
Normal file
@ -0,0 +1,287 @@
|
||||
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
|
||||
import cytoscape, {
|
||||
type ElementDefinition,
|
||||
type NodeSingular,
|
||||
type Singular,
|
||||
} from "cytoscape";
|
||||
import cxtmenu from "cytoscape-cxtmenu";
|
||||
import klay, { type KlayLayoutOptions } from "cytoscape-klay";
|
||||
import { type UserProfileSchema, familyGetFamilyGraph } from "#openapi";
|
||||
|
||||
cytoscape.use(klay);
|
||||
cytoscape.use(cxtmenu);
|
||||
|
||||
type GraphData = (
|
||||
| { data: UserProfileSchema }
|
||||
| { data: { source: number; target: number } }
|
||||
)[];
|
||||
|
||||
function isMobile() {
|
||||
return window.innerWidth < 500;
|
||||
}
|
||||
|
||||
async function getGraphData(
|
||||
userId: number,
|
||||
godfathersDepth: number,
|
||||
godchildrenDepth: number,
|
||||
): Promise<GraphData> {
|
||||
const data = (
|
||||
await familyGetFamilyGraph({
|
||||
path: {
|
||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||
user_id: userId,
|
||||
},
|
||||
query: {
|
||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||
godfathers_depth: godfathersDepth,
|
||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||
godchildren_depth: godchildrenDepth,
|
||||
},
|
||||
})
|
||||
).data;
|
||||
return [
|
||||
...data.users.map((user) => {
|
||||
return { data: user };
|
||||
}),
|
||||
...data.relationships.map((rel) => {
|
||||
return {
|
||||
data: { source: rel.godfather, target: rel.godchild },
|
||||
};
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function createGraph(container: HTMLDivElement, data: GraphData, activeUserId: number) {
|
||||
const cy = cytoscape({
|
||||
boxSelectionEnabled: false,
|
||||
autounselectify: true,
|
||||
|
||||
container,
|
||||
elements: data as ElementDefinition[],
|
||||
minZoom: 0.5,
|
||||
|
||||
style: [
|
||||
// the stylesheet for the graph
|
||||
{
|
||||
selector: "node",
|
||||
style: {
|
||||
label: "data(display_name)",
|
||||
"background-image": "data(profile_pict)",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
"background-fit": "cover",
|
||||
"background-repeat": "no-repeat",
|
||||
shape: "ellipse",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
selector: "edge",
|
||||
style: {
|
||||
width: 5,
|
||||
"line-color": "#ccc",
|
||||
"target-arrow-color": "#ccc",
|
||||
"target-arrow-shape": "triangle",
|
||||
"curve-style": "bezier",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
selector: ".traversed",
|
||||
style: {
|
||||
"border-width": "5px",
|
||||
"border-style": "solid",
|
||||
"border-color": "red",
|
||||
"target-arrow-color": "red",
|
||||
"line-color": "red",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
selector: ".not-traversed",
|
||||
style: {
|
||||
"line-opacity": 0.5,
|
||||
"background-opacity": 0.5,
|
||||
"background-image-opacity": 0.5,
|
||||
},
|
||||
},
|
||||
],
|
||||
layout: {
|
||||
name: "klay",
|
||||
nodeDimensionsIncludeLabels: true,
|
||||
fit: true,
|
||||
klay: {
|
||||
addUnnecessaryBendpoints: true,
|
||||
direction: "DOWN",
|
||||
nodePlacement: "INTERACTIVE",
|
||||
layoutHierarchy: true,
|
||||
},
|
||||
} as KlayLayoutOptions,
|
||||
});
|
||||
const activeUser = cy
|
||||
.getElementById(activeUserId.toString())
|
||||
.style("shape", "rectangle");
|
||||
/* Reset graph */
|
||||
const resetGraph = () => {
|
||||
cy.elements().removeClass("traversed not-traversed");
|
||||
};
|
||||
|
||||
const onNodeTap = (el: Singular) => {
|
||||
resetGraph();
|
||||
/* Create path on graph if selected isn't the targeted user */
|
||||
if (el === activeUser) {
|
||||
return;
|
||||
}
|
||||
cy.elements().addClass("not-traversed");
|
||||
|
||||
for (const traversed of cy.elements().aStar({
|
||||
root: el,
|
||||
goal: activeUser,
|
||||
}).path) {
|
||||
traversed.removeClass("not-traversed");
|
||||
traversed.addClass("traversed");
|
||||
}
|
||||
};
|
||||
|
||||
cy.on("tap", "node", (tapped) => {
|
||||
onNodeTap(tapped.target);
|
||||
});
|
||||
|
||||
/* Add context menu */
|
||||
cy.cxtmenu({
|
||||
selector: "node",
|
||||
|
||||
commands: [
|
||||
{
|
||||
content: '<i class="fa fa-external-link fa-2x"></i>',
|
||||
select: (el) => {
|
||||
window.open(el.data().profile_url, "_blank").focus();
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
content: '<span class="fa fa-mouse-pointer fa-2x"></span>',
|
||||
select: (el) => {
|
||||
onNodeTap(el);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
content: '<i class="fa fa-eraser fa-2x"></i>',
|
||||
select: (_) => {
|
||||
resetGraph();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return cy;
|
||||
}
|
||||
|
||||
interface FamilyGraphConfig {
|
||||
/**Id of the user to fetch the tree from*/
|
||||
activeUser: number;
|
||||
/**Minimum tree depth for godfathers and godchildren*/
|
||||
depthMin: number;
|
||||
/**Maximum tree depth for godfathers and godchildren*/
|
||||
depthMax: number;
|
||||
}
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
const defaultDepth = 2;
|
||||
|
||||
Alpine.data("graph", (config: FamilyGraphConfig) => ({
|
||||
loading: false,
|
||||
godfathersDepth: 0,
|
||||
godchildrenDepth: 0,
|
||||
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
|
||||
graph: undefined as cytoscape.Core,
|
||||
graphData: {},
|
||||
isZoomEnabled: !isMobile(),
|
||||
|
||||
getInitialDepth(prop: string) {
|
||||
const value = Number.parseInt(initialUrlParams.get(prop));
|
||||
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
|
||||
return defaultDepth;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
|
||||
async init() {
|
||||
this.godfathersDepth = this.getInitialDepth("godfathersDepth");
|
||||
this.godchildrenDepth = this.getInitialDepth("godchildrenDepth");
|
||||
|
||||
const delayedFetch = Alpine.debounce(async () => {
|
||||
await this.fetchGraphData();
|
||||
}, 100);
|
||||
for (const param of ["godfathersDepth", "godchildrenDepth"]) {
|
||||
this.$watch(param, async (value: number) => {
|
||||
if (value < config.depthMin || value > config.depthMax) {
|
||||
return;
|
||||
}
|
||||
updateQueryString(param, value.toString(), History.Replace);
|
||||
await delayedFetch();
|
||||
});
|
||||
}
|
||||
this.$watch("reverse", async (value: number) => {
|
||||
updateQueryString("reverse", value.toString(), History.Replace);
|
||||
await this.reverseGraph();
|
||||
});
|
||||
this.$watch("graphData", async () => {
|
||||
this.generateGraph();
|
||||
if (this.reverse) {
|
||||
await this.reverseGraph();
|
||||
}
|
||||
});
|
||||
this.$watch("isZoomEnabled", () => {
|
||||
this.graph.userZoomingEnabled(this.isZoomEnabled);
|
||||
});
|
||||
await this.fetchGraphData();
|
||||
},
|
||||
|
||||
screenshot() {
|
||||
const link = document.createElement("a");
|
||||
link.href = this.graph.jpg();
|
||||
link.download = interpolate(
|
||||
gettext("family_tree.%(extension)s"),
|
||||
{ extension: "jpg" },
|
||||
true,
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.reverse = false;
|
||||
this.godfathersDepth = defaultDepth;
|
||||
this.godchildrenDepth = defaultDepth;
|
||||
},
|
||||
|
||||
async reverseGraph() {
|
||||
this.graph.elements((el: NodeSingular) => {
|
||||
el.position({ x: -el.position().x, y: -el.position().y });
|
||||
});
|
||||
this.graph.center(this.graph.elements());
|
||||
},
|
||||
|
||||
async fetchGraphData() {
|
||||
this.graphData = await getGraphData(
|
||||
config.activeUser,
|
||||
this.godfathersDepth,
|
||||
this.godchildrenDepth,
|
||||
);
|
||||
},
|
||||
|
||||
generateGraph() {
|
||||
this.loading = true;
|
||||
this.graph = createGraph(
|
||||
this.$refs.graph as HTMLDivElement,
|
||||
this.graphData,
|
||||
config.activeUser,
|
||||
);
|
||||
this.graph.userZoomingEnabled(this.isZoomEnabled);
|
||||
this.loading = false;
|
||||
},
|
||||
}));
|
||||
});
|
38
core/static/bundled/utils/alert-message.ts
Normal file
38
core/static/bundled/utils/alert-message.ts
Normal file
@ -0,0 +1,38 @@
|
||||
interface AlertParams {
|
||||
success?: boolean;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export class AlertMessage {
|
||||
public open: boolean;
|
||||
public success: boolean;
|
||||
public content: string;
|
||||
private timeoutId?: number;
|
||||
private readonly defaultDuration: number;
|
||||
|
||||
constructor(params?: { defaultDuration: number }) {
|
||||
this.open = false;
|
||||
this.content = "";
|
||||
this.timeoutId = null;
|
||||
this.defaultDuration = params?.defaultDuration ?? 2000;
|
||||
}
|
||||
|
||||
public display(message: string, params: AlertParams) {
|
||||
this.clear();
|
||||
this.open = true;
|
||||
this.content = message;
|
||||
this.success = params.success ?? true;
|
||||
this.timeoutId = setTimeout(() => {
|
||||
this.open = false;
|
||||
this.timeoutId = null;
|
||||
}, params.duration ?? this.defaultDuration);
|
||||
}
|
||||
|
||||
public clear() {
|
||||
if (this.timeoutId !== null) {
|
||||
clearTimeout(this.timeoutId);
|
||||
this.timeoutId = null;
|
||||
}
|
||||
this.open = false;
|
||||
}
|
||||
}
|
@ -16,14 +16,74 @@
|
||||
--event-details-padding: 20px;
|
||||
--event-details-border: 1px solid #EEEEEE;
|
||||
--event-details-border-radius: 4px;
|
||||
--event-details-box-shadow: 0px 6px 20px 4px rgb(0 0 0 / 16%);
|
||||
--event-details-box-shadow: 0 6px 20px 4px rgb(0 0 0 / 16%);
|
||||
--event-details-max-width: 600px;
|
||||
}
|
||||
|
||||
ics-calendar {
|
||||
ics-calendar,
|
||||
room-scheduler {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
|
||||
a.fc-col-header-cell-cushion,
|
||||
a.fc-col-header-cell-cushion:hover {
|
||||
color: black;
|
||||
}
|
||||
|
||||
a.fc-daygrid-day-number,
|
||||
a.fc-daygrid-day-number:hover {
|
||||
color: rgb(34, 34, 34);
|
||||
}
|
||||
|
||||
td {
|
||||
overflow: visible; // Show events on multiple days
|
||||
}
|
||||
|
||||
td, th {
|
||||
text-align: unset;
|
||||
}
|
||||
|
||||
//Reset from style.scss
|
||||
table {
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
-moz-border-radius: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Reset from style.scss
|
||||
thead {
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
// Reset from style.scss
|
||||
tbody > tr {
|
||||
&:nth-child(even):not(.highlight) {
|
||||
background: white;
|
||||
}
|
||||
}
|
||||
|
||||
.fc .fc-toolbar.fc-footer-toolbar {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
button.text-copy,
|
||||
button.text-copy:focus,
|
||||
button.text-copy:hover {
|
||||
background-color: #67AE6E !important;
|
||||
transition: 500ms ease-in;
|
||||
}
|
||||
|
||||
button.text-copied,
|
||||
button.text-copied:focus,
|
||||
button.text-copied:hover {
|
||||
transition: 500ms ease-out;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
ics-calendar {
|
||||
#event-details {
|
||||
z-index: 10;
|
||||
max-width: 1151px;
|
||||
@ -60,31 +120,10 @@ ics-calendar {
|
||||
align-items: start;
|
||||
flex-direction: row;
|
||||
background-color: var(--event-details-background-color);
|
||||
margin-top: 0px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
a.fc-col-header-cell-cushion,
|
||||
a.fc-col-header-cell-cushion:hover {
|
||||
color: black;
|
||||
}
|
||||
|
||||
a.fc-daygrid-day-number,
|
||||
a.fc-daygrid-day-number:hover {
|
||||
color: rgb(34, 34, 34);
|
||||
}
|
||||
|
||||
td {
|
||||
overflow: visible; // Show events on multiple days
|
||||
}
|
||||
|
||||
//Reset from style.scss
|
||||
table {
|
||||
box-shadow: none;
|
||||
border-radius: 0px;
|
||||
-moz-border-radius: 0px;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
// Reset from style.scss
|
||||
@ -136,7 +175,6 @@ ics-calendar {
|
||||
.fc .fc-helpButton-button:hover {
|
||||
background-color: rgba(20, 20, 20, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip.calendar-copy-tooltip {
|
||||
opacity: 1;
|
@ -16,6 +16,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.card-group {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: $primary-neutral-light-color;
|
||||
border-radius: 5px;
|
||||
@ -92,13 +99,23 @@
|
||||
}
|
||||
|
||||
@media screen and (max-width: 765px) {
|
||||
@include row-layout
|
||||
@include row-layout;
|
||||
}
|
||||
|
||||
// When combined with card, card-row display the card in a row layout,
|
||||
// whatever the size of the screen.
|
||||
&.card-row {
|
||||
@include row-layout
|
||||
@include row-layout;
|
||||
|
||||
&.card-row-m {
|
||||
//width: 50%;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
&.card-row-s {
|
||||
//width: 33%;
|
||||
max-width: 33%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
53
core/static/core/components/tabs.scss
Normal file
53
core/static/core/components/tabs.scss
Normal file
@ -0,0 +1,53 @@
|
||||
@import "core/static/core/colors";
|
||||
|
||||
ui-tab-group {
|
||||
*[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
border-radius: 5px;
|
||||
|
||||
.tab-headers {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
background-color: $primary-neutral-light-color;
|
||||
padding: 3px 12px 12px;
|
||||
column-gap: 20px;
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
|
||||
.tab-header {
|
||||
border: none;
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
font-size: 120%;
|
||||
background-color: unset;
|
||||
position: relative;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
border-bottom: 4px solid darken($primary-neutral-light-color, 10%);
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&:hover:after {
|
||||
border-bottom-color: darken($primary-neutral-light-color, 20%);
|
||||
}
|
||||
|
||||
&.active:after {
|
||||
border-bottom-color: $primary-dark-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
}
|
89
core/static/core/footer.scss
Normal file
89
core/static/core/footer.scss
Normal file
@ -0,0 +1,89 @@
|
||||
@import "colors";
|
||||
@import "devices";
|
||||
|
||||
footer.bottom-links {
|
||||
@media (max-width: $small-devices) {
|
||||
margin-top: 0.6em;
|
||||
padding: 1.25em;
|
||||
background-color: $primary-neutral-dark-color;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
gap: 1.25em;
|
||||
|
||||
>section {
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.8em;
|
||||
|
||||
}
|
||||
|
||||
a {
|
||||
color: $white-color;
|
||||
width: auto;
|
||||
|
||||
&:hover {
|
||||
color: $white-color;
|
||||
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
|
||||
}
|
||||
}
|
||||
|
||||
.fa-github {
|
||||
color: $white-color;
|
||||
}
|
||||
|
||||
hr {
|
||||
width: 100%;
|
||||
height: 0px;
|
||||
border: none;
|
||||
border-top: 0.5px solid $white-color;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: $small-devices) {
|
||||
width: 90%;
|
||||
margin: 2em auto;
|
||||
|
||||
font-size: 90%;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
|
||||
section:first-of-type {
|
||||
margin: 0.6em 0;
|
||||
color: $white-color;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
background-color: $primary-neutral-dark-color;
|
||||
box-shadow: $shadow-color 0 0 15px;
|
||||
|
||||
a {
|
||||
color: $white-color;
|
||||
width: auto;
|
||||
padding: 0.8em;
|
||||
flex: 1;
|
||||
font-weight: bold;
|
||||
|
||||
&:hover {
|
||||
color: $white-color;
|
||||
text-shadow: 0.5px 0.5px 0.5px $shadow-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fa-github {
|
||||
color: $githubblack;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
height: 5px;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,42 +1,4 @@
|
||||
$(() => {
|
||||
// const buttons = $('.choose_file_button')
|
||||
const popups = $(".choose_file_widget");
|
||||
popups.dialog({
|
||||
autoOpen: false,
|
||||
modal: true,
|
||||
width: "90%",
|
||||
create: (event) => {
|
||||
const target = $(event.target);
|
||||
target.parent().css({
|
||||
position: "fixed",
|
||||
top: "5%",
|
||||
bottom: "5%",
|
||||
});
|
||||
target.css("height", "300px");
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
text: "Choose",
|
||||
click: function () {
|
||||
$(`input[name=${$(this).attr("name")}]`).attr(
|
||||
"value",
|
||||
$("#file_id").attr("value"),
|
||||
);
|
||||
$(this).dialog("close");
|
||||
},
|
||||
disabled: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
$(".choose_file_button")
|
||||
.button()
|
||||
.on("click", function () {
|
||||
const popup = popups.filter(`[name=${$(this).attr("name")}]`);
|
||||
popup.html(
|
||||
'<iframe src="/file/popup" width="100%" height="95%"></iframe><div id="file_id" value="null" />',
|
||||
);
|
||||
popup.dialog({ title: $(this).text() }).dialog("open");
|
||||
});
|
||||
$("#quick_notif li").click(function () {
|
||||
$(this).hide();
|
||||
});
|
||||
|
@ -111,12 +111,6 @@ body {
|
||||
|
||||
/*--------------------------------HEADER-------------------------------*/
|
||||
|
||||
#popupheader {
|
||||
width: 88%;
|
||||
margin: 0 auto;
|
||||
padding: 0.3em 1%;
|
||||
}
|
||||
|
||||
#info_boxes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -352,52 +346,6 @@ body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
border-radius: 5px;
|
||||
|
||||
.tab-headers {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
background-color: $primary-neutral-light-color;
|
||||
padding: 3px 12px 12px;
|
||||
column-gap: 20px;
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
|
||||
.tab-header {
|
||||
border: none;
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
font-size: 120%;
|
||||
background-color: unset;
|
||||
position: relative;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
border-bottom: 4px solid darken($primary-neutral-light-color, 10%);
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&:hover:after {
|
||||
border-bottom-color: darken($primary-neutral-light-color, 20%);
|
||||
}
|
||||
|
||||
&.active:after {
|
||||
border-bottom-color: $primary-dark-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.tool_bar {
|
||||
overflow: auto;
|
||||
padding: 4px;
|
||||
@ -765,47 +713,6 @@ textarea {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/*--------------------------------FOOTER-------------------------------*/
|
||||
|
||||
footer {
|
||||
width: 90%;
|
||||
margin: 2em auto;
|
||||
|
||||
font-size: 90%;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
|
||||
div {
|
||||
margin: 0.6em 0;
|
||||
color: $white-color;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
background-color: $primary-neutral-dark-color;
|
||||
box-shadow: $shadow-color 0 0 15px;
|
||||
|
||||
a {
|
||||
padding: 0.8em;
|
||||
flex: 1;
|
||||
font-weight: bold;
|
||||
color: $white-color !important;
|
||||
|
||||
&:hover {
|
||||
color: $primary-dark-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
>.version {
|
||||
margin-top: 3px;
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.fa-github {
|
||||
color: $githubblack;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.ui-dialog .ui-dialog-buttonpane {
|
||||
@ -848,25 +755,6 @@ footer {
|
||||
}
|
||||
|
||||
/*--------------------------------JQuery-------------------------------*/
|
||||
|
||||
.ui-state-active,
|
||||
.ui-widget-content .ui-state-active,
|
||||
.ui-widget-header .ui-state-active,
|
||||
a.ui-button:active,
|
||||
.ui-button:active,
|
||||
.ui-button.ui-state-active:hover {
|
||||
background: $primary-color;
|
||||
border-color: $primary-color;
|
||||
}
|
||||
|
||||
.ui-corner-all,
|
||||
.ui-corner-bottom,
|
||||
.ui-corner-right,
|
||||
.ui-corner-top,
|
||||
.ui-corner-left {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
#club_detail {
|
||||
.club_logo {
|
||||
float: right;
|
||||
|
@ -10,10 +10,9 @@
|
||||
border-radius: 5px;
|
||||
padding: 5px 10px;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transition: opacity 500ms ease-out;
|
||||
|
||||
width: max-content;
|
||||
white-space: normal;
|
||||
|
||||
left: 0;
|
||||
|
@ -4,6 +4,12 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.zoom-control {
|
||||
margin-right: 10px;
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
}
|
||||
|
||||
.graph-toolbar {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
@ -34,30 +40,37 @@
|
||||
|
||||
.depth-choice {
|
||||
white-space: nowrap;
|
||||
|
||||
input[type="number"] {
|
||||
-webkit-appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
|
||||
&::-webkit-inner-spin-button,
|
||||
&::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
background: none;
|
||||
|
||||
&>.fa {
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
&:enabled>.fa {
|
||||
background-color: #354a5f;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&:enabled:hover>.fa {
|
||||
color: white;
|
||||
background-color: #35405f; // just a bit darker
|
||||
}
|
||||
|
||||
&:disabled>.fa {
|
||||
background-color: gray;
|
||||
color: white;
|
||||
@ -74,6 +87,7 @@
|
||||
@media screen and (max-width: 500px) {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
.toolbar-column {
|
||||
min-width: 100%;
|
||||
}
|
||||
@ -91,10 +105,12 @@
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
#family-tree-link {
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
|
||||
@media (min-width: 450px) {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
@ -11,17 +11,15 @@
|
||||
<link rel="stylesheet" href="{{ static('core/markdown.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/header.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/navbar.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/footer.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/pagination.scss') }}">
|
||||
<link rel="stylesheet" href="{{ static('core/accordion.scss') }}">
|
||||
|
||||
{% block jquery_css %}
|
||||
{# Thile file is quite heavy (around 250kb), so declaring it in a block allows easy removal #}
|
||||
<link rel="stylesheet" href="{{ static('bundled/jquery-ui-index.css') }}">
|
||||
{% endblock %}
|
||||
<link rel="preload" as="style" href="{{ static('bundled/fontawesome-index.css') }}" onload="this.onload=null;this.rel='stylesheet'">
|
||||
<noscript><link rel="stylesheet" href="{{ static('bundled/fontawesome-index.css') }}"></noscript>
|
||||
|
||||
<script src="{{ url('javascript-catalog') }}"></script>
|
||||
<script type="module" src={{ static("bundled/core/navbar-index.ts") }}></script>
|
||||
<script type="module" src={{ static("bundled/core/components/include-index.ts") }}></script>
|
||||
<script type="module" src="{{ static('bundled/alpine-index.js') }}"></script>
|
||||
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
|
||||
@ -30,11 +28,8 @@
|
||||
|
||||
<!-- Jquery declared here to be accessible in every django widgets -->
|
||||
<script src="{{ static('bundled/vendored/jquery.min.js') }}"></script>
|
||||
<script src="{{ static('bundled/vendored/jquery-ui.min.js') }}"></script>
|
||||
<script src="{{ static('core/js/script.js') }}"></script>
|
||||
|
||||
|
||||
|
||||
{% block additional_css %}{% endblock %}
|
||||
{% block additional_js %}{% endblock %}
|
||||
{% endblock %}
|
||||
@ -47,7 +42,6 @@
|
||||
{% csrf_token %}
|
||||
|
||||
{% block header %}
|
||||
{% if not popup %}
|
||||
{% include "core/base/header.jinja" %}
|
||||
|
||||
{% block info_boxes %}
|
||||
@ -66,16 +60,10 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% else %}
|
||||
<div id="popupheader">{{ user.get_display_name() }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block nav %}
|
||||
{% if not popup %}
|
||||
{% include "core/base/navbar.jinja" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
<div id="page">
|
||||
@ -102,50 +90,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not popup %}
|
||||
<footer>
|
||||
{% block footer %}
|
||||
<div>
|
||||
<a href="{{ url('core:page', 'contacts') }}">{% trans %}Contacts{% endtrans %}</a>
|
||||
<a href="{{ url('core:page', 'legals') }}">{% trans %}Legal notices{% endtrans %}</a>
|
||||
<a href="{{ url('core:page', 'copyright_agent') }}">{% trans %}Intellectual property{% endtrans %}</a>
|
||||
<a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
|
||||
<a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
|
||||
</div>
|
||||
<a rel="nofollow" href="https://github.com/ae-utbm/sith" target="#">
|
||||
<i class="fa-brands fa-github"></i>
|
||||
{% trans %}Site created by the IT Department of the AE{% endtrans %}
|
||||
</a>
|
||||
{% include "core/base/footer.jinja" %}
|
||||
{% endblock %}
|
||||
<br>
|
||||
</footer>
|
||||
{% endif %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
const menuItems = document.querySelectorAll(".navbar details[name='navbar'].menu");
|
||||
const isMobile = () => {
|
||||
return window.innerWidth >= 500;
|
||||
}
|
||||
for (const item of menuItems){
|
||||
item.addEventListener("mouseover", () => {
|
||||
if (isMobile()){
|
||||
item.setAttribute("open", "");
|
||||
}
|
||||
})
|
||||
item.addEventListener("mouseout", () => {
|
||||
if (isMobile()){
|
||||
item.removeAttribute("open");
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function showMenu() {
|
||||
let navbar = document.getElementById("navbar-content");
|
||||
const current = navbar.getAttribute("mobile-display");
|
||||
navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden")
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
// Looking at the `s` key when not typing in a form
|
||||
if (e.keyCode !== 83 || ["INPUT", "TEXTAREA", "SELECT"].includes(e.target.nodeName)) {
|
||||
|
16
core/templates/core/base/footer.jinja
Normal file
16
core/templates/core/base/footer.jinja
Normal file
@ -0,0 +1,16 @@
|
||||
<footer class="bottom-links">
|
||||
<section>
|
||||
<a href="{{ url('core:page', 'contacts') }}">{% trans %}Contacts{% endtrans %}</a>
|
||||
<a href="{{ url('core:page', 'legals') }}">{% trans %}Legal notices{% endtrans %}</a>
|
||||
<a href="{{ url('core:page', 'copyright_agent') }}">{% trans %}Intellectual property{% endtrans %}</a>
|
||||
<a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
|
||||
<a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
|
||||
</section>
|
||||
<hr>
|
||||
<section>
|
||||
<a rel="nofollow" href="https://github.com/ae-utbm/sith" target="#">
|
||||
<i class="fa-brands fa-github"></i>
|
||||
{% trans %}Site created by the IT Department of the AE{% endtrans %}
|
||||
</a>
|
||||
</section>
|
||||
</footer>
|
@ -19,9 +19,9 @@
|
||||
{% macro print_file_name(file) %}
|
||||
{% if file %}
|
||||
{{ print_file_name(file.parent) }} >
|
||||
<a href="{{ url('core:file_detail', file_id=file.id, popup=popup) }}">{{ file.get_display_name() }}</a>
|
||||
<a href="{{ url('core:file_detail', file_id=file.id) }}">{{ file.get_display_name() }}</a>
|
||||
{% else %}
|
||||
<a href="{{ url('core:file_list', popup) }}">{% trans %}Files{% endtrans %}</a>
|
||||
<a href="{{ url('core:file_list') }}">{% trans %}Files{% endtrans %}</a>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
@ -33,16 +33,16 @@
|
||||
<div>
|
||||
{% set home = user.home %}
|
||||
{% if home %}
|
||||
<a href="{{ url('core:file_detail', home.id, popup) }}">{% trans %}My files{% endtrans %}</a>
|
||||
<a href="{{ url('core:file_detail', home.id) }}">{% trans %}My files{% endtrans %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if file %}
|
||||
<a href="{{ url('core:file_detail', file.id, popup) }}">{% trans %}View{% endtrans %}</a>
|
||||
<a href="{{ url('core:file_detail', file.id) }}">{% trans %}View{% endtrans %}</a>
|
||||
{% if can_edit(file, user) %}
|
||||
<a href="{{ url('core:file_edit', file_id=file.id, popup=popup) }}">{% trans %}Edit{% endtrans %}</a>
|
||||
<a href="{{ url('core:file_edit', file_id=file.id) }}">{% trans %}Edit{% endtrans %}</a>
|
||||
{% endif %}
|
||||
{% if can_edit_prop(file, user) %}
|
||||
<a href="{{ url('core:file_prop', file_id=file.id, popup=popup) }}">{% trans %}Prop{% endtrans %}</a>
|
||||
<a href="{{ url('core:file_prop', file_id=file.id) }}">{% trans %}Prop{% endtrans %}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
@ -45,7 +45,7 @@
|
||||
{% else %}
|
||||
<i class="fa fa-file" aria-hidden="true"></i>
|
||||
{% endif %}
|
||||
<a href="{{ url('core:file_detail', file_id=f.id, popup=popup) }}">{{ f.get_display_name() }}</a></li>
|
||||
<a href="{{ url('core:file_detail', file_id=f.id) }}">{{ f.get_display_name() }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</form>
|
||||
@ -59,22 +59,9 @@
|
||||
<p><a href="{{ url('core:download', file_id=file.id) }}">{% trans %}Download{% endtrans %}</a></p>
|
||||
{% endif %}
|
||||
{% if not file.home_of and not file.home_of_club and file.parent %}
|
||||
<p><a href="{{ url('core:file_delete', file_id=file.id, popup=popup) }}">{% trans %}Delete{% endtrans %}</a></p>
|
||||
<p><a href="{{ url('core:file_delete', file_id=file.id) }}">{% trans %}Delete{% endtrans %}</a></p>
|
||||
{% endif %}
|
||||
{% if user.is_com_admin %}
|
||||
<p><a href="{{ url('core:file_moderate', file_id=file.id) }}">{% trans %}Moderate{% endtrans %}</a></p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
{{ super() }}
|
||||
<script>
|
||||
{% if popup and file.is_file %}
|
||||
parent.$("#file_id").replaceWith('<div id="file_id" value="{{ file.id }}">{{ file.name }}</div>');
|
||||
parent.$(".ui-dialog-buttonpane button").button("option", "disabled", false);
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
|
@ -12,7 +12,7 @@
|
||||
{% else %}
|
||||
<i class="fa fa-file" aria-hidden="true"></i>
|
||||
{% endif %}
|
||||
<a href="{{ url('core:file_detail', file_id=f.id, popup=popup) }}">{{ f.name }}</a></li>
|
||||
<a href="{{ url('core:file_detail', file_id=f.id) }}">{{ f.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
|
@ -26,9 +26,11 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url('core:login') }}">
|
||||
<form method="post" action="{{ url('core:login') }}" id="login-form">
|
||||
{% if form.errors %}
|
||||
<p class="alert alert-red">{% trans %}Your username and password didn't match. Please try again.{% endtrans %}</p>
|
||||
<p class="alert alert-red">
|
||||
{% trans %}Your credentials didn't match. Please try again.{% endtrans %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% csrf_token %}
|
||||
|
@ -245,65 +245,3 @@
|
||||
<button type="button" onclick="checkbox_{{form_id}}(true);">{% trans %}Select All{% endtrans %}</button>
|
||||
<button type="button" onclick="checkbox_{{form_id}}(false);">{% trans %}Unselect All{% endtrans %}</button>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro tabs(tab_list, attrs = "") %}
|
||||
{# Tab component
|
||||
|
||||
Parameters:
|
||||
tab_list: list[tuple[str, str]] The list of tabs to display.
|
||||
Each element of the list is a tuple which first element
|
||||
is the title of the tab and the second element its content
|
||||
attrs: str Additional attributes to put on the enclosing div
|
||||
|
||||
Example:
|
||||
A basic usage would be as follow :
|
||||
|
||||
{{ tabs([("title 1", "content 1"), ("title 2", "content 2")]) }}
|
||||
|
||||
If you want to display more complex logic, you can define macros
|
||||
and use those macros in parameters :
|
||||
|
||||
{{ tabs([("title", my_macro())]) }}
|
||||
|
||||
It's also possible to get and set the currently selected tab using Alpine.
|
||||
Here, the title of the currently selected tab will be displayed.
|
||||
Moreover, on page load, the tab will be opened on "tab 2".
|
||||
|
||||
<div x-data="{current_tab: 'tab 2'}">
|
||||
<p x-text="current_tab"></p>
|
||||
{{ tabs([("tab 1", "Hello"), ("tab 2", "World")], "x-model=current_tab") }}
|
||||
</div>
|
||||
|
||||
If you want to have translated tab titles, you can enclose the macro call
|
||||
in a with block :
|
||||
|
||||
{% with title=_("title"), content=_("Content") %}
|
||||
{{ tabs([(tab1, content)]) }}
|
||||
{% endwith %}
|
||||
#}
|
||||
<div
|
||||
class="tabs shadow"
|
||||
x-data="{selected: '{{ tab_list[0][0] }}'}"
|
||||
x-modelable="selected"
|
||||
{{ attrs }}
|
||||
>
|
||||
<div class="tab-headers">
|
||||
{% for title, _ in tab_list %}
|
||||
<button
|
||||
class="tab-header clickable"
|
||||
:class="{active: selected === '{{ title }}'}"
|
||||
@click="selected = '{{ title }}'"
|
||||
>
|
||||
{{ title }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
{% for title, content in tab_list %}
|
||||
<section x-show="selected === '{{ title }}'">
|
||||
{{ content }}
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
@ -74,7 +74,7 @@
|
||||
{%- if this_picture -%}
|
||||
{% set default_picture = this_picture.get_download_url()|tojson %}
|
||||
{% set delete_url = (
|
||||
url('core:file_delete', file_id=this_picture.id, popup='')
|
||||
url('core:file_delete', file_id=this_picture.id)
|
||||
+ "?next=" + url('core:user_edit', user_id=profile.id)
|
||||
)|tojson %}
|
||||
{%- else -%}
|
||||
|
@ -7,7 +7,7 @@
|
||||
{%- endblock -%}
|
||||
|
||||
{% block additional_js %}
|
||||
<script type="module" src="{{ static("bundled/user/family-graph-index.js") }}"></script>
|
||||
<script type="module" src="{{ static("bundled/user/family-graph-index.ts") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}
|
||||
@ -15,7 +15,14 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="graph" :aria-busy="loading">
|
||||
<div
|
||||
x-data="graph({
|
||||
activeUser: {{ object.id }},
|
||||
depthMin: {{ depth_min }},
|
||||
depthMax: {{ depth_max }},
|
||||
})"
|
||||
:aria-busy="loading"
|
||||
>
|
||||
<div class="graph-toolbar">
|
||||
<div class="toolbar-column">
|
||||
<div class="toolbar-input">
|
||||
@ -86,17 +93,36 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zoom-control" x-ref="zoomControl">
|
||||
<button
|
||||
@click="graph.zoom(graph.zoom() + 1)"
|
||||
:disabled="!isZoomEnabled"
|
||||
>
|
||||
<i class="fa-solid fa-magnifying-glass-plus"></i>
|
||||
</button>
|
||||
<button
|
||||
@click="graph.zoom(graph.zoom() - 1)"
|
||||
:disabled="!isZoomEnabled"
|
||||
>
|
||||
<i class="fa-solid fa-magnifying-glass-minus"></i>
|
||||
</button>
|
||||
<button
|
||||
x-show="isZoomEnabled"
|
||||
@click="isZoomEnabled = false"
|
||||
>
|
||||
<i class="fa-solid fa-unlock"></i>
|
||||
</button>
|
||||
<button
|
||||
x-show="!isZoomEnabled"
|
||||
@click="isZoomEnabled = true"
|
||||
>
|
||||
<i class="fa-solid fa-lock"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-ref="graph" class="graph"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
loadFamilyGraph({
|
||||
activeUser: {{ object.id }},
|
||||
depthMin: {{ depth_min }},
|
||||
depthMax: {{ depth_max }},
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
@ -38,6 +38,7 @@ from core.markdown import markdown
|
||||
from core.models import AnonymousUser, Group, Page, User
|
||||
from core.utils import get_semester_code, get_start_of_semester
|
||||
from core.views import AllowFragment
|
||||
from counter.models import Customer
|
||||
from sith import settings
|
||||
|
||||
|
||||
@ -151,24 +152,44 @@ class TestUserLogin:
|
||||
def user(self) -> User:
|
||||
return baker.make(User, password=make_password("plop"))
|
||||
|
||||
def test_login_fail(self, client, user):
|
||||
@pytest.mark.parametrize(
|
||||
"identifier_getter",
|
||||
[
|
||||
lambda user: user.username,
|
||||
lambda user: user.email,
|
||||
lambda user: Customer.get_or_create(user)[0].account_id,
|
||||
],
|
||||
)
|
||||
def test_login_fail(self, client, user, identifier_getter):
|
||||
"""Should not login a user correctly."""
|
||||
identifier = identifier_getter(user)
|
||||
response = client.post(
|
||||
reverse("core:login"),
|
||||
{"username": user.username, "password": "wrong-password"},
|
||||
{"username": identifier, "password": "wrong-password"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert (
|
||||
'<p class="alert alert-red">Votre nom d\'utilisateur '
|
||||
"et votre mot de passe ne correspondent pas. Merci de réessayer.</p>"
|
||||
) in response.text
|
||||
assert response.wsgi_request.user.is_anonymous
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
form = soup.find(id="login-form")
|
||||
assert (
|
||||
form.find(class_="alert alert-red").get_text(strip=True)
|
||||
== "Vos identifiants ne correspondent pas. Veuillez réessayer."
|
||||
)
|
||||
assert form.find("input", attrs={"name": "username"}).get("value") == identifier
|
||||
|
||||
def test_login_success(self, client, user):
|
||||
@pytest.mark.parametrize(
|
||||
"identifier_getter",
|
||||
[
|
||||
lambda user: user.username,
|
||||
lambda user: user.email,
|
||||
lambda user: Customer.get_or_create(user)[0].account_id,
|
||||
],
|
||||
)
|
||||
def test_login_success(self, client, user, identifier_getter):
|
||||
"""Should login a user correctly."""
|
||||
response = client.post(
|
||||
reverse("core:login"),
|
||||
{"username": user.username, "password": "plop"},
|
||||
{"username": identifier_getter(user), "password": "plop"},
|
||||
)
|
||||
assertRedirects(response, reverse("core:index"))
|
||||
assert response.wsgi_request.user == user
|
||||
@ -361,17 +382,9 @@ class TestUserIsInGroup(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.root_group = Group.objects.get(name="Root")
|
||||
cls.public_group = Group.objects.get(name="Public")
|
||||
cls.public_group = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)
|
||||
cls.public_user = baker.make(User)
|
||||
cls.subscribers = Group.objects.get(name="Subscribers")
|
||||
cls.old_subscribers = Group.objects.get(name="Old subscribers")
|
||||
cls.accounting_admin = Group.objects.get(name="Accounting admin")
|
||||
cls.com_admin = Group.objects.get(name="Communication admin")
|
||||
cls.counter_admin = Group.objects.get(name="Counter admin")
|
||||
cls.sas_admin = Group.objects.get(name="SAS admin")
|
||||
cls.club = baker.make(Club)
|
||||
cls.main_club = Club.objects.get(id=1)
|
||||
|
||||
def assert_in_public_group(self, user):
|
||||
assert user.is_in_group(pk=self.public_group.id)
|
||||
@ -379,15 +392,7 @@ class TestUserIsInGroup(TestCase):
|
||||
|
||||
def assert_only_in_public_group(self, user):
|
||||
self.assert_in_public_group(user)
|
||||
for group in (
|
||||
self.root_group,
|
||||
self.accounting_admin,
|
||||
self.sas_admin,
|
||||
self.subscribers,
|
||||
self.old_subscribers,
|
||||
self.club.members_group,
|
||||
self.club.board_group,
|
||||
):
|
||||
for group in Group.objects.exclude(id=self.public_group.id):
|
||||
assert not user.is_in_group(pk=group.pk)
|
||||
assert not user.is_in_group(name=group.name)
|
||||
|
||||
|
@ -146,7 +146,7 @@ class TestUserProfilePicture:
|
||||
return client.post(
|
||||
reverse(
|
||||
"core:file_delete",
|
||||
kwargs={"file_id": user.profile_pict.pk, "popup": ""},
|
||||
kwargs={"file_id": user.profile_pict.pk},
|
||||
query={"next": user.get_absolute_url()},
|
||||
),
|
||||
)
|
||||
|
10
core/urls.py
10
core/urls.py
@ -193,24 +193,24 @@ urlpatterns = [
|
||||
name="user_gift_delete",
|
||||
),
|
||||
# File views
|
||||
re_path(r"^file/(?P<popup>popup)?$", FileListView.as_view(), name="file_list"),
|
||||
re_path(r"^file/$", FileListView.as_view(), name="file_list"),
|
||||
re_path(
|
||||
r"^file/(?P<file_id>[0-9]+)/(?P<popup>popup)?$",
|
||||
r"^file/(?P<file_id>[0-9]+)/$",
|
||||
FileView.as_view(),
|
||||
name="file_detail",
|
||||
),
|
||||
re_path(
|
||||
r"^file/(?P<file_id>[0-9]+)/edit/(?P<popup>popup)?$",
|
||||
r"^file/(?P<file_id>[0-9]+)/edit/$",
|
||||
FileEditView.as_view(),
|
||||
name="file_edit",
|
||||
),
|
||||
re_path(
|
||||
r"^file/(?P<file_id>[0-9]+)/prop/(?P<popup>popup)?$",
|
||||
r"^file/(?P<file_id>[0-9]+)/prop/$",
|
||||
FileEditPropView.as_view(),
|
||||
name="file_prop",
|
||||
),
|
||||
re_path(
|
||||
r"^file/(?P<file_id>[0-9]+)/delete/(?P<popup>popup)?$",
|
||||
r"^file/(?P<file_id>[0-9]+)/delete/$",
|
||||
FileDeleteView.as_view(),
|
||||
name="file_delete",
|
||||
),
|
||||
|
@ -37,8 +37,6 @@ from core.views.forms import LoginForm
|
||||
|
||||
def forbidden(request, exception):
|
||||
context = {"next": request.path, "form": LoginForm()}
|
||||
if popup := request.resolver_match.kwargs.get("popup"):
|
||||
context["popup"] = popup
|
||||
return HttpResponseForbidden(render(request, "core/403.jinja", context=context))
|
||||
|
||||
|
||||
|
@ -198,9 +198,6 @@ class FileListView(ListView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["popup"] = ""
|
||||
if self.kwargs.get("popup") is not None:
|
||||
kwargs["popup"] = "popup"
|
||||
return kwargs
|
||||
|
||||
|
||||
@ -217,20 +214,7 @@ class FileEditView(CanEditMixin, UpdateView):
|
||||
return modelform_factory(SithFile, fields=fields)
|
||||
|
||||
def get_success_url(self):
|
||||
if self.kwargs.get("popup") is not None:
|
||||
return reverse(
|
||||
"core:file_detail", kwargs={"file_id": self.object.id, "popup": "popup"}
|
||||
)
|
||||
return reverse(
|
||||
"core:file_detail", kwargs={"file_id": self.object.id, "popup": ""}
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["popup"] = ""
|
||||
if self.kwargs.get("popup") is not None:
|
||||
kwargs["popup"] = "popup"
|
||||
return kwargs
|
||||
return reverse("core:file_detail", kwargs={"file_id": self.object.id})
|
||||
|
||||
|
||||
class FileEditPropForm(forms.ModelForm):
|
||||
@ -268,16 +252,9 @@ class FileEditPropView(CanEditPropMixin, UpdateView):
|
||||
def get_success_url(self):
|
||||
return reverse(
|
||||
"core:file_detail",
|
||||
kwargs={"file_id": self.object.id, "popup": self.kwargs.get("popup", "")},
|
||||
kwargs={"file_id": self.object.id},
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["popup"] = ""
|
||||
if self.kwargs.get("popup") is not None:
|
||||
kwargs["popup"] = "popup"
|
||||
return kwargs
|
||||
|
||||
|
||||
class FileView(CanViewMixin, DetailView, FormMixin):
|
||||
"""Handle the upload of new files into a folder."""
|
||||
@ -353,15 +330,12 @@ class FileView(CanViewMixin, DetailView, FormMixin):
|
||||
def get_success_url(self):
|
||||
return reverse(
|
||||
"core:file_detail",
|
||||
kwargs={"file_id": self.object.id, "popup": self.kwargs.get("popup", "")},
|
||||
kwargs={"file_id": self.object.id},
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["popup"] = ""
|
||||
kwargs["form"] = self.form
|
||||
if self.kwargs.get("popup") is not None:
|
||||
kwargs["popup"] = "popup"
|
||||
kwargs["clipboard"] = SithFile.objects.filter(
|
||||
id__in=self.request.session["clipboard"]
|
||||
)
|
||||
@ -380,19 +354,17 @@ class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
|
||||
return self.request.GET["next"]
|
||||
if self.object.parent is None:
|
||||
return reverse(
|
||||
"core:file_list", kwargs={"popup": self.kwargs.get("popup", "")}
|
||||
"core:file_list",
|
||||
)
|
||||
return reverse(
|
||||
"core:file_detail",
|
||||
kwargs={
|
||||
"file_id": self.object.parent.id,
|
||||
"popup": self.kwargs.get("popup", ""),
|
||||
},
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
kwargs = super().get_context_data(**kwargs)
|
||||
kwargs["popup"] = "" if self.kwargs.get("popup") is None else "popup"
|
||||
kwargs["next"] = self.request.GET.get("next", None)
|
||||
kwargs["previous"] = self.request.GET.get("previous", None)
|
||||
kwargs["current"] = self.request.path
|
||||
|
@ -39,9 +39,8 @@ from django.forms import (
|
||||
DateInput,
|
||||
DateTimeInput,
|
||||
TextInput,
|
||||
Widget,
|
||||
)
|
||||
from django.utils.timezone import now
|
||||
from django.utils.timezone import localtime, now
|
||||
from django.utils.translation import gettext
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||
@ -86,30 +85,6 @@ class NFCTextInput(TextInput):
|
||||
return context
|
||||
|
||||
|
||||
class SelectFile(TextInput):
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
if attrs:
|
||||
attrs["class"] = "select_file"
|
||||
else:
|
||||
attrs = {"class": "select_file"}
|
||||
output = (
|
||||
'%(content)s<div name="%(name)s" class="choose_file_widget" title="%(title)s"></div>'
|
||||
% {
|
||||
"content": super().render(name, value, attrs, renderer),
|
||||
"title": _("Choose file"),
|
||||
"name": name,
|
||||
}
|
||||
)
|
||||
output += (
|
||||
'<span name="'
|
||||
+ name
|
||||
+ '" class="choose_file_button">'
|
||||
+ gettext("Choose file")
|
||||
+ "</span>"
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
class SelectUser(TextInput):
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
if attrs:
|
||||
@ -139,7 +114,7 @@ class SelectUser(TextInput):
|
||||
|
||||
def validate_future_timestamp(value: date | datetime):
|
||||
if value <= now():
|
||||
raise ValueError(_("Ensure this timestamp is set in the future"))
|
||||
raise ValidationError(_("Ensure this timestamp is set in the future"))
|
||||
|
||||
|
||||
class FutureDateTimeField(forms.DateTimeField):
|
||||
@ -147,8 +122,8 @@ class FutureDateTimeField(forms.DateTimeField):
|
||||
|
||||
default_validators = [validate_future_timestamp]
|
||||
|
||||
def widget_attrs(self, widget: Widget) -> dict[str, str]:
|
||||
return {"min": widget.format_value(now())}
|
||||
def widget_attrs(self, widget: forms.Widget) -> dict[str, str]:
|
||||
return {"min": widget.format_value(localtime())}
|
||||
|
||||
|
||||
# Forms
|
||||
@ -156,29 +131,31 @@ class FutureDateTimeField(forms.DateTimeField):
|
||||
|
||||
class LoginForm(AuthenticationForm):
|
||||
def __init__(self, *arg, **kwargs):
|
||||
if "data" in kwargs:
|
||||
from counter.models import Customer
|
||||
|
||||
data = kwargs["data"].copy()
|
||||
account_code = re.compile(r"^[0-9]+[A-Za-z]$")
|
||||
try:
|
||||
if account_code.match(data["username"]):
|
||||
user = (
|
||||
Customer.objects.filter(account_id__iexact=data["username"])
|
||||
.first()
|
||||
.user
|
||||
)
|
||||
elif "@" in data["username"]:
|
||||
user = User.objects.filter(email__iexact=data["username"]).first()
|
||||
else:
|
||||
user = User.objects.filter(username=data["username"]).first()
|
||||
data["username"] = user.username
|
||||
except: # noqa E722 I don't know what error is supposed to be raised here
|
||||
pass
|
||||
kwargs["data"] = data
|
||||
super().__init__(*arg, **kwargs)
|
||||
self.fields["username"].label = _("Username, email, or account number")
|
||||
|
||||
def clean_username(self):
|
||||
identifier: str = self.cleaned_data["username"]
|
||||
account_code = re.compile(r"^[0-9]+[A-Za-z]$")
|
||||
if account_code.match(identifier):
|
||||
qs_filter = "customer__account_id__iexact"
|
||||
elif identifier.count("@") == 1:
|
||||
qs_filter = "email"
|
||||
else:
|
||||
qs_filter = None
|
||||
if qs_filter:
|
||||
# if the user gave an email or an account code instead of
|
||||
# a username, retrieve and return the corresponding username.
|
||||
# If there is no username, return an empty string, so that
|
||||
# Django will properly handle the error when failing the authentication
|
||||
identifier = (
|
||||
User.objects.filter(**{qs_filter: identifier})
|
||||
.values_list("username", flat=True)
|
||||
.first()
|
||||
or ""
|
||||
)
|
||||
return identifier
|
||||
|
||||
|
||||
class RegisteringForm(UserCreationForm):
|
||||
error_css_class = "error"
|
||||
|
@ -109,7 +109,7 @@ class FragmentMixin(TemplateResponseMixin, ContextMixin):
|
||||
return render(
|
||||
request,
|
||||
"app/template.jinja",
|
||||
context={"fragment": fragment(request)
|
||||
context={"fragment": fragment(request)}
|
||||
}
|
||||
|
||||
# in urls.py
|
||||
|
@ -41,6 +41,7 @@ class ProductAdmin(SearchModelAdmin):
|
||||
"profit",
|
||||
"archived",
|
||||
)
|
||||
list_select_related = ("product_type",)
|
||||
search_fields = ("name", "code")
|
||||
|
||||
|
||||
@ -81,20 +82,13 @@ class AccountDumpAdmin(admin.ModelAdmin):
|
||||
"customer",
|
||||
"warning_mail_sent_at",
|
||||
"warning_mail_error",
|
||||
"dump_operation",
|
||||
"dump_operation__date",
|
||||
"amount",
|
||||
)
|
||||
list_select_related = ("customer", "customer__user", "dump_operation")
|
||||
autocomplete_fields = ("customer", "dump_operation")
|
||||
list_filter = ("warning_mail_error",)
|
||||
|
||||
def get_queryset(self, request):
|
||||
# the `amount` property requires to know the customer and the dump_operation
|
||||
return (
|
||||
super()
|
||||
.get_queryset(request)
|
||||
.select_related("customer", "customer__user", "dump_operation")
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Counter)
|
||||
class CounterAdmin(admin.ModelAdmin):
|
||||
@ -113,11 +107,14 @@ class RefillingAdmin(SearchModelAdmin):
|
||||
"customer__account_id",
|
||||
"counter__name",
|
||||
)
|
||||
list_filter = (("counter", admin.RelatedOnlyFieldListFilter),)
|
||||
date_hierarchy = "date"
|
||||
|
||||
|
||||
@admin.register(Selling)
|
||||
class SellingAdmin(SearchModelAdmin):
|
||||
list_display = ("customer", "label", "unit_price", "quantity", "counter", "date")
|
||||
list_select_related = ("customer", "customer__user", "counter")
|
||||
search_fields = (
|
||||
"customer__user__username",
|
||||
"customer__user__first_name",
|
||||
@ -126,6 +123,8 @@ class SellingAdmin(SearchModelAdmin):
|
||||
"counter__name",
|
||||
)
|
||||
autocomplete_fields = ("customer", "seller")
|
||||
list_filter = (("counter", admin.RelatedOnlyFieldListFilter),)
|
||||
date_hierarchy = "date"
|
||||
|
||||
|
||||
@admin.register(Permanency)
|
||||
|
@ -16,11 +16,13 @@ from django.conf import settings
|
||||
from django.db.models import F
|
||||
from django.shortcuts import get_object_or_404
|
||||
from ninja import Query
|
||||
from ninja.security import SessionAuth
|
||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||
from ninja_extra.schemas import PaginatedResponseSchema
|
||||
|
||||
from core.auth.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
|
||||
from api.auth import ApiKeyAuth
|
||||
from api.permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
|
||||
from counter.models import Counter, Product, ProductType
|
||||
from counter.schemas import (
|
||||
CounterFilterSchema,
|
||||
@ -62,6 +64,7 @@ class CounterController(ControllerBase):
|
||||
@route.get(
|
||||
"/search",
|
||||
response=PaginatedResponseSchema[SimplifiedCounterSchema],
|
||||
auth=[SessionAuth(), ApiKeyAuth()],
|
||||
permissions=[CanAccessLookup],
|
||||
)
|
||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||
@ -74,6 +77,7 @@ class ProductController(ControllerBase):
|
||||
@route.get(
|
||||
"/search",
|
||||
response=PaginatedResponseSchema[SimpleProductSchema],
|
||||
auth=[SessionAuth(), ApiKeyAuth()],
|
||||
permissions=[CanAccessLookup],
|
||||
)
|
||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||
|
@ -61,7 +61,7 @@ class CustomerQuerySet(models.QuerySet):
|
||||
Returns:
|
||||
The number of updated rows.
|
||||
|
||||
Warnings:
|
||||
Warning:
|
||||
The execution time of this query grows really quickly.
|
||||
When updating 500 customers, it may take around a second.
|
||||
If you try to update all customers at once, the execution time
|
||||
|
@ -5,7 +5,7 @@ from django.urls import reverse
|
||||
from ninja import Field, FilterSchema, ModelSchema, Schema
|
||||
from pydantic import model_validator
|
||||
|
||||
from club.schemas import ClubSchema
|
||||
from club.schemas import SimpleClubSchema
|
||||
from core.schemas import GroupSchema, SimpleUserSchema
|
||||
from counter.models import Counter, Product, ProductType
|
||||
|
||||
@ -82,7 +82,7 @@ class ProductSchema(ModelSchema):
|
||||
]
|
||||
|
||||
buying_groups: list[GroupSchema]
|
||||
club: ClubSchema
|
||||
club: SimpleClubSchema
|
||||
product_type: SimpleProductTypeSchema | None
|
||||
url: str
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { AlertMessage } from "#core:utils/alert-message";
|
||||
import { BasketItem } from "#counter:counter/basket";
|
||||
import type { CounterConfig, ErrorMessage } from "#counter:counter/types";
|
||||
import type { CounterProductSelect } from "./components/counter-product-select-index.ts";
|
||||
@ -5,14 +6,9 @@ import type { CounterProductSelect } from "./components/counter-product-select-i
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("counter", (config: CounterConfig) => ({
|
||||
basket: {} as Record<string, BasketItem>,
|
||||
errors: [],
|
||||
customerBalance: config.customerBalance,
|
||||
codeField: null as CounterProductSelect | null,
|
||||
alertMessage: {
|
||||
content: "",
|
||||
show: false,
|
||||
timeout: null,
|
||||
},
|
||||
alertMessage: new AlertMessage({ defaultDuration: 2000 }),
|
||||
|
||||
init() {
|
||||
// Fill the basket with the initial data
|
||||
@ -77,22 +73,10 @@ document.addEventListener("alpine:init", () => {
|
||||
return total;
|
||||
},
|
||||
|
||||
showAlertMessage(message: string) {
|
||||
if (this.alertMessage.timeout !== null) {
|
||||
clearTimeout(this.alertMessage.timeout);
|
||||
}
|
||||
this.alertMessage.content = message;
|
||||
this.alertMessage.show = true;
|
||||
this.alertMessage.timeout = setTimeout(() => {
|
||||
this.alertMessage.show = false;
|
||||
this.alertMessage.timeout = null;
|
||||
}, 2000);
|
||||
},
|
||||
|
||||
addToBasketWithMessage(id: string, quantity: number) {
|
||||
const message = this.addToBasket(id, quantity);
|
||||
if (message.length > 0) {
|
||||
this.showAlertMessage(message);
|
||||
this.alertMessage.display(message, { success: false });
|
||||
}
|
||||
},
|
||||
|
||||
@ -109,7 +93,9 @@ document.addEventListener("alpine:init", () => {
|
||||
|
||||
finish() {
|
||||
if (this.getBasketSize() === 0) {
|
||||
this.showAlertMessage(gettext("You can't send an empty basket."));
|
||||
this.alertMessage.display(gettext("You can't send an empty basket."), {
|
||||
success: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.$refs.basketForm.submit();
|
||||
@ -137,8 +123,3 @@ document.addEventListener("alpine:init", () => {
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
$(() => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
|
||||
($("#products") as any).tabs();
|
||||
});
|
||||
|
@ -1,15 +1,11 @@
|
||||
import { AlertMessage } from "#core:utils/alert-message";
|
||||
import Alpine from "alpinejs";
|
||||
import { producttypeReorder } from "#openapi";
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("productTypesList", () => ({
|
||||
loading: false,
|
||||
alertMessage: {
|
||||
open: false,
|
||||
success: true,
|
||||
content: "",
|
||||
timeout: null,
|
||||
},
|
||||
alertMessage: new AlertMessage({ defaultDuration: 2000 }),
|
||||
|
||||
async reorder(itemId: number, newPosition: number) {
|
||||
// The sort plugin of Alpine doesn't manage dynamic lists with x-sort
|
||||
@ -41,23 +37,14 @@ document.addEventListener("alpine:init", () => {
|
||||
},
|
||||
|
||||
openAlertMessage(response: Response) {
|
||||
if (response.ok) {
|
||||
this.alertMessage.success = true;
|
||||
this.alertMessage.content = gettext("Products types reordered!");
|
||||
} else {
|
||||
this.alertMessage.success = false;
|
||||
this.alertMessage.content = interpolate(
|
||||
const success = response.ok;
|
||||
const content = response.ok
|
||||
? gettext("Products types reordered!")
|
||||
: interpolate(
|
||||
gettext("Product type reorganisation failed with status code : %d"),
|
||||
[response.status],
|
||||
);
|
||||
}
|
||||
this.alertMessage.open = true;
|
||||
if (this.alertMessage.timeout !== null) {
|
||||
clearTimeout(this.alertMessage.timeout);
|
||||
}
|
||||
this.alertMessage.timeout = setTimeout(() => {
|
||||
this.alertMessage.open = false;
|
||||
}, 2000);
|
||||
this.alertMessage.display(content, { success: success });
|
||||
this.loading = false;
|
||||
},
|
||||
}));
|
||||
|
2
counter/static/bundled/counter/types.d.ts
vendored
2
counter/static/bundled/counter/types.d.ts
vendored
@ -1,4 +1,4 @@
|
||||
type ErrorMessage = string;
|
||||
declare type ErrorMessage = string;
|
||||
|
||||
export interface InitialFormData {
|
||||
/* Used to refill the form when the backend raises an error */
|
||||
|
@ -9,12 +9,14 @@
|
||||
<link rel="stylesheet" type="text/css" href="{{ static('counter/css/counter-click.scss') }}" defer></link>
|
||||
<link rel="stylesheet" type="text/css" href="{{ static('bundled/core/components/ajax-select-index.css') }}" defer></link>
|
||||
<link rel="stylesheet" type="text/css" href="{{ static('core/components/ajax-select.scss') }}" defer></link>
|
||||
<link rel="stylesheet" type="text/css" href="{{ static('core/components/tabs.scss') }}" defer></link>
|
||||
<link rel="stylesheet" href="{{ static("core/components/card.scss") }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_js %}
|
||||
<script type="module" src="{{ static('bundled/counter/counter-click-index.ts') }}"></script>
|
||||
<script type="module" src="{{ static('bundled/counter/components/counter-product-select-index.ts') }}"></script>
|
||||
<script type="module" src="{{ static('bundled/core/components/tabs-index.ts') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block info_boxes %}
|
||||
@ -205,13 +207,9 @@
|
||||
{% trans %}No products available on this counter for this user{% endtrans %}
|
||||
</div>
|
||||
{% else %}
|
||||
<ul>
|
||||
<ui-tab-group>
|
||||
{% for category in categories.keys() -%}
|
||||
<li><a href="#cat_{{ category|slugify }}">{{ category }}</a></li>
|
||||
{%- endfor %}
|
||||
</ul>
|
||||
{% for category in categories.keys() -%}
|
||||
<div id="cat_{{ category|slugify }}">
|
||||
<ui-tab title="{{ category }}" {% if loop.index == 1 -%}active{%- endif -%}>
|
||||
<h5 class="margin-bottom">{{ category }}</h5>
|
||||
<div class="row gap-2x">
|
||||
{% for product in categories[category] -%}
|
||||
@ -232,8 +230,9 @@
|
||||
</button>
|
||||
{%- endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{%- endfor %}
|
||||
</ui-tab>
|
||||
{% endfor %}
|
||||
</ui-tab-group>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -5,10 +5,6 @@
|
||||
{% trans counter_name=counter %}{{ counter_name }} stats{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block jquery_css %}
|
||||
{# Remove jquery_css #}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h3>{% trans counter_name=counter %}{{ counter_name }} stats{% endtrans %}</h3>
|
||||
<h4>
|
||||
|
@ -17,6 +17,7 @@ from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission, make_password
|
||||
from django.core.cache import cache
|
||||
@ -823,3 +824,53 @@ class TestClubCounterClickAccess(TestCase):
|
||||
self.client.force_login(self.user)
|
||||
res = self.client.get(self.click_url)
|
||||
assert res.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCounterLogout:
|
||||
def test_logout_simple(self, client: Client):
|
||||
perm_counter = baker.make(Counter, type="BAR")
|
||||
permanence = baker.make(
|
||||
Permanency,
|
||||
counter=perm_counter,
|
||||
start=now() - timedelta(hours=1),
|
||||
activity=now() - timedelta(minutes=10),
|
||||
)
|
||||
with freeze_time():
|
||||
res = client.post(
|
||||
reverse("counter:logout", kwargs={"counter_id": permanence.counter_id}),
|
||||
data={"user_id": permanence.user_id},
|
||||
)
|
||||
assertRedirects(
|
||||
res,
|
||||
reverse(
|
||||
"counter:details", kwargs={"counter_id": permanence.counter_id}
|
||||
),
|
||||
)
|
||||
permanence.refresh_from_db()
|
||||
assert permanence.end == now()
|
||||
|
||||
def test_logout_doesnt_change_old_permanences(self, client: Client):
|
||||
perm_counter = baker.make(Counter, type="BAR")
|
||||
permanence = baker.make(
|
||||
Permanency,
|
||||
counter=perm_counter,
|
||||
start=now() - timedelta(hours=1),
|
||||
activity=now() - timedelta(minutes=10),
|
||||
)
|
||||
old_end = now() - relativedelta(year=10)
|
||||
old_permanence = baker.make(
|
||||
Permanency,
|
||||
counter=perm_counter,
|
||||
end=old_end,
|
||||
activity=now() - relativedelta(year=8),
|
||||
)
|
||||
with freeze_time():
|
||||
client.post(
|
||||
reverse("counter:logout", kwargs={"counter_id": permanence.counter_id}),
|
||||
data={"user_id": permanence.user_id},
|
||||
)
|
||||
permanence.refresh_from_db()
|
||||
assert permanence.end == now()
|
||||
old_permanence.refresh_from_db()
|
||||
assert old_permanence.end == old_end
|
||||
|
@ -13,10 +13,10 @@
|
||||
#
|
||||
#
|
||||
|
||||
from django.db.models import F
|
||||
from django.http import HttpRequest, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import now
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from core.views.forms import LoginForm
|
||||
@ -47,7 +47,7 @@ def counter_login(request: HttpRequest, counter_id: int) -> HttpResponseRedirect
|
||||
@require_POST
|
||||
def counter_logout(request: HttpRequest, counter_id: int) -> HttpResponseRedirect:
|
||||
"""End the permanency of a user in this counter."""
|
||||
Permanency.objects.filter(counter=counter_id, user=request.POST["user_id"]).update(
|
||||
end=F("activity")
|
||||
)
|
||||
Permanency.objects.filter(
|
||||
counter=counter_id, user=request.POST["user_id"], end=None
|
||||
).update(end=now())
|
||||
return redirect("counter:details", counter_id=counter_id)
|
||||
|
BIN
docs/img/api_key_authorize_1.png
Normal file
BIN
docs/img/api_key_authorize_1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 33 KiB |
BIN
docs/img/api_key_authorize_2.png
Normal file
BIN
docs/img/api_key_authorize_2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 49 KiB |
6
docs/reference/api/auth.md
Normal file
6
docs/reference/api/auth.md
Normal file
@ -0,0 +1,6 @@
|
||||
::: api.auth
|
||||
handler: python
|
||||
options:
|
||||
heading_level: 3
|
||||
members:
|
||||
- ApiKeyAuth
|
8
docs/reference/api/hashers.md
Normal file
8
docs/reference/api/hashers.md
Normal file
@ -0,0 +1,8 @@
|
||||
::: api.hashers
|
||||
handler: python
|
||||
options:
|
||||
heading_level: 3
|
||||
members:
|
||||
- Sha256ApiKeyHasher
|
||||
- get_hasher
|
||||
- generate_key
|
7
docs/reference/api/models.md
Normal file
7
docs/reference/api/models.md
Normal file
@ -0,0 +1,7 @@
|
||||
::: api.auth
|
||||
handler: python
|
||||
options:
|
||||
heading_level: 3
|
||||
members:
|
||||
- ApiKey
|
||||
- ApiClient
|
4
docs/reference/api/perms.md
Normal file
4
docs/reference/api/perms.md
Normal file
@ -0,0 +1,4 @@
|
||||
::: api.permissions
|
||||
handler: python
|
||||
options:
|
||||
heading_level: 3
|
@ -20,13 +20,6 @@
|
||||
- CanCreateMixin
|
||||
- CanEditMixin
|
||||
- CanViewMixin
|
||||
- CanEditPropMixin
|
||||
- FormerSubscriberMixin
|
||||
- PermissionOrAuthorRequiredMixin
|
||||
|
||||
|
||||
## API Permissions
|
||||
|
||||
::: core.auth.api_permissions
|
||||
handler: python
|
||||
options:
|
||||
heading_level: 3
|
215
docs/tutorial/api/connect.md
Normal file
215
docs/tutorial/api/connect.md
Normal file
@ -0,0 +1,215 @@
|
||||
La connexion à l'API du site AE peut se faire par deux moyens :
|
||||
|
||||
- par le cookie de session du site ; si vous accédez à l'API depuis le sith
|
||||
en étant connecté, cette méthode fonctionne par défaut
|
||||
- par clef d'API ; si vous accédez à l'API depuis une application externe,
|
||||
vous devez passer par cette méthode.
|
||||
|
||||
Comme la méthode par cookie de session ne devrait pas être utilisée
|
||||
en dehors du cadre interne au site et qu'elle marche par défaut
|
||||
dans le cadre de ce dernier, nous ne décrirons pas outre mesure la manière
|
||||
de l'utiliser.
|
||||
|
||||
## Obtenir une clef d'API
|
||||
|
||||
Il n'y a, à l'heure actuelle, pas d'interface accessible sur le site
|
||||
pour obtenir une clef d'API.
|
||||
Si vous désirez en obtenir une, demandez directement au respo info.
|
||||
|
||||
!!!danger
|
||||
|
||||
Votre clef d'API doit rester secrète.
|
||||
Ne la transmettez à personne, ne l'inscrivez pas en dur dans votre code.
|
||||
|
||||
Si votre clef a fuité, ou que vous soupçonnez qu'elle ait pu fuiter,
|
||||
informez-en immédiatement l'équipe informatique !
|
||||
|
||||
## L'interface Swagger
|
||||
|
||||
Avant de commencer à utiliser l'API du site, vous pouvez explorer
|
||||
les différentes routes qu'elle met à disposition,
|
||||
avec les schémas de données attendus en requête et en réponse.
|
||||
|
||||
Pour cela, vous pouvez vous rendre sur
|
||||
[https://ae.utbm.fr/api/docs](https://ae.utbm.fr/api/docs).
|
||||
|
||||
Toutes les routes, à de rares exceptions près, y sont recensées.
|
||||
Vous pouvez les utiliser dans les limites
|
||||
de ce à quoi vos permissions vous donnent droit
|
||||
et de la méthode d'authentification.
|
||||
|
||||
Vous pouvez vous connecter directement sur l'interface Swagger,
|
||||
en cliquant sur ce bouton, en haut à droite :
|
||||
|
||||

|
||||
/// caption
|
||||
Bouton d'autorisation sur Swagger
|
||||
///
|
||||
|
||||
Puis rentrez votre clef d'API dans le champ prévu à cet effet,
|
||||
et cliquez sur authorize :
|
||||
|
||||
|
||||

|
||||
/// caption
|
||||
Saisie de la clef d'API
|
||||
///
|
||||
|
||||
Les routes accessibles avec une clef d'API seront alors marquées par
|
||||
une icône de cadenas fermé, sur la droite.
|
||||
|
||||
!!!warning "Authentification et permissions"
|
||||
|
||||
L'icône de cadenas signifie que la route accepte l'authentification
|
||||
basée sur les clefs d'API, mais pas forcément que vous avez les
|
||||
permissions nécessaires.
|
||||
|
||||
Si une route vous renvoie une erreur 403,
|
||||
référez-en à l'équipe info, pour qu'elle puisse vous donner
|
||||
les permissions nécessaires.
|
||||
|
||||
## Utiliser la clef d'API
|
||||
|
||||
### `X-APIKey`
|
||||
|
||||
Maintenant que vous avez la clef d'API,
|
||||
il faut l'utiliser pour authentifier votre application
|
||||
lorsqu'elle effectue des requêtes au site.
|
||||
|
||||
Pour cela, vous devez le fournir dans vos requêtes
|
||||
à travers le header `X-APIKey`.
|
||||
|
||||
Par exemple :
|
||||
|
||||
```shell
|
||||
curl "https://ae.utbm.fr/api/club/1" \
|
||||
-H "X-APIKey: <votre clef d'API>"
|
||||
```
|
||||
|
||||
Comme votre clef d'API doit rester absolument secrète,
|
||||
vous ne devez en aucun cas la mettre dans votre code.
|
||||
À la place, vous pouvez créer un fichier (par exemple, un `.env`)
|
||||
qui contiendra votre clef et qui sera gitignoré.
|
||||
|
||||
```dotenv title=".env"
|
||||
API_KEY="<votre clef d'API>"
|
||||
```
|
||||
|
||||
Vous fournirez alors la clef d'API en la chargeant depuis votre environnement.
|
||||
Notez que c'est une bonne pratique à double-titre,
|
||||
puisque vous pouvez ainsi aisément changer votre clef d'API.
|
||||
|
||||
### Connexion persistante
|
||||
|
||||
La plupart des librairies permettant d'effectuer des requêtes
|
||||
HTTP incluent une prise en charge des sessions persistantes.
|
||||
Nous vous recommandons fortement d'utiliser ces fonctionnalités,
|
||||
puisqu'elles permettent de rendre votre code plus simple
|
||||
(vous n'aurez à renseigner votre clef d'API qu'une seule fois)
|
||||
et plus efficace (réutiliser la même connexion plutôt que d'en créer
|
||||
une nouvelle à chaque requête peut résulter en un gain de performance significatif ;
|
||||
cf. [HTTP persistant connection (wikipedia)](https://en.wikipedia.org/wiki/HTTP_persistent_connection))
|
||||
|
||||
Voici quelques exemples :
|
||||
|
||||
=== "Python (requests)"
|
||||
|
||||
Dépendances :
|
||||
|
||||
- `requests` (>=2.32)
|
||||
- `environs` (>=14.1)
|
||||
|
||||
```python
|
||||
import requests
|
||||
from environs import Env
|
||||
|
||||
env = Env()
|
||||
env.read_env()
|
||||
|
||||
with requests.Session() as session:
|
||||
session.headers["X-APIKey"] = env.str("API_KEY")
|
||||
response = session.get("https://ae.utbm.fr/api/club/1")
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
=== "Python (aiohttp)"
|
||||
|
||||
Dépendances :
|
||||
|
||||
- `aiohttp` (>=3.11)
|
||||
- `environs` (>=14.1)
|
||||
|
||||
```python
|
||||
import aiohttp
|
||||
import asyncio
|
||||
from environs import Env
|
||||
|
||||
env = Env()
|
||||
env.read_env()
|
||||
|
||||
async def main():
|
||||
async with aiohttp.ClientSession(
|
||||
base_url="https://ae.utbm.fr/api/",
|
||||
headers={"X-APIKey": env.str("API_KEY")}
|
||||
) as session:
|
||||
async with session.get("club/1") as res:
|
||||
print(await res.json())
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
=== "Javascript (axios)"
|
||||
|
||||
Dépendances :
|
||||
|
||||
- `axios` (>=1.9)
|
||||
- `dotenv` (>=16.5)
|
||||
|
||||
```javascript
|
||||
import { axios } from "axios";
|
||||
import { config } from "dotenv";
|
||||
|
||||
config();
|
||||
|
||||
const instance = axios.create({
|
||||
baseUrl: "https://ae.utbm.fr/api/",
|
||||
headers: { "X-APIKey": process.env.API_KEY }
|
||||
});
|
||||
console.log(await instance.get("club/1").json());
|
||||
```
|
||||
|
||||
=== "Rust (reqwest)"
|
||||
|
||||
Dépendances :
|
||||
|
||||
- `reqwest` (>= 0.12, features `json` et `gzip`)
|
||||
- `tokio` (>= 1.44, feature `derive`)
|
||||
- `dotenvy` (>= 0.15)
|
||||
|
||||
```rust
|
||||
use reqwest::Client;
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use dotenvy::EnvLoader;
|
||||
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let env = EnvLoader::new().load()?;
|
||||
let mut headers = HeaderMap::new();
|
||||
let mut api_key = HeaderValue::from_str(env.var("API_KEY")?.as_str());
|
||||
api_key.set_sensitive(true);
|
||||
headers.insert("X-APIKey", api_key);
|
||||
let client = Client::builder()
|
||||
.default_headers(headers)
|
||||
.gzip(true)
|
||||
.build()?;
|
||||
let resp = client
|
||||
.get("https://ae.utbm.fr/api/club/1")
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
println!("{resp:#?}");
|
||||
Ok(())
|
||||
}
|
||||
```
|
175
docs/tutorial/api/dev.md
Normal file
175
docs/tutorial/api/dev.md
Normal file
@ -0,0 +1,175 @@
|
||||
|
||||
Pour l'API, nous utilisons `django-ninja` et sa surcouche `django-ninja-extra`.
|
||||
Ce sont des librairies relativement simples et qui présentent
|
||||
l'immense avantage d'offrir des mécanismes de validation et de sérialisation
|
||||
de données à la fois simples et expressifs.
|
||||
|
||||
## Dossiers et fichiers
|
||||
|
||||
L'API possède une application (`api`)
|
||||
à la racine du projet, contenant des utilitaires
|
||||
et de la configuration partagée par toutes les autres applications.
|
||||
C'est la pièce centrale de notre API, mais ce n'est pas là que
|
||||
vous trouverez les routes de l'API.
|
||||
|
||||
Les routes en elles-mêmes sont contenues dans les autres applications,
|
||||
de manière thématiques :
|
||||
les routes liées aux clubs sont dans `club`, les routes liées
|
||||
aux photos dans `sas` et ainsi de suite.
|
||||
|
||||
Les fichiers liés à l'API dans chaque application sont
|
||||
`schemas.py` et `api.py`.
|
||||
`schemas.py` contient les schémas de validation de données
|
||||
et `api.py` contient les contrôleurs de l'API.
|
||||
|
||||
|
||||
## Schéma de données
|
||||
|
||||
Le cœur de django-ninja étant sa validation de données grâce à Pydantic,
|
||||
le développement de l'API commence par l'écriture de ses schémas de données.
|
||||
|
||||
Pour en comprendre le fonctionnement, veuillez consulter
|
||||
[la doc de django-ninja](https://django-ninja.dev/guides/response/).
|
||||
|
||||
Il est également important de consulter
|
||||
[la doc de pydantic](https://docs.pydantic.dev/latest/).
|
||||
|
||||
Notre surcouche par-dessus les schémas de django-ninja est relativement mince.
|
||||
Elle ne comprend que [UploadedImage][core.schemas.UploadedImage], qui hérite de
|
||||
[`UploadedFile`](https://django-ninja.dev/guides/input/file-params/?h=upl)
|
||||
pour le restreindre uniquement aux images.
|
||||
|
||||
## Authentification et permissions
|
||||
|
||||
### Authentification
|
||||
|
||||
Notre API offre deux moyens d'authentification :
|
||||
|
||||
- par cookie de session (la méthode par défaut de django)
|
||||
- par clef d'API
|
||||
|
||||
La plus grande partie des routes de l'API utilisent la méthode par cookie de session.
|
||||
|
||||
Pour placer une route d'API derrière l'une de ces méthodes (ou bien les deux),
|
||||
utilisez l'attribut `auth` et les classes `SessionAuth` et
|
||||
[`ApiKeyAuth`][api.auth.ApiKeyAuth].
|
||||
|
||||
!!!example
|
||||
|
||||
```python
|
||||
@api_controller("/foo")
|
||||
class FooController(ControllerBase):
|
||||
# Cette route sera accessible uniquement avec l'authentification
|
||||
# par cookie de session
|
||||
@route.get("", auth=[SessionAuth()])
|
||||
def fetch_foo(self, club_id: int): ...
|
||||
|
||||
# Et celle-ci sera accessible peut importe la méthode d'authentification
|
||||
@route.get("/bar", auth=[SessionAuth(), ApiKeyAuth()])
|
||||
def fetch_bar(self, club_id: int): ...
|
||||
```
|
||||
|
||||
### Permissions
|
||||
|
||||
Si l'utilisateur est connecté, ça ne veut pas dire pour autant qu'il a accès à tout.
|
||||
Une fois qu'il est authentifié, il faut donc vérifier ses permissions.
|
||||
|
||||
Pour cela, nous utilisons une surcouche
|
||||
par-dessus `django-ninja`, le système de permissions de django
|
||||
et notre propre système.
|
||||
Cette dernière est documentée [ici](../perms.md).
|
||||
|
||||
### Limites des clefs d'API
|
||||
|
||||
#### Incompatibilité avec certaines permissions
|
||||
|
||||
Le système des clefs d'API est apparu très tard dans l'histoire du site
|
||||
(en P25, 10 ans après le début du développement).
|
||||
Il s'agit ni plus ni moins qu'un système d'authentification parallèle fait maison,
|
||||
devant interagir avec un système de permissions ayant connu lui-même
|
||||
une histoire assez chaotique.
|
||||
|
||||
Assez logiquement, on ne peut pas tout faire :
|
||||
il n'est pas possible que toutes les routes acceptent
|
||||
l'authentification par clef d'API.
|
||||
|
||||
Cette impossibilité provient majoritairement d'une incompatibilité
|
||||
entre cette méthode d'authentification et le système de permissions
|
||||
(qui n'a pas été prévu pour l'implémentation d'un client d'API).
|
||||
Les principaux points de friction sont :
|
||||
|
||||
- `CanView` et `CanEdit`, qui se basent `User.can_view` et `User.can_edit`,
|
||||
qui peuvent eux-mêmes se baser sur les méthodes `can_be_viewed_by`
|
||||
et `can_be_edited_by` des différents modèles.
|
||||
Or, ces dernières testent spécifiquement la relation entre l'objet et un `User`.
|
||||
Ce comportement est possiblement changeable, mais au prix d'un certain travail
|
||||
et au risque de transformer encore plus notre système de permissions
|
||||
en usine à gaz.
|
||||
- `IsSubscriber` et `OldSubscriber`, qui vérifient qu'un utilisateur est ou
|
||||
a été cotisant.
|
||||
Or, une clef d'API est liée à un client d'API, pas à un utilisateur.
|
||||
Par définition, un client d'API ne peut pas être cotisant.
|
||||
- `IsLoggedInCounter`, qui utilise encore un autre système
|
||||
d'authentification maison et qui n'est pas fait pour être utilisé en dehors du site.
|
||||
|
||||
#### Incompatibilité avec les tokens csrf
|
||||
|
||||
Le [CSRF (*cross-site request forgery*)](https://fr.wikipedia.org/wiki/Cross-site_request_forgery)
|
||||
est un des multiples facteurs d'attaque sur le web.
|
||||
Heureusement, Django vient encore une fois à notre aide,
|
||||
avec des mécanismes intégrés pour s'en protéger.
|
||||
Ceux-ci incluent notamment un système de
|
||||
[token CSRF](https://docs.djangoproject.com/fr/stable/ref/csrf/)
|
||||
à fournir dans les requêtes POST/PUT/PATCH.
|
||||
|
||||
Ceux-ci sont bien adaptés au cycle requêtes/réponses
|
||||
typique de l'expérience utilisateur sur un navigateur,
|
||||
où les requêtes POST sont toujours effectuées après une requête
|
||||
GET au cours de laquelle on a pu récupérer un token csrf.
|
||||
Cependant, le flux des requêtes sur une API est bien différent ;
|
||||
de ce fait, il est à attendre que les requêtes POST envoyées à l'API
|
||||
par un client externe n'aient pas de token CSRF et se retrouvent
|
||||
donc bloquées.
|
||||
|
||||
Pour ces raisons, l'accès aux requêtes POST/PUT/PATCH de l'API
|
||||
par un client externe ne marche pas.
|
||||
|
||||
## Créer un client et une clef d'API
|
||||
|
||||
Le site n'a actuellement pas d'interface permettant à ses utilisateurs
|
||||
de créer une application et des clefs d'API.
|
||||
|
||||
C'est volontaire : tant que le système ne sera pas suffisamment mature,
|
||||
toute attribution de clef d'API doit passer par le pôle info.
|
||||
|
||||
Cette opération se fait au travers de l'interface admin.
|
||||
|
||||
Pour commencer, créez un client d'API, en renseignant son nom,
|
||||
son propriétaire (l'utilisateur qui vous a demandé de le créer)
|
||||
et les groupes qui lui sont attribués.
|
||||
Ces groupes sont les mêmes que ceux qui sont attribués aux utilisateurs,
|
||||
ce qui permet de réutiliser une partie du système d'authentification.
|
||||
|
||||
!!!warning
|
||||
|
||||
N'attribuez pas les groupes "anciens cotisants" et "cotisants"
|
||||
aux clients d'API.
|
||||
Un client d'API géré comme un cotisant, ça n'a aucun sens.
|
||||
|
||||
Evitez également de donner à des clients d'API des droits
|
||||
autres que ceux de lecture sur le site.
|
||||
|
||||
Et surtout, n'attribuez jamais le group Root à un client d'API.
|
||||
|
||||
Une fois le client d'API créé, créez-lui une clef d'API.
|
||||
Renseignez uniquement son nom et le client d'API auquel elle est lié.
|
||||
La valeur de cette clef d'API est automatiquement générée
|
||||
et affichée en haut de la page une fois la création complétée.
|
||||
|
||||
Notez bien la valeur de la clef d'API et transmettez-la à la personne
|
||||
qui en a besoin.
|
||||
Dites-lui bien de garder cette clef en lieu sûr !
|
||||
Si la clef est perdue, il n'y a pas moyen de la récupérer,
|
||||
vous devrez en recréer une.
|
||||
|
||||
|
@ -606,4 +606,4 @@ vous ne devriez pas être perdu, étant donné
|
||||
que le système de permissions de l'API utilise
|
||||
des noms assez similaires : `IsInGroup`, `IsRoot`, `IsSubscriber`...
|
||||
Vous pouvez trouver des exemples d'utilisation de ce système
|
||||
dans [cette partie](../reference/core/api_permissions.md).
|
||||
dans [cette partie](../reference/api/perms.md).
|
||||
|
@ -24,62 +24,66 @@ sith/
|
||||
├── .github/
|
||||
│ ├── actions/ (1)
|
||||
│ └── workflows/ (2)
|
||||
├── club/ (3)
|
||||
├── api/ (3)
|
||||
│ └── ...
|
||||
├── com/ (4)
|
||||
├── antispam/ (4)
|
||||
│ └── ...
|
||||
├── core/ (5)
|
||||
├── club/ (5)
|
||||
│ └── ...
|
||||
├── counter/ (6)
|
||||
├── com/ (6)
|
||||
│ └── ...
|
||||
├── docs/ (7)
|
||||
├── core/ (7)
|
||||
│ └── ...
|
||||
├── eboutic/ (8)
|
||||
├── counter/ (8)
|
||||
│ └── ...
|
||||
├── election/ (9)
|
||||
├── docs/ (9)
|
||||
│ └── ...
|
||||
├── forum/ (10)
|
||||
├── eboutic/ (10)
|
||||
│ └── ...
|
||||
├── galaxy/ (11)
|
||||
├── election/ (11)
|
||||
│ └── ...
|
||||
├── locale/ (12)
|
||||
├── forum/ (12)
|
||||
│ └── ...
|
||||
├── matmat/ (13)
|
||||
├── galaxy/ (13)
|
||||
│ └── ...
|
||||
├── pedagogy/ (14)
|
||||
├── locale/ (14)
|
||||
│ └── ...
|
||||
├── rootplace/ (15)
|
||||
├── matmat/ (15)
|
||||
│ └── ...
|
||||
├── sas/ (16)
|
||||
├── pedagogy/ (16)
|
||||
│ └── ...
|
||||
├── sith/ (17)
|
||||
├── rootplace/ (17)
|
||||
│ └── ...
|
||||
├── subscription/ (18)
|
||||
├── sas/ (18)
|
||||
│ └── ...
|
||||
├── trombi/ (19)
|
||||
├── sith/ (19)
|
||||
│ └── ...
|
||||
├── antispam/ (20)
|
||||
├── subscription/ (20)
|
||||
│ └── ...
|
||||
├── staticfiles/ (21)
|
||||
├── trombi/ (21)
|
||||
│ └── ...
|
||||
├── processes/ (22)
|
||||
├── antispam/ (22)
|
||||
│ └── ...
|
||||
├── staticfiles/ (23)
|
||||
│ └── ...
|
||||
├── processes/ (24)
|
||||
│ └── ...
|
||||
│
|
||||
├── .coveragerc (23)
|
||||
├── .envrc (24)
|
||||
├── .coveragerc (25)
|
||||
├── .envrc (26)
|
||||
├── .gitattributes
|
||||
├── .gitignore
|
||||
├── .mailmap
|
||||
├── .env (25)
|
||||
├── .env.example (26)
|
||||
├── manage.py (27)
|
||||
├── mkdocs.yml (28)
|
||||
├── .env (27)
|
||||
├── .env.example (28)
|
||||
├── manage.py (29)
|
||||
├── mkdocs.yml (30)
|
||||
├── uv.lock
|
||||
├── pyproject.toml (29)
|
||||
├── .venv/ (30)
|
||||
├── .python-version (31)
|
||||
├── Procfile.static (32)
|
||||
├── Procfile.service (33)
|
||||
├── pyproject.toml (31)
|
||||
├── .venv/ (32)
|
||||
├── .python-version (33)
|
||||
├── Procfile.static (34)
|
||||
├── Procfile.service (35)
|
||||
└── README.md
|
||||
```
|
||||
</div>
|
||||
@ -92,53 +96,55 @@ sith/
|
||||
des workflows Github.
|
||||
Par exemple, le workflow `docs.yml` compile
|
||||
et publie la documentation à chaque push sur la branche `master`.
|
||||
3. Application de gestion des clubs et de leurs membres.
|
||||
4. Application contenant les fonctionnalités
|
||||
3. Application avec la configuration de l'API
|
||||
4. Application contenant des utilitaires pour bloquer le spam et les bots
|
||||
5. Application de gestion des clubs et de leurs membres.
|
||||
6. Application contenant les fonctionnalités
|
||||
destinées aux responsables communication de l'AE.
|
||||
5. Application contenant la modélisation centrale du site.
|
||||
7. Application contenant la modélisation centrale du site.
|
||||
On en reparle plus loin sur cette page.
|
||||
6. Application de gestion des comptoirs, des permanences
|
||||
8. Application de gestion des comptoirs, des permanences
|
||||
sur ces comptoirs et des transactions qui y sont effectuées.
|
||||
7. Dossier contenant la documentation.
|
||||
8. Application de gestion de la boutique en ligne.
|
||||
9. Application de gestion des élections.
|
||||
10. Application de gestion du forum
|
||||
11. Application de gestion de la galaxie ; la galaxie
|
||||
9. Dossier contenant la documentation.
|
||||
10. Application de gestion de la boutique en ligne.
|
||||
11. Application de gestion des élections.
|
||||
12. Application de gestion du forum
|
||||
13. Application de gestion de la galaxie ; la galaxie
|
||||
est un graphe des niveaux de proximité entre les différents
|
||||
étudiants.
|
||||
12. Dossier contenant les fichiers de traduction.
|
||||
13. Fonctionnalités de recherche d'utilisateurs.
|
||||
14. Le guide des UEs du site, sur lequel les utilisateurs
|
||||
14. Dossier contenant les fichiers de traduction.
|
||||
15. Fonctionnalités de recherche d'utilisateurs.
|
||||
16. Le guide des UEs du site, sur lequel les utilisateurs
|
||||
peuvent également laisser leurs avis.
|
||||
15. Fonctionnalités utiles aux utilisateurs root.
|
||||
16. Le SAS, où l'on trouve toutes les photos de l'AE.
|
||||
17. Application principale du projet, contenant sa configuration.
|
||||
18. Gestion des cotisations des utilisateurs du site.
|
||||
19. Outil pour faciliter la fabrication des trombinoscopes de promo.
|
||||
20. Fonctionnalités pour gérer le spam.
|
||||
21. Gestion des statics du site. Override le système de statics de Django.
|
||||
17. Fonctionnalités utiles aux utilisateurs root.
|
||||
18. Le SAS, où l'on trouve toutes les photos de l'AE.
|
||||
19. Application principale du projet, contenant sa configuration.
|
||||
20. Gestion des cotisations des utilisateurs du site.
|
||||
21. Outil pour faciliter la fabrication des trombinoscopes de promo.
|
||||
22. Fonctionnalités pour gérer le spam.
|
||||
23. Gestion des statics du site. Override le système de statics de Django.
|
||||
Ajoute l'intégration du scss et du bundler js
|
||||
de manière transparente pour l'utilisateur.
|
||||
22. Module de gestion des services externes.
|
||||
24. Module de gestion des services externes.
|
||||
Offre une API simple pour utiliser les fichiers `Procfile.*`.
|
||||
23. Fichier de configuration de coverage.
|
||||
24. Fichier de configuration de direnv.
|
||||
25. Contient les variables d'environnement, qui sont susceptibles
|
||||
25. Fichier de configuration de coverage.
|
||||
26. Fichier de configuration de direnv.
|
||||
27. Contient les variables d'environnement, qui sont susceptibles
|
||||
de varier d'une machine à l'autre.
|
||||
26. Contient des valeurs par défaut pour le `.env`
|
||||
28. Contient des valeurs par défaut pour le `.env`
|
||||
pouvant convenir à un environnment de développement local
|
||||
27. Fichier généré automatiquement par Django. C'est lui
|
||||
29. Fichier généré automatiquement par Django. C'est lui
|
||||
qui permet d'appeler des commandes de gestion du projet
|
||||
avec la syntaxe `python ./manage.py <nom de la commande>`
|
||||
28. Le fichier de configuration de la documentation,
|
||||
30. Le fichier de configuration de la documentation,
|
||||
avec ses plugins et sa table des matières.
|
||||
29. Le fichier où sont déclarés les dépendances et la configuration
|
||||
31. Le fichier où sont déclarés les dépendances et la configuration
|
||||
de certaines d'entre elles.
|
||||
30. Dossier d'environnement virtuel généré par uv
|
||||
31. Fichier qui contrôle quelle version de python utiliser pour le projet
|
||||
32. Fichier qui contrôle les commandes à lancer pour gérer la compilation
|
||||
32. Dossier d'environnement virtuel généré par uv
|
||||
33. Fichier qui contrôle quelle version de python utiliser pour le projet
|
||||
34. Fichier qui contrôle les commandes à lancer pour gérer la compilation
|
||||
automatique des static et autres services nécessaires à la command runserver.
|
||||
33. Fichier qui contrôle les services tiers nécessaires au fonctionnement
|
||||
35. Fichier qui contrôle les services tiers nécessaires au fonctionnement
|
||||
du Sith tel que redis.
|
||||
|
||||
## L'application principale
|
||||
|
@ -1,7 +1,7 @@
|
||||
from ninja_extra import ControllerBase, api_controller, route
|
||||
from ninja_extra.exceptions import NotFound
|
||||
|
||||
from core.auth.api_permissions import CanView
|
||||
from api.permissions import CanView
|
||||
from counter.models import BillingInfo
|
||||
from eboutic.models import Basket
|
||||
|
||||
|
@ -4,10 +4,6 @@
|
||||
{% trans %}Basket state{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block jquery_css %}
|
||||
{# Remove jquery css #}
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_js %}
|
||||
<script type="module" src="{{ static('bundled/eboutic/checkout-index.ts') }}"></script>
|
||||
{% endblock %}
|
||||
|
@ -4,10 +4,6 @@
|
||||
{% trans %}Eboutic{% endtrans %}
|
||||
{% endblock %}
|
||||
|
||||
{% block jquery_css %}
|
||||
{# Remove jquery css #}
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_js %}
|
||||
{# This script contains the code to perform requests to manipulate the
|
||||
user basket without having to reload the page #}
|
||||
|
@ -93,13 +93,14 @@ $min_col_width: 100px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0;
|
||||
row-gap: 10px;
|
||||
gap: 20px;
|
||||
padding: $padding;
|
||||
width: 100%;
|
||||
|
||||
|
||||
>.role_text {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
|
||||
>h4 {
|
||||
@ -107,7 +108,6 @@ $min_col_width: 100px;
|
||||
}
|
||||
|
||||
.role_description {
|
||||
flex-grow: 1;
|
||||
margin-top: .5em;
|
||||
text-wrap: auto;
|
||||
text-align: left;
|
||||
|
@ -6,7 +6,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-06-04 09:58+0200\n"
|
||||
"POT-Creation-Date: 2025-06-30 15:15+0200\n"
|
||||
"PO-Revision-Date: 2016-07-18\n"
|
||||
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||
@ -35,6 +35,68 @@ msgstr ""
|
||||
"True si gardé à jour par le biais d'un fournisseur externe de domains "
|
||||
"toxics, False sinon"
|
||||
|
||||
#: api/admin.py
|
||||
#, python-format
|
||||
msgid ""
|
||||
"The API key for %(name)s is: %(key)s. Please store it somewhere safe: you "
|
||||
"will not be able to see it again."
|
||||
msgstr ""
|
||||
"La clef d'API pour %(name)s est : %(key)s. Gardez-là dans un emplacement "
|
||||
"sûr : vous ne pourrez pas la revoir à nouveau."
|
||||
|
||||
#: api/admin.py
|
||||
msgid "Revoke selected API keys"
|
||||
msgstr "Révoquer les clefs d'API sélectionnées"
|
||||
|
||||
#: api/models.py club/models.py com/models.py counter/models.py forum/models.py
|
||||
msgid "name"
|
||||
msgstr "nom"
|
||||
|
||||
#: api/models.py core/models.py
|
||||
msgid "owner"
|
||||
msgstr "propriétaire"
|
||||
|
||||
#: api/models.py core/models.py
|
||||
msgid "groups"
|
||||
msgstr "groupes"
|
||||
|
||||
#: api/models.py
|
||||
msgid "client permissions"
|
||||
msgstr "permissions du client"
|
||||
|
||||
#: api/models.py
|
||||
msgid "Specific permissions for this api client."
|
||||
msgstr "Permissions spécifiques pour ce client d'API"
|
||||
|
||||
#: api/models.py
|
||||
msgid "api client"
|
||||
msgstr "client d'api"
|
||||
|
||||
#: api/models.py
|
||||
msgid "api clients"
|
||||
msgstr "clients d'api"
|
||||
|
||||
#: api/models.py
|
||||
msgid "prefix"
|
||||
msgstr "préfixe"
|
||||
|
||||
#: api/models.py
|
||||
msgid "hashed key"
|
||||
msgstr "hash de la clef"
|
||||
|
||||
#: api/models.py
|
||||
msgctxt "api key"
|
||||
msgid "revoked"
|
||||
msgstr "révoquée"
|
||||
|
||||
#: api/models.py
|
||||
msgid "api key"
|
||||
msgstr "clef d'api"
|
||||
|
||||
#: api/models.py
|
||||
msgid "api keys"
|
||||
msgstr "clefs d'api"
|
||||
|
||||
#: club/forms.py
|
||||
msgid "Users to add"
|
||||
msgstr "Utilisateurs à ajouter"
|
||||
@ -119,10 +181,6 @@ msgstr "Vous devez choisir un rôle"
|
||||
msgid "You do not have the permission to do that"
|
||||
msgstr "Vous n'avez pas la permission de faire cela"
|
||||
|
||||
#: club/models.py com/models.py counter/models.py forum/models.py
|
||||
msgid "name"
|
||||
msgstr "nom"
|
||||
|
||||
#: club/models.py
|
||||
msgid "slug name"
|
||||
msgstr "nom slug"
|
||||
@ -181,7 +239,7 @@ msgid "role"
|
||||
msgstr "rôle"
|
||||
|
||||
#: club/models.py core/models.py counter/models.py election/models.py
|
||||
#: forum/models.py
|
||||
#: forum/models.py reservation/models.py
|
||||
msgid "description"
|
||||
msgstr "description"
|
||||
|
||||
@ -457,6 +515,18 @@ msgstr "Nouveau Trombi"
|
||||
msgid "Posters"
|
||||
msgstr "Affiches"
|
||||
|
||||
#: club/templates/club/club_tools.jinja
|
||||
msgid "Reservable rooms"
|
||||
msgstr "Salles réservables"
|
||||
|
||||
#: club/templates/club/club_tools.jinja
|
||||
msgid "Add a room"
|
||||
msgstr "Ajouter une salle"
|
||||
|
||||
#: club/templates/club/club_tools.jinja
|
||||
msgid "This club manages no reservable room"
|
||||
msgstr "Ce club ne gère pas de salle réservable"
|
||||
|
||||
#: club/templates/club/club_tools.jinja
|
||||
msgid "Counters:"
|
||||
msgstr "Comptoirs : "
|
||||
@ -669,8 +739,7 @@ msgstr "message d'info"
|
||||
msgid "weekmail destinations"
|
||||
msgstr "destinataires du weekmail"
|
||||
|
||||
#: com/models.py core/templates/core/macros.jinja election/models.py
|
||||
#: forum/models.py pedagogy/models.py
|
||||
#: com/models.py election/models.py forum/models.py pedagogy/models.py
|
||||
msgid "title"
|
||||
msgstr "titre"
|
||||
|
||||
@ -698,7 +767,7 @@ msgstr "Une description plus détaillée et exhaustive de l'évènement."
|
||||
msgid "The club which organizes the event."
|
||||
msgstr "Le club qui organise l'évènement."
|
||||
|
||||
#: com/models.py pedagogy/models.py trombi/models.py
|
||||
#: com/models.py pedagogy/models.py reservation/models.py trombi/models.py
|
||||
msgid "author"
|
||||
msgstr "auteur"
|
||||
|
||||
@ -844,7 +913,7 @@ msgid "News admin"
|
||||
msgstr "Administration des nouvelles"
|
||||
|
||||
#: com/templates/com/news_admin_list.jinja com/templates/com/news_detail.jinja
|
||||
#: com/templates/com/news_list.jinja com/views.py
|
||||
#: com/views.py
|
||||
msgid "News"
|
||||
msgstr "Nouvelles"
|
||||
|
||||
@ -986,6 +1055,11 @@ msgstr "Guide des UVs"
|
||||
msgid "Matmatronch"
|
||||
msgstr "Matmatronch"
|
||||
|
||||
#: com/templates/com/news_list.jinja
|
||||
#: reservation/templates/reservation/schedule.jinja
|
||||
msgid "Room reservation"
|
||||
msgstr "Réservation de salle"
|
||||
|
||||
#: com/templates/com/news_list.jinja core/templates/core/base/navbar.jinja
|
||||
#: core/templates/core/user_tools.jinja
|
||||
msgid "Elections"
|
||||
@ -1095,7 +1169,7 @@ msgstr "Nouvel article"
|
||||
msgid "Articles in no weekmail yet"
|
||||
msgstr "Articles dans aucun weekmail"
|
||||
|
||||
#: com/templates/com/weekmail.jinja core/templates/core/macros.jinja
|
||||
#: com/templates/com/weekmail.jinja
|
||||
msgid "Content"
|
||||
msgstr "Contenu"
|
||||
|
||||
@ -1257,10 +1331,6 @@ msgstr "surnom"
|
||||
msgid "last update"
|
||||
msgstr "dernière mise à jour"
|
||||
|
||||
#: core/models.py
|
||||
msgid "groups"
|
||||
msgstr "groupes"
|
||||
|
||||
#: core/models.py
|
||||
msgid ""
|
||||
"The groups this user belongs to. A user will get all permissions granted to "
|
||||
@ -1497,10 +1567,6 @@ msgstr "version allégée"
|
||||
msgid "thumbnail"
|
||||
msgstr "miniature"
|
||||
|
||||
#: core/models.py
|
||||
msgid "owner"
|
||||
msgstr "propriétaire"
|
||||
|
||||
#: core/models.py
|
||||
msgid "edit group"
|
||||
msgstr "groupe d'édition"
|
||||
@ -1659,27 +1725,27 @@ msgstr "500, Erreur Serveur"
|
||||
msgid "Welcome!"
|
||||
msgstr "Bienvenue !"
|
||||
|
||||
#: core/templates/core/base.jinja core/templates/core/base/navbar.jinja
|
||||
#: core/templates/core/base/footer.jinja core/templates/core/base/navbar.jinja
|
||||
msgid "Contacts"
|
||||
msgstr "Contacts"
|
||||
|
||||
#: core/templates/core/base.jinja
|
||||
#: core/templates/core/base/footer.jinja
|
||||
msgid "Legal notices"
|
||||
msgstr "Mentions légales"
|
||||
|
||||
#: core/templates/core/base.jinja
|
||||
#: core/templates/core/base/footer.jinja
|
||||
msgid "Intellectual property"
|
||||
msgstr "Propriété intellectuelle"
|
||||
|
||||
#: core/templates/core/base.jinja
|
||||
#: core/templates/core/base/footer.jinja
|
||||
msgid "Help & Documentation"
|
||||
msgstr "Aide & Documentation"
|
||||
|
||||
#: core/templates/core/base.jinja
|
||||
#: core/templates/core/base/footer.jinja
|
||||
msgid "R&D"
|
||||
msgstr "R&D"
|
||||
|
||||
#: core/templates/core/base.jinja
|
||||
#: core/templates/core/base/footer.jinja
|
||||
msgid "Site created by the IT Department of the AE"
|
||||
msgstr "Site réalisé par le Pôle Informatique de l'AE"
|
||||
|
||||
@ -1819,6 +1885,7 @@ msgstr "Confirmation"
|
||||
#: core/templates/core/file_delete_confirm.jinja
|
||||
#: counter/templates/counter/counter_click.jinja
|
||||
#: counter/templates/counter/fragments/delete_student_card.jinja
|
||||
#: reservation/templates/reservation/fragments/create_reservation.jinja
|
||||
#: sas/templates/sas/ask_picture_removal.jinja
|
||||
msgid "Cancel"
|
||||
msgstr "Annuler"
|
||||
@ -1966,10 +2033,8 @@ msgid "Please login or create an account to see this page."
|
||||
msgstr "Merci de vous identifier ou de créer un compte pour voir cette page."
|
||||
|
||||
#: core/templates/core/login.jinja
|
||||
msgid "Your username and password didn't match. Please try again."
|
||||
msgstr ""
|
||||
"Votre nom d'utilisateur et votre mot de passe ne correspondent pas. Merci de "
|
||||
"réessayer."
|
||||
msgid "Your credentials didn't match. Please try again."
|
||||
msgstr "Vos identifiants ne correspondent pas. Veuillez réessayer."
|
||||
|
||||
#: core/templates/core/login.jinja
|
||||
msgid "Lost password?"
|
||||
@ -2725,10 +2790,6 @@ msgstr "Erreur d'envoi du fichier %(file_name)s : %(msg)s"
|
||||
msgid "Apply rights recursively"
|
||||
msgstr "Appliquer les droits récursivement"
|
||||
|
||||
#: core/views/forms.py
|
||||
msgid "Choose file"
|
||||
msgstr "Choisir un fichier"
|
||||
|
||||
#: core/views/forms.py
|
||||
msgid "Choose user"
|
||||
msgstr "Choisir un utilisateur"
|
||||
@ -2949,7 +3010,7 @@ msgstr "Mettre à True si le mail a reçu une erreur"
|
||||
msgid "The operation that emptied the account."
|
||||
msgstr "L'opération qui a vidé le compte."
|
||||
|
||||
#: counter/models.py pedagogy/models.py
|
||||
#: counter/models.py pedagogy/models.py reservation/models.py
|
||||
msgid "comment"
|
||||
msgstr "commentaire"
|
||||
|
||||
@ -4529,6 +4590,78 @@ msgstr "Autocomplétion réussite"
|
||||
msgid "An error occurred: "
|
||||
msgstr "Une erreur est survenue : "
|
||||
|
||||
#: reservation/forms.py
|
||||
msgid "The start must be set before the end"
|
||||
msgstr "Le début doit être placé avant la fin"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "room name"
|
||||
msgstr "Nom de la salle"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "room owner"
|
||||
msgstr "propriétaire de la salle"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "The club which manages this room"
|
||||
msgstr "Le club qui gère cette salle"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "site"
|
||||
msgstr "site"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "reservable room"
|
||||
msgstr "salle réservable"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "reservable rooms"
|
||||
msgstr "salles réservables"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "reserved room"
|
||||
msgstr "salle réservée"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "slot start"
|
||||
msgstr "début du créneau"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "slot end"
|
||||
msgstr "fin du créneau"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "reservation slot"
|
||||
msgstr "créneau de réservation"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "reservation slots"
|
||||
msgstr "créneaux de réservation"
|
||||
|
||||
#: reservation/models.py
|
||||
msgid "There is already a reservation on this slot."
|
||||
msgstr "Il y a déjà une réservation sur ce créneau."
|
||||
|
||||
#: reservation/templates/reservation/fragments/create_reservation.jinja
|
||||
msgid "Book a room"
|
||||
msgstr "Réserver une salle"
|
||||
|
||||
#: reservation/templates/reservation/schedule.jinja
|
||||
msgid "You can book a room by selecting a free slot in the calendar."
|
||||
msgstr ""
|
||||
"Vous pouvez réserver une salle en sélectionnant un emplacement libre dans le "
|
||||
"calendrier."
|
||||
|
||||
#: reservation/views.py
|
||||
#, python-format
|
||||
msgid "%(name)s was created successfully"
|
||||
msgstr "%(name)s a été créé avec succès"
|
||||
|
||||
#: reservation/views.py
|
||||
#, python-format
|
||||
msgid "%(name)s was updated successfully"
|
||||
msgstr "%(name)s a été mis à jour avec succès"
|
||||
|
||||
#: rootplace/forms.py
|
||||
msgid "User that will be kept"
|
||||
msgstr "Utilisateur qui sera conservé"
|
||||
@ -5058,8 +5191,9 @@ msgid "There are %s pictures to be moderated in the SAS"
|
||||
msgstr "Il y a %s photos à modérer dans le SAS"
|
||||
|
||||
#: sith/settings.py
|
||||
msgid "You've been identified on some pictures"
|
||||
msgstr "Vous avez été identifié sur des photos"
|
||||
#, python-format
|
||||
msgid "You've been identified in album %s"
|
||||
msgstr "Vous avez été identifié dans l'album %s"
|
||||
|
||||
#: sith/settings.py
|
||||
#, python-format
|
||||
|
@ -7,7 +7,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-04-13 00:18+0200\n"
|
||||
"POT-Creation-Date: 2025-06-30 22:48+0200\n"
|
||||
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
|
||||
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||
@ -37,15 +37,15 @@ msgstr "Supprimer"
|
||||
msgid "Copy calendar link"
|
||||
msgstr "Copier le lien du calendrier"
|
||||
|
||||
#: com/static/bundled/com/components/ics-calendar-index.ts
|
||||
msgid "How to use calendar link"
|
||||
msgstr "Comment utiliser le lien du calendrier"
|
||||
|
||||
#: com/static/bundled/com/components/ics-calendar-index.ts
|
||||
msgid "Link copied"
|
||||
msgstr "Lien copié"
|
||||
|
||||
#: com/static/bundled/com/components/moderation-alert-index.ts
|
||||
#: com/static/bundled/com/components/ics-calendar-index.ts
|
||||
msgid "How to use calendar link"
|
||||
msgstr "Comment utiliser le lien du calendrier"
|
||||
|
||||
#: com/static/bundled/com/moderation-alert-index.ts
|
||||
#, javascript-format
|
||||
msgid ""
|
||||
"This event will take place every week for %s weeks. If you publish or delete "
|
||||
@ -193,7 +193,7 @@ msgstr "Montrer moins"
|
||||
msgid "Show more"
|
||||
msgstr "Montrer plus"
|
||||
|
||||
#: core/static/bundled/user/family-graph-index.js
|
||||
#: core/static/bundled/user/family-graph-index.ts
|
||||
msgid "family_tree.%(extension)s"
|
||||
msgstr "arbre_genealogique.%(extension)s"
|
||||
|
||||
@ -251,6 +251,14 @@ msgstr "Types de produits réordonnés !"
|
||||
msgid "Product type reorganisation failed with status code : %d"
|
||||
msgstr "La réorganisation des types de produit a échoué avec le code : %d"
|
||||
|
||||
#: reservation/static/bundled/reservation/components/room-scheduler-index.ts
|
||||
msgid "Rooms"
|
||||
msgstr "Salles"
|
||||
|
||||
#: reservation/static/bundled/reservation/slot-reservation-index.ts
|
||||
msgid "This slot has been successfully moved"
|
||||
msgstr "Ce créneau a été bougé avec succès"
|
||||
|
||||
#: sas/static/bundled/sas/pictures-download-index.ts
|
||||
msgid "pictures.%(extension)s"
|
||||
msgstr "photos.%(extension)s"
|
||||
|
10
mkdocs.yml
10
mkdocs.yml
@ -45,7 +45,6 @@ plugins:
|
||||
members: true
|
||||
members_order: source
|
||||
show_source: true
|
||||
show_inherited_members: true
|
||||
merge_init_into_class: true
|
||||
show_root_toc_entry: false
|
||||
- include-markdown:
|
||||
@ -67,6 +66,9 @@ nav:
|
||||
- Gestion des permissions: tutorial/perms.md
|
||||
- Gestion des groupes: tutorial/groups.md
|
||||
- Les fragments: tutorial/fragments.md
|
||||
- API:
|
||||
- Développement: tutorial/api/dev.md
|
||||
- Connexion à l'API: tutorial/api/connect.md
|
||||
- Etransactions: tutorial/etransaction.md
|
||||
- How-to:
|
||||
- L'ORM de Django: howto/querysets.md
|
||||
@ -84,6 +86,11 @@ nav:
|
||||
- antispam:
|
||||
- reference/antispam/models.md
|
||||
- reference/antispam/forms.md
|
||||
- api:
|
||||
- reference/api/auth.md
|
||||
- reference/api/hashers.md
|
||||
- reference/api/models.md
|
||||
- reference/api/perms.md
|
||||
- club:
|
||||
- reference/club/models.md
|
||||
- reference/club/views.md
|
||||
@ -153,6 +160,7 @@ markdown_extensions:
|
||||
- pymdownx.details
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.keys
|
||||
- pymdownx.blocks.caption
|
||||
- pymdownx.superfences:
|
||||
custom_fences:
|
||||
- name: mermaid
|
||||
|
2607
package-lock.json
generated
2607
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user