Browse Source

feat: configured Swagger UI and added some api helpers

Alex Gheorghita 1 month ago
parent
commit
174861acf9

+ 8 - 2
account/admin.py

@@ -1,7 +1,8 @@
 from django.contrib import admin
-from django.utils.translation import gettext_lazy as _
 from django.contrib.auth.admin import UserAdmin as UA
-from .models import User
+from django.utils.translation import gettext_lazy as _
+
+from .models import User, UserInstitution
 
 
 @admin.register(User)
@@ -35,3 +36,8 @@ class UserAdmin(UA):
     list_display = ("email", "first_name", "last_name", "is_staff")
     ordering = ("-is_staff",)
     readonly_fields = ("last_login", "date_joined")
+
+
+@admin.register(UserInstitution)
+class UserInstitutionAdmin(admin.ModelAdmin):
+    pass

+ 59 - 0
account/apis.py

@@ -0,0 +1,59 @@
+from drf_spectacular.utils import extend_schema
+from rest_framework import serializers
+from rest_framework.views import APIView, Response
+
+from account import selectors, services
+
+
+class GetUsersView(APIView):
+    class OutputSerializer(serializers.Serializer):
+        id = serializers.IntegerField()
+        email = serializers.EmailField()
+        is_staff = serializers.BooleanField()
+        is_active = serializers.BooleanField()
+
+    class FilterSerializer(serializers.Serializer):
+        name = serializers.CharField(required=False)
+
+    @extend_schema(responses=OutputSerializer, parameters=[FilterSerializer])
+    def get(self, request):
+        users = selectors.get_users(self.FilterSerializer(request.query_params).data)
+
+        return Response(self.OutputSerializer(users, many=True).data)
+
+
+get_users_view = GetUsersView.as_view()
+
+
+class AddUserView(APIView):
+    class InputSerializer(serializers.Serializer):
+        first_name = serializers.CharField()
+        last_name = serializers.CharField()
+        email = serializers.EmailField()
+        is_staff = serializers.BooleanField()
+        institution_id = serializers.CharField()
+
+    class OutputSerializer(serializers.Serializer):
+        id = serializers.IntegerField()
+        email = serializers.EmailField()
+        is_staff = serializers.BooleanField()
+        is_active = serializers.BooleanField()
+
+    serializer_class = InputSerializer
+
+    def post(self, request):
+        serz = self.InputSerializer(data=request.data)
+        serz.is_valid(raise_exception=True)
+
+        user = services.add_user(
+            first_name=serz.validated_data["first_name"],  # type: ignore
+            last_name=serz.validated_data["last_name"],  # type: ignore
+            email=serz.validated_data["email"],  # type: ignore
+            is_staff=serz.validated_data["is_staff"],  # type: ignore
+            institution_id=serz.validated_data["institution_id"],  # type: ignore
+        )
+
+        return Response(self.OutputSerializer(user).data)
+
+
+add_user_view = AddUserView.as_view()

+ 2 - 0
account/exceptions.py

@@ -0,0 +1,2 @@
+class AccountServiceException(Exception):
+    pass

+ 32 - 0
account/migrations/0002_userinstitution_user_institution.py

@@ -0,0 +1,32 @@
+# Generated by Django 5.1.1 on 2024-10-01 11:00
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("account", "0001_initial"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="UserInstitution",
+            fields=[
+                ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+                ("name", models.CharField(max_length=255)),
+            ],
+        ),
+        migrations.AddField(
+            model_name="user",
+            name="institution",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="users",
+                to="account.userinstitution",
+            ),
+        ),
+    ]

+ 14 - 2
account/models.py

@@ -5,6 +5,15 @@ from django.utils import timezone
 from .managers import UserManager
 
 
+class UserInstitution(models.Model):
+    name = models.CharField(max_length=255)
+
+    class Meta:
+        verbose_name = _("User institution")
+        verbose_name_plural = _("User institutions")
+
+
+
 class User(AbstractBaseUser, PermissionsMixin):
     first_name = models.CharField(_("first name"), max_length=150, blank=True)
     last_name = models.CharField(_("last name"), max_length=150, blank=True)
