From fdaf12582230eae16829592041fe8bda0a14a193 Mon Sep 17 00:00:00 2001 From: Tiko Kobiashvili Date: Sat, 27 Jan 2024 21:38:43 +0100 Subject: [PATCH 1/9] started implementing ECG monitorin app --- .gitignore | 161 +++++++++++++++++++ docker-compose.yml | 37 +++++ ecg/Dockerfile | 15 ++ ecg/__init__.py | 0 ecg/app/connector/__init__.py | 0 ecg/app/connector/migrations/0001_initial.py | 33 ++++ ecg/app/connector/migrations/__init__.py | 0 ecg/app/connector/models.py | 20 +++ ecg/app/connector/operations.py | 0 ecg/app/connector/serializers.py | 30 ++++ ecg/app/connector/tests/__init__.py | 0 ecg/app/connector/tests/test_models.py | 0 ecg/app/connector/tests/test_operations.py | 0 ecg/app/connector/tests/test_serializers.py | 0 ecg/app/connector/tests/test_urls.py | 0 ecg/app/connector/tests/test_views.py | 0 ecg/app/connector/urls.py | 11 ++ ecg/app/connector/views.py | 38 +++++ ecg/app/ecg/__init__.py | 0 ecg/app/ecg/asgi.py | 16 ++ ecg/app/ecg/settings.py | 150 +++++++++++++++++ ecg/app/ecg/urls.py | 31 ++++ ecg/app/ecg/wsgi.py | 16 ++ ecg/app/manage.py | 22 +++ ecg/config/requirements.txt | 17 ++ 25 files changed, 597 insertions(+) create mode 100644 .gitignore create mode 100644 docker-compose.yml create mode 100644 ecg/Dockerfile create mode 100644 ecg/__init__.py create mode 100644 ecg/app/connector/__init__.py create mode 100644 ecg/app/connector/migrations/0001_initial.py create mode 100644 ecg/app/connector/migrations/__init__.py create mode 100644 ecg/app/connector/models.py create mode 100644 ecg/app/connector/operations.py create mode 100644 ecg/app/connector/serializers.py create mode 100644 ecg/app/connector/tests/__init__.py create mode 100644 ecg/app/connector/tests/test_models.py create mode 100644 ecg/app/connector/tests/test_operations.py create mode 100644 ecg/app/connector/tests/test_serializers.py create mode 100644 ecg/app/connector/tests/test_urls.py create mode 100644 ecg/app/connector/tests/test_views.py create mode 100644 ecg/app/connector/urls.py create mode 100644 ecg/app/connector/views.py create mode 100644 ecg/app/ecg/__init__.py create mode 100644 ecg/app/ecg/asgi.py create mode 100644 ecg/app/ecg/settings.py create mode 100644 ecg/app/ecg/urls.py create mode 100644 ecg/app/ecg/wsgi.py create mode 100755 ecg/app/manage.py create mode 100644 ecg/config/requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2333bd8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,161 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env.ecg +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ +.DS_Store \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d0eea22 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +version: '3.8' + +services: + ecg: + build: + dockerfile: Dockerfile + context: ecg + args: + - environment=${ENVIRONMENT} + image: ecg:latest + ports: + - "8000:8000" + env_file: + - .env.ecg + environment: + - VIRTUAL_HOST=ecg.localhost + - VIRTUAL_PORT=8000 + depends_on: + - ecg-db + volumes: + - ./ecg/app:/local + command: bash -c "gunicorn ecg.wsgi --workers 1 -b 0.0.0.0:8000 --reload" + + + ecg-db: + platform: linux/x86_64 # Necessary for MacOS M1 Chip + image: mysql:5.7.37 + env_file: + - .env.ecg + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + command: --default-authentication-plugin=mysql_native_password + +volumes: + mysql_data: \ No newline at end of file diff --git a/ecg/Dockerfile b/ecg/Dockerfile new file mode 100644 index 0000000..e67cc7a --- /dev/null +++ b/ecg/Dockerfile @@ -0,0 +1,15 @@ +ARG environment +FROM python:3.10.9 + +# set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +RUN mkdir /local +ADD /app /local +ADD /config /local/config +RUN pip install -r /local/config/requirements.txt + +EXPOSE 8000 +CMD bash -c "gunicorn uservice.wsgi --workers 3 --threads 100 --max-requests 1000 --max-requests-jitter 15 -b 0.0.0.0:8000" +WORKDIR /local diff --git a/ecg/__init__.py b/ecg/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ecg/app/connector/__init__.py b/ecg/app/connector/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ecg/app/connector/migrations/0001_initial.py b/ecg/app/connector/migrations/0001_initial.py new file mode 100644 index 0000000..e9211ad --- /dev/null +++ b/ecg/app/connector/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.23 on 2024-01-27 20:32 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='ECGModel', + fields=[ + ('id', models.AutoField(help_text='A unique identifier for each ECG', primary_key=True, serialize=False)), + ('date', models.DateTimeField(auto_now_add=True, help_text='The date of creation')), + ('leads', models.JSONField(help_text='List of leads')), + ], + ), + migrations.CreateModel( + name='LeadsModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='The lead identifier', max_length=50)), + ('num_samples', models.IntegerField(blank=True, help_text='The sample size of the signal', null=True)), + ('signal', models.JSONField(help_text='A list of integer values')), + ('ecg', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='connector.ecgmodel')), + ], + ), + ] diff --git a/ecg/app/connector/migrations/__init__.py b/ecg/app/connector/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ecg/app/connector/models.py b/ecg/app/connector/models.py new file mode 100644 index 0000000..61120c5 --- /dev/null +++ b/ecg/app/connector/models.py @@ -0,0 +1,20 @@ +from django.db import models + + +class ECGModel(models.Model): + id = models.AutoField(primary_key=True, help_text='A unique identifier for each ECG') + date = models.DateTimeField(auto_now_add=True, help_text='The date of creation') + leads = models.JSONField(help_text='List of leads') + + def __str__(self): + return f"ECG {self.id}" + + +class LeadsModel(models.Model): + ecg = models.ForeignKey(ECGModel, on_delete=models.CASCADE) + name = models.CharField(max_length=50, help_text='The lead identifier') + num_samples = models.IntegerField(null=True, blank=True, help_text='The sample size of the signal') + signal = models.JSONField(help_text='A list of integer values') + + def __str__(self): + return f"{self.ecg} - Lead {self.name}" diff --git a/ecg/app/connector/operations.py b/ecg/app/connector/operations.py new file mode 100644 index 0000000..e69de29 diff --git a/ecg/app/connector/serializers.py b/ecg/app/connector/serializers.py new file mode 100644 index 0000000..a68f098 --- /dev/null +++ b/ecg/app/connector/serializers.py @@ -0,0 +1,30 @@ +from rest_framework import serializers +from connector.models import ECGModel, LeadsModel + + +class LeadsModelSerializer(serializers.ModelSerializer): + class Meta: + model = LeadsModel + fields = ['name', 'num_samples', 'signal'] + + +class ECGModelSerializer(serializers.ModelSerializer): + leads = LeadsModelSerializer(many=True, write_only=True) + + class Meta: + model = ECGModel + fields = ['id', 'date', 'leads'] + + def create(self, validated_data): + leads_data = validated_data.pop('leads') + ecg_instance = ECGModel.objects.create(**validated_data) + + for lead_data in leads_data: + LeadsModel.objects.create(ecg=ecg_instance, **lead_data) + + return ecg_instance + + +class ECGResponseSerializer(serializers.Serializer): + channel_name = serializers.CharField() + zero_crossings_count = serializers.IntegerField() diff --git a/ecg/app/connector/tests/__init__.py b/ecg/app/connector/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ecg/app/connector/tests/test_models.py b/ecg/app/connector/tests/test_models.py new file mode 100644 index 0000000..e69de29 diff --git a/ecg/app/connector/tests/test_operations.py b/ecg/app/connector/tests/test_operations.py new file mode 100644 index 0000000..e69de29 diff --git a/ecg/app/connector/tests/test_serializers.py b/ecg/app/connector/tests/test_serializers.py new file mode 100644 index 0000000..e69de29 diff --git a/ecg/app/connector/tests/test_urls.py b/ecg/app/connector/tests/test_urls.py new file mode 100644 index 0000000..e69de29 diff --git a/ecg/app/connector/tests/test_views.py b/ecg/app/connector/tests/test_views.py new file mode 100644 index 0000000..e69de29 diff --git a/ecg/app/connector/urls.py b/ecg/app/connector/urls.py new file mode 100644 index 0000000..1abf33d --- /dev/null +++ b/ecg/app/connector/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path( + 'ecg_monitoring', + views.ECGView.as_view({'get': 'retrieve', 'post': 'create'}), + name='ecg_monitoring', + ), +] diff --git a/ecg/app/connector/views.py b/ecg/app/connector/views.py new file mode 100644 index 0000000..c540fe1 --- /dev/null +++ b/ecg/app/connector/views.py @@ -0,0 +1,38 @@ +from rest_framework import status, viewsets +from connector import serializers + +from drf_spectacular.utils import OpenApiResponse, extend_schema + + +@extend_schema(tags=['ecg_monitoring']) +class ECGView(viewsets.ViewSet): + serializer_class_response = serializers.ECGResponseSerializer + serializer_class_request = serializers.ECGModelSerializer + + @extend_schema( + responses={ + 200: OpenApiResponse( + description='Request success' + ), + 400: OpenApiResponse(description='Invalid value'), + 403: OpenApiResponse(description='Permission Denied'), + 500: OpenApiResponse(description='Internal server error'), + }, + request=serializer_class_request + ) + def create(self): + pass + + @extend_schema( + responses={ + 200: OpenApiResponse( + description='Request success',response=serializer_class_response + ), + 404: OpenApiResponse(description='Resource not available'), + 400: OpenApiResponse(description='Invalid value'), + 403: OpenApiResponse(description='Permission Denied'), + 500: OpenApiResponse(description='Internal server error'), + }, + ) + def retrieve(self): + pass diff --git a/ecg/app/ecg/__init__.py b/ecg/app/ecg/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ecg/app/ecg/asgi.py b/ecg/app/ecg/asgi.py new file mode 100644 index 0000000..e177972 --- /dev/null +++ b/ecg/app/ecg/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for ecg project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ecg.settings") + +application = get_asgi_application() diff --git a/ecg/app/ecg/settings.py b/ecg/app/ecg/settings.py new file mode 100644 index 0000000..49b1acb --- /dev/null +++ b/ecg/app/ecg/settings.py @@ -0,0 +1,150 @@ +""" +Django settings for ecg project. + +Generated by 'django-admin startproject' using Django 4.1. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.1/ref/settings/ +""" + +from pathlib import Path +import os + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-@j^qopfkj#nn*t4872(n-vdt(+8e8a6ka13mnzm#kluy*ktxn#" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ['*'] + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "connector", + "rest_framework", + 'health_check', + 'drf_spectacular', + 'drf_spectacular_sidecar', +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +REST_FRAMEWORK = { + 'DEFAULT_RENDERER_CLASSES': ('rest_framework.renderers.JSONRenderer',), + 'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',), + 'TEST_REQUEST_DEFAULT_FORMAT': 'json', + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema' +} + +ROOT_URLCONF = 'ecg.urls' + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "ecg.wsgi.application" + +# Database +# https://docs.djangoproject.com/en/4.1/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': os.environ['DATABASE_NAME'], + 'USER': os.environ['DATABASE_USER'], + 'PASSWORD': os.environ['DATABASE_PASSWORD'], + 'HOST': os.environ['DATABASE_HOST'], + 'PORT': os.environ['DATABASE_PORT'], + } +} + +# Password validation +# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +# Internationalization +# https://docs.djangoproject.com/en/4.1/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.1/howto/static-files/ + +STATIC_URL = "/static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +""" +Swagger configuration +""" +SPECTACULAR_SETTINGS = { + 'TITLE': 'ECG', + 'DESCRIPTION': 'ECG Monitoring app', + 'VERSION': '1.0.0', + 'SWAGGER_UI_DIST': 'SIDECAR', # shorthand to use the sidecar instead + 'SWAGGER_UI_FAVICON_HREF': 'SIDECAR', + 'REDOC_DIST': 'SIDECAR', + 'LOGIN_URL': '/admin/login', + 'LOGOUT_URL': '/admin/logout', + 'LICENSE': {'name': ''}, + 'SWAGGER_UI_SETTINGS': {'displayOperationId': True}, +} diff --git a/ecg/app/ecg/urls.py b/ecg/app/ecg/urls.py new file mode 100644 index 0000000..f57ff86 --- /dev/null +++ b/ecg/app/ecg/urls.py @@ -0,0 +1,31 @@ +"""ecg URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +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.conf.urls import url +from django.contrib import admin +from django.contrib.staticfiles.urls import staticfiles_urlpatterns +from django.urls import include +from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView + +urlpatterns = [ + url(r'^healthz/', include('health_check.urls')), + url(r'^api/tecuidamos/', include('connector.urls')), + url(r'^schema/$', SpectacularAPIView.as_view(), name='schema'), + url(r'^swagger/$', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + url(r'^redoc/$', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), + url(r'^admin/', admin.site.urls), +] + +urlpatterns += staticfiles_urlpatterns() diff --git a/ecg/app/ecg/wsgi.py b/ecg/app/ecg/wsgi.py new file mode 100644 index 0000000..f891feb --- /dev/null +++ b/ecg/app/ecg/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for ecg project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ecg.settings") + +application = get_wsgi_application() diff --git a/ecg/app/manage.py b/ecg/app/manage.py new file mode 100755 index 0000000..6a64736 --- /dev/null +++ b/ecg/app/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ecg.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/ecg/config/requirements.txt b/ecg/config/requirements.txt new file mode 100644 index 0000000..1f72b6a --- /dev/null +++ b/ecg/config/requirements.txt @@ -0,0 +1,17 @@ +# Django +djangorestframework==3.14.0 +Django>=3.2,<4 +django-filter==22.1 +django-health-check==3.17.0 + +# Database +django-mysql==4.8.0 +mysqlclient==2.1.1 +opentelemetry.instrumentation.pymysql==0.40b0 +PyMySQL==1.0.2 + +# Swagger +drf-spectacular==0.26.2 +drf-spectacular-sidecar==2023.5.1 + +gunicorn==20.1.0 From 553e56c569da8f56ef09354b71fa1ac7cb4f60c3 Mon Sep 17 00:00:00 2001 From: Tiko Kobiashvili Date: Sun, 28 Jan 2024 10:27:56 +0100 Subject: [PATCH 2/9] added user login and registration endpoints --- ecg/Dockerfile | 4 ++ ecg/app/connector/admin.py | 34 +++++++++ ecg/app/connector/migrations/0001_initial.py | 38 +++++++++- .../0002_alter_usermodel_second_last_name.py | 18 +++++ ecg/app/connector/models.py | 28 ++++++++ ecg/app/connector/serializers.py | 60 +++++++++++++++- ecg/app/connector/urls.py | 4 +- ecg/app/connector/views.py | 71 ++++++++++++++++++- ecg/app/ecg/settings.py | 35 ++++++++- 9 files changed, 286 insertions(+), 6 deletions(-) create mode 100644 ecg/app/connector/admin.py create mode 100644 ecg/app/connector/migrations/0002_alter_usermodel_second_last_name.py diff --git a/ecg/Dockerfile b/ecg/Dockerfile index e67cc7a..7f326a3 100644 --- a/ecg/Dockerfile +++ b/ecg/Dockerfile @@ -5,6 +5,10 @@ FROM python:3.10.9 ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONUNBUFFERED 1 +RUN apt-get update \ + && apt-get install -y libxml2-dev libxmlsec1-dev \ + && rm -rf /var/lib/apt/lists/* + RUN mkdir /local ADD /app /local ADD /config /local/config diff --git a/ecg/app/connector/admin.py b/ecg/app/connector/admin.py new file mode 100644 index 0000000..e7871f5 --- /dev/null +++ b/ecg/app/connector/admin.py @@ -0,0 +1,34 @@ +from django.contrib import admin + +from .models import ECGModel, LeadsModel, UserModel + + +class ECGAdmin(admin.ModelAdmin): + list_display = ( + 'id', + 'date', + 'leads' + ) + + +class LeadsAdmin(admin.ModelAdmin): + list_display = ( + 'ecg', + 'name', + 'num_samples', + 'signal' + ) + + +class UserAdmin(admin.ModelAdmin): + list_display = ( + 'username', + 'name', + 'last_name', + 'id' + ) + + +admin.site.register(ECGModel, ECGAdmin) +admin.site.register(LeadsModel, LeadsAdmin) +admin.site.register(UserModel, UserAdmin) diff --git a/ecg/app/connector/migrations/0001_initial.py b/ecg/app/connector/migrations/0001_initial.py index e9211ad..9a4c98d 100644 --- a/ecg/app/connector/migrations/0001_initial.py +++ b/ecg/app/connector/migrations/0001_initial.py @@ -1,7 +1,11 @@ -# Generated by Django 3.2.23 on 2024-01-27 20:32 +# Generated by Django 3.2.23 on 2024-01-28 00:24 +from django.conf import settings +import django.contrib.auth.models +import django.core.validators from django.db import migrations, models import django.db.models.deletion +import django.utils.timezone class Migration(migrations.Migration): @@ -9,15 +13,47 @@ class Migration(migrations.Migration): initial = True dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), ] operations = [ + migrations.CreateModel( + name='UserModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('is_admin', models.BooleanField(default=False)), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='unique identifier of the user', max_length=50, unique=True, validators=[django.core.validators.RegexValidator(message='Username must contain only alphanumeric characters.', regex='^[a-zA-Z0-9_-]+$')])), + ('name', models.CharField(max_length=20)), + ('last_name', models.CharField(max_length=50)), + ('second_last_name', models.CharField(max_length=50)), + ('email', models.EmailField(max_length=254, unique=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), migrations.CreateModel( name='ECGModel', fields=[ ('id', models.AutoField(help_text='A unique identifier for each ECG', primary_key=True, serialize=False)), ('date', models.DateTimeField(auto_now_add=True, help_text='The date of creation')), ('leads', models.JSONField(help_text='List of leads')), + ('username', models.CharField(help_text='user that has uploaded the ECG', max_length=20)), + ('user', models.ForeignKey(help_text='user that has uploaded the ECG', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], ), migrations.CreateModel( diff --git a/ecg/app/connector/migrations/0002_alter_usermodel_second_last_name.py b/ecg/app/connector/migrations/0002_alter_usermodel_second_last_name.py new file mode 100644 index 0000000..6664eaf --- /dev/null +++ b/ecg/app/connector/migrations/0002_alter_usermodel_second_last_name.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.23 on 2024-01-28 00:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('connector', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='usermodel', + name='second_last_name', + field=models.CharField(blank=True, max_length=50, null=True), + ), + ] diff --git a/ecg/app/connector/models.py b/ecg/app/connector/models.py index 61120c5..0e91075 100644 --- a/ecg/app/connector/models.py +++ b/ecg/app/connector/models.py @@ -1,4 +1,28 @@ from django.db import models +from django.contrib.auth.models import AbstractUser +from django.core.validators import RegexValidator + + +class UserModel(AbstractUser): + # Restrict username to alphanumeric characters, underscores or hyphens + username_validator = RegexValidator( + regex=r'^[a-zA-Z0-9_-]+$', + message='Username must contain only alphanumeric characters.', + ) + + is_admin = models.BooleanField(default=False) + username = models.CharField( + max_length=50, + unique=True, + validators=[username_validator], + error_messages={ + 'unique': 'A user with that username already exists.', + }, + help_text='unique identifier of the user') + name = models.CharField(max_length=20) + last_name = models.CharField(max_length=50) + second_last_name = models.CharField(max_length=50, blank=True, null=True,) + email = models.EmailField(unique=True) class ECGModel(models.Model): @@ -6,6 +30,10 @@ class ECGModel(models.Model): date = models.DateTimeField(auto_now_add=True, help_text='The date of creation') leads = models.JSONField(help_text='List of leads') + username = models.CharField(max_length=20, help_text='user that has uploaded the ECG') + user = models.ForeignKey(UserModel, on_delete=models.CASCADE, + help_text='user that has uploaded the ECG') # ForeignKey relationship with User model + def __str__(self): return f"ECG {self.id}" diff --git a/ecg/app/connector/serializers.py b/ecg/app/connector/serializers.py index a68f098..fce1766 100644 --- a/ecg/app/connector/serializers.py +++ b/ecg/app/connector/serializers.py @@ -1,5 +1,63 @@ from rest_framework import serializers -from connector.models import ECGModel, LeadsModel +from connector.models import ECGModel, LeadsModel, UserModel +from django.contrib.auth import authenticate +from django.contrib.auth.hashers import make_password +import re + + +class UserLoginSerializer(serializers.Serializer): + username = serializers.CharField(required=False) + password = serializers.CharField(write_only=True) + email = serializers.EmailField(required=False) + + def validate(self, data): + username = data.get('username') + email = data.get('email') + password = data.get('password') + if (username or email) and password: + if username: + user = authenticate(username=username, password=password) + elif email: + user = authenticate(email=email, password=password) + else: + raise serializers.ValidationError('Invalid credentials') + print(f'\n\n##################################\n variable: {user}\n') + if user: + data['user'] = user + else: + raise serializers.ValidationError('Invalid credentials') + else: + raise serializers.ValidationError('Must include both username/email and password') + + return data + + +class UserRegistrationSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True) + + class Meta: + model = UserModel + fields = '__all__' + + def validate_password(self, value): + # Check if password has at least 8 characters + if len(value) < 8: + raise serializers.ValidationError("Password must be at least 8 characters long.") + + # Check if password has at least one uppercase letter + if not any(char.isupper() for char in value): + raise serializers.ValidationError("Password must contain at least one uppercase letter.") + + # Check if password has at least one special character + if not re.search(r'[!@#$%^&*(),.?":{}|<>]', value): + raise serializers.ValidationError("Password must contain at least one special character.") + + return value + + def create(self, validated_data): + # Hash the password before saving + validated_data['password'] = make_password(validated_data['password']) + return super(UserRegistrationSerializer, self).create(validated_data) class LeadsModelSerializer(serializers.ModelSerializer): diff --git a/ecg/app/connector/urls.py b/ecg/app/connector/urls.py index 1abf33d..8e62185 100644 --- a/ecg/app/connector/urls.py +++ b/ecg/app/connector/urls.py @@ -4,8 +4,10 @@ urlpatterns = [ path( - 'ecg_monitoring', + 'api/ecg_monitoring', views.ECGView.as_view({'get': 'retrieve', 'post': 'create'}), name='ecg_monitoring', ), + path('api/login/', views.UserLoginView.as_view(), name='api-login'), + path('api/registration/', views.UserRegistrationView.as_view(), name='api-register'), ] diff --git a/ecg/app/connector/views.py b/ecg/app/connector/views.py index c540fe1..bd31fd2 100644 --- a/ecg/app/connector/views.py +++ b/ecg/app/connector/views.py @@ -1,11 +1,72 @@ from rest_framework import status, viewsets from connector import serializers +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework.views import APIView +from rest_framework.authtoken.models import Token +from rest_framework.response import Response + + +@extend_schema(tags=['user']) +class UserLoginView(APIView): + """ + User login view + """ + serializer_class = serializers.UserLoginSerializer + + @extend_schema( + responses={ + 200: OpenApiResponse( + description='Request success' + ), + 400: OpenApiResponse(description='Invalid value'), + 403: OpenApiResponse(description='Permission Denied'), + 500: OpenApiResponse(description='Internal server error'), + }, + request=serializer_class + ) + def post(self, request): + serializer = serializers.UserLoginSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + user = serializer.validated_data['user'] + token, created = Token.objects.get_or_create(user=user) + + return Response({'token': token.key}, status=status.HTTP_200_OK) + + +@extend_schema(tags=['user']) +class UserRegistrationView(APIView): + """ + User registration view + """ + serializer_class = serializers.UserRegistrationSerializer + + @extend_schema( + responses={ + 200: OpenApiResponse( + description='Request success' + ), + 400: OpenApiResponse(description='Invalid value'), + 403: OpenApiResponse(description='Permission Denied'), + 500: OpenApiResponse(description='Internal server error'), + }, + request=serializer_class + ) + def post(self, request, *args, **kwargs): + serializer = self.serializer_class(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @extend_schema(tags=['ecg_monitoring']) -class ECGView(viewsets.ViewSet): +class ECGView(viewsets.ViewSet, LoginRequiredMixin): + """ + ECG Monitoring application service + """ serializer_class_response = serializers.ECGResponseSerializer serializer_class_request = serializers.ECGModelSerializer @@ -21,12 +82,15 @@ class ECGView(viewsets.ViewSet): request=serializer_class_request ) def create(self): + """ + Receives ECG data for processing + """ pass @extend_schema( responses={ 200: OpenApiResponse( - description='Request success',response=serializer_class_response + description='Request success', response=serializer_class_response ), 404: OpenApiResponse(description='Resource not available'), 400: OpenApiResponse(description='Invalid value'), @@ -35,4 +99,7 @@ def create(self): }, ) def retrieve(self): + """ + Returns the number of times each ECG channel crosses zero + """ pass diff --git a/ecg/app/ecg/settings.py b/ecg/app/ecg/settings.py index 49b1acb..daf348f 100644 --- a/ecg/app/ecg/settings.py +++ b/ecg/app/ecg/settings.py @@ -19,6 +19,9 @@ # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ +# Auth User Model +AUTH_USER_MODEL = 'connector.UserModel' + # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = "django-insecure-@j^qopfkj#nn*t4872(n-vdt(+8e8a6ka13mnzm#kluy*ktxn#" @@ -30,6 +33,8 @@ # Application definition INSTALLED_APPS = [ + 'rest_framework', + 'rest_framework.authtoken', "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", @@ -37,7 +42,6 @@ "django.contrib.messages", "django.contrib.staticfiles", "connector", - "rest_framework", 'health_check', 'drf_spectacular', 'drf_spectacular_sidecar', @@ -60,6 +64,35 @@ 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema' } +# Authentication Backends +AUTHENTICATION_BACKENDS = [ + 'django.contrib.auth.backends.ModelBackend', +] + +AUTHENTICATION_CLASSES = ( + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.TokenAuthentication', +) +# +# # Account settings +# ACCOUNT_EMAIL_VERIFICATION = os.environ.get('ACCOUNT_EMAIL_VERIFICATION') +# ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = os.environ.get('ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS') +# ACCOUNT_UNIQUE_EMAIL = os.environ.get('ACCOUNT_UNIQUE_EMAIL') +# ACCOUNT_AUTHENTICATION_METHOD = os.environ.get('ACCOUNT_AUTHENTICATION_METHOD') +# ACCOUNT_EMAIL_REQUIRED = os.environ.get('ACCOUNT_EMAIL_REQUIRED') +# ACCOUNT_USERNAME_REQUIRED = os.environ.get('ACCOUNT_USERNAME_REQUIRED') +# ACCOUNT_USER_MODEL_USERNAME_FIELD = os.environ.get('ACCOUNT_USER_MODEL_USERNAME_FIELD') +# ACCOUNT_USER_EMAIL_FIELD = os.environ.get('ACCOUNT_USER_EMAIL_FIELD') +# ACCOUNT_LOGOUT_ON_GET = os.environ.get('ACCOUNT_LOGOUT_ON_GET') +# ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = os.environ.get('ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION') +# ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL = os.environ.get('ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL') +# ACCOUNT_EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL = os.environ.get( +# 'ACCOUNT_EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL') +# ACCOUNT_EMAIL_CONFIRMATION_HMAC = os.environ.get('ACCOUNT_EMAIL_CONFIRMATION_HMAC') + +# Django REST Framework settings +REST_USE_JWT = True # Use JWT for authentication + ROOT_URLCONF = 'ecg.urls' TEMPLATES = [ From 3b2af1cfacdb6adca9d2f0b39c1812e29f69789e Mon Sep 17 00:00:00 2001 From: Tiko Kobiashvili Date: Mon, 29 Jan 2024 02:09:33 +0100 Subject: [PATCH 3/9] changed the structure of ECG model, finished implementing the ECGView --- ecg/app/connector/admin.py | 39 +++++--- ecg/app/connector/migrations/0001_initial.py | 34 ++----- .../0002_alter_usermodel_second_last_name.py | 18 ---- ecg/app/connector/models.py | 19 +--- ecg/app/connector/operations.py | 91 +++++++++++++++++++ ecg/app/connector/serializers.py | 37 ++++---- ecg/app/connector/urls.py | 7 +- ecg/app/connector/views.py | 21 ++++- ecg/app/ecg/settings.py | 24 +---- ecg/config/requirements.txt | 3 + 10 files changed, 178 insertions(+), 115 deletions(-) delete mode 100644 ecg/app/connector/migrations/0002_alter_usermodel_second_last_name.py diff --git a/ecg/app/connector/admin.py b/ecg/app/connector/admin.py index e7871f5..e7fdd92 100644 --- a/ecg/app/connector/admin.py +++ b/ecg/app/connector/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin -from .models import ECGModel, LeadsModel, UserModel +from .models import ECGModel, UserModel class ECGAdmin(admin.ModelAdmin): @@ -10,25 +11,33 @@ class ECGAdmin(admin.ModelAdmin): 'leads' ) + # Grant add permission unless the user is an admin + def has_add_permission(self, request): + # This can also be done from admin panel + # create admin group that can not add ECG model -> + # add created group to the admin + return not request.user.is_admin -class LeadsAdmin(admin.ModelAdmin): - list_display = ( - 'ecg', - 'name', - 'num_samples', - 'signal' - ) +class UserAdmin(BaseUserAdmin): + list_display = ('username', 'email', 'name', 'last_name', 'is_admin') + search_fields = ('username', 'email', 'name', 'last_name') + ordering = ('username',) -class UserAdmin(admin.ModelAdmin): - list_display = ( - 'username', - 'name', - 'last_name', - 'id' + fieldsets = ( + (None, {'fields': ('username', 'password')}), + (('Personal Info'), {'fields': ('name', 'last_name', 'second_last_name', 'email')}), + (('Permissions'), {'fields': ('is_active', 'is_admin', 'groups', 'is_staff')}), + (('Important dates'), {'fields': ('last_login', 'date_joined')}), + ) + + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('username', 'email', 'password1', 'password2'), + }), ) admin.site.register(ECGModel, ECGAdmin) -admin.site.register(LeadsModel, LeadsAdmin) admin.site.register(UserModel, UserAdmin) diff --git a/ecg/app/connector/migrations/0001_initial.py b/ecg/app/connector/migrations/0001_initial.py index 9a4c98d..79c1c77 100644 --- a/ecg/app/connector/migrations/0001_initial.py +++ b/ecg/app/connector/migrations/0001_initial.py @@ -1,10 +1,8 @@ -# Generated by Django 3.2.23 on 2024-01-28 00:24 +# Generated by Django 3.2.23 on 2024-01-29 00:26 -from django.conf import settings import django.contrib.auth.models import django.core.validators from django.db import migrations, models -import django.db.models.deletion import django.utils.timezone @@ -17,6 +15,14 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name='ECGModel', + fields=[ + ('id', models.AutoField(help_text='A unique identifier for each ECG', primary_key=True, serialize=False)), + ('date', models.DateTimeField(auto_now_add=True, help_text='The date of creation')), + ('leads', models.JSONField()), + ], + ), migrations.CreateModel( name='UserModel', fields=[ @@ -32,7 +38,7 @@ class Migration(migrations.Migration): ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='unique identifier of the user', max_length=50, unique=True, validators=[django.core.validators.RegexValidator(message='Username must contain only alphanumeric characters.', regex='^[a-zA-Z0-9_-]+$')])), ('name', models.CharField(max_length=20)), ('last_name', models.CharField(max_length=50)), - ('second_last_name', models.CharField(max_length=50)), + ('second_last_name', models.CharField(blank=True, max_length=50, null=True)), ('email', models.EmailField(max_length=254, unique=True)), ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), @@ -46,24 +52,4 @@ class Migration(migrations.Migration): ('objects', django.contrib.auth.models.UserManager()), ], ), - migrations.CreateModel( - name='ECGModel', - fields=[ - ('id', models.AutoField(help_text='A unique identifier for each ECG', primary_key=True, serialize=False)), - ('date', models.DateTimeField(auto_now_add=True, help_text='The date of creation')), - ('leads', models.JSONField(help_text='List of leads')), - ('username', models.CharField(help_text='user that has uploaded the ECG', max_length=20)), - ('user', models.ForeignKey(help_text='user that has uploaded the ECG', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - migrations.CreateModel( - name='LeadsModel', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(help_text='The lead identifier', max_length=50)), - ('num_samples', models.IntegerField(blank=True, help_text='The sample size of the signal', null=True)), - ('signal', models.JSONField(help_text='A list of integer values')), - ('ecg', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='connector.ecgmodel')), - ], - ), ] diff --git a/ecg/app/connector/migrations/0002_alter_usermodel_second_last_name.py b/ecg/app/connector/migrations/0002_alter_usermodel_second_last_name.py deleted file mode 100644 index 6664eaf..0000000 --- a/ecg/app/connector/migrations/0002_alter_usermodel_second_last_name.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.2.23 on 2024-01-28 00:36 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('connector', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='usermodel', - name='second_last_name', - field=models.CharField(blank=True, max_length=50, null=True), - ), - ] diff --git a/ecg/app/connector/models.py b/ecg/app/connector/models.py index 0e91075..3da9b65 100644 --- a/ecg/app/connector/models.py +++ b/ecg/app/connector/models.py @@ -1,6 +1,7 @@ from django.db import models from django.contrib.auth.models import AbstractUser from django.core.validators import RegexValidator +import json class UserModel(AbstractUser): @@ -21,28 +22,14 @@ class UserModel(AbstractUser): help_text='unique identifier of the user') name = models.CharField(max_length=20) last_name = models.CharField(max_length=50) - second_last_name = models.CharField(max_length=50, blank=True, null=True,) + second_last_name = models.CharField(max_length=50, blank=True, null=True, ) email = models.EmailField(unique=True) class ECGModel(models.Model): id = models.AutoField(primary_key=True, help_text='A unique identifier for each ECG') date = models.DateTimeField(auto_now_add=True, help_text='The date of creation') - leads = models.JSONField(help_text='List of leads') - - username = models.CharField(max_length=20, help_text='user that has uploaded the ECG') - user = models.ForeignKey(UserModel, on_delete=models.CASCADE, - help_text='user that has uploaded the ECG') # ForeignKey relationship with User model + leads = models.JSONField() def __str__(self): return f"ECG {self.id}" - - -class LeadsModel(models.Model): - ecg = models.ForeignKey(ECGModel, on_delete=models.CASCADE) - name = models.CharField(max_length=50, help_text='The lead identifier') - num_samples = models.IntegerField(null=True, blank=True, help_text='The sample size of the signal') - signal = models.JSONField(help_text='A list of integer values') - - def __str__(self): - return f"{self.ecg} - Lead {self.name}" diff --git a/ecg/app/connector/operations.py b/ecg/app/connector/operations.py index e69de29..846fc39 100644 --- a/ecg/app/connector/operations.py +++ b/ecg/app/connector/operations.py @@ -0,0 +1,91 @@ +import json + +import numpy as np +from connector import serializers +from connector.models import ECGModel +from django.core.exceptions import ValidationError, ObjectDoesNotExist +import logging +from collections import namedtuple +from django.forms.models import model_to_dict + +from rest_framework import status + +ErrorTuple = namedtuple('Error', ['code', 'status', 'details']) + +logger = logging.getLogger(__name__) + +ERROR_LIST = [ + ErrorTuple('RESOURCE_NOT_AVAILABLE', status.HTTP_404_NOT_FOUND, 'Resource not available'), + ErrorTuple('INVALID_VALUE', status.HTTP_400_BAD_REQUEST, 'Invalid value'), + ErrorTuple('INTERNAL_SERVER_ERROR', status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal server error') +] + + +class ECGOperations: + serializer_class = serializers.ECGModelSerializer + + def _zero_crossing(self, signal): + """ + Calculates the number of times each ECG channel crosses zero + """ + signal = np.array(json.loads(signal)) + zero_crossing_count = ((signal[:-1] * signal[1:]) < 0).sum() + + return zero_crossing_count + + def get_zero_crossing_count(self, ecg_id): + """ + Gets the ECG record from the database on given id + returns number of times each ECG channel crosses zero + + ecg_id: the id of the ECG record + """ + try: + ecg_record = ECGModel.objects.get(pk=ecg_id) + leads = ecg_record.leads + response = [] + for lead in leads: + signal = lead.get('signal') + zero_crossing_count = self._zero_crossing(signal) + + response.append({ + 'zero_crossing_count': zero_crossing_count, + 'lead_name': lead.get('name') + }) + + return response + + except ECGModel.DoesNotExist: + error_message = 'Model/Record not found' + logger.error(error_message) + raise ObjectDoesNotExist(ERROR_LIST.RESOURCE_NOT_AVAILABLE, error_message) + except ValidationError as e: + logger.error(e.message) + raise ValidationError(ERROR_LIST.INVALID_VALUE, e.message) + except Exception as e: + # log any other kind of error + logger.error(e) + raise Exception(ERROR_LIST.INTERNAL_SERVER_ERROR, e.__cause__) + + def create_ecg_record(self, params): + """ + Creates ECG record in database + + params: ECG data + """ + try: + serializer = self.serializer_class(data=params) + except ECGModel.DoesNotExist: + error_message = 'Model/Record not found' + logger.error(error_message) + raise ObjectDoesNotExist(ERROR_LIST.RESOURCE_NOT_AVAILABLE, error_message) + except ValidationError as e: + logger.error(e.message) + raise ValidationError(ERROR_LIST.INVALID_VALUE, e.message) + except Exception as e: + # log any other kind of error + logger.error(e) + raise Exception(ERROR_LIST.INTERNAL_SERVER_ERROR, e.__cause__) + + serializer.is_valid(raise_exception=True) + serializer.save() diff --git a/ecg/app/connector/serializers.py b/ecg/app/connector/serializers.py index fce1766..a3af495 100644 --- a/ecg/app/connector/serializers.py +++ b/ecg/app/connector/serializers.py @@ -1,5 +1,7 @@ +import json + from rest_framework import serializers -from connector.models import ECGModel, LeadsModel, UserModel +from connector.models import ECGModel, UserModel from django.contrib.auth import authenticate from django.contrib.auth.hashers import make_password import re @@ -60,29 +62,30 @@ def create(self, validated_data): return super(UserRegistrationSerializer, self).create(validated_data) -class LeadsModelSerializer(serializers.ModelSerializer): - class Meta: - model = LeadsModel - fields = ['name', 'num_samples', 'signal'] +# class LeadsModelSerializer(serializers.ModelSerializer): +# class Meta: +# model = LeadsModel +# fields = '__all__' -class ECGModelSerializer(serializers.ModelSerializer): - leads = LeadsModelSerializer(many=True, write_only=True) +class LeadsSerializer(serializers.Serializer): + LEAD_IDENTIFIERS = ['I', 'II', 'III', 'aVR', 'aVL', 'aVF', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6'] - class Meta: - model = ECGModel - fields = ['id', 'date', 'leads'] + name = serializers.ChoiceField(choices=[(lead, lead) for lead in LEAD_IDENTIFIERS], + help_text='The lead identifier of ECG leads') - def create(self, validated_data): - leads_data = validated_data.pop('leads') - ecg_instance = ECGModel.objects.create(**validated_data) + num_samples = serializers.IntegerField(required=False, help_text='The sample size of the signal') + signal = serializers.CharField(help_text='A list of integer values') - for lead_data in leads_data: - LeadsModel.objects.create(ecg=ecg_instance, **lead_data) - return ecg_instance +class ECGModelSerializer(serializers.ModelSerializer): + leads = LeadsSerializer(many=True) + + class Meta: + model = ECGModel + fields = '__all__' class ECGResponseSerializer(serializers.Serializer): - channel_name = serializers.CharField() + lead_name = serializers.CharField() zero_crossings_count = serializers.IntegerField() diff --git a/ecg/app/connector/urls.py b/ecg/app/connector/urls.py index 8e62185..8233201 100644 --- a/ecg/app/connector/urls.py +++ b/ecg/app/connector/urls.py @@ -5,7 +5,12 @@ urlpatterns = [ path( 'api/ecg_monitoring', - views.ECGView.as_view({'get': 'retrieve', 'post': 'create'}), + views.ECGView.as_view({'post': 'create'}), + name='ecg_monitoring', + ), + path( + 'api/ecg_monitoring/', + views.ECGView.as_view({'get': 'retrieve'}), name='ecg_monitoring', ), path('api/login/', views.UserLoginView.as_view(), name='api-login'), diff --git a/ecg/app/connector/views.py b/ecg/app/connector/views.py index bd31fd2..01b439e 100644 --- a/ecg/app/connector/views.py +++ b/ecg/app/connector/views.py @@ -6,6 +6,9 @@ from rest_framework.views import APIView from rest_framework.authtoken.models import Token from rest_framework.response import Response +from rest_framework.permissions import AllowAny + +from connector import operations @extend_schema(tags=['user']) @@ -55,6 +58,7 @@ class UserRegistrationView(APIView): request=serializer_class ) def post(self, request, *args, **kwargs): + # Other than admin that was required, this endpoint also can create/register users serializer = self.serializer_class(data=request.data) if serializer.is_valid(): serializer.save() @@ -63,10 +67,13 @@ def post(self, request, *args, **kwargs): @extend_schema(tags=['ecg_monitoring']) -class ECGView(viewsets.ViewSet, LoginRequiredMixin): +class ECGView(viewsets.ViewSet): """ ECG Monitoring application service """ + + permission_classes = (AllowAny,) + serializer_class_response = serializers.ECGResponseSerializer serializer_class_request = serializers.ECGModelSerializer @@ -81,11 +88,13 @@ class ECGView(viewsets.ViewSet, LoginRequiredMixin): }, request=serializer_class_request ) - def create(self): + def create(self, request): """ Receives ECG data for processing """ - pass + operations.ECGOperations().create_ecg_record(request.data) + + return Response(f'ECG record created successfully', status=status.HTTP_200_OK) @extend_schema( responses={ @@ -98,8 +107,10 @@ def create(self): 500: OpenApiResponse(description='Internal server error'), }, ) - def retrieve(self): + def retrieve(self, request, ecg_id): """ Returns the number of times each ECG channel crosses zero """ - pass + response = operations.ECGOperations().get_zero_crossing_count(ecg_id) + + return Response({'zero_crossing_count': response}, status=status.HTTP_200_OK) diff --git a/ecg/app/ecg/settings.py b/ecg/app/ecg/settings.py index daf348f..0385b20 100644 --- a/ecg/app/ecg/settings.py +++ b/ecg/app/ecg/settings.py @@ -69,26 +69,12 @@ 'django.contrib.auth.backends.ModelBackend', ] -AUTHENTICATION_CLASSES = ( - 'rest_framework.authentication.SessionAuthentication', - 'rest_framework.authentication.TokenAuthentication', -) +# AUTHENTICATION_CLASSES = ( +# # 'rest_framework.authentication.SessionAuthentication', +# # 'rest_framework.authentication.TokenAuthentication', +# ) # -# # Account settings -# ACCOUNT_EMAIL_VERIFICATION = os.environ.get('ACCOUNT_EMAIL_VERIFICATION') -# ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = os.environ.get('ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS') -# ACCOUNT_UNIQUE_EMAIL = os.environ.get('ACCOUNT_UNIQUE_EMAIL') -# ACCOUNT_AUTHENTICATION_METHOD = os.environ.get('ACCOUNT_AUTHENTICATION_METHOD') -# ACCOUNT_EMAIL_REQUIRED = os.environ.get('ACCOUNT_EMAIL_REQUIRED') -# ACCOUNT_USERNAME_REQUIRED = os.environ.get('ACCOUNT_USERNAME_REQUIRED') -# ACCOUNT_USER_MODEL_USERNAME_FIELD = os.environ.get('ACCOUNT_USER_MODEL_USERNAME_FIELD') -# ACCOUNT_USER_EMAIL_FIELD = os.environ.get('ACCOUNT_USER_EMAIL_FIELD') -# ACCOUNT_LOGOUT_ON_GET = os.environ.get('ACCOUNT_LOGOUT_ON_GET') -# ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = os.environ.get('ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION') -# ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL = os.environ.get('ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL') -# ACCOUNT_EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL = os.environ.get( -# 'ACCOUNT_EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL') -# ACCOUNT_EMAIL_CONFIRMATION_HMAC = os.environ.get('ACCOUNT_EMAIL_CONFIRMATION_HMAC') + # Django REST Framework settings REST_USE_JWT = True # Use JWT for authentication diff --git a/ecg/config/requirements.txt b/ecg/config/requirements.txt index 1f72b6a..dadfd53 100644 --- a/ecg/config/requirements.txt +++ b/ecg/config/requirements.txt @@ -15,3 +15,6 @@ drf-spectacular==0.26.2 drf-spectacular-sidecar==2023.5.1 gunicorn==20.1.0 + +numpy==1.26.3 + From 08677c8885e6af621d034df5aed216f56dc30cbd Mon Sep 17 00:00:00 2001 From: Tiko Kobiashvili Date: Tue, 30 Jan 2024 02:03:24 +0100 Subject: [PATCH 4/9] added custom permission class and user foreign key in ecg model --- ecg/app/connector/admin.py | 2 +- .../migrations/0002_ecgmodel_user.py | 20 ++++++++++ ecg/app/connector/models.py | 2 + ecg/app/connector/operations.py | 34 +++++++++-------- ecg/app/connector/permissions.py | 12 ++++++ ecg/app/connector/serializers.py | 8 +--- ecg/app/connector/urls.py | 4 +- ecg/app/connector/views.py | 37 +++++++++++++++---- ecg/app/ecg/settings.py | 12 ++---- ecg/app/ecg/urls.py | 2 +- 10 files changed, 90 insertions(+), 43 deletions(-) create mode 100644 ecg/app/connector/migrations/0002_ecgmodel_user.py create mode 100644 ecg/app/connector/permissions.py diff --git a/ecg/app/connector/admin.py b/ecg/app/connector/admin.py index e7fdd92..aa9c101 100644 --- a/ecg/app/connector/admin.py +++ b/ecg/app/connector/admin.py @@ -34,7 +34,7 @@ class UserAdmin(BaseUserAdmin): add_fieldsets = ( (None, { 'classes': ('wide',), - 'fields': ('username', 'email', 'password1', 'password2'), + 'fields': ('username', 'email', 'password1', 'password2', 'name', 'last_name', 'second_last_name'), }), ) diff --git a/ecg/app/connector/migrations/0002_ecgmodel_user.py b/ecg/app/connector/migrations/0002_ecgmodel_user.py new file mode 100644 index 0000000..64c473f --- /dev/null +++ b/ecg/app/connector/migrations/0002_ecgmodel_user.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.23 on 2024-01-29 22:19 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('connector', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='ecgmodel', + name='user', + field=models.ForeignKey(default=1, help_text='The user who created the ECG', on_delete=django.db.models.deletion.CASCADE, related_name='ecgs', to='connector.usermodel'), + preserve_default=False, + ), + ] diff --git a/ecg/app/connector/models.py b/ecg/app/connector/models.py index 3da9b65..bc8edb8 100644 --- a/ecg/app/connector/models.py +++ b/ecg/app/connector/models.py @@ -30,6 +30,8 @@ class ECGModel(models.Model): id = models.AutoField(primary_key=True, help_text='A unique identifier for each ECG') date = models.DateTimeField(auto_now_add=True, help_text='The date of creation') leads = models.JSONField() + user = models.ForeignKey(UserModel, on_delete=models.CASCADE, related_name='ecgs', + help_text='The user who created the ECG') def __str__(self): return f"ECG {self.id}" diff --git a/ecg/app/connector/operations.py b/ecg/app/connector/operations.py index 846fc39..62cb606 100644 --- a/ecg/app/connector/operations.py +++ b/ecg/app/connector/operations.py @@ -33,28 +33,30 @@ def _zero_crossing(self, signal): return zero_crossing_count - def get_zero_crossing_count(self, ecg_id): + def get_zero_crossing_count(self, ecg_record): """ Gets the ECG record from the database on given id returns number of times each ECG channel crosses zero ecg_id: the id of the ECG record """ - try: - ecg_record = ECGModel.objects.get(pk=ecg_id) - leads = ecg_record.leads - response = [] - for lead in leads: - signal = lead.get('signal') - zero_crossing_count = self._zero_crossing(signal) + leads = ecg_record.leads + response = [] + for lead in leads: + signal = lead.get('signal') + zero_crossing_count = self._zero_crossing(signal) - response.append({ - 'zero_crossing_count': zero_crossing_count, - 'lead_name': lead.get('name') - }) + response.append({ + 'zero_crossing_count': zero_crossing_count, + 'lead_name': lead.get('name') + }) - return response + return response + def get_ecg_instance(self, ecg_id): + try: + ecg_record = ECGModel.objects.get(pk=ecg_id) + return ecg_record except ECGModel.DoesNotExist: error_message = 'Model/Record not found' logger.error(error_message) @@ -67,14 +69,14 @@ def get_zero_crossing_count(self, ecg_id): logger.error(e) raise Exception(ERROR_LIST.INTERNAL_SERVER_ERROR, e.__cause__) - def create_ecg_record(self, params): + def create_ecg_record(self, ecg_data, context): """ Creates ECG record in database - params: ECG data + ecg_data: ECG data """ try: - serializer = self.serializer_class(data=params) + serializer = self.serializer_class(data=ecg_data, context=context) except ECGModel.DoesNotExist: error_message = 'Model/Record not found' logger.error(error_message) diff --git a/ecg/app/connector/permissions.py b/ecg/app/connector/permissions.py new file mode 100644 index 0000000..b4438a1 --- /dev/null +++ b/ecg/app/connector/permissions.py @@ -0,0 +1,12 @@ +from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import BasePermission + + +class HasECGDataPermission(BasePermission): + message = "Permission Denied" + + def has_object_permission(self, request, view, obj): + # Check if the authenticated user matches the user who created the ECG + if obj.user != request.user: + raise PermissionDenied(self.message) + return True diff --git a/ecg/app/connector/serializers.py b/ecg/app/connector/serializers.py index a3af495..ef323a3 100644 --- a/ecg/app/connector/serializers.py +++ b/ecg/app/connector/serializers.py @@ -23,7 +23,6 @@ def validate(self, data): user = authenticate(email=email, password=password) else: raise serializers.ValidationError('Invalid credentials') - print(f'\n\n##################################\n variable: {user}\n') if user: data['user'] = user else: @@ -62,12 +61,6 @@ def create(self, validated_data): return super(UserRegistrationSerializer, self).create(validated_data) -# class LeadsModelSerializer(serializers.ModelSerializer): -# class Meta: -# model = LeadsModel -# fields = '__all__' - - class LeadsSerializer(serializers.Serializer): LEAD_IDENTIFIERS = ['I', 'II', 'III', 'aVR', 'aVL', 'aVF', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6'] @@ -80,6 +73,7 @@ class LeadsSerializer(serializers.Serializer): class ECGModelSerializer(serializers.ModelSerializer): leads = LeadsSerializer(many=True) + user = serializers.HiddenField(default=serializers.CurrentUserDefault()) class Meta: model = ECGModel diff --git a/ecg/app/connector/urls.py b/ecg/app/connector/urls.py index 8233201..fef7ad9 100644 --- a/ecg/app/connector/urls.py +++ b/ecg/app/connector/urls.py @@ -9,8 +9,8 @@ name='ecg_monitoring', ), path( - 'api/ecg_monitoring/', - views.ECGView.as_view({'get': 'retrieve'}), + 'api/ecg_monitoring/zero_crossing/', + views.ECGView.as_view({'get': 'retrieve_zero_crossing'}), name='ecg_monitoring', ), path('api/login/', views.UserLoginView.as_view(), name='api-login'), diff --git a/ecg/app/connector/views.py b/ecg/app/connector/views.py index 01b439e..73fb65b 100644 --- a/ecg/app/connector/views.py +++ b/ecg/app/connector/views.py @@ -1,12 +1,15 @@ -from rest_framework import status, viewsets +from rest_framework import status, viewsets, permissions from connector import serializers from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin - +from .permissions import HasECGDataPermission +from rest_framework.authentication import TokenAuthentication +from rest_framework.permissions import IsAuthenticated from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework.views import APIView from rest_framework.authtoken.models import Token from rest_framework.response import Response from rest_framework.permissions import AllowAny +from rest_framework.exceptions import PermissionDenied from connector import operations @@ -16,6 +19,8 @@ class UserLoginView(APIView): """ User login view """ + permission_classes = (AllowAny,) + serializer_class = serializers.UserLoginSerializer @extend_schema( @@ -44,6 +49,8 @@ class UserRegistrationView(APIView): """ User registration view """ + permission_classes = (AllowAny,) + serializer_class = serializers.UserRegistrationSerializer @extend_schema( @@ -71,12 +78,21 @@ class ECGView(viewsets.ViewSet): """ ECG Monitoring application service """ - - permission_classes = (AllowAny,) + authentication_classes = (TokenAuthentication,) + permission_classes = (IsAuthenticated,) serializer_class_response = serializers.ECGResponseSerializer serializer_class_request = serializers.ECGModelSerializer + def get_permissions(self): + """ + Instantiates and returns the list of permissions that this view/method requires. + """ + if self.action == 'retrieve_zero_crossing': + return [permissions.IsAuthenticated(), HasECGDataPermission()] + else: + return [permissions.IsAuthenticated()] + @extend_schema( responses={ 200: OpenApiResponse( @@ -92,7 +108,7 @@ def create(self, request): """ Receives ECG data for processing """ - operations.ECGOperations().create_ecg_record(request.data) + operations.ECGOperations().create_ecg_record(ecg_data=request.data, context={'request': request}) return Response(f'ECG record created successfully', status=status.HTTP_200_OK) @@ -107,10 +123,15 @@ def create(self, request): 500: OpenApiResponse(description='Internal server error'), }, ) - def retrieve(self, request, ecg_id): + def retrieve_zero_crossing(self, request, ecg_id): """ Returns the number of times each ECG channel crosses zero """ - response = operations.ECGOperations().get_zero_crossing_count(ecg_id) + ecg_instance = operations.ECGOperations().get_ecg_instance(ecg_id) + + # Check if the authenticated user has permission to retrieve this ECG data + self.check_object_permissions(request, ecg_instance) + + response = operations.ECGOperations().get_zero_crossing_count(ecg_instance) - return Response({'zero_crossing_count': response}, status=status.HTTP_200_OK) + return Response(response, status=status.HTTP_200_OK) diff --git a/ecg/app/ecg/settings.py b/ecg/app/ecg/settings.py index 0385b20..807d643 100644 --- a/ecg/app/ecg/settings.py +++ b/ecg/app/ecg/settings.py @@ -61,7 +61,10 @@ 'DEFAULT_RENDERER_CLASSES': ('rest_framework.renderers.JSONRenderer',), 'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',), 'TEST_REQUEST_DEFAULT_FORMAT': 'json', - 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema' + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + ], } # Authentication Backends @@ -69,13 +72,6 @@ 'django.contrib.auth.backends.ModelBackend', ] -# AUTHENTICATION_CLASSES = ( -# # 'rest_framework.authentication.SessionAuthentication', -# # 'rest_framework.authentication.TokenAuthentication', -# ) -# - - # Django REST Framework settings REST_USE_JWT = True # Use JWT for authentication diff --git a/ecg/app/ecg/urls.py b/ecg/app/ecg/urls.py index f57ff86..07d7f77 100644 --- a/ecg/app/ecg/urls.py +++ b/ecg/app/ecg/urls.py @@ -21,7 +21,7 @@ urlpatterns = [ url(r'^healthz/', include('health_check.urls')), - url(r'^api/tecuidamos/', include('connector.urls')), + url(r'^api/', include('connector.urls')), url(r'^schema/$', SpectacularAPIView.as_view(), name='schema'), url(r'^swagger/$', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), url(r'^redoc/$', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), From 001c39fa99aab810cd180f34a86c2871ba823519 Mon Sep 17 00:00:00 2001 From: Tiko Kobiashvili Date: Tue, 30 Jan 2024 05:17:37 +0100 Subject: [PATCH 5/9] added pre-commit configuration and fixed error handling in operations --- ecg/.pre-commit-config.yaml | 50 ++++++ ecg/Dockerfile | 2 +- ecg/app/connector/admin.py | 32 ++-- ecg/app/connector/migrations/0001_initial.py | 150 ++++++++++++++++-- .../migrations/0002_ecgmodel_user.py | 10 +- ecg/app/connector/models.py | 32 ++-- ecg/app/connector/operations.py | 89 +++++------ ecg/app/connector/permissions.py | 2 +- ecg/app/connector/serializers.py | 59 +++++-- ecg/app/connector/urls.py | 12 +- ecg/app/connector/views.py | 113 ++++++++----- ecg/app/ecg/asgi.py | 2 +- ecg/app/ecg/settings.py | 70 ++++---- ecg/app/ecg/urls.py | 21 +-- ecg/app/ecg/wsgi.py | 2 +- ecg/app/manage.py | 10 +- ecg/config/requirements.txt | 2 +- ecg/pyproject.toml | 53 +++++++ 18 files changed, 508 insertions(+), 203 deletions(-) create mode 100644 ecg/.pre-commit-config.yaml create mode 100644 ecg/pyproject.toml diff --git a/ecg/.pre-commit-config.yaml b/ecg/.pre-commit-config.yaml new file mode 100644 index 0000000..d255d0a --- /dev/null +++ b/ecg/.pre-commit-config.yaml @@ -0,0 +1,50 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +# +# You must run next command first time: +# pre-commit install +# +default_language_version: + python: python3.10 # TODO: upgrade to newer Python version if it's required. +default_stages: [commit] +repos: + # black and docformatter always at top of the list. + - repo: https://github.com/ambv/black + rev: 22.12.0 + hooks: + - id: black + description: Python code formatter. + - repo: https://github.com/PyCQA/docformatter + rev: v1.5.1 + hooks: + - id: docformatter + description: Formats docstrings to follow PEP 257. + args: [--config=./pyproject.toml] + - repo: https://github.com/PyCQA/flake8 + rev: 6.0.0 + hooks: + - id: flake8 + description: Command-line utility for enforcing style consistency across Python projects. + entry: pflake8 + additional_dependencies: [pyproject-flake8] + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + description: Trims trailing whitespace. + types: [python] + - id: double-quote-string-fixer + description: Replaces double quoted strings with single quoted strings. + types: [python] + - repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + description: Reorders imports in python files. + args: [--settings-path=ecg/pyproject.toml, --filter-files] + - repo: https://github.com/asottile/pyupgrade + rev: v3.3.1 + hooks: + - id: pyupgrade + description: Automatically upgrade syntax for newer versions. + args: [--py36-plus] # TODO: upgrade to newer Python version if it's required. diff --git a/ecg/Dockerfile b/ecg/Dockerfile index 7f326a3..29a1293 100644 --- a/ecg/Dockerfile +++ b/ecg/Dockerfile @@ -1,6 +1,6 @@ ARG environment FROM python:3.10.9 - +# noqa: E501 # set environment variables ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONUNBUFFERED 1 diff --git a/ecg/app/connector/admin.py b/ecg/app/connector/admin.py index aa9c101..fcc3c6f 100644 --- a/ecg/app/connector/admin.py +++ b/ecg/app/connector/admin.py @@ -3,13 +3,11 @@ from .models import ECGModel, UserModel +# flake8: noqa: E501 + class ECGAdmin(admin.ModelAdmin): - list_display = ( - 'id', - 'date', - 'leads' - ) + list_display = ('id', 'date', 'leads') # Grant add permission unless the user is an admin def has_add_permission(self, request): @@ -26,16 +24,30 @@ class UserAdmin(BaseUserAdmin): fieldsets = ( (None, {'fields': ('username', 'password')}), - (('Personal Info'), {'fields': ('name', 'last_name', 'second_last_name', 'email')}), + ( + ('Personal Info'), + {'fields': ('name', 'last_name', 'second_last_name', 'email')}, + ), (('Permissions'), {'fields': ('is_active', 'is_admin', 'groups', 'is_staff')}), (('Important dates'), {'fields': ('last_login', 'date_joined')}), ) add_fieldsets = ( - (None, { - 'classes': ('wide',), - 'fields': ('username', 'email', 'password1', 'password2', 'name', 'last_name', 'second_last_name'), - }), + ( + None, + { + 'classes': ('wide',), + 'fields': ( + 'username', + 'email', + 'password1', + 'password2', + 'name', + 'last_name', + 'second_last_name', + ), + }, + ), ) diff --git a/ecg/app/connector/migrations/0001_initial.py b/ecg/app/connector/migrations/0001_initial.py index 79c1c77..a16fedb 100644 --- a/ecg/app/connector/migrations/0001_initial.py +++ b/ecg/app/connector/migrations/0001_initial.py @@ -2,12 +2,11 @@ import django.contrib.auth.models import django.core.validators -from django.db import migrations, models import django.utils.timezone +from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ @@ -18,30 +17,149 @@ class Migration(migrations.Migration): migrations.CreateModel( name='ECGModel', fields=[ - ('id', models.AutoField(help_text='A unique identifier for each ECG', primary_key=True, serialize=False)), - ('date', models.DateTimeField(auto_now_add=True, help_text='The date of creation')), + ( + 'id', + models.AutoField( + help_text='A unique identifier for each ECG', + primary_key=True, + serialize=False, + ), + ), + ( + 'date', + models.DateTimeField( + auto_now_add=True, + help_text='The date of creation', + ), + ), ('leads', models.JSONField()), ], ), migrations.CreateModel( name='UserModel', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ( + 'id', + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'password', + models.CharField( + max_length=128, + verbose_name='password', + ), + ), + ( + 'last_login', + models.DateTimeField( + blank=True, + null=True, + verbose_name='last login', + ), + ), + ( + 'is_superuser', + models.BooleanField( + default=False, + help_text='Designates that this user has all' + ' permissions without explicitly assigning them.', + verbose_name='superuser status', + ), + ), + ( + 'first_name', + models.CharField( + blank=True, + max_length=150, + verbose_name='first name', + ), + ), + ( + 'is_staff', + models.BooleanField( + default=False, + help_text='Designates ' + 'whether the user' + ' can log into this admin site.', + verbose_name='staff status', + ), + ), + ( + 'is_active', + models.BooleanField( + default=True, + help_text='Designates whether this user' + ' should be treated as active. ' + 'Unselect this instead of deleting accounts.', + verbose_name='active', + ), + ), + ( + 'date_joined', + models.DateTimeField( + default=django.utils.timezone.now, + verbose_name='date joined', + ), + ), ('is_admin', models.BooleanField(default=False)), - ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='unique identifier of the user', max_length=50, unique=True, validators=[django.core.validators.RegexValidator(message='Username must contain only alphanumeric characters.', regex='^[a-zA-Z0-9_-]+$')])), + ( + 'username', + models.CharField( + error_messages={ + 'unique': 'A user with that username already exists.' # noqa: E501 + }, + help_text='unique identifier of the user', + max_length=50, + unique=True, + validators=[ + django.core.validators.RegexValidator( + message='Username must contain' + ' only alphanumeric characters.', + regex='^[a-zA-Z0-9_-]+$', + ) + ], + ), + ), ('name', models.CharField(max_length=20)), ('last_name', models.CharField(max_length=50)), - ('second_last_name', models.CharField(blank=True, max_length=50, null=True)), + ( + 'second_last_name', + models.CharField( + blank=True, + max_length=50, + null=True, + ), + ), ('email', models.EmailField(max_length=254, unique=True)), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ( + 'groups', + models.ManyToManyField( + blank=True, + help_text='The groups this user belongs to.' + ' A user will get all permissions' + ' granted to each of their groups.', + related_name='user_set', + related_query_name='user', + to='auth.Group', + verbose_name='groups', + ), + ), + ( + 'user_permissions', + models.ManyToManyField( + blank=True, + help_text='Specific permissions for this user.', + related_name='user_set', + related_query_name='user', + to='auth.Permission', + verbose_name='user permissions', + ), + ), ], options={ 'verbose_name': 'user', diff --git a/ecg/app/connector/migrations/0002_ecgmodel_user.py b/ecg/app/connector/migrations/0002_ecgmodel_user.py index 64c473f..b98c319 100644 --- a/ecg/app/connector/migrations/0002_ecgmodel_user.py +++ b/ecg/app/connector/migrations/0002_ecgmodel_user.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.23 on 2024-01-29 22:19 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): @@ -14,7 +14,13 @@ class Migration(migrations.Migration): migrations.AddField( model_name='ecgmodel', name='user', - field=models.ForeignKey(default=1, help_text='The user who created the ECG', on_delete=django.db.models.deletion.CASCADE, related_name='ecgs', to='connector.usermodel'), + field=models.ForeignKey( + default=1, + help_text='The user who created the ECG', + on_delete=django.db.models.deletion.CASCADE, + related_name='ecgs', + to='connector.usermodel', + ), preserve_default=False, ), ] diff --git a/ecg/app/connector/models.py b/ecg/app/connector/models.py index bc8edb8..5d5edb0 100644 --- a/ecg/app/connector/models.py +++ b/ecg/app/connector/models.py @@ -1,7 +1,6 @@ -from django.db import models from django.contrib.auth.models import AbstractUser from django.core.validators import RegexValidator -import json +from django.db import models class UserModel(AbstractUser): @@ -19,19 +18,34 @@ class UserModel(AbstractUser): error_messages={ 'unique': 'A user with that username already exists.', }, - help_text='unique identifier of the user') + help_text='unique identifier of the user', + ) name = models.CharField(max_length=20) last_name = models.CharField(max_length=50) - second_last_name = models.CharField(max_length=50, blank=True, null=True, ) + second_last_name = models.CharField( + max_length=50, + blank=True, + null=True, + ) email = models.EmailField(unique=True) class ECGModel(models.Model): - id = models.AutoField(primary_key=True, help_text='A unique identifier for each ECG') - date = models.DateTimeField(auto_now_add=True, help_text='The date of creation') + id = models.AutoField( + primary_key=True, + help_text='A unique identifier for each ECG', + ) + date = models.DateTimeField( + auto_now_add=True, + help_text='The date of creation', + ) leads = models.JSONField() - user = models.ForeignKey(UserModel, on_delete=models.CASCADE, related_name='ecgs', - help_text='The user who created the ECG') + user = models.ForeignKey( + UserModel, + on_delete=models.CASCADE, + related_name='ecgs', + help_text='The user who created the ECG', + ) def __str__(self): - return f"ECG {self.id}" + return f'ECG {self.id}' diff --git a/ecg/app/connector/operations.py b/ecg/app/connector/operations.py index 62cb606..ffdeeae 100644 --- a/ecg/app/connector/operations.py +++ b/ecg/app/connector/operations.py @@ -1,24 +1,20 @@ import json - -import numpy as np -from connector import serializers -from connector.models import ECGModel -from django.core.exceptions import ValidationError, ObjectDoesNotExist import logging -from collections import namedtuple -from django.forms.models import model_to_dict +import numpy as np +from django.core.exceptions import ValidationError from rest_framework import status +from rest_framework.exceptions import APIException, NotFound -ErrorTuple = namedtuple('Error', ['code', 'status', 'details']) +from connector import serializers +from connector.models import ECGModel logger = logging.getLogger(__name__) -ERROR_LIST = [ - ErrorTuple('RESOURCE_NOT_AVAILABLE', status.HTTP_404_NOT_FOUND, 'Resource not available'), - ErrorTuple('INVALID_VALUE', status.HTTP_400_BAD_REQUEST, 'Invalid value'), - ErrorTuple('INTERNAL_SERVER_ERROR', status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal server error') -] + +class CustomValidationError(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = 'Validation error' class ECGOperations: @@ -46,10 +42,12 @@ def get_zero_crossing_count(self, ecg_record): signal = lead.get('signal') zero_crossing_count = self._zero_crossing(signal) - response.append({ - 'zero_crossing_count': zero_crossing_count, - 'lead_name': lead.get('name') - }) + response.append( + { + 'zero_crossing_count': zero_crossing_count, + 'lead_name': lead.get('name'), + } + ) return response @@ -57,37 +55,28 @@ def get_ecg_instance(self, ecg_id): try: ecg_record = ECGModel.objects.get(pk=ecg_id) return ecg_record - except ECGModel.DoesNotExist: - error_message = 'Model/Record not found' - logger.error(error_message) - raise ObjectDoesNotExist(ERROR_LIST.RESOURCE_NOT_AVAILABLE, error_message) - except ValidationError as e: - logger.error(e.message) - raise ValidationError(ERROR_LIST.INVALID_VALUE, e.message) - except Exception as e: - # log any other kind of error - logger.error(e) - raise Exception(ERROR_LIST.INTERNAL_SERVER_ERROR, e.__cause__) - - def create_ecg_record(self, ecg_data, context): - """ - Creates ECG record in database - - ecg_data: ECG data - """ - try: - serializer = self.serializer_class(data=ecg_data, context=context) - except ECGModel.DoesNotExist: - error_message = 'Model/Record not found' - logger.error(error_message) - raise ObjectDoesNotExist(ERROR_LIST.RESOURCE_NOT_AVAILABLE, error_message) + except ECGModel.DoesNotExist as e: + raise NotFound(e) except ValidationError as e: - logger.error(e.message) - raise ValidationError(ERROR_LIST.INVALID_VALUE, e.message) - except Exception as e: - # log any other kind of error - logger.error(e) - raise Exception(ERROR_LIST.INTERNAL_SERVER_ERROR, e.__cause__) - - serializer.is_valid(raise_exception=True) - serializer.save() + raise ValidationError(e) + except APIException as e: + raise APIException(e) + + +def create_ecg_record(self, ecg_data, context): + """ + Creates ECG record in database + + ecg_data: ECG data + """ + try: + serializer = self.serializer_class(data=ecg_data, context=context) + except ECGModel.DoesNotExist as e: + raise NotFound(e) + except ValidationError as e: + raise ValidationError(e) + except APIException as e: + raise APIException(e) + + serializer.is_valid(raise_exception=True) + serializer.save() diff --git a/ecg/app/connector/permissions.py b/ecg/app/connector/permissions.py index b4438a1..4f48479 100644 --- a/ecg/app/connector/permissions.py +++ b/ecg/app/connector/permissions.py @@ -3,7 +3,7 @@ class HasECGDataPermission(BasePermission): - message = "Permission Denied" + message = 'Permission Denied' def has_object_permission(self, request, view, obj): # Check if the authenticated user matches the user who created the ECG diff --git a/ecg/app/connector/serializers.py b/ecg/app/connector/serializers.py index ef323a3..2d826f7 100644 --- a/ecg/app/connector/serializers.py +++ b/ecg/app/connector/serializers.py @@ -1,10 +1,10 @@ -import json +import re -from rest_framework import serializers -from connector.models import ECGModel, UserModel from django.contrib.auth import authenticate from django.contrib.auth.hashers import make_password -import re +from rest_framework import serializers + +from connector.models import ECGModel, UserModel class UserLoginSerializer(serializers.Serializer): @@ -28,7 +28,9 @@ def validate(self, data): else: raise serializers.ValidationError('Invalid credentials') else: - raise serializers.ValidationError('Must include both username/email and password') + raise serializers.ValidationError( + 'Must include both username and password', + ) return data @@ -43,31 +45,56 @@ class Meta: def validate_password(self, value): # Check if password has at least 8 characters if len(value) < 8: - raise serializers.ValidationError("Password must be at least 8 characters long.") + raise ( + serializers.ValidationError( + 'Password must be at least 8 characters long.', + ), + ) # Check if password has at least one uppercase letter if not any(char.isupper() for char in value): - raise serializers.ValidationError("Password must contain at least one uppercase letter.") + raise serializers.ValidationError( + 'Password must contain at least one uppercase letter.' + ) # Check if password has at least one special character - if not re.search(r'[!@#$%^&*(),.?":{}|<>]', value): - raise serializers.ValidationError("Password must contain at least one special character.") + if not re.search(r'[!@#$%^&*(),.?":{}|<>]', value): # noqa: E501 + raise serializers.ValidationError( + 'Password must contain at least one special character.' + ) return value def create(self, validated_data): # Hash the password before saving validated_data['password'] = make_password(validated_data['password']) - return super(UserRegistrationSerializer, self).create(validated_data) + return super().create(validated_data) class LeadsSerializer(serializers.Serializer): - LEAD_IDENTIFIERS = ['I', 'II', 'III', 'aVR', 'aVL', 'aVF', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6'] - - name = serializers.ChoiceField(choices=[(lead, lead) for lead in LEAD_IDENTIFIERS], - help_text='The lead identifier of ECG leads') - - num_samples = serializers.IntegerField(required=False, help_text='The sample size of the signal') + LEAD_IDENTIFIERS = [ + 'I', + 'II', + 'III', + 'aVR', + 'aVL', + 'aVF', + 'V1', + 'V2', + 'V3', + 'V4', + 'V5', + 'V6', + ] + + name = serializers.ChoiceField( + choices=[(lead, lead) for lead in LEAD_IDENTIFIERS], + help_text='The lead identifier of ECG leads', + ) + + num_samples = serializers.IntegerField( + required=False, help_text='The sample size of the signal' + ) signal = serializers.CharField(help_text='A list of integer values') diff --git a/ecg/app/connector/urls.py b/ecg/app/connector/urls.py index fef7ad9..0fb7fe7 100644 --- a/ecg/app/connector/urls.py +++ b/ecg/app/connector/urls.py @@ -13,6 +13,14 @@ views.ECGView.as_view({'get': 'retrieve_zero_crossing'}), name='ecg_monitoring', ), - path('api/login/', views.UserLoginView.as_view(), name='api-login'), - path('api/registration/', views.UserRegistrationView.as_view(), name='api-register'), + path( + 'api/login/', + views.UserLoginView.as_view(), + name='api-login', + ), + path( + 'api/registration/', + views.UserRegistrationView.as_view(), + name='api-register', + ), ] diff --git a/ecg/app/connector/views.py b/ecg/app/connector/views.py index 73fb65b..7441bfe 100644 --- a/ecg/app/connector/views.py +++ b/ecg/app/connector/views.py @@ -1,17 +1,19 @@ -from rest_framework import status, viewsets, permissions -from connector import serializers -from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin -from .permissions import HasECGDataPermission -from rest_framework.authentication import TokenAuthentication -from rest_framework.permissions import IsAuthenticated from drf_spectacular.utils import OpenApiResponse, extend_schema -from rest_framework.views import APIView +from rest_framework import permissions, status, viewsets +from rest_framework.authentication import TokenAuthentication from rest_framework.authtoken.models import Token from rest_framework.response import Response -from rest_framework.permissions import AllowAny -from rest_framework.exceptions import PermissionDenied +from rest_framework.views import APIView + +from connector.operations import ECGOperations +from connector.serializers import ( + ECGModelSerializer, + ECGResponseSerializer, + UserLoginSerializer, + UserRegistrationSerializer, +) -from connector import operations +from .permissions import HasECGDataPermission @extend_schema(tags=['user']) @@ -19,23 +21,22 @@ class UserLoginView(APIView): """ User login view """ - permission_classes = (AllowAny,) - serializer_class = serializers.UserLoginSerializer + permission_classes = (permissions.AllowAny,) + + serializer_class = UserLoginSerializer @extend_schema( responses={ - 200: OpenApiResponse( - description='Request success' - ), + 200: OpenApiResponse(description='Request success'), 400: OpenApiResponse(description='Invalid value'), 403: OpenApiResponse(description='Permission Denied'), 500: OpenApiResponse(description='Internal server error'), }, - request=serializer_class + request=serializer_class, ) def post(self, request): - serializer = serializers.UserLoginSerializer(data=request.data) + serializer = UserLoginSerializer(data=request.data) serializer.is_valid(raise_exception=True) user = serializer.validated_data['user'] @@ -49,23 +50,23 @@ class UserRegistrationView(APIView): """ User registration view """ - permission_classes = (AllowAny,) - serializer_class = serializers.UserRegistrationSerializer + permission_classes = (permissions.AllowAny,) + + serializer_class = UserRegistrationSerializer @extend_schema( responses={ - 200: OpenApiResponse( - description='Request success' - ), + 200: OpenApiResponse(description='Request success'), 400: OpenApiResponse(description='Invalid value'), 403: OpenApiResponse(description='Permission Denied'), 500: OpenApiResponse(description='Internal server error'), }, - request=serializer_class + request=serializer_class, ) def post(self, request, *args, **kwargs): - # Other than admin that was required, this endpoint also can create/register users + # Other than admin that was required, + # this endpoint also can create/register users serializer = self.serializer_class(data=request.data) if serializer.is_valid(): serializer.save() @@ -78,15 +79,17 @@ class ECGView(viewsets.ViewSet): """ ECG Monitoring application service """ + authentication_classes = (TokenAuthentication,) - permission_classes = (IsAuthenticated,) + permission_classes = (permissions.IsAuthenticated,) - serializer_class_response = serializers.ECGResponseSerializer - serializer_class_request = serializers.ECGModelSerializer + serializer_class_response = ECGResponseSerializer + serializer_class_request = ECGModelSerializer def get_permissions(self): """ - Instantiates and returns the list of permissions that this view/method requires. + Instantiates and returns the list of permissions + that this view/method requires. """ if self.action == 'retrieve_zero_crossing': return [permissions.IsAuthenticated(), HasECGDataPermission()] @@ -96,42 +99,64 @@ def get_permissions(self): @extend_schema( responses={ 200: OpenApiResponse( - description='Request success' + description='Request success', + ), + 400: OpenApiResponse( + description='Invalid value', + ), + 403: OpenApiResponse( + description='Permission Denied', + ), + 500: OpenApiResponse( + description='Internal server error', ), - 400: OpenApiResponse(description='Invalid value'), - 403: OpenApiResponse(description='Permission Denied'), - 500: OpenApiResponse(description='Internal server error'), }, - request=serializer_class_request + request=serializer_class_request, ) def create(self, request): """ Receives ECG data for processing """ - operations.ECGOperations().create_ecg_record(ecg_data=request.data, context={'request': request}) + ECGOperations().create_ecg_record( + ecg_data=request.data, + context={'request': request}, + ) - return Response(f'ECG record created successfully', status=status.HTTP_200_OK) + return Response( + data='ECG record created successfully', + status=status.HTTP_200_OK, + ) @extend_schema( responses={ 200: OpenApiResponse( - description='Request success', response=serializer_class_response + description='Request success', + response=serializer_class_response, + ), + 404: OpenApiResponse( + description='Resource not available', + ), + 400: OpenApiResponse( + description='Invalid value', + ), + 403: OpenApiResponse( + description='Permission Denied', + ), + 500: OpenApiResponse( + description='Internal server error', ), - 404: OpenApiResponse(description='Resource not available'), - 400: OpenApiResponse(description='Invalid value'), - 403: OpenApiResponse(description='Permission Denied'), - 500: OpenApiResponse(description='Internal server error'), }, ) def retrieve_zero_crossing(self, request, ecg_id): """ - Returns the number of times each ECG channel crosses zero + Returns the number of times each ECG channel crosses zero """ - ecg_instance = operations.ECGOperations().get_ecg_instance(ecg_id) + ecg_instance = ECGOperations().get_ecg_instance(ecg_id) - # Check if the authenticated user has permission to retrieve this ECG data + # Check if the authenticated user + # has permission to retrieve this ECG data self.check_object_permissions(request, ecg_instance) - response = operations.ECGOperations().get_zero_crossing_count(ecg_instance) + response = ECGOperations().get_zero_crossing_count(ecg_instance) return Response(response, status=status.HTTP_200_OK) diff --git a/ecg/app/ecg/asgi.py b/ecg/app/ecg/asgi.py index e177972..9132fd3 100644 --- a/ecg/app/ecg/asgi.py +++ b/ecg/app/ecg/asgi.py @@ -11,6 +11,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ecg.settings") +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ecg.settings') application = get_asgi_application() diff --git a/ecg/app/ecg/settings.py b/ecg/app/ecg/settings.py index 807d643..e1704a2 100644 --- a/ecg/app/ecg/settings.py +++ b/ecg/app/ecg/settings.py @@ -9,9 +9,11 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/4.1/ref/settings/ """ +# flake8: noqa: E501 + -from pathlib import Path import os +from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -23,7 +25,7 @@ AUTH_USER_MODEL = 'connector.UserModel' # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "django-insecure-@j^qopfkj#nn*t4872(n-vdt(+8e8a6ka13mnzm#kluy*ktxn#" +SECRET_KEY = 'django-insecure-@j^qopfkj#nn*t4872(n-vdt(+8e8a6ka13mnzm#kluy*ktxn#' # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -35,26 +37,26 @@ INSTALLED_APPS = [ 'rest_framework', 'rest_framework.authtoken', - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", - "connector", + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'connector', 'health_check', 'drf_spectacular', 'drf_spectacular_sidecar', ] MIDDLEWARE = [ - "django.middleware.security.SecurityMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] REST_FRAMEWORK = { @@ -79,21 +81,21 @@ TEMPLATES = [ { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.debug", - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', ], }, }, ] -WSGI_APPLICATION = "ecg.wsgi.application" +WSGI_APPLICATION = 'ecg.wsgi.application' # Database # https://docs.djangoproject.com/en/4.1/ref/settings/#databases @@ -114,25 +116,25 @@ AUTH_PASSWORD_VALIDATORS = [ { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', }, { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', }, { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', }, { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', }, ] # Internationalization # https://docs.djangoproject.com/en/4.1/topics/i18n/ -LANGUAGE_CODE = "en-us" +LANGUAGE_CODE = 'en-us' -TIME_ZONE = "UTC" +TIME_ZONE = 'UTC' USE_I18N = True @@ -141,12 +143,12 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.1/howto/static-files/ -STATIC_URL = "/static/" +STATIC_URL = '/static/' # Default primary key field type # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' """ Swagger configuration diff --git a/ecg/app/ecg/urls.py b/ecg/app/ecg/urls.py index 07d7f77..38878b7 100644 --- a/ecg/app/ecg/urls.py +++ b/ecg/app/ecg/urls.py @@ -10,22 +10,23 @@ 1. Add an import: from other_app.views import Home 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') Including another URLconf - 1. Import the include() function: from django.urls import include, path + 1. Import the include() function: from django.urls import include, path # noqa 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ -from django.conf.urls import url +# flake8: noqa: E501 + + from django.contrib import admin from django.contrib.staticfiles.urls import staticfiles_urlpatterns -from django.urls import include +from django.urls import include, path from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView urlpatterns = [ - url(r'^healthz/', include('health_check.urls')), - url(r'^api/', include('connector.urls')), - url(r'^schema/$', SpectacularAPIView.as_view(), name='schema'), - url(r'^swagger/$', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), - url(r'^redoc/$', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), - url(r'^admin/', admin.site.urls), + path('api/', include('connector.urls')), + path('schema/', SpectacularAPIView.as_view(), name='schema'), + path('swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + path('redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), + path('admin/', admin.site.urls), + path('healthz/', include('health_check.urls')), ] - urlpatterns += staticfiles_urlpatterns() diff --git a/ecg/app/ecg/wsgi.py b/ecg/app/ecg/wsgi.py index f891feb..9a16aca 100644 --- a/ecg/app/ecg/wsgi.py +++ b/ecg/app/ecg/wsgi.py @@ -11,6 +11,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ecg.settings") +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ecg.settings') application = get_wsgi_application() diff --git a/ecg/app/manage.py b/ecg/app/manage.py index 6a64736..081bb99 100755 --- a/ecg/app/manage.py +++ b/ecg/app/manage.py @@ -6,17 +6,17 @@ def main(): """Run administrative tasks.""" - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ecg.settings") + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ecg.settings') try: from django.core.management import execute_from_command_line except ImportError as exc: raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" + 'Could not import Django. Are you sure it is installed and ' + 'available on your PYTHONPATH environment variable? Did you ' + 'forget to activate a virtual environment?' ) from exc execute_from_command_line(sys.argv) -if __name__ == "__main__": +if __name__ == '__main__': main() diff --git a/ecg/config/requirements.txt b/ecg/config/requirements.txt index dadfd53..72d7432 100644 --- a/ecg/config/requirements.txt +++ b/ecg/config/requirements.txt @@ -15,6 +15,6 @@ drf-spectacular==0.26.2 drf-spectacular-sidecar==2023.5.1 gunicorn==20.1.0 - numpy==1.26.3 +pre-commit==2.20.0 diff --git a/ecg/pyproject.toml b/ecg/pyproject.toml new file mode 100644 index 0000000..0db014c --- /dev/null +++ b/ecg/pyproject.toml @@ -0,0 +1,53 @@ +[tool.black] +line-length = 99 +target-version = ['py310'] +skip-string-normalization = true +include = '\.pyi?$' +force-exclude = ''' +( +\.git +| \.hg +| \.mypy_cache +| \.tox +| \.venv +| _build +| buck-out +| build +| \/migrations\/ +) +''' + +[tool.commitizen] +version = "0.2.0" +version_files = [ + "__version__.py", +] + +[tool.docformatter] +wrap-summaries=72 +in-place=true + +[tool.flake8] +max-doc-length = 99 +max-line-length = 99 +exclude = [ + # No need to traverse our git directory + ".git", + # There's no value in checking cache directories + "__pycache__", + # This contains our built documentation + "docs/*", + "connector/migrations" + # TODO: Add new directories that we don't want to check, for example: database migrations. +] +count = true + +[tool.isort] +profile = "black" +# TODO: Add new local application/library package. +known_first_party = ["connector", "ecg"] +sections = ["FUTURE","STDLIB","THIRDPARTY","FIRSTPARTY","LOCALFOLDER"] +line_length = 99 +combine_as_imports = true +# TODO: Add new directories that we don't want to check, for example: database migrations. +skip_glob = ["*/migrations/*"] From ad8edb495e6287449e87bb49e2923a77511fb59b Mon Sep 17 00:00:00 2001 From: Tiko Kobiashvili Date: Wed, 31 Jan 2024 00:00:45 +0100 Subject: [PATCH 6/9] implemented tests for all services, added new endpoints delete/get/update ecg --- docker-compose.yml | 20 ++ ecg/.pre-commit-config.yaml | 17 +- ecg/app/connector/admin.py | 20 +- .../migrations/0003_auto_20240130_2141.py | 22 ++ ecg/app/connector/models.py | 2 +- ecg/app/connector/operations.py | 66 +++-- ecg/app/connector/serializers.py | 48 +++- ecg/app/connector/tests/test_models.py | 158 +++++++++++ ecg/app/connector/tests/test_operations.py | 126 +++++++++ ecg/app/connector/tests/test_serializers.py | 251 ++++++++++++++++++ ecg/app/connector/tests/test_urls.py | 20 ++ ecg/app/connector/tests/test_views.py | 159 +++++++++++ ecg/app/connector/urls.py | 19 +- ecg/app/connector/utils/__init__.py | 0 ecg/app/connector/utils/test_mocker.py | 7 + ecg/app/connector/utils/tools.py | 15 ++ ecg/app/connector/views.py | 128 ++++++++- ecg/app/ecg/settings.py | 31 ++- ecg/app/ecg/urls.py | 16 +- ecg/config/requirements.txt | 7 + 20 files changed, 1064 insertions(+), 68 deletions(-) create mode 100644 ecg/app/connector/migrations/0003_auto_20240130_2141.py create mode 100644 ecg/app/connector/utils/__init__.py create mode 100644 ecg/app/connector/utils/test_mocker.py create mode 100644 ecg/app/connector/utils/tools.py diff --git a/docker-compose.yml b/docker-compose.yml index d0eea22..7f09f6a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,10 +17,30 @@ services: - VIRTUAL_PORT=8000 depends_on: - ecg-db + - redis volumes: - ./ecg/app:/local command: bash -c "gunicorn ecg.wsgi --workers 1 -b 0.0.0.0:8000 --reload" + redis-server: + image: redis:5.0.4 + expose: + - "6379" + command: [ "redis-server", "--appendonly", "yes" ] + volumes: + - ./certs:/certs + + redis: + depends_on: + - redis-server + image: runnable/redis-stunnel + volumes: + - ./certs/rediscert.pem:/stunnel/private.pem:ro + expose: + - "6380" + environment: + - REDIS_PORT_6379_TCP_ADDR=redis-server + - REDIS_PORT_6379_TCP_PORT=6379 ecg-db: platform: linux/x86_64 # Necessary for MacOS M1 Chip diff --git a/ecg/.pre-commit-config.yaml b/ecg/.pre-commit-config.yaml index d255d0a..32277cc 100644 --- a/ecg/.pre-commit-config.yaml +++ b/ecg/.pre-commit-config.yaml @@ -5,8 +5,8 @@ # pre-commit install # default_language_version: - python: python3.10 # TODO: upgrade to newer Python version if it's required. -default_stages: [commit] + python: python3.10 # TODO: upgrade to newer Python version if it's required. +default_stages: [ commit ] repos: # black and docformatter always at top of the list. - repo: https://github.com/ambv/black @@ -14,37 +14,38 @@ repos: hooks: - id: black description: Python code formatter. + args: [ --line-length=79 ] - repo: https://github.com/PyCQA/docformatter rev: v1.5.1 hooks: - id: docformatter description: Formats docstrings to follow PEP 257. - args: [--config=./pyproject.toml] + args: [ --config=./pyproject.toml ] - repo: https://github.com/PyCQA/flake8 rev: 6.0.0 hooks: - id: flake8 description: Command-line utility for enforcing style consistency across Python projects. entry: pflake8 - additional_dependencies: [pyproject-flake8] + additional_dependencies: [ pyproject-flake8 ] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - id: trailing-whitespace description: Trims trailing whitespace. - types: [python] + types: [ python ] - id: double-quote-string-fixer description: Replaces double quoted strings with single quoted strings. - types: [python] + types: [ python ] - repo: https://github.com/pycqa/isort rev: 5.12.0 hooks: - id: isort description: Reorders imports in python files. - args: [--settings-path=ecg/pyproject.toml, --filter-files] + args: [ --settings-path=ecg/pyproject.toml, --filter-files, --line-length=79 ] - repo: https://github.com/asottile/pyupgrade rev: v3.3.1 hooks: - id: pyupgrade description: Automatically upgrade syntax for newer versions. - args: [--py36-plus] # TODO: upgrade to newer Python version if it's required. + args: [ --py36-plus ] # TODO: upgrade to newer Python version if it's required. diff --git a/ecg/app/connector/admin.py b/ecg/app/connector/admin.py index fcc3c6f..9f55891 100644 --- a/ecg/app/connector/admin.py +++ b/ecg/app/connector/admin.py @@ -18,17 +18,27 @@ def has_add_permission(self, request): class UserAdmin(BaseUserAdmin): - list_display = ('username', 'email', 'name', 'last_name', 'is_admin') - search_fields = ('username', 'email', 'name', 'last_name') + list_display = ('username', 'email', 'first_name', 'last_name', 'is_admin') + search_fields = ('username', 'email', 'first_name', 'last_name') ordering = ('username',) fieldsets = ( (None, {'fields': ('username', 'password')}), ( ('Personal Info'), - {'fields': ('name', 'last_name', 'second_last_name', 'email')}, + { + 'fields': ( + 'first_name', + 'last_name', + 'second_last_name', + 'email', + ) + }, + ), + ( + ('Permissions'), + {'fields': ('is_active', 'is_admin', 'groups', 'is_staff')}, ), - (('Permissions'), {'fields': ('is_active', 'is_admin', 'groups', 'is_staff')}), (('Important dates'), {'fields': ('last_login', 'date_joined')}), ) @@ -42,7 +52,7 @@ class UserAdmin(BaseUserAdmin): 'email', 'password1', 'password2', - 'name', + 'first_name', 'last_name', 'second_last_name', ), diff --git a/ecg/app/connector/migrations/0003_auto_20240130_2141.py b/ecg/app/connector/migrations/0003_auto_20240130_2141.py new file mode 100644 index 0000000..59acea9 --- /dev/null +++ b/ecg/app/connector/migrations/0003_auto_20240130_2141.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.23 on 2024-01-30 21:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('connector', '0002_ecgmodel_user'), + ] + + operations = [ + migrations.RemoveField( + model_name='usermodel', + name='name', + ), + migrations.AlterField( + model_name='usermodel', + name='first_name', + field=models.CharField(max_length=20), + ), + ] diff --git a/ecg/app/connector/models.py b/ecg/app/connector/models.py index 5d5edb0..ad4ff1a 100644 --- a/ecg/app/connector/models.py +++ b/ecg/app/connector/models.py @@ -20,7 +20,7 @@ class UserModel(AbstractUser): }, help_text='unique identifier of the user', ) - name = models.CharField(max_length=20) + first_name = models.CharField(max_length=20) last_name = models.CharField(max_length=50) second_last_name = models.CharField( max_length=50, diff --git a/ecg/app/connector/operations.py b/ecg/app/connector/operations.py index ffdeeae..b9bb345 100644 --- a/ecg/app/connector/operations.py +++ b/ecg/app/connector/operations.py @@ -3,7 +3,6 @@ import numpy as np from django.core.exceptions import ValidationError -from rest_framework import status from rest_framework.exceptions import APIException, NotFound from connector import serializers @@ -12,11 +11,6 @@ logger = logging.getLogger(__name__) -class CustomValidationError(APIException): - status_code = status.HTTP_400_BAD_REQUEST - default_detail = 'Validation error' - - class ECGOperations: serializer_class = serializers.ECGModelSerializer @@ -62,21 +56,51 @@ def get_ecg_instance(self, ecg_id): except APIException as e: raise APIException(e) + def create_ecg_record(self, ecg_data, context): + """ + Creates ECG record in database -def create_ecg_record(self, ecg_data, context): - """ - Creates ECG record in database + ecg_data: ECG data + """ + try: + serializer = self.serializer_class(data=ecg_data, context=context) + except ECGModel.DoesNotExist as e: + raise NotFound(e) + except ValidationError as e: + raise ValidationError(e) + except APIException as e: + raise APIException(e) + + serializer.is_valid(raise_exception=True) + serializer.save() - ecg_data: ECG data - """ - try: - serializer = self.serializer_class(data=ecg_data, context=context) - except ECGModel.DoesNotExist as e: - raise NotFound(e) - except ValidationError as e: - raise ValidationError(e) - except APIException as e: - raise APIException(e) + def update_ecg_record(self, ecg_data, context, ecg_instance): + """ + Creates ECG record in database + + ecg_data: ECG data + context: {'contect':request} + ecg_instance: ecg instance on given id + """ + try: + serializer = self.serializer_class( + ecg_instance, data=ecg_data, context=context + ) + serializer.is_valid(raise_exception=True) + serializer.save() + except ECGModel.DoesNotExist as e: + raise NotFound(e) + except ValidationError as e: + raise ValidationError(e) + except APIException as e: + raise APIException(e) - serializer.is_valid(raise_exception=True) - serializer.save() + def delete_ecg_record(self, ecg_instance): + try: + ecg_instance.delete() + except ECGModel.DoesNotExist as e: + raise NotFound(e) + except ValidationError as e: + raise ValidationError(e) + except APIException as e: + raise APIException(e) diff --git a/ecg/app/connector/serializers.py b/ecg/app/connector/serializers.py index 2d826f7..ffc8ef9 100644 --- a/ecg/app/connector/serializers.py +++ b/ecg/app/connector/serializers.py @@ -1,3 +1,4 @@ +import json import re from django.contrib.auth import authenticate @@ -10,19 +11,13 @@ class UserLoginSerializer(serializers.Serializer): username = serializers.CharField(required=False) password = serializers.CharField(write_only=True) - email = serializers.EmailField(required=False) def validate(self, data): username = data.get('username') - email = data.get('email') password = data.get('password') - if (username or email) and password: - if username: - user = authenticate(username=username, password=password) - elif email: - user = authenticate(email=email, password=password) - else: - raise serializers.ValidationError('Invalid credentials') + if username and password: + user = authenticate(username=username, password=password) + if user: data['user'] = user else: @@ -45,10 +40,8 @@ class Meta: def validate_password(self, value): # Check if password has at least 8 characters if len(value) < 8: - raise ( - serializers.ValidationError( - 'Password must be at least 8 characters long.', - ), + raise serializers.ValidationError( + 'Password must be at least 8 characters long.' ) # Check if password has at least one uppercase letter @@ -95,7 +88,33 @@ class LeadsSerializer(serializers.Serializer): num_samples = serializers.IntegerField( required=False, help_text='The sample size of the signal' ) - signal = serializers.CharField(help_text='A list of integer values') + signal = serializers.CharField( + help_text='A list of integer values in the format [1, 2, 3, -4, 2, -6]' + ) + + def validate_signal(self, value): + try: + # Try to parse the input string as + # a JSON list of integers or floats + parsed_data = json.loads(value) + + # Ensure the parsed data is a list of numbers (integers or floats) + if not isinstance(parsed_data, list) or not all( + isinstance(x, (int, float)) for x in parsed_data + ): + raise serializers.ValidationError( + 'Invalid signal format. ' + 'Must be a list of numbers (integers or floats).' + ) + + return value # Return originally received string + except (TypeError, ValueError): + # If parsing fails, raise a validation error + raise serializers.ValidationError( + 'Invalid signal format.' + ' Must be a valid JSON list' + ' of numbers (integers or floats).' + ) class ECGModelSerializer(serializers.ModelSerializer): @@ -105,6 +124,7 @@ class ECGModelSerializer(serializers.ModelSerializer): class Meta: model = ECGModel fields = '__all__' + read_only_fields = ('date',) class ECGResponseSerializer(serializers.Serializer): diff --git a/ecg/app/connector/tests/test_models.py b/ecg/app/connector/tests/test_models.py index e69de29..91e0dc6 100644 --- a/ecg/app/connector/tests/test_models.py +++ b/ecg/app/connector/tests/test_models.py @@ -0,0 +1,158 @@ +import datetime + +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.test import TestCase + +from connector.models import ECGModel + + +class UserModelTest(TestCase): + def setUp(self): + self.valid_user_data = { + 'username': 'testuser', + 'password': 'testpassword', + 'first_name': 'name', + 'last_name': 'last_name', + 'email': 'testuser@example.com', + } + + def test_create_user(self): + user = get_user_model().objects.create_user(**self.valid_user_data) + self.assertEqual(user.username, self.valid_user_data['username']) + self.assertEqual(user.first_name, self.valid_user_data['first_name']) + self.assertEqual(user.last_name, self.valid_user_data['last_name']) + self.assertEqual(user.email, self.valid_user_data['email']) + self.assertFalse(user.is_admin) + + def test_create_superuser(self): + superuser = get_user_model().objects.create_superuser( + **self.valid_user_data, is_admin=True + ) + self.assertTrue(superuser.is_admin) + + def test_username_validation(self): + # Test username validation + invalid_user_data = { + 'username': 'user with spaces', + 'password': 'testpassword', + 'first_name': 'name', + 'last_name': 'lastname', + 'email': 'testuser@example.com', + } + + invalid_user = get_user_model()(**invalid_user_data) + + with self.assertRaisesMessage( + ValidationError, + 'Username must contain only alphanumeric characters.', + ): + invalid_user.full_clean() + # Ensure that the user is not saved to the database + self.assertFalse( + get_user_model() + .objects.filter(username=invalid_user_data['username']) + .exists() + ) + + valid_user = get_user_model()(**self.valid_user_data) + valid_user.save() + + # Check that the user is saved successfully + self.assertTrue( + get_user_model() + .objects.filter(username=self.valid_user_data['username']) + .exists() + ) + + def test_email_uniqueness(self): + # Test email uniqueness validation + user1 = get_user_model().objects.create_user( # noqa E501 + **self.valid_user_data + ) + duplicate_user_data = { + 'username': 'anotheruser', + 'password': 'anotherpassword', + 'first_name': 'name', + 'last_name': 'last_name', + 'email': self.valid_user_data['email'], # Reusing the same email + } + + # Catch the IntegrityError and + # check if it's related to the unique constraint + with self.assertRaises(IntegrityError) as context: + get_user_model().objects.create_user(**duplicate_user_data) + + # Check if the IntegrityError is related to + # the unique constraint violation + self.assertIn( + 'UNIQUE constraint failed: connector_usermodel.email', + str(context.exception), + ) + + +class ECGModelTest(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user( + username='testuser', + password='testpassword', + email='testuser@example.com', + ) + + self.valid_ecg_data = { + 'leads': [ + { + 'name': 'I', + 'num_samples': 0, + 'signal': '[1,2,3,-4,2,-6]', + } + ], + 'user': self.user, + } + + def test_create_valid_ecg(self): + # Test creating a valid ECG + ecg = ECGModel.objects.create(**self.valid_ecg_data) + + # Check that the ECG is created successfully + self.assertIsNotNone(ecg.id) + self.assertIsInstance(ecg.date, datetime.datetime) + self.assertEqual(ecg.user, self.user) + self.assertEqual(str(ecg), f'ECG {ecg.id}') + + def test_create_invalid_ecg_missing_leads(self): + # Test creating an ECG with missing 'leads' field + invalid_ecg_data = self.valid_ecg_data.copy() + invalid_ecg_data['leads'] = None + + # Catch the IntegrityError and + # check if it's related to the unique constraint + with self.assertRaises(IntegrityError) as context: + ECGModel.objects.create(**invalid_ecg_data) + + # Check if the IntegrityError is + # related to the unique constraint violation + self.assertIn( + 'NOT NULL constraint failed: connector_ecgmodel.leads', + str(context.exception), + ) + + def test_create_invalid_ecg_invalid_user(self): + # Test creating an ECG with an invalid 'user' field + invalid_ecg_data = self.valid_ecg_data.copy() + invalid_ecg_data['user'] = 999 # Assuming there is no user with ID 999 + + # Try to create the ECG and expect a ValueError + with self.assertRaises(ValueError) as context: + ECGModel.objects.create(**invalid_ecg_data) + + # Check if the ValueError is related to the UserModel instance + message = ( + 'Cannot assign "999": ' + '"ECGModel.user" must be a "UserModel" instance' + ) + self.assertIn( + message, + str(context.exception), + ) diff --git a/ecg/app/connector/tests/test_operations.py b/ecg/app/connector/tests/test_operations.py index e69de29..b64b16c 100644 --- a/ecg/app/connector/tests/test_operations.py +++ b/ecg/app/connector/tests/test_operations.py @@ -0,0 +1,126 @@ +from unittest.mock import MagicMock, patch + +from django.core.exceptions import ValidationError +from django.test import TestCase +from rest_framework.exceptions import APIException, NotFound + +from connector.models import ECGModel +from connector.operations import ECGOperations + + +class ECGOperationsTests(TestCase): + def setUp(self): + self.ecg_operations = ECGOperations() + self.ecg_data = { + 'leads': [ + { + 'name': 'I', + 'num_samples': 0, + 'signal': '[1,2,3,-4,2,-6]', + } + ] + } + self.context = {'request': MagicMock()} + + @patch('connector.serializers.ECGModelSerializer') + def test_create_ecg_record(self, mock_ecg_model_serializer): + # Mocking the serializer instance + serializer_instance = mock_ecg_model_serializer.return_value + serializer_instance.is_valid.return_value = True + serializer_instance.save.return_value = MagicMock() + + # Mocking the create_ecg_record method + with patch( + 'connector.operations.ECGOperations.serializer_class', + mock_ecg_model_serializer, + ): + self.ecg_operations.create_ecg_record(ecg_data={}, context={}) + + # Assert that the serializer's is_valid and save methods are called + serializer_instance.is_valid.assert_called_once() + serializer_instance.save.assert_called_once() + + @patch('connector.operations.ECGModel.objects.get') + def test_get_ecg_instance(self, mock_get_ecg_model): + # Mocking the ECGModel.objects.get method + mock_ecg_instance = MagicMock(spec=ECGModel) + mock_get_ecg_model.return_value = mock_ecg_instance + + # Call the get_ecg_instance method + result = self.ecg_operations.get_ecg_instance(ecg_id=1) + + # Assert that the mocked method is + # called and returns the expected instance + mock_get_ecg_model.assert_called_once_with(pk=1) + self.assertEqual(result, mock_ecg_instance) + + @patch('connector.operations.ECGModel.objects.get') + def test_get_ecg_instance_not_found(self, mock_get_ecg_model): + # Mocking the ECGModel.objects.get + # method to raise a DoesNotExist exception + mock_get_ecg_model.side_effect = ECGModel.DoesNotExist() + + # Call the get_ecg_instance method + with self.assertRaises(NotFound) as context: + self.ecg_operations.get_ecg_instance(ecg_id=1) + + # Assert that the exception detail + # contains the original DoesNotExist exception + self.assertIsInstance(context.exception, NotFound) + + @patch('connector.operations.ECGModel.objects.get') + def test_get_ecg_instance_validation_error(self, mock_get_ecg_model): + # Mocking the ECGModel.objects.get + # method to raise a Django ValidationError + mock_get_ecg_model.side_effect = ValidationError('Invalid Value') + + # Call the get_ecg_instance method + with self.assertRaises(ValidationError) as context: + self.ecg_operations.get_ecg_instance(ecg_id=1) + + self.assertEqual(context.exception.message, 'Invalid Value') + + @patch('connector.operations.ECGModel.objects.get') + def test_get_ecg_instance_api_exception(self, mock_get_ecg_model): + # Mocking the ECGModel.objects.get method to raise an APIException + mock_get_ecg_model.side_effect = APIException('API exception') + + # Call the get_ecg_instance method + with self.assertRaises(APIException) as context: + self.ecg_operations.get_ecg_instance(ecg_id=1) + + # Assert that the exception detail contains the original APIException + self.assertEqual(str(context.exception), 'API exception') + + def test_zero_crossing(self): + data = '[1, 2, 3, -4, 2, -6]' + + # Call the _zero_crossing method + result = self.ecg_operations._zero_crossing(signal=data) + + self.assertEqual(result, 3) + + def test_get_zero_crossing_count(self): + # Mocking an ECGModel instance with leads + ecg_instance = MagicMock(spec=ECGModel) + ecg_instance.leads = [ + {'name': 'I', 'signal': '[1, 2, 3, -4, 2, -6]'}, + {'name': 'II', 'signal': '[3, 2, 1, -1, -2, -3]'}, + ] + + # Mocking the _zero_crossing method + with patch.object( + self.ecg_operations, '_zero_crossing', side_effect=[3, 4] + ): + # Call the get_zero_crossing_count method + result = self.ecg_operations.get_zero_crossing_count(ecg_instance) + + # Assert that the mocked method is + # called and returns the expected result + self.assertEqual( + result, + [ + {'zero_crossing_count': 3, 'lead_name': 'I'}, + {'zero_crossing_count': 4, 'lead_name': 'II'}, + ], + ) diff --git a/ecg/app/connector/tests/test_serializers.py b/ecg/app/connector/tests/test_serializers.py index e69de29..672da07 100644 --- a/ecg/app/connector/tests/test_serializers.py +++ b/ecg/app/connector/tests/test_serializers.py @@ -0,0 +1,251 @@ +from django.test import TestCase +from rest_framework.test import APIRequestFactory + +from connector.models import UserModel +from connector.serializers import ( + ECGModelSerializer, + ECGResponseSerializer, + LeadsSerializer, + UserLoginSerializer, + UserRegistrationSerializer, +) + + +class UserLoginSerializerTests(TestCase): + def setUp(self): + self.user = UserModel.objects.create_user( + username='testuser', + email='testuser@example.com', + password='TestPassword123!', + ) + self.user_data = { + 'username': 'testuser', + 'password': 'TestPassword123!', + } + + def tearDown(self): + # Clean up created users + UserModel.objects.all().delete() + + def test_valid_login_with_username(self): + data = {'username': 'testuser', 'password': 'TestPassword123!'} + serializer = UserLoginSerializer(data=data) + self.assertTrue(serializer.is_valid()) + self.assertIn('user', serializer.validated_data) + + def test_invalid_login_with_email(self): + data = { + 'email': 'testuser@example.com', + 'password': 'TestPassword123!', + } + serializer = UserLoginSerializer(data=data) + self.assertFalse(serializer.is_valid()) + + def test_invalid_login_missing_password(self): + data = {'username': 'testuser'} + serializer = UserLoginSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn('password', serializer.errors) + + def test_invalid_login_missing_username_and_password(self): + data = {} + serializer = UserLoginSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn('password', serializer.errors) + + # + def test_invalid_login_wrong_credentials(self): + data = {'username': 'testuser', 'password': 'WrongPassword'} + serializer = UserLoginSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn('non_field_errors', serializer.errors) + + +class UserRegistrationSerializerTests(TestCase): + def setUp(self) -> None: + self.user_data = { + 'username': 'testuser', + 'email': 'testuser@example.com', + 'password': 'TestPassword123!', + 'first_name': 'name', + 'last_name': 'last_name', + } + + def test_valid_registration_data(self): + serializer = UserRegistrationSerializer(data=self.user_data) + + self.assertTrue(serializer.is_valid()) + self.assertEqual(serializer.validated_data['username'], 'testuser') + + def test_short_password(self): + data = self.user_data.copy() + data['password'] = 'Pass' + serializer = UserRegistrationSerializer(data=data) + + self.assertFalse(serializer.is_valid()) + self.assertIn('password', serializer.errors) + + def test_missing_uppercase_letter(self): + data = self.user_data.copy() + data['password'] = 'password!' + + serializer = UserRegistrationSerializer(data=data) + + self.assertFalse(serializer.is_valid()) + self.assertIn('password', serializer.errors) + + def test_missing_special_character(self): + data = self.user_data.copy() + data['password'] = 'MissingSpecialChar123' + + serializer = UserRegistrationSerializer(data=data) + + self.assertFalse(serializer.is_valid()) + self.assertIn('password', serializer.errors) + + def test_password_hashing(self): + serializer = UserRegistrationSerializer(data=self.user_data) + + self.assertTrue(serializer.is_valid()) + saved_user = serializer.save() + + self.assertNotEqual(saved_user.password, 'TestPassword123!') + self.assertTrue(saved_user.check_password('TestPassword123!')) + + +class LeadsSerializerTests(TestCase): + def setUp(self) -> None: + self.signal = '[1, 2, 3, -4, 2, -6, -4.3, 4.2]' + self.num_samples = 10 + self.name = 'I' + + def test_leads_serializer_valid_data(self): + data = { + 'name': self.name, + 'num_samples': self.num_samples, + 'signal': self.signal, + } + + serializer = LeadsSerializer(data=data) + + self.assertTrue(serializer.is_valid()) + self.assertEqual(serializer.validated_data['name'], self.name) + self.assertEqual( + serializer.validated_data['num_samples'], + self.num_samples, + ) + self.assertEqual(serializer.validated_data['signal'], self.signal) + + def test_leads_serializer_missing_required_field(self): + # Test with missing 'name' field + data = {'num_samples': self.num_samples, 'signal': self.signal} + + serializer = LeadsSerializer(data=data) + + self.assertFalse(serializer.is_valid()) + self.assertIn('name', serializer.errors) + + def test_leads_serializer_invalid_choice(self): + # Test with invalid 'name' choice + data = { + 'name': 'InvalidLead', + 'num_samples': self.num_samples, + 'signal': self.signal, + } + + serializer = LeadsSerializer(data=data) + + self.assertFalse(serializer.is_valid()) + self.assertIn('name', serializer.errors) + + def test_leads_serializer_invalid_num_samples(self): + # Test with invalid 'num_samples' type + data = { + 'name': self.name, + 'num_samples': 'invalid', + 'signal': self.signal, + } + + serializer = LeadsSerializer(data=data) + + self.assertFalse(serializer.is_valid()) + self.assertIn('num_samples', serializer.errors) + + def test_leads_serializer_invalid_signal_format(self): + # Test with invalid 'signal' format + data = { + 'name': self.name, + 'num_samples': self.num_samples, + 'signal': 'invalid', + } + + serializer = LeadsSerializer(data=data) + + self.assertFalse(serializer.is_valid()) + self.assertIn('signal', serializer.errors) + + +class ECGModelSerializerTests(TestCase): + def setUp(self): + self.user = UserModel.objects.create_user( + username='testuser', + password='testpassword', + ) + self.factory = APIRequestFactory() + self.request = self.factory.get('/ecg_monitoring/') + self.request.user = self.user + + def tearDown(self): + # Clean up created users + UserModel.objects.all().delete() + + def test_ecg_model_serializer(self): + leads_data = [ + {'name': 'I', 'num_samples': 0, 'signal': '[1,2,3,-4,2,-6]'}, + ] + + data = { + 'leads': leads_data, + } + + serializer = ECGModelSerializer( + data=data, + context={'request': self.request}, + ) + + self.assertTrue(serializer.is_valid()) + self.assertEqual(serializer.validated_data['user'], self.user) + + # Optionally, you can test the LeadsSerializer as well + leads_serializer = LeadsSerializer(data=leads_data, many=True) + self.assertTrue(leads_serializer.is_valid()) + + +class ECGResponseSerializerTests(TestCase): + def test_ecg_response_serializer(self): + data = {'lead_name': 'I', 'zero_crossings_count': 5} + + serializer = ECGResponseSerializer(data=data) + + self.assertTrue(serializer.is_valid()) + self.assertEqual(serializer.validated_data['lead_name'], 'I') + self.assertEqual(serializer.validated_data['zero_crossings_count'], 5) + + def test_ecg_response_serializer_invalid_data(self): + # Test with invalid data (missing 'zero_crossings_count') + data = {'lead_name': 'I'} + + serializer = ECGResponseSerializer(data=data) + + self.assertFalse(serializer.is_valid()) + self.assertIn('zero_crossings_count', serializer.errors) + + def test_ecg_response_serializer_invalid_type(self): + # Test with invalid data types + data = {'lead_name': {}, 'zero_crossings_count': 'invalid'} + + serializer = ECGResponseSerializer(data=data) + + self.assertFalse(serializer.is_valid()) + self.assertIn('lead_name', serializer.errors) + self.assertIn('zero_crossings_count', serializer.errors) diff --git a/ecg/app/connector/tests/test_urls.py b/ecg/app/connector/tests/test_urls.py index e69de29..180710a 100644 --- a/ecg/app/connector/tests/test_urls.py +++ b/ecg/app/connector/tests/test_urls.py @@ -0,0 +1,20 @@ +from django.test import TestCase +from django.urls import resolve + + +class TestUrls(TestCase): + def test_ecg_monitoring_url(self): + resolver = resolve('/api/ecg_monitoring') + self.assertEqual(resolver.view_name, 'ecg_monitoring') + + def test_ecg_monitoring_zero_crossing_url(self): + resolver = resolve('/api/ecg_monitoring/zero_crossing/1') + self.assertEqual(resolver.view_name, 'ecg_monitoring') + + def test_login_url(self): + resolver = resolve('/api/login/') + self.assertEqual(resolver.view_name, 'api_login') + + def test_registration_url(self): + resolver = resolve('/api/registration/') + self.assertEqual(resolver.view_name, 'api_register') diff --git a/ecg/app/connector/tests/test_views.py b/ecg/app/connector/tests/test_views.py index e69de29..7abe80b 100644 --- a/ecg/app/connector/tests/test_views.py +++ b/ecg/app/connector/tests/test_views.py @@ -0,0 +1,159 @@ +from unittest.mock import patch + +from django.test import TestCase +from django.urls import reverse +from rest_framework import status +from rest_framework.authtoken.models import Token +from rest_framework.test import APIClient + +from connector.models import UserModel + + +class UserLoginViewTest(TestCase): + def setUp(self): + self.client = APIClient() + self.user = UserModel.objects.create_user( + username='testuser', + password='testpassword', + ) + self.login_url = '/api/login/' + + def test_user_login(self): + data = {'username': 'testuser', 'password': 'testpassword'} + response = self.client.post(self.login_url, data, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('token', response.data) + + +class UserRegistrationViewTest(TestCase): + def setUp(self): + self.client = APIClient() + self.registration_url = '/api/registration/' + self.registration_data = { + 'username': 'newuser', + 'password': 'Password12!', + 'first_name': 'name', + 'last_name': 'last_name', + 'email': 'email@email.com', + } + + def test_user_registration(self): + response = self.client.post( + self.registration_url, + self.registration_data, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertIn('username', response.data) + + def test_user_registration_fail(self): + data = {'username': 'username', 'password': 'password'} + response = self.client.post(self.registration_url, data, format='json') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + +class ECGViewTest(TestCase): + URL_NAME_ECG = 'ecg_monitoring' + + def setUp(self): + self.client = APIClient() + self.user = UserModel.objects.create_user( + username='testuser', + password='Testpassword1!', + first_name='name', + last_name='lastname', + email='email@email.com', + ) + self.user_2 = UserModel.objects.create_user( + username='testuser2', + password='Testpassword2!', + first_name='name', + last_name='lastname', + email='email2@email.com', + ) + self.token = Token.objects.create(user=self.user) + self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) + self.ecg_url_get = self.api_url(self.URL_NAME_ECG, ['1']) + self.ecg_url_not_exist = self.api_url(self.URL_NAME_ECG, ['1345']) + + self.ecg_data = { + 'leads': [ + {'name': 'I', 'num_samples': 0, 'signal': '[1,2,3,-4,2,-6]'}, + ] + } + + def tearDown(self): + # Clean up created users and tokens + UserModel.objects.all().delete() + Token.objects.all().delete() + + class ECGInstance: + leads = [{'name': 'I', 'num_samples': 0, 'signal': '[1,2,3,-4,2,-6]'}] + + def __init__(self, user): + self.user = user + + @classmethod + def api_url(cls, view_name, args=None): + url = reverse(view_name, args=args) + return url + + @patch('connector.views.ECGOperations', autospec=True) + def test_create_ecg_record(self, mock_create_ecg_record): + response = self.client.post( + reverse(self.URL_NAME_ECG), + data=self.ecg_data, + format='json', + ) + + mock_create_ecg_record.assert_called_once() + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data, 'ECG record created successfully') + + @patch('connector.operations.ECGOperations.get_ecg_instance') + def test_retrieve_zero_crossing(self, mock_get_ecg_instance): + # Mock the get_ecg_instance function to return a mock ECG instance + mock_ecg_instance = self.ECGInstance(user=self.user) + mock_get_ecg_instance.return_value = mock_ecg_instance + + response = self.client.get(self.ecg_url_get) + # Assert that the mock function was called + # and the response status code is OK + mock_get_ecg_instance.assert_called_once() + self.assertEqual(response.status_code, status.HTTP_200_OK) + + @patch('connector.operations.ECGOperations.get_ecg_instance') + def test_retrieve_zero_crossing_fail(self, mock_get_ecg_instance): + """ + Should return PERMISSION DENIED when user is trying + to access ecg data that they haven't created + """ + # Mock the get_ecg_instance function to return a mock ECG instance + mock_ecg_instance = self.ECGInstance(user=self.user_2) + mock_get_ecg_instance.return_value = mock_ecg_instance + + response = self.client.get(self.ecg_url_get) + # Assert that the mock function was called + # and the response status code is OK + mock_get_ecg_instance.assert_called_once() + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + @patch('connector.operations.ECGOperations.get_ecg_instance') + @patch('connector.operations.ECGOperations.delete_ecg_record') + def test_delete_ecg_success( + self, mock_delete_ecg_record, mock_get_ecg_instance + ): + # Mock the get_ecg_instance method to return a mock ECG instance + ecg_instance_mock = mock_get_ecg_instance.return_value + + response = self.client.delete(self.ecg_url_get) + + # Assert that the status code is 200 OK + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Assert that the delete_ecg_record + # method was called with the correct arguments + mock_delete_ecg_record.assert_called_once_with(ecg_instance_mock) diff --git a/ecg/app/connector/urls.py b/ecg/app/connector/urls.py index 0fb7fe7..1ff0406 100644 --- a/ecg/app/connector/urls.py +++ b/ecg/app/connector/urls.py @@ -4,23 +4,28 @@ urlpatterns = [ path( - 'api/ecg_monitoring', - views.ECGView.as_view({'post': 'create'}), + 'ecg_monitoring', + views.ECGView.as_view({'post': 'create', 'put': 'update'}), name='ecg_monitoring', ), path( - 'api/ecg_monitoring/zero_crossing/', + 'ecg_monitoring/', + views.ECGView.as_view({'get': 'retrieve', 'delete': 'delete'}), + name='ecg_monitoring', + ), + path( + 'ecg_monitoring/zero_crossing/', views.ECGView.as_view({'get': 'retrieve_zero_crossing'}), name='ecg_monitoring', ), path( - 'api/login/', + 'login/', views.UserLoginView.as_view(), - name='api-login', + name='api_login', ), path( - 'api/registration/', + 'registration/', views.UserRegistrationView.as_view(), - name='api-register', + name='api_register', ), ] diff --git a/ecg/app/connector/utils/__init__.py b/ecg/app/connector/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ecg/app/connector/utils/test_mocker.py b/ecg/app/connector/utils/test_mocker.py new file mode 100644 index 0000000..d024cbd --- /dev/null +++ b/ecg/app/connector/utils/test_mocker.py @@ -0,0 +1,7 @@ +class RedlockMock: + def __init__(self, *args, **kwargs): + pass + + +def mock_redlock(retry_count=20, retry_delay=0.2): + return None diff --git a/ecg/app/connector/utils/tools.py b/ecg/app/connector/utils/tools.py new file mode 100644 index 0000000..c670d57 --- /dev/null +++ b/ecg/app/connector/utils/tools.py @@ -0,0 +1,15 @@ +from urllib.parse import urlparse + +from django.conf import settings +from redis.client import StrictRedis +from redlock import Redlock + + +def get_redis_client() -> StrictRedis: + url_redis = urlparse(settings.REDIS_LOCATION) + redis_kwargs = {'ssl_cert_reqs': None} if url_redis.scheme == 'rediss' else {} + return StrictRedis.from_url(settings.REDIS_LOCATION, **redis_kwargs) + + +def get_lock_client(**kwargs) -> Redlock: + return Redlock([get_redis_client()], **kwargs) diff --git a/ecg/app/connector/views.py b/ecg/app/connector/views.py index 7441bfe..1515405 100644 --- a/ecg/app/connector/views.py +++ b/ecg/app/connector/views.py @@ -83,22 +83,27 @@ class ECGView(viewsets.ViewSet): authentication_classes = (TokenAuthentication,) permission_classes = (permissions.IsAuthenticated,) - serializer_class_response = ECGResponseSerializer - serializer_class_request = ECGModelSerializer + serializer_class_response_zero_crossing = ECGResponseSerializer + serializer_class = ECGModelSerializer def get_permissions(self): """ Instantiates and returns the list of permissions that this view/method requires. """ - if self.action == 'retrieve_zero_crossing': + if self.action in [ + 'retrieve_zero_crossing', + 'delete', + 'update', + 'retrieve', + ]: return [permissions.IsAuthenticated(), HasECGDataPermission()] else: return [permissions.IsAuthenticated()] @extend_schema( responses={ - 200: OpenApiResponse( + 201: OpenApiResponse( description='Request success', ), 400: OpenApiResponse( @@ -111,7 +116,7 @@ def get_permissions(self): description='Internal server error', ), }, - request=serializer_class_request, + request=serializer_class, ) def create(self, request): """ @@ -124,14 +129,54 @@ def create(self, request): return Response( data='ECG record created successfully', - status=status.HTTP_200_OK, + status=status.HTTP_201_CREATED, + ) + + @extend_schema( + responses={ + 201: OpenApiResponse( + description='Request success', + ), + 400: OpenApiResponse( + description='Invalid value', + ), + 403: OpenApiResponse( + description='Permission Denied', + ), + 500: OpenApiResponse( + description='Internal server error', + ), + }, + request=serializer_class, + ) + def update(self, request): + """ + Receives ECG data for processing + """ + ecg_id = request.data.get('id') + + ecg_instance = ECGOperations().get_ecg_instance(ecg_id) + + # Check if the authenticated user + # has permission to retrieve this ECG data + self.check_object_permissions(request, ecg_instance) + + ECGOperations().update_ecg_record( + ecg_data=request.data, + context={'request': request}, + ecg_instance=ecg_instance, + ) + + return Response( + data='ECG record updated successfully', + status=status.HTTP_201_CREATED, ) @extend_schema( responses={ 200: OpenApiResponse( description='Request success', - response=serializer_class_response, + response=serializer_class_response_zero_crossing, ), 404: OpenApiResponse( description='Resource not available', @@ -160,3 +205,72 @@ def retrieve_zero_crossing(self, request, ecg_id): response = ECGOperations().get_zero_crossing_count(ecg_instance) return Response(response, status=status.HTTP_200_OK) + + @extend_schema( + responses={ + 200: OpenApiResponse( + description='Request success', + response=serializer_class, + ), + 404: OpenApiResponse( + description='Resource not available', + ), + 400: OpenApiResponse( + description='Invalid value', + ), + 403: OpenApiResponse( + description='Permission Denied', + ), + 500: OpenApiResponse( + description='Internal server error', + ), + }, + ) + def retrieve(self, request, ecg_id): + """ + Returns the ECG data on given ID + """ + ecg_instance = ECGOperations().get_ecg_instance(ecg_id) + + # Check if the authenticated user + # has permission to retrieve this ECG data + self.check_object_permissions(request, ecg_instance) + serializer = self.serializer_class(ecg_instance) + + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + responses={ + 201: OpenApiResponse( + description='Request success', + ), + 404: OpenApiResponse( + description='Resource not available', + ), + 400: OpenApiResponse( + description='Invalid value', + ), + 403: OpenApiResponse( + description='Permission Denied', + ), + 500: OpenApiResponse( + description='Internal server error', + ), + }, + ) + def delete(self, request, ecg_id): + """ + Deletes ECG data on given id + """ + ecg_instance = ECGOperations().get_ecg_instance(ecg_id) + + # Check if the authenticated user + # has permission to delete this ECG data + self.check_object_permissions(request, ecg_instance) + + ECGOperations().delete_ecg_record(ecg_instance) + + return Response( + {f'ECG data on id {ecg_id} was successfully deleted'}, + status=status.HTTP_200_OK, + ) diff --git a/ecg/app/ecg/settings.py b/ecg/app/ecg/settings.py index e1704a2..2d49121 100644 --- a/ecg/app/ecg/settings.py +++ b/ecg/app/ecg/settings.py @@ -13,8 +13,11 @@ import os +import sys from pathlib import Path +TEST = 'test' in sys.argv + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -25,7 +28,9 @@ AUTH_USER_MODEL = 'connector.UserModel' # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-@j^qopfkj#nn*t4872(n-vdt(+8e8a6ka13mnzm#kluy*ktxn#' +SECRET_KEY = ( + 'django-insecure-@j^qopfkj#nn*t4872(n-vdt(+8e8a6ka13mnzm#kluy*ktxn#' +) # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -61,7 +66,9 @@ REST_FRAMEWORK = { 'DEFAULT_RENDERER_CLASSES': ('rest_framework.renderers.JSONRenderer',), - 'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAuthenticated',), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', + ), 'TEST_REQUEST_DEFAULT_FORMAT': 'json', 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_AUTHENTICATION_CLASSES': [ @@ -165,3 +172,23 @@ 'LICENSE': {'name': ''}, 'SWAGGER_UI_SETTINGS': {'displayOperationId': True}, } + +if TEST: + from unittest.mock import patch + + from connector.utils.test_mocker import mock_redlock + + patch('connector.utils.tools.get_redis_client', mock_redlock).start() + + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + } + } + + CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', + } + } diff --git a/ecg/app/ecg/urls.py b/ecg/app/ecg/urls.py index 38878b7..35d42c7 100644 --- a/ecg/app/ecg/urls.py +++ b/ecg/app/ecg/urls.py @@ -19,13 +19,23 @@ from django.contrib import admin from django.contrib.staticfiles.urls import staticfiles_urlpatterns from django.urls import include, path -from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) urlpatterns = [ path('api/', include('connector.urls')), path('schema/', SpectacularAPIView.as_view(), name='schema'), - path('swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), - path('redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), + path( + 'swagger/', + SpectacularSwaggerView.as_view(url_name='schema'), + name='swagger-ui', + ), + path( + 'redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc' + ), path('admin/', admin.site.urls), path('healthz/', include('health_check.urls')), ] diff --git a/ecg/config/requirements.txt b/ecg/config/requirements.txt index 72d7432..26f8cc8 100644 --- a/ecg/config/requirements.txt +++ b/ecg/config/requirements.txt @@ -14,7 +14,14 @@ PyMySQL==1.0.2 drf-spectacular==0.26.2 drf-spectacular-sidecar==2023.5.1 +# Redis +django-redis==5.2.0 +redis==3.5.3 +opentelemetry-instrumentation-redis==0.40b0 +redlock-py==1.0.8 + gunicorn==20.1.0 numpy==1.26.3 pre-commit==2.20.0 +coverage==7.2.1 From e4880abf47eece4e60aafb78cb77f77ff737047d Mon Sep 17 00:00:00 2001 From: Tiko Kobiashvili Date: Wed, 31 Jan 2024 16:42:16 +0100 Subject: [PATCH 7/9] implemented tests for delete/get/update views and operations --- ecg/app/connector/operations.py | 4 + ecg/app/connector/tests/test_operations.py | 163 ++++++++++++++++++++- ecg/app/connector/tests/test_views.py | 83 +++++++++++ ecg/app/connector/utils/tools.py | 4 +- 4 files changed, 252 insertions(+), 2 deletions(-) diff --git a/ecg/app/connector/operations.py b/ecg/app/connector/operations.py index b9bb345..bdd5a5e 100644 --- a/ecg/app/connector/operations.py +++ b/ecg/app/connector/operations.py @@ -96,6 +96,10 @@ def update_ecg_record(self, ecg_data, context, ecg_instance): raise APIException(e) def delete_ecg_record(self, ecg_instance): + """ + Deletes ECG record in database + ecg_instance: ecg instance on given id + """ try: ecg_instance.delete() except ECGModel.DoesNotExist as e: diff --git a/ecg/app/connector/tests/test_operations.py b/ecg/app/connector/tests/test_operations.py index b64b16c..8914aec 100644 --- a/ecg/app/connector/tests/test_operations.py +++ b/ecg/app/connector/tests/test_operations.py @@ -4,13 +4,17 @@ from django.test import TestCase from rest_framework.exceptions import APIException, NotFound -from connector.models import ECGModel +from connector.models import ECGModel, UserModel from connector.operations import ECGOperations class ECGOperationsTests(TestCase): def setUp(self): self.ecg_operations = ECGOperations() + self.user = UserModel.objects.create_user( + username='testuser', + password='testpassword', + ) self.ecg_data = { 'leads': [ { @@ -124,3 +128,160 @@ def test_get_zero_crossing_count(self): {'zero_crossing_count': 4, 'lead_name': 'II'}, ], ) + + @patch('connector.serializers.ECGModelSerializer') + def test_update_ecg_record(self, mock_ecg_model_serializer): + # Mocking the serializer instance + serializer_instance = mock_ecg_model_serializer.return_value + serializer_instance.is_valid.return_value = True + serializer_instance.save.return_value = MagicMock() + + # Mocking the update_ecg_record method + with patch( + 'connector.operations.ECGOperations.serializer_class', + mock_ecg_model_serializer, + ): + ecg_instance = MagicMock(spec=ECGModel) + + self.ecg_operations.update_ecg_record( + ecg_data={}, context={}, ecg_instance=ecg_instance + ) + + # Asserting that the serializer's + # is_valid and save methods are called + serializer_instance.is_valid.assert_called_once() + serializer_instance.save.assert_called_once_with() + + @patch('connector.serializers.ECGModelSerializer') + def test_update_ecg_record_invalid_data(self, mock_ecg_model_serializer): + # Mocking the serializer instance + mock_ecg_model_serializer.side_effect = ValidationError( + 'Invalid Value' + ) + ecg_instance = MagicMock(spec=ECGModel) + + # Mocking the create_ecg_record method + with patch( + 'connector.operations.ECGOperations.serializer_class', + mock_ecg_model_serializer, + ): + # Call the get_ecg_instance method + with self.assertRaises(ValidationError) as context: + self.ecg_operations.update_ecg_record( + ecg_data={}, context={}, ecg_instance=ecg_instance + ) + + self.assertEqual(context.exception.message, 'Invalid Value') + # Asserting that the serializer's + # is_valid and save methods are not called + serializer_instance = mock_ecg_model_serializer.return_value + serializer_instance.is_valid.assert_not_called() + serializer_instance.save.assert_not_called() + + @patch('connector.serializers.ECGModelSerializer') + def test_update_ecg_record_not_found(self, mock_ecg_model_serializer): + # Mocking the ECGModelSerializer to raise NotFound + mock_ecg_model_serializer.side_effect = ECGModel.DoesNotExist() + + # Mocking the update_ecg_record method + with patch( + 'connector.operations.ECGOperations.serializer_class', + mock_ecg_model_serializer, + ): + # Calling the update_ecg_record method with ecg_instance not found + with self.assertRaises(Exception) as context: + self.ecg_operations.update_ecg_record( + ecg_data={}, context={}, ecg_instance=None + ) + self.assertIsInstance(context.exception, NotFound) + # Asserting that the serializer's + # is_valid and save methods are not called + serializer_instance = mock_ecg_model_serializer.return_value + serializer_instance.is_valid.assert_not_called() + serializer_instance.save.assert_not_called() + + @patch('connector.serializers.ECGModelSerializer') + def test_update_ecg_record_api_exception(self, mock_ecg_model_serializer): + # Mocking the ECGModelSerializer to raise APIException + mock_ecg_model_serializer.side_effect = APIException('API exception') + + # Mocking the update_ecg_record method + with patch( + 'connector.operations.ECGOperations.serializer_class', + mock_ecg_model_serializer, + ): + # Calling the update_ecg_record method with APIException + with self.assertRaises(APIException) as context: + self.ecg_operations.update_ecg_record( + ecg_data={}, context={}, ecg_instance=None + ) + self.assertEqual(str(context.exception), 'API exception') + + # Asserting that the serializer's + # is_valid and save methods are not called + serializer_instance = mock_ecg_model_serializer.return_value + serializer_instance.is_valid.assert_not_called() + serializer_instance.save.assert_not_called() + + def test_delete_ecg_record(self): + mock_ecg_instance = MagicMock(spec=ECGModel) + mock_ecg_instance.return_value = mock_ecg_instance + + # Mocking the delete method + with patch.object(mock_ecg_instance, 'delete') as mock_delete: + # Call the delete_ecg_record method + self.ecg_operations.delete_ecg_record( + ecg_instance=mock_ecg_instance + ) + + # Assert that the mocked methods are called + mock_delete.assert_called_once() + + def test_delete_ecg_record_not_found(self): + mock_ecg_instance = MagicMock(spec=ECGModel) + mock_ecg_instance.delete.side_effect = ECGModel.DoesNotExist() + + ecg_operations = ECGOperations() + + # Call the delete_ecg_record method with the mocked instance + with patch.object(ECGModel, 'objects', return_value=mock_ecg_instance): + with self.assertRaises(NotFound) as context: + ecg_operations.delete_ecg_record( + ecg_instance=mock_ecg_instance + ) + + # Assert that the exception detail contains + # the original DoesNotExist exception + self.assertIsInstance(context.exception, NotFound) + + def test_delete_ecg_record_validation_error(self): + mock_ecg_instance = MagicMock(spec=ECGModel) + mock_ecg_instance.delete.side_effect = ValidationError('Invalid Value') + + ecg_operations = ECGOperations() + + # Call the delete_ecg_record method with the mocked instance + with patch.object(ECGModel, 'objects', return_value=mock_ecg_instance): + with self.assertRaises(ValidationError) as context: + ecg_operations.delete_ecg_record( + ecg_instance=mock_ecg_instance + ) + + # Assert that the exception detail contains + # the original ValidationError exception + self.assertEqual(context.exception.message, 'Invalid Value') + + def test_delete_ecg_record_api_exception(self): + mock_ecg_instance = MagicMock(spec=ECGModel) + mock_ecg_instance.delete.side_effect = APIException('API exception') + ecg_operations = ECGOperations() + + # Call the delete_ecg_record method with the mocked instance + with patch.object(ECGModel, 'objects', return_value=mock_ecg_instance): + with self.assertRaises(APIException) as context: + ecg_operations.delete_ecg_record( + ecg_instance=mock_ecg_instance + ) + + # Assert that the exception detail contains the original APIException + self.assertEqual(str(context.exception), 'API exception') diff --git a/ecg/app/connector/tests/test_views.py b/ecg/app/connector/tests/test_views.py index 7abe80b..03ba352 100644 --- a/ecg/app/connector/tests/test_views.py +++ b/ecg/app/connector/tests/test_views.py @@ -4,6 +4,7 @@ from django.urls import reverse from rest_framework import status from rest_framework.authtoken.models import Token +from rest_framework.exceptions import NotFound from rest_framework.test import APIClient from connector.models import UserModel @@ -141,6 +142,88 @@ def test_retrieve_zero_crossing_fail(self, mock_get_ecg_instance): mock_get_ecg_instance.assert_called_once() self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + @patch('connector.operations.ECGOperations.get_ecg_instance') + @patch('connector.operations.ECGOperations.update_ecg_record') + def test_update_ecg_success( + self, mock_update_ecg_record, mock_get_ecg_instance + ): + # Mock the get_ecg_instance method to return a mock ECG instance + mock_ecg_instance = self.ECGInstance(user=self.user) + mock_get_ecg_instance.return_value = mock_ecg_instance + + # Mock the update_ecg_record method + mock_update_ecg_record.return_value = None + + data = { + 'id': 1, + 'leads': [ + {'name': 'I', 'num_samples': 0, 'signal': '[1,2,3,-4]'}, + ], + } + + response = self.client.put( + reverse(self.URL_NAME_ECG), + data=data, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + mock_get_ecg_instance.assert_called_once() + + @patch('connector.operations.ECGOperations.get_ecg_instance') + def test_update_ecg_permission_denied(self, mock_get_ecg_instance): + # Mock the get_ecg_instance method to return a mock ECG instance + mock_ecg_instance = self.ECGInstance(user=self.user_2) + mock_get_ecg_instance.return_value = mock_ecg_instance + + data = { + 'id': 1, + 'leads': [ + {'name': 'I', 'num_samples': 0, 'signal': '[1,2,3,-4]'}, + ], + } + + response = self.client.put( + reverse(self.URL_NAME_ECG), + data=data, + format='json', + ) + # Assert that the status code is 403 Forbidden + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + @patch('connector.operations.ECGOperations.get_ecg_instance') + def test_update_ecg_not_found(self, mock_get_ecg_instance): + # Mock the get_ecg_instance method to raise a NotFound exception + mock_get_ecg_instance.side_effect = NotFound + + data = { + 'id': 2, + 'leads': [ + {'name': 'I', 'num_samples': 0, 'signal': '[1,2,3,-4]'}, + ], + } + + response = self.client.put( + reverse(self.URL_NAME_ECG), + data=data, + format='json', + ) + # Assert that the status code is 404 Not Found + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + @patch('connector.operations.ECGOperations.get_ecg_instance') + def test_retrieve_ecg_success(self, mock_get_ecg_instance): + # Mock the get_ecg_instance method to return a mock ECG instance + mock_ecg_instance = self.ECGInstance(user=self.user) + mock_get_ecg_instance.return_value = mock_ecg_instance + + ecg_id = '1' + + response = self.client.get( + self.ecg_url_get, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + mock_get_ecg_instance.assert_called_once_with(ecg_id) + @patch('connector.operations.ECGOperations.get_ecg_instance') @patch('connector.operations.ECGOperations.delete_ecg_record') def test_delete_ecg_success( diff --git a/ecg/app/connector/utils/tools.py b/ecg/app/connector/utils/tools.py index c670d57..5069016 100644 --- a/ecg/app/connector/utils/tools.py +++ b/ecg/app/connector/utils/tools.py @@ -7,7 +7,9 @@ def get_redis_client() -> StrictRedis: url_redis = urlparse(settings.REDIS_LOCATION) - redis_kwargs = {'ssl_cert_reqs': None} if url_redis.scheme == 'rediss' else {} + redis_kwargs = ( + {'ssl_cert_reqs': None} if url_redis.scheme == 'rediss' else {} + ) return StrictRedis.from_url(settings.REDIS_LOCATION, **redis_kwargs) From 2678c548c6e71911e5efa1087f2c3b25e06ea836 Mon Sep 17 00:00:00 2001 From: Tiko Kobiashvili Date: Wed, 31 Jan 2024 17:23:06 +0100 Subject: [PATCH 8/9] created README.md file for the microservice --- ecg/README.md | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 ecg/README.md diff --git a/ecg/README.md b/ecg/README.md new file mode 100644 index 0000000..278288d --- /dev/null +++ b/ecg/README.md @@ -0,0 +1,77 @@ +# ECG Monitoring Microservice + +The ECG Monitoring Microservice is designed to handle electrocardiograms (ECG) and provide various insights about them. +It offers endpoints for performing CRUD operations on ECG records, as well as calculating the number of zero crossings +of the ECG signal. Additionally, the microservice includes user login and registration endpoints. + +## Usage + +To run the microservice, use the following Docker Compose command: + +```bash +docker-compose up ecg --build +``` + +## Access + +- The service will be accessible at *http://ecg.localhost:8000/swagger/* +- To access the admin panel, visit *http://ecg.localhost:8000/admin/* + +## Authentication + +To interact with the microservice endpoints, a user token is required. +After a user logs in, the token is generated and should be included in the request header as follows: + +**Authorization: Token {*generated_token*}** + +## User Access + +Users can only access ECG data they have created. +Admin users, registered in the admin panel, +have the authority to register new users but cannot create new ECG data. +Other than admin registration, users can also be registerd with **registration** endpoint + +## Running Tests + +To run tests and generate coverage reports, use the following command: + +```bash +docker-compose run --no-deps ecg bash -c "coverage run manage.py test connector; coverage report -m; coverage html; coverage xml" +``` + +## Pre-commit Checks + +Ensure code quality and formatting by running pre-commit checks: + +```bash +pre-commit run --all-files +``` + +## Environment Configuration + +### Setting up Environment Variables + +For local development, setting up environment variables is necessary. Copy the provided `.env.ecg.sample` file to +create your own `.env.ecg` file. Update the values according to your configuration. + +#### Step 1: Copy the Sample Environment File + +Copy the contents of `.env.ecg.sample`: + +```bash +cp .env.ecg.sample .env.ecg +``` + +#### Step 2: Update Environment Variables + +Open the newly created .env.ecg file and update the values based on your specific configuration. +This file contains essential settings for the database, Django, and other environment-specific variables. + +## Files + +- **docker-compose.yml**: Docker Compose configuration file. +- **Dockerfile**: Docker configuration file for building the microservice image. +- **config/requirements.txt**: List of Python dependencies. +- **pre-commit.yml**: Configuration file for pre-commit hooks. +- **pyproject.toml**: TOML configuration file for the project. +- **.env.ecg.sample**: sample for environment variables \ No newline at end of file From a5a706bb608f47238cdad1bd8fa29ac80e86742d Mon Sep 17 00:00:00 2001 From: Tiko Kobiashvili Date: Wed, 31 Jan 2024 17:24:20 +0100 Subject: [PATCH 9/9] added environment sample file and modified docker-compose.yml to not include unnecessary platform --- docker-compose.yml | 2 +- ecg/.env.ecg.sample | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 ecg/.env.ecg.sample diff --git a/docker-compose.yml b/docker-compose.yml index 7f09f6a..f05c2d5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,7 +43,7 @@ services: - REDIS_PORT_6379_TCP_PORT=6379 ecg-db: - platform: linux/x86_64 # Necessary for MacOS M1 Chip +# platform: linux/x86_64 # Necessary for MacOS M1 Chip image: mysql:5.7.37 env_file: - .env.ecg diff --git a/ecg/.env.ecg.sample b/ecg/.env.ecg.sample new file mode 100644 index 0000000..c187207 --- /dev/null +++ b/ecg/.env.ecg.sample @@ -0,0 +1,21 @@ + +# Database configuration +DATABASE_HOST=ecg-db +DATABASE_PORT=3306 +DATABASE_NAME=ecg_docker +DATABASE_USER=docker +DATABASE_PASSWORD=docker + +# MySQL docker +MYSQL_ALLOW_EMPTY_PASSWORD=True +MYSQL_DATABASE=ecg_docker +MYSQL_USER=docker +MYSQL_PASSWORD=docker + +DEBUG=True + +DJANGO_LOG_LEVEL=INFO +LOG_LEVEL=INFO + +ENVIRONMENT=local +DJANGO_SETTINGS_MODULE=ecg.settings