Loading src/jama/adminui/app.py +88 −0 Original line number Diff line number Diff line Loading @@ -34,11 +34,13 @@ from jama.admin_services import config as config_services from jama.admin_services import tasks as task_services from resources import acl from resources.admin_services import access as access_services from resources.admin_services import api_keys as api_key_services from resources.admin_services import filetypes as filetype_services from resources.admin_services import metadata as metadata_services from resources.admin_services import projects as project_services from resources.admin_services import users as user_services from resources.models import ( APIKey, FileType, Metadata, MetadataSet, Loading Loading @@ -595,6 +597,7 @@ class JamaAdminApp(App): ("ctrl+r", "roles", "Roles"), ("m", "metadata", "Metadata"), ("ctrl+u", "members", "Members"), ("ctrl+k", "api_keys", "API keys"), ("s", "delete_successful_tasks", "Delete successful"), ("f", "delete_failed_tasks", "Delete failed"), ("space", "toggle", "Toggle"), Loading Loading @@ -670,6 +673,7 @@ class JamaAdminApp(App): ], "users": [ FooterCommand("Enter project roles", "open_selected"), FooterCommand("Ctrl+K API keys", "api_keys"), FooterCommand("A add user", "add"), FooterCommand("E edit user", "edit"), FooterCommand("D delete user", "delete"), Loading @@ -677,12 +681,21 @@ class JamaAdminApp(App): FooterCommand("Q quit", "quit"), ], "user_accesses": [ FooterCommand("Ctrl+K API keys", "api_keys"), FooterCommand("A add project role", "add"), FooterCommand("D remove project role", "delete"), FooterCommand("Esc back", "back"), FooterCommand("R refresh", "refresh"), FooterCommand("Q quit", "quit"), ], "user_api_keys": [ FooterCommand("A add API key", "add"), FooterCommand("E edit API key", "edit"), FooterCommand("D delete API key", "delete"), FooterCommand("Esc back", "back"), FooterCommand("R refresh", "refresh"), FooterCommand("Q quit", "quit"), ], "tasks": [ FooterCommand("Enter details", "open_selected"), FooterCommand("A filter", "add"), Loading Loading @@ -810,6 +823,11 @@ class JamaAdminApp(App): if project: self.open_mode("project_members", {"project_id": project.pk}) def action_api_keys(self) -> None: user = self.selected_user() if user: self.open_mode("user_api_keys", {"user_id": user.pk}) def action_open_selected(self) -> None: handlers = { "projects": self.open_project_stats, Loading Loading @@ -837,6 +855,7 @@ class JamaAdminApp(App): "filetypes": self.add_filetype, "tasks": self.filter_tasks, "cache": self.clear_all_caches, "user_api_keys": self.add_api_key, } handler = handlers.get(self.mode_name) if handler: Loading @@ -852,6 +871,7 @@ class JamaAdminApp(App): "metadata": self.edit_metadata, "filetypes": self.edit_filetype, "config": self.edit_env_entry, "user_api_keys": self.edit_api_key, } handler = handlers.get(self.mode_name) if handler: Loading @@ -867,6 +887,7 @@ class JamaAdminApp(App): "metadata_sets": self.delete_metadata_set, "metadata": self.delete_metadata, "filetypes": self.delete_filetype, "user_api_keys": self.delete_api_key, } handler = handlers.get(self.mode_name) if handler: Loading Loading @@ -979,6 +1000,14 @@ class JamaAdminApp(App): ) return None def selected_user(self) -> User | None: if self.mode_name == "users": selected = self.selected_object() return selected if isinstance(selected, User) else None if self.context.get("user_id"): return User.objects.filter(pk=self.context["user_id"]).first() return None def refresh_table(self) -> None: try: getattr(self, f"load_{self.mode_name}")() Loading Loading @@ -1730,6 +1759,65 @@ class JamaAdminApp(App): ), ) def load_user_api_keys(self) -> None: user = get_object_or_404(User, pk=self.context["user_id"]) self.set_title(f"{self.translate('API keys')}: {user.username}") self.reset_table(["id", "active", "key hash"]) for api_key in api_key_services.list_api_keys(user=user): self.add_row( f"api-key:{api_key.pk}", api_key, [api_key.pk, api_key.active, api_key.key_hash], ) self.set_status("A add API key. E activate/deactivate. D delete.") def add_api_key(self) -> None: user = get_object_or_404(User, pk=self.context["user_id"]) def create(values): result = api_key_services.create_api_key( user, values["key"], active=values["active"] ) if result.generated: return f"New API key (copy it now): {result.raw_key}" return "API key saved." self.form( "Add API key", [ Field("key", "API key (blank = generate)", "", "password"), Field("active", "Active", True, "bool"), ], create, ) def edit_api_key(self) -> None: api_key = self.selected_object() if isinstance(api_key, APIKey): def update(values): api_key_services.update_api_key(api_key, active=values["active"]) return "API key updated." self.form( f"{self.translate('Edit API key')} {api_key.pk}", [Field("active", "Active", api_key.active, "bool")], update, ) def delete_api_key(self) -> None: api_key = self.selected_object() if isinstance(api_key, APIKey): def delete(_): api_key_services.delete_api_key(api_key) return "API key deleted." self.confirm( f"Delete API key {api_key.pk}?", delete, ) def load_tasks(self) -> None: self.set_title("Task results") self.reset_table(["id", "project", "task_path", "enqueued_at", "status"]) Loading src/jama/adminui/i18n.py +13 −0 Original line number Diff line number Diff line Loading @@ -31,6 +31,7 @@ FR_TRANSLATIONS = { "Ctrl+R roles": "Ctrl+R rôles", "M metadata": "M métadonnées", "Ctrl+U members": "Ctrl+U membres", "Ctrl+K API keys": "Ctrl+K clés API", "R refresh": "R actualiser", "Q quit": "Q quitter", "Esc back": "Esc retour", Loading @@ -44,6 +45,9 @@ FR_TRANSLATIONS = { "D delete scoped": "D supprimer ciblée", "A add member": "A ajouter membre", "D remove selected role": "D retirer rôle sélectionné", "A add API key": "A ajouter clé API", "E edit API key": "E modifier clé API", "D delete API key": "D supprimer clé API", "Enter metadata": "Entrée métadonnées", "A add set": "A ajouter jeu", "E rename set": "E renommer jeu", Loading Loading @@ -111,6 +115,7 @@ FR_TRANSLATIONS = { "iiif": "IIIF", "hls": "HLS", "key": "clé", "key hash": "empreinte de clé", "utility": "utilitaire", "alias": "alias", "line": "ligne", Loading Loading @@ -163,6 +168,10 @@ FR_TRANSLATIONS = { "Last name": "Nom", "Password (blank = unusable)": "Mot de passe (vide = inutilisable)", "Active": "Actif", "API keys": "Clés API", "Add API key": "Ajouter une clé API", "Edit API key": "Modifier la clé API", "API key (blank = generate)": "Clé API (vide = générer)", "Staff": "Équipe", "Superuser": "Superutilisateur", "New password (blank = unchanged)": "Nouveau mot de passe (vide = inchangé)", Loading Loading @@ -214,6 +223,10 @@ FR_TRANSLATIONS = { "A add metadata. E edit. D delete.": "A ajouter métadonnée. E modifier. D supprimer.", "Enter opens project roles. A add, E edit, D delete.": "Entrée ouvre les rôles projet. A ajouter, E modifier, D supprimer.", "A add by project/role ids. D remove selected access.": "A ajoute par projet/rôle. D retire l'accès sélectionné.", "A add API key. E activate/deactivate. D delete.": "A ajoute une clé API. E active/désactive. D supprime.", "API key saved.": "Clé API enregistrée.", "API key updated.": "Clé API mise à jour.", "API key deleted.": "Clé API supprimée.", "A filters. Enter details. S deletes SUCCESSFUL. F deletes FAILED.": "A filtre. Entrée détails. S supprime SUCCESSFUL. F supprime FAILED.", "Esc back.": "Esc retour.", "A add, E edit, D delete. Utilities can load built-ins.": "A ajouter, E modifier, D supprimer. Les utilitaires peuvent charger les types intégrés.", Loading src/jama/tests.py +21 −0 Original line number Diff line number Diff line Loading @@ -379,6 +379,27 @@ class AdminUiAppTestCase(TransactionTestCase): with patch.dict(os.environ, {"DJANGO_ALLOW_ASYNC_UNSAFE": "true"}): asyncio.run(run_app()) def test_ctrl_k_on_user_opens_api_keys(self): user = User.objects.create(username="api-key-user") api_key, _ = APIKey.create_for_key(user, "raw-key") async def run_app(): from jama.adminui.app import JamaAdminApp app = JamaAdminApp() async with app.run_test() as pilot: app.load("users") await pilot.pause() await pilot.press("ctrl+k") await pilot.pause() self.assertEqual(app.mode_name, "user_api_keys") self.assertEqual(app.context["user_id"], user.pk) self.assertEqual(app.selected_object().pk, api_key.pk) with patch.dict(os.environ, {"DJANGO_ALLOW_ASYNC_UNSAFE": "true"}): asyncio.run(run_app()) def test_m_on_project_opens_metadata_sets(self): project = Project.objects.create(label="Metadata project", description="") Loading src/resources/admin_services/api_keys.py 0 → 100644 +109 −0 Original line number Diff line number Diff line import secrets import string from dataclasses import dataclass from django.contrib.auth.models import User from resources.models import APIKey @dataclass(frozen=True) class APIKeyCreation: api_key: APIKey raw_key: str created: bool generated: bool def generate_api_key(length: int = 32) -> str: alphabet = string.ascii_letters + string.digits return "".join(secrets.choice(alphabet) for _ in range(length)) def list_api_keys(*, user: User | None = None, active_only: bool = False): api_keys = APIKey.objects.select_related("user").order_by("user__username", "id") if user is not None: api_keys = api_keys.filter(user=user) if active_only: api_keys = api_keys.filter(active=True) return api_keys def create_api_key( user: User, raw_key: str | None = None, *, active: bool = True ) -> APIKeyCreation: generated = not raw_key raw_key = raw_key or generate_api_key() api_key, created = APIKey.create_for_key(user=user, key=raw_key) if api_key.active != active: api_key.active = active api_key.save(update_fields=["active"]) return APIKeyCreation(api_key, raw_key, created, generated) def create_api_key_for_username( username: str, raw_key: str | None = None, *, active: bool = True, create_user: bool = False, ) -> APIKeyCreation: user = User.objects.filter(username=username).first() if user is None: if not create_user: raise ValueError( f'User "{username}" does not exist. Use --create-user to create it.' ) user = User.objects.create(username=username) return create_api_key(user, raw_key, active=active) def update_api_key(api_key: APIKey, *, active: bool) -> APIKey: api_key.active = active api_key.save(update_fields=["active"]) return api_key def get_api_key( *, key_id: int | None = None, key_hash: str | None = None, username: str | None = None, raw_key: str | None = None, ) -> APIKey: selectors = sum( selected for selected in ( key_id is not None, key_hash is not None, username is not None or raw_key is not None, ) ) if selectors != 1: raise ValueError( "Delete exactly one key using --id, --hash, or username plus raw key." ) if (username is None) != (raw_key is None): raise ValueError("Deleting by raw key requires both username and key.") filters = {} if key_id is not None: filters["id"] = key_id elif key_hash is not None: filters["key_hash"] = key_hash else: user = User.objects.filter(username=username).first() if user is None: raise ValueError(f'User "{username}" does not exist.') filters.update(user=user, key_hash=APIKey.hash_key(raw_key)) try: return APIKey.objects.select_related("user").get(**filters) except APIKey.DoesNotExist: raise ValueError("API key does not exist.") from None def delete_api_key(api_key: APIKey) -> tuple[int, str]: deleted_id = api_key.pk username = api_key.user.username api_key.delete() return deleted_id, username src/resources/management/commands/apikey.py +33 −72 Original line number Diff line number Diff line import secrets import string from django.contrib.auth.models import User from django.core.management.base import BaseCommand, CommandError from resources.models import APIKey def generate_api_key(length=32): return "".join( secrets.choice(string.ascii_letters + string.digits) for _ in range(length) ) from resources.admin_services import api_keys as api_key_services class Command(BaseCommand): Loading Loading @@ -75,35 +65,34 @@ class Command(BaseCommand): self.handle_delete(options) def handle_add(self, options): username = options["username"] user = User.objects.filter(username=username).first() if user is None: if not options["create_user"]: raise CommandError( f'User "{username}" does not exist. Use --create-user to create it.' ) user = User.objects.create(username=username) raw_key = options["key"] or generate_api_key() api_key, created = APIKey.create_for_key(user=user, key=raw_key) api_key.active = not options["inactive"] api_key.save(update_fields=["active"]) status = "created" if created else "updated" try: result = api_key_services.create_api_key_for_username( options["username"], options["key"], active=not options["inactive"], create_user=options["create_user"], ) except ValueError as error: raise CommandError(str(error)) from error status = "created" if result.created else "updated" self.stdout.write( f"{status}\tid={api_key.pk}\tuser={user.username}\tactive={api_key.active}" f"{status}\tid={result.api_key.pk}\tuser={result.api_key.user.username}" f"\tactive={result.api_key.active}" ) if options["key"] is None: self.stdout.write(f"key\t{raw_key}") if result.generated: self.stdout.write(f"key\t{result.raw_key}") def handle_list(self, options): api_keys = APIKey.objects.select_related("user").order_by( "user__username", "id" ) user = None if options["username"]: api_keys = api_keys.filter(user__username=options["username"]) if options["active_only"]: api_keys = api_keys.filter(active=True) from django.contrib.auth.models import User user = User.objects.filter(username=options["username"]).first() if user is None: return self.stdout.write("id\tuser\tactive\tkey_hash") api_keys = api_key_services.list_api_keys( user=user, active_only=options["active_only"] ) self.stdout.write("id\tuser\tactive\tkey_hash") for api_key in api_keys: Loading @@ -117,42 +106,14 @@ class Command(BaseCommand): username = options["username"] raw_key = options["key"] selectors = sum( 1 for is_selected in ( key_id is not None, key_hash is not None, username is not None or raw_key is not None, ) if is_selected ) if selectors != 1: raise CommandError( "Delete exactly one key using --id, --hash, or username plus raw key." ) if (username is None) != (raw_key is None): raise CommandError("Deleting by raw key requires both username and key.") api_keys = APIKey.objects.select_related("user") if key_id is not None: api_key = self.get_api_key(api_keys, id=key_id) elif key_hash is not None: api_key = self.get_api_key(api_keys, key_hash=key_hash) else: user = User.objects.filter(username=username).first() if user is None: raise CommandError(f'User "{username}" does not exist.') api_key = self.get_api_key( api_keys, user=user, key_hash=APIKey.hash_key(raw_key) ) deleted_id = api_key.pk deleted_user = api_key.user.username api_key.delete() self.stdout.write(f"deleted\tid={deleted_id}\tuser={deleted_user}") def get_api_key(self, api_keys, **filters): try: return api_keys.get(**filters) except APIKey.DoesNotExist: raise CommandError("API key does not exist.") from None api_key = api_key_services.get_api_key( key_id=key_id, key_hash=key_hash, username=username, raw_key=raw_key, ) deleted_id, deleted_user = api_key_services.delete_api_key(api_key) except ValueError as error: raise CommandError(str(error)) from error self.stdout.write(f"deleted\tid={deleted_id}\tuser={deleted_user}") Loading
src/jama/adminui/app.py +88 −0 Original line number Diff line number Diff line Loading @@ -34,11 +34,13 @@ from jama.admin_services import config as config_services from jama.admin_services import tasks as task_services from resources import acl from resources.admin_services import access as access_services from resources.admin_services import api_keys as api_key_services from resources.admin_services import filetypes as filetype_services from resources.admin_services import metadata as metadata_services from resources.admin_services import projects as project_services from resources.admin_services import users as user_services from resources.models import ( APIKey, FileType, Metadata, MetadataSet, Loading Loading @@ -595,6 +597,7 @@ class JamaAdminApp(App): ("ctrl+r", "roles", "Roles"), ("m", "metadata", "Metadata"), ("ctrl+u", "members", "Members"), ("ctrl+k", "api_keys", "API keys"), ("s", "delete_successful_tasks", "Delete successful"), ("f", "delete_failed_tasks", "Delete failed"), ("space", "toggle", "Toggle"), Loading Loading @@ -670,6 +673,7 @@ class JamaAdminApp(App): ], "users": [ FooterCommand("Enter project roles", "open_selected"), FooterCommand("Ctrl+K API keys", "api_keys"), FooterCommand("A add user", "add"), FooterCommand("E edit user", "edit"), FooterCommand("D delete user", "delete"), Loading @@ -677,12 +681,21 @@ class JamaAdminApp(App): FooterCommand("Q quit", "quit"), ], "user_accesses": [ FooterCommand("Ctrl+K API keys", "api_keys"), FooterCommand("A add project role", "add"), FooterCommand("D remove project role", "delete"), FooterCommand("Esc back", "back"), FooterCommand("R refresh", "refresh"), FooterCommand("Q quit", "quit"), ], "user_api_keys": [ FooterCommand("A add API key", "add"), FooterCommand("E edit API key", "edit"), FooterCommand("D delete API key", "delete"), FooterCommand("Esc back", "back"), FooterCommand("R refresh", "refresh"), FooterCommand("Q quit", "quit"), ], "tasks": [ FooterCommand("Enter details", "open_selected"), FooterCommand("A filter", "add"), Loading Loading @@ -810,6 +823,11 @@ class JamaAdminApp(App): if project: self.open_mode("project_members", {"project_id": project.pk}) def action_api_keys(self) -> None: user = self.selected_user() if user: self.open_mode("user_api_keys", {"user_id": user.pk}) def action_open_selected(self) -> None: handlers = { "projects": self.open_project_stats, Loading Loading @@ -837,6 +855,7 @@ class JamaAdminApp(App): "filetypes": self.add_filetype, "tasks": self.filter_tasks, "cache": self.clear_all_caches, "user_api_keys": self.add_api_key, } handler = handlers.get(self.mode_name) if handler: Loading @@ -852,6 +871,7 @@ class JamaAdminApp(App): "metadata": self.edit_metadata, "filetypes": self.edit_filetype, "config": self.edit_env_entry, "user_api_keys": self.edit_api_key, } handler = handlers.get(self.mode_name) if handler: Loading @@ -867,6 +887,7 @@ class JamaAdminApp(App): "metadata_sets": self.delete_metadata_set, "metadata": self.delete_metadata, "filetypes": self.delete_filetype, "user_api_keys": self.delete_api_key, } handler = handlers.get(self.mode_name) if handler: Loading Loading @@ -979,6 +1000,14 @@ class JamaAdminApp(App): ) return None def selected_user(self) -> User | None: if self.mode_name == "users": selected = self.selected_object() return selected if isinstance(selected, User) else None if self.context.get("user_id"): return User.objects.filter(pk=self.context["user_id"]).first() return None def refresh_table(self) -> None: try: getattr(self, f"load_{self.mode_name}")() Loading Loading @@ -1730,6 +1759,65 @@ class JamaAdminApp(App): ), ) def load_user_api_keys(self) -> None: user = get_object_or_404(User, pk=self.context["user_id"]) self.set_title(f"{self.translate('API keys')}: {user.username}") self.reset_table(["id", "active", "key hash"]) for api_key in api_key_services.list_api_keys(user=user): self.add_row( f"api-key:{api_key.pk}", api_key, [api_key.pk, api_key.active, api_key.key_hash], ) self.set_status("A add API key. E activate/deactivate. D delete.") def add_api_key(self) -> None: user = get_object_or_404(User, pk=self.context["user_id"]) def create(values): result = api_key_services.create_api_key( user, values["key"], active=values["active"] ) if result.generated: return f"New API key (copy it now): {result.raw_key}" return "API key saved." self.form( "Add API key", [ Field("key", "API key (blank = generate)", "", "password"), Field("active", "Active", True, "bool"), ], create, ) def edit_api_key(self) -> None: api_key = self.selected_object() if isinstance(api_key, APIKey): def update(values): api_key_services.update_api_key(api_key, active=values["active"]) return "API key updated." self.form( f"{self.translate('Edit API key')} {api_key.pk}", [Field("active", "Active", api_key.active, "bool")], update, ) def delete_api_key(self) -> None: api_key = self.selected_object() if isinstance(api_key, APIKey): def delete(_): api_key_services.delete_api_key(api_key) return "API key deleted." self.confirm( f"Delete API key {api_key.pk}?", delete, ) def load_tasks(self) -> None: self.set_title("Task results") self.reset_table(["id", "project", "task_path", "enqueued_at", "status"]) Loading
src/jama/adminui/i18n.py +13 −0 Original line number Diff line number Diff line Loading @@ -31,6 +31,7 @@ FR_TRANSLATIONS = { "Ctrl+R roles": "Ctrl+R rôles", "M metadata": "M métadonnées", "Ctrl+U members": "Ctrl+U membres", "Ctrl+K API keys": "Ctrl+K clés API", "R refresh": "R actualiser", "Q quit": "Q quitter", "Esc back": "Esc retour", Loading @@ -44,6 +45,9 @@ FR_TRANSLATIONS = { "D delete scoped": "D supprimer ciblée", "A add member": "A ajouter membre", "D remove selected role": "D retirer rôle sélectionné", "A add API key": "A ajouter clé API", "E edit API key": "E modifier clé API", "D delete API key": "D supprimer clé API", "Enter metadata": "Entrée métadonnées", "A add set": "A ajouter jeu", "E rename set": "E renommer jeu", Loading Loading @@ -111,6 +115,7 @@ FR_TRANSLATIONS = { "iiif": "IIIF", "hls": "HLS", "key": "clé", "key hash": "empreinte de clé", "utility": "utilitaire", "alias": "alias", "line": "ligne", Loading Loading @@ -163,6 +168,10 @@ FR_TRANSLATIONS = { "Last name": "Nom", "Password (blank = unusable)": "Mot de passe (vide = inutilisable)", "Active": "Actif", "API keys": "Clés API", "Add API key": "Ajouter une clé API", "Edit API key": "Modifier la clé API", "API key (blank = generate)": "Clé API (vide = générer)", "Staff": "Équipe", "Superuser": "Superutilisateur", "New password (blank = unchanged)": "Nouveau mot de passe (vide = inchangé)", Loading Loading @@ -214,6 +223,10 @@ FR_TRANSLATIONS = { "A add metadata. E edit. D delete.": "A ajouter métadonnée. E modifier. D supprimer.", "Enter opens project roles. A add, E edit, D delete.": "Entrée ouvre les rôles projet. A ajouter, E modifier, D supprimer.", "A add by project/role ids. D remove selected access.": "A ajoute par projet/rôle. D retire l'accès sélectionné.", "A add API key. E activate/deactivate. D delete.": "A ajoute une clé API. E active/désactive. D supprime.", "API key saved.": "Clé API enregistrée.", "API key updated.": "Clé API mise à jour.", "API key deleted.": "Clé API supprimée.", "A filters. Enter details. S deletes SUCCESSFUL. F deletes FAILED.": "A filtre. Entrée détails. S supprime SUCCESSFUL. F supprime FAILED.", "Esc back.": "Esc retour.", "A add, E edit, D delete. Utilities can load built-ins.": "A ajouter, E modifier, D supprimer. Les utilitaires peuvent charger les types intégrés.", Loading
src/jama/tests.py +21 −0 Original line number Diff line number Diff line Loading @@ -379,6 +379,27 @@ class AdminUiAppTestCase(TransactionTestCase): with patch.dict(os.environ, {"DJANGO_ALLOW_ASYNC_UNSAFE": "true"}): asyncio.run(run_app()) def test_ctrl_k_on_user_opens_api_keys(self): user = User.objects.create(username="api-key-user") api_key, _ = APIKey.create_for_key(user, "raw-key") async def run_app(): from jama.adminui.app import JamaAdminApp app = JamaAdminApp() async with app.run_test() as pilot: app.load("users") await pilot.pause() await pilot.press("ctrl+k") await pilot.pause() self.assertEqual(app.mode_name, "user_api_keys") self.assertEqual(app.context["user_id"], user.pk) self.assertEqual(app.selected_object().pk, api_key.pk) with patch.dict(os.environ, {"DJANGO_ALLOW_ASYNC_UNSAFE": "true"}): asyncio.run(run_app()) def test_m_on_project_opens_metadata_sets(self): project = Project.objects.create(label="Metadata project", description="") Loading
src/resources/admin_services/api_keys.py 0 → 100644 +109 −0 Original line number Diff line number Diff line import secrets import string from dataclasses import dataclass from django.contrib.auth.models import User from resources.models import APIKey @dataclass(frozen=True) class APIKeyCreation: api_key: APIKey raw_key: str created: bool generated: bool def generate_api_key(length: int = 32) -> str: alphabet = string.ascii_letters + string.digits return "".join(secrets.choice(alphabet) for _ in range(length)) def list_api_keys(*, user: User | None = None, active_only: bool = False): api_keys = APIKey.objects.select_related("user").order_by("user__username", "id") if user is not None: api_keys = api_keys.filter(user=user) if active_only: api_keys = api_keys.filter(active=True) return api_keys def create_api_key( user: User, raw_key: str | None = None, *, active: bool = True ) -> APIKeyCreation: generated = not raw_key raw_key = raw_key or generate_api_key() api_key, created = APIKey.create_for_key(user=user, key=raw_key) if api_key.active != active: api_key.active = active api_key.save(update_fields=["active"]) return APIKeyCreation(api_key, raw_key, created, generated) def create_api_key_for_username( username: str, raw_key: str | None = None, *, active: bool = True, create_user: bool = False, ) -> APIKeyCreation: user = User.objects.filter(username=username).first() if user is None: if not create_user: raise ValueError( f'User "{username}" does not exist. Use --create-user to create it.' ) user = User.objects.create(username=username) return create_api_key(user, raw_key, active=active) def update_api_key(api_key: APIKey, *, active: bool) -> APIKey: api_key.active = active api_key.save(update_fields=["active"]) return api_key def get_api_key( *, key_id: int | None = None, key_hash: str | None = None, username: str | None = None, raw_key: str | None = None, ) -> APIKey: selectors = sum( selected for selected in ( key_id is not None, key_hash is not None, username is not None or raw_key is not None, ) ) if selectors != 1: raise ValueError( "Delete exactly one key using --id, --hash, or username plus raw key." ) if (username is None) != (raw_key is None): raise ValueError("Deleting by raw key requires both username and key.") filters = {} if key_id is not None: filters["id"] = key_id elif key_hash is not None: filters["key_hash"] = key_hash else: user = User.objects.filter(username=username).first() if user is None: raise ValueError(f'User "{username}" does not exist.') filters.update(user=user, key_hash=APIKey.hash_key(raw_key)) try: return APIKey.objects.select_related("user").get(**filters) except APIKey.DoesNotExist: raise ValueError("API key does not exist.") from None def delete_api_key(api_key: APIKey) -> tuple[int, str]: deleted_id = api_key.pk username = api_key.user.username api_key.delete() return deleted_id, username
src/resources/management/commands/apikey.py +33 −72 Original line number Diff line number Diff line import secrets import string from django.contrib.auth.models import User from django.core.management.base import BaseCommand, CommandError from resources.models import APIKey def generate_api_key(length=32): return "".join( secrets.choice(string.ascii_letters + string.digits) for _ in range(length) ) from resources.admin_services import api_keys as api_key_services class Command(BaseCommand): Loading Loading @@ -75,35 +65,34 @@ class Command(BaseCommand): self.handle_delete(options) def handle_add(self, options): username = options["username"] user = User.objects.filter(username=username).first() if user is None: if not options["create_user"]: raise CommandError( f'User "{username}" does not exist. Use --create-user to create it.' ) user = User.objects.create(username=username) raw_key = options["key"] or generate_api_key() api_key, created = APIKey.create_for_key(user=user, key=raw_key) api_key.active = not options["inactive"] api_key.save(update_fields=["active"]) status = "created" if created else "updated" try: result = api_key_services.create_api_key_for_username( options["username"], options["key"], active=not options["inactive"], create_user=options["create_user"], ) except ValueError as error: raise CommandError(str(error)) from error status = "created" if result.created else "updated" self.stdout.write( f"{status}\tid={api_key.pk}\tuser={user.username}\tactive={api_key.active}" f"{status}\tid={result.api_key.pk}\tuser={result.api_key.user.username}" f"\tactive={result.api_key.active}" ) if options["key"] is None: self.stdout.write(f"key\t{raw_key}") if result.generated: self.stdout.write(f"key\t{result.raw_key}") def handle_list(self, options): api_keys = APIKey.objects.select_related("user").order_by( "user__username", "id" ) user = None if options["username"]: api_keys = api_keys.filter(user__username=options["username"]) if options["active_only"]: api_keys = api_keys.filter(active=True) from django.contrib.auth.models import User user = User.objects.filter(username=options["username"]).first() if user is None: return self.stdout.write("id\tuser\tactive\tkey_hash") api_keys = api_key_services.list_api_keys( user=user, active_only=options["active_only"] ) self.stdout.write("id\tuser\tactive\tkey_hash") for api_key in api_keys: Loading @@ -117,42 +106,14 @@ class Command(BaseCommand): username = options["username"] raw_key = options["key"] selectors = sum( 1 for is_selected in ( key_id is not None, key_hash is not None, username is not None or raw_key is not None, ) if is_selected ) if selectors != 1: raise CommandError( "Delete exactly one key using --id, --hash, or username plus raw key." ) if (username is None) != (raw_key is None): raise CommandError("Deleting by raw key requires both username and key.") api_keys = APIKey.objects.select_related("user") if key_id is not None: api_key = self.get_api_key(api_keys, id=key_id) elif key_hash is not None: api_key = self.get_api_key(api_keys, key_hash=key_hash) else: user = User.objects.filter(username=username).first() if user is None: raise CommandError(f'User "{username}" does not exist.') api_key = self.get_api_key( api_keys, user=user, key_hash=APIKey.hash_key(raw_key) ) deleted_id = api_key.pk deleted_user = api_key.user.username api_key.delete() self.stdout.write(f"deleted\tid={deleted_id}\tuser={deleted_user}") def get_api_key(self, api_keys, **filters): try: return api_keys.get(**filters) except APIKey.DoesNotExist: raise CommandError("API key does not exist.") from None api_key = api_key_services.get_api_key( key_id=key_id, key_hash=key_hash, username=username, raw_key=raw_key, ) deleted_id, deleted_user = api_key_services.delete_api_key(api_key) except ValueError as error: raise CommandError(str(error)) from error self.stdout.write(f"deleted\tid={deleted_id}\tuser={deleted_user}")