@@ -22,11 +31,14 @@ class User(AbstractBaseUser, PermissionsMixin):
         ),
     )
     date_joined = models.DateTimeField(_("date joined"), default=timezone.now)
+    institution = models.ForeignKey(
+        UserInstitution, on_delete=models.CASCADE, related_name="users", null=True, blank=True
+    )
 
     USERNAME_FIELD = "email"
 
     objects = UserManager()  # Here
 
     class Meta:
-        verbose_name = _("user")
-        verbose_name_plural = _("users")
+        verbose_name = _("User")
+        verbose_name_plural = _("Users")

+ 13 - 0
account/selectors.py

@@ -0,0 +1,13 @@
+from account.models import User
+import django_filters
+
+
+class UserFilter(django_filters.FilterSet):
+    email = django_filters.CharFilter(lookup_expr="icontains")
+
+
+def get_users(filters=None):
+    filters = filters or {}
+    qs = User.objects.all()
+
+    return UserFilter(filters, queryset=qs).qs

+ 26 - 0
account/services.py

@@ -0,0 +1,26 @@
+from django.core.exceptions import ValidationError
+from account.models import User, UserInstitution
+
+
+
+def add_user(first_name: str, last_name: str,  email: str, is_staff: bool, institution_id: str) -> User:
+    try:
+        institution = UserInstitution.objects.get(id=institution_id)
+    except UserInstitution.DoesNotExist:
+        raise AccountServiceException("Institution does not exist")
+    
+    user = User()
+    user.first_name = first_name
+    user.last_name = last_name
+    user.email = email
+    user.is_staff = is_staff
+    user.institution = institution
+
+    try:
+        user.full_clean()
+    except ValidationError as e:
+        raise AccountServiceException(str(e))
+
+    user.save()
+
+    return user

+ 8 - 0
account/urls.py

@@ -0,0 +1,8 @@
+from django.urls import path
+
+from account import apis
+
+urlpatterns = [
+    path("get/", apis.get_users_view, name="get_users"),
+    path("add/", apis.add_user_view, name="get_users"),
+]

+ 0 - 0
api/__init__.py


+ 50 - 0
api/exception_handlers.py

@@ -0,0 +1,50 @@
+from django.core.exceptions import PermissionDenied
+from django.core.exceptions import ValidationError as DjangoValidationError
+from django.http import Http404
+from rest_framework import exceptions
+from rest_framework.response import Response
+from rest_framework.serializers import as_serializer_error
+from rest_framework.views import exception_handler
+
+from account.exceptions import AccountServiceException
+
+
+def custom_exception_handler(exc, ctx):
+    """
+    {
+        "message": "Error message",
+        "extra": {}
+    }
+    """
+    if isinstance(exc, DjangoValidationError):
+        exc = exceptions.ValidationError(as_serializer_error(exc))
+
+    if isinstance(exc, Http404):
+        exc = exceptions.NotFound()
+
+    if isinstance(exc, PermissionDenied):
+        exc = exceptions.PermissionDenied()
+
+    response = exception_handler(exc, ctx)
+
+    # If unexpected error occurs (server error, etc.)
+    if response is None:
+        if isinstance(exc, AccountServiceException):
+            data = {"message": exc.message, "extra": exc.extra}
+            return Response(data, status=400)
+
+        return response
+
+    if isinstance(exc.detail, (list, dict)):
+        response.data = {"detail": response.data}
+
+    if isinstance(exc, exceptions.ValidationError):
+        response.data["message"] = "Validation error"
+        response.data["extra"] = {"fields": response.data["detail"]}
+    else:
+        response.data["message"] = response.data["detail"]
+        response.data["extra"] = {}
+
+    del response.data["detail"]
+
+    return response

+ 53 - 0
api/pagination.py

