added auth, dicom crud

This commit is contained in:
Alexander Karpov 2022-10-26 23:22:04 +03:00
parent 8d9d0697da
commit 4f15a29b07
24 changed files with 239 additions and 142 deletions

View File

@ -3,4 +3,5 @@
<component name="JavaScriptSettings"> <component name="JavaScriptSettings">
<option name="languageLevel" value="ES6" /> <option name="languageLevel" value="ES6" />
</component> </component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10 (lct_backend)" project-jdk-type="Python SDK" />
</project> </project>

View File

@ -1,15 +1,30 @@
from django.conf import settings from dicom.api.views import ListCreateDicomApi, RetrieveUpdateDeleteDicomApi
from rest_framework.routers import DefaultRouter, SimpleRouter 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 urlpatterns = [
path(
if settings.DEBUG: "auth/",
router = DefaultRouter() include(
else: [
router = SimpleRouter() path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
path("refresh/", TokenRefreshView.as_view(), name="token_refresh"),
router.register("users", UserViewSet) path("register/", RegisterView.as_view(), name="user_register"),
]
),
app_name = "api" ),
urlpatterns = router.urls path(
"dicom/",
include(
[
path("", ListCreateDicomApi.as_view(), name="list_create_dicom"),
path(
"<str:slug>",
RetrieveUpdateDeleteDicomApi.as_view(),
name="get_update_delete_dicom",
),
]
),
),
]

View File

@ -10,7 +10,7 @@ ROOT_DIR = Path(__file__).resolve(strict=True).parent.parent.parent
APPS_DIR = ROOT_DIR / "image_markuper" APPS_DIR = ROOT_DIR / "image_markuper"
env = environ.Env() 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: if READ_DOT_ENV_FILE:
# OS environment variables take precedence over variables from .env # OS environment variables take precedence over variables from .env
env.read_env(str(ROOT_DIR / ".env")) env.read_env(str(ROOT_DIR / ".env"))
@ -80,6 +80,7 @@ THIRD_PARTY_APPS = [
LOCAL_APPS = [ LOCAL_APPS = [
"image_markuper.users", "image_markuper.users",
"image_markuper.dicom"
# Your stuff: custom apps go here # Your stuff: custom apps go here
] ]
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps # 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/ # django-rest-framework - https://www.django-rest-framework.org/api-guide/settings/
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": ( "DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework.authentication.SessionAuthentication", "rest_framework_simplejwt.authentication.JWTAuthentication",
"rest_framework.authentication.TokenAuthentication",
), ),
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
@ -311,18 +311,19 @@ REST_FRAMEWORK = {
# django-cors-headers - https://github.com/adamchainz/django-cors-headers#setup # django-cors-headers - https://github.com/adamchainz/django-cors-headers#setup
CORS_URLS_REGEX = r"^/api/.*$" CORS_URLS_REGEX = r"^/api/.*$"
# By Default swagger ui is available only to admin user(s). You can change permission classes to change that # 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 # See more configuration options at https://drf-spectacular.readthedocs.io/en/latest/settings.html#settings
SPECTACULAR_SETTINGS = { SPECTACULAR_SETTINGS = {
"TITLE": "Image markuper API", "TITLE": "Image markuper API",
"DESCRIPTION": "Documentation of API endpoints of Image markuper", "DESCRIPTION": "Documentation of API endpoints of Image markuper",
"VERSION": "1.0.0", "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": [ "SERVERS": [
{"url": "http://127.0.0.1:8000", "description": "Local Development server"}, {"url": "https://dev.akarpov.ru", "description": "Development server"},
{"url": "https://akarpov.ru", "description": "Production server"}, {"url": "https//127.0.0.1:8000", "description": "Development server"},
], ],
} }
# Your stuff...
# ------------------------------------------------------------------------------ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

View File

@ -1,3 +1,5 @@
from datetime import timedelta
from .base import * # noqa from .base import * # noqa
from .base import env from .base import env
@ -11,7 +13,7 @@ SECRET_KEY = env(
default="XgO9NMQfMY5CNeVNh98WrKRXiQZnvtPzHJrF9ROPhAFLVEG1FvDD2ZRKTdJKVu8p", default="XgO9NMQfMY5CNeVNh98WrKRXiQZnvtPzHJrF9ROPhAFLVEG1FvDD2ZRKTdJKVu8p",
) )
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts # 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 # CACHES
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
@ -64,5 +66,9 @@ INSTALLED_APPS += ["django_extensions"] # noqa F405
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-eager-propagates # https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-eager-propagates
CELERY_TASK_EAGER_PROPAGATES = True CELERY_TASK_EAGER_PROPAGATES = True
# Your stuff...
# ------------------------------------------------------------------------------ SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=10000),
}
CORS_ALLOW_ALL_ORIGINS = True

View File

