mirror of
https://github.com/ae-utbm/sith.git
synced 2025-06-18 09:05:20 +00:00
commit
28b60c7bae
0
api/__init__.py
Normal file
0
api/__init__.py
Normal file
55
api/admin.py
Normal file
55
api/admin.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
from django.contrib import admin, messages
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from api.hashers import generate_key
|
||||||
|
from api.models import ApiClient, ApiKey
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ApiClient)
|
||||||
|
class ApiClientAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "owner", "created_at", "updated_at")
|
||||||
|
search_fields = (
|
||||||
|
"name",
|
||||||
|
"owner__first_name",
|
||||||
|
"owner__last_name",
|
||||||
|
"owner__nick_name",
|
||||||
|
)
|
||||||
|
autocomplete_fields = ("owner", "groups", "client_permissions")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ApiKey)
|
||||||
|
class ApiKeyAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "client", "created_at", "revoked")
|
||||||
|
list_filter = ("revoked",)
|
||||||
|
date_hierarchy = "created_at"
|
||||||
|
|
||||||
|
readonly_fields = ("prefix", "hashed_key")
|
||||||
|
actions = ("revoke_keys",)
|
||||||
|
|
||||||
|
def save_model(self, request: HttpRequest, obj: ApiKey, form, change):
|
||||||
|
if not change:
|
||||||
|
key, hashed = generate_key()
|
||||||
|
obj.prefix = key[: ApiKey.PREFIX_LENGTH]
|
||||||
|
obj.hashed_key = hashed
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
_(
|
||||||
|
"The API key for %(name)s is: %(key)s. "
|
||||||
|
"Please store it somewhere safe: "
|
||||||
|
"you will not be able to see it again."
|
||||||
|
)
|
||||||
|
% {"name": obj.name, "key": key},
|
||||||
|
level=messages.WARNING,
|
||||||
|
)
|
||||||
|
return super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
|
def get_readonly_fields(self, request, obj: ApiKey | None = None):
|
||||||
|
if obj is None or obj.revoked:
|
||||||
|
return ["revoked", *self.readonly_fields]
|
||||||
|
return self.readonly_fields
|
||||||
|
|
||||||
|
@admin.action(description=_("Revoke selected API keys"))
|
||||||
|
def revoke_keys(self, _request: HttpRequest, queryset: QuerySet[ApiKey]):
|
||||||
|
queryset.update(revoked=True)
|
6
api/apps.py
Normal file
6
api/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ApiConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "api"
|
20
api/auth.py
Normal file
20
api/auth.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
from django.http import HttpRequest
|
||||||
|
from ninja.security import APIKeyHeader
|
||||||
|
|
||||||
|
from api.hashers import get_hasher
|
||||||
|
from api.models import ApiClient, ApiKey
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyAuth(APIKeyHeader):
|
||||||
|
param_name = "X-APIKey"
|
||||||
|
|
||||||
|
def authenticate(self, request: HttpRequest, key: str | None) -> ApiClient | None:
|
||||||
|
if not key or len(key) != ApiKey.KEY_LENGTH:
|
||||||
|
return None
|
||||||
|
hasher = get_hasher()
|
||||||
|
hashed_key = hasher.encode(key)
|
||||||
|
try:
|
||||||
|
key_obj = ApiKey.objects.get(revoked=False, hashed_key=hashed_key)
|
||||||
|
except ApiKey.DoesNotExist:
|
||||||
|
return None
|
||||||
|
return key_obj.client
|
43
api/hashers.py
Normal file
43
api/hashers.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import functools
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from django.contrib.auth.hashers import BasePasswordHasher
|
||||||
|
from django.utils.crypto import constant_time_compare
|
||||||
|
|
||||||
|
|
||||||
|
class Sha512ApiKeyHasher(BasePasswordHasher):
|
||||||
|
"""
|
||||||
|
An API key hasher using the sha256 algorithm.
|
||||||
|
|
||||||
|
This hasher shouldn't be used in Django's `PASSWORD_HASHERS` setting.
|
||||||
|
It is insecure for use in hashing passwords, but is safe for hashing
|
||||||
|
high entropy, randomly generated API keys.
|
||||||
|
"""
|
||||||
|
|
||||||
|
algorithm = "sha512"
|
||||||
|
|
||||||
|
def salt(self) -> str:
|
||||||
|
# No need for a salt on a high entropy key.
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def encode(self, password: str, salt: str = "") -> str:
|
||||||
|
hashed = hashlib.sha512(password.encode()).hexdigest()
|
||||||
|
return f"{self.algorithm}$${hashed}"
|
||||||
|
|
||||||
|
def verify(self, password: str, encoded: str) -> bool:
|
||||||
|
encoded_2 = self.encode(password, "")
|
||||||
|
return constant_time_compare(encoded, encoded_2)
|
||||||
|
|
||||||
|
|
||||||
|
@functools.cache
|
||||||
|
def get_hasher():
|
||||||
|
return Sha512ApiKeyHasher()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_key() -> tuple[str, str]:
|
||||||
|
"""Generate a [key, hash] couple."""
|
||||||
|
# this will result in key with a length of 72
|
||||||
|
key = str(secrets.token_urlsafe(54))
|
||||||
|
hasher = get_hasher()
|
||||||
|
return key, hasher.encode(key)
|
113
api/migrations/0001_initial.py
Normal file
113
api/migrations/0001_initial.py
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
# Generated by Django 5.2 on 2025-06-01 08:53
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("auth", "0012_alter_user_first_name_max_length"),
|
||||||
|
("core", "0046_permissionrights"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ApiClient",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=64, verbose_name="name")),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"client_permissions",
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Specific permissions for this api client.",
|
||||||
|
related_name="clients",
|
||||||
|
to="auth.permission",
|
||||||
|
verbose_name="client permissions",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"groups",
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
related_name="api_clients",
|
||||||
|
to="core.group",
|
||||||
|
verbose_name="groups",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"owner",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="api_clients",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="owner",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "api client",
|
||||||
|
"verbose_name_plural": "api clients",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ApiKey",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(blank=True, default="", verbose_name="name")),
|
||||||
|
(
|
||||||
|
"prefix",
|
||||||
|
models.CharField(
|
||||||
|
editable=False, max_length=5, verbose_name="prefix"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"hashed_key",
|
||||||
|
models.CharField(
|
||||||
|
db_index=True,
|
||||||
|
editable=False,
|
||||||
|
max_length=136,
|
||||||
|
verbose_name="hashed key",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("revoked", models.BooleanField(default=False, verbose_name="revoked")),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
(
|
||||||
|
"client",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="api_keys",
|
||||||
|
to="api.apiclient",
|
||||||
|
verbose_name="api client",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "api key",
|
||||||
|
"verbose_name_plural": "api keys",
|
||||||
|
"permissions": [("revoke_apikey", "Revoke API keys")],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
0
api/migrations/__init__.py
Normal file
0
api/migrations/__init__.py
Normal file
94
api/models.py
Normal file
94
api/models.py
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.utils.translation import pgettext_lazy
|
||||||
|
|
||||||
|
from core.models import Group, User
|
||||||
|
|
||||||
|
|
||||||
|
class ApiClient(models.Model):
|
||||||
|
name = models.CharField(_("name"), max_length=64)
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
verbose_name=_("owner"),
|
||||||
|
related_name="api_clients",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
groups = models.ManyToManyField(
|
||||||
|
Group, verbose_name=_("groups"), related_name="api_clients", blank=True
|
||||||
|
)
|
||||||
|
client_permissions = models.ManyToManyField(
|
||||||
|
Permission,
|
||||||
|
verbose_name=_("client permissions"),
|
||||||
|
blank=True,
|
||||||
|
help_text=_("Specific permissions for this api client."),
|
||||||
|
related_name="clients",
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
_perm_cache: set[str] | None = None
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("api client")
|
||||||
|
verbose_name_plural = _("api clients")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def has_perm(self, perm: str):
|
||||||
|
"""Return True if the client has the specified permission."""
|
||||||
|
|
||||||
|
if self._perm_cache is None:
|
||||||
|
group_permissions = (
|
||||||
|
Permission.objects.filter(group__group__in=self.groups.all())
|
||||||
|
.values_list("content_type__app_label", "codename")
|
||||||
|
.order_by()
|
||||||
|
)
|
||||||
|
client_permissions = self.client_permissions.values_list(
|
||||||
|
"content_type__app_label", "codename"
|
||||||
|
).order_by()
|
||||||
|
self._perm_cache = {
|
||||||
|
f"{content_type}.{name}"
|
||||||
|
for content_type, name in (*group_permissions, *client_permissions)
|
||||||
|
}
|
||||||
|
return perm in self._perm_cache
|
||||||
|
|
||||||
|
def has_perms(self, perm_list):
|
||||||
|
"""
|
||||||
|
Return True if the client has each of the specified permissions. If
|
||||||
|
object is passed, check if the client has all required perms for it.
|
||||||
|
"""
|
||||||
|
if not isinstance(perm_list, Iterable) or isinstance(perm_list, str):
|
||||||
|
raise ValueError("perm_list must be an iterable of permissions.")
|
||||||
|
return all(self.has_perm(perm) for perm in perm_list)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKey(models.Model):
|
||||||
|
PREFIX_LENGTH = 5
|
||||||
|
KEY_LENGTH = 72
|
||||||
|
HASHED_KEY_LENGTH = 136
|
||||||
|
|
||||||
|
name = models.CharField(_("name"), blank=True, default="")
|
||||||
|
prefix = models.CharField(_("prefix"), max_length=PREFIX_LENGTH, editable=False)
|
||||||
|
hashed_key = models.CharField(
|
||||||
|
_("hashed key"), max_length=HASHED_KEY_LENGTH, db_index=True, editable=False
|
||||||
|
)
|
||||||
|
client = models.ForeignKey(
|
||||||
|
ApiClient,
|
||||||
|
verbose_name=_("api client"),
|
||||||
|
related_name="api_keys",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
revoked = models.BooleanField(pgettext_lazy("api key", "revoked"), default=False)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("api key")
|
||||||
|
verbose_name_plural = _("api keys")
|
||||||
|
permissions = [("revoke_apikey", "Revoke API keys")]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.prefix}***)"
|
@ -39,7 +39,7 @@ Example:
|
|||||||
|
|
||||||
import operator
|
import operator
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
from typing import Any
|
from typing import Any, Callable
|
||||||
|
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
@ -67,21 +67,26 @@ class HasPerm(BasePermission):
|
|||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
# this route will require both permissions
|
@api_controller("/foo")
|
||||||
@route.put("/foo", permissions=[HasPerm(["foo.change_foo", "foo.add_foo"])]
|
class FooController(ControllerBase):
|
||||||
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,
|
# This route will require at least one of the perm,
|
||||||
# but it's not mandatory to have all of them
|
# but it's not mandatory to have all of them
|
||||||
@route.put(
|
@route.put(
|
||||||
"/bar",
|
"/bar",
|
||||||
permissions=[HasPerm(["foo.change_bar", "foo.add_bar"], op=operator.or_)],
|
permissions=[HasPerm(["foo.change_bar", "foo.add_bar"], op=operator.or_)],
|
||||||
)
|
)
|
||||||
def bar(self): ...
|
def bar(self): ...
|
||||||
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, perms: str | Permission | list[str | Permission], op=operator.and_
|
self,
|
||||||
|
perms: str | Permission | list[str | Permission],
|
||||||
|
op: Callable[[bool, bool], bool] = operator.and_,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
@ -96,7 +101,16 @@ class HasPerm(BasePermission):
|
|||||||
self._perms = perms
|
self._perms = perms
|
||||||
|
|
||||||
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
|
def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool:
|
||||||
return reduce(self._operator, (request.user.has_perm(p) for p in self._perms))
|
# if the request has the `auth` property,
|
||||||
|
# it means that the user has been explicitly authenticated
|
||||||
|
# using a django-ninja authentication backend
|
||||||
|
# (whether it is SessionAuth or ApiKeyAuth).
|
||||||
|
# If not, this authentication has not been done, but the user may
|
||||||
|
# still be implicitly authenticated through AuthenticationMiddleware
|
||||||
|
user = request.auth if hasattr(request, "auth") else request.user
|
||||||
|
# `user` may either be a `core.User` or an `api.ApiClient` ;
|
||||||
|
# they are not the same model, but they both implement the `has_perm` method
|
||||||
|
return reduce(self._operator, (user.has_perm(p) for p in self._perms))
|
||||||
|
|
||||||
|
|
||||||
class IsRoot(BasePermission):
|
class IsRoot(BasePermission):
|
||||||
@ -180,4 +194,4 @@ class IsLoggedInCounter(BasePermission):
|
|||||||
return Counter.objects.filter(token=token).exists()
|
return Counter.objects.filter(token=token).exists()
|
||||||
|
|
||||||
|
|
||||||
CanAccessLookup = IsOldSubscriber | IsRoot | IsLoggedInCounter
|
CanAccessLookup = IsLoggedInCounter | HasPerm("core.access_lookup")
|
0
api/tests/__init__.py
Normal file
0
api/tests/__init__.py
Normal file
29
api/tests/test_api_key.py
Normal file
29
api/tests/test_api_key.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import pytest
|
||||||
|
from django.test import RequestFactory
|
||||||
|
from model_bakery import baker
|
||||||
|
|
||||||
|
from api.auth import ApiKeyAuth
|
||||||
|
from api.hashers import generate_key
|
||||||
|
from api.models import ApiClient, ApiKey
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_api_key_auth():
|
||||||
|
key, hashed = generate_key()
|
||||||
|
client = baker.make(ApiClient)
|
||||||
|
baker.make(ApiKey, client=client, hashed_key=hashed)
|
||||||
|
auth = ApiKeyAuth()
|
||||||
|
|
||||||
|
assert auth.authenticate(RequestFactory().get(""), key) == client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("key", "hashed"), [(generate_key()[0], generate_key()[1]), (generate_key()[0], "")]
|
||||||
|
)
|
||||||
|
def test_api_key_auth_invalid(key, hashed):
|
||||||
|
client = baker.make(ApiClient)
|
||||||
|
baker.make(ApiKey, client=client, hashed_key=hashed)
|
||||||
|
auth = ApiKeyAuth()
|
||||||
|
|
||||||
|
assert auth.authenticate(RequestFactory().get(""), key) is None
|
10
api/urls.py
Normal file
10
api/urls.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from ninja_extra import NinjaExtraAPI
|
||||||
|
|
||||||
|
api = NinjaExtraAPI(
|
||||||
|
title="PICON",
|
||||||
|
description="Portail Interaction de Communication avec les Services Étudiants",
|
||||||
|
version="0.2.0",
|
||||||
|
urls_namespace="api",
|
||||||
|
csrf=True,
|
||||||
|
)
|
||||||
|
api.auto_discover_controllers()
|
22
club/api.py
22
club/api.py
@ -1,22 +1,38 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from annotated_types import MinLen
|
from annotated_types import MinLen
|
||||||
|
from ninja.security import SessionAuth
|
||||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||||
from ninja_extra.schemas import PaginatedResponseSchema
|
from ninja_extra.schemas import PaginatedResponseSchema
|
||||||
|
|
||||||
|
from api.auth import ApiKeyAuth
|
||||||
|
from api.permissions import CanAccessLookup, HasPerm
|
||||||
from club.models import Club
|
from club.models import Club
|
||||||
from club.schemas import ClubSchema
|
from club.schemas import ClubSchema, SimpleClubSchema
|
||||||
from core.auth.api_permissions import CanAccessLookup
|
|
||||||
|
|
||||||
|
|
||||||
@api_controller("/club")
|
@api_controller("/club")
|
||||||
class ClubController(ControllerBase):
|
class ClubController(ControllerBase):
|
||||||
@route.get(
|
@route.get(
|
||||||
"/search",
|
"/search",
|
||||||
response=PaginatedResponseSchema[ClubSchema],
|
response=PaginatedResponseSchema[SimpleClubSchema],
|
||||||
|
auth=[SessionAuth(), ApiKeyAuth()],
|
||||||
permissions=[CanAccessLookup],
|
permissions=[CanAccessLookup],
|
||||||
|
url_name="search_club",
|
||||||
)
|
)
|
||||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
def search_club(self, search: Annotated[str, MinLen(1)]):
|
def search_club(self, search: Annotated[str, MinLen(1)]):
|
||||||
return Club.objects.filter(name__icontains=search).values()
|
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):
|
||||||
|
return self.get_object_or_exception(
|
||||||
|
Club.objects.prefetch_related("members", "members__user"), id=club_id
|
||||||
|
)
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
from ninja import ModelSchema
|
from ninja import ModelSchema
|
||||||
|
|
||||||
from club.models import Club
|
from club.models import Club, Membership
|
||||||
|
from core.schemas import SimpleUserSchema
|
||||||
|
|
||||||
|
|
||||||
class ClubSchema(ModelSchema):
|
class SimpleClubSchema(ModelSchema):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Club
|
model = Club
|
||||||
fields = ["id", "name"]
|
fields = ["id", "name"]
|
||||||
@ -21,3 +22,19 @@ class ClubProfileSchema(ModelSchema):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def resolve_url(obj: Club) -> str:
|
def resolve_url(obj: Club) -> str:
|
||||||
return obj.get_absolute_url()
|
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]
|
||||||
|
21
club/tests/test_club_controller.py
Normal file
21
club/tests/test_club_controller.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import pytest
|
||||||
|
from django.test import Client
|
||||||
|
from django.urls import reverse
|
||||||
|
from model_bakery import baker
|
||||||
|
from pytest_django.asserts import assertNumQueries
|
||||||
|
|
||||||
|
from club.models import Club, Membership
|
||||||
|
from core.baker_recipes import subscriber_user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_fetch_club(client: Client):
|
||||||
|
club = baker.make(Club)
|
||||||
|
baker.make(Membership, club=club, _quantity=10, _bulk_create=True)
|
||||||
|
user = subscriber_user.make()
|
||||||
|
client.force_login(user)
|
||||||
|
with assertNumQueries(7):
|
||||||
|
# - 4 queries for authentication
|
||||||
|
# - 3 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 pydantic import TypeAdapter
|
||||||
|
|
||||||
from club.models import Club
|
from club.models import Club
|
||||||
from club.schemas import ClubSchema
|
from club.schemas import SimpleClubSchema
|
||||||
from core.views.widgets.ajax_select import (
|
from core.views.widgets.ajax_select import (
|
||||||
AutoCompleteSelect,
|
AutoCompleteSelect,
|
||||||
AutoCompleteSelectMultiple,
|
AutoCompleteSelectMultiple,
|
||||||
@ -13,7 +13,7 @@ _js = ["bundled/club/components/ajax-select-index.ts"]
|
|||||||
class AutoCompleteSelectClub(AutoCompleteSelect):
|
class AutoCompleteSelectClub(AutoCompleteSelect):
|
||||||
component_name = "club-ajax-select"
|
component_name = "club-ajax-select"
|
||||||
model = Club
|
model = Club
|
||||||
adapter = TypeAdapter(list[ClubSchema])
|
adapter = TypeAdapter(list[SimpleClubSchema])
|
||||||
|
|
||||||
js = _js
|
js = _js
|
||||||
|
|
||||||
@ -21,6 +21,6 @@ class AutoCompleteSelectClub(AutoCompleteSelect):
|
|||||||
class AutoCompleteSelectMultipleClub(AutoCompleteSelectMultiple):
|
class AutoCompleteSelectMultipleClub(AutoCompleteSelectMultiple):
|
||||||
component_name = "club-ajax-select"
|
component_name = "club-ajax-select"
|
||||||
model = Club
|
model = Club
|
||||||
adapter = TypeAdapter(list[ClubSchema])
|
adapter = TypeAdapter(list[SimpleClubSchema])
|
||||||
|
|
||||||
js = _js
|
js = _js
|
||||||
|
@ -8,10 +8,10 @@ from ninja_extra.pagination import PageNumberPaginationExtra
|
|||||||
from ninja_extra.permissions import IsAuthenticated
|
from ninja_extra.permissions import IsAuthenticated
|
||||||
from ninja_extra.schemas import PaginatedResponseSchema
|
from ninja_extra.schemas import PaginatedResponseSchema
|
||||||
|
|
||||||
|
from api.permissions import HasPerm
|
||||||
from com.ics_calendar import IcsCalendar
|
from com.ics_calendar import IcsCalendar
|
||||||
from com.models import News, NewsDate
|
from com.models import News, NewsDate
|
||||||
from com.schemas import NewsDateFilterSchema, NewsDateSchema
|
from com.schemas import NewsDateFilterSchema, NewsDateSchema
|
||||||
from core.auth.api_permissions import HasPerm
|
|
||||||
from core.views.files import send_raw_file
|
from core.views.files import send_raw_file
|
||||||
|
|
||||||
|
|
||||||
|
@ -194,7 +194,7 @@ class NewsDateQuerySet(models.QuerySet):
|
|||||||
class NewsDate(models.Model):
|
class NewsDate(models.Model):
|
||||||
"""A date associated with news.
|
"""A date associated with news.
|
||||||
|
|
||||||
A [News][] can have multiple dates, for example if it is a recurring event.
|
A [News][com.models.News] can have multiple dates, for example if it is a recurring event.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
news = models.ForeignKey(
|
news = models.ForeignKey(
|
||||||
|
@ -5,13 +5,15 @@ from django.conf import settings
|
|||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from ninja import File, Query
|
from ninja import File, Query
|
||||||
|
from ninja.security import SessionAuth
|
||||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||||
from ninja_extra.exceptions import PermissionDenied
|
from ninja_extra.exceptions import PermissionDenied
|
||||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||||
from ninja_extra.schemas import PaginatedResponseSchema
|
from ninja_extra.schemas import PaginatedResponseSchema
|
||||||
|
|
||||||
|
from api.auth import ApiKeyAuth
|
||||||
|
from api.permissions import CanAccessLookup, CanView, HasPerm
|
||||||
from club.models import Mailing
|
from club.models import Mailing
|
||||||
from core.auth.api_permissions import CanAccessLookup, CanView, HasPerm
|
|
||||||
from core.models import Group, QuickUploadImage, SithFile, User
|
from core.models import Group, QuickUploadImage, SithFile, User
|
||||||
from core.schemas import (
|
from core.schemas import (
|
||||||
FamilyGodfatherSchema,
|
FamilyGodfatherSchema,
|
||||||
@ -90,6 +92,7 @@ class SithFileController(ControllerBase):
|
|||||||
@route.get(
|
@route.get(
|
||||||
"/search",
|
"/search",
|
||||||
response=PaginatedResponseSchema[SithFileSchema],
|
response=PaginatedResponseSchema[SithFileSchema],
|
||||||
|
auth=[SessionAuth(), ApiKeyAuth()],
|
||||||
permissions=[CanAccessLookup],
|
permissions=[CanAccessLookup],
|
||||||
)
|
)
|
||||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
@ -102,6 +105,7 @@ class GroupController(ControllerBase):
|
|||||||
@route.get(
|
@route.get(
|
||||||
"/search",
|
"/search",
|
||||||
response=PaginatedResponseSchema[GroupSchema],
|
response=PaginatedResponseSchema[GroupSchema],
|
||||||
|
auth=[SessionAuth(), ApiKeyAuth()],
|
||||||
permissions=[CanAccessLookup],
|
permissions=[CanAccessLookup],
|
||||||
)
|
)
|
||||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
|
@ -805,6 +805,8 @@ class Command(BaseCommand):
|
|||||||
"add_peoplepicturerelation",
|
"add_peoplepicturerelation",
|
||||||
"add_page",
|
"add_page",
|
||||||
"add_quickuploadimage",
|
"add_quickuploadimage",
|
||||||
|
"view_club",
|
||||||
|
"access_lookup",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
28
core/migrations/0046_permissionrights.py
Normal file
28
core/migrations/0046_permissionrights.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 5.2 on 2025-05-20 17:50
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [("core", "0045_quickuploadimage")]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="GlobalPermissionRights",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"permissions": [("access_lookup", "Can access any lookup in the sith")],
|
||||||
|
"managed": False,
|
||||||
|
"default_permissions": [],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
@ -754,6 +754,23 @@ class UserBan(models.Model):
|
|||||||
return f"Ban of user {self.user.id}"
|
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):
|
class Preferences(models.Model):
|
||||||
user = models.OneToOneField(
|
user = models.OneToOneField(
|
||||||
User, related_name="_preferences", on_delete=models.CASCADE
|
User, related_name="_preferences", on_delete=models.CASCADE
|
||||||
|
@ -16,11 +16,13 @@ from django.conf import settings
|
|||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from ninja import Query
|
from ninja import Query
|
||||||
|
from ninja.security import SessionAuth
|
||||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||||
from ninja_extra.schemas import PaginatedResponseSchema
|
from ninja_extra.schemas import PaginatedResponseSchema
|
||||||
|
|
||||||
from core.auth.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
|
from api.auth import ApiKeyAuth
|
||||||
|
from api.permissions import CanAccessLookup, CanView, IsInGroup, IsRoot
|
||||||
from counter.models import Counter, Product, ProductType
|
from counter.models import Counter, Product, ProductType
|
||||||
from counter.schemas import (
|
from counter.schemas import (
|
||||||
CounterFilterSchema,
|
CounterFilterSchema,
|
||||||
@ -62,6 +64,7 @@ class CounterController(ControllerBase):
|
|||||||
@route.get(
|
@route.get(
|
||||||
"/search",
|
"/search",
|
||||||
response=PaginatedResponseSchema[SimplifiedCounterSchema],
|
response=PaginatedResponseSchema[SimplifiedCounterSchema],
|
||||||
|
auth=[SessionAuth(), ApiKeyAuth()],
|
||||||
permissions=[CanAccessLookup],
|
permissions=[CanAccessLookup],
|
||||||
)
|
)
|
||||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
@ -74,6 +77,7 @@ class ProductController(ControllerBase):
|
|||||||
@route.get(
|
@route.get(
|
||||||
"/search",
|
"/search",
|
||||||
response=PaginatedResponseSchema[SimpleProductSchema],
|
response=PaginatedResponseSchema[SimpleProductSchema],
|
||||||
|
auth=[SessionAuth(), ApiKeyAuth()],
|
||||||
permissions=[CanAccessLookup],
|
permissions=[CanAccessLookup],
|
||||||
)
|
)
|
||||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
|
@ -61,7 +61,7 @@ class CustomerQuerySet(models.QuerySet):
|
|||||||
Returns:
|
Returns:
|
||||||
The number of updated rows.
|
The number of updated rows.
|
||||||
|
|
||||||
Warnings:
|
Warning:
|
||||||
The execution time of this query grows really quickly.
|
The execution time of this query grows really quickly.
|
||||||
When updating 500 customers, it may take around a second.
|
When updating 500 customers, it may take around a second.
|
||||||
If you try to update all customers at once, the execution time
|
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 ninja import Field, FilterSchema, ModelSchema, Schema
|
||||||
from pydantic import model_validator
|
from pydantic import model_validator
|
||||||
|
|
||||||
from club.schemas import ClubSchema
|
from club.schemas import SimpleClubSchema
|
||||||
from core.schemas import GroupSchema, SimpleUserSchema
|
from core.schemas import GroupSchema, SimpleUserSchema
|
||||||
from counter.models import Counter, Product, ProductType
|
from counter.models import Counter, Product, ProductType
|
||||||
|
|
||||||
@ -82,7 +82,7 @@ class ProductSchema(ModelSchema):
|
|||||||
]
|
]
|
||||||
|
|
||||||
buying_groups: list[GroupSchema]
|
buying_groups: list[GroupSchema]
|
||||||
club: ClubSchema
|
club: SimpleClubSchema
|
||||||
product_type: SimpleProductTypeSchema | None
|
product_type: SimpleProductTypeSchema | None
|
||||||
url: str
|
url: str
|
||||||
|
|
||||||
|
BIN
docs/img/api_key_authorize_1.png
Normal file
BIN
docs/img/api_key_authorize_1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 33 KiB |
BIN
docs/img/api_key_authorize_2.png
Normal file
BIN
docs/img/api_key_authorize_2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 49 KiB |
6
docs/reference/api/auth.md
Normal file
6
docs/reference/api/auth.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
::: api.auth
|
||||||
|
handler: python
|
||||||
|
options:
|
||||||
|
heading_level: 3
|
||||||
|
members:
|
||||||
|
- ApiKeyAuth
|
8
docs/reference/api/hashers.md
Normal file
8
docs/reference/api/hashers.md
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
::: api.hashers
|
||||||
|
handler: python
|
||||||
|
options:
|
||||||
|
heading_level: 3
|
||||||
|
members:
|
||||||
|
- Sha256ApiKeyHasher
|
||||||
|
- get_hasher
|
||||||
|
- generate_key
|
7
docs/reference/api/models.md
Normal file
7
docs/reference/api/models.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
::: api.auth
|
||||||
|
handler: python
|
||||||
|
options:
|
||||||
|
heading_level: 3
|
||||||
|
members:
|
||||||
|
- ApiKey
|
||||||
|
- ApiClient
|
4
docs/reference/api/perms.md
Normal file
4
docs/reference/api/perms.md
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
::: api.permissions
|
||||||
|
handler: python
|
||||||
|
options:
|
||||||
|
heading_level: 3
|
@ -20,13 +20,6 @@
|
|||||||
- CanCreateMixin
|
- CanCreateMixin
|
||||||
- CanEditMixin
|
- CanEditMixin
|
||||||
- CanViewMixin
|
- CanViewMixin
|
||||||
|
- CanEditPropMixin
|
||||||
- FormerSubscriberMixin
|
- FormerSubscriberMixin
|
||||||
- PermissionOrAuthorRequiredMixin
|
- PermissionOrAuthorRequiredMixin
|
||||||
|
|
||||||
|
|
||||||
## API Permissions
|
|
||||||
|
|
||||||
::: core.auth.api_permissions
|
|
||||||
handler: python
|
|
||||||
options:
|
|
||||||
heading_level: 3
|
|
215
docs/tutorial/api/connect.md
Normal file
215
docs/tutorial/api/connect.md
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
La connexion à l'API du site AE peut se faire par deux moyens :
|
||||||
|
|
||||||
|
- par le cookie de session du site ; si vous accédez à l'API depuis le sith
|
||||||
|
en étant connecté, cette méthode fonctionne par défaut
|
||||||
|
- par clef d'API ; si vous accédez à l'API depuis une application externe,
|
||||||
|
vous devez passer par cette méthode.
|
||||||
|
|
||||||
|
Comme la méthode par cookie de session ne devrait pas être utilisée
|
||||||
|
en dehors du cadre interne au site et qu'elle marche par défaut
|
||||||
|
dans le cadre de ce dernier, nous ne décrirons pas outre mesure la manière
|
||||||
|
de l'utiliser.
|
||||||
|
|
||||||
|
## Obtenir une clef d'API
|
||||||
|
|
||||||
|
Il n'y a, à l'heure actuelle, pas d'interface accessible sur le site
|
||||||
|
pour obtenir une clef d'API.
|
||||||
|
Si vous désirez en obtenir une, demandez directement au respo info.
|
||||||
|
|
||||||
|
!!!danger
|
||||||
|
|
||||||
|
Votre clef d'API doit rester secrète.
|
||||||
|
Ne la transmettez à personne, ne l'inscrivez pas en dur dans votre code.
|
||||||
|
|
||||||
|
Si votre clef a fuité, ou que vous soupçonnez qu'elle ait pu fuiter,
|
||||||
|
informez-en immédiatement l'équipe informatique !
|
||||||
|
|
||||||
|
## L'interface Swagger
|
||||||
|
|
||||||
|
Avant de commencer à utiliser l'API du site, vous pouvez explorer
|
||||||
|
les différentes routes qu'elle met à disposition,
|
||||||
|
avec les schémas de données attendus en requête et en réponse.
|
||||||
|
|
||||||
|
Pour cela, vous pouvez vous rendre sur
|
||||||
|
[https://ae.utbm.fr/api/docs](https://ae.utbm.fr/api/docs).
|
||||||
|
|
||||||
|
Toutes les routes, à de rares exceptions près, y sont recensées.
|
||||||
|
Vous pouvez les utiliser dans les limites
|
||||||
|
de ce à quoi vos permissions vous donnent droit
|
||||||
|
et de la méthode d'authentification.
|
||||||
|
|
||||||
|
Vous pouvez vous connecter directement sur l'interface Swagger,
|
||||||
|
en cliquant sur ce bouton, en haut à droite :
|
||||||
|
|
||||||
|

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

|
||||||
|
/// caption
|
||||||
|
Saisie de la clef d'API
|
||||||
|
///
|
||||||
|
|
||||||
|
Les routes accessibles avec une clef d'API seront alors marquées par
|
||||||
|
une icône de cadenas fermé, sur la droite.
|
||||||
|
|
||||||
|
!!!warning "Authentification et permissions"
|
||||||
|
|
||||||
|
L'icône de cadenas signifie que la route accepte l'authentification
|
||||||
|
basée sur les clefs d'API, mais pas forcément que vous avez les
|
||||||
|
permissions nécessaires.
|
||||||
|
|
||||||
|
Si une route vous renvoie une erreur 403,
|
||||||
|
référez-en à l'équipe info, pour qu'elle puisse vous donner
|
||||||
|
les permissions nécessaires.
|
||||||
|
|
||||||
|
## Utiliser la clef d'API
|
||||||
|
|
||||||
|
### `X-APIKey`
|
||||||
|
|
||||||
|
Maintenant que vous avez la clef d'API,
|
||||||
|
il faut l'utiliser pour authentifier votre application
|
||||||
|
lorsqu'elle effectue des requêtes au site.
|
||||||
|
|
||||||
|
Pour cela, vous devez le fournir dans vos requêtes
|
||||||
|
à travers le header `X-APIKey`.
|
||||||
|
|
||||||
|
Par exemple :
|
||||||
|
|
||||||
|
```shell
|
||||||
|
curl "https://ae.utbm.fr/api/club/1" \
|
||||||
|
-H "X-APIKey: <votre clef d'API>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Comme votre clef d'API doit rester absolument secrète,
|
||||||
|
vous ne devez en aucun cas la mettre dans votre code.
|
||||||
|
À la place, vous pouvez créer un fichier (par exemple, un `.env`)
|
||||||
|
qui contiendra votre clef et qui sera gitignoré.
|
||||||
|
|
||||||
|
```dotenv title=".env"
|
||||||
|
API_KEY="<votre clef d'API>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Vous fournirez alors la clef d'API en la chargeant depuis votre environnement.
|
||||||
|
Notez que c'est une bonne pratique à double-titre,
|
||||||
|
puisque vous pouvez ainsi aisément changer votre clef d'API.
|
||||||
|
|
||||||
|
### Connexion persistante
|
||||||
|
|
||||||
|
La plupart des librairies permettant d'effectuer des requêtes
|
||||||
|
HTTP incluent une prise en charge des sessions persistantes.
|
||||||
|
Nous vous recommandons fortement d'utiliser ces fonctionnalités,
|
||||||
|
puisqu'elles permettent de rendre votre code plus simple
|
||||||
|
(vous n'aurez à renseigner votre clef d'API qu'une seule fois)
|
||||||
|
et plus efficace (réutiliser la même connexion plutôt que d'en créer
|
||||||
|
une nouvelle à chaque requête peut résulter en un gain de performance significatif ;
|
||||||
|
cf. [HTTP persistant connection (wikipedia)](https://en.wikipedia.org/wiki/HTTP_persistent_connection))
|
||||||
|
|
||||||
|
Voici quelques exemples :
|
||||||
|
|
||||||
|
=== "Python (requests)"
|
||||||
|
|
||||||
|
Dépendances :
|
||||||
|
|
||||||
|
- `requests` (>=2.32)
|
||||||
|
- `environs` (>=14.1)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
from environs import Env
|
||||||
|
|
||||||
|
env = Env()
|
||||||
|
env.read_env()
|
||||||
|
|
||||||
|
with requests.Session() as session:
|
||||||
|
session.headers["X-APIKey"] = env.str("API_KEY")
|
||||||
|
response = session.get("https://ae.utbm.fr/api/club/1")
|
||||||
|
print(response.json())
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Python (aiohttp)"
|
||||||
|
|
||||||
|
Dépendances :
|
||||||
|
|
||||||
|
- `aiohttp` (>=3.11)
|
||||||
|
- `environs` (>=14.1)
|
||||||
|
|
||||||
|
```python
|
||||||
|
import aiohttp
|
||||||
|
import asyncio
|
||||||
|
from environs import Env
|
||||||
|
|
||||||
|
env = Env()
|
||||||
|
env.read_env()
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
async with aiohttp.ClientSession(
|
||||||
|
base_url="https://ae.utbm.fr/api/",
|
||||||
|
headers={"X-APIKey": env.str("API_KEY")}
|
||||||
|
) as session:
|
||||||
|
async with session.get("club/1") as res:
|
||||||
|
print(await res.json())
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Javascript (axios)"
|
||||||
|
|
||||||
|
Dépendances :
|
||||||
|
|
||||||
|
- `axios` (>=1.9)
|
||||||
|
- `dotenv` (>=16.5)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { axios } from "axios";
|
||||||
|
import { config } from "dotenv";
|
||||||
|
|
||||||
|
config();
|
||||||
|
|
||||||
|
const instance = axios.create({
|
||||||
|
baseUrl: "https://ae.utbm.fr/api/",
|
||||||
|
headers: { "X-APIKey": process.env.API_KEY }
|
||||||
|
});
|
||||||
|
console.log(await instance.get("club/1").json());
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Rust (reqwest)"
|
||||||
|
|
||||||
|
Dépendances :
|
||||||
|
|
||||||
|
- `reqwest` (>= 0.12, features `json` et `gzip`)
|
||||||
|
- `tokio` (>= 1.44, feature `derive`)
|
||||||
|
- `dotenvy` (>= 0.15)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use reqwest::Client;
|
||||||
|
use reqwest::header::{HeaderMap, HeaderValue};
|
||||||
|
use dotenvy::EnvLoader;
|
||||||
|
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let env = EnvLoader::new().load()?;
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
let mut api_key = HeaderValue::from_str(env.var("API_KEY")?.as_str());
|
||||||
|
api_key.set_sensitive(true);
|
||||||
|
headers.insert("X-APIKey", api_key);
|
||||||
|
let client = Client::builder()
|
||||||
|
.default_headers(headers)
|
||||||
|
.gzip(true)
|
||||||
|
.build()?;
|
||||||
|
let resp = client
|
||||||
|
.get("https://ae.utbm.fr/api/club/1")
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
println!("{resp:#?}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
175
docs/tutorial/api/dev.md
Normal file
175
docs/tutorial/api/dev.md
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
|
||||||
|
Pour l'API, nous utilisons `django-ninja` et sa surcouche `django-ninja-extra`.
|
||||||
|
Ce sont des librairies relativement simples et qui présentent
|
||||||
|
l'immense avantage d'offrir des mécanismes de validation et de sérialisation
|
||||||
|
de données à la fois simples et expressifs.
|
||||||
|
|
||||||
|
## Dossiers et fichiers
|
||||||
|
|
||||||
|
L'API possède une application (`api`)
|
||||||
|
à la racine du projet, contenant des utilitaires
|
||||||
|
et de la configuration partagée par toutes les autres applications.
|
||||||
|
C'est la pièce centrale de notre API, mais ce n'est pas là que
|
||||||
|
vous trouverez les routes de l'API.
|
||||||
|
|
||||||
|
Les routes en elles-mêmes sont contenues dans les autres applications,
|
||||||
|
de manière thématiques :
|
||||||
|
les routes liées aux clubs sont dans `club`, les routes liées
|
||||||
|
aux photos dans `sas` et ainsi de suite.
|
||||||
|
|
||||||
|
Les fichiers liés à l'API dans chaque application sont
|
||||||
|
`schemas.py` et `api.py`.
|
||||||
|
`schemas.py` contient les schémas de validation de données
|
||||||
|
et `api.py` contient les contrôleurs de l'API.
|
||||||
|
|
||||||
|
|
||||||
|
## Schéma de données
|
||||||
|
|
||||||
|
Le cœur de django-ninja étant sa validation de données grâce à Pydantic,
|
||||||
|
le développement de l'API commence par l'écriture de ses schémas de données.
|
||||||
|
|
||||||
|
Pour en comprendre le fonctionnement, veuillez consulter
|
||||||
|
[la doc de django-ninja](https://django-ninja.dev/guides/response/).
|
||||||
|
|
||||||
|
Il est également important de consulter
|
||||||
|
[la doc de pydantic](https://docs.pydantic.dev/latest/).
|
||||||
|
|
||||||
|
Notre surcouche par-dessus les schémas de django-ninja est relativement mince.
|
||||||
|
Elle ne comprend que [UploadedImage][core.schemas.UploadedImage], qui hérite de
|
||||||
|
[`UploadedFile`](https://django-ninja.dev/guides/input/file-params/?h=upl)
|
||||||
|
pour le restreindre uniquement aux images.
|
||||||
|
|
||||||
|
## Authentification et permissions
|
||||||
|
|
||||||
|
### Authentification
|
||||||
|
|
||||||
|
Notre API offre deux moyens d'authentification :
|
||||||
|
|
||||||
|
- par cookie de session (la méthode par défaut de django)
|
||||||
|
- par clef d'API
|
||||||
|
|
||||||
|
La plus grande partie des routes de l'API utilisent la méthode par cookie de session.
|
||||||
|
|
||||||
|
Pour placer une route d'API derrière l'une de ces méthodes (ou bien les deux),
|
||||||
|
utilisez l'attribut `auth` et les classes `SessionAuth` et
|
||||||
|
[`ApiKeyAuth`][api.auth.ApiKeyAuth].
|
||||||
|
|
||||||
|
!!!example
|
||||||
|
|
||||||
|
```python
|
||||||
|
@api_controller("/foo")
|
||||||
|
class FooController(ControllerBase):
|
||||||
|
# Cette route sera accessible uniquement avec l'authentification
|
||||||
|
# par cookie de session
|
||||||
|
@route.get("", auth=[SessionAuth()])
|
||||||
|
def fetch_foo(self, club_id: int): ...
|
||||||
|
|
||||||
|
# Et celle-ci sera accessible peut importe la méthode d'authentification
|
||||||
|
@route.get("/bar", auth=[SessionAuth(), ApiKeyAuth()])
|
||||||
|
def fetch_bar(self, club_id: int): ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permissions
|
||||||
|
|
||||||
|
Si l'utilisateur est connecté, ça ne veut pas dire pour autant qu'il a accès à tout.
|
||||||
|
Une fois qu'il est authentifié, il faut donc vérifier ses permissions.
|
||||||
|
|
||||||
|
Pour cela, nous utilisons une surcouche
|
||||||
|
par-dessus `django-ninja`, le système de permissions de django
|
||||||
|
et notre propre système.
|
||||||
|
Cette dernière est documentée [ici](../perms.md).
|
||||||
|
|
||||||
|
### Limites des clefs d'API
|
||||||
|
|
||||||
|
#### Incompatibilité avec certaines permissions
|
||||||
|
|
||||||
|
Le système des clefs d'API est apparu très tard dans l'histoire du site
|
||||||
|
(en P25, 10 ans après le début du développement).
|
||||||
|
Il s'agit ni plus ni moins qu'un système d'authentification parallèle fait maison,
|
||||||
|
devant interagir avec un système de permissions ayant connu lui-même
|
||||||
|
une histoire assez chaotique.
|
||||||
|
|
||||||
|
Assez logiquement, on ne peut pas tout faire :
|
||||||
|
il n'est pas possible que toutes les routes acceptent
|
||||||
|
l'authentification par clef d'API.
|
||||||
|
|
||||||
|
Cette impossibilité provient majoritairement d'une incompatibilité
|
||||||
|
entre cette méthode d'authentification et le système de permissions
|
||||||
|
(qui n'a pas été prévu pour l'implémentation d'un client d'API).
|
||||||
|
Les principaux points de friction sont :
|
||||||
|
|
||||||
|
- `CanView` et `CanEdit`, qui se basent `User.can_view` et `User.can_edit`,
|
||||||
|
qui peuvent eux-mêmes se baser sur les méthodes `can_be_viewed_by`
|
||||||
|
et `can_be_edited_by` des différents modèles.
|
||||||
|
Or, ces dernières testent spécifiquement la relation entre l'objet et un `User`.
|
||||||
|
Ce comportement est possiblement changeable, mais au prix d'un certain travail
|
||||||
|
et au risque de transformer encore plus notre système de permissions
|
||||||
|
en usine à gaz.
|
||||||
|
- `IsSubscriber` et `OldSubscriber`, qui vérifient qu'un utilisateur est ou
|
||||||
|
a été cotisant.
|
||||||
|
Or, une clef d'API est liée à un client d'API, pas à un utilisateur.
|
||||||
|
Par définition, un client d'API ne peut pas être cotisant.
|
||||||
|
- `IsLoggedInCounter`, qui utilise encore un autre système
|
||||||
|
d'authentification maison et qui n'est pas fait pour être utilisé en dehors du site.
|
||||||
|
|
||||||
|
#### Incompatibilité avec les tokens csrf
|
||||||
|
|
||||||
|
Le [CSRF (*cross-site request forgery*)](https://fr.wikipedia.org/wiki/Cross-site_request_forgery)
|
||||||
|
est un des multiples facteurs d'attaque sur le web.
|
||||||
|
Heureusement, Django vient encore une fois à notre aide,
|
||||||
|
avec des mécanismes intégrés pour s'en protéger.
|
||||||
|
Ceux-ci incluent notamment un système de
|
||||||
|
[token CSRF](https://docs.djangoproject.com/fr/stable/ref/csrf/)
|
||||||
|
à fournir dans les requêtes POST/PUT/PATCH.
|
||||||
|
|
||||||
|
Ceux-ci sont bien adaptés au cycle requêtes/réponses
|
||||||
|
typique de l'expérience utilisateur sur un navigateur,
|
||||||
|
où les requêtes POST sont toujours effectuées après une requête
|
||||||
|
GET au cours de laquelle on a pu récupérer un token csrf.
|
||||||
|
Cependant, le flux des requêtes sur une API est bien différent ;
|
||||||
|
de ce fait, il est à attendre que les requêtes POST envoyées à l'API
|
||||||
|
par un client externe n'aient pas de token CSRF et se retrouvent
|
||||||
|
donc bloquées.
|
||||||
|
|
||||||
|
Pour ces raisons, l'accès aux requêtes POST/PUT/PATCH de l'API
|
||||||
|
par un client externe ne marche pas.
|
||||||
|
|
||||||
|
## Créer un client et une clef d'API
|
||||||
|
|
||||||
|
Le site n'a actuellement pas d'interface permettant à ses utilisateurs
|
||||||
|
de créer une application et des clefs d'API.
|
||||||
|
|
||||||
|
C'est volontaire : tant que le système ne sera pas suffisamment mature,
|
||||||
|
toute attribution de clef d'API doit passer par le pôle info.
|
||||||
|
|
||||||
|
Cette opération se fait au travers de l'interface admin.
|
||||||
|
|
||||||
|
Pour commencer, créez un client d'API, en renseignant son nom,
|
||||||
|
son propriétaire (l'utilisateur qui vous a demandé de le créer)
|
||||||
|
et les groupes qui lui sont attribués.
|
||||||
|
Ces groupes sont les mêmes que ceux qui sont attribués aux utilisateurs,
|
||||||
|
ce qui permet de réutiliser une partie du système d'authentification.
|
||||||
|
|
||||||
|
!!!warning
|
||||||
|
|
||||||
|
N'attribuez pas les groupes "anciens cotisants" et "cotisants"
|
||||||
|
aux clients d'API.
|
||||||
|
Un client d'API géré comme un cotisant, ça n'a aucun sens.
|
||||||
|
|
||||||
|
Evitez également de donner à des clients d'API des droits
|
||||||
|
autres que ceux de lecture sur le site.
|
||||||
|
|
||||||
|
Et surtout, n'attribuez jamais le group Root à un client d'API.
|
||||||
|
|
||||||
|
Une fois le client d'API créé, créez-lui une clef d'API.
|
||||||
|
Renseignez uniquement son nom et le client d'API auquel elle est lié.
|
||||||
|
La valeur de cette clef d'API est automatiquement générée
|
||||||
|
et affichée en haut de la page une fois la création complétée.
|
||||||
|
|
||||||
|
Notez bien la valeur de la clef d'API et transmettez-la à la personne
|
||||||
|
qui en a besoin.
|
||||||
|
Dites-lui bien de garder cette clef en lieu sûr !
|
||||||
|
Si la clef est perdue, il n'y a pas moyen de la récupérer,
|
||||||
|
vous devrez en recréer une.
|
||||||
|
|
||||||
|
|
@ -606,4 +606,4 @@ vous ne devriez pas être perdu, étant donné
|
|||||||
que le système de permissions de l'API utilise
|
que le système de permissions de l'API utilise
|
||||||
des noms assez similaires : `IsInGroup`, `IsRoot`, `IsSubscriber`...
|
des noms assez similaires : `IsInGroup`, `IsRoot`, `IsSubscriber`...
|
||||||
Vous pouvez trouver des exemples d'utilisation de ce système
|
Vous pouvez trouver des exemples d'utilisation de ce système
|
||||||
dans [cette partie](../reference/core/api_permissions.md).
|
dans [cette partie](../reference/api/perms.md).
|
||||||
|
@ -24,62 +24,66 @@ sith/
|
|||||||
├── .github/
|
├── .github/
|
||||||
│ ├── actions/ (1)
|
│ ├── actions/ (1)
|
||||||
│ └── workflows/ (2)
|
│ └── workflows/ (2)
|
||||||
├── club/ (3)
|
├── api/ (3)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── com/ (4)
|
├── antispam/ (4)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── core/ (5)
|
├── club/ (5)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── counter/ (6)
|
├── com/ (6)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── docs/ (7)
|
├── core/ (7)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── eboutic/ (8)
|
├── counter/ (8)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── election/ (9)
|
├── docs/ (9)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── forum/ (10)
|
├── eboutic/ (10)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── galaxy/ (11)
|
├── election/ (11)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── locale/ (12)
|
├── forum/ (12)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── matmat/ (13)
|
├── galaxy/ (13)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── pedagogy/ (14)
|
├── locale/ (14)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── rootplace/ (15)
|
├── matmat/ (15)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── sas/ (16)
|
├── pedagogy/ (16)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── sith/ (17)
|
├── rootplace/ (17)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── subscription/ (18)
|
├── sas/ (18)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── trombi/ (19)
|
├── sith/ (19)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── antispam/ (20)
|
├── subscription/ (20)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── staticfiles/ (21)
|
├── trombi/ (21)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── processes/ (22)
|
├── antispam/ (22)
|
||||||
|
│ └── ...
|
||||||
|
├── staticfiles/ (23)
|
||||||
|
│ └── ...
|
||||||
|
├── processes/ (24)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
│
|
│
|
||||||
├── .coveragerc (23)
|
├── .coveragerc (25)
|
||||||
├── .envrc (24)
|
├── .envrc (26)
|
||||||
├── .gitattributes
|
├── .gitattributes
|
||||||
├── .gitignore
|
├── .gitignore
|
||||||
├── .mailmap
|
├── .mailmap
|
||||||
├── .env (25)
|
├── .env (27)
|
||||||
├── .env.example (26)
|
├── .env.example (28)
|
||||||
├── manage.py (27)
|
├── manage.py (29)
|
||||||
├── mkdocs.yml (28)
|
├── mkdocs.yml (30)
|
||||||
├── uv.lock
|
├── uv.lock
|
||||||
├── pyproject.toml (29)
|
├── pyproject.toml (31)
|
||||||
├── .venv/ (30)
|
├── .venv/ (32)
|
||||||
├── .python-version (31)
|
├── .python-version (33)
|
||||||
├── Procfile.static (32)
|
├── Procfile.static (34)
|
||||||
├── Procfile.service (33)
|
├── Procfile.service (35)
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
</div>
|
</div>
|
||||||
@ -92,53 +96,55 @@ sith/
|
|||||||
des workflows Github.
|
des workflows Github.
|
||||||
Par exemple, le workflow `docs.yml` compile
|
Par exemple, le workflow `docs.yml` compile
|
||||||
et publie la documentation à chaque push sur la branche `master`.
|
et publie la documentation à chaque push sur la branche `master`.
|
||||||
3. Application de gestion des clubs et de leurs membres.
|
3. Application avec la configuration de l'API
|
||||||
4. Application contenant les fonctionnalités
|
4. Application contenant des utilitaires pour bloquer le spam et les bots
|
||||||
|
5. Application de gestion des clubs et de leurs membres.
|
||||||
|
6. Application contenant les fonctionnalités
|
||||||
destinées aux responsables communication de l'AE.
|
destinées aux responsables communication de l'AE.
|
||||||
5. Application contenant la modélisation centrale du site.
|
7. Application contenant la modélisation centrale du site.
|
||||||
On en reparle plus loin sur cette page.
|
On en reparle plus loin sur cette page.
|
||||||
6. Application de gestion des comptoirs, des permanences
|
8. Application de gestion des comptoirs, des permanences
|
||||||
sur ces comptoirs et des transactions qui y sont effectuées.
|
sur ces comptoirs et des transactions qui y sont effectuées.
|
||||||
7. Dossier contenant la documentation.
|
9. Dossier contenant la documentation.
|
||||||
8. Application de gestion de la boutique en ligne.
|
10. Application de gestion de la boutique en ligne.
|
||||||
9. Application de gestion des élections.
|
11. Application de gestion des élections.
|
||||||
10. Application de gestion du forum
|
12. Application de gestion du forum
|
||||||
11. Application de gestion de la galaxie ; la galaxie
|
13. Application de gestion de la galaxie ; la galaxie
|
||||||
est un graphe des niveaux de proximité entre les différents
|
est un graphe des niveaux de proximité entre les différents
|
||||||
étudiants.
|
étudiants.
|
||||||
12. Dossier contenant les fichiers de traduction.
|
14. Dossier contenant les fichiers de traduction.
|
||||||
13. Fonctionnalités de recherche d'utilisateurs.
|
15. Fonctionnalités de recherche d'utilisateurs.
|
||||||
14. Le guide des UEs du site, sur lequel les utilisateurs
|
16. Le guide des UEs du site, sur lequel les utilisateurs
|
||||||
peuvent également laisser leurs avis.
|
peuvent également laisser leurs avis.
|
||||||
15. Fonctionnalités utiles aux utilisateurs root.
|
17. Fonctionnalités utiles aux utilisateurs root.
|
||||||
16. Le SAS, où l'on trouve toutes les photos de l'AE.
|
18. Le SAS, où l'on trouve toutes les photos de l'AE.
|
||||||
17. Application principale du projet, contenant sa configuration.
|
19. Application principale du projet, contenant sa configuration.
|
||||||
18. Gestion des cotisations des utilisateurs du site.
|
20. Gestion des cotisations des utilisateurs du site.
|
||||||
19. Outil pour faciliter la fabrication des trombinoscopes de promo.
|
21. Outil pour faciliter la fabrication des trombinoscopes de promo.
|
||||||
20. Fonctionnalités pour gérer le spam.
|
22. Fonctionnalités pour gérer le spam.
|
||||||
21. Gestion des statics du site. Override le système de statics de Django.
|
23. Gestion des statics du site. Override le système de statics de Django.
|
||||||
Ajoute l'intégration du scss et du bundler js
|
Ajoute l'intégration du scss et du bundler js
|
||||||
de manière transparente pour l'utilisateur.
|
de manière transparente pour l'utilisateur.
|
||||||
22. Module de gestion des services externes.
|
24. Module de gestion des services externes.
|
||||||
Offre une API simple pour utiliser les fichiers `Procfile.*`.
|
Offre une API simple pour utiliser les fichiers `Procfile.*`.
|
||||||
23. Fichier de configuration de coverage.
|
25. Fichier de configuration de coverage.
|
||||||
24. Fichier de configuration de direnv.
|
26. Fichier de configuration de direnv.
|
||||||
25. Contient les variables d'environnement, qui sont susceptibles
|
27. Contient les variables d'environnement, qui sont susceptibles
|
||||||
de varier d'une machine à l'autre.
|
de varier d'une machine à l'autre.
|
||||||
26. Contient des valeurs par défaut pour le `.env`
|
28. Contient des valeurs par défaut pour le `.env`
|
||||||
pouvant convenir à un environnment de développement local
|
pouvant convenir à un environnment de développement local
|
||||||
27. Fichier généré automatiquement par Django. C'est lui
|
29. Fichier généré automatiquement par Django. C'est lui
|
||||||
qui permet d'appeler des commandes de gestion du projet
|
qui permet d'appeler des commandes de gestion du projet
|
||||||
avec la syntaxe `python ./manage.py <nom de la commande>`
|
avec la syntaxe `python ./manage.py <nom de la commande>`
|
||||||
28. Le fichier de configuration de la documentation,
|
30. Le fichier de configuration de la documentation,
|
||||||
avec ses plugins et sa table des matières.
|
avec ses plugins et sa table des matières.
|
||||||
29. Le fichier où sont déclarés les dépendances et la configuration
|
31. Le fichier où sont déclarés les dépendances et la configuration
|
||||||
de certaines d'entre elles.
|
de certaines d'entre elles.
|
||||||
30. Dossier d'environnement virtuel généré par uv
|
32. Dossier d'environnement virtuel généré par uv
|
||||||
31. Fichier qui contrôle quelle version de python utiliser pour le projet
|
33. 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
|
34. Fichier qui contrôle les commandes à lancer pour gérer la compilation
|
||||||
automatique des static et autres services nécessaires à la command runserver.
|
automatique des static et autres services nécessaires à la command runserver.
|
||||||
33. Fichier qui contrôle les services tiers nécessaires au fonctionnement
|
35. Fichier qui contrôle les services tiers nécessaires au fonctionnement
|
||||||
du Sith tel que redis.
|
du Sith tel que redis.
|
||||||
|
|
||||||
## L'application principale
|
## L'application principale
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
from ninja_extra import ControllerBase, api_controller, route
|
from ninja_extra import ControllerBase, api_controller, route
|
||||||
from ninja_extra.exceptions import NotFound
|
from ninja_extra.exceptions import NotFound
|
||||||
|
|
||||||
from core.auth.api_permissions import CanView
|
from api.permissions import CanView
|
||||||
from counter.models import BillingInfo
|
from counter.models import BillingInfo
|
||||||
from eboutic.models import Basket
|
from eboutic.models import Basket
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2025-06-04 09:58+0200\n"
|
"POT-Creation-Date: 2025-06-16 14:54+0200\n"
|
||||||
"PO-Revision-Date: 2016-07-18\n"
|
"PO-Revision-Date: 2016-07-18\n"
|
||||||
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
"Last-Translator: Maréchal <thomas.girod@utbm.fr\n"
|
||||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||||
@ -35,6 +35,68 @@ msgstr ""
|
|||||||
"True si gardé à jour par le biais d'un fournisseur externe de domains "
|
"True si gardé à jour par le biais d'un fournisseur externe de domains "
|
||||||
"toxics, False sinon"
|
"toxics, False sinon"
|
||||||
|
|
||||||
|
#: api/admin.py
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"The API key for %(name)s is: %(key)s. Please store it somewhere safe: you "
|
||||||
|
"will not be able to see it again."
|
||||||
|
msgstr ""
|
||||||
|
"La clef d'API pour %(name)s est : %(key)s. Gardez-là dans un emplacement "
|
||||||
|
"sûr : vous ne pourrez pas la revoir à nouveau."
|
||||||
|
|
||||||
|
#: api/admin.py
|
||||||
|
msgid "Revoke selected API keys"
|
||||||
|
msgstr "Révoquer les clefs d'API sélectionnées"
|
||||||
|
|
||||||
|
#: api/models.py club/models.py com/models.py counter/models.py forum/models.py
|
||||||
|
msgid "name"
|
||||||
|
msgstr "nom"
|
||||||
|
|
||||||
|
#: api/models.py core/models.py
|
||||||
|
msgid "owner"
|
||||||
|
msgstr "propriétaire"
|
||||||
|
|
||||||
|
#: api/models.py core/models.py
|
||||||
|
msgid "groups"
|
||||||
|
msgstr "groupes"
|
||||||
|
|
||||||
|
#: api/models.py
|
||||||
|
msgid "client permissions"
|
||||||
|
msgstr "permissions du client"
|
||||||
|
|
||||||
|
#: api/models.py
|
||||||
|
msgid "Specific permissions for this api client."
|
||||||
|
msgstr "Permissions spécifiques pour ce client d'API"
|
||||||
|
|
||||||
|
#: api/models.py
|
||||||
|
msgid "api client"
|
||||||
|
msgstr "client d'api"
|
||||||
|
|
||||||
|
#: api/models.py
|
||||||
|
msgid "api clients"
|
||||||
|
msgstr "clients d'api"
|
||||||
|
|
||||||
|
#: api/models.py
|
||||||
|
msgid "prefix"
|
||||||
|
msgstr "préfixe"
|
||||||
|
|
||||||
|
#: api/models.py
|
||||||
|
msgid "hashed key"
|
||||||
|
msgstr "hash de la clef"
|
||||||
|
|
||||||
|
#: api/models.py
|
||||||
|
msgctxt "api key"
|
||||||
|
msgid "revoked"
|
||||||
|
msgstr "révoquée"
|
||||||
|
|
||||||
|
#: api/models.py
|
||||||
|
msgid "api key"
|
||||||
|
msgstr "clef d'api"
|
||||||
|
|
||||||
|
#: api/models.py
|
||||||
|
msgid "api keys"
|
||||||
|
msgstr "clefs d'api"
|
||||||
|
|
||||||
#: club/forms.py
|
#: club/forms.py
|
||||||
msgid "Users to add"
|
msgid "Users to add"
|
||||||
msgstr "Utilisateurs à ajouter"
|
msgstr "Utilisateurs à ajouter"
|
||||||
@ -119,10 +181,6 @@ msgstr "Vous devez choisir un rôle"
|
|||||||
msgid "You do not have the permission to do that"
|
msgid "You do not have the permission to do that"
|
||||||
msgstr "Vous n'avez pas la permission de faire cela"
|
msgstr "Vous n'avez pas la permission de faire cela"
|
||||||
|
|
||||||
#: club/models.py com/models.py counter/models.py forum/models.py
|
|
||||||
msgid "name"
|
|
||||||
msgstr "nom"
|
|
||||||
|
|
||||||
#: club/models.py
|
#: club/models.py
|
||||||
msgid "slug name"
|
msgid "slug name"
|
||||||
msgstr "nom slug"
|
msgstr "nom slug"
|
||||||
@ -669,8 +727,7 @@ msgstr "message d'info"
|
|||||||
msgid "weekmail destinations"
|
msgid "weekmail destinations"
|
||||||
msgstr "destinataires du weekmail"
|
msgstr "destinataires du weekmail"
|
||||||
|
|
||||||
#: com/models.py core/templates/core/macros.jinja election/models.py
|
#: com/models.py election/models.py forum/models.py pedagogy/models.py
|
||||||
#: forum/models.py pedagogy/models.py
|
|
||||||
msgid "title"
|
msgid "title"
|
||||||
msgstr "titre"
|
msgstr "titre"
|
||||||
|
|
||||||
@ -1095,7 +1152,7 @@ msgstr "Nouvel article"
|
|||||||
msgid "Articles in no weekmail yet"
|
msgid "Articles in no weekmail yet"
|
||||||
msgstr "Articles dans aucun weekmail"
|
msgstr "Articles dans aucun weekmail"
|
||||||
|
|
||||||
#: com/templates/com/weekmail.jinja core/templates/core/macros.jinja
|
#: com/templates/com/weekmail.jinja
|
||||||
msgid "Content"
|
msgid "Content"
|
||||||
msgstr "Contenu"
|
msgstr "Contenu"
|
||||||
|
|
||||||
@ -1257,10 +1314,6 @@ msgstr "surnom"
|
|||||||
msgid "last update"
|
msgid "last update"
|
||||||
msgstr "dernière mise à jour"
|
msgstr "dernière mise à jour"
|
||||||
|
|
||||||
#: core/models.py
|
|
||||||
msgid "groups"
|
|
||||||
msgstr "groupes"
|
|
||||||
|
|
||||||
#: core/models.py
|
#: core/models.py
|
||||||
msgid ""
|
msgid ""
|
||||||
"The groups this user belongs to. A user will get all permissions granted to "
|
"The groups this user belongs to. A user will get all permissions granted to "
|
||||||
@ -1497,10 +1550,6 @@ msgstr "version allégée"
|
|||||||
msgid "thumbnail"
|
msgid "thumbnail"
|
||||||
msgstr "miniature"
|
msgstr "miniature"
|
||||||
|
|
||||||
#: core/models.py
|
|
||||||
msgid "owner"
|
|
||||||
msgstr "propriétaire"
|
|
||||||
|
|
||||||
#: core/models.py
|
#: core/models.py
|
||||||
msgid "edit group"
|
msgid "edit group"
|
||||||
msgstr "groupe d'édition"
|
msgstr "groupe d'édition"
|
||||||
@ -2725,10 +2774,6 @@ msgstr "Erreur d'envoi du fichier %(file_name)s : %(msg)s"
|
|||||||
msgid "Apply rights recursively"
|
msgid "Apply rights recursively"
|
||||||
msgstr "Appliquer les droits récursivement"
|
msgstr "Appliquer les droits récursivement"
|
||||||
|
|
||||||
#: core/views/forms.py
|
|
||||||
msgid "Choose file"
|
|
||||||
msgstr "Choisir un fichier"
|
|
||||||
|
|
||||||
#: core/views/forms.py
|
#: core/views/forms.py
|
||||||
msgid "Choose user"
|
msgid "Choose user"
|
||||||
msgstr "Choisir un utilisateur"
|
msgstr "Choisir un utilisateur"
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2025-04-13 00:18+0200\n"
|
"POT-Creation-Date: 2025-05-18 12:17+0200\n"
|
||||||
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
|
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
|
||||||
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
||||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||||
@ -37,14 +37,14 @@ msgstr "Supprimer"
|
|||||||
msgid "Copy calendar link"
|
msgid "Copy calendar link"
|
||||||
msgstr "Copier le lien du calendrier"
|
msgstr "Copier le lien du calendrier"
|
||||||
|
|
||||||
#: com/static/bundled/com/components/ics-calendar-index.ts
|
|
||||||
msgid "How to use calendar link"
|
|
||||||
msgstr "Comment utiliser le lien du calendrier"
|
|
||||||
|
|
||||||
#: com/static/bundled/com/components/ics-calendar-index.ts
|
#: com/static/bundled/com/components/ics-calendar-index.ts
|
||||||
msgid "Link copied"
|
msgid "Link copied"
|
||||||
msgstr "Lien copié"
|
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/moderation-alert-index.ts
|
||||||
#, javascript-format
|
#, javascript-format
|
||||||
msgid ""
|
msgid ""
|
||||||
|
10
mkdocs.yml
10
mkdocs.yml
@ -45,7 +45,6 @@ plugins:
|
|||||||
members: true
|
members: true
|
||||||
members_order: source
|
members_order: source
|
||||||
show_source: true
|
show_source: true
|
||||||
show_inherited_members: true
|
|
||||||
merge_init_into_class: true
|
merge_init_into_class: true
|
||||||
show_root_toc_entry: false
|
show_root_toc_entry: false
|
||||||
- include-markdown:
|
- include-markdown:
|
||||||
@ -67,6 +66,9 @@ nav:
|
|||||||
- Gestion des permissions: tutorial/perms.md
|
- Gestion des permissions: tutorial/perms.md
|
||||||
- Gestion des groupes: tutorial/groups.md
|
- Gestion des groupes: tutorial/groups.md
|
||||||
- Les fragments: tutorial/fragments.md
|
- Les fragments: tutorial/fragments.md
|
||||||
|
- API:
|
||||||
|
- Développement: tutorial/api/dev.md
|
||||||
|
- Connexion à l'API: tutorial/api/connect.md
|
||||||
- Etransactions: tutorial/etransaction.md
|
- Etransactions: tutorial/etransaction.md
|
||||||
- How-to:
|
- How-to:
|
||||||
- L'ORM de Django: howto/querysets.md
|
- L'ORM de Django: howto/querysets.md
|
||||||
@ -84,6 +86,11 @@ nav:
|
|||||||
- antispam:
|
- antispam:
|
||||||
- reference/antispam/models.md
|
- reference/antispam/models.md
|
||||||
- reference/antispam/forms.md
|
- reference/antispam/forms.md
|
||||||
|
- api:
|
||||||
|
- reference/api/auth.md
|
||||||
|
- reference/api/hashers.md
|
||||||
|
- reference/api/models.md
|
||||||
|
- reference/api/perms.md
|
||||||
- club:
|
- club:
|
||||||
- reference/club/models.md
|
- reference/club/models.md
|
||||||
- reference/club/views.md
|
- reference/club/views.md
|
||||||
@ -153,6 +160,7 @@ markdown_extensions:
|
|||||||
- pymdownx.details
|
- pymdownx.details
|
||||||
- pymdownx.inlinehilite
|
- pymdownx.inlinehilite
|
||||||
- pymdownx.keys
|
- pymdownx.keys
|
||||||
|
- pymdownx.blocks.caption
|
||||||
- pymdownx.superfences:
|
- pymdownx.superfences:
|
||||||
custom_fences:
|
custom_fences:
|
||||||
- name: mermaid
|
- name: mermaid
|
||||||
|
@ -3,11 +3,13 @@ from typing import Annotated
|
|||||||
|
|
||||||
from annotated_types import Ge
|
from annotated_types import Ge
|
||||||
from ninja import Query
|
from ninja import Query
|
||||||
|
from ninja.security import SessionAuth
|
||||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||||
from ninja_extra.exceptions import NotFound
|
from ninja_extra.exceptions import NotFound
|
||||||
from ninja_extra.pagination import PageNumberPaginationExtra, PaginatedResponseSchema
|
from ninja_extra.pagination import PageNumberPaginationExtra, PaginatedResponseSchema
|
||||||
|
|
||||||
from core.auth.api_permissions import HasPerm
|
from api.auth import ApiKeyAuth
|
||||||
|
from api.permissions import HasPerm
|
||||||
from pedagogy.models import UV
|
from pedagogy.models import UV
|
||||||
from pedagogy.schemas import SimpleUvSchema, UvFilterSchema, UvSchema
|
from pedagogy.schemas import SimpleUvSchema, UvFilterSchema, UvSchema
|
||||||
from pedagogy.utbm_api import UtbmApiClient
|
from pedagogy.utbm_api import UtbmApiClient
|
||||||
@ -17,6 +19,7 @@ from pedagogy.utbm_api import UtbmApiClient
|
|||||||
class UvController(ControllerBase):
|
class UvController(ControllerBase):
|
||||||
@route.get(
|
@route.get(
|
||||||
"/{code}",
|
"/{code}",
|
||||||
|
auth=[SessionAuth(), ApiKeyAuth()],
|
||||||
permissions=[
|
permissions=[
|
||||||
# this route will almost always be called in the context
|
# this route will almost always be called in the context
|
||||||
# of a UV creation/edition
|
# of a UV creation/edition
|
||||||
@ -42,6 +45,7 @@ class UvController(ControllerBase):
|
|||||||
"",
|
"",
|
||||||
response=PaginatedResponseSchema[SimpleUvSchema],
|
response=PaginatedResponseSchema[SimpleUvSchema],
|
||||||
url_name="fetch_uvs",
|
url_name="fetch_uvs",
|
||||||
|
auth=[SessionAuth(), ApiKeyAuth()],
|
||||||
permissions=[HasPerm("pedagogy.view_uv")],
|
permissions=[HasPerm("pedagogy.view_uv")],
|
||||||
)
|
)
|
||||||
@paginate(PageNumberPaginationExtra, page_size=100)
|
@paginate(PageNumberPaginationExtra, page_size=100)
|
||||||
|
@ -68,7 +68,7 @@ class TestUVSearch(TestCase):
|
|||||||
def test_permissions(self):
|
def test_permissions(self):
|
||||||
# Test with anonymous user
|
# Test with anonymous user
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
assert response.status_code == 403
|
assert response.status_code == 401
|
||||||
|
|
||||||
# Test with not subscribed user
|
# Test with not subscribed user
|
||||||
self.client.force_login(baker.make(User))
|
self.client.force_login(baker.make(User))
|
||||||
|
@ -5,6 +5,7 @@ from django.core.exceptions import ValidationError
|
|||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from ninja import Body, File, Query
|
from ninja import Body, File, Query
|
||||||
|
from ninja.security import SessionAuth
|
||||||
from ninja_extra import ControllerBase, api_controller, paginate, route
|
from ninja_extra import ControllerBase, api_controller, paginate, route
|
||||||
from ninja_extra.exceptions import NotFound, PermissionDenied
|
from ninja_extra.exceptions import NotFound, PermissionDenied
|
||||||
from ninja_extra.pagination import PageNumberPaginationExtra
|
from ninja_extra.pagination import PageNumberPaginationExtra
|
||||||
@ -12,7 +13,8 @@ from ninja_extra.permissions import IsAuthenticated
|
|||||||
from ninja_extra.schemas import PaginatedResponseSchema
|
from ninja_extra.schemas import PaginatedResponseSchema
|
||||||
from pydantic import NonNegativeInt
|
from pydantic import NonNegativeInt
|
||||||
|
|
||||||
from core.auth.api_permissions import (
|
from api.auth import ApiKeyAuth
|
||||||
|
from api.permissions import (
|
||||||
CanAccessLookup,
|
CanAccessLookup,
|
||||||
CanEdit,
|
CanEdit,
|
||||||
CanView,
|
CanView,
|
||||||
@ -53,6 +55,7 @@ class AlbumController(ControllerBase):
|
|||||||
@route.get(
|
@route.get(
|
||||||
"/autocomplete-search",
|
"/autocomplete-search",
|
||||||
response=PaginatedResponseSchema[AlbumAutocompleteSchema],
|
response=PaginatedResponseSchema[AlbumAutocompleteSchema],
|
||||||
|
auth=[SessionAuth(), ApiKeyAuth()],
|
||||||
permissions=[CanAccessLookup],
|
permissions=[CanAccessLookup],
|
||||||
)
|
)
|
||||||
@paginate(PageNumberPaginationExtra, page_size=50)
|
@paginate(PageNumberPaginationExtra, page_size=50)
|
||||||
|
@ -124,6 +124,7 @@ INSTALLED_APPS = (
|
|||||||
"pedagogy",
|
"pedagogy",
|
||||||
"galaxy",
|
"galaxy",
|
||||||
"antispam",
|
"antispam",
|
||||||
|
"api",
|
||||||
)
|
)
|
||||||
|
|
||||||
MIDDLEWARE = (
|
MIDDLEWARE = (
|
||||||
|
@ -12,14 +12,14 @@
|
|||||||
# OR WITHIN THE LOCAL FILE "LICENSE"
|
# OR WITHIN THE LOCAL FILE "LICENSE"
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from django.views.i18n import JavaScriptCatalog
|
from django.views.i18n import JavaScriptCatalog
|
||||||
from ninja_extra import NinjaExtraAPI
|
|
||||||
|
from api.urls import api
|
||||||
|
|
||||||
js_info_dict = {"packages": ("sith",)}
|
js_info_dict = {"packages": ("sith",)}
|
||||||
|
|
||||||
@ -27,9 +27,6 @@ handler403 = "core.views.forbidden"
|
|||||||
handler404 = "core.views.not_found"
|
handler404 = "core.views.not_found"
|
||||||
handler500 = "core.views.internal_servor_error"
|
handler500 = "core.views.internal_servor_error"
|
||||||
|
|
||||||
api = NinjaExtraAPI(version="0.2.0", urls_namespace="api", csrf=True)
|
|
||||||
api.auto_discover_controllers()
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", include(("core.urls", "core"), namespace="core")),
|
path("", include(("core.urls", "core"), namespace="core")),
|
||||||
path("api/", api.urls),
|
path("api/", api.urls),
|
||||||
|
@ -11,7 +11,7 @@ import rjsmin
|
|||||||
import sass
|
import sass
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from sith.urls import api
|
from api.urls import api
|
||||||
from staticfiles.apps import BUNDLED_FOLDER_NAME, BUNDLED_ROOT, GENERATED_ROOT
|
from staticfiles.apps import BUNDLED_FOLDER_NAME, BUNDLED_ROOT, GENERATED_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user