mirror of
https://github.com/ae-utbm/sith.git
synced 2025-07-12 21:09:24 +00:00
Compare commits
4 Commits
Author | SHA1 | Date | |
---|---|---|---|
5788c1a8b3 | |||
ca31584ce1 | |||
30a8a9dc7c | |||
2e2676fd1f |
14
.github/actions/setup_project/action.yml
vendored
14
.github/actions/setup_project/action.yml
vendored
@ -1,24 +1,15 @@
|
||||
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"
|
||||
@ -46,20 +37,15 @@ 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,8 +37,6 @@ 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,7 +2,11 @@ name: deploy_docs
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- taiste
|
||||
- master
|
||||
env:
|
||||
SECRET_KEY: notTheRealOne
|
||||
DATABASE_URL: sqlite:///db.sqlite3
|
||||
CACHE_URL: redis://127.0.0.1:6379/0
|
||||
permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
|
@ -1,7 +1,7 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.11.13
|
||||
rev: v0.11.11
|
||||
hooks:
|
||||
- id: ruff-check # just check the code, and print the errors
|
||||
- id: ruff-check # actually fix the fixable errors, but print nothing
|
||||
|
55
api/admin.py
55
api/admin.py
@ -1,55 +0,0 @@
|
||||
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)
|
@ -1,6 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "api"
|
20
api/auth.py
20
api/auth.py
@ -1,20 +0,0 @@
|
||||
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
|
@ -1,43 +0,0 @@
|
||||
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)
|
@ -1,113 +0,0 @@
|
||||
# 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")],
|
||||
},
|
||||
),
|
||||
]
|
@ -1,94 +0,0 @@
|
||||
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}***)"
|
@ -1,29 +0,0 @@
|
||||
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
10
api/urls.py
@ -1,10 +0,0 @@
|
||||
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,42 +1,22 @@
|
||||
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 api.auth import ApiKeyAuth
|
||||
from api.permissions import CanAccessLookup, HasPerm
|
||||
from club.models import Club, Membership
|
||||
from club.schemas import ClubSchema, SimpleClubSchema
|
||||
from club.models import Club
|
||||
from club.schemas import ClubSchema
|
||||
from core.auth.api_permissions import CanAccessLookup
|
||||
|
||||
|
||||
@api_controller("/club")
|
||||
class ClubController(ControllerBase):
|
||||
@route.get(
|
||||
"/search",
|
||||
response=PaginatedResponseSchema[SimpleClubSchema],
|
||||
auth=[SessionAuth(), ApiKeyAuth()],
|
||||
response=PaginatedResponseSchema[ClubSchema],
|
||||
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,16 +163,15 @@ class SellingsForm(forms.Form):
|
||||
|
||||
def __init__(self, club, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# 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(
|
||||
Q(club=club)
|
||||
| Q(products__club=club)
|
||||
| Exists(Selling.objects.filter(counter=OuterRef("pk"), club=club))
|
||||
)
|
||||
.distinct()
|
||||
.order_by(Lower("name"))
|
||||
)
|
||||
counters_qs = Counter.objects.filter(id__in=ids).order_by(Lower("name"))
|
||||
self.fields["counters"] = forms.ModelMultipleChoiceField(
|
||||
counters_qs, label=_("Counter"), required=False
|
||||
)
|
||||
|
@ -1,10 +1,9 @@
|
||||
from ninja import ModelSchema
|
||||
|
||||
from club.models import Club, Membership
|
||||
from core.schemas import SimpleUserSchema
|
||||
from club.models import Club
|
||||
|
||||
|
||||
class SimpleClubSchema(ModelSchema):
|
||||
class ClubSchema(ModelSchema):
|
||||
class Meta:
|
||||
model = Club
|
||||
fields = ["id", "name"]
|
||||
@ -22,19 +21,3 @@ 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,43 +0,0 @@
|
||||
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
|
@ -1,7 +1,7 @@
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from club.models import Club
|
||||
from club.schemas import SimpleClubSchema
|
||||
from club.schemas import ClubSchema
|
||||
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[SimpleClubSchema])
|
||||
adapter = TypeAdapter(list[ClubSchema])
|
||||
|
||||
js = _js
|
||||
|
||||
@ -21,6 +21,6 @@ class AutoCompleteSelectClub(AutoCompleteSelect):
|
||||
class AutoCompleteSelectMultipleClub(AutoCompleteSelectMultiple):
|
||||
component_name = "club-ajax-select"
|
||||
model = Club
|
||||
adapter = TypeAdapter(list[SimpleClubSchema])
|
||||
adapter = TypeAdapter(list[ClubSchema])
|
||||
|
||||
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,6 +170,7 @@ def news_notification_callback(notif: Notification):
|
||||
if count:
|
||||
notif.viewed = False
|
||||
notif.param = str(count)
|
||||
notif.date = timezone.now()
|
||||
else:
|
||||
notif.viewed = True
|
||||
|
||||
@ -193,7 +194,7 @@ class NewsDateQuerySet(models.QuerySet):
|
||||
class NewsDate(models.Model):
|
||||
"""A date associated with news.
|
||||
|
||||
A [News][com.models.News] can have multiple dates, for example if it is a recurring event.
|
||||
A [News][] can have multiple dates, for example if it is a recurring event.
|
||||
"""
|
||||
|
||||
news = models.ForeignKey(
|
||||
|
@ -7,7 +7,6 @@ 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,
|
||||
@ -177,25 +176,29 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
oldPopup.remove();
|
||||
}
|
||||
|
||||
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 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 makePopupTitle = (event: EventImpl) => {
|
||||
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>
|
||||
const row = document.createElement("div");
|
||||
row.innerHTML = `
|
||||
<h4 class="event-details-row-content">
|
||||
${event.title}
|
||||
</h4>
|
||||
<span class="event-details-row-content">
|
||||
${this.formatDate(event.start)} - ${this.formatDate(event.end)}
|
||||
</span>
|
||||
`;
|
||||
return makePopupInfo(
|
||||
row,
|
||||
@ -207,11 +210,9 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
if (event.extendedProps.location === null) {
|
||||
return null;
|
||||
}
|
||||
const info = html`
|
||||
<div>
|
||||
${event.extendedProps.location}
|
||||
</div>
|
||||
`;
|
||||
const info = document.createElement("div");
|
||||
info.innerText = event.extendedProps.location;
|
||||
|
||||
return makePopupInfo(info, "fa-solid fa-location-dot");
|
||||
};
|
||||
|
||||
@ -219,7 +220,10 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
if (event.url === "") {
|
||||
return null;
|
||||
}
|
||||
const url = html`<a href="${event.url}">${gettext("More info")}</a>`;
|
||||
const url = document.createElement("a");
|
||||
url.href = event.url;
|
||||
url.textContent = gettext("More info");
|
||||
|
||||
return makePopupInfo(url, "fa-solid fa-link");
|
||||
};
|
||||
|
||||
@ -228,59 +232,64 @@ export class IcsCalendar extends inheritHtmlElement("div") {
|
||||
return null;
|
||||
}
|
||||
const newsId = this.getNewsId(event);
|
||||
const buttons = [] as HTMLTemplateResult[];
|
||||
|
||||
const div = document.createElement("div");
|
||||
if (this.canModerate) {
|
||||
if (event.source.internalEventSource.ui.classNames.includes("unpublished")) {
|
||||
const button = html`
|
||||
<button class="btn btn-green" @click="${() => this.publishNews(newsId)}">
|
||||
<i class="fa fa-check"></i>${gettext("Publish")}
|
||||
</button>
|
||||
`;
|
||||
buttons.push(button);
|
||||
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);
|
||||
} else {
|
||||
const button = html`
|
||||
<button class="btn btn-orange" @click="${() => this.unpublishNews(newsId)}">
|
||||
<i class="fa fa-times"></i>${gettext("Unpublish")}
|
||||
</button>
|
||||
`;
|
||||
buttons.push(button);
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (this.canDelete) {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
return makePopupInfo(html`<div>${buttons}</div>`, "fa-solid fa-toolbox");
|
||||
return makePopupInfo(div, "fa-solid fa-toolbox");
|
||||
};
|
||||
|
||||
// Create new popup
|
||||
const infos = [] as HTMLTemplateResult[];
|
||||
infos.push(makePopupTitle(event.event));
|
||||
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 location = makePopupLocation(event.event);
|
||||
if (location !== null) {
|
||||
infos.push(location);
|
||||
popupContainer.appendChild(location);
|
||||
}
|
||||
|
||||
const url = makePopupUrl(event.event);
|
||||
if (url !== null) {
|
||||
infos.push(url);
|
||||
popupContainer.appendChild(url);
|
||||
}
|
||||
|
||||
const tools = makePopupTools(event.event);
|
||||
if (tools !== null) {
|
||||
infos.push(tools);
|
||||
popupContainer.appendChild(tools);
|
||||
}
|
||||
|
||||
const popup = document.createElement("div");
|
||||
popup.setAttribute("id", "event-details");
|
||||
render(html`<div class="event-details-container">${infos}</div>`, popup);
|
||||
popup.appendChild(popupContainer);
|
||||
|
||||
// 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,17 +8,13 @@ interface ParsedNewsDateSchema extends Omit<NewsDateSchema, "start_date" | "end_
|
||||
}
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
Alpine.data("upcomingNewsLoader", (startDate: Date, locale: string) => ({
|
||||
Alpine.data("upcomingNewsLoader", (startDate: Date) => ({
|
||||
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;
|
@ -18,7 +18,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block additional_js %}
|
||||
<script type="module" src={{ static("bundled/com/moderation-alert-index.ts") }}></script>
|
||||
<script type="module" src={{ static("bundled/com/components/moderation-alert-index.ts") }}></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
@ -15,8 +15,8 @@
|
||||
|
||||
{% block additional_js %}
|
||||
<script type="module" src={{ static("bundled/com/components/ics-calendar-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>
|
||||
<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>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@ -84,11 +84,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 +103,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div x-data="upcomingNewsLoader(new Date('{{ last_day + timedelta(days=1) }}'), '{{ get_language() }}')">
|
||||
<div x-data="upcomingNewsLoader(new Date('{{ last_day + timedelta(days=1) }}'))">
|
||||
<template x-for="newsList in Object.values(groupedDates())">
|
||||
<div class="news_events_group">
|
||||
<div class="news_events_group_date">
|
||||
@ -139,11 +139,11 @@
|
||||
<div class="news_date">
|
||||
<time
|
||||
:datetime="newsDate.start_date.toISOString()"
|
||||
x-text="dateFormat.format(newsDate.start_date)"
|
||||
x-text="`${newsDate.start_date.getHours()}:${newsDate.start_date.getMinutes()}`"
|
||||
></time> -
|
||||
<time
|
||||
:datetime="newsDate.end_date.toISOString()"
|
||||
x-text="dateFormat.format(newsDate.end_date)"
|
||||
x-text="`${newsDate.end_date.getHours()}:${newsDate.end_date.getMinutes()}`"
|
||||
></time>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -88,9 +88,9 @@ class PageAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(SithFile)
|
||||
class SithFileAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "owner", "size", "date", "is_in_sas")
|
||||
list_display = ("name", "owner", "size", "date")
|
||||
autocomplete_fields = ("parent", "owner", "moderator")
|
||||
search_fields = ("name", "parent__name")
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
@admin.register(OperationLog)
|
||||
|
@ -5,15 +5,13 @@ 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,
|
||||
@ -92,12 +90,11 @@ class SithFileController(ControllerBase):
|
||||
@route.get(
|
||||
"/search",
|
||||
response=PaginatedResponseSchema[SithFileSchema],
|
||||
auth=[SessionAuth(), ApiKeyAuth()],
|
||||
permissions=[CanAccessLookup],
|
||||
)
|
||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||
def search_files(self, search: Annotated[str, annotated_types.MinLen(1)]):
|
||||
return SithFile.objects.filter(is_in_sas=False).filter(name__icontains=search)
|
||||
return SithFile.objects.filter(name__icontains=search)
|
||||
|
||||
|
||||
@api_controller("/group")
|
||||
@ -105,7 +102,6 @@ class GroupController(ControllerBase):
|
||||
@route.get(
|
||||
"/search",
|
||||
response=PaginatedResponseSchema[GroupSchema],
|
||||
auth=[SessionAuth(), ApiKeyAuth()],
|
||||
permissions=[CanAccessLookup],
|
||||
)
|
||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||
|
@ -39,7 +39,7 @@ Example:
|
||||
|
||||
import operator
|
||||
from functools import reduce
|
||||
from typing import Any, Callable
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.http import HttpRequest
|
||||
@ -67,26 +67,21 @@ 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): ...
|
||||
# this route will require both permissions
|
||||
@route.put("/foo", permissions=[HasPerm(["foo.change_foo", "foo.add_foo"])]
|
||||
def foo(self): ...
|
||||
|
||||
# This route will require at least one of the perm,
|
||||
# but it's not mandatory to have all of them
|
||||
@route.put(
|
||||
"/bar",
|
||||
permissions=[HasPerm(["foo.change_bar", "foo.add_bar"], op=operator.or_)],
|
||||
)
|
||||
def bar(self): ...
|
||||
```
|
||||
# This route will require at least one of the perm,
|
||||
# but it's not mandatory to have all of them
|
||||
@route.put(
|
||||
"/bar",
|
||||
permissions=[HasPerm(["foo.change_bar", "foo.add_bar"], op=operator.or_)],
|
||||
)
|
||||
def bar(self): ...
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
perms: str | Permission | list[str | Permission],
|
||||
op: Callable[[bool, bool], bool] = operator.and_,
|
||||
self, perms: str | Permission | list[str | Permission], op=operator.and_
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
@ -101,16 +96,7 @@ class HasPerm(BasePermission):
|
||||
self._perms = perms
|
||||
|
||||
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
|
||||
# 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))
|
||||
return reduce(self._operator, (request.user.has_perm(p) for p in self._perms))
|
||||
|
||||
|
||||
class IsRoot(BasePermission):
|
||||
@ -194,4 +180,4 @@ class IsLoggedInCounter(BasePermission):
|
||||
return Counter.objects.filter(token=token).exists()
|
||||
|
||||
|
||||
CanAccessLookup = IsLoggedInCounter | HasPerm("core.access_lookup")
|
||||
CanAccessLookup = IsOldSubscriber | IsRoot | IsLoggedInCounter
|
@ -4,13 +4,13 @@
|
||||
VERSION="$1"
|
||||
|
||||
# Cleanup env vars for auto discovery mechanism
|
||||
unset CPATH
|
||||
unset LIBRARY_PATH
|
||||
unset CFLAGS
|
||||
unset LDFLAGS
|
||||
unset CCFLAGS
|
||||
unset CXXFLAGS
|
||||
unset CPPFLAGS
|
||||
export CPATH=
|
||||
export LIBRARY_PATH=
|
||||
export CFLAGS=
|
||||
export LDFLAGS=
|
||||
export CCFLAGS=
|
||||
export CXXFLAGS=
|
||||
export CPPFLAGS=
|
||||
|
||||
# prepare
|
||||
rm -rf "$VIRTUAL_ENV/packages"
|
||||
|
@ -59,7 +59,6 @@ class PopulatedGroups(NamedTuple):
|
||||
counter_admin: Group
|
||||
accounting_admin: Group
|
||||
pedagogy_admin: Group
|
||||
campus_admin: Group
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@ -110,7 +109,6 @@ class Command(BaseCommand):
|
||||
p.save(force_lock=True)
|
||||
|
||||
club_root = SithFile.objects.create(name="clubs", owner=root)
|
||||
sas = SithFile.objects.create(name="SAS", owner=root)
|
||||
main_club = Club.objects.create(
|
||||
id=1, name="AE", address="6 Boulevard Anatole France, 90000 Belfort"
|
||||
)
|
||||
@ -693,33 +691,21 @@ class Command(BaseCommand):
|
||||
# SAS
|
||||
for f in self.SAS_FIXTURE_PATH.glob("*"):
|
||||
if f.is_dir():
|
||||
album = Album(
|
||||
parent=sas,
|
||||
name=f.name,
|
||||
owner=root,
|
||||
is_folder=True,
|
||||
is_in_sas=True,
|
||||
is_moderated=True,
|
||||
)
|
||||
album.clean()
|
||||
album.save()
|
||||
album = Album.objects.create(name=f.name, is_moderated=True)
|
||||
for p in f.iterdir():
|
||||
file = resize_image(Image.open(p), 1000, "WEBP")
|
||||
pict = Picture(
|
||||
parent=album,
|
||||
name=p.name,
|
||||
file=file,
|
||||
original=file,
|
||||
owner=root,
|
||||
is_folder=False,
|
||||
is_in_sas=True,
|
||||
is_moderated=True,
|
||||
mime_type="image/webp",
|
||||
size=file.size,
|
||||
)
|
||||
pict.file.name = p.name
|
||||
pict.full_clean()
|
||||
pict.original.name = pict.name
|
||||
pict.generate_thumbnails()
|
||||
pict.full_clean()
|
||||
pict.save()
|
||||
album.generate_thumbnail()
|
||||
|
||||
img_skia = Picture.objects.get(name="skia.jpg")
|
||||
img_sli = Picture.objects.get(name="sli.jpg")
|
||||
@ -785,13 +771,13 @@ class Command(BaseCommand):
|
||||
# public has no permission.
|
||||
# Its purpose is not to link users to permissions,
|
||||
# but to other objects (like products)
|
||||
public_group = Group.objects.create(name="Publique")
|
||||
public_group = Group.objects.create(name="Public")
|
||||
|
||||
subscribers = Group.objects.create(name="Cotisants")
|
||||
subscribers = Group.objects.create(name="Subscribers")
|
||||
subscribers.permissions.add(
|
||||
*list(perms.filter(codename__in=["add_news", "add_uvcomment"]))
|
||||
)
|
||||
old_subscribers = Group.objects.create(name="Anciens cotisants")
|
||||
old_subscribers = Group.objects.create(name="Old subscribers")
|
||||
old_subscribers.permissions.add(
|
||||
*list(
|
||||
perms.filter(
|
||||
@ -806,14 +792,12 @@ class Command(BaseCommand):
|
||||
"add_peoplepicturerelation",
|
||||
"add_page",
|
||||
"add_quickuploadimage",
|
||||
"view_club",
|
||||
"access_lookup",
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
accounting_admin = Group.objects.create(
|
||||
name="Admin comptabilité", is_manually_manageable=True
|
||||
name="Accounting admin", is_manually_manageable=True
|
||||
)
|
||||
accounting_admin.permissions.add(
|
||||
*list(
|
||||
@ -834,7 +818,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
)
|
||||
com_admin = Group.objects.create(
|
||||
name="Admin communication", is_manually_manageable=True
|
||||
name="Communication admin", is_manually_manageable=True
|
||||
)
|
||||
com_admin.permissions.add(
|
||||
*list(
|
||||
@ -842,7 +826,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
)
|
||||
counter_admin = Group.objects.create(
|
||||
name="Admin comptoirs", is_manually_manageable=True
|
||||
name="Counter admin", is_manually_manageable=True
|
||||
)
|
||||
counter_admin.permissions.add(
|
||||
*list(
|
||||
@ -852,14 +836,14 @@ class Command(BaseCommand):
|
||||
)
|
||||
)
|
||||
)
|
||||
sas_admin = Group.objects.create(name="Admin SAS", is_manually_manageable=True)
|
||||
sas_admin = Group.objects.create(name="SAS admin", is_manually_manageable=True)
|
||||
sas_admin.permissions.add(
|
||||
*list(
|
||||
perms.filter(content_type__app_label="sas").values_list("pk", flat=True)
|
||||
)
|
||||
)
|
||||
forum_admin = Group.objects.create(
|
||||
name="Admin forum", is_manually_manageable=True
|
||||
name="Forum admin", is_manually_manageable=True
|
||||
)
|
||||
forum_admin.permissions.add(
|
||||
*list(
|
||||
@ -869,7 +853,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
)
|
||||
pedagogy_admin = Group.objects.create(
|
||||
name="Admin pédagogie", is_manually_manageable=True
|
||||
name="Pedagogy admin", is_manually_manageable=True
|
||||
)
|
||||
pedagogy_admin.permissions.add(
|
||||
*list(
|
||||
@ -878,16 +862,6 @@ 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(
|
||||
@ -900,7 +874,6 @@ class Command(BaseCommand):
|
||||
accounting_admin=accounting_admin,
|
||||
sas_admin=sas_admin,
|
||||
pedagogy_admin=pedagogy_admin,
|
||||
campus_admin=campus_admin,
|
||||
)
|
||||
|
||||
def _create_ban_groups(self):
|
||||
|
@ -238,13 +238,7 @@ 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(
|
||||
id__in=[
|
||||
settings.SITH_GROUP_SUBSCRIBERS_ID,
|
||||
settings.SITH_GROUP_OLD_SUBSCRIBERS_ID,
|
||||
settings.SITH_GROUP_PUBLIC_ID,
|
||||
]
|
||||
)
|
||||
Group.objects.filter(name__in=["Subscribers", "Old subscribers", "Public"])
|
||||
)
|
||||
counters = list(
|
||||
Counter.objects.filter(name__in=["Foyer", "MDE", "La Gommette", "Eboutic"])
|
||||
|
@ -1,28 +0,0 @@
|
||||
# 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": [],
|
||||
},
|
||||
),
|
||||
]
|
27
core/migrations/0046_remove_sithfiles.py
Normal file
27
core/migrations/0046_remove_sithfiles.py
Normal file
@ -0,0 +1,27 @@
|
||||
# Generated by Django 4.2.17 on 2025-01-26 15:01
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.db import migrations
|
||||
from django.db.migrations.state import StateApps
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import core.models
|
||||
|
||||
|
||||
def remove_sas_sithfiles(apps: StateApps, schema_editor):
|
||||
SithFile: type[core.models.SithFile] = apps.get_model("core", "SithFile")
|
||||
SithFile.objects.filter(is_in_sas=True).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0045_quickuploadimage"),
|
||||
("sas", "0006_alter_peoplepicturerelation_picture_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
remove_sas_sithfiles, reverse_code=migrations.RunPython.noop, elidable=True
|
||||
)
|
||||
]
|
@ -1,27 +0,0 @@
|
||||
# Generated by Django 5.2.1 on 2025-06-11 16:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import core.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("core", "0046_permissionrights")]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="notification",
|
||||
name="date",
|
||||
field=models.DateTimeField(auto_now=True, verbose_name="date"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="notification",
|
||||
name="type",
|
||||
field=models.CharField(
|
||||
choices=core.models.get_notification_types,
|
||||
default="GENERIC",
|
||||
max_length=32,
|
||||
verbose_name="type",
|
||||
),
|
||||
),
|
||||
]
|
9
core/migrations/0047_remove_sithfile_is_in_sas.py
Normal file
9
core/migrations/0047_remove_sithfile_is_in_sas.py
Normal file
@ -0,0 +1,9 @@
|
||||
# Generated by Django 4.2.17 on 2025-02-14 11:58
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("core", "0046_remove_sithfiles")]
|
||||
|
||||
operations = [migrations.RemoveField(model_name="sithfile", name="is_in_sas")]
|
@ -754,23 +754,6 @@ 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
|
||||
@ -863,9 +846,6 @@ class SithFile(models.Model):
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
asked_for_removal = models.BooleanField(_("asked for removal"), default=False)
|
||||
is_in_sas = models.BooleanField(
|
||||
_("is in the SAS"), default=False, db_index=True
|
||||
) # Allows to query this flag, updated at each call to save()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("file")
|
||||
@ -874,22 +854,10 @@ class SithFile(models.Model):
|
||||
return self.get_parent_path() + "/" + self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
sas = SithFile.objects.filter(id=settings.SITH_SAS_ROOT_DIR_ID).first()
|
||||
self.is_in_sas = sas in self.get_parent_list() or self == sas
|
||||
adding = self._state.adding
|
||||
super().save(*args, **kwargs)
|
||||
if adding:
|
||||
self.copy_rights()
|
||||
if self.is_in_sas:
|
||||
for user in User.objects.filter(
|
||||
groups__id__in=[settings.SITH_GROUP_SAS_ADMIN_ID]
|
||||
):
|
||||
Notification(
|
||||
user=user,
|
||||
url=reverse("sas:moderation"),
|
||||
type="SAS_MODERATION",
|
||||
param="1",
|
||||
).save()
|
||||
|
||||
def is_owned_by(self, user: User) -> bool:
|
||||
if user.is_anonymous:
|
||||
@ -902,8 +870,6 @@ class SithFile(models.Model):
|
||||
return user.is_board_member
|
||||
if user.is_com_admin:
|
||||
return True
|
||||
if self.is_in_sas and user.is_in_group(pk=settings.SITH_GROUP_SAS_ADMIN_ID):
|
||||
return True
|
||||
return user.id == self.owner_id
|
||||
|
||||
def can_be_viewed_by(self, user: User) -> bool:
|
||||
@ -930,8 +896,6 @@ class SithFile(models.Model):
|
||||
super().clean()
|
||||
if "/" in self.name:
|
||||
raise ValidationError(_("Character '/' not authorized in name"))
|
||||
if self == self.parent:
|
||||
raise ValidationError(_("Loop in folder tree"), code="loop")
|
||||
if self == self.parent or (
|
||||
self.parent is not None and self in self.get_parent_list()
|
||||
):
|
||||
@ -1069,18 +1033,6 @@ class SithFile(models.Model):
|
||||
def is_file(self):
|
||||
return not self.is_folder
|
||||
|
||||
@cached_property
|
||||
def as_picture(self):
|
||||
from sas.models import Picture
|
||||
|
||||
return Picture.objects.filter(id=self.id).first()
|
||||
|
||||
@cached_property
|
||||
def as_album(self):
|
||||
from sas.models import Album
|
||||
|
||||
return Album.objects.filter(id=self.id).first()
|
||||
|
||||
def get_parent_list(self):
|
||||
parents = []
|
||||
current = self.parent
|
||||
@ -1451,10 +1403,6 @@ 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
|
||||
@ -1462,9 +1410,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=get_notification_types, default="GENERIC"
|
||||
_("type"), max_length=32, choices=settings.SITH_NOTIFICATIONS, default="GENERIC"
|
||||
)
|
||||
date = models.DateTimeField(_("date"), auto_now=True)
|
||||
date = models.DateTimeField(_("date"), default=timezone.now)
|
||||
viewed = models.BooleanField(_("viewed"), default=False, db_index=True)
|
||||
|
||||
def __str__(self):
|
||||
|
@ -1,120 +0,0 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
@ -1,36 +0,0 @@
|
||||
import { exportToHtml } from "#core:utils/globals";
|
||||
|
||||
exportToHtml("showMenu", () => {
|
||||
const navbar = document.getElementById("navbar-content");
|
||||
const current = navbar.getAttribute("mobile-display");
|
||||
navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden");
|
||||
});
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
const menuItems = document.querySelectorAll(".navbar details[name='navbar'].menu");
|
||||
const isDesktop = () => {
|
||||
return window.innerWidth >= 500;
|
||||
};
|
||||
for (const item of menuItems) {
|
||||
item.addEventListener("mouseover", () => {
|
||||
if (isDesktop()) {
|
||||
item.setAttribute("open", "");
|
||||
}
|
||||
});
|
||||
item.addEventListener("mouseout", () => {
|
||||
if (isDesktop()) {
|
||||
item.removeAttribute("open");
|
||||
}
|
||||
});
|
||||
item.addEventListener("click", (event: MouseEvent) => {
|
||||
// Don't close when clicking on desktop mode
|
||||
if ((event.target as HTMLElement).nodeName !== "SUMMARY" || event.detail === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDesktop()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
2
core/static/bundled/jquery-ui-index.js
vendored
Normal file
2
core/static/bundled/jquery-ui-index.js
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
// This is only used to import jquery-ui css files
|
||||
import "jquery-ui/themes/base/all.css";
|
274
core/static/bundled/user/family-graph-index.js
Normal file
274
core/static/bundled/user/family-graph-index.js
Normal file
@ -0,0 +1,274 @@
|
||||
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
|
||||
import cytoscape from "cytoscape";
|
||||
import cxtmenu from "cytoscape-cxtmenu";
|
||||
import klay from "cytoscape-klay";
|
||||
import { familyGetFamilyGraph } from "#openapi";
|
||||
|
||||
cytoscape.use(klay);
|
||||
cytoscape.use(cxtmenu);
|
||||
|
||||
async function getGraphData(userId, godfathersDepth, godchildrenDepth) {
|
||||
const data = (
|
||||
await familyGetFamilyGraph({
|
||||
path: {
|
||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||
user_id: userId,
|
||||
},
|
||||
query: {
|
||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||
godfathers_depth: godfathersDepth,
|
||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||
godchildren_depth: godchildrenDepth,
|
||||
},
|
||||
})
|
||||
).data;
|
||||
return [
|
||||
...data.users.map((user) => {
|
||||
return { data: user };
|
||||
}),
|
||||
...data.relationships.map((rel) => {
|
||||
return {
|
||||
data: { source: rel.godfather, target: rel.godchild },
|
||||
};
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function createGraph(container, data, activeUserId) {
|
||||
const cy = cytoscape({
|
||||
boxSelectionEnabled: false,
|
||||
autounselectify: true,
|
||||
|
||||
container,
|
||||
elements: data,
|
||||
minZoom: 0.5,
|
||||
|
||||
style: [
|
||||
// the stylesheet for the graph
|
||||
{
|
||||
selector: "node",
|
||||
style: {
|
||||
label: "data(display_name)",
|
||||
"background-image": "data(profile_pict)",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
"background-fit": "cover",
|
||||
"background-repeat": "no-repeat",
|
||||
shape: "ellipse",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
selector: "edge",
|
||||
style: {
|
||||
width: 5,
|
||||
"line-color": "#ccc",
|
||||
"target-arrow-color": "#ccc",
|
||||
"target-arrow-shape": "triangle",
|
||||
"curve-style": "bezier",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
selector: ".traversed",
|
||||
style: {
|
||||
"border-width": "5px",
|
||||
"border-style": "solid",
|
||||
"border-color": "red",
|
||||
"target-arrow-color": "red",
|
||||
"line-color": "red",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
selector: ".not-traversed",
|
||||
style: {
|
||||
"line-opacity": "0.5",
|
||||
"background-opacity": "0.5",
|
||||
"background-image-opacity": "0.5",
|
||||
},
|
||||
},
|
||||
],
|
||||
layout: {
|
||||
name: "klay",
|
||||
nodeDimensionsIncludeLabels: true,
|
||||
fit: true,
|
||||
klay: {
|
||||
addUnnecessaryBendpoints: true,
|
||||
direction: "DOWN",
|
||||
nodePlacement: "INTERACTIVE",
|
||||
layoutHierarchy: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const activeUser = cy.getElementById(activeUserId).style("shape", "rectangle");
|
||||
/* Reset graph */
|
||||
const resetGraph = () => {
|
||||
cy.elements((element) => {
|
||||
if (element.hasClass("traversed")) {
|
||||
element.removeClass("traversed");
|
||||
}
|
||||
if (element.hasClass("not-traversed")) {
|
||||
element.removeClass("not-traversed");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onNodeTap = (el) => {
|
||||
resetGraph();
|
||||
/* Create path on graph if selected isn't the targeted user */
|
||||
if (el === activeUser) {
|
||||
return;
|
||||
}
|
||||
cy.elements((element) => {
|
||||
element.addClass("not-traversed");
|
||||
});
|
||||
|
||||
for (const traversed of cy.elements().aStar({
|
||||
root: el,
|
||||
goal: activeUser,
|
||||
}).path) {
|
||||
traversed.removeClass("not-traversed");
|
||||
traversed.addClass("traversed");
|
||||
}
|
||||
};
|
||||
|
||||
cy.on("tap", "node", (tapped) => {
|
||||
onNodeTap(tapped.target);
|
||||
});
|
||||
cy.zoomingEnabled(false);
|
||||
|
||||
/* Add context menu */
|
||||
cy.cxtmenu({
|
||||
selector: "node",
|
||||
|
||||
commands: [
|
||||
{
|
||||
content: '<i class="fa fa-external-link fa-2x"></i>',
|
||||
select: (el) => {
|
||||
window.open(el.data().profile_url, "_blank").focus();
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
content: '<span class="fa fa-mouse-pointer fa-2x"></span>',
|
||||
select: (el) => {
|
||||
onNodeTap(el);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
content: '<i class="fa fa-eraser fa-2x"></i>',
|
||||
select: (_) => {
|
||||
resetGraph();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return cy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef FamilyGraphConfig
|
||||
* @property {number} activeUser Id of the user to fetch the tree from
|
||||
* @property {number} depthMin Minimum tree depth for godfathers and godchildren
|
||||
* @property {number} depthMax Maximum tree depth for godfathers and godchildren
|
||||
**/
|
||||
|
||||
/**
|
||||
* Create a family graph of an user
|
||||
* @param {FamilyGraphConfig} config
|
||||
**/
|
||||
window.loadFamilyGraph = (config) => {
|
||||
document.addEventListener("alpine:init", () => {
|
||||
const defaultDepth = 2;
|
||||
|
||||
function getInitialDepth(prop) {
|
||||
const value = Number.parseInt(initialUrlParams.get(prop));
|
||||
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
|
||||
return defaultDepth;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
Alpine.data("graph", () => ({
|
||||
loading: false,
|
||||
godfathersDepth: getInitialDepth("godfathersDepth"),
|
||||
godchildrenDepth: getInitialDepth("godchildrenDepth"),
|
||||
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
|
||||
graph: undefined,
|
||||
graphData: {},
|
||||
|
||||
async init() {
|
||||
const delayedFetch = Alpine.debounce(async () => {
|
||||
await this.fetchGraphData();
|
||||
}, 100);
|
||||
for (const param of ["godfathersDepth", "godchildrenDepth"]) {
|
||||
this.$watch(param, async (value) => {
|
||||
if (value < config.depthMin || value > config.depthMax) {
|
||||
return;
|
||||
}
|
||||
updateQueryString(param, value, History.Replace);
|
||||
await delayedFetch();
|
||||
});
|
||||
}
|
||||
this.$watch("reverse", async (value) => {
|
||||
updateQueryString("reverse", value, History.Replace);
|
||||
await this.reverseGraph();
|
||||
});
|
||||
this.$watch("graphData", async () => {
|
||||
this.generateGraph();
|
||||
if (this.reverse) {
|
||||
await this.reverseGraph();
|
||||
}
|
||||
});
|
||||
await this.fetchGraphData();
|
||||
},
|
||||
|
||||
screenshot() {
|
||||
const link = document.createElement("a");
|
||||
link.href = this.graph.jpg();
|
||||
link.download = interpolate(
|
||||
gettext("family_tree.%(extension)s"),
|
||||
{ extension: "jpg" },
|
||||
true,
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.reverse = false;
|
||||
this.godfathersDepth = defaultDepth;
|
||||
this.godchildrenDepth = defaultDepth;
|
||||
},
|
||||
|
||||
async reverseGraph() {
|
||||
this.graph.elements((el) => {
|
||||
el.position({ x: -el.position().x, y: -el.position().y });
|
||||
});
|
||||
this.graph.center(this.graph.elements());
|
||||
},
|
||||
|
||||
async fetchGraphData() {
|
||||
this.graphData = await getGraphData(
|
||||
config.activeUser,
|
||||
this.godfathersDepth,
|
||||
this.godchildrenDepth,
|
||||
);
|
||||
},
|
||||
|
||||
generateGraph() {
|
||||
this.loading = true;
|
||||
this.graph = createGraph(
|
||||
$(this.$refs.graph),
|
||||
this.graphData,
|
||||
config.activeUser,
|
||||
);
|
||||
this.loading = false;
|
||||
},
|
||||
}));
|
||||
});
|
||||
};
|
@ -1,287 +0,0 @@
|
||||
import { History, initialUrlParams, updateQueryString } from "#core:utils/history";
|
||||
import cytoscape, {
|
||||
type ElementDefinition,
|
||||
type NodeSingular,
|
||||
type Singular,
|
||||
} from "cytoscape";
|
||||
import cxtmenu from "cytoscape-cxtmenu";
|
||||
import klay, { type KlayLayoutOptions } from "cytoscape-klay";
|
||||
import { type UserProfileSchema, familyGetFamilyGraph } from "#openapi";
|
||||
|
||||
cytoscape.use(klay);
|
||||
cytoscape.use(cxtmenu);
|
||||
|
||||
type GraphData = (
|
||||
| { data: UserProfileSchema }
|
||||
| { data: { source: number; target: number } }
|
||||
)[];
|
||||
|
||||
function isMobile() {
|
||||
return window.innerWidth < 500;
|
||||
}
|
||||
|
||||
async function getGraphData(
|
||||
userId: number,
|
||||
godfathersDepth: number,
|
||||
godchildrenDepth: number,
|
||||
): Promise<GraphData> {
|
||||
const data = (
|
||||
await familyGetFamilyGraph({
|
||||
path: {
|
||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||
user_id: userId,
|
||||
},
|
||||
query: {
|
||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||
godfathers_depth: godfathersDepth,
|
||||
// biome-ignore lint/style/useNamingConvention: api is snake_case
|
||||
godchildren_depth: godchildrenDepth,
|
||||
},
|
||||
})
|
||||
).data;
|
||||
return [
|
||||
...data.users.map((user) => {
|
||||
return { data: user };
|
||||
}),
|
||||
...data.relationships.map((rel) => {
|
||||
return {
|
||||
data: { source: rel.godfather, target: rel.godchild },
|
||||
};
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function createGraph(container: HTMLDivElement, data: GraphData, activeUserId: number) {
|
||||
const cy = cytoscape({
|
||||
boxSelectionEnabled: false,
|
||||
autounselectify: true,
|
||||
|
||||
container,
|
||||
elements: data as ElementDefinition[],
|
||||
minZoom: 0.5,
|
||||
|
||||
style: [
|
||||
// the stylesheet for the graph
|
||||
{
|
||||
selector: "node",
|
||||
style: {
|
||||
label: "data(display_name)",
|
||||
"background-image": "data(profile_pict)",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
"background-fit": "cover",
|
||||
"background-repeat": "no-repeat",
|
||||
shape: "ellipse",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
selector: "edge",
|
||||
style: {
|
||||
width: 5,
|
||||
"line-color": "#ccc",
|
||||
"target-arrow-color": "#ccc",
|
||||
"target-arrow-shape": "triangle",
|
||||
"curve-style": "bezier",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
selector: ".traversed",
|
||||
style: {
|
||||
"border-width": "5px",
|
||||
"border-style": "solid",
|
||||
"border-color": "red",
|
||||
"target-arrow-color": "red",
|
||||
"line-color": "red",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
selector: ".not-traversed",
|
||||
style: {
|
||||
"line-opacity": 0.5,
|
||||
"background-opacity": 0.5,
|
||||
"background-image-opacity": 0.5,
|
||||
},
|
||||
},
|
||||
],
|
||||
layout: {
|
||||
name: "klay",
|
||||
nodeDimensionsIncludeLabels: true,
|
||||
fit: true,
|
||||
klay: {
|
||||
addUnnecessaryBendpoints: true,
|
||||
direction: "DOWN",
|
||||
nodePlacement: "INTERACTIVE",
|
||||
layoutHierarchy: true,
|
||||
},
|
||||
} as KlayLayoutOptions,
|
||||
});
|
||||
const activeUser = cy
|
||||
.getElementById(activeUserId.toString())
|
||||
.style("shape", "rectangle");
|
||||
/* Reset graph */
|
||||
const resetGraph = () => {
|
||||
cy.elements().removeClass("traversed not-traversed");
|
||||
};
|
||||
|
||||
const onNodeTap = (el: Singular) => {
|
||||
resetGraph();
|
||||
/* Create path on graph if selected isn't the targeted user */
|
||||
if (el === activeUser) {
|
||||
return;
|
||||
}
|
||||
cy.elements().addClass("not-traversed");
|
||||
|
||||
for (const traversed of cy.elements().aStar({
|
||||
root: el,
|
||||
goal: activeUser,
|
||||
}).path) {
|
||||
traversed.removeClass("not-traversed");
|
||||
traversed.addClass("traversed");
|
||||
}
|
||||
};
|
||||
|
||||
cy.on("tap", "node", (tapped) => {
|
||||
onNodeTap(tapped.target);
|
||||
});
|
||||
|
||||
/* Add context menu */
|
||||
cy.cxtmenu({
|
||||
selector: "node",
|
||||
|
||||
commands: [
|
||||
{
|
||||
content: '<i class="fa fa-external-link fa-2x"></i>',
|
||||
select: (el) => {
|
||||
window.open(el.data().profile_url, "_blank").focus();
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
content: '<span class="fa fa-mouse-pointer fa-2x"></span>',
|
||||
select: (el) => {
|
||||
onNodeTap(el);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
content: '<i class="fa fa-eraser fa-2x"></i>',
|
||||
select: (_) => {
|
||||
resetGraph();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return cy;
|
||||
}
|
||||
|
||||
interface FamilyGraphConfig {
|
||||
/**Id of the user to fetch the tree from*/
|
||||
activeUser: number;
|
||||
/**Minimum tree depth for godfathers and godchildren*/
|
||||
depthMin: number;
|
||||
/**Maximum tree depth for godfathers and godchildren*/
|
||||
depthMax: number;
|
||||
}
|
||||
|
||||
document.addEventListener("alpine:init", () => {
|
||||
const defaultDepth = 2;
|
||||
|
||||
Alpine.data("graph", (config: FamilyGraphConfig) => ({
|
||||
loading: false,
|
||||
godfathersDepth: 0,
|
||||
godchildrenDepth: 0,
|
||||
reverse: initialUrlParams.get("reverse")?.toLowerCase?.() === "true",
|
||||
graph: undefined as cytoscape.Core,
|
||||
graphData: {},
|
||||
isZoomEnabled: !isMobile(),
|
||||
|
||||
getInitialDepth(prop: string) {
|
||||
const value = Number.parseInt(initialUrlParams.get(prop));
|
||||
if (Number.isNaN(value) || value < config.depthMin || value > config.depthMax) {
|
||||
return defaultDepth;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
|
||||
async init() {
|
||||
this.godfathersDepth = this.getInitialDepth("godfathersDepth");
|
||||
this.godchildrenDepth = this.getInitialDepth("godchildrenDepth");
|
||||
|
||||
const delayedFetch = Alpine.debounce(async () => {
|
||||
await this.fetchGraphData();
|
||||
}, 100);
|
||||
for (const param of ["godfathersDepth", "godchildrenDepth"]) {
|
||||
this.$watch(param, async (value: number) => {
|
||||
if (value < config.depthMin || value > config.depthMax) {
|
||||
return;
|
||||
}
|
||||
updateQueryString(param, value.toString(), History.Replace);
|
||||
await delayedFetch();
|
||||
});
|
||||
}
|
||||
this.$watch("reverse", async (value: number) => {
|
||||
updateQueryString("reverse", value.toString(), History.Replace);
|
||||
await this.reverseGraph();
|
||||
});
|
||||
this.$watch("graphData", async () => {
|
||||
this.generateGraph();
|
||||
if (this.reverse) {
|
||||
await this.reverseGraph();
|
||||
}
|
||||
});
|
||||
this.$watch("isZoomEnabled", () => {
|
||||
this.graph.userZoomingEnabled(this.isZoomEnabled);
|
||||
});
|
||||
await this.fetchGraphData();
|
||||
},
|
||||
|
||||
screenshot() {
|
||||
const link = document.createElement("a");
|
||||
link.href = this.graph.jpg();
|
||||
link.download = interpolate(
|
||||
gettext("family_tree.%(extension)s"),
|
||||
{ extension: "jpg" },
|
||||
true,
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.reverse = false;
|
||||
this.godfathersDepth = defaultDepth;
|
||||
this.godchildrenDepth = defaultDepth;
|
||||
},
|
||||
|
||||
async reverseGraph() {
|
||||
this.graph.elements((el: NodeSingular) => {
|
||||
el.position({ x: -el.position().x, y: -el.position().y });
|
||||
});
|
||||
this.graph.center(this.graph.elements());
|
||||
},
|
||||
|
||||
async fetchGraphData() {
|
||||
this.graphData = await getGraphData(
|
||||
config.activeUser,
|
||||
this.godfathersDepth,
|
||||
this.godchildrenDepth,
|
||||
);
|
||||
},
|
||||
|
||||
generateGraph() {
|
||||
this.loading = true;
|
||||
this.graph = createGraph(
|
||||
this.$refs.graph as HTMLDivElement,
|
||||
this.graphData,
|
||||
config.activeUser,
|
||||
);
|
||||
this.graph.userZoomingEnabled(this.isZoomEnabled);
|
||||
this.loading = false;
|
||||
},
|
||||
}));
|
||||
});
|
@ -1,38 +0,0 @@
|
||||
interface AlertParams {
|
||||
success?: boolean;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export class AlertMessage {
|
||||
public open: boolean;
|
||||
public success: boolean;
|
||||
public content: string;
|
||||
private timeoutId?: number;
|
||||
private readonly defaultDuration: number;
|
||||
|
||||
constructor(params?: { defaultDuration: number }) {
|
||||
this.open = false;
|
||||
this.content = "";
|
||||
this.timeoutId = null;
|
||||
this.defaultDuration = params?.defaultDuration ?? 2000;
|
||||
}
|
||||
|
||||
public display(message: string, params: AlertParams) {
|
||||
this.clear();
|
||||
this.open = true;
|
||||
this.content = message;
|
||||
this.success = params.success ?? true;
|
||||
this.timeoutId = setTimeout(() => {
|
||||
this.open = false;
|
||||
this.timeoutId = null;
|
||||
}, params.duration ?? this.defaultDuration);
|
||||
}
|
||||
|
||||
public clear() {
|
||||
if (this.timeoutId !== null) {
|
||||
clearTimeout(this.timeoutId);
|
||||
this.timeoutId = null;
|
||||
}
|
||||
this.open = false;
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import type { Client, RequestResult, TDataShape } from "#openapi:client";
|
||||
import { type Options, client } from "#openapi";
|
||||
import type { Client, Options, RequestResult, TDataShape } from "@hey-api/client-fetch";
|
||||
import { client } from "#openapi";
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
count: number;
|
||||
|
@ -1,53 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,93 +0,0 @@
|
||||
@import "colors";
|
||||
@import "devices";
|
||||
|
||||
footer.bottom-links {
|
||||
>section>a {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@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,4 +1,42 @@
|
||||
$(() => {
|
||||
// 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,6 +111,12 @@ body {
|
||||
|
||||
/*--------------------------------HEADER-------------------------------*/
|
||||
|
||||
#popupheader {
|
||||
width: 88%;
|
||||
margin: 0 auto;
|
||||
padding: 0.3em 1%;
|
||||
}
|
||||
|
||||
#info_boxes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -346,6 +352,52 @@ 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;
|
||||
@ -713,6 +765,47 @@ 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 {
|
||||
@ -755,6 +848,25 @@ textarea {
|
||||
}
|
||||
|
||||
/*--------------------------------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;
|
||||
|
@ -4,12 +4,6 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.zoom-control {
|
||||
margin-right: 10px;
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
}
|
||||
|
||||
.graph-toolbar {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
@ -18,7 +12,7 @@
|
||||
justify-content: space-around;
|
||||
gap: 30px;
|
||||
|
||||
.toolbar-column {
|
||||
.toolbar-column{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
@ -40,38 +34,31 @@
|
||||
|
||||
.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 {
|
||||
& > .fa {
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
&:enabled>.fa {
|
||||
&:enabled > .fa {
|
||||
background-color: #354a5f;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&:enabled:hover>.fa {
|
||||
&:enabled:hover > .fa {
|
||||
color: white;
|
||||
background-color: #35405f; // just a bit darker
|
||||
}
|
||||
|
||||
&:disabled>.fa {
|
||||
&:disabled > .fa {
|
||||
background-color: gray;
|
||||
color: white;
|
||||
}
|
||||
@ -87,7 +74,6 @@
|
||||
@media screen and (max-width: 500px) {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
.toolbar-column {
|
||||
min-width: 100%;
|
||||
}
|
||||
@ -101,16 +87,14 @@
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
|
||||
>form {
|
||||
> form {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
#family-tree-link {
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
|
||||
@media (min-width: 450px) {
|
||||
margin-right: auto;
|
||||
}
|
||||
@ -138,10 +122,10 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
>div.mini_profile_link {
|
||||
> div.mini_profile_link {
|
||||
position: relative;
|
||||
|
||||
>a {
|
||||
> a {
|
||||
&.mini_profile_link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -156,7 +140,7 @@
|
||||
max-height: 65px;
|
||||
}
|
||||
|
||||
>span {
|
||||
> span {
|
||||
height: 150px;
|
||||
width: 100%;
|
||||
|
||||
@ -165,7 +149,7 @@
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
>img {
|
||||
> img {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
@ -179,7 +163,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
>em {
|
||||
> em {
|
||||
box-sizing: border-box;
|
||||
padding: 0 5px;
|
||||
text-align: center;
|
||||
@ -211,7 +195,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
>a.mini_profile_link {
|
||||
> a.mini_profile_link {
|
||||
display: none;
|
||||
}
|
||||
}
|
@ -11,15 +11,17 @@
|
||||
<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>
|
||||
@ -28,8 +30,11 @@
|
||||
|
||||
<!-- 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 %}
|
||||
@ -42,28 +47,35 @@
|
||||
{% csrf_token %}
|
||||
|
||||
{% block header %}
|
||||
{% include "core/base/header.jinja" %}
|
||||
{% if not popup %}
|
||||
{% include "core/base/header.jinja" %}
|
||||
|
||||
{% block info_boxes %}
|
||||
<div id="info_boxes">
|
||||
{% set sith = get_sith() %}
|
||||
{% if sith.alert_msg %}
|
||||
<div id="alert_box">
|
||||
{{ sith.alert_msg|markdown }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if sith.info_msg %}
|
||||
<div id="info_box">
|
||||
{{ sith.info_msg|markdown }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block info_boxes %}
|
||||
<div id="info_boxes">
|
||||
{% set sith = get_sith() %}
|
||||
{% if sith.alert_msg %}
|
||||
<div id="alert_box">
|
||||
{{ sith.alert_msg|markdown }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if sith.info_msg %}
|
||||
<div id="info_box">
|
||||
{{ sith.info_msg|markdown }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% else %}
|
||||
<div id="popupheader">{{ user.get_display_name() }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block nav %}
|
||||
{% include "core/base/navbar.jinja" %}
|
||||
{% if not popup %}
|
||||
{% include "core/base/navbar.jinja" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
<div id="page">
|
||||
@ -90,12 +102,50 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block footer %}
|
||||
{% include "core/base/footer.jinja" %}
|
||||
{% endblock %}
|
||||
{% 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>
|
||||
{% 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)) {
|
||||
|
@ -1,16 +0,0 @@
|
||||
<footer class="bottom-links">
|
||||
<section>
|
||||
<a href="{{ url('core:page', 'contacts') }}">{% trans %}Contacts{% endtrans %}</a>
|
||||
<a href="{{ url('core:page', 'legals') }}">{% trans %}Legal notices{% endtrans %}</a>
|
||||
<a href="{{ url('core:page', 'copyright_agent') }}">{% trans %}Intellectual property{% endtrans %}</a>
|
||||
<a href="{{ url('core:page', 'docs') }}">{% trans %}Help & Documentation{% endtrans %}</a>
|
||||
<a href="{{ url('core:page', 'rd') }}">{% trans %}R&D{% endtrans %}</a>
|
||||
</section>
|
||||
<hr>
|
||||
<section>
|
||||
<a rel="nofollow" href="https://github.com/ae-utbm/sith" target="#">
|
||||
<i class="fa-brands fa-github"></i>
|
||||
{% trans %}Site created by the IT Department of the AE{% endtrans %}
|
||||
</a>
|
||||
</section>
|
||||
</footer>
|
@ -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) }}">{{ file.get_display_name() }}</a>
|
||||
<a href="{{ url('core:file_detail', file_id=file.id, popup=popup) }}">{{ file.get_display_name() }}</a>
|
||||
{% else %}
|
||||
<a href="{{ url('core:file_list') }}">{% trans %}Files{% endtrans %}</a>
|
||||
<a href="{{ url('core:file_list', popup) }}">{% 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) }}">{% trans %}My files{% endtrans %}</a>
|
||||
<a href="{{ url('core:file_detail', home.id, popup) }}">{% trans %}My files{% endtrans %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if file %}
|
||||
<a href="{{ url('core:file_detail', file.id) }}">{% trans %}View{% endtrans %}</a>
|
||||
<a href="{{ url('core:file_detail', file.id, popup) }}">{% trans %}View{% endtrans %}</a>
|
||||
{% if can_edit(file, user) %}
|
||||
<a href="{{ url('core:file_edit', file_id=file.id) }}">{% trans %}Edit{% endtrans %}</a>
|
||||
<a href="{{ url('core:file_edit', file_id=file.id, popup=popup) }}">{% trans %}Edit{% endtrans %}</a>
|
||||
{% endif %}
|
||||
{% if can_edit_prop(file, user) %}
|
||||
<a href="{{ url('core:file_prop', file_id=file.id) }}">{% trans %}Prop{% endtrans %}</a>
|
||||
<a href="{{ url('core:file_prop', file_id=file.id, popup=popup) }}">{% 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) }}">{{ f.get_display_name() }}</a></li>
|
||||
<a href="{{ url('core:file_detail', file_id=f.id, popup=popup) }}">{{ f.get_display_name() }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</form>
|
||||
@ -59,9 +59,22 @@
|
||||
<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) }}">{% trans %}Delete{% endtrans %}</a></p>
|
||||
<p><a href="{{ url('core:file_delete', file_id=file.id, popup=popup) }}">{% 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) }}">{{ f.name }}</a></li>
|
||||
<a href="{{ url('core:file_detail', file_id=f.id, popup=popup) }}">{{ f.name }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
|
@ -26,11 +26,9 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url('core:login') }}" id="login-form">
|
||||
<form method="post" action="{{ url('core:login') }}">
|
||||
{% if form.errors %}
|
||||
<p class="alert alert-red">
|
||||
{% trans %}Your credentials didn't match. Please try again.{% endtrans %}
|
||||
</p>
|
||||
<p class="alert alert-red">{% trans %}Your username and password didn't match. Please try again.{% endtrans %}</p>
|
||||
{% endif %}
|
||||
|
||||
{% csrf_token %}
|
||||
|
@ -245,3 +245,65 @@
|
||||
<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)
|
||||
url('core:file_delete', file_id=this_picture.id, popup='')
|
||||
+ "?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.ts") }}"></script>
|
||||
<script type="module" src="{{ static("bundled/user/family-graph-index.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}
|
||||
@ -15,14 +15,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div
|
||||
x-data="graph({
|
||||
activeUser: {{ object.id }},
|
||||
depthMin: {{ depth_min }},
|
||||
depthMax: {{ depth_max }},
|
||||
})"
|
||||
:aria-busy="loading"
|
||||
>
|
||||
<div x-data="graph" :aria-busy="loading">
|
||||
<div class="graph-toolbar">
|
||||
<div class="toolbar-column">
|
||||
<div class="toolbar-input">
|
||||
@ -93,36 +86,17 @@
|
||||
</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,7 +38,6 @@ 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
|
||||
|
||||
|
||||
@ -152,44 +151,24 @@ class TestUserLogin:
|
||||
def user(self) -> User:
|
||||
return baker.make(User, password=make_password("plop"))
|
||||
|
||||
@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):
|
||||
def test_login_fail(self, client, user):
|
||||
"""Should not login a user correctly."""
|
||||
identifier = identifier_getter(user)
|
||||
response = client.post(
|
||||
reverse("core:login"),
|
||||
{"username": identifier, "password": "wrong-password"},
|
||||
{"username": user.username, "password": "wrong-password"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
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
|
||||
'<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
|
||||
|
||||
@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):
|
||||
def test_login_success(self, client, user):
|
||||
"""Should login a user correctly."""
|
||||
response = client.post(
|
||||
reverse("core:login"),
|
||||
{"username": identifier_getter(user), "password": "plop"},
|
||||
{"username": user.username, "password": "plop"},
|
||||
)
|
||||
assertRedirects(response, reverse("core:index"))
|
||||
assert response.wsgi_request.user == user
|
||||
@ -382,9 +361,17 @@ class TestUserIsInGroup(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.public_group = Group.objects.get(id=settings.SITH_GROUP_PUBLIC_ID)
|
||||
cls.root_group = Group.objects.get(name="Root")
|
||||
cls.public_group = Group.objects.get(name="Public")
|
||||
cls.public_user = baker.make(User)
|
||||
cls.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)
|
||||
@ -392,7 +379,15 @@ class TestUserIsInGroup(TestCase):
|
||||
|
||||
def assert_only_in_public_group(self, user):
|
||||
self.assert_in_public_group(user)
|
||||
for group in Group.objects.exclude(id=self.public_group.id):
|
||||
for group in (
|
||||
self.root_group,
|
||||
self.accounting_admin,
|
||||
self.sas_admin,
|
||||
self.subscribers,
|
||||
self.old_subscribers,
|
||||
self.club.members_group,
|
||||
self.club.board_group,
|
||||
):
|
||||
assert not user.is_in_group(pk=group.pk)
|
||||
assert not user.is_in_group(name=group.name)
|
||||
|
||||
|
@ -5,6 +5,7 @@ from typing import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
|
||||
from django.test import Client, TestCase
|
||||
@ -17,8 +18,8 @@ from pytest_django.asserts import assertNumQueries
|
||||
from core.baker_recipes import board_user, old_subscriber_user, subscriber_user
|
||||
from core.models import Group, QuickUploadImage, SithFile, User
|
||||
from core.utils import RED_PIXEL_PNG
|
||||
from sas.baker_recipes import picture_recipe
|
||||
from sas.models import Picture
|
||||
from sith import settings
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@ -30,24 +31,19 @@ class TestImageAccess:
|
||||
lambda: baker.make(
|
||||
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_SAS_ADMIN_ID)]
|
||||
),
|
||||
lambda: baker.make(
|
||||
User, groups=[Group.objects.get(pk=settings.SITH_GROUP_COM_ADMIN_ID)]
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_sas_image_access(self, user_factory: Callable[[], User]):
|
||||
"""Test that only authorized users can access the sas image."""
|
||||
user = user_factory()
|
||||
picture: SithFile = baker.make(
|
||||
Picture, parent=SithFile.objects.get(pk=settings.SITH_SAS_ROOT_DIR_ID)
|
||||
)
|
||||
assert picture.is_owned_by(user)
|
||||
picture = picture_recipe.make()
|
||||
assert user.can_edit(picture)
|
||||
|
||||
def test_sas_image_access_owner(self):
|
||||
"""Test that the owner of the image can access it."""
|
||||
user = baker.make(User)
|
||||
picture: Picture = baker.make(Picture, owner=user)
|
||||
assert picture.is_owned_by(user)
|
||||
picture = picture_recipe.make(owner=user)
|
||||
assert user.can_edit(picture)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user_factory",
|
||||
@ -63,7 +59,41 @@ class TestImageAccess:
|
||||
user = user_factory()
|
||||
owner = baker.make(User)
|
||||
picture: Picture = baker.make(Picture, owner=owner)
|
||||
assert not picture.is_owned_by(user)
|
||||
assert not user.can_edit(picture)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestUserPicture:
|
||||
def test_anonymous_user_unauthorized(self, client):
|
||||
"""An anonymous user shouldn't have access to an user's photo page."""
|
||||
response = client.get(
|
||||
reverse(
|
||||
"core:user_pictures",
|
||||
kwargs={"user_id": User.objects.get(username="sli").pk},
|
||||
)
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("username", "status"),
|
||||
[
|
||||
("guy", 403),
|
||||
("root", 200),
|
||||
("skia", 200),
|
||||
("sli", 200),
|
||||
],
|
||||
)
|
||||
def test_page_is_working(self, client, username, status):
|
||||
"""Only user that subscribed (or admins) should be able to see the page."""
|
||||
# Test for simple user
|
||||
client.force_login(User.objects.get(username=username))
|
||||
response = client.get(
|
||||
reverse(
|
||||
"core:user_pictures",
|
||||
kwargs={"user_id": User.objects.get(username="sli").pk},
|
||||
)
|
||||
)
|
||||
assert response.status_code == status
|
||||
|
||||
|
||||
# TODO: many tests on the pages:
|
||||
@ -146,7 +176,7 @@ class TestUserProfilePicture:
|
||||
return client.post(
|
||||
reverse(
|
||||
"core:file_delete",
|
||||
kwargs={"file_id": user.profile_pict.pk},
|
||||
kwargs={"file_id": user.profile_pict.pk, "popup": ""},
|
||||
query={"next": user.get_absolute_url()},
|
||||
),
|
||||
)
|
||||
|
@ -22,6 +22,7 @@ from core.models import Group, User
|
||||
from core.views import UserTabsMixin
|
||||
from counter.models import Counter, Refilling, Selling
|
||||
from eboutic.models import Invoice, InvoiceItem
|
||||
from sas.models import Picture
|
||||
|
||||
|
||||
class TestSearchUsers(TestCase):
|
||||
@ -29,6 +30,7 @@ class TestSearchUsers(TestCase):
|
||||
def setUpTestData(cls):
|
||||
# News.author has on_delete=PROTECT, so news must be deleted beforehand
|
||||
News.objects.all().delete()
|
||||
Picture.objects.all().delete() # same for pictures
|
||||
User.objects.all().delete()
|
||||
user_recipe = Recipe(
|
||||
User,
|
||||
|
10
core/urls.py
10
core/urls.py
@ -193,24 +193,24 @@ urlpatterns = [
|
||||
name="user_gift_delete",
|
||||
),
|
||||
# File views
|
||||
re_path(r"^file/$", FileListView.as_view(), name="file_list"),
|
||||
re_path(r"^file/(?P<popup>popup)?$", FileListView.as_view(), name="file_list"),
|
||||
re_path(
|
||||
r"^file/(?P<file_id>[0-9]+)/$",
|
||||
r"^file/(?P<file_id>[0-9]+)/(?P<popup>popup)?$",
|
||||
FileView.as_view(),
|
||||
name="file_detail",
|
||||
),
|
||||
re_path(
|
||||
r"^file/(?P<file_id>[0-9]+)/edit/$",
|
||||
r"^file/(?P<file_id>[0-9]+)/edit/(?P<popup>popup)?$",
|
||||
FileEditView.as_view(),
|
||||
name="file_edit",
|
||||
),
|
||||
re_path(
|
||||
r"^file/(?P<file_id>[0-9]+)/prop/$",
|
||||
r"^file/(?P<file_id>[0-9]+)/prop/(?P<popup>popup)?$",
|
||||
FileEditPropView.as_view(),
|
||||
name="file_prop",
|
||||
),
|
||||
re_path(
|
||||
r"^file/(?P<file_id>[0-9]+)/delete/$",
|
||||
r"^file/(?P<file_id>[0-9]+)/delete/(?P<popup>popup)?$",
|
||||
FileDeleteView.as_view(),
|
||||
name="file_delete",
|
||||
),
|
||||
|
@ -12,18 +12,23 @@
|
||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||
#
|
||||
#
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
|
||||
# Image utils
|
||||
from io import BytesIO
|
||||
from typing import Final
|
||||
from typing import Any, Final, Unpack
|
||||
|
||||
import PIL
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.http import HttpRequest
|
||||
from django.db import models
|
||||
from django.forms import BaseForm
|
||||
from django.http import Http404, HttpRequest
|
||||
from django.shortcuts import get_list_or_404
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.safestring import SafeString
|
||||
from django.utils.timezone import localdate
|
||||
from PIL import ExifTags
|
||||
from PIL.Image import Image, Resampling
|
||||
@ -42,6 +47,21 @@ to generate a dummy image that is considered valid nonetheless
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class FormFragmentTemplateData[T: BaseForm]:
|
||||
"""Dataclass used to pre-render form fragments"""
|
||||
|
||||
form: T
|
||||
template: str
|
||||
context: dict[str, Any]
|
||||
|
||||
def render(self, request: HttpRequest) -> SafeString:
|
||||
# Request is needed for csrf_tokens
|
||||
return render_to_string(
|
||||
self.template, context={"form": self.form, **self.context}, request=request
|
||||
)
|
||||
|
||||
|
||||
def get_start_of_semester(today: date | None = None) -> date:
|
||||
"""Return the date of the start of the semester of the given date.
|
||||
If no date is given, return the start date of the current semester.
|
||||
@ -195,3 +215,56 @@ def get_client_ip(request: HttpRequest) -> str | None:
|
||||
return ip
|
||||
|
||||
return None
|
||||
|
||||
|
||||
Filterable = models.Model | models.QuerySet | models.Manager
|
||||
ListFilter = dict[str, list | tuple | set]
|
||||
|
||||
|
||||
def get_list_exact_or_404(klass: Filterable, **kwargs: Unpack[ListFilter]) -> list:
|
||||
"""Use filter() to return a list of objects from a list of unique keys (like ids)
|
||||
or raises Http404 if the list has not the same length as the given one.
|
||||
|
||||
Work like `get_object_or_404()` but for lists of objects, with some caveats :
|
||||
|
||||
- The filter must be a list, a tuple or a set.
|
||||
- There can't be more than exactly one filter.
|
||||
- There must be no duplicate in the filter.
|
||||
- The filter should consist in unique keys (like ids), or it could fail randomly.
|
||||
|
||||
klass may be a Model, Manager, or QuerySet object. All other passed
|
||||
arguments and keyword arguments are used in the filter() query.
|
||||
|
||||
Raises:
|
||||
Http404: If the list is empty or doesn't have as many elements as the keys list.
|
||||
ValueError: If the first argument is not a Model, Manager, or QuerySet object.
|
||||
ValueError: If more than one filter is passed.
|
||||
TypeError: If the given filter is not a list, a tuple or a set.
|
||||
|
||||
Examples:
|
||||
Get all the products with ids 1, 2, 3: ::
|
||||
|
||||
products = get_list_exact_or_404(Product, id__in=[1, 2, 3])
|
||||
|
||||
Don't work with duplicate ids: ::
|
||||
|
||||
products = get_list_exact_or_404(Product, id__in=[1, 2, 3, 3])
|
||||
# Raises Http404: "The list of keys must contain no duplicates."
|
||||
"""
|
||||
if len(kwargs) > 1:
|
||||
raise ValueError("get_list_exact_or_404() only accepts one filter.")
|
||||
key, list_filter = next(iter(kwargs.items()))
|
||||
if not isinstance(list_filter, (list, tuple, set)):
|
||||
raise TypeError(
|
||||
f"The given filter must be a list, a tuple or a set, not {type(list_filter)}"
|
||||
)
|
||||
if len(list_filter) != len(set(list_filter)):
|
||||
raise ValueError("The list of keys must contain no duplicates.")
|
||||
kwargs = {key: list_filter}
|
||||
obj_list = get_list_or_404(klass, **kwargs)
|
||||
if len(obj_list) != len(list_filter):
|
||||
raise Http404(
|
||||
"The given list of keys doesn't match the number of objects found."
|
||||
f"Expected {len(list_filter)} items, got {len(obj_list)}."
|
||||
)
|
||||
return obj_list
|
||||
|
@ -37,6 +37,8 @@ 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,6 +198,9 @@ 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
|
||||
|
||||
|
||||
@ -214,7 +217,20 @@ class FileEditView(CanEditMixin, UpdateView):
|
||||
return modelform_factory(SithFile, fields=fields)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse("core:file_detail", kwargs={"file_id": self.object.id})
|
||||
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
|
||||
|
||||
|
||||
class FileEditPropForm(forms.ModelForm):
|
||||
@ -252,9 +268,16 @@ class FileEditPropView(CanEditPropMixin, UpdateView):
|
||||
def get_success_url(self):
|
||||
return reverse(
|
||||
"core:file_detail",
|
||||
kwargs={"file_id": self.object.id},
|
||||
kwargs={"file_id": self.object.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 not None:
|
||||
kwargs["popup"] = "popup"
|
||||
return kwargs
|
||||
|
||||
|
||||
class FileView(CanViewMixin, DetailView, FormMixin):
|
||||
"""Handle the upload of new files into a folder."""
|
||||
@ -330,12 +353,15 @@ class FileView(CanViewMixin, DetailView, FormMixin):
|
||||
def get_success_url(self):
|
||||
return reverse(
|
||||
"core:file_detail",
|
||||
kwargs={"file_id": self.object.id},
|
||||
kwargs={"file_id": self.object.id, "popup": self.kwargs.get("popup", "")},
|
||||
)
|
||||
|
||||
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"]
|
||||
)
|
||||
@ -354,17 +380,19 @@ class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
|
||||
return self.request.GET["next"]
|
||||
if self.object.parent is None:
|
||||
return reverse(
|
||||
"core:file_list",
|
||||
"core:file_list", kwargs={"popup": self.kwargs.get("popup", "")}
|
||||
)
|
||||
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
|
||||
@ -374,7 +402,7 @@ class FileDeleteView(AllowFragment, CanEditPropMixin, DeleteView):
|
||||
class FileModerationView(AllowFragment, ListView):
|
||||
model = SithFile
|
||||
template_name = "core/file_moderation.jinja"
|
||||
queryset = SithFile.objects.filter(is_moderated=False, is_in_sas=False)
|
||||
queryset = SithFile.objects.filter(is_moderated=False)
|
||||
ordering = "id"
|
||||
paginate_by = 100
|
||||
|
||||
|
@ -86,6 +86,30 @@ 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:
|
||||
@ -132,31 +156,29 @@ class FutureDateTimeField(forms.DateTimeField):
|
||||
|
||||
class LoginForm(AuthenticationForm):
|
||||
def __init__(self, *arg, **kwargs):
|
||||
if "data" in kwargs:
|
||||
from counter.models import Customer
|
||||
|
||||
data = kwargs["data"].copy()
|
||||
account_code = re.compile(r"^[0-9]+[A-Za-z]$")
|
||||
try:
|
||||
if account_code.match(data["username"]):
|
||||
user = (
|
||||
Customer.objects.filter(account_id__iexact=data["username"])
|
||||
.first()
|
||||
.user
|
||||
)
|
||||
elif "@" in data["username"]:
|
||||
user = User.objects.filter(email__iexact=data["username"]).first()
|
||||
else:
|
||||
user = User.objects.filter(username=data["username"]).first()
|
||||
data["username"] = user.username
|
||||
except: # noqa E722 I don't know what error is supposed to be raised here
|
||||
pass
|
||||
kwargs["data"] = data
|
||||
super().__init__(*arg, **kwargs)
|
||||
self.fields["username"].label = _("Username, email, or account number")
|
||||
|
||||
def clean_username(self):
|
||||
identifier: str = self.cleaned_data["username"]
|
||||
account_code = re.compile(r"^[0-9]+[A-Za-z]$")
|
||||
if account_code.match(identifier):
|
||||
qs_filter = "customer__account_id__iexact"
|
||||
elif identifier.count("@") == 1:
|
||||
qs_filter = "email"
|
||||
else:
|
||||
qs_filter = None
|
||||
if qs_filter:
|
||||
# if the user gave an email or an account code instead of
|
||||
# a username, retrieve and return the corresponding username.
|
||||
# If there is no username, return an empty string, so that
|
||||
# Django will properly handle the error when failing the authentication
|
||||
identifier = (
|
||||
User.objects.filter(**{qs_filter: identifier})
|
||||
.values_list("username", flat=True)
|
||||
.first()
|
||||
or ""
|
||||
)
|
||||
return identifier
|
||||
|
||||
|
||||
class RegisteringForm(UserCreationForm):
|
||||
error_css_class = "error"
|
||||
|
@ -41,7 +41,6 @@ class ProductAdmin(SearchModelAdmin):
|
||||
"profit",
|
||||
"archived",
|
||||
)
|
||||
list_select_related = ("product_type",)
|
||||
search_fields = ("name", "code")
|
||||
|
||||
|
||||
@ -82,13 +81,20 @@ class AccountDumpAdmin(admin.ModelAdmin):
|
||||
"customer",
|
||||
"warning_mail_sent_at",
|
||||
"warning_mail_error",
|
||||
"dump_operation__date",
|
||||
"dump_operation",
|
||||
"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):
|
||||
@ -107,14 +113,11 @@ 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",
|
||||
@ -123,8 +126,6 @@ class SellingAdmin(SearchModelAdmin):
|
||||
"counter__name",
|
||||
)
|
||||
autocomplete_fields = ("customer", "seller")
|
||||
list_filter = (("counter", admin.RelatedOnlyFieldListFilter),)
|
||||
date_hierarchy = "date"
|
||||
|
||||
|
||||
@admin.register(Permanency)
|
||||
|
@ -16,13 +16,11 @@ 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 api.auth import ApiKeyAuth
|
||||
from api.permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
|
||||
from core.auth.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
|
||||
from counter.models import Counter, Product, ProductType
|
||||
from counter.schemas import (
|
||||
CounterFilterSchema,
|
||||
@ -64,7 +62,6 @@ class CounterController(ControllerBase):
|
||||
@route.get(
|
||||
"/search",
|
||||
response=PaginatedResponseSchema[SimplifiedCounterSchema],
|
||||
auth=[SessionAuth(), ApiKeyAuth()],
|
||||
permissions=[CanAccessLookup],
|
||||
)
|
||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||
@ -77,7 +74,6 @@ 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.
|
||||
|
||||
Warning:
|
||||
Warnings:
|
||||
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 SimpleClubSchema
|
||||
from club.schemas import ClubSchema
|
||||
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: SimpleClubSchema
|
||||
club: ClubSchema
|
||||
product_type: SimpleProductTypeSchema | None
|
||||
url: str
|
||||
|
||||
|
@ -1,4 +1,3 @@
|
||||
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";
|
||||
@ -6,9 +5,14 @@ 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: new AlertMessage({ defaultDuration: 2000 }),
|
||||
alertMessage: {
|
||||
content: "",
|
||||
show: false,
|
||||
timeout: null,
|
||||
},
|
||||
|
||||
init() {
|
||||
// Fill the basket with the initial data
|
||||
@ -73,10 +77,22 @@ 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.alertMessage.display(message, { success: false });
|
||||
this.showAlertMessage(message);
|
||||
}
|
||||
},
|
||||
|
||||
@ -93,9 +109,7 @@ document.addEventListener("alpine:init", () => {
|
||||
|
||||
finish() {
|
||||
if (this.getBasketSize() === 0) {
|
||||
this.alertMessage.display(gettext("You can't send an empty basket."), {
|
||||
success: false,
|
||||
});
|
||||
this.showAlertMessage(gettext("You can't send an empty basket."));
|
||||
return;
|
||||
}
|
||||
this.$refs.basketForm.submit();
|
||||
@ -123,3 +137,8 @@ document.addEventListener("alpine:init", () => {
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
$(() => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: dealing with legacy jquery
|
||||
($("#products") as any).tabs();
|
||||
});
|
||||
|
@ -167,7 +167,7 @@ document.addEventListener("alpine:init", () => {
|
||||
});
|
||||
// if products to download are already in-memory, directly take them.
|
||||
// If not, fetch them.
|
||||
const products: ProductSchema[] =
|
||||
const products =
|
||||
this.nbPages > 1
|
||||
? await paginated(productSearchProductsDetailed, this.getQueryParams())
|
||||
: Object.values<ProductSchema[]>(this.products).flat();
|
||||
|
@ -1,11 +1,15 @@
|
||||
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: new AlertMessage({ defaultDuration: 2000 }),
|
||||
alertMessage: {
|
||||
open: false,
|
||||
success: true,
|
||||
content: "",
|
||||
timeout: null,
|
||||
},
|
||||
|
||||
async reorder(itemId: number, newPosition: number) {
|
||||
// The sort plugin of Alpine doesn't manage dynamic lists with x-sort
|
||||
@ -37,14 +41,23 @@ document.addEventListener("alpine:init", () => {
|
||||
},
|
||||
|
||||
openAlertMessage(response: Response) {
|
||||
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.display(content, { success: success });
|
||||
if (response.ok) {
|
||||
this.alertMessage.success = true;
|
||||
this.alertMessage.content = gettext("Products types reordered!");
|
||||
} else {
|
||||
this.alertMessage.success = false;
|
||||
this.alertMessage.content = 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.loading = false;
|
||||
},
|
||||
}));
|
||||
|
2
counter/static/bundled/counter/types.d.ts
vendored
2
counter/static/bundled/counter/types.d.ts
vendored
@ -1,4 +1,4 @@
|
||||
export type ErrorMessage = string;
|
||||
type ErrorMessage = string;
|
||||
|
||||
export interface InitialFormData {
|
||||
/* Used to refill the form when the backend raises an error */
|
||||
|
@ -9,14 +9,12 @@
|
||||
<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 %}
|
||||
@ -207,32 +205,35 @@
|
||||
{% trans %}No products available on this counter for this user{% endtrans %}
|
||||
</div>
|
||||
{% else %}
|
||||
<ui-tab-group>
|
||||
<ul>
|
||||
{% for category in categories.keys() -%}
|
||||
<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] -%}
|
||||
<button class="card shadow" @click="addToBasketWithMessage('{{ product.id }}', 1)">
|
||||
<img
|
||||
class="card-image"
|
||||
alt="image de {{ product.name }}"
|
||||
{% if product.icon %}
|
||||
src="{{ product.icon.url }}"
|
||||
{% else %}
|
||||
src="{{ static('core/img/na.gif') }}"
|
||||
{% endif %}
|
||||
/>
|
||||
<span class="card-content">
|
||||
<strong class="card-title">{{ product.name }}</strong>
|
||||
<p>{{ product.price }} €<br>{{ product.code }}</p>
|
||||
</span>
|
||||
</button>
|
||||
{%- endfor %}
|
||||
</div>
|
||||
</ui-tab>
|
||||
{% endfor %}
|
||||
</ui-tab-group>
|
||||
<li><a href="#cat_{{ category|slugify }}">{{ category }}</a></li>
|
||||
{%- endfor %}
|
||||
</ul>
|
||||
{% for category in categories.keys() -%}
|
||||
<div id="cat_{{ category|slugify }}">
|
||||
<h5 class="margin-bottom">{{ category }}</h5>
|
||||
<div class="row gap-2x">
|
||||
{% for product in categories[category] -%}
|
||||
<button class="card shadow" @click="addToBasketWithMessage('{{ product.id }}', 1)">
|
||||
<img
|
||||
class="card-image"
|
||||
alt="image de {{ product.name }}"
|
||||
{% if product.icon %}
|
||||
src="{{ product.icon.url }}"
|
||||
{% else %}
|
||||
src="{{ static('core/img/na.gif') }}"
|
||||
{% endif %}
|
||||
/>
|
||||
<span class="card-content">
|
||||
<strong class="card-title">{{ product.name }}</strong>
|
||||
<p>{{ product.price }} €<br>{{ product.code }}</p>
|
||||
</span>
|
||||
</button>
|
||||
{%- endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{%- endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -5,6 +5,10 @@
|
||||
{% 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,7 +17,6 @@ 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
|
||||
@ -824,53 +823,3 @@ 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"], end=None
|
||||
).update(end=now())
|
||||
Permanency.objects.filter(counter=counter_id, user=request.POST["user_id"]).update(
|
||||
end=F("activity")
|
||||
)
|
||||
return redirect("counter:details", counter_id=counter_id)
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 33 KiB |
Binary file not shown.
Before Width: | Height: | Size: 49 KiB |
@ -1,6 +0,0 @@
|
||||
::: api.auth
|
||||
handler: python
|
||||
options:
|
||||
heading_level: 3
|
||||
members:
|
||||
- ApiKeyAuth
|
@ -1,8 +0,0 @@
|
||||
::: api.hashers
|
||||
handler: python
|
||||
options:
|
||||
heading_level: 3
|
||||
members:
|
||||
- Sha256ApiKeyHasher
|
||||
- get_hasher
|
||||
- generate_key
|
@ -1,7 +0,0 @@
|
||||
::: api.auth
|
||||
handler: python
|
||||
options:
|
||||
heading_level: 3
|
||||
members:
|
||||
- ApiKey
|
||||
- ApiClient
|
@ -1,4 +0,0 @@
|
||||
::: api.permissions
|
||||
handler: python
|
||||
options:
|
||||
heading_level: 3
|
@ -20,6 +20,13 @@
|
||||
- CanCreateMixin
|
||||
- CanEditMixin
|
||||
- CanViewMixin
|
||||
- CanEditPropMixin
|
||||
- FormerSubscriberMixin
|
||||
- PermissionOrAuthorRequiredMixin
|
||||
|
||||
|
||||
## API Permissions
|
||||
|
||||
::: core.auth.api_permissions
|
||||
handler: python
|
||||
options:
|
||||
heading_level: 3
|
@ -1,215 +0,0 @@
|
||||
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(())
|
||||
}
|
||||
```
|
@ -1,175 +0,0 @@
|
||||
|
||||
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.
|
||||
|
||||
|
@ -263,3 +263,35 @@ avec un unique champ permettant de sélectionner des groupes.
|
||||
Par défaut, seuls les utilisateurs avec la permission
|
||||
`auth.change_permission` auront accès à ce formulaire
|
||||
(donc, normalement, uniquement les utilisateurs Root).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Utilisateur
|
||||
participant B as ReverseProxy
|
||||
participant C as MarkdownImage
|
||||
participant D as Model
|
||||
|
||||
A->>B: GET /page/foo
|
||||
B->>C: GET /page/foo
|
||||
C-->>B: La page, avec les urls
|
||||
B-->>A: La page, avec les urls
|
||||
alt image publique
|
||||
A->>B: GET markdown/public/2025/img.webp
|
||||
B-->>A: img.webp
|
||||
end
|
||||
alt image privée
|
||||
A->>B: GET markdown_image/{id}
|
||||
B->>C: GET markdown_image/{id}
|
||||
C->>D: user.can_view(image)
|
||||
alt l'utilisateur a le droit de voir l'image
|
||||
D-->>C: True
|
||||
C-->>B: 200 (avec le X-Accel-Redirect)
|
||||
B-->>A: img.webp
|
||||
end
|
||||
alt l'utilisateur n'a pas le droit de l'image
|
||||
D-->>C: False
|
||||
C-->>B: 403
|
||||
B-->>A: 403
|
||||
end
|
||||
end
|
||||
```
|
||||
|
@ -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/api/perms.md).
|
||||
dans [cette partie](../reference/core/api_permissions.md).
|
||||
|
@ -24,66 +24,62 @@ sith/
|
||||
├── .github/
|
||||
│ ├── actions/ (1)
|
||||
│ └── workflows/ (2)
|
||||
├── api/ (3)
|
||||
├── club/ (3)
|
||||
│ └── ...
|
||||
├── antispam/ (4)
|
||||
├── com/ (4)
|
||||
│ └── ...
|
||||
├── club/ (5)
|
||||
├── core/ (5)
|
||||
│ └── ...
|
||||
├── com/ (6)
|
||||
├── counter/ (6)
|
||||
│ └── ...
|
||||
├── core/ (7)
|
||||
├── docs/ (7)
|
||||
│ └── ...
|
||||
├── counter/ (8)
|
||||
├── eboutic/ (8)
|
||||
│ └── ...
|
||||
├── docs/ (9)
|
||||
├── election/ (9)
|
||||
│ └── ...
|
||||
├── eboutic/ (10)
|
||||
├── forum/ (10)
|
||||
│ └── ...
|
||||
├── election/ (11)
|
||||
├── galaxy/ (11)
|
||||
│ └── ...
|
||||
├── forum/ (12)
|
||||
├── locale/ (12)
|
||||
│ └── ...
|
||||
├── galaxy/ (13)
|
||||
├── matmat/ (13)
|
||||
│ └── ...
|
||||
├── locale/ (14)
|
||||
├── pedagogy/ (14)
|
||||
│ └── ...
|
||||
├── matmat/ (15)
|
||||
├── rootplace/ (15)
|
||||
│ └── ...
|
||||
├── pedagogy/ (16)
|
||||
├── sas/ (16)
|
||||
│ └── ...
|
||||
├── rootplace/ (17)
|
||||
├── sith/ (17)
|
||||
│ └── ...
|
||||
├── sas/ (18)
|
||||
├── subscription/ (18)
|
||||
│ └── ...
|
||||
├── sith/ (19)
|
||||
├── trombi/ (19)
|
||||
│ └── ...
|
||||
├── subscription/ (20)
|
||||
├── antispam/ (20)
|
||||
│ └── ...
|
||||
├── trombi/ (21)
|
||||
├── staticfiles/ (21)
|
||||
│ └── ...
|
||||
├── antispam/ (22)
|
||||
│ └── ...
|
||||
├── staticfiles/ (23)
|
||||
│ └── ...
|
||||
├── processes/ (24)
|
||||
├── processes/ (22)
|
||||
│ └── ...
|
||||
│
|
||||
├── .coveragerc (25)
|
||||
├── .envrc (26)
|
||||
├── .coveragerc (23)
|
||||
├── .envrc (24)
|
||||
├── .gitattributes
|
||||
├── .gitignore
|
||||
├── .mailmap
|
||||
├── .env (27)
|
||||
├── .env.example (28)
|
||||
├── manage.py (29)
|
||||
├── mkdocs.yml (30)
|
||||
├── .env (25)
|
||||
├── .env.example (26)
|
||||
├── manage.py (27)
|
||||
├── mkdocs.yml (28)
|
||||
├── uv.lock
|
||||
├── pyproject.toml (31)
|
||||
├── .venv/ (32)
|
||||
├── .python-version (33)
|
||||
├── Procfile.static (34)
|
||||
├── Procfile.service (35)
|
||||
├── pyproject.toml (29)
|
||||
├── .venv/ (30)
|
||||
├── .python-version (31)
|
||||
├── Procfile.static (32)
|
||||
├── Procfile.service (33)
|
||||
└── README.md
|
||||
```
|
||||
</div>
|
||||
@ -96,55 +92,53 @@ sith/
|
||||
des workflows Github.
|
||||
Par exemple, le workflow `docs.yml` compile
|
||||
et publie la documentation à chaque push sur la branche `master`.
|
||||
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
|
||||
3. Application de gestion des clubs et de leurs membres.
|
||||
4. Application contenant les fonctionnalités
|
||||
destinées aux responsables communication de l'AE.
|
||||
7. Application contenant la modélisation centrale du site.
|
||||
5. Application contenant la modélisation centrale du site.
|
||||
On en reparle plus loin sur cette page.
|
||||
8. Application de gestion des comptoirs, des permanences
|
||||
6. Application de gestion des comptoirs, des permanences
|
||||
sur ces comptoirs et des transactions qui y sont effectuées.
|
||||
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
|
||||
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
|
||||
est un graphe des niveaux de proximité entre les différents
|
||||
étudiants.
|
||||
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
|
||||
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
|
||||
peuvent également laisser leurs avis.
|
||||
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.
|
||||
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.
|
||||
Ajoute l'intégration du scss et du bundler js
|
||||
de manière transparente pour l'utilisateur.
|
||||
24. Module de gestion des services externes.
|
||||
22. Module de gestion des services externes.
|
||||
Offre une API simple pour utiliser les fichiers `Procfile.*`.
|
||||
25. Fichier de configuration de coverage.
|
||||
26. Fichier de configuration de direnv.
|
||||
27. Contient les variables d'environnement, qui sont susceptibles
|
||||
23. Fichier de configuration de coverage.
|
||||
24. Fichier de configuration de direnv.
|
||||
25. Contient les variables d'environnement, qui sont susceptibles
|
||||
de varier d'une machine à l'autre.
|
||||
28. Contient des valeurs par défaut pour le `.env`
|
||||
26. Contient des valeurs par défaut pour le `.env`
|
||||
pouvant convenir à un environnment de développement local
|
||||
29. Fichier généré automatiquement par Django. C'est lui
|
||||
27. 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>`
|
||||
30. Le fichier de configuration de la documentation,
|
||||
28. Le fichier de configuration de la documentation,
|
||||
avec ses plugins et sa table des matières.
|
||||
31. Le fichier où sont déclarés les dépendances et la configuration
|
||||
29. Le fichier où sont déclarés les dépendances et la configuration
|
||||
de certaines d'entre elles.
|
||||
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
|
||||
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
|
||||
automatique des static et autres services nécessaires à la command runserver.
|
||||
35. Fichier qui contrôle les services tiers nécessaires au fonctionnement
|
||||
33. 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 api.permissions import CanView
|
||||
from core.auth.api_permissions import CanView
|
||||
from counter.models import BillingInfo
|
||||
from eboutic.models import Basket
|
||||
|
||||
|
@ -4,6 +4,10 @@
|
||||
{% 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,6 +4,10 @@
|
||||
{% 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,14 +93,13 @@ $min_col_width: 100px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0;
|
||||
gap: 20px;
|
||||
row-gap: 10px;
|
||||
padding: $padding;
|
||||
width: 100%;
|
||||
|
||||
|
||||
>.role_text {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
|
||||
>h4 {
|
||||
@ -108,6 +107,7 @@ $min_col_width: 100px;
|
||||
}
|
||||
|
||||
.role_description {
|
||||
flex-grow: 1;
|
||||
margin-top: .5em;
|
||||
text-wrap: auto;
|
||||
text-align: left;
|
||||
|
@ -25,13 +25,12 @@ import warnings
|
||||
from datetime import timedelta
|
||||
from typing import Final, Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from club.models import Club, Membership
|
||||
from core.models import Group, Page, SithFile, User
|
||||
from core.models import Group, Page, User
|
||||
from core.utils import RED_PIXEL_PNG
|
||||
from sas.models import Album, PeoplePictureRelation, Picture
|
||||
from subscription.models import Subscription
|
||||
@ -91,13 +90,8 @@ class Command(BaseCommand):
|
||||
self.NB_CLUBS = options["club_count"]
|
||||
|
||||
root = User.objects.filter(username="root").first()
|
||||
sas = SithFile.objects.get(id=settings.SITH_SAS_ROOT_DIR_ID)
|
||||
self.galaxy_album = Album.objects.create(
|
||||
name="galaxy-register-file",
|
||||
owner=root,
|
||||
is_moderated=True,
|
||||
is_in_sas=True,
|
||||
parent=sas,
|
||||
name="galaxy-register-file", owner=root, is_moderated=True
|
||||
)
|
||||
|
||||
self.make_clubs()
|
||||
@ -285,14 +279,10 @@ class Command(BaseCommand):
|
||||
owner=u,
|
||||
name=f"galaxy-picture {u} {i // self.NB_USERS}",
|
||||
is_moderated=True,
|
||||
is_folder=False,
|
||||
parent=self.galaxy_album,
|
||||
is_in_sas=True,
|
||||
file=ContentFile(RED_PIXEL_PNG),
|
||||
original=ContentFile(RED_PIXEL_PNG),
|
||||
compressed=ContentFile(RED_PIXEL_PNG),
|
||||
thumbnail=ContentFile(RED_PIXEL_PNG),
|
||||
mime_type="image/png",
|
||||
size=len(RED_PIXEL_PNG),
|
||||
)
|
||||
)
|
||||
self.picts[i].file.name = self.picts[i].name
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -7,7 +7,7 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-05-18 12:17+0200\n"
|
||||
"POT-Creation-Date: 2025-04-13 00:18+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 "Link copied"
|
||||
msgstr "Lien copié"
|
||||
|
||||
#: 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
|
||||
#: com/static/bundled/com/components/ics-calendar-index.ts
|
||||
msgid "Link copied"
|
||||
msgstr "Lien copié"
|
||||
|
||||
#: com/static/bundled/com/components/moderation-alert-index.ts
|
||||
#, javascript-format
|
||||
msgid ""
|
||||
"This event will take place every week for %s weeks. If you publish or delete "
|
||||
|
10
mkdocs.yml
10
mkdocs.yml
@ -45,6 +45,7 @@ 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:
|
||||
@ -66,9 +67,6 @@ 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
|
||||
@ -86,11 +84,6 @@ 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
|
||||
@ -160,7 +153,6 @@ markdown_extensions:
|
||||
- pymdownx.details
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.keys
|
||||
- pymdownx.blocks.caption
|
||||
- pymdownx.superfences:
|
||||
custom_fences:
|
||||
- name: mermaid
|
||||
|
2520
package-lock.json
generated
2520
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