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..f05c2d5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +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 + - 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 + 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/.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 diff --git a/ecg/.pre-commit-config.yaml b/ecg/.pre-commit-config.yaml new file mode 100644 index 0000000..32277cc --- /dev/null +++ b/ecg/.pre-commit-config.yaml @@ -0,0 +1,51 @@ +# 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. + 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 ] + - 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, --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. diff --git a/ecg/Dockerfile b/ecg/Dockerfile new file mode 100644 index 0000000..29a1293 --- /dev/null +++ b/ecg/Dockerfile @@ -0,0 +1,19 @@ +ARG environment +FROM python:3.10.9 +# noqa: E501 +# set environment variables +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 +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/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 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/admin.py b/ecg/app/connector/admin.py new file mode 100644 index 0000000..9f55891 --- /dev/null +++ b/ecg/app/connector/admin.py @@ -0,0 +1,65 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin + +from .models import ECGModel, UserModel + +# flake8: noqa: E501 + + +class ECGAdmin(admin.ModelAdmin): + list_display = ('id', 'date', '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 UserAdmin(BaseUserAdmin): + 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': ( + 'first_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', + 'first_name', + 'last_name', + 'second_last_name', + ), + }, + ), + ) + + +admin.site.register(ECGModel, ECGAdmin) +admin.site.register(UserModel, UserAdmin) diff --git a/ecg/app/connector/migrations/0001_initial.py b/ecg/app/connector/migrations/0001_initial.py new file mode 100644 index 0000000..a16fedb --- /dev/null +++ b/ecg/app/connector/migrations/0001_initial.py @@ -0,0 +1,173 @@ +# Generated by Django 3.2.23 on 2024-01-29 00:26 + +import django.contrib.auth.models +import django.core.validators +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + 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=[ + ( + '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.' # 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, + ), + ), + ('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()), + ], + ), + ] 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..b98c319 --- /dev/null +++ b/ecg/app/connector/migrations/0002_ecgmodel_user.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.23 on 2024-01-29 22:19 + +import django.db.models.deletion +from django.db import migrations, models + + +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/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/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..ad4ff1a --- /dev/null +++ b/ecg/app/connector/models.py @@ -0,0 +1,51 @@ +from django.contrib.auth.models import AbstractUser +from django.core.validators import RegexValidator +from django.db import models + + +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', + ) + first_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): + 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 new file mode 100644 index 0000000..bdd5a5e --- /dev/null +++ b/ecg/app/connector/operations.py @@ -0,0 +1,110 @@ +import json +import logging + +import numpy as np +from django.core.exceptions import ValidationError +from rest_framework.exceptions import APIException, NotFound + +from connector import serializers +from connector.models import ECGModel + +logger = logging.getLogger(__name__) + + +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_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 + """ + 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 + + def get_ecg_instance(self, ecg_id): + try: + ecg_record = ECGModel.objects.get(pk=ecg_id) + return ecg_record + except ECGModel.DoesNotExist as e: + raise NotFound(e) + except ValidationError as e: + 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() + + 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) + + 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: + raise NotFound(e) + except ValidationError as e: + raise ValidationError(e) + except APIException as e: + raise APIException(e) diff --git a/ecg/app/connector/permissions.py b/ecg/app/connector/permissions.py new file mode 100644 index 0000000..4f48479 --- /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 new file mode 100644 index 0000000..ffc8ef9 --- /dev/null +++ b/ecg/app/connector/serializers.py @@ -0,0 +1,132 @@ +import json +import re + +from django.contrib.auth import authenticate +from django.contrib.auth.hashers import make_password +from rest_framework import serializers + +from connector.models import ECGModel, UserModel + + +class UserLoginSerializer(serializers.Serializer): + username = serializers.CharField(required=False) + password = serializers.CharField(write_only=True) + + def validate(self, data): + username = data.get('username') + password = data.get('password') + if username and password: + user = authenticate(username=username, password=password) + + if user: + data['user'] = user + else: + raise serializers.ValidationError('Invalid credentials') + else: + raise serializers.ValidationError( + 'Must include both username 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): # 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().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' + ) + 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): + leads = LeadsSerializer(many=True) + user = serializers.HiddenField(default=serializers.CurrentUserDefault()) + + class Meta: + model = ECGModel + fields = '__all__' + read_only_fields = ('date',) + + +class ECGResponseSerializer(serializers.Serializer): + lead_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..91e0dc6 --- /dev/null +++ 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 new file mode 100644 index 0000000..8914aec --- /dev/null +++ b/ecg/app/connector/tests/test_operations.py @@ -0,0 +1,287 @@ +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, 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': [ + { + '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'}, + ], + ) + + @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_serializers.py b/ecg/app/connector/tests/test_serializers.py new file mode 100644 index 0000000..672da07 --- /dev/null +++ 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 new file mode 100644 index 0000000..180710a --- /dev/null +++ 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 new file mode 100644 index 0000000..03ba352 --- /dev/null +++ b/ecg/app/connector/tests/test_views.py @@ -0,0 +1,242 @@ +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.exceptions import NotFound +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.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( + 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 new file mode 100644 index 0000000..1ff0406 --- /dev/null +++ b/ecg/app/connector/urls.py @@ -0,0 +1,31 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path( + 'ecg_monitoring', + views.ECGView.as_view({'post': 'create', 'put': 'update'}), + name='ecg_monitoring', + ), + path( + '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( + 'login/', + views.UserLoginView.as_view(), + name='api_login', + ), + path( + 'registration/', + views.UserRegistrationView.as_view(), + 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..5069016 --- /dev/null +++ b/ecg/app/connector/utils/tools.py @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..1515405 --- /dev/null +++ b/ecg/app/connector/views.py @@ -0,0 +1,276 @@ +from drf_spectacular.utils import OpenApiResponse, extend_schema +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.views import APIView + +from connector.operations import ECGOperations +from connector.serializers import ( + ECGModelSerializer, + ECGResponseSerializer, + UserLoginSerializer, + UserRegistrationSerializer, +) + +from .permissions import HasECGDataPermission + + +@extend_schema(tags=['user']) +class UserLoginView(APIView): + """ + User login view + """ + + permission_classes = (permissions.AllowAny,) + + serializer_class = 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 = 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 + """ + + permission_classes = (permissions.AllowAny,) + + serializer_class = 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): + # 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() + 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): + """ + ECG Monitoring application service + """ + + authentication_classes = (TokenAuthentication,) + permission_classes = (permissions.IsAuthenticated,) + + 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 in [ + 'retrieve_zero_crossing', + 'delete', + 'update', + 'retrieve', + ]: + return [permissions.IsAuthenticated(), HasECGDataPermission()] + else: + return [permissions.IsAuthenticated()] + + @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 create(self, request): + """ + Receives ECG data for processing + """ + ECGOperations().create_ecg_record( + ecg_data=request.data, + context={'request': request}, + ) + + return Response( + data='ECG record created successfully', + 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_zero_crossing, + ), + 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 + """ + 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) + + 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/__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..9132fd3 --- /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..2d49121 --- /dev/null +++ b/ecg/app/ecg/settings.py @@ -0,0 +1,194 @@ +""" +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/ +""" +# flake8: noqa: E501 + + +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 + +# 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#' +) + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ['*'] + +# Application definition + +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', + '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', + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + ], +} + +# Authentication Backends +AUTHENTICATION_BACKENDS = [ + 'django.contrib.auth.backends.ModelBackend', +] + +# Django REST Framework settings +REST_USE_JWT = True # Use JWT for authentication + +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}, +} + +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 new file mode 100644 index 0000000..35d42c7 --- /dev/null +++ b/ecg/app/ecg/urls.py @@ -0,0 +1,42 @@ +"""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 # noqa + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +# flake8: noqa: E501 + + +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, +) + +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('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 new file mode 100644 index 0000000..9a16aca --- /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..081bb99 --- /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( + '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__': + main() diff --git a/ecg/config/requirements.txt b/ecg/config/requirements.txt new file mode 100644 index 0000000..26f8cc8 --- /dev/null +++ b/ecg/config/requirements.txt @@ -0,0 +1,27 @@ +# 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 + +# 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 + 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/*"]