@@ -0,0 +1,53 @@
+from collections import OrderedDict
+
+from rest_framework.pagination import LimitOffsetPagination as _LimitOffsetPagination
+from rest_framework.response import Response
+
+
+def get_paginated_response(*, pagination_class, serializer_class, queryset, request, view):
+    paginator = pagination_class()
+
+    page = paginator.paginate_queryset(queryset, request, view=view)
+
+    if page is not None:
+        serializer = serializer_class(page, many=True)
+        return paginator.get_paginated_response(serializer.data)
+
+    serializer = serializer_class(queryset, many=True)
+
+    return Response(data=serializer.data)
+
+
+class LimitOffsetPagination(_LimitOffsetPagination):
+    default_limit = 10
+    max_limit = 50
+
+    def get_paginated_data(self, data):
+        return OrderedDict(
+            [
+                ("limit", self.limit),
+                ("offset", self.offset),
+                ("count", self.count),
+                ("next", self.get_next_link()),
+                ("previous", self.get_previous_link()),
+                ("results", data),
+            ]
+        )
+
+    def get_paginated_response(self, data):
+        """
+        We redefine this method in order to return `limit` and `offset`.
+        This is used by the frontend to construct the pagination itself.
+        """
+        return Response(
+            OrderedDict(
+                [
+                    ("limit", self.limit),
+                    ("offset", self.offset),
+                    ("count", self.count),
+                    ("next", self.get_next_link()),
+                    ("previous", self.get_previous_link()),
+                    ("results", data),
+                ]
+            )
+        )

+ 18 - 0
api/utils.py

@@ -0,0 +1,18 @@
+from rest_framework import serializers
+
+
+def create_serializer_class(name, fields):
+    return type(name, (serializers.Serializer,), fields)
+
+
+def inline_serializer(*, fields, data=None, **kwargs):
+    # Important note if you are using `drf-spectacular`
+    # Please refer to the following issue:
+    # https://github.com/HackSoftware/Django-Styleguide/issues/105#issuecomment-1669468898
+    # Since you might need to use unique names (uuids) for each inline serializer
+    serializer_class = create_serializer_class(name="inline_serializer", fields=fields)
+
+    if data is not None:
+        return serializer_class(data=data, **kwargs)
+
+    return serializer_class(**kwargs)

+ 1 - 0
core/urls.py

@@ -0,0 +1 @@
+urlpatterns = []

BIN
db.sqlite3


+ 16 - 0
pnim/settings.py

@@ -39,6 +39,8 @@ INSTALLED_APPS = [
     "django.contrib.staticfiles",
     "account",
     "core",
+    "rest_framework",
+    "drf_spectacular",
 ]
 
 MIDDLEWARE = [
@@ -125,3 +127,17 @@ STATIC_URL = "static/"
 # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
 
 DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
+
+REST_FRAMEWORK = {
+    "EXCEPTION_HANDLER": "api.exception_handlers.custom_exception_handler",
+    "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"],
+    "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
+}
+
+SPECTACULAR_SETTINGS = {
+    "TITLE": "PNIM API",
+    "DESCRIPTION": "API for PNIM project",
+    "VERSION": "1.0.0",
+    "SERVE_INCLUDE_SCHEMA": False,
+    "SCHEMA_PATH_PREFIX": "/openapi/",
+}

+ 12 - 1
pnim/urls.py

@@ -14,9 +14,20 @@ Including another URLconf
     1. Import the include() function: from django.urls import include, path
     2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
 """
+
 from django.contrib import admin
-from django.urls import path
+from django.urls import include, path
+from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
+
+schema_url_patterns = [
+    path("user/", include("account.urls")),
+    path("core/", include("core.urls")),
+]
 
 urlpatterns = [
     path("admin/", admin.site.urls),
+
+    path("api/", include(schema_url_patterns)),
+    path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
+    path("api/schema/swagger-ui/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
 ]

+ 8 - 0
pyproject.toml

@@ -0,0 +1,8 @@
+[tool.black]
+line-length = 119
+
+[tool.isort]
+profile = "black"
+
+[flake8]
+max-line-length = 119

+ 8 - 0
requirements.dev.txt

@@ -0,0 +1,8 @@
+black
+flake8
+pytest
+isort
+pyright
+django-types
+
+-r requirements.txt

+ 7 - 0
requirements.txt

@@ -0,0 +1,7 @@
+Django==5.1.1
+djangorestframework==3.15.2
+djnago-filter==24.3
+pyyaml==6.0.2
+inflection==0.5.1
+uritemplate==4.1.1
+drf-spectacular==0.27.2