From 813780a18b7557460e725bfe1f6a95eee7d26c9b Mon Sep 17 00:00:00 2001 From: Jan Koppe Date: Sun, 17 Mar 2024 21:02:41 +0100 Subject: [PATCH] replace DRF with django-ninja --- source/concierge/api.py | 112 ++++++++++++++++++++ source/concierge/urls.py | 8 -- source/concierge/views.py | 94 ----------------- source/config/api.py | 144 ++++++++++++++++++++++++++ source/portier/api.py | 30 ++++++ source/portier/settings.py | 5 +- source/portier/urls.py | 5 +- source/requirements.txt | 3 +- source/restapi/__init__.py | 0 source/restapi/apps.py | 5 - source/restapi/migrations/__init__.py | 0 source/restapi/urls.py | 16 --- source/restapi/views.py | 51 --------- source/static/js/restream-list.js | 4 +- source/static/js/stream-list.js | 2 +- 15 files changed, 296 insertions(+), 183 deletions(-) create mode 100644 source/concierge/api.py delete mode 100644 source/concierge/urls.py delete mode 100644 source/concierge/views.py create mode 100644 source/config/api.py create mode 100644 source/portier/api.py delete mode 100644 source/restapi/__init__.py delete mode 100644 source/restapi/apps.py delete mode 100644 source/restapi/migrations/__init__.py delete mode 100644 source/restapi/urls.py delete mode 100644 source/restapi/views.py diff --git a/source/concierge/api.py b/source/concierge/api.py new file mode 100644 index 0000000..4643045 --- /dev/null +++ b/source/concierge/api.py @@ -0,0 +1,112 @@ +from ninja import Router, Schema +from ninja.errors import HttpError +from concierge import models +from typing import List +from django.db import transaction +from django.shortcuts import get_object_or_404 +from django.utils.timezone import now +from django.views.decorators.csrf import csrf_exempt + +import json + + +router = Router() + + +class ClaimReference(Schema): + uuid: str + + +class AvailableTask(Schema): + uuid: str + type: str + + +class HeartbeatResponse(Schema): + success: bool = True + claims: List[ClaimReference] + available: List[AvailableTask] + + +class ClaimResponse(Schema): + uuid: str + type: str + configuration: dict + + +class ReleaseResponse(Schema): + success: bool = True + uuid: str + type: str + + +@router.post('/heartbeat/{identity}', response=HeartbeatResponse) +@csrf_exempt +def receive_heartbeat(request, identity: str): + try: + id = models.Identity.objects.get(identity=identity) + except models.Identity.DoesNotExist: + raise HttpError(403, "identity unknown") + + # update heartbeat + id.heartbeat = now() + id.save() + + # get current claims and available tasks + claims = models.Task.objects.filter(claimed_by=id).all() + available = models.Task.objects.filter(claimed_by=None).all() + + return { + 'success': True, + 'claims': [{'uuid': str(o.uuid)} for o in list(claims)], + 'available': [{'uuid': str(o.uuid), 'type': o.type} for o in list(available)], + } + + +@router.post('/claim/{identity}/{task_uuid}', response=ClaimResponse) +@csrf_exempt +def claim_task(request, identity: str, task_uuid: str): + try: + id = models.Identity.objects.get(identity=identity) + except models.Identity.DoesNotExist: + raise HttpError(403, "identity unknown") + + with transaction.atomic(): + task = get_object_or_404(models.Task, uuid=task_uuid) + + if task.claimed_by: + raise HttpError(423, "task already claimed") + + task.claimed_by = id + task.save() + + return { + 'success': True, + 'uuid': task.uuid, + 'type': task.type, + 'configuration': json.loads(task.configuration) + } + + +@router.post('/release/{identity}/{task_uuid}') +@csrf_exempt +def release_task(request, identity: str, task_uuid: str): + try: + id = models.Identity.objects.get(identity=identity) + except models.Identity.DoesNotExist: + raise HttpError(403, "identity unknown") + + with transaction.atomic(): + task = get_object_or_404(models.Task, uuid=task_uuid) + + if task.claimed_by != id: + raise HttpError(403, "task not claimed by this identity") + + task.claimed_by = None + task.save() + + return { + 'success': True, + 'uuid': task.uuid, + 'type': task.type, + } \ No newline at end of file diff --git a/source/concierge/urls.py b/source/concierge/urls.py deleted file mode 100644 index 0c33e30..0000000 --- a/source/concierge/urls.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.urls import path -from . import views - -urlpatterns = [ - path('api//heartbeat', views.heartbeat, name='heartbeat'), - path('api//claim/', views.claim, name='claim'), - path('api//release/', views.release, name='release'), -] diff --git a/source/concierge/views.py b/source/concierge/views.py deleted file mode 100644 index b9aef24..0000000 --- a/source/concierge/views.py +++ /dev/null @@ -1,94 +0,0 @@ -import json - -from django.db import transaction -from django.http import JsonResponse -from django.views.decorators.csrf import csrf_exempt -from django.views.decorators.http import require_POST -from django.core.exceptions import ObjectDoesNotExist -from django.utils.timezone import now - -from .models import Identity, Task - - -@csrf_exempt -@require_POST -def heartbeat(request, identity): - try: - id = Identity.objects.get(identity=identity) - except ObjectDoesNotExist: - return JsonResponse({'error': 'identity unknown'}, status=403) - - # update heartbeat - id.heartbeat = now() - id.save() - - # get current claims and available tasks - claims = Task.objects.filter(claimed_by=id).all() - available = Task.objects.filter(claimed_by=None).all() - - data = { - 'success': True, - 'claims': [{'uuid': str(o.uuid)} for o in list(claims)], - 'available': [{'uuid': str(o.uuid), 'type': o.type} for o in list(available)], - } - - return JsonResponse(data) - - -@csrf_exempt -@require_POST -def claim(request, identity, task_uuid): - try: - id = Identity.objects.get(identity=identity) - except ObjectDoesNotExist: - return JsonResponse({'error': 'identity unknown'}, status=403) - - with transaction.atomic(): - try: - task = Task.objects.get(uuid=task_uuid) - except ObjectDoesNotExist: - return JsonResponse({'error': 'task unknown'}, status=404) - - if task.claimed_by: - return JsonResponse({'error': 'task already claimed'}, status=423) - - task.claimed_by = id - task.save() - - data = { - 'success': True, - 'uuid': task.uuid, - 'type': task.type, - 'configuration': json.loads(task.configuration) - } - - return JsonResponse(data) - - -@csrf_exempt -@require_POST -def release(request, identity, task_uuid): - try: - id = Identity.objects.get(identity=identity) - except ObjectDoesNotExist: - return JsonResponse({'error': 'identity unknown'}, status=403) - - with transaction.atomic(): - try: - task = Task.objects.get(uuid=task_uuid) - except ObjectDoesNotExist: - return JsonResponse({'error': 'task unknown'}, status=404) - - if task.claimed_by != id: - return JsonResponse({'error': 'task claimed by other identity'}, status=403) - - task.claimed_by = None - task.save() - - data = { - 'success': True, - 'uuid': task.uuid, - 'type': task.type, - } - - return JsonResponse(data) diff --git a/source/config/api.py b/source/config/api.py new file mode 100644 index 0000000..c904a72 --- /dev/null +++ b/source/config/api.py @@ -0,0 +1,144 @@ +from ninja import Router, ModelSchema, Schema +from ninja.errors import HttpError +from config import models +from typing import List +from guardian.shortcuts import get_objects_for_user, assign_perm +from django.shortcuts import get_object_or_404 + +router = Router() + + +class Restream(ModelSchema): + class Meta: + model = models.Restream + fields = "__all__" + + +class RestreamPatch(ModelSchema): + class Meta: + model = models.Restream + exclude = ["id"] + fields_optional = "__all__" + extra = "forbid" + + +class Stream(ModelSchema): + class Meta: + model = models.Stream + fields = "__all__" + + +class StreamPatch(ModelSchema): + class Meta: + model = models.Stream + fields = ["name"] + fields_optional = "__all__" + extra = "forbid" + + +class StreamCreate(ModelSchema): + class Meta: + model = models.Stream + fields = ["name"] + extra = "forbid" + + +@router.get('/streams', response=List[Stream]) +def list_streams(request): + return get_objects_for_user(request.user, 'view_stream', models.Stream.objects.all()) + + +@router.post('/streams', response=Stream) +def create_stream(request, payload: StreamCreate): + stream = models.Stream.objects.create(**payload.dict()) + assign_perm('view_stream', request.user, stream) + assign_perm('change_stream', request.user, stream) + assign_perm('delete_stream', request.user, stream) + return stream + + +@router.get('/streams/{id}', response=Stream) +def get_stream(request, id: int): + stream = get_object_or_404(models.Stream, id=id) + + if not request.user.has_perm('view_stream', stream): + raise HttpError(401, "unauthorized") + return stream + + +@router.patch('/streams/{id}', response=Stream) +def update_stream(request, id: int, payload: StreamPatch): + stream = get_object_or_404(models.Stream, id=id) + + if not request.user.has_perm('change_stream', stream): + raise HttpError(401, "unauthorized") + + stream.name = payload.name + stream.save() + return stream + + +@router.delete('/streams/{id}', response=None) +def delete_stream(request, id: int): + stream = get_object_or_404(models.Stream, id=id) + + if not request.user.has_perm('delete_stream', stream): + raise HttpError(401, "unauthorized") + + stream.delete() + + +@router.get('/restreams', response=List[Restream]) +def list_restreams(request): + return get_objects_for_user(request.user, 'view_restream', models.Restream.objects.all()) + + +@router.post('/restreams', response=Restream) +def create_restream(request, payload: Restream): + if not request.user.has_perm('view_stream', payload.stream): + raise HttpError(401, "unauthorized") + + restream = models.Restream.objects.create(**payload.dict()) + assign_perm('view_restream', request.user, restream) + assign_perm('change_restream', request.user, restream) + assign_perm('delete_restream', request.user, restream) + return restream + + +@router.get('/restreams/{id}', response=Restream) +def get_restream(request, id: int): + restream = get_object_or_404(models.Restream, id=id) + + if not request.user.has_perm('view_restream', restream): + raise HttpError(401, "unauthorized") + + return restream + + +@router.patch('/restreams/{id}', response=Restream) +def update_restream(request, id: int, payload: RestreamPatch): + restream = get_object_or_404(models.Restream, id=id) + + if not request.user.has_perm('change_restream', restream): + raise HttpError(401, "unauthorized") + + if payload.stream: + payload.stream = get_object_or_404(models.Stream, id=payload.stream) + + if not request.user.has_perm('view_stream', payload.stream): + raise HttpError(401, "unauthorized") + + for key, value in payload.dict(exclude_unset=True).items(): + setattr(restream, key, value) + restream.save() + return restream + + +@router.delete('/restreams/{id}', response=None) +def delete_restream(request, id: int): + restream = get_object_or_404(models.Restream, id=id) + + if not request.user.has_perm('delete_restream', restream): + raise HttpError(401, "unauthorized") + + restream.delete() \ No newline at end of file diff --git a/source/portier/api.py b/source/portier/api.py new file mode 100644 index 0000000..b04f7df --- /dev/null +++ b/source/portier/api.py @@ -0,0 +1,30 @@ +from ninja import NinjaAPI, ModelSchema, Router +from ninja.security import django_auth +from django.contrib.auth.models import User + +from config.api import router as config_router +from concierge.api import router as concierge_router + +core_router = Router() + +class UserSchema(ModelSchema): + class Meta: + model = User + fields = ["id", "username", "email"] + +@core_router.get("/me", response=UserSchema) +def me(request): + return request.user + + + +api = NinjaAPI( + title="Portier API", + version="2.0.0", + description="HTTP API for Portier. Use this to interact with the Portier backend.", + csrf=True, +) + +api.add_router("/", core_router, auth=django_auth, tags=["Core"]) +api.add_router("/config/", config_router, auth=django_auth, tags=["Configuration"]) +api.add_router("/concierge/", concierge_router, auth=None, tags=["Concierge"]) diff --git a/source/portier/settings.py b/source/portier/settings.py index 0f74cbf..ab10537 100644 --- a/source/portier/settings.py +++ b/source/portier/settings.py @@ -41,7 +41,8 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - 'rest_framework', + 'ninja', + #'rest_framework', 'guardian', 'django_registration', 'bootstrap4', @@ -194,7 +195,7 @@ DJANGO_CELERY_BEAT_TZ_AWARE = False DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' -if DEBUG: +if DEBUG == "LOLOLOL": LOGGING = { 'version': 1, 'disable_existing_loggers': False, diff --git a/source/portier/urls.py b/source/portier/urls.py index f6475b7..82c11a5 100644 --- a/source/portier/urls.py +++ b/source/portier/urls.py @@ -1,13 +1,14 @@ from django.contrib import admin from django.urls import include, path +from .api import api + urlpatterns = [ path('accounts/', include('django_registration.backends.activation.urls')), path('accounts/', include('django.contrib.auth.urls')), path('i18n/', include('django.conf.urls.i18n')), path('admin/', admin.site.urls), path('config/', include('config.urls')), - path('concierge/', include('concierge.urls')), - path('api/v1/', include('restapi.urls')), + path('api/v2/', api.urls), path('', include('core.urls')), ] diff --git a/source/requirements.txt b/source/requirements.txt index c801828..e245f72 100644 --- a/source/requirements.txt +++ b/source/requirements.txt @@ -5,8 +5,7 @@ django-guardian django-fontawesome-5 django-celery-beat django-filter -djangorestframework -djangorestframework-guardian +django-ninja celery>=5.3 gunicorn>=20 psycopg2-binary diff --git a/source/restapi/__init__.py b/source/restapi/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/source/restapi/apps.py b/source/restapi/apps.py deleted file mode 100644 index a0a4de1..0000000 --- a/source/restapi/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class RestapiConfig(AppConfig): - name = 'restapi' diff --git a/source/restapi/migrations/__init__.py b/source/restapi/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/source/restapi/urls.py b/source/restapi/urls.py deleted file mode 100644 index d662a42..0000000 --- a/source/restapi/urls.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.urls import path, include -from rest_framework import routers - -from .views import StreamViewSet, RestreamViewSet - - -router = routers.DefaultRouter() -router.register(r'stream', StreamViewSet) -router.register(r'restream', RestreamViewSet) - -app_name = 'restapi' - -urlpatterns = [ - path('', include(router.urls)), - path('auth/', include('rest_framework.urls', namespace='rest_framework')) -] diff --git a/source/restapi/views.py b/source/restapi/views.py deleted file mode 100644 index 9676828..0000000 --- a/source/restapi/views.py +++ /dev/null @@ -1,51 +0,0 @@ -from rest_framework_guardian.serializers import ObjectPermissionsAssignmentMixin -from rest_framework import serializers, viewsets -from rest_framework_guardian import filters -from config.models import Stream, Restream - - -class StreamSerializer(ObjectPermissionsAssignmentMixin, serializers.ModelSerializer): - class Meta: - model = Stream - fields = '__all__' - read_only_fields = ['publish_counter'] - - def get_permissions_map(self, created): - current_user = self.context['request'].user - return { - 'view_stream': [current_user], - 'change_stream': [current_user], - 'delete_stream': [current_user] - } - - -class StreamViewSet(viewsets.ModelViewSet): - queryset = Stream.objects.all() - serializer_class = StreamSerializer - filter_backends = [filters.ObjectPermissionsFilter] - - -class RestreamSerializer(ObjectPermissionsAssignmentMixin, serializers.ModelSerializer): - class Meta: - model = Restream - fields = '__all__' - - def get_permissions_map(self, created): - current_user = self.context['request'].user - return { - 'view_restream': [current_user], - 'change_restream': [current_user], - 'delete_restream': [current_user] - } - - def validate_stream(self, value): - request = self.context['request'] - if not request.user.has_perm('config.view_stream', value): - raise serializers.ValidationError('Access to stream is not authorized') - return value - - -class RestreamViewSet(viewsets.ModelViewSet): - queryset = Restream.objects.all() - serializer_class = RestreamSerializer - filter_backends = [filters.ObjectPermissionsFilter] diff --git a/source/static/js/restream-list.js b/source/static/js/restream-list.js index 0b4259d..f1eda93 100644 --- a/source/static/js/restream-list.js +++ b/source/static/js/restream-list.js @@ -13,7 +13,7 @@ var app = new Vue({ }, toggleActive(cfg) { axios - .patch('/api/v1/restream/' + cfg.id + '/', { active: !cfg.active }) + .patch('/api/v2/config/restreams/' + cfg.id, { active: !cfg.active }) .then(response => { i = this.cfgs.findIndex((obj => obj.id == cfg.id)) Vue.set(this.cfgs, i, response.data) @@ -22,7 +22,7 @@ var app = new Vue({ }, fetchData() { axios - .get('/api/v1/restream/') + .get('/api/v2/config/restreams') .then(response => { this.cfgs = response.data this.isLoading = false diff --git a/source/static/js/stream-list.js b/source/static/js/stream-list.js index 159a2c9..2dfdd6f 100644 --- a/source/static/js/stream-list.js +++ b/source/static/js/stream-list.js @@ -16,7 +16,7 @@ var app = new Vue({ }, fetchData() { axios - .get('/api/v1/stream/') + .get('/api/v2/config/streams') .then(response => { this.streams = response.data this.isLoading = false