From 4f15a29b07ccad914cad3ecd1fe014b371d3a5c6 Mon Sep 17 00:00:00 2001 From: Alexandr Karpov Date: Wed, 26 Oct 2022 23:22:04 +0300 Subject: [PATCH] added auth, dicom crud --- .idea/misc.xml | 1 + config/api_router.py | 43 ++++++++---- config/settings/base.py | 19 +++--- config/settings/local.py | 12 +++- config/urls.py | 49 ++++---------- image_markuper/dicom/__init__.py | 0 image_markuper/dicom/admin.py | 0 image_markuper/dicom/api/__init__.py | 0 image_markuper/dicom/api/serializers.py | 24 +++++++ image_markuper/dicom/api/views.py | 36 ++++++++++ image_markuper/dicom/apps.py | 12 ++++ .../dicom/migrations/0001_initial.py | 28 ++++++++ image_markuper/dicom/migrations/__init__.py | 0 image_markuper/dicom/models.py | 16 +++++ image_markuper/dicom/signals.py | 12 ++++ image_markuper/users/admin.py | 5 +- image_markuper/users/api/serializers.py | 22 +++++- image_markuper/users/api/views.py | 12 +++- image_markuper/users/models.py | 5 +- image_markuper/users/views.py | 3 - image_markuper/utils/files.py | 7 ++ image_markuper/utils/generators.py | 7 ++ merge_production_dotenvs_in_dotenv.py | 67 ------------------- requirements/base.txt | 1 + 24 files changed, 239 insertions(+), 142 deletions(-) create mode 100644 image_markuper/dicom/__init__.py create mode 100644 image_markuper/dicom/admin.py create mode 100644 image_markuper/dicom/api/__init__.py create mode 100644 image_markuper/dicom/api/serializers.py create mode 100644 image_markuper/dicom/api/views.py create mode 100644 image_markuper/dicom/apps.py create mode 100644 image_markuper/dicom/migrations/0001_initial.py create mode 100644 image_markuper/dicom/migrations/__init__.py create mode 100644 image_markuper/dicom/models.py create mode 100644 image_markuper/dicom/signals.py create mode 100644 image_markuper/utils/files.py create mode 100644 image_markuper/utils/generators.py delete mode 100644 merge_production_dotenvs_in_dotenv.py diff --git a/.idea/misc.xml b/.idea/misc.xml index 10af178..a970995 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,4 +3,5 @@ + diff --git a/config/api_router.py b/config/api_router.py index 1633bdb..ce8228d 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -1,15 +1,30 @@ -from django.conf import settings -from rest_framework.routers import DefaultRouter, SimpleRouter +from dicom.api.views import ListCreateDicomApi, RetrieveUpdateDeleteDicomApi +from django.urls import include, path +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView +from users.api.views import RegisterView -from image_markuper.users.api.views import UserViewSet - -if settings.DEBUG: - router = DefaultRouter() -else: - router = SimpleRouter() - -router.register("users", UserViewSet) - - -app_name = "api" -urlpatterns = router.urls +urlpatterns = [ + path( + "auth/", + include( + [ + path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), + path("refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("register/", RegisterView.as_view(), name="user_register"), + ] + ), + ), + path( + "dicom/", + include( + [ + path("", ListCreateDicomApi.as_view(), name="list_create_dicom"), + path( + "", + RetrieveUpdateDeleteDicomApi.as_view(), + name="get_update_delete_dicom", + ), + ] + ), + ), +] diff --git a/config/settings/base.py b/config/settings/base.py index 4c4d7c2..10904c6 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -10,7 +10,7 @@ ROOT_DIR = Path(__file__).resolve(strict=True).parent.parent.parent APPS_DIR = ROOT_DIR / "image_markuper" env = environ.Env() -READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=False) +READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=True) if READ_DOT_ENV_FILE: # OS environment variables take precedence over variables from .env env.read_env(str(ROOT_DIR / ".env")) @@ -80,6 +80,7 @@ THIRD_PARTY_APPS = [ LOCAL_APPS = [ "image_markuper.users", + "image_markuper.dicom" # Your stuff: custom apps go here ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps @@ -302,8 +303,7 @@ SOCIALACCOUNT_FORMS = {"signup": "image_markuper.users.forms.UserSocialSignupFor # django-rest-framework - https://www.django-rest-framework.org/api-guide/settings/ REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( - "rest_framework.authentication.SessionAuthentication", - "rest_framework.authentication.TokenAuthentication", + "rest_framework_simplejwt.authentication.JWTAuthentication", ), "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", @@ -311,18 +311,19 @@ REST_FRAMEWORK = { # django-cors-headers - https://github.com/adamchainz/django-cors-headers#setup CORS_URLS_REGEX = r"^/api/.*$" - # By Default swagger ui is available only to admin user(s). You can change permission classes to change that # See more configuration options at https://drf-spectacular.readthedocs.io/en/latest/settings.html#settings SPECTACULAR_SETTINGS = { "TITLE": "Image markuper API", "DESCRIPTION": "Documentation of API endpoints of Image markuper", "VERSION": "1.0.0", - "SERVE_PERMISSIONS": ["rest_framework.permissions.IsAdminUser"], + "SCHEMA_PATH_PREFIX": r"/api/", + "SERVE_INCLUDE_SCHEMA": False, + "SERVE_PERMISSIONS": ["rest_framework.permissions.AllowAny"], "SERVERS": [ - {"url": "http://127.0.0.1:8000", "description": "Local Development server"}, - {"url": "https://akarpov.ru", "description": "Production server"}, + {"url": "https://dev.akarpov.ru", "description": "Development server"}, + {"url": "https//127.0.0.1:8000", "description": "Development server"}, ], } -# Your stuff... -# ------------------------------------------------------------------------------ + +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") diff --git a/config/settings/local.py b/config/settings/local.py index 559abc1..b4cd5a0 100644 --- a/config/settings/local.py +++ b/config/settings/local.py @@ -1,3 +1,5 @@ +from datetime import timedelta + from .base import * # noqa from .base import env @@ -11,7 +13,7 @@ SECRET_KEY = env( default="XgO9NMQfMY5CNeVNh98WrKRXiQZnvtPzHJrF9ROPhAFLVEG1FvDD2ZRKTdJKVu8p", ) # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts -ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1"] +ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1", "dev.akarpov.ru"] # CACHES # ------------------------------------------------------------------------------ @@ -64,5 +66,9 @@ INSTALLED_APPS += ["django_extensions"] # noqa F405 # https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-eager-propagates CELERY_TASK_EAGER_PROPAGATES = True -# Your stuff... -# ------------------------------------------------------------------------------ + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=10000), +} + +CORS_ALLOW_ALL_ORIGINS = True diff --git a/config/urls.py b/config/urls.py index f4538df..150f240 100644 --- a/config/urls.py +++ b/config/urls.py @@ -2,21 +2,14 @@ from django.conf import settings from django.conf.urls.static import static from django.contrib import admin from django.urls import include, path -from django.views import defaults as default_views -from django.views.generic import TemplateView -from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView -from rest_framework.authtoken.views import obtain_auth_token +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) urlpatterns = [ - path("", TemplateView.as_view(template_name="pages/home.html"), name="home"), - path( - "about/", TemplateView.as_view(template_name="pages/about.html"), name="about" - ), - # Django Admin, use {% url 'admin:index' %} - path(settings.ADMIN_URL, admin.site.urls), - # User management - path("users/", include("image_markuper.users.urls", namespace="users")), - path("accounts/", include("allauth.urls")), + path(settings.ADMIN_URL, admin.site.urls) # Your stuff: custom urls includes go here ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) @@ -24,37 +17,23 @@ urlpatterns = [ urlpatterns += [ # API base url path("api/", include("config.api_router")), - # DRF auth token - path("auth-token/", obtain_auth_token), - path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"), + path("api_schema/", SpectacularAPIView.as_view(), name="api-schema"), + path("api_rschema/", SpectacularAPIView.as_view(), name="api-redoc-schema"), path( "api/docs/", SpectacularSwaggerView.as_view(url_name="api-schema"), - name="api-docs", + name="home", + ), + path( + "api/redoc/", + SpectacularRedocView.as_view(url_name="api-redoc-schema"), + name="home", ), ] if settings.DEBUG: # This allows the error pages to be debugged during development, just visit # these url in browser to see how these error pages look like. - urlpatterns += [ - path( - "400/", - default_views.bad_request, - kwargs={"exception": Exception("Bad Request!")}, - ), - path( - "403/", - default_views.permission_denied, - kwargs={"exception": Exception("Permission Denied")}, - ), - path( - "404/", - default_views.page_not_found, - kwargs={"exception": Exception("Page not Found")}, - ), - path("500/", default_views.server_error), - ] if "debug_toolbar" in settings.INSTALLED_APPS: import debug_toolbar diff --git a/image_markuper/dicom/__init__.py b/image_markuper/dicom/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/image_markuper/dicom/admin.py b/image_markuper/dicom/admin.py new file mode 100644 index 0000000..e69de29 diff --git a/image_markuper/dicom/api/__init__.py b/image_markuper/dicom/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/image_markuper/dicom/api/serializers.py b/image_markuper/dicom/api/serializers.py new file mode 100644 index 0000000..6164c58 --- /dev/null +++ b/image_markuper/dicom/api/serializers.py @@ -0,0 +1,24 @@ +from dicom.models import Dicom +from rest_framework import serializers + + +class ListDicomSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name="get_update_delete_dicom", lookup_field="slug" + ) + file = serializers.FileField() + + class Meta: + model = Dicom + fields = ["file", "uploaded", "url"] + + def create(self, validated_data): + return Dicom.objects.create(**validated_data, user=self.context["request"].user) + + +class DicomSerializer(serializers.ModelSerializer): + file = serializers.FileField() + + class Meta: + model = Dicom + fields = ["file", "uploaded"] diff --git a/image_markuper/dicom/api/views.py b/image_markuper/dicom/api/views.py new file mode 100644 index 0000000..9c248b1 --- /dev/null +++ b/image_markuper/dicom/api/views.py @@ -0,0 +1,36 @@ +from drf_spectacular.utils import extend_schema +from rest_framework import generics +from rest_framework.parsers import FormParser, MultiPartParser + +from ..models import Dicom +from .serializers import DicomSerializer, ListDicomSerializer + + +class ListCreateDicomApi(generics.ListCreateAPIView): + serializer_class = ListDicomSerializer + parser_classes = [MultiPartParser, FormParser] + + def get_queryset(self): + return Dicom.objects.filter(user=self.request.user) + + @extend_schema( + operation_id="upload_file", + request={ + "multipart/form-data": { + "type": "object", + "properties": {"file": {"type": "string", "format": "binary"}}, + } + }, + ) + def post(self, request, *args, **kwargs): + return self.create(request, *args, **kwargs) + + +class RetrieveUpdateDeleteDicomApi(generics.RetrieveUpdateDestroyAPIView): + def get_queryset(self): + return Dicom.objects.filter(user=self.request.user) + + serializer_class = DicomSerializer + parser_classes = [MultiPartParser, FormParser] + + lookup_field = "slug" diff --git a/image_markuper/dicom/apps.py b/image_markuper/dicom/apps.py new file mode 100644 index 0000000..b2643f8 --- /dev/null +++ b/image_markuper/dicom/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class DicomConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "dicom" + + def ready(self): + try: + import image_markuper.dicom.signals # noqa F401 + except ImportError: + pass diff --git a/image_markuper/dicom/migrations/0001_initial.py b/image_markuper/dicom/migrations/0001_initial.py new file mode 100644 index 0000000..5c9370b --- /dev/null +++ b/image_markuper/dicom/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 4.0.8 on 2022-10-26 15:01 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import utils.files + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Dicom', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('slug', models.SlugField()), + ('file', models.FileField(upload_to=utils.files.media_upload_path)), + ('uploaded', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/image_markuper/dicom/migrations/__init__.py b/image_markuper/dicom/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/image_markuper/dicom/models.py b/image_markuper/dicom/models.py new file mode 100644 index 0000000..315d5b3 --- /dev/null +++ b/image_markuper/dicom/models.py @@ -0,0 +1,16 @@ +from django.contrib.auth import get_user_model +from django.db import models +from utils.files import media_upload_path + +User = get_user_model() + + +class Dicom(models.Model): + user = models.ForeignKey(User, related_name="files", on_delete=models.CASCADE) + slug = models.SlugField() + + file = models.FileField(upload_to=media_upload_path) + uploaded = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.file.name diff --git a/image_markuper/dicom/signals.py b/image_markuper/dicom/signals.py new file mode 100644 index 0000000..2f138e9 --- /dev/null +++ b/image_markuper/dicom/signals.py @@ -0,0 +1,12 @@ +from dicom.models import Dicom +from django.db.models.signals import pre_save +from django.dispatch import receiver +from utils.generators import generate_charset + + +@receiver(pre_save, sender=Dicom) +def create_dicom(sender, instance: Dicom, **kwargs): + slug = generate_charset(5) + while Dicom.objects.filter(slug=slug): + slug = generate_charset(5) + instance.slug = slug diff --git a/image_markuper/users/admin.py b/image_markuper/users/admin.py index 10e5a31..d2d6698 100644 --- a/image_markuper/users/admin.py +++ b/image_markuper/users/admin.py @@ -15,7 +15,6 @@ class UserAdmin(auth_admin.UserAdmin): add_form = UserAdminCreationForm fieldsets = ( (None, {"fields": ("username", "password")}), - (_("Personal info"), {"fields": ("name", "email")}), ( _("Permissions"), { @@ -30,5 +29,5 @@ class UserAdmin(auth_admin.UserAdmin): ), (_("Important dates"), {"fields": ("last_login", "date_joined")}), ) - list_display = ["username", "name", "is_superuser"] - search_fields = ["name"] + list_display = ["username", "is_superuser"] + search_fields = ["username"] diff --git a/image_markuper/users/api/serializers.py b/image_markuper/users/api/serializers.py index b5ccabb..e6ee9d6 100644 --- a/image_markuper/users/api/serializers.py +++ b/image_markuper/users/api/serializers.py @@ -7,8 +7,28 @@ User = get_user_model() class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ["username", "name", "url"] + fields = ["username", "url"] extra_kwargs = { "url": {"view_name": "api:user-detail", "lookup_field": "username"} } + + +class RegisterSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ( + "username", + "password", + ) + extra_kwargs = {"password": {"write_only": True}} + + def create(self, validated_data): + user = User.objects.create( + username=validated_data["username"], + ) + + user.set_password(validated_data["password"]) + user.save() + + return user diff --git a/image_markuper/users/api/views.py b/image_markuper/users/api/views.py index 98bb04e..afa7569 100644 --- a/image_markuper/users/api/views.py +++ b/image_markuper/users/api/views.py @@ -1,11 +1,12 @@ from django.contrib.auth import get_user_model -from rest_framework import status +from rest_framework import generics, status from rest_framework.decorators import action from rest_framework.mixins import ListModelMixin, RetrieveModelMixin, UpdateModelMixin +from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet -from .serializers import UserSerializer +from .serializers import RegisterSerializer, UserSerializer User = get_user_model() @@ -16,10 +17,15 @@ class UserViewSet(RetrieveModelMixin, ListModelMixin, UpdateModelMixin, GenericV lookup_field = "username" def get_queryset(self, *args, **kwargs): - assert isinstance(self.request.user.id, int) return self.queryset.filter(id=self.request.user.id) @action(detail=False) def me(self, request): serializer = UserSerializer(request.user, context={"request": request}) return Response(status=status.HTTP_200_OK, data=serializer.data) + + +class RegisterView(generics.CreateAPIView): + queryset = User.objects.all() + permission_classes = (AllowAny,) + serializer_class = RegisterSerializer diff --git a/image_markuper/users/models.py b/image_markuper/users/models.py index 6898a0f..6557eb2 100644 --- a/image_markuper/users/models.py +++ b/image_markuper/users/models.py @@ -1,7 +1,5 @@ from django.contrib.auth.models import AbstractUser -from django.db.models import CharField from django.urls import reverse -from django.utils.translation import gettext_lazy as _ class User(AbstractUser): @@ -11,10 +9,9 @@ class User(AbstractUser): check forms.SignupForm and forms.SocialSignupForms accordingly. """ - #: First and last name do not cover name patterns around the globe - name = CharField(_("Name of User"), blank=True, max_length=255) first_name = None # type: ignore last_name = None # type: ignore + email = None # type: ignore def get_absolute_url(self): """Get url for user's detail view. diff --git a/image_markuper/users/views.py b/image_markuper/users/views.py index baa04a0..cc31b1e 100644 --- a/image_markuper/users/views.py +++ b/image_markuper/users/views.py @@ -25,9 +25,6 @@ class UserUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): success_message = _("Information successfully updated") def get_success_url(self): - assert ( - self.request.user.is_authenticated - ) # for mypy to know that the user is authenticated return self.request.user.get_absolute_url() def get_object(self): diff --git a/image_markuper/utils/files.py b/image_markuper/utils/files.py new file mode 100644 index 0000000..af79808 --- /dev/null +++ b/image_markuper/utils/files.py @@ -0,0 +1,7 @@ +import os + +from utils.generators import generate_charset + + +def media_upload_path(instance, filename): + return os.path.join(f"uploads/dicom/{generate_charset(7)}/", filename) diff --git a/image_markuper/utils/generators.py b/image_markuper/utils/generators.py new file mode 100644 index 0000000..eeebfb8 --- /dev/null +++ b/image_markuper/utils/generators.py @@ -0,0 +1,7 @@ +import random +import string + + +def generate_charset(length: int) -> str: + """Generate a random string of characters of a given length.""" + return "".join(random.choice(string.ascii_letters) for _ in range(length)) diff --git a/merge_production_dotenvs_in_dotenv.py b/merge_production_dotenvs_in_dotenv.py deleted file mode 100644 index d702a5f..0000000 --- a/merge_production_dotenvs_in_dotenv.py +++ /dev/null @@ -1,67 +0,0 @@ -import os -from collections.abc import Sequence -from pathlib import Path - -import pytest - -ROOT_DIR_PATH = Path(__file__).parent.resolve() -PRODUCTION_DOTENVS_DIR_PATH = ROOT_DIR_PATH / ".envs" / ".production" -PRODUCTION_DOTENV_FILE_PATHS = [ - PRODUCTION_DOTENVS_DIR_PATH / ".django", - PRODUCTION_DOTENVS_DIR_PATH / ".postgres", -] -DOTENV_FILE_PATH = ROOT_DIR_PATH / ".env" - - -def merge( - output_file_path: str, merged_file_paths: Sequence[str], append_linesep: bool = True -) -> None: - with open(output_file_path, "w") as output_file: - for merged_file_path in merged_file_paths: - with open(merged_file_path) as merged_file: - merged_file_content = merged_file.read() - output_file.write(merged_file_content) - if append_linesep: - output_file.write(os.linesep) - - -def main(): - merge(DOTENV_FILE_PATH, PRODUCTION_DOTENV_FILE_PATHS) - - -@pytest.mark.parametrize("merged_file_count", range(3)) -@pytest.mark.parametrize("append_linesep", [True, False]) -def test_merge(tmpdir_factory, merged_file_count: int, append_linesep: bool): - tmp_dir_path = Path(str(tmpdir_factory.getbasetemp())) - - output_file_path = tmp_dir_path / ".env" - - expected_output_file_content = "" - merged_file_paths = [] - for i in range(merged_file_count): - merged_file_ord = i + 1 - - merged_filename = f".service{merged_file_ord}" - merged_file_path = tmp_dir_path / merged_filename - - merged_file_content = merged_filename * merged_file_ord - - with open(merged_file_path, "w+") as file: - file.write(merged_file_content) - - expected_output_file_content += merged_file_content - if append_linesep: - expected_output_file_content += os.linesep - - merged_file_paths.append(merged_file_path) - - merge(output_file_path, merged_file_paths, append_linesep) - - with open(output_file_path) as output_file: - actual_output_file_content = output_file.read() - - assert actual_output_file_content == expected_output_file_content - - -if __name__ == "__main__": - main() diff --git a/requirements/base.txt b/requirements/base.txt index ce9009a..531a40d 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -21,5 +21,6 @@ django-redis==5.2.0 # https://github.com/jazzband/django-redis # Django REST Framework djangorestframework==3.14.0 # https://github.com/encode/django-rest-framework django-cors-headers==3.13.0 # https://github.com/adamchainz/django-cors-headers +djangorestframework-simplejwt==5.2.2 # DRF-spectacular for api documentation drf-spectacular==0.24.2 # https://github.com/tfranzel/drf-spectacular