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

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

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