diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/admin.py b/api/admin.py new file mode 100644 index 00000000..611bdba0 --- /dev/null +++ b/api/admin.py @@ -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) diff --git a/api/apps.py b/api/apps.py new file mode 100644 index 00000000..878e7d54 --- /dev/null +++ b/api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "api" diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 00000000..787234a6 --- /dev/null +++ b/api/auth.py @@ -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 diff --git a/api/hashers.py b/api/hashers.py new file mode 100644 index 00000000..95c16673 --- /dev/null +++ b/api/hashers.py @@ -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) diff --git a/api/migrations/0001_initial.py b/api/migrations/0001_initial.py new file mode 100644 index 00000000..4ebfe9d4 --- /dev/null +++ b/api/migrations/0001_initial.py @@ -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")], + }, + ), + ] diff --git a/api/migrations/__init__.py b/api/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/models.py b/api/models.py new file mode 100644 index 00000000..36e20287 --- /dev/null +++ b/api/models.py @@ -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}***)" diff --git a/core/auth/api_permissions.py b/api/permissions.py similarity index 78% rename from core/auth/api_permissions.py rename to api/permissions.py index 6a28f13c..f371910b 100644 --- a/core/auth/api_permissions.py +++ b/api/permissions.py @@ -39,7 +39,7 @@ Example: import operator from functools import reduce -from typing import Any +from typing import Any, Callable from django.contrib.auth.models import Permission from django.http import HttpRequest @@ -67,21 +67,26 @@ class HasPerm(BasePermission): Example: ```python - # this route will require both permissions - @route.put("/foo", permissions=[HasPerm(["foo.change_foo", "foo.add_foo"])] - def foo(self): ... + @api_controller("/foo") + class FooController(ControllerBase): + # this route will require both permissions + @route.put("/foo", permissions=[HasPerm(["foo.change_foo", "foo.add_foo"])] + def foo(self): ... - # This route will require at least one of the perm, - # but it's not mandatory to have all of them - @route.put( - "/bar", - permissions=[HasPerm(["foo.change_bar", "foo.add_bar"], op=operator.or_)], - ) - def bar(self): ... + # This route will require at least one of the perm, + # but it's not mandatory to have all of them + @route.put( + "/bar", + permissions=[HasPerm(["foo.change_bar", "foo.add_bar"], op=operator.or_)], + ) + def bar(self): ... + ``` """ def __init__( - self, perms: str | Permission | list[str | Permission], op=operator.and_ + self, + perms: str | Permission | list[str | Permission], + op: Callable[[bool, bool], bool] = operator.and_, ): """ Args: @@ -96,7 +101,16 @@ class HasPerm(BasePermission): self._perms = perms def has_permission(self, request: HttpRequest, controller: ControllerBase) -> bool: - return reduce(self._operator, (request.user.has_perm(p) for p in self._perms)) + # if the request has the `auth` property, + # it means that the user has been explicitly authenticated + # using a django-ninja authentication backend + # (whether it is SessionAuth or ApiKeyAuth). + # If not, this authentication has not been done, but the user may + # still be implicitly authenticated through AuthenticationMiddleware + user = request.auth if hasattr(request, "auth") else request.user + # `user` may either be a `core.User` or an `api.ApiClient` ; + # they are not the same model, but they both implement the `has_perm` method + return reduce(self._operator, (user.has_perm(p) for p in self._perms)) class IsRoot(BasePermission): @@ -180,4 +194,4 @@ class IsLoggedInCounter(BasePermission): return Counter.objects.filter(token=token).exists() -CanAccessLookup = IsOldSubscriber | IsRoot | IsLoggedInCounter +CanAccessLookup = IsLoggedInCounter | HasPerm("core.access_lookup") diff --git a/api/tests/__init__.py b/api/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/tests/test_api_key.py b/api/tests/test_api_key.py new file mode 100644 index 00000000..4fad18d3 --- /dev/null +++ b/api/tests/test_api_key.py @@ -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 diff --git a/api/urls.py b/api/urls.py new file mode 100644 index 00000000..ed58c790 --- /dev/null +++ b/api/urls.py @@ -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() diff --git a/club/api.py b/club/api.py index 2ad0f5c8..ef46e4c7 100644 --- a/club/api.py +++ b/club/api.py @@ -1,22 +1,38 @@ from typing import Annotated from annotated_types import MinLen +from ninja.security import SessionAuth from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.schemas import PaginatedResponseSchema +from api.auth import ApiKeyAuth +from api.permissions import CanAccessLookup, HasPerm from club.models import Club -from club.schemas import ClubSchema -from core.auth.api_permissions import CanAccessLookup +from club.schemas import ClubSchema, SimpleClubSchema @api_controller("/club") class ClubController(ControllerBase): @route.get( "/search", - response=PaginatedResponseSchema[ClubSchema], + response=PaginatedResponseSchema[SimpleClubSchema], + auth=[SessionAuth(), ApiKeyAuth()], permissions=[CanAccessLookup], + url_name="search_club", ) @paginate(PageNumberPaginationExtra, page_size=50) def search_club(self, search: Annotated[str, MinLen(1)]): return Club.objects.filter(name__icontains=search).values() + + @route.get( + "/{int:club_id}", + response=ClubSchema, + auth=[SessionAuth(), ApiKeyAuth()], + permissions=[HasPerm("club.view_club")], + url_name="fetch_club", + ) + def fetch_club(self, club_id: int): + return self.get_object_or_exception( + Club.objects.prefetch_related("members", "members__user"), id=club_id + ) diff --git a/club/schemas.py b/club/schemas.py index 7969f119..b0601af8 100644 --- a/club/schemas.py +++ b/club/schemas.py @@ -1,9 +1,10 @@ from ninja import ModelSchema -from club.models import Club +from club.models import Club, Membership +from core.schemas import SimpleUserSchema -class ClubSchema(ModelSchema): +class SimpleClubSchema(ModelSchema): class Meta: model = Club fields = ["id", "name"] @@ -21,3 +22,19 @@ class ClubProfileSchema(ModelSchema): @staticmethod def resolve_url(obj: Club) -> str: return obj.get_absolute_url() + + +class ClubMemberSchema(ModelSchema): + class Meta: + model = Membership + fields = ["start_date", "end_date", "role", "description"] + + user: SimpleUserSchema + + +class ClubSchema(ModelSchema): + class Meta: + model = Club + fields = ["id", "name", "logo", "is_active", "short_description", "address"] + + members: list[ClubMemberSchema] diff --git a/club/tests/test_club_controller.py b/club/tests/test_club_controller.py new file mode 100644 index 00000000..ade8eb4d --- /dev/null +++ b/club/tests/test_club_controller.py @@ -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 diff --git a/club/widgets/ajax_select.py b/club/widgets/ajax_select.py index 36ad3e9a..ddcc820f 100644 --- a/club/widgets/ajax_select.py +++ b/club/widgets/ajax_select.py @@ -1,7 +1,7 @@ from pydantic import TypeAdapter from club.models import Club -from club.schemas import ClubSchema +from club.schemas import SimpleClubSchema from core.views.widgets.ajax_select import ( AutoCompleteSelect, AutoCompleteSelectMultiple, @@ -13,7 +13,7 @@ _js = ["bundled/club/components/ajax-select-index.ts"] class AutoCompleteSelectClub(AutoCompleteSelect): component_name = "club-ajax-select" model = Club - adapter = TypeAdapter(list[ClubSchema]) + adapter = TypeAdapter(list[SimpleClubSchema]) js = _js @@ -21,6 +21,6 @@ class AutoCompleteSelectClub(AutoCompleteSelect): class AutoCompleteSelectMultipleClub(AutoCompleteSelectMultiple): component_name = "club-ajax-select" model = Club - adapter = TypeAdapter(list[ClubSchema]) + adapter = TypeAdapter(list[SimpleClubSchema]) js = _js diff --git a/com/api.py b/com/api.py index 79ff9c34..b01eef0e 100644 --- a/com/api.py +++ b/com/api.py @@ -8,10 +8,10 @@ from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.permissions import IsAuthenticated from ninja_extra.schemas import PaginatedResponseSchema +from api.permissions import HasPerm from com.ics_calendar import IcsCalendar from com.models import News, NewsDate from com.schemas import NewsDateFilterSchema, NewsDateSchema -from core.auth.api_permissions import HasPerm from core.views.files import send_raw_file diff --git a/com/models.py b/com/models.py index b6253619..04f6bf56 100644 --- a/com/models.py +++ b/com/models.py @@ -194,7 +194,7 @@ class NewsDateQuerySet(models.QuerySet): class NewsDate(models.Model): """A date associated with news. - A [News][] can have multiple dates, for example if it is a recurring event. + A [News][com.models.News] can have multiple dates, for example if it is a recurring event. """ news = models.ForeignKey( diff --git a/core/api.py b/core/api.py index 830e06e9..06b32989 100644 --- a/core/api.py +++ b/core/api.py @@ -5,13 +5,15 @@ from django.conf import settings from django.db.models import F from django.http import HttpResponse from ninja import File, Query +from ninja.security import SessionAuth from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.exceptions import PermissionDenied from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.schemas import PaginatedResponseSchema +from api.auth import ApiKeyAuth +from api.permissions import CanAccessLookup, CanView, HasPerm from club.models import Mailing -from core.auth.api_permissions import CanAccessLookup, CanView, HasPerm from core.models import Group, QuickUploadImage, SithFile, User from core.schemas import ( FamilyGodfatherSchema, @@ -90,6 +92,7 @@ class SithFileController(ControllerBase): @route.get( "/search", response=PaginatedResponseSchema[SithFileSchema], + auth=[SessionAuth(), ApiKeyAuth()], permissions=[CanAccessLookup], ) @paginate(PageNumberPaginationExtra, page_size=50) @@ -102,6 +105,7 @@ class GroupController(ControllerBase): @route.get( "/search", response=PaginatedResponseSchema[GroupSchema], + auth=[SessionAuth(), ApiKeyAuth()], permissions=[CanAccessLookup], ) @paginate(PageNumberPaginationExtra, page_size=50) diff --git a/core/management/commands/populate.py b/core/management/commands/populate.py index ea1f0342..8f101d9f 100644 --- a/core/management/commands/populate.py +++ b/core/management/commands/populate.py @@ -805,6 +805,8 @@ class Command(BaseCommand): "add_peoplepicturerelation", "add_page", "add_quickuploadimage", + "view_club", + "access_lookup", ] ) ) diff --git a/core/migrations/0046_permissionrights.py b/core/migrations/0046_permissionrights.py new file mode 100644 index 00000000..33df716b --- /dev/null +++ b/core/migrations/0046_permissionrights.py @@ -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": [], + }, + ), + ] diff --git a/core/models.py b/core/models.py index 0a7a5e37..23b863f6 100644 --- a/core/models.py +++ b/core/models.py @@ -754,6 +754,23 @@ class UserBan(models.Model): return f"Ban of user {self.user.id}" +class GlobalPermissionRights(models.Model): + """Little hack to have permissions not linked to a specific db table.""" + + class Meta: + # No database table creation or deletion + # operations will be performed for this model. + managed = False + + # disable "add", "change", "delete" and "view" default permissions + default_permissions = [] + + permissions = [("access_lookup", "Can access any lookup in the sith")] + + def __str__(self): + return self.__class__.__name__ + + class Preferences(models.Model): user = models.OneToOneField( User, related_name="_preferences", on_delete=models.CASCADE diff --git a/counter/api.py b/counter/api.py index 44b58488..c7bcb540 100644 --- a/counter/api.py +++ b/counter/api.py @@ -16,11 +16,13 @@ from django.conf import settings from django.db.models import F from django.shortcuts import get_object_or_404 from ninja import Query +from ninja.security import SessionAuth from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.pagination import PageNumberPaginationExtra from ninja_extra.schemas import PaginatedResponseSchema -from core.auth.api_permissions import CanAccessLookup, CanView, IsInGroup, IsRoot +from api.auth import ApiKeyAuth +from api.permissions import CanAccessLookup, CanView, IsInGroup, IsRoot from counter.models import Counter, Product, ProductType from counter.schemas import ( CounterFilterSchema, @@ -62,6 +64,7 @@ class CounterController(ControllerBase): @route.get( "/search", response=PaginatedResponseSchema[SimplifiedCounterSchema], + auth=[SessionAuth(), ApiKeyAuth()], permissions=[CanAccessLookup], ) @paginate(PageNumberPaginationExtra, page_size=50) @@ -74,6 +77,7 @@ class ProductController(ControllerBase): @route.get( "/search", response=PaginatedResponseSchema[SimpleProductSchema], + auth=[SessionAuth(), ApiKeyAuth()], permissions=[CanAccessLookup], ) @paginate(PageNumberPaginationExtra, page_size=50) diff --git a/counter/models.py b/counter/models.py index 3515e081..0f46176b 100644 --- a/counter/models.py +++ b/counter/models.py @@ -61,7 +61,7 @@ class CustomerQuerySet(models.QuerySet): Returns: The number of updated rows. - Warnings: + Warning: The execution time of this query grows really quickly. When updating 500 customers, it may take around a second. If you try to update all customers at once, the execution time diff --git a/counter/schemas.py b/counter/schemas.py index 978422a5..6ee9f8b1 100644 --- a/counter/schemas.py +++ b/counter/schemas.py @@ -5,7 +5,7 @@ from django.urls import reverse from ninja import Field, FilterSchema, ModelSchema, Schema from pydantic import model_validator -from club.schemas import ClubSchema +from club.schemas import SimpleClubSchema from core.schemas import GroupSchema, SimpleUserSchema from counter.models import Counter, Product, ProductType @@ -82,7 +82,7 @@ class ProductSchema(ModelSchema): ] buying_groups: list[GroupSchema] - club: ClubSchema + club: SimpleClubSchema product_type: SimpleProductTypeSchema | None url: str diff --git a/docs/img/api_key_authorize_1.png b/docs/img/api_key_authorize_1.png new file mode 100644 index 00000000..b01d2e66 Binary files /dev/null and b/docs/img/api_key_authorize_1.png differ diff --git a/docs/img/api_key_authorize_2.png b/docs/img/api_key_authorize_2.png new file mode 100644 index 00000000..9d562328 Binary files /dev/null and b/docs/img/api_key_authorize_2.png differ diff --git a/docs/reference/api/auth.md b/docs/reference/api/auth.md new file mode 100644 index 00000000..764dd318 --- /dev/null +++ b/docs/reference/api/auth.md @@ -0,0 +1,6 @@ +::: api.auth + handler: python + options: + heading_level: 3 + members: + - ApiKeyAuth \ No newline at end of file diff --git a/docs/reference/api/hashers.md b/docs/reference/api/hashers.md new file mode 100644 index 00000000..91c420b2 --- /dev/null +++ b/docs/reference/api/hashers.md @@ -0,0 +1,8 @@ +::: api.hashers + handler: python + options: + heading_level: 3 + members: + - Sha256ApiKeyHasher + - get_hasher + - generate_key \ No newline at end of file diff --git a/docs/reference/api/models.md b/docs/reference/api/models.md new file mode 100644 index 00000000..dbf2676b --- /dev/null +++ b/docs/reference/api/models.md @@ -0,0 +1,7 @@ +::: api.auth + handler: python + options: + heading_level: 3 + members: + - ApiKey + - ApiClient \ No newline at end of file diff --git a/docs/reference/api/perms.md b/docs/reference/api/perms.md new file mode 100644 index 00000000..c481bd28 --- /dev/null +++ b/docs/reference/api/perms.md @@ -0,0 +1,4 @@ +::: api.permissions + handler: python + options: + heading_level: 3 \ No newline at end of file diff --git a/docs/reference/core/auth.md b/docs/reference/core/auth.md index ade23f49..4226bb2c 100644 --- a/docs/reference/core/auth.md +++ b/docs/reference/core/auth.md @@ -20,13 +20,6 @@ - CanCreateMixin - CanEditMixin - CanViewMixin + - CanEditPropMixin - FormerSubscriberMixin - PermissionOrAuthorRequiredMixin - - -## API Permissions - -::: core.auth.api_permissions - handler: python - options: - heading_level: 3 \ No newline at end of file diff --git a/docs/tutorial/api/connect.md b/docs/tutorial/api/connect.md new file mode 100644 index 00000000..8ce52bdd --- /dev/null +++ b/docs/tutorial/api/connect.md @@ -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: " +``` + +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="" +``` + +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> { + 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(()) + } + ``` diff --git a/docs/tutorial/api/dev.md b/docs/tutorial/api/dev.md new file mode 100644 index 00000000..165d605f --- /dev/null +++ b/docs/tutorial/api/dev.md @@ -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. + + diff --git a/docs/tutorial/perms.md b/docs/tutorial/perms.md index c23ca25f..4594c81e 100644 --- a/docs/tutorial/perms.md +++ b/docs/tutorial/perms.md @@ -606,4 +606,4 @@ vous ne devriez pas être perdu, étant donné que le système de permissions de l'API utilise des noms assez similaires : `IsInGroup`, `IsRoot`, `IsSubscriber`... Vous pouvez trouver des exemples d'utilisation de ce système -dans [cette partie](../reference/core/api_permissions.md). +dans [cette partie](../reference/api/perms.md). diff --git a/docs/tutorial/structure.md b/docs/tutorial/structure.md index 1421cddc..a796ee91 100644 --- a/docs/tutorial/structure.md +++ b/docs/tutorial/structure.md @@ -24,62 +24,66 @@ sith/ ├── .github/ │ ├── actions/ (1) │ └── workflows/ (2) -├── club/ (3) +├── api/ (3) │ └── ... -├── com/ (4) +├── antispam/ (4) │ └── ... -├── core/ (5) +├── club/ (5) │ └── ... -├── counter/ (6) +├── com/ (6) │ └── ... -├── docs/ (7) +├── core/ (7) │ └── ... -├── eboutic/ (8) +├── counter/ (8) │ └── ... -├── election/ (9) +├── docs/ (9) │ └── ... -├── forum/ (10) +├── eboutic/ (10) │ └── ... -├── galaxy/ (11) +├── election/ (11) │ └── ... -├── locale/ (12) +├── forum/ (12) │ └── ... -├── matmat/ (13) +├── galaxy/ (13) │ └── ... -├── pedagogy/ (14) +├── locale/ (14) │ └── ... -├── rootplace/ (15) +├── matmat/ (15) │ └── ... -├── sas/ (16) +├── pedagogy/ (16) │ └── ... -├── sith/ (17) +├── rootplace/ (17) │ └── ... -├── subscription/ (18) +├── sas/ (18) │ └── ... -├── trombi/ (19) +├── sith/ (19) │ └── ... -├── antispam/ (20) +├── subscription/ (20) │ └── ... -├── staticfiles/ (21) +├── trombi/ (21) │ └── ... -├── processes/ (22) +├── antispam/ (22) +│ └── ... +├── staticfiles/ (23) +│ └── ... +├── processes/ (24) │ └── ... │ -├── .coveragerc (23) -├── .envrc (24) +├── .coveragerc (25) +├── .envrc (26) ├── .gitattributes ├── .gitignore ├── .mailmap -├── .env (25) -├── .env.example (26) -├── manage.py (27) -├── mkdocs.yml (28) +├── .env (27) +├── .env.example (28) +├── manage.py (29) +├── mkdocs.yml (30) ├── uv.lock -├── pyproject.toml (29) -├── .venv/ (30) -├── .python-version (31) -├── Procfile.static (32) -├── Procfile.service (33) +├── pyproject.toml (31) +├── .venv/ (32) +├── .python-version (33) +├── Procfile.static (34) +├── Procfile.service (35) └── README.md ``` @@ -92,53 +96,55 @@ sith/ des workflows Github. Par exemple, le workflow `docs.yml` compile et publie la documentation à chaque push sur la branche `master`. -3. Application de gestion des clubs et de leurs membres. -4. Application contenant les fonctionnalités +3. Application avec la configuration de l'API +4. Application contenant des utilitaires pour bloquer le spam et les bots +5. Application de gestion des clubs et de leurs membres. +6. Application contenant les fonctionnalités destinées aux responsables communication de l'AE. -5. Application contenant la modélisation centrale du site. +7. Application contenant la modélisation centrale du site. On en reparle plus loin sur cette page. -6. Application de gestion des comptoirs, des permanences +8. Application de gestion des comptoirs, des permanences sur ces comptoirs et des transactions qui y sont effectuées. -7. Dossier contenant la documentation. -8. Application de gestion de la boutique en ligne. -9. Application de gestion des élections. -10. Application de gestion du forum -11. Application de gestion de la galaxie ; la galaxie +9. Dossier contenant la documentation. +10. Application de gestion de la boutique en ligne. +11. Application de gestion des élections. +12. Application de gestion du forum +13. Application de gestion de la galaxie ; la galaxie est un graphe des niveaux de proximité entre les différents étudiants. -12. Dossier contenant les fichiers de traduction. -13. Fonctionnalités de recherche d'utilisateurs. -14. Le guide des UEs du site, sur lequel les utilisateurs +14. Dossier contenant les fichiers de traduction. +15. Fonctionnalités de recherche d'utilisateurs. +16. Le guide des UEs du site, sur lequel les utilisateurs peuvent également laisser leurs avis. -15. Fonctionnalités utiles aux utilisateurs root. -16. Le SAS, où l'on trouve toutes les photos de l'AE. -17. Application principale du projet, contenant sa configuration. -18. Gestion des cotisations des utilisateurs du site. -19. Outil pour faciliter la fabrication des trombinoscopes de promo. -20. Fonctionnalités pour gérer le spam. -21. Gestion des statics du site. Override le système de statics de Django. +17. Fonctionnalités utiles aux utilisateurs root. +18. Le SAS, où l'on trouve toutes les photos de l'AE. +19. Application principale du projet, contenant sa configuration. +20. Gestion des cotisations des utilisateurs du site. +21. Outil pour faciliter la fabrication des trombinoscopes de promo. +22. Fonctionnalités pour gérer le spam. +23. Gestion des statics du site. Override le système de statics de Django. Ajoute l'intégration du scss et du bundler js de manière transparente pour l'utilisateur. -22. Module de gestion des services externes. +24. Module de gestion des services externes. Offre une API simple pour utiliser les fichiers `Procfile.*`. -23. Fichier de configuration de coverage. -24. Fichier de configuration de direnv. -25. Contient les variables d'environnement, qui sont susceptibles +25. Fichier de configuration de coverage. +26. Fichier de configuration de direnv. +27. Contient les variables d'environnement, qui sont susceptibles de varier d'une machine à l'autre. -26. Contient des valeurs par défaut pour le `.env` +28. Contient des valeurs par défaut pour le `.env` pouvant convenir à un environnment de développement local -27. Fichier généré automatiquement par Django. C'est lui +29. Fichier généré automatiquement par Django. C'est lui qui permet d'appeler des commandes de gestion du projet avec la syntaxe `python ./manage.py ` -28. Le fichier de configuration de la documentation, +30. Le fichier de configuration de la documentation, avec ses plugins et sa table des matières. -29. Le fichier où sont déclarés les dépendances et la configuration +31. Le fichier où sont déclarés les dépendances et la configuration de certaines d'entre elles. -30. Dossier d'environnement virtuel généré par uv -31. Fichier qui contrôle quelle version de python utiliser pour le projet -32. Fichier qui contrôle les commandes à lancer pour gérer la compilation +32. Dossier d'environnement virtuel généré par uv +33. Fichier qui contrôle quelle version de python utiliser pour le projet +34. Fichier qui contrôle les commandes à lancer pour gérer la compilation automatique des static et autres services nécessaires à la command runserver. -33. Fichier qui contrôle les services tiers nécessaires au fonctionnement +35. Fichier qui contrôle les services tiers nécessaires au fonctionnement du Sith tel que redis. ## L'application principale diff --git a/eboutic/api.py b/eboutic/api.py index 2041fb87..c44a8cc9 100644 --- a/eboutic/api.py +++ b/eboutic/api.py @@ -1,7 +1,7 @@ from ninja_extra import ControllerBase, api_controller, route from ninja_extra.exceptions import NotFound -from core.auth.api_permissions import CanView +from api.permissions import CanView from counter.models import BillingInfo from eboutic.models import Basket diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index c7c4d34f..f8c89349 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-06-04 09:58+0200\n" +"POT-Creation-Date: 2025-06-16 14:54+0200\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -35,6 +35,68 @@ msgstr "" "True si gardé à jour par le biais d'un fournisseur externe de domains " "toxics, False sinon" +#: api/admin.py +#, python-format +msgid "" +"The API key for %(name)s is: %(key)s. Please store it somewhere safe: you " +"will not be able to see it again." +msgstr "" +"La clef d'API pour %(name)s est : %(key)s. Gardez-là dans un emplacement " +"sûr : vous ne pourrez pas la revoir à nouveau." + +#: api/admin.py +msgid "Revoke selected API keys" +msgstr "Révoquer les clefs d'API sélectionnées" + +#: api/models.py club/models.py com/models.py counter/models.py forum/models.py +msgid "name" +msgstr "nom" + +#: api/models.py core/models.py +msgid "owner" +msgstr "propriétaire" + +#: api/models.py core/models.py +msgid "groups" +msgstr "groupes" + +#: api/models.py +msgid "client permissions" +msgstr "permissions du client" + +#: api/models.py +msgid "Specific permissions for this api client." +msgstr "Permissions spécifiques pour ce client d'API" + +#: api/models.py +msgid "api client" +msgstr "client d'api" + +#: api/models.py +msgid "api clients" +msgstr "clients d'api" + +#: api/models.py +msgid "prefix" +msgstr "préfixe" + +#: api/models.py +msgid "hashed key" +msgstr "hash de la clef" + +#: api/models.py +msgctxt "api key" +msgid "revoked" +msgstr "révoquée" + +#: api/models.py +msgid "api key" +msgstr "clef d'api" + +#: api/models.py +msgid "api keys" +msgstr "clefs d'api" + #: club/forms.py msgid "Users to add" msgstr "Utilisateurs à ajouter" @@ -119,10 +181,6 @@ msgstr "Vous devez choisir un rôle" msgid "You do not have the permission to do that" msgstr "Vous n'avez pas la permission de faire cela" -#: club/models.py com/models.py counter/models.py forum/models.py -msgid "name" -msgstr "nom" - #: club/models.py msgid "slug name" msgstr "nom slug" @@ -669,8 +727,7 @@ msgstr "message d'info" msgid "weekmail destinations" msgstr "destinataires du weekmail" -#: com/models.py core/templates/core/macros.jinja election/models.py -#: forum/models.py pedagogy/models.py +#: com/models.py election/models.py forum/models.py pedagogy/models.py msgid "title" msgstr "titre" @@ -1095,7 +1152,7 @@ msgstr "Nouvel article" msgid "Articles in no weekmail yet" msgstr "Articles dans aucun weekmail" -#: com/templates/com/weekmail.jinja core/templates/core/macros.jinja +#: com/templates/com/weekmail.jinja msgid "Content" msgstr "Contenu" @@ -1257,10 +1314,6 @@ msgstr "surnom" msgid "last update" msgstr "dernière mise à jour" -#: core/models.py -msgid "groups" -msgstr "groupes" - #: core/models.py msgid "" "The groups this user belongs to. A user will get all permissions granted to " @@ -1497,10 +1550,6 @@ msgstr "version allégée" msgid "thumbnail" msgstr "miniature" -#: core/models.py -msgid "owner" -msgstr "propriétaire" - #: core/models.py msgid "edit group" msgstr "groupe d'édition" @@ -2725,10 +2774,6 @@ msgstr "Erreur d'envoi du fichier %(file_name)s : %(msg)s" msgid "Apply rights recursively" msgstr "Appliquer les droits récursivement" -#: core/views/forms.py -msgid "Choose file" -msgstr "Choisir un fichier" - #: core/views/forms.py msgid "Choose user" msgstr "Choisir un utilisateur" diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index a6e211d0..bcb6d1de 100644 --- a/locale/fr/LC_MESSAGES/djangojs.po +++ b/locale/fr/LC_MESSAGES/djangojs.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-04-13 00:18+0200\n" +"POT-Creation-Date: 2025-05-18 12:17+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n" "Last-Translator: Sli \n" "Language-Team: AE info \n" @@ -37,14 +37,14 @@ msgstr "Supprimer" msgid "Copy calendar link" msgstr "Copier le lien du calendrier" -#: com/static/bundled/com/components/ics-calendar-index.ts -msgid "How to use calendar link" -msgstr "Comment utiliser le lien du calendrier" - #: com/static/bundled/com/components/ics-calendar-index.ts msgid "Link copied" msgstr "Lien copié" +#: com/static/bundled/com/components/ics-calendar-index.ts +msgid "How to use calendar link" +msgstr "Comment utiliser le lien du calendrier" + #: com/static/bundled/com/moderation-alert-index.ts #, javascript-format msgid "" diff --git a/mkdocs.yml b/mkdocs.yml index a89bbeea..ffa4a8b4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -45,7 +45,6 @@ plugins: members: true members_order: source show_source: true - show_inherited_members: true merge_init_into_class: true show_root_toc_entry: false - include-markdown: @@ -67,6 +66,9 @@ nav: - Gestion des permissions: tutorial/perms.md - Gestion des groupes: tutorial/groups.md - Les fragments: tutorial/fragments.md + - API: + - Développement: tutorial/api/dev.md + - Connexion à l'API: tutorial/api/connect.md - Etransactions: tutorial/etransaction.md - How-to: - L'ORM de Django: howto/querysets.md @@ -84,6 +86,11 @@ nav: - antispam: - reference/antispam/models.md - reference/antispam/forms.md + - api: + - reference/api/auth.md + - reference/api/hashers.md + - reference/api/models.md + - reference/api/perms.md - club: - reference/club/models.md - reference/club/views.md @@ -153,6 +160,7 @@ markdown_extensions: - pymdownx.details - pymdownx.inlinehilite - pymdownx.keys + - pymdownx.blocks.caption - pymdownx.superfences: custom_fences: - name: mermaid diff --git a/pedagogy/api.py b/pedagogy/api.py index 9ad0c3f6..ae8b7ea8 100644 --- a/pedagogy/api.py +++ b/pedagogy/api.py @@ -3,11 +3,13 @@ from typing import Annotated from annotated_types import Ge from ninja import Query +from ninja.security import SessionAuth from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.exceptions import NotFound 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.schemas import SimpleUvSchema, UvFilterSchema, UvSchema from pedagogy.utbm_api import UtbmApiClient @@ -17,6 +19,7 @@ from pedagogy.utbm_api import UtbmApiClient class UvController(ControllerBase): @route.get( "/{code}", + auth=[SessionAuth(), ApiKeyAuth()], permissions=[ # this route will almost always be called in the context # of a UV creation/edition @@ -42,6 +45,7 @@ class UvController(ControllerBase): "", response=PaginatedResponseSchema[SimpleUvSchema], url_name="fetch_uvs", + auth=[SessionAuth(), ApiKeyAuth()], permissions=[HasPerm("pedagogy.view_uv")], ) @paginate(PageNumberPaginationExtra, page_size=100) diff --git a/pedagogy/tests/test_api.py b/pedagogy/tests/test_api.py index cbb99c18..a95acf20 100644 --- a/pedagogy/tests/test_api.py +++ b/pedagogy/tests/test_api.py @@ -68,7 +68,7 @@ class TestUVSearch(TestCase): def test_permissions(self): # Test with anonymous user response = self.client.get(self.url) - assert response.status_code == 403 + assert response.status_code == 401 # Test with not subscribed user self.client.force_login(baker.make(User)) diff --git a/sas/api.py b/sas/api.py index b82ff5e1..fa37e974 100644 --- a/sas/api.py +++ b/sas/api.py @@ -5,6 +5,7 @@ from django.core.exceptions import ValidationError from django.db.models import F from django.urls import reverse from ninja import Body, File, Query +from ninja.security import SessionAuth from ninja_extra import ControllerBase, api_controller, paginate, route from ninja_extra.exceptions import NotFound, PermissionDenied from ninja_extra.pagination import PageNumberPaginationExtra @@ -12,7 +13,8 @@ from ninja_extra.permissions import IsAuthenticated from ninja_extra.schemas import PaginatedResponseSchema from pydantic import NonNegativeInt -from core.auth.api_permissions import ( +from api.auth import ApiKeyAuth +from api.permissions import ( CanAccessLookup, CanEdit, CanView, @@ -53,6 +55,7 @@ class AlbumController(ControllerBase): @route.get( "/autocomplete-search", response=PaginatedResponseSchema[AlbumAutocompleteSchema], + auth=[SessionAuth(), ApiKeyAuth()], permissions=[CanAccessLookup], ) @paginate(PageNumberPaginationExtra, page_size=50) diff --git a/sith/settings.py b/sith/settings.py index 5a7e8e1b..2841a9b3 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -124,6 +124,7 @@ INSTALLED_APPS = ( "pedagogy", "galaxy", "antispam", + "api", ) MIDDLEWARE = ( diff --git a/sith/urls.py b/sith/urls.py index 98608e14..dd560626 100644 --- a/sith/urls.py +++ b/sith/urls.py @@ -12,14 +12,14 @@ # OR WITHIN THE LOCAL FILE "LICENSE" # # - from django.conf import settings from django.conf.urls.static import static from django.contrib import admin from django.http import Http404 from django.urls import include, path from django.views.i18n import JavaScriptCatalog -from ninja_extra import NinjaExtraAPI + +from api.urls import api js_info_dict = {"packages": ("sith",)} @@ -27,9 +27,6 @@ handler403 = "core.views.forbidden" handler404 = "core.views.not_found" handler500 = "core.views.internal_servor_error" -api = NinjaExtraAPI(version="0.2.0", urls_namespace="api", csrf=True) -api.auto_discover_controllers() - urlpatterns = [ path("", include(("core.urls", "core"), namespace="core")), path("api/", api.urls), diff --git a/staticfiles/processors.py b/staticfiles/processors.py index 5f44731b..bc1ceeb1 100644 --- a/staticfiles/processors.py +++ b/staticfiles/processors.py @@ -11,7 +11,7 @@ import rjsmin import sass 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