@ -2,21 +2,14 @@ from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from django.views import defaults as default_views from drf_spectacular.views import (
from django.views.generic import TemplateView SpectacularAPIView,
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView SpectacularRedocView,
from rest_framework.authtoken.views import obtain_auth_token SpectacularSwaggerView,
)
urlpatterns = [ urlpatterns = [
path("", TemplateView.as_view(template_name="pages/home.html"), name="home"), path(settings.ADMIN_URL, admin.site.urls)
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")),
# Your stuff: custom urls includes go here # Your stuff: custom urls includes go here
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
@ -24,37 +17,23 @@ urlpatterns = [
urlpatterns += [ urlpatterns += [
# API base url # API base url
path("api/", include("config.api_router")), path("api/", include("config.api_router")),
# DRF auth token path("api_schema/", SpectacularAPIView.as_view(), name="api-schema"),
path("auth-token/", obtain_auth_token), path("api_rschema/", SpectacularAPIView.as_view(), name="api-redoc-schema"),
path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"),
path( path(
"api/docs/", "api/docs/",
SpectacularSwaggerView.as_view(url_name="api-schema"), 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: if settings.DEBUG:
# This allows the error pages to be debugged during development, just visit # This allows the error pages to be debugged during development, just visit
# these url in browser to see how these error pages look like. # 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: if "debug_toolbar" in settings.INSTALLED_APPS:
import debug_toolbar import debug_toolbar

View File

View File

View File

View File

@ -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"]

View File

@ -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"

View File

@ -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

View File

@ -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)),
],
),
]

View File

@ -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

View File

@ -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

View File

@ -15,7 +15,6 @@ class UserAdmin(auth_admin.UserAdmin):
add_form = UserAdminCreationForm add_form = UserAdminCreationForm
fieldsets = ( fieldsets = (
(None, {"fields": ("username", "password")}), (None, {"fields": ("username", "password")}),
(_("Personal info"), {"fields": ("name", "email")}),
( (
_("Permissions"), _("Permissions"),
{ {
@ -30,5 +29,5 @@ class UserAdmin(auth_admin.UserAdmin):
), ),
(_("Important dates"), {"fields": ("last_login", "date_joined")}), (_("Important dates"), {"fields": ("last_login", "date_joined")}),
) )
list_display = ["username", "name", "is_superuser"] list_display = ["username", "is_superuser"]
search_fields = ["name"] search_fields = ["username"]

View File

@ -7,8 +7,28 @@ User = get_user_model()
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = User model = User
fields = ["username", "name", "url"] fields = ["username", "url"]
extra_kwargs = { extra_kwargs = {
"url": {"view_name": "api:user-detail", "lookup_field": "username"} "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

View File

@ -1,11 +1,12 @@
from django.contrib.auth import get_user_model 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.decorators import action
from rest_framework.mixins import ListModelMixin, RetrieveModelMixin, UpdateModelMixin from rest_framework.mixins import ListModelMixin, RetrieveModelMixin, UpdateModelMixin
from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet from rest_framework.viewsets import GenericViewSet
from .serializers import UserSerializer from .serializers import RegisterSerializer, UserSerializer
User = get_user_model() User = get_user_model()
@ -16,10 +17,15 @@ class UserViewSet(RetrieveModelMixin, ListModelMixin, UpdateModelMixin, GenericV
lookup_field = "username" lookup_field = "username"
def get_queryset(self, *args, **kwargs): def get_queryset(self, *args, **kwargs):
assert isinstance(self.request.user.id, int)
return self.queryset.filter(id=self.request.user.id) return self.queryset.filter(id=self.request.user.id)
@action(detail=False) @action(detail=False)
def me(self, request): def me(self, request):
serializer = UserSerializer(request.user, context={"request": request}) serializer = UserSerializer(request.user, context={"request": request})
return Response(status=status.HTTP_200_OK, data=serializer.data) return Response(status=status.HTTP_200_OK, data=serializer.data)
class RegisterView(generics.CreateAPIView):
queryset = User.objects.all()
permission_classes = (AllowAny,)
serializer_class = RegisterSerializer

View File

@ -1,7 +1,5 @@
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db.models import CharField
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _
class User(AbstractUser): class User(AbstractUser):
@ -11,10 +9,9 @@ class User(AbstractUser):
check forms.SignupForm and forms.SocialSignupForms accordingly. 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 first_name = None # type: ignore
last_name = None # type: ignore last_name = None # type: ignore
email = None # type: ignore
def get_absolute_url(self): def get_absolute_url(self):
"""Get url for user's detail view. """Get url for user's detail view.

View File

@ -25,9 +25,6 @@ class UserUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
success_message = _("Information successfully updated") success_message = _("Information successfully updated")
def get_success_url(self): 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() return self.request.user.get_absolute_url()
def get_object(self): def get_object(self):

View File

@ -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)

View File

@ -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))

View File

@ -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()

View File

@ -21,5 +21,6 @@ django-redis==5.2.0 # https://github.com/jazzband/django-redis
# Django REST Framework # Django REST Framework
djangorestframework==3.14.0 # https://github.com/encode/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 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 for api documentation
drf-spectacular==0.24.2 # https://github.com/tfranzel/drf-spectacular drf-spectacular==0.24.2 # https://github.com/tfranzel/drf-spectacular