Merge pull request #1097 from ae-utbm/api-key

Basic api key management
This commit is contained in:
thomas girod 2025-06-16 18:21:19 +02:00 committed by GitHub
commit 28b60c7bae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 1101 additions and 136 deletions

0
api/__init__.py Normal file
View File

55
api/admin.py Normal file
View 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
View 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
View 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
View 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)

View 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")],
},
),
]

View File

94
api/models.py Normal file
View 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}***)"

View File

@ -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,6 +67,8 @@ class HasPerm(BasePermission):
Example: Example:
```python ```python
@api_controller("/foo")
class FooController(ControllerBase):
# this route will require both permissions # this route will require both permissions
@route.put("/foo", permissions=[HasPerm(["foo.change_foo", "foo.add_foo"])] @route.put("/foo", permissions=[HasPerm(["foo.change_foo", "foo.add_foo"])]
def foo(self): ... def foo(self): ...
@ -78,10 +80,13 @@ class HasPerm(BasePermission):
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
View File

29
api/tests/test_api_key.py Normal file
View 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
View 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()

View File

@ -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
)

View File

@ -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]

View 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

View File

@ -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

View File

@ -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

View 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(

View File

@ -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)

View File

@ -805,6 +805,8 @@ class Command(BaseCommand):
"add_peoplepicturerelation", "add_peoplepicturerelation",
"add_page", "add_page",
"add_quickuploadimage", "add_quickuploadimage",
"view_club",
"access_lookup",
] ]
) )
) )

View 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": [],
},
),
]

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -0,0 +1,6 @@
::: api.auth
handler: python
options:
heading_level: 3
members:
- ApiKeyAuth

View File

@ -0,0 +1,8 @@
::: api.hashers
handler: python
options:
heading_level: 3
members:
- Sha256ApiKeyHasher
- get_hasher
- generate_key

View File

@ -0,0 +1,7 @@
::: api.auth
handler: python
options:
heading_level: 3
members:
- ApiKey
- ApiClient

View File

@ -0,0 +1,4 @@
::: api.permissions
handler: python
options:
heading_level: 3

View File

@ -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

View 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 :
![Swagger auth (1)](../../img/api_key_authorize_1.png)
/// caption
Bouton d'autorisation sur Swagger
///
Puis rentrez votre clef d'API dans le champ prévu à cet effet,
et cliquez sur authorize :
![Swagger auth (2)](../../img/api_key_authorize_2.png)
/// 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
View 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.

View File

@ -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).

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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 ""

View File

@ -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

View File

@ -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)

View File

@ -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))

View File

@ -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)

View File

@ -124,6 +124,7 @@ INSTALLED_APPS = (
"pedagogy", "pedagogy",
"galaxy", "galaxy",
"antispam", "antispam",
"api",
) )
MIDDLEWARE = ( MIDDLEWARE = (

View File

@ -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),

View File

@ -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