mirror of
https://github.com/ae-utbm/sith.git
synced 2025-06-07 19:55:20 +00:00
feat: basic api key management
This commit is contained in:
parent
99be8a56f3
commit
a11897cd10
0
apikey/__init__.py
Normal file
0
apikey/__init__.py
Normal file
55
apikey/admin.py
Normal file
55
apikey/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 apikey.hashers import generate_key
|
||||||
|
from apikey.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
apikey/apps.py
Normal file
6
apikey/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ApikeyConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apikey"
|
22
apikey/auth.py
Normal file
22
apikey/auth.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
from django.http import HttpRequest
|
||||||
|
from ninja.security import APIKeyHeader
|
||||||
|
|
||||||
|
from apikey.hashers import get_hasher
|
||||||
|
from apikey.models import ApiClient, ApiKey
|
||||||
|
|
||||||
|
_UUID_LENGTH = 36
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyAuth(APIKeyHeader):
|
||||||
|
param_name = "X-APIKey"
|
||||||
|
|
||||||
|
def authenticate(self, request: HttpRequest, key: str | None) -> ApiClient | None:
|
||||||
|
if not key or len(key) != _UUID_LENGTH:
|
||||||
|
return None
|
||||||
|
hasher = get_hasher()
|
||||||
|
hashed_key = hasher.encode(key)
|
||||||
|
try:
|
||||||
|
key_obj = ApiKey.objects.get(hashed_key=hashed_key)
|
||||||
|
except ApiKey.DoesNotExist:
|
||||||
|
return None
|
||||||
|
return key_obj.client
|
42
apikey/hashers.py
Normal file
42
apikey/hashers.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import functools
|
||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.contrib.auth.hashers import BasePasswordHasher
|
||||||
|
from django.utils.crypto import constant_time_compare
|
||||||
|
|
||||||
|
|
||||||
|
class Sha256ApiKeyHasher(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 = "sha256"
|
||||||
|
|
||||||
|
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.sha256(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 Sha256ApiKeyHasher()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_key() -> tuple[str, str]:
|
||||||
|
"""Generate a [key, hash] couple."""
|
||||||
|
key = str(uuid.uuid4())
|
||||||
|
hasher = get_hasher()
|
||||||
|
return key, hasher.encode(key)
|
113
apikey/migrations/0001_initial.py
Normal file
113
apikey/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=150,
|
||||||
|
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="apikey.apiclient",
|
||||||
|
verbose_name="api client",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "api key",
|
||||||
|
"verbose_name_plural": "api keys",
|
||||||
|
"permissions": [("revoke_apikey", "Revoke API keys")],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
0
apikey/migrations/__init__.py
Normal file
0
apikey/migrations/__init__.py
Normal file
94
apikey/models.py
Normal file
94
apikey/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",
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
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=150, 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}***)"
|
29
apikey/tests.py
Normal file
29
apikey/tests.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import pytest
|
||||||
|
from django.test import RequestFactory
|
||||||
|
from model_bakery import baker
|
||||||
|
|
||||||
|
from apikey.auth import ApiKeyAuth
|
||||||
|
from apikey.hashers import generate_key
|
||||||
|
from apikey.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
|
6
docs/reference/apikey/auth.md
Normal file
6
docs/reference/apikey/auth.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
::: apikey.auth
|
||||||
|
handler: python
|
||||||
|
options:
|
||||||
|
heading_level: 3
|
||||||
|
members:
|
||||||
|
- ApiKeyAuth
|
8
docs/reference/apikey/hashers.md
Normal file
8
docs/reference/apikey/hashers.md
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
::: apikey.hashers
|
||||||
|
handler: python
|
||||||
|
options:
|
||||||
|
heading_level: 3
|
||||||
|
members:
|
||||||
|
- Sha256ApiKeyHasher
|
||||||
|
- get_hasher
|
||||||
|
- generate_key
|
7
docs/reference/apikey/models.md
Normal file
7
docs/reference/apikey/models.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
::: apikey.auth
|
||||||
|
handler: python
|
||||||
|
options:
|
||||||
|
heading_level: 3
|
||||||
|
members:
|
||||||
|
- ApiKey
|
||||||
|
- ApiClient
|
@ -35,6 +35,69 @@ msgstr ""
|
|||||||
"True si gardé à jour par le biais d'un fournisseur externe de domains "
|
"True si gardé à jour par le biais d'un fournisseur externe de domains "
|
||||||
"toxics, False sinon"
|
"toxics, False sinon"
|
||||||
|
|
||||||
|
#: apikey/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."
|
||||||
|
|
||||||
|
#: apikey/admin.py
|
||||||
|
msgid "Revoke selected API keys"
|
||||||
|
msgstr "Révoquer les clefs d'API sélectionnées"
|
||||||
|
|
||||||
|
#: apikey/models.py club/models.py com/models.py counter/models.py
|
||||||
|
#: forum/models.py launderette/models.py
|
||||||
|
msgid "name"
|
||||||
|
msgstr "nom"
|
||||||
|
|
||||||
|
#: apikey/models.py core/models.py
|
||||||
|
msgid "owner"
|
||||||
|
msgstr "propriétaire"
|
||||||
|
|
||||||
|
#: apikey/models.py core/models.py
|
||||||
|
msgid "groups"
|
||||||
|
msgstr "groupes"
|
||||||
|
|
||||||
|
#: apikey/models.py
|
||||||
|
msgid "client permissions"
|
||||||
|
msgstr "permissions du client"
|
||||||
|
|
||||||
|
#: apikey/models.py
|
||||||
|
msgid "Specific permissions for this api client."
|
||||||
|
msgstr "Permissions spécifiques pour ce client d'API"
|
||||||
|
|
||||||
|
#: apikey/models.py
|
||||||
|
msgid "api client"
|
||||||
|
msgstr "client d'api"
|
||||||
|
|
||||||
|
#: apikey/models.py
|
||||||
|
msgid "api clients"
|
||||||
|
msgstr "clients d'api"
|
||||||
|
|
||||||
|
#: apikey/models.py
|
||||||
|
msgid "prefix"
|
||||||
|
msgstr "préfixe"
|
||||||
|
|
||||||
|
#: apikey/models.py
|
||||||
|
msgid "hashed key"
|
||||||
|
msgstr "hash de la clef"
|
||||||
|
|
||||||
|
#: apikey/models.py
|
||||||
|
msgctxt "api key"
|
||||||
|
msgid "revoked"
|
||||||
|
msgstr "révoquée"
|
||||||
|
|
||||||
|
#: apikey/models.py
|
||||||
|
msgid "api key"
|
||||||
|
msgstr "clef d'api"
|
||||||
|
|
||||||
|
#: apikey/models.py
|
||||||
|
msgid "api keys"
|
||||||
|
msgstr "clefs d'api"
|
||||||
|
|
||||||
#: club/forms.py
|
#: club/forms.py
|
||||||
msgid "Users to add"
|
msgid "Users to add"
|
||||||
msgstr "Utilisateurs à ajouter"
|
msgstr "Utilisateurs à ajouter"
|
||||||
@ -1257,10 +1320,6 @@ msgstr "surnom"
|
|||||||
msgid "last update"
|
msgid "last update"
|
||||||
msgstr "dernière mise à jour"
|
msgstr "dernière mise à jour"
|
||||||
|
|
||||||
#: core/models.py
|
|
||||||
msgid "groups"
|
|
||||||
msgstr "groupes"
|
|
||||||
|
|
||||||
#: core/models.py
|
#: core/models.py
|
||||||
msgid ""
|
msgid ""
|
||||||
"The groups this user belongs to. A user will get all permissions granted to "
|
"The groups this user belongs to. A user will get all permissions granted to "
|
||||||
@ -1497,10 +1556,6 @@ msgstr "version allégée"
|
|||||||
msgid "thumbnail"
|
msgid "thumbnail"
|
||||||
msgstr "miniature"
|
msgstr "miniature"
|
||||||
|
|
||||||
#: core/models.py
|
|
||||||
msgid "owner"
|
|
||||||
msgstr "propriétaire"
|
|
||||||
|
|
||||||
#: core/models.py
|
#: core/models.py
|
||||||
msgid "edit group"
|
msgid "edit group"
|
||||||
msgstr "groupe d'édition"
|
msgstr "groupe d'édition"
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2025-04-13 00:18+0200\n"
|
"POT-Creation-Date: 2025-05-18 12:17+0200\n"
|
||||||
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
|
"PO-Revision-Date: 2024-09-17 11:54+0200\n"
|
||||||
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
"Last-Translator: Sli <antoine@bartuccio.fr>\n"
|
||||||
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
"Language-Team: AE info <ae.info@utbm.fr>\n"
|
||||||
@ -37,14 +37,14 @@ msgstr "Supprimer"
|
|||||||
msgid "Copy calendar link"
|
msgid "Copy calendar link"
|
||||||
msgstr "Copier le lien du calendrier"
|
msgstr "Copier le lien du calendrier"
|
||||||
|
|
||||||
#: com/static/bundled/com/components/ics-calendar-index.ts
|
|
||||||
msgid "How to use calendar link"
|
|
||||||
msgstr "Comment utiliser le lien du calendrier"
|
|
||||||
|
|
||||||
#: com/static/bundled/com/components/ics-calendar-index.ts
|
#: com/static/bundled/com/components/ics-calendar-index.ts
|
||||||
msgid "Link copied"
|
msgid "Link copied"
|
||||||
msgstr "Lien copié"
|
msgstr "Lien copié"
|
||||||
|
|
||||||
|
#: com/static/bundled/com/components/ics-calendar-index.ts
|
||||||
|
msgid "How to use calendar link"
|
||||||
|
msgstr "Comment utiliser le lien du calendrier"
|
||||||
|
|
||||||
#: com/static/bundled/com/components/moderation-alert-index.ts
|
#: com/static/bundled/com/components/moderation-alert-index.ts
|
||||||
#, javascript-format
|
#, javascript-format
|
||||||
msgid ""
|
msgid ""
|
||||||
|
@ -84,6 +84,10 @@ nav:
|
|||||||
- antispam:
|
- antispam:
|
||||||
- reference/antispam/models.md
|
- reference/antispam/models.md
|
||||||
- reference/antispam/forms.md
|
- reference/antispam/forms.md
|
||||||
|
- apikey:
|
||||||
|
- reference/apikey/auth.md
|
||||||
|
- reference/apikey/hashers.md
|
||||||
|
- reference/apikey/models.md
|
||||||
- club:
|
- club:
|
||||||
- reference/club/models.md
|
- reference/club/models.md
|
||||||
- reference/club/views.md
|
- reference/club/views.md
|
||||||
|
@ -124,6 +124,7 @@ INSTALLED_APPS = (
|
|||||||
"pedagogy",
|
"pedagogy",
|
||||||
"galaxy",
|
"galaxy",
|
||||||
"antispam",
|
"antispam",
|
||||||
|
"apikey",
|
||||||
)
|
)
|
||||||
|
|
||||||
MIDDLEWARE = (
|
MIDDLEWARE = (
|
||||||
|
Loading…
x
Reference in New Issue
Block a user