From da834229c756be124e6a8dd197610a1646cd26a0 Mon Sep 17 00:00:00 2001 From: Vedant Jain <129421822+jainvedant392@users.noreply.github.com> Date: Tue, 9 Apr 2024 16:37:37 +0530 Subject: [PATCH 01/15] Add django filter for patient review missed (#1938) * Add django filter for patient review missed * re-commit * Update care/facility/tests/test_patient_api.py * fix lint issues * fix test cases * handle review_missed = False --------- Co-authored-by: Aakash Singh --- care/facility/api/viewsets/patient.py | 15 +++++++++++++++ care/facility/tests/test_patient_api.py | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/care/facility/api/viewsets/patient.py b/care/facility/api/viewsets/patient.py index f55a60030a..634035ab13 100644 --- a/care/facility/api/viewsets/patient.py +++ b/care/facility/api/viewsets/patient.py @@ -18,6 +18,7 @@ ) from django.db.models.functions import Coalesce, ExtractDay, Now from django.db.models.query import QuerySet +from django.utils import timezone from django_filters import rest_framework as filters from djqscsv import render_to_csv_response from drf_spectacular.utils import extend_schema, extend_schema_view @@ -237,6 +238,20 @@ def filter_bed_not_null(self, queryset, name, value): last_consultation__discharge_date__isnull=True, ) + def filter_by_review_missed(self, queryset, name, value): + if isinstance(value, bool): + if value: + queryset = queryset.filter( + (Q(review_time__isnull=False) & Q(review_time__lt=timezone.now())) + ) + else: + queryset = queryset.filter( + Q(review_time__isnull=True) | Q(review_time__gt=timezone.now()) + ) + return queryset + + review_missed = filters.BooleanFilter(method="filter_by_review_missed") + # Filter consultations by ICD-11 Diagnoses diagnoses = MultiSelectFilter(method="filter_by_diagnoses") diagnoses_unconfirmed = MultiSelectFilter(method="filter_by_diagnoses") diff --git a/care/facility/tests/test_patient_api.py b/care/facility/tests/test_patient_api.py index 01b017c34d..d86f7fdd20 100644 --- a/care/facility/tests/test_patient_api.py +++ b/care/facility/tests/test_patient_api.py @@ -409,6 +409,21 @@ def test_filter_by_diagnoses_confirmed(self): ) self.assertNotContains(res, self.patient.external_id) + def test_filter_by_review_missed(self): + self.client.force_authenticate(user=self.user) + res = self.client.get(self.get_base_url() + "?review_missed=true") + self.assertEqual(res.status_code, status.HTTP_200_OK) + for patient in res.json()["results"]: + self.assertLess(patient["review_time"], now()) + + res = self.client.get(self.get_base_url() + "?review_missed=false") + self.assertEqual(res.status_code, status.HTTP_200_OK) + for patient in res.json()["results"]: + if patient["review_time"]: + self.assertGreaterEqual(patient["review_time"], now()) + else: + self.assertIsNone(patient["review_time"]) + class PatientTransferTestCase(TestUtils, APITestCase): @classmethod From fc1f37578c8545f47f5b5aa039d95518d122d66c Mon Sep 17 00:00:00 2001 From: Vignesh Hari Date: Sat, 13 Apr 2024 00:24:12 +0530 Subject: [PATCH 02/15] Add Plugin Support (#2036) * Add Plugin Support * fix lint * make care installable install using `pip install /path/to/care/` * change root url of apps from / to /api to make the accessible via reverse proxy * crete abstract file upload model * fix file upload api * add care_scribe config vars * fix plug class * Add check before installing plugs * make file choice labels compatible with previous enums to avoid issues * add docs to configure plugins * Remove unused vars. * update docs --------- Co-authored-by: Aakash Singh --- .gitignore | 1 + Pipfile | 1 + Pipfile.lock | 94 ++++++++++++- .../0426_alter_fileupload_file_type.py | 29 ++++ care/facility/models/file_upload.py | 133 ++++++++++-------- config/settings/base.py | 17 +-- config/urls.py | 3 + docker/dev.Dockerfile | 4 +- docker/prod.Dockerfile | 5 +- docs/conf.py | 2 +- docs/index.rst | 1 + docs/pluggable-apps/configuration.md | 57 ++++++++ install_plugins.py | 3 + plug_config.py | 5 + plugs/manager.py | 29 ++++ plugs/plug.py | 10 ++ setup.py | 24 ++++ 17 files changed, 350 insertions(+), 68 deletions(-) create mode 100644 care/facility/migrations/0426_alter_fileupload_file_type.py create mode 100644 docs/pluggable-apps/configuration.md create mode 100644 install_plugins.py create mode 100644 plug_config.py create mode 100644 plugs/manager.py create mode 100644 plugs/plug.py create mode 100644 setup.py diff --git a/.gitignore b/.gitignore index ce2d3a5bab..4acdd07d9f 100644 --- a/.gitignore +++ b/.gitignore @@ -355,3 +355,4 @@ secrets.sh /.idea/misc.xml /.idea/modules.xml /.idea/vcs.xml +/.idea/ruff.xml diff --git a/Pipfile b/Pipfile index d4ffa91468..b6586ba774 100644 --- a/Pipfile +++ b/Pipfile @@ -71,6 +71,7 @@ werkzeug = "==2.3.8" [docs] furo = "==2023.9.10" sphinx = "==7.2.6" +myst-parser = "==2.0.0" [requires] python_version = "3.11" diff --git a/Pipfile.lock b/Pipfile.lock index 8bbef9e4ff..0e22cc9f7f 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "d3f8439435571930893eb20d0599cf4de93bfb4646965c3725af4fdd966b8138" + "sha256": "c5a63e53b98f2ef609bf9957685d8e9246b34b0c260e860b0292d082f7bc52c5" }, "pipfile-spec": 6, "requires": { @@ -2493,6 +2493,14 @@ "markers": "python_version >= '3.7'", "version": "==3.1.3" }, + "markdown-it-py": { + "hashes": [ + "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", + "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb" + ], + "markers": "python_version >= '3.8'", + "version": "==3.0.0" + }, "markupsafe": { "hashes": [ "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf", @@ -2559,6 +2567,31 @@ "markers": "python_version >= '3.7'", "version": "==2.1.5" }, + "mdit-py-plugins": { + "hashes": [ + "sha256:b51b3bb70691f57f974e257e367107857a93b36f322a9e6d44ca5bf28ec2def9", + "sha256:d8ab27e9aed6c38aa716819fedfde15ca275715955f8a185a8e1cf90fb1d2c1b" + ], + "markers": "python_version >= '3.8'", + "version": "==0.4.0" + }, + "mdurl": { + "hashes": [ + "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", + "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba" + ], + "markers": "python_version >= '3.7'", + "version": "==0.1.2" + }, + "myst-parser": { + "hashes": [ + "sha256:7c36344ae39c8e740dad7fdabf5aa6fc4897a813083c6cc9990044eb93656b14", + "sha256:ea929a67a6a0b1683cdbe19b8d2e724cd7643f8aa3e7bb18dd65beac3483bead" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==2.0.0" + }, "packaging": { "hashes": [ "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", @@ -2575,6 +2608,63 @@ "markers": "python_version >= '3.7'", "version": "==2.17.2" }, + "pyyaml": { + "hashes": [ + "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5", + "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", + "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df", + "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", + "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206", + "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27", + "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595", + "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62", + "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98", + "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696", + "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290", + "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9", + "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", + "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6", + "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867", + "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47", + "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486", + "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6", + "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3", + "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", + "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938", + "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0", + "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c", + "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735", + "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d", + "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28", + "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", + "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba", + "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8", + "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef", + "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5", + "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd", + "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3", + "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0", + "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515", + "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c", + "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c", + "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924", + "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34", + "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", + "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859", + "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", + "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54", + "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a", + "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b", + "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", + "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa", + "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c", + "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585", + "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d", + "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f" + ], + "markers": "python_version >= '3.6'", + "version": "==6.0.1" + }, "requests": { "hashes": [ "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", @@ -2669,7 +2759,7 @@ "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" ], - "markers": "python_version >= '3.8'", + "markers": "python_version >= '3.6'", "version": "==2.2.1" } } diff --git a/care/facility/migrations/0426_alter_fileupload_file_type.py b/care/facility/migrations/0426_alter_fileupload_file_type.py new file mode 100644 index 0000000000..fd371a24fd --- /dev/null +++ b/care/facility/migrations/0426_alter_fileupload_file_type.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.10 on 2024-04-12 12:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("facility", "0425_merge_20240403_2055"), + ] + + operations = [ + migrations.AlterField( + model_name="fileupload", + name="file_type", + field=models.IntegerField( + choices=[ + (0, "OTHER"), + (1, "PATIENT"), + (2, "CONSULTATION"), + (3, "SAMPLE_MANAGEMENT"), + (4, "CLAIM"), + (5, "DISCHARGE_SUMMARY"), + (6, "COMMUNICATION"), + (7, "CONSENT_RECORD"), + ], + default=1, + ), + ), + ] diff --git a/care/facility/models/file_upload.py b/care/facility/models/file_upload.py index 9f5e1e04af..56a140fd5d 100644 --- a/care/facility/models/file_upload.py +++ b/care/facility/models/file_upload.py @@ -1,79 +1,53 @@ -import enum import time from uuid import uuid4 import boto3 +from django.contrib.auth import get_user_model from django.db import models -from care.facility.models import FacilityBaseModel -from care.users.models import User from care.utils.csp.config import BucketType, get_client_config +from care.utils.models.base import BaseManager +User = get_user_model() -class FileUpload(FacilityBaseModel): - """ - Stores data about all file uploads - the file can belong to any type ie Patient , Consultation , Daily Round and so on ... - the file will be uploaded to the corresponding folders - the file name will be randomised and converted into an internal name before storing in S3 - all data will be private and file access will be given on a NEED TO BASIS ONLY - """ - - # TODO : Periodic tasks that removes files that were never uploaded - class FileType(enum.Enum): - PATIENT = 1 - CONSULTATION = 2 - SAMPLE_MANAGEMENT = 3 - CLAIM = 4 - DISCHARGE_SUMMARY = 5 - COMMUNICATION = 6 - CONSENT_RECORD = 7 +class BaseFileUpload(models.Model): + class FileCategory(models.TextChoices): + UNSPECIFIED = "UNSPECIFIED", "UNSPECIFIED" + XRAY = "XRAY", "XRAY" + AUDIO = "AUDIO", "AUDIO" + IDENTITY_PROOF = "IDENTITY_PROOF", "IDENTITY_PROOF" - class FileCategory(enum.Enum): - UNSPECIFIED = "UNSPECIFIED" - XRAY = "XRAY" - AUDIO = "AUDIO" - IDENTITY_PROOF = "IDENTITY_PROOF" - - FileTypeChoices = [(e.value, e.name) for e in FileType] - FileCategoryChoices = [(e.value, e.name) for e in FileCategory] + external_id = models.UUIDField(default=uuid4, unique=True, db_index=True) name = models.CharField(max_length=2000) # name should not contain file extension internal_name = models.CharField( max_length=2000 ) # internal_name should include file extension associating_id = models.CharField(max_length=100, blank=False, null=False) - upload_completed = models.BooleanField(default=False) - is_archived = models.BooleanField(default=False) - archive_reason = models.TextField(blank=True) - uploaded_by = models.ForeignKey( - User, - on_delete=models.PROTECT, - null=True, - blank=True, - related_name="uploaded_by", - ) - archived_by = models.ForeignKey( - User, - on_delete=models.PROTECT, - null=True, - blank=True, - related_name="archived_by", - ) - archived_datetime = models.DateTimeField(blank=True, null=True) - file_type = models.IntegerField( - choices=FileTypeChoices, default=FileType.PATIENT.value - ) + file_type = models.IntegerField(default=0) file_category = models.CharField( - choices=FileCategoryChoices, - default=FileCategory.UNSPECIFIED.value, + choices=FileCategory.choices, + default=FileCategory.UNSPECIFIED, max_length=100, ) + created_date = models.DateTimeField( + auto_now_add=True, null=True, blank=True, db_index=True + ) + modified_date = models.DateTimeField( + auto_now=True, null=True, blank=True, db_index=True + ) + upload_completed = models.BooleanField(default=False) + deleted = models.BooleanField(default=False, db_index=True) - def get_extension(self): - parts = self.internal_name.split(".") - return f".{parts[-1]}" if len(parts) > 1 else "" + objects = BaseManager() + + class Meta: + abstract = True + + def delete(self, *args): + self.deleted = True + self.save(update_fields=["deleted"]) def save(self, *args, **kwargs): if "force_insert" in kwargs or (not self.internal_name): @@ -85,6 +59,10 @@ def save(self, *args, **kwargs): self.internal_name = internal_name return super().save(*args, **kwargs) + def get_extension(self): + parts = self.internal_name.split(".") + return f".{parts[-1]}" if len(parts) > 1 else "" + def signed_url( self, duration=60 * 60, mime_type=None, bucket_type=BucketType.PATIENT ): @@ -138,3 +116,48 @@ def file_contents(self): content_type = response["ContentType"] content = response["Body"].read() return content_type, content + + +class FileUpload(BaseFileUpload): + """ + Stores data about all file uploads + the file can belong to any type ie Patient , Consultation , Daily Round and so on ... + the file will be uploaded to the corresponding folders + the file name will be randomised and converted into an internal name before storing in S3 + all data will be private and file access will be given on a NEED TO BASIS ONLY + """ + + # TODO : Periodic tasks that removes files that were never uploaded + + class FileType(models.IntegerChoices): + OTHER = 0, "OTHER" + PATIENT = 1, "PATIENT" + CONSULTATION = 2, "CONSULTATION" + SAMPLE_MANAGEMENT = 3, "SAMPLE_MANAGEMENT" + CLAIM = 4, "CLAIM" + DISCHARGE_SUMMARY = 5, "DISCHARGE_SUMMARY" + COMMUNICATION = 6, "COMMUNICATION" + CONSENT_RECORD = 7, "CONSENT_RECORD" + + file_type = models.IntegerField(choices=FileType.choices, default=FileType.PATIENT) + is_archived = models.BooleanField(default=False) + archive_reason = models.TextField(blank=True) + uploaded_by = models.ForeignKey( + User, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="uploaded_by", + ) + archived_by = models.ForeignKey( + User, + on_delete=models.PROTECT, + null=True, + blank=True, + related_name="archived_by", + ) + archived_datetime = models.DateTimeField(blank=True, null=True) + + # TODO: switch to Choices.choices + FileTypeChoices = [(x.value, x.name) for x in FileType] + FileCategoryChoices = [(x.value, x.name) for x in BaseFileUpload.FileCategory] diff --git a/config/settings/base.py b/config/settings/base.py index f87916098a..9a6df3249b 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -14,6 +14,7 @@ from care.utils.csp import config as csp_config from care.utils.jwks.generate_jwk import generate_encoded_jwks +from plug_config import manager BASE_DIR = Path(__file__).resolve(strict=True).parent.parent.parent APPS_DIR = BASE_DIR / "care" @@ -58,7 +59,6 @@ DATABASES["default"]["CONN_MAX_AGE"] = env.int("CONN_MAX_AGE", default=0) DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" - REDIS_URL = env("REDIS_URL", default="redis://localhost:6379") # CACHES @@ -77,7 +77,6 @@ } } - # URLS # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf @@ -119,8 +118,15 @@ "care.audit_log", "care.hcx", ] + +PLUGIN_APPS = manager.get_apps() + +# Plugin Section + +PLUGIN_CONFIGS = manager.get_config() + # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps -INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS +INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS + PLUGIN_APPS # MIGRATIONS # ------------------------------------------------------------------------------ @@ -387,7 +393,6 @@ # https://github.com/fabiocaccamo/django-maintenance-mode/tree/main#configuration-optional MAINTENANCE_MODE = int(env("MAINTENANCE_MODE", default="0")) - # Password Reset # ------------------------------------------------------------------------------ # https://github.com/anexia-it/django-rest-passwordreset#configuration--settings @@ -396,7 +401,6 @@ # https://github.com/anexia-it/django-rest-passwordreset#custom-email-lookup DJANGO_REST_LOOKUP_FIELD = "username" - # Hardcopy settings (pdf generation) # ------------------------------------------------------------------------------ # https://github.com/loftylabs/django-hardcopy#installation @@ -485,7 +489,6 @@ ) SEND_SMS_NOTIFICATION = False - # Cloud and Buckets # ------------------------------------------------------------------------------ @@ -567,7 +570,6 @@ else FACILITY_S3_BUCKET_ENDPOINT, ) - # for setting the shifting mode PEACETIME_MODE = env.bool("PEACETIME_MODE", default=True) @@ -603,7 +605,6 @@ X_CM_ID = env("X_CM_ID", default="sbx") FIDELIUS_URL = env("FIDELIUS_URL", default="http://fidelius:8090") - IS_PRODUCTION = False # HCX diff --git a/config/urls.py b/config/urls.py index 9b76c36bbf..a79e444d4f 100644 --- a/config/urls.py +++ b/config/urls.py @@ -143,3 +143,6 @@ ), path("redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), ] + +for plug in settings.PLUGIN_APPS: + urlpatterns += [path(f"api/{plug}/", include(f"{plug}.urls"))] diff --git a/docker/dev.Dockerfile b/docker/dev.Dockerfile index 3a916db775..b4eb2f8c92 100644 --- a/docker/dev.Dockerfile +++ b/docker/dev.Dockerfile @@ -8,7 +8,7 @@ ENV PATH /venv/bin:$PATH RUN apt-get update && apt-get install --no-install-recommends -y \ build-essential libjpeg-dev zlib1g-dev \ - libpq-dev gettext wget curl gnupg chromium \ + libpq-dev gettext wget curl gnupg chromium git \ && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ && rm -rf /var/lib/apt/lists/* @@ -21,6 +21,8 @@ RUN pipenv install --system --categories "packages dev-packages" COPY . /app +RUN python3 /app/install_plugins.py + HEALTHCHECK \ --interval=10s \ --timeout=5s \ diff --git a/docker/prod.Dockerfile b/docker/prod.Dockerfile index a6e7d8709b..ab6d548a73 100644 --- a/docker/prod.Dockerfile +++ b/docker/prod.Dockerfile @@ -14,7 +14,7 @@ ARG BUILD_ENVIRONMENT=production ENV PATH /venv/bin:$PATH RUN apt-get update && apt-get install --no-install-recommends -y \ - build-essential libjpeg-dev zlib1g-dev libpq-dev + build-essential libjpeg-dev zlib1g-dev libpq-dev git # use pipenv to manage virtualenv RUN python -m venv /venv @@ -23,6 +23,9 @@ RUN pip install pipenv COPY Pipfile Pipfile.lock ./ RUN pipenv sync --system --categories "packages" +COPY . /app + +RUN python3 /app/install_plugins.py # --- FROM base as runtime diff --git a/docs/conf.py b/docs/conf.py index 02636605ad..ea31c5ef15 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,7 +31,7 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = [] +extensions = ["myst_parser"] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] diff --git a/docs/index.rst b/docs/index.rst index 9d8069158a..66b3e8b7af 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,6 +14,7 @@ Welcome to Care's documentation! pycharm/configuration working-components/configuration django-configuration/configuration + pluggable-apps/configuration django-commands/configuration github-repo/configuration others/configuration diff --git a/docs/pluggable-apps/configuration.md b/docs/pluggable-apps/configuration.md new file mode 100644 index 0000000000..adfeed3e86 --- /dev/null +++ b/docs/pluggable-apps/configuration.md @@ -0,0 +1,57 @@ +# Pluggable Apps + + +## Overview + +Care supports plugins that can be used to extend its functionality. Plugins are basically django apps that are defined in the `plug_config.py`. +These plugins can be automatically loaded during docker image build or run time, however its recommended to include them during docker image build time. +The default care image does not include any plugins, but you can create your own plugin config by overriding the `plug_config.py` file. + + +example `plug_config.py` file: + +```python + +from plugs.manager import PlugManager +from plugs.plug import Plug + +my_plugin = Plug( + name="my_plugin", + package_name="git+https://github.com/octo/my_plugin.git", + version="@v1.0.0", + configs={ + "SERVICE_API_KEY": "my_api_key", + "SERVICE_SECRET_KEY": "my_secret_key", + "VALUE_1_MAX": 10, + }, +) + +plugs = [my_plugin] + +manager = PlugManager(plugs) +``` + +## Plugin config variables + +Each plugin will define their own config variables with some defaults, they can also pick the values from the environment variables if required. +The order of precedence is as follows: + +- Environment variables +- Configs defined in the `plug_config.py` +- Default values defined in the plugin + + +## Development + +To get started with developing a plugin, use [care-plugin-cookiecutter](https://github.com/coronasafe/care-plugin-cookiecutter) +The plugin follows the structure of a typical django app where you can define your models, views, urls, etc. in the plugin folder. +The plugin manager will automatically load the required configurations and plugin urls under `/api/plugin-name/`. + +To develop the plugins locally you can install the plugin in the editable mode using `pip install -e /path/to/plugin`. + +If you need to inherit the components from the core app, you can install care in editable mode in the plugin using `pip install -e /path/to/care`. + + +## Available Plugins + +- [Care Scribe](https://github.com/coronasafe/care_scribe): Care Scribe is a plugin that provides autofill functionality for the care consultation forms. diff --git a/install_plugins.py b/install_plugins.py new file mode 100644 index 0000000000..8324ff795b --- /dev/null +++ b/install_plugins.py @@ -0,0 +1,3 @@ +from plug_config import manager + +manager.install() diff --git a/plug_config.py b/plug_config.py new file mode 100644 index 0000000000..a99af83fc5 --- /dev/null +++ b/plug_config.py @@ -0,0 +1,5 @@ +from plugs.manager import PlugManager + +plugs = [] + +manager = PlugManager(plugs) diff --git a/plugs/manager.py b/plugs/manager.py new file mode 100644 index 0000000000..c9216b31e6 --- /dev/null +++ b/plugs/manager.py @@ -0,0 +1,29 @@ +import subprocess +import sys +from collections import defaultdict + + +class PlugManager: + """ + Manager to manage plugs in care + """ + + def __init__(self, plugs): + self.plugs = plugs + + def install(self): + packages = [x.package_name + x.version for x in self.plugs] + if packages: + subprocess.check_call( + [sys.executable, "-m", "pip", "install", " ".join(packages)] + ) + + def get_apps(self): + return [plug.name for plug in self.plugs] + + def get_config(self): + configs = defaultdict(dict) + for plug in self.plugs: + for key, value in plug.configs.items(): + configs[plug.name][key] = value + return configs diff --git a/plugs/plug.py b/plugs/plug.py new file mode 100644 index 0000000000..043dfe5063 --- /dev/null +++ b/plugs/plug.py @@ -0,0 +1,10 @@ +class Plug: + """ + Abstraction of a plugin + """ + + def __init__(self, name, package_name, version, configs): + self.name = name + self.package_name = package_name + self.version = version + self.configs = configs diff --git a/setup.py b/setup.py new file mode 100644 index 0000000000..996cb5544b --- /dev/null +++ b/setup.py @@ -0,0 +1,24 @@ +from setuptools import find_packages, setup + +setup( + name="care", + version="0.1", + packages=find_packages(include=["care", "care.*"]), + include_package_data=True, + install_requires=[], + author="Open Healthcare Network", + author_email="care@ops.ohc.network", + description="A Django app for managing healthcare across hospitals and care centers.", + license="MIT", + keywords="django care ohc", + url="https://github.com/coronasafe/care", + classifiers=[ + "Development Status :: 3 - Alpha", + "Framework :: Django", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + ], +) From e31c08404e030610c704f7e08dbd27eaf6e7c2ad Mon Sep 17 00:00:00 2001 From: Prafful Sharma <115104695+DraKen0009@users.noreply.github.com> Date: Sat, 13 Apr 2024 00:32:36 +0530 Subject: [PATCH 03/15] test for various endpoints in the external_result module (#2034) * added test for external result api * updated external test result tests * added filtering test in list endpoint * updated upsert-endpoint tests * removed print statement * fixing lint issue --------- Co-authored-by: Aakash Singh --- .../tests/test_external_result_api.py | 146 ++++++++++++++++++ .../tests/test_upload_external_result.py | 91 ----------- care/utils/tests/test_utils.py | 34 ++++ 3 files changed, 180 insertions(+), 91 deletions(-) create mode 100644 care/facility/tests/test_external_result_api.py delete mode 100644 care/facility/tests/test_upload_external_result.py diff --git a/care/facility/tests/test_external_result_api.py b/care/facility/tests/test_external_result_api.py new file mode 100644 index 0000000000..d1a2b88263 --- /dev/null +++ b/care/facility/tests/test_external_result_api.py @@ -0,0 +1,146 @@ +from rest_framework import status +from rest_framework.test import APITestCase + +from care.facility.models import PatientExternalTest +from care.utils.tests.test_utils import TestUtils + + +class PatientExternalTestViewSetTestCase(TestUtils, APITestCase): + @classmethod + def setUpTestData(cls) -> None: + cls.state = cls.create_state() + cls.district = cls.create_district(cls.state) + cls.local_body = cls.create_local_body(cls.district) + cls.ward = cls.create_ward(cls.local_body) + cls.user = cls.create_super_user("su", cls.district) + cls.external_result = cls.create_patient_external_test( + cls.district, cls.local_body, cls.ward, name="TEST_1" + ) + + def test_list_external_result(self): + state2 = self.create_state() + district2 = self.create_district(state2) + local_body2 = self.create_local_body(district2) + ward2 = self.create_ward(local_body2) + + self.create_patient_external_test( + district2, local_body2, ward2, name="TEST_2", mobile_number="9999988888" + ) + self.create_patient_external_test( + self.district, self.local_body, self.ward, srf_id="ID001" + ) + + response = self.client.get("/api/v1/external_result/") + patient_external_test = PatientExternalTest.objects.all() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], len(patient_external_test)) + + response = self.client.get("/api/v1/external_result/?name=TEST_2") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["results"][0]["name"], "TEST_2") + + response = self.client.get("/api/v1/external_result/?srf_id=ID001") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["results"][0]["srf_id"], "ID001") + + response = self.client.get(f"/api/v1/external_result/?ward__id={self.ward.id}") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data["results"][0]["ward_object"]["name"], self.ward.name + ) + + response = self.client.get( + f"/api/v1/external_result/?district__id={self.district.id}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data["results"][0]["district_object"]["name"], self.district.name + ) + + response = self.client.get( + f"/api/v1/external_result/?local_body={self.local_body.id}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data["results"][0]["local_body_object"]["name"], + self.local_body.name, + ) + + response = self.client.get("/api/v1/external_result/?mobile_number=9999988888") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["results"][0]["mobile_number"], "9999988888") + self.assertEqual(response.data["results"][0]["name"], "TEST_2") + + def test_retrieve_external_result(self): + response = self.client.get( + f"/api/v1/external_result/{self.external_result.id}/" + ) + patient_external_test = PatientExternalTest.objects.get( + id=self.external_result.id + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get("id"), patient_external_test.id) + self.assertEqual(response.data.get("name"), patient_external_test.name) + + def test_update_patient_external_result(self): + sample_data = { + "address": "Upload test address Updated", + } + response = self.client.put( + f"/api/v1/external_result/{self.external_result.id}/", sample_data + ) + self.external_result.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(self.external_result.address, sample_data["address"]) + + def test_update_patch_patient_external_result(self): + sample_data = { + "address": "Upload test address Updated patch", + } + response = self.client.patch( + f"/api/v1/external_result/{self.external_result.id}/", sample_data + ) + self.external_result.refresh_from_db() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(self.external_result.address, sample_data["address"]) + + def test_delete_patient_external_result(self): + response = self.client.delete( + f"/api/v1/external_result/{self.external_result.id}/" + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse( + PatientExternalTest.objects.filter(id=self.external_result.id).exists() + ) + + def test_no_data_upload(self): + response = self.client.post( + "/api/v1/external_result/bulk_upsert/", sample_data={} + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["sample_tests"], "No Data was provided") + + def test_different_district_upload(self): + state2 = self.create_state() + district2 = self.create_district(state2) + external_test_data = self.get_patient_external_test_data( + str(district2), str(self.local_body.name), self.ward.number + ).copy() + sample_data = {"sample_tests": [external_test_data]} + response = self.client.post( + "/api/v1/external_result/bulk_upsert/", sample_data, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["Error"], "User must belong to same district") + + def test_same_district_upload(self): + external_test_data = self.get_patient_external_test_data( + str(self.district), str(self.local_body.name), self.ward.number + ).copy() + external_test_data.update({"local_body_type": "municipality"}) + sample_data = {"sample_tests": [external_test_data]} + response = self.client.post( + "/api/v1/external_result/bulk_upsert/", sample_data, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) diff --git a/care/facility/tests/test_upload_external_result.py b/care/facility/tests/test_upload_external_result.py deleted file mode 100644 index 7e0cee89a6..0000000000 --- a/care/facility/tests/test_upload_external_result.py +++ /dev/null @@ -1,91 +0,0 @@ -from rest_framework import status -from rest_framework.test import APITestCase - -from care.utils.tests.test_utils import TestUtils - - -class PatientExternalTestViewSetTestCase(TestUtils, APITestCase): - @classmethod - def setUpTestData(cls) -> None: - cls.state = cls.create_state() - cls.district = cls.create_district(cls.state) - cls.local_body = cls.create_local_body(cls.district) - cls.ward = cls.create_ward(cls.local_body) - cls.user = cls.create_super_user("su", cls.district) - - def test_no_data_upload(self): - response = self.client.post( - "/api/v1/external_result/bulk_upsert/", sample_data={} - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.data["sample_tests"], "No Data was provided") - - def test_different_district_upload(self): - sample_data = { - "sample_tests": [ - { - "district": "Random_district", - "srf_id": "00/EKM/0000", - "name": "Test Upload0", - "age": 24, - "age_in": "years", - "gender": "m", - "mobile_number": 8888888888, - "address": "Upload test address", - "ward": self.ward.number, - "local_body": str(self.local_body.name), - "local_body_type": "municipality", - "source": "Secondary contact aparna", - "sample_collection_date": "2020-10-14", - "result_date": "2020-10-14", - "test_type": "Antigen", - "lab_name": "Karothukuzhi Laboratory", - "sample_type": "Ag-SD_Biosensor_Standard_Q_COVID-19_Ag_detection_kit", - "patient_status": "Asymptomatic", - "is_repeat": "NO", - "patient_category": "Cat 17: All individuals who wish to get themselves tested", - "result": "Negative", - } - ] - } - - response = self.client.post( - "/api/v1/external_result/bulk_upsert/", sample_data, format="json" - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.data["Error"], "User must belong to same district") - - def test_same_district_upload(self): - sample_data = { - "sample_tests": [ - { - "district": str(self.district), - "srf_id": "00/EKM/0000", - "name": "Test Upload0", - "age": 24, - "age_in": "years", - "gender": "m", - "mobile_number": 8888888888, - "address": "Upload test address", - "ward": self.ward.number, - "local_body": str(self.local_body.name), - "local_body_type": "municipality", - "source": "Secondary contact aparna", - "sample_collection_date": "2020-10-14", - "result_date": "2020-10-14", - "test_type": "Antigen", - "lab_name": "Karothukuzhi Laboratory", - "sample_type": "Ag-SD_Biosensor_Standard_Q_COVID-19_Ag_detection_kit", - "patient_status": "Asymptomatic", - "is_repeat": "NO", - "patient_category": "Cat 17: All individuals who wish to get themselves tested", - "result": "Negative", - } - ] - } - - response = self.client.post( - "/api/v1/external_result/bulk_upsert/", sample_data, format="json" - ) - print(response.data) - self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) diff --git a/care/utils/tests/test_utils.py b/care/utils/tests/test_utils.py index fcc93584b4..6a34c198ca 100644 --- a/care/utils/tests/test_utils.py +++ b/care/utils/tests/test_utils.py @@ -18,6 +18,7 @@ Facility, LocalBody, PatientConsultation, + PatientExternalTest, PatientRegistration, User, Ward, @@ -345,6 +346,39 @@ def create_consultation( patient.save() return consultation + @classmethod + def get_patient_external_test_data(cls, district, local_body, ward) -> dict: + return { + "district": district, + "srf_id": "00/EKM/0000", + "name": now().timestamp(), + "age": 24, + "age_in": "years", + "gender": "m", + "mobile_number": 8888888888, + "address": "Upload test address", + "ward": ward, + "local_body": local_body, + "source": "Secondary contact aparna", + "sample_collection_date": "2020-10-14", + "result_date": "2020-10-14", + "test_type": "Antigen", + "lab_name": "Karothukuzhi Laboratory", + "sample_type": "Ag-SD_Biosensor_Standard_Q_COVID-19_Ag_detection_kit", + "patient_status": "Asymptomatic", + "is_repeat": True, + "patient_category": "Cat 17: All individuals who wish to get themselves tested", + "result": "Negative", + } + + @classmethod + def create_patient_external_test( + cls, district: District, local_body: LocalBody, ward: Ward, **kwargs + ) -> PatientExternalTest: + data = cls.get_patient_external_test_data(district, local_body, ward).copy() + data.update(kwargs) + return PatientExternalTest.objects.create(**data) + @classmethod def create_asset_location(cls, facility: Facility, **kwargs) -> AssetLocation: data = { From 9e02579017dbc26db4c3e691c8c74fe3487cabb7 Mon Sep 17 00:00:00 2001 From: Vignesh Hari Date: Sat, 13 Apr 2024 00:45:27 +0530 Subject: [PATCH 04/15] Bump Dependencies (#2073) --- Pipfile | 10 +- Pipfile.lock | 382 ++++++++++++++++++++++++++------------------------- 2 files changed, 197 insertions(+), 195 deletions(-) diff --git a/Pipfile b/Pipfile index b6586ba774..a3f62a7553 100644 --- a/Pipfile +++ b/Pipfile @@ -5,7 +5,7 @@ name = "pypi" [packages] argon2-cffi = "==23.1.0" -authlib = "==1.2.1" +authlib = "==1.3.0" boto3 = "==1.34.75" celery = "==5.3.6" django = "==4.2.10" @@ -33,7 +33,7 @@ healthy-django = "==0.1.0" jsonschema = "==4.20.0" jwcrypto = "==1.5.6" newrelic = "==9.3.0" -pillow = "==10.2.0" +pillow = "==10.3.0" psycopg = "==3.1.18" pycryptodome = "==3.20.0" pydantic = "==1.10.12" # fix for fhir.resources < 7.0.2 @@ -47,12 +47,12 @@ whitenoise = "==6.5.0" redis-om = "==0.2.1" [dev-packages] -black = "==23.9.1" +black = "==24.3.0" boto3-stubs = {extras = ["s3", "boto3"], version = "==1.34.75"} coverage = "==7.4.0" debugpy = "==1.8.1" django-coverage-plugin = "==3.1.0" -django-debug-toolbar = "==4.2.0" +django-debug-toolbar = "==4.3.0" django-extensions = "==3.2.3" django-silk = "==5.0.3" django-stubs = "==4.2.4" @@ -62,7 +62,7 @@ flake8 = "==7.0.0" freezegun = "==1.2.2" ipython = "==8.15.0" isort = "==5.12.0" -mypy = "==1.5.1" +mypy = "==1.9.0" pre-commit = "==3.4.0" tblib = "==2.0.0" watchdog = "==3.0.0" diff --git a/Pipfile.lock b/Pipfile.lock index 0e22cc9f7f..5d4f755705 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c5a63e53b98f2ef609bf9957685d8e9246b34b0c260e860b0292d082f7bc52c5" + "sha256": "945c853ca13288b642bd6d4b53e5d4b23f8465266978ce18ed96d09403e015b1" }, "pipfile-spec": 6, "requires": { @@ -62,11 +62,11 @@ }, "asgiref": { "hashes": [ - "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e", - "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed" + "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", + "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590" ], - "markers": "python_version >= '3.7'", - "version": "==3.7.2" + "markers": "python_version >= '3.8'", + "version": "==3.8.1" }, "attrs": { "hashes": [ @@ -78,11 +78,12 @@ }, "authlib": { "hashes": [ - "sha256:421f7c6b468d907ca2d9afede256f068f87e34d23dd221c07d13d4c234726afb", - "sha256:c88984ea00149a90e3537c964327da930779afa4564e354edfd98410bea01911" + "sha256:959ea62a5b7b5123c5059758296122b57cd2585ae2ed1c0622c21b371ffdae06", + "sha256:9637e4de1fb498310a56900b3e2043a206b03cb11c05422014b0302cbc814be3" ], "index": "pypi", - "version": "==1.2.1" + "markers": "python_version >= '3.8'", + "version": "==1.3.0" }, "billiard": { "hashes": [ @@ -103,11 +104,11 @@ }, "botocore": { "hashes": [ - "sha256:06113ee2587e6160211a6bd797e135efa6aa21b5bde97bf455c02f7dff40203c", - "sha256:1d7f683d99eba65076dfb9af3b42fa967c64f11111d9699b65757420902aa002" + "sha256:0a3fbbe018416aeefa8978454fb0b8129adbaf556647b72269bf02e4bf1f4161", + "sha256:0f302aa76283d4df62b4fbb6d3d20115c1a8957fc02171257fc93904d69d5636" ], "markers": "python_version >= '3.8'", - "version": "==1.34.75" + "version": "==1.34.83" }, "celery": { "hashes": [ @@ -290,11 +291,11 @@ }, "click-didyoumean": { "hashes": [ - "sha256:a0713dc7a1de3f06bc0df5a9567ad19ead2d3d5689b434768a6145bff77c0667", - "sha256:f184f0d851d96b6d29297354ed981b7dd71df7ff500d82fa6d11f0856bee8035" + "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", + "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c" ], - "markers": "python_full_version >= '3.6.2' and python_full_version < '4.0.0'", - "version": "==0.3.0" + "markers": "python_full_version >= '3.6.2'", + "version": "==0.3.1" }, "click-plugins": { "hashes": [ @@ -674,11 +675,11 @@ }, "idna": { "hashes": [ - "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", - "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f" + "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc", + "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0" ], "markers": "python_version >= '3.5'", - "version": "==3.6" + "version": "==3.7" }, "inflection": { "hashes": [ @@ -724,11 +725,11 @@ }, "kombu": { "hashes": [ - "sha256:0eac1bbb464afe6fb0924b21bf79460416d25d8abc52546d4f16cad94f789488", - "sha256:30e470f1a6b49c70dc6f6d13c3e4cc4e178aa6c469ceb6bcd55645385fc84b93" + "sha256:011c4cd9a355c14a1de8d35d257314a1d2456d52b7140388561acac3cf1a97bf", + "sha256:5634c511926309c7f9789f1433e9ed402616b56836ef9878f01bd59267b4c7a9" ], "markers": "python_version >= '3.8'", - "version": "==5.3.5" + "version": "==5.3.7" }, "more-itertools": { "hashes": [ @@ -770,78 +771,79 @@ }, "pillow": { "hashes": [ - "sha256:0304004f8067386b477d20a518b50f3fa658a28d44e4116970abfcd94fac34a8", - "sha256:0689b5a8c5288bc0504d9fcee48f61a6a586b9b98514d7d29b840143d6734f39", - "sha256:0eae2073305f451d8ecacb5474997c08569fb4eb4ac231ffa4ad7d342fdc25ac", - "sha256:0fb3e7fc88a14eacd303e90481ad983fd5b69c761e9e6ef94c983f91025da869", - "sha256:11fa2e5984b949b0dd6d7a94d967743d87c577ff0b83392f17cb3990d0d2fd6e", - "sha256:127cee571038f252a552760076407f9cff79761c3d436a12af6000cd182a9d04", - "sha256:154e939c5f0053a383de4fd3d3da48d9427a7e985f58af8e94d0b3c9fcfcf4f9", - "sha256:15587643b9e5eb26c48e49a7b33659790d28f190fc514a322d55da2fb5c2950e", - "sha256:170aeb00224ab3dc54230c797f8404507240dd868cf52066f66a41b33169bdbe", - "sha256:1b5e1b74d1bd1b78bc3477528919414874748dd363e6272efd5abf7654e68bef", - "sha256:1da3b2703afd040cf65ec97efea81cfba59cdbed9c11d8efc5ab09df9509fc56", - "sha256:1e23412b5c41e58cec602f1135c57dfcf15482013ce6e5f093a86db69646a5aa", - "sha256:2247178effb34a77c11c0e8ac355c7a741ceca0a732b27bf11e747bbc950722f", - "sha256:257d8788df5ca62c980314053197f4d46eefedf4e6175bc9412f14412ec4ea2f", - "sha256:3031709084b6e7852d00479fd1d310b07d0ba82765f973b543c8af5061cf990e", - "sha256:322209c642aabdd6207517e9739c704dc9f9db943015535783239022002f054a", - "sha256:322bdf3c9b556e9ffb18f93462e5f749d3444ce081290352c6070d014c93feb2", - "sha256:33870dc4653c5017bf4c8873e5488d8f8d5f8935e2f1fb9a2208c47cdd66efd2", - "sha256:35bb52c37f256f662abdfa49d2dfa6ce5d93281d323a9af377a120e89a9eafb5", - "sha256:3c31822339516fb3c82d03f30e22b1d038da87ef27b6a78c9549888f8ceda39a", - "sha256:3eedd52442c0a5ff4f887fab0c1c0bb164d8635b32c894bc1faf4c618dd89df2", - "sha256:3ff074fc97dd4e80543a3e91f69d58889baf2002b6be64347ea8cf5533188213", - "sha256:47c0995fc4e7f79b5cfcab1fc437ff2890b770440f7696a3ba065ee0fd496563", - "sha256:49d9ba1ed0ef3e061088cd1e7538a0759aab559e2e0a80a36f9fd9d8c0c21591", - "sha256:51f1a1bffc50e2e9492e87d8e09a17c5eea8409cda8d3f277eb6edc82813c17c", - "sha256:52a50aa3fb3acb9cf7213573ef55d31d6eca37f5709c69e6858fe3bc04a5c2a2", - "sha256:54f1852cd531aa981bc0965b7d609f5f6cc8ce8c41b1139f6ed6b3c54ab82bfb", - "sha256:609448742444d9290fd687940ac0b57fb35e6fd92bdb65386e08e99af60bf757", - "sha256:69ffdd6120a4737710a9eee73e1d2e37db89b620f702754b8f6e62594471dee0", - "sha256:6fad5ff2f13d69b7e74ce5b4ecd12cc0ec530fcee76356cac6742785ff71c452", - "sha256:7049e301399273a0136ff39b84c3678e314f2158f50f517bc50285fb5ec847ad", - "sha256:70c61d4c475835a19b3a5aa42492409878bbca7438554a1f89d20d58a7c75c01", - "sha256:716d30ed977be8b37d3ef185fecb9e5a1d62d110dfbdcd1e2a122ab46fddb03f", - "sha256:753cd8f2086b2b80180d9b3010dd4ed147efc167c90d3bf593fe2af21265e5a5", - "sha256:773efe0603db30c281521a7c0214cad7836c03b8ccff897beae9b47c0b657d61", - "sha256:7823bdd049099efa16e4246bdf15e5a13dbb18a51b68fa06d6c1d4d8b99a796e", - "sha256:7c8f97e8e7a9009bcacbe3766a36175056c12f9a44e6e6f2d5caad06dcfbf03b", - "sha256:823ef7a27cf86df6597fa0671066c1b596f69eba53efa3d1e1cb8b30f3533068", - "sha256:8373c6c251f7ef8bda6675dd6d2b3a0fcc31edf1201266b5cf608b62a37407f9", - "sha256:83b2021f2ade7d1ed556bc50a399127d7fb245e725aa0113ebd05cfe88aaf588", - "sha256:870ea1ada0899fd0b79643990809323b389d4d1d46c192f97342eeb6ee0b8483", - "sha256:8d12251f02d69d8310b046e82572ed486685c38f02176bd08baf216746eb947f", - "sha256:9c23f307202661071d94b5e384e1e1dc7dfb972a28a2310e4ee16103e66ddb67", - "sha256:9d189550615b4948f45252d7f005e53c2040cea1af5b60d6f79491a6e147eef7", - "sha256:a086c2af425c5f62a65e12fbf385f7c9fcb8f107d0849dba5839461a129cf311", - "sha256:a2b56ba36e05f973d450582fb015594aaa78834fefe8dfb8fcd79b93e64ba4c6", - "sha256:aebb6044806f2e16ecc07b2a2637ee1ef67a11840a66752751714a0d924adf72", - "sha256:b1b3020d90c2d8e1dae29cf3ce54f8094f7938460fb5ce8bc5c01450b01fbaf6", - "sha256:b4b6b1e20608493548b1f32bce8cca185bf0480983890403d3b8753e44077129", - "sha256:b6f491cdf80ae540738859d9766783e3b3c8e5bd37f5dfa0b76abdecc5081f13", - "sha256:b792a349405fbc0163190fde0dc7b3fef3c9268292586cf5645598b48e63dc67", - "sha256:b7c2286c23cd350b80d2fc9d424fc797575fb16f854b831d16fd47ceec078f2c", - "sha256:babf5acfede515f176833ed6028754cbcd0d206f7f614ea3447d67c33be12516", - "sha256:c365fd1703040de1ec284b176d6af5abe21b427cb3a5ff68e0759e1e313a5e7e", - "sha256:c4225f5220f46b2fde568c74fca27ae9771536c2e29d7c04f4fb62c83275ac4e", - "sha256:c570f24be1e468e3f0ce7ef56a89a60f0e05b30a3669a459e419c6eac2c35364", - "sha256:c6dafac9e0f2b3c78df97e79af707cdc5ef8e88208d686a4847bab8266870023", - "sha256:c8de2789052ed501dd829e9cae8d3dcce7acb4777ea4a479c14521c942d395b1", - "sha256:cb28c753fd5eb3dd859b4ee95de66cc62af91bcff5db5f2571d32a520baf1f04", - "sha256:cb4c38abeef13c61d6916f264d4845fab99d7b711be96c326b84df9e3e0ff62d", - "sha256:d1b35bcd6c5543b9cb547dee3150c93008f8dd0f1fef78fc0cd2b141c5baf58a", - "sha256:d8e6aeb9201e655354b3ad049cb77d19813ad4ece0df1249d3c793de3774f8c7", - "sha256:d8ecd059fdaf60c1963c58ceb8997b32e9dc1b911f5da5307aab614f1ce5c2fb", - "sha256:da2b52b37dad6d9ec64e653637a096905b258d2fc2b984c41ae7d08b938a67e4", - "sha256:e87f0b2c78157e12d7686b27d63c070fd65d994e8ddae6f328e0dcf4a0cd007e", - "sha256:edca80cbfb2b68d7b56930b84a0e45ae1694aeba0541f798e908a49d66b837f1", - "sha256:f379abd2f1e3dddb2b61bc67977a6b5a0a3f7485538bcc6f39ec76163891ee48", - "sha256:fe4c15f6c9285dc54ce6553a3ce908ed37c8f3825b5a51a15c91442bb955b868" + "sha256:048ad577748b9fa4a99a0548c64f2cb8d672d5bf2e643a739ac8faff1164238c", + "sha256:048eeade4c33fdf7e08da40ef402e748df113fd0b4584e32c4af74fe78baaeb2", + "sha256:0ba26351b137ca4e0db0342d5d00d2e355eb29372c05afd544ebf47c0956ffeb", + "sha256:0ea2a783a2bdf2a561808fe4a7a12e9aa3799b701ba305de596bc48b8bdfce9d", + "sha256:1530e8f3a4b965eb6a7785cf17a426c779333eb62c9a7d1bbcf3ffd5bf77a4aa", + "sha256:16563993329b79513f59142a6b02055e10514c1a8e86dca8b48a893e33cf91e3", + "sha256:19aeb96d43902f0a783946a0a87dbdad5c84c936025b8419da0a0cd7724356b1", + "sha256:1a1d1915db1a4fdb2754b9de292642a39a7fb28f1736699527bb649484fb966a", + "sha256:1b87bd9d81d179bd8ab871603bd80d8645729939f90b71e62914e816a76fc6bd", + "sha256:1dfc94946bc60ea375cc39cff0b8da6c7e5f8fcdc1d946beb8da5c216156ddd8", + "sha256:2034f6759a722da3a3dbd91a81148cf884e91d1b747992ca288ab88c1de15999", + "sha256:261ddb7ca91fcf71757979534fb4c128448b5b4c55cb6152d280312062f69599", + "sha256:2ed854e716a89b1afcedea551cd85f2eb2a807613752ab997b9974aaa0d56936", + "sha256:3102045a10945173d38336f6e71a8dc71bcaeed55c3123ad4af82c52807b9375", + "sha256:339894035d0ede518b16073bdc2feef4c991ee991a29774b33e515f1d308e08d", + "sha256:412444afb8c4c7a6cc11a47dade32982439925537e483be7c0ae0cf96c4f6a0b", + "sha256:4203efca580f0dd6f882ca211f923168548f7ba334c189e9eab1178ab840bf60", + "sha256:45ebc7b45406febf07fef35d856f0293a92e7417ae7933207e90bf9090b70572", + "sha256:4b5ec25d8b17217d635f8935dbc1b9aa5907962fae29dff220f2659487891cd3", + "sha256:4c8e73e99da7db1b4cad7f8d682cf6abad7844da39834c288fbfa394a47bbced", + "sha256:4e6f7d1c414191c1199f8996d3f2282b9ebea0945693fb67392c75a3a320941f", + "sha256:4eaa22f0d22b1a7e93ff0a596d57fdede2e550aecffb5a1ef1106aaece48e96b", + "sha256:50b8eae8f7334ec826d6eeffaeeb00e36b5e24aa0b9df322c247539714c6df19", + "sha256:50fd3f6b26e3441ae07b7c979309638b72abc1a25da31a81a7fbd9495713ef4f", + "sha256:51243f1ed5161b9945011a7360e997729776f6e5d7005ba0c6879267d4c5139d", + "sha256:5d512aafa1d32efa014fa041d38868fda85028e3f930a96f85d49c7d8ddc0383", + "sha256:5f77cf66e96ae734717d341c145c5949c63180842a545c47a0ce7ae52ca83795", + "sha256:6b02471b72526ab8a18c39cb7967b72d194ec53c1fd0a70b050565a0f366d355", + "sha256:6fb1b30043271ec92dc65f6d9f0b7a830c210b8a96423074b15c7bc999975f57", + "sha256:7161ec49ef0800947dc5570f86568a7bb36fa97dd09e9827dc02b718c5643f09", + "sha256:72d622d262e463dfb7595202d229f5f3ab4b852289a1cd09650362db23b9eb0b", + "sha256:74d28c17412d9caa1066f7a31df8403ec23d5268ba46cd0ad2c50fb82ae40462", + "sha256:78618cdbccaa74d3f88d0ad6cb8ac3007f1a6fa5c6f19af64b55ca170bfa1edf", + "sha256:793b4e24db2e8742ca6423d3fde8396db336698c55cd34b660663ee9e45ed37f", + "sha256:798232c92e7665fe82ac085f9d8e8ca98826f8e27859d9a96b41d519ecd2e49a", + "sha256:81d09caa7b27ef4e61cb7d8fbf1714f5aec1c6b6c5270ee53504981e6e9121ad", + "sha256:8ab74c06ffdab957d7670c2a5a6e1a70181cd10b727cd788c4dd9005b6a8acd9", + "sha256:8eb0908e954d093b02a543dc963984d6e99ad2b5e36503d8a0aaf040505f747d", + "sha256:90b9e29824800e90c84e4022dd5cc16eb2d9605ee13f05d47641eb183cd73d45", + "sha256:9797a6c8fe16f25749b371c02e2ade0efb51155e767a971c61734b1bf6293994", + "sha256:9d2455fbf44c914840c793e89aa82d0e1763a14253a000743719ae5946814b2d", + "sha256:9d3bea1c75f8c53ee4d505c3e67d8c158ad4df0d83170605b50b64025917f338", + "sha256:9e2ec1e921fd07c7cda7962bad283acc2f2a9ccc1b971ee4b216b75fad6f0463", + "sha256:9e91179a242bbc99be65e139e30690e081fe6cb91a8e77faf4c409653de39451", + "sha256:a0eaa93d054751ee9964afa21c06247779b90440ca41d184aeb5d410f20ff591", + "sha256:a2c405445c79c3f5a124573a051062300936b0281fee57637e706453e452746c", + "sha256:aa7e402ce11f0885305bfb6afb3434b3cd8f53b563ac065452d9d5654c7b86fd", + "sha256:aff76a55a8aa8364d25400a210a65ff59d0168e0b4285ba6bf2bd83cf675ba32", + "sha256:b09b86b27a064c9624d0a6c54da01c1beaf5b6cadfa609cf63789b1d08a797b9", + "sha256:b14f16f94cbc61215115b9b1236f9c18403c15dd3c52cf629072afa9d54c1cbf", + "sha256:b50811d664d392f02f7761621303eba9d1b056fb1868c8cdf4231279645c25f5", + "sha256:b7bc2176354defba3edc2b9a777744462da2f8e921fbaf61e52acb95bafa9828", + "sha256:c78e1b00a87ce43bb37642c0812315b411e856a905d58d597750eb79802aaaa3", + "sha256:c83341b89884e2b2e55886e8fbbf37c3fa5efd6c8907124aeb72f285ae5696e5", + "sha256:ca2870d5d10d8726a27396d3ca4cf7976cec0f3cb706debe88e3a5bd4610f7d2", + "sha256:ccce24b7ad89adb5a1e34a6ba96ac2530046763912806ad4c247356a8f33a67b", + "sha256:cd5e14fbf22a87321b24c88669aad3a51ec052eb145315b3da3b7e3cc105b9a2", + "sha256:ce49c67f4ea0609933d01c0731b34b8695a7a748d6c8d186f95e7d085d2fe475", + "sha256:d33891be6df59d93df4d846640f0e46f1a807339f09e79a8040bc887bdcd7ed3", + "sha256:d3b2348a78bc939b4fed6552abfd2e7988e0f81443ef3911a4b8498ca084f6eb", + "sha256:d886f5d353333b4771d21267c7ecc75b710f1a73d72d03ca06df49b09015a9ef", + "sha256:d93480005693d247f8346bc8ee28c72a2191bdf1f6b5db469c096c0c867ac015", + "sha256:dc1a390a82755a8c26c9964d457d4c9cbec5405896cba94cf51f36ea0d855002", + "sha256:dd78700f5788ae180b5ee8902c6aea5a5726bac7c364b202b4b3e3ba2d293170", + "sha256:e46f38133e5a060d46bd630faa4d9fa0202377495df1f068a8299fd78c84de84", + "sha256:e4b878386c4bf293578b48fc570b84ecfe477d3b77ba39a6e87150af77f40c57", + "sha256:f0d0591a0aeaefdaf9a5e545e7485f89910c977087e7de2b6c388aec32011e9f", + "sha256:fdcbb4068117dfd9ce0138d068ac512843c52295ed996ae6dd1faf537b6dbc27", + "sha256:ff61bfd9253c3915e6d41c651d5f962da23eda633cf02262990094a18a55371a" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==10.2.0" + "version": "==10.3.0" }, "ply": { "hashes": [ @@ -875,10 +877,11 @@ }, "pycparser": { "hashes": [ - "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", - "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" + "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", + "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc" ], - "version": "==2.21" + "markers": "python_version >= '3.8'", + "version": "==2.22" }, "pycryptodome": { "hashes": [ @@ -1268,19 +1271,19 @@ }, "types-redis": { "hashes": [ - "sha256:6b9d68a29aba1ee400c823d8e5fe88675282eb69d7211e72fe65dbe54b33daca", - "sha256:e049bbdff0e0a1f8e701b64636811291d21bff79bf1e7850850a44055224a85f" + "sha256:a3b92760c49a034827a0c3825206728df4e61e981c1324099d4414335af4f52f", + "sha256:ce217c279581d769df992c5b76d61c65425b0a679626048e633e643868eb881b" ], "markers": "python_version >= '3.8'", - "version": "==4.6.0.20240311" + "version": "==4.6.0.20240409" }, "typing-extensions": { "hashes": [ - "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", - "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb" + "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0", + "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a" ], "markers": "python_version >= '3.8'", - "version": "==4.10.0" + "version": "==4.11.0" }, "tzdata": { "hashes": [ @@ -1309,7 +1312,7 @@ "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" ], - "markers": "python_version >= '3.6'", + "markers": "python_version >= '3.8'", "version": "==2.2.1" }, "vine": { @@ -1340,11 +1343,11 @@ "develop": { "asgiref": { "hashes": [ - "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e", - "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed" + "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", + "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590" ], - "markers": "python_version >= '3.7'", - "version": "==3.7.2" + "markers": "python_version >= '3.8'", + "version": "==3.8.1" }, "asttokens": { "hashes": [ @@ -1370,41 +1373,41 @@ }, "black": { "hashes": [ - "sha256:031e8c69f3d3b09e1aa471a926a1eeb0b9071f80b17689a655f7885ac9325a6f", - "sha256:13a2e4a93bb8ca74a749b6974925c27219bb3df4d42fc45e948a5d9feb5122b7", - "sha256:13ef033794029b85dfea8032c9d3b92b42b526f1ff4bf13b2182ce4e917f5100", - "sha256:14f04c990259576acd093871e7e9b14918eb28f1866f91968ff5524293f9c573", - "sha256:24b6b3ff5c6d9ea08a8888f6977eae858e1f340d7260cf56d70a49823236b62d", - "sha256:403397c033adbc45c2bd41747da1f7fc7eaa44efbee256b53842470d4ac5a70f", - "sha256:50254ebfa56aa46a9fdd5d651f9637485068a1adf42270148cd101cdf56e0ad9", - "sha256:538efb451cd50f43aba394e9ec7ad55a37598faae3348d723b59ea8e91616300", - "sha256:638619a559280de0c2aa4d76f504891c9860bb8fa214267358f0a20f27c12948", - "sha256:6a3b50e4b93f43b34a9d3ef00d9b6728b4a722c997c99ab09102fd5efdb88325", - "sha256:6ccd59584cc834b6d127628713e4b6b968e5f79572da66284532525a042549f9", - "sha256:75a2dc41b183d4872d3a500d2b9c9016e67ed95738a3624f4751a0cb4818fe71", - "sha256:7d30ec46de88091e4316b17ae58bbbfc12b2de05e069030f6b747dfc649ad186", - "sha256:8431445bf62d2a914b541da7ab3e2b4f3bc052d2ccbf157ebad18ea126efb91f", - "sha256:8fc1ddcf83f996247505db6b715294eba56ea9372e107fd54963c7553f2b6dfe", - "sha256:a732b82747235e0542c03bf352c126052c0fbc458d8a239a94701175b17d4855", - "sha256:adc3e4442eef57f99b5590b245a328aad19c99552e0bdc7f0b04db6656debd80", - "sha256:c46767e8df1b7beefb0899c4a95fb43058fa8500b6db144f4ff3ca38eb2f6393", - "sha256:c619f063c2d68f19b2d7270f4cf3192cb81c9ec5bc5ba02df91471d0b88c4c5c", - "sha256:cf3a4d00e4cdb6734b64bf23cd4341421e8953615cba6b3670453737a72ec204", - "sha256:cf99f3de8b3273a8317681d8194ea222f10e0133a24a7548c73ce44ea1679377", - "sha256:d6bc09188020c9ac2555a498949401ab35bb6bf76d4e0f8ee251694664df6301" + "sha256:2818cf72dfd5d289e48f37ccfa08b460bf469e67fb7c4abb07edc2e9f16fb63f", + "sha256:41622020d7120e01d377f74249e677039d20e6344ff5851de8a10f11f513bf93", + "sha256:4acf672def7eb1725f41f38bf6bf425c8237248bb0804faa3965c036f7672d11", + "sha256:4be5bb28e090456adfc1255e03967fb67ca846a03be7aadf6249096100ee32d0", + "sha256:4f1373a7808a8f135b774039f61d59e4be7eb56b2513d3d2f02a8b9365b8a8a9", + "sha256:56f52cfbd3dabe2798d76dbdd299faa046a901041faf2cf33288bc4e6dae57b5", + "sha256:65b76c275e4c1c5ce6e9870911384bff5ca31ab63d19c76811cb1fb162678213", + "sha256:65c02e4ea2ae09d16314d30912a58ada9a5c4fdfedf9512d23326128ac08ac3d", + "sha256:6905238a754ceb7788a73f02b45637d820b2f5478b20fec82ea865e4f5d4d9f7", + "sha256:79dcf34b33e38ed1b17434693763301d7ccbd1c5860674a8f871bd15139e7837", + "sha256:7bb041dca0d784697af4646d3b62ba4a6b028276ae878e53f6b4f74ddd6db99f", + "sha256:7d5e026f8da0322b5662fa7a8e752b3fa2dac1c1cbc213c3d7ff9bdd0ab12395", + "sha256:9f50ea1132e2189d8dff0115ab75b65590a3e97de1e143795adb4ce317934995", + "sha256:a0c9c4a0771afc6919578cec71ce82a3e31e054904e7197deacbc9382671c41f", + "sha256:aadf7a02d947936ee418777e0247ea114f78aff0d0959461057cae8a04f20597", + "sha256:b5991d523eee14756f3c8d5df5231550ae8993e2286b8014e2fdea7156ed0959", + "sha256:bf21b7b230718a5f08bd32d5e4f1db7fc8788345c8aea1d155fc17852b3410f5", + "sha256:c45f8dff244b3c431b36e3224b6be4a127c6aca780853574c00faf99258041eb", + "sha256:c7ed6668cbbfcd231fa0dc1b137d3e40c04c7f786e626b405c62bcd5db5857e4", + "sha256:d7de8d330763c66663661a1ffd432274a2f92f07feeddd89ffd085b5744f85e7", + "sha256:e19cb1c6365fd6dc38a6eae2dcb691d7d83935c10215aef8e6c38edee3f77abd", + "sha256:e2af80566f43c85f5797365077fb64a393861a3730bd110971ab7a0c94e873e7" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==23.9.1" + "version": "==24.3.0" }, "boto3": { "hashes": [ - "sha256:b611de58ab28940a36c77d7ef9823427ebf25d5ee8277b802f9979b14e780534", - "sha256:db97f9c29f1806cf9020679be0dd5ffa2aff2670e28e0e2046f98b979be498a4" + "sha256:ba5d2104bba4370766036d64ad9021eb6289d154265852a2a821ec6a5e816faa", + "sha256:eaec72fda124084105a31bcd67eafa1355b34df6da70cadae0c0f262d8a4294f" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.34.65" + "version": "==1.34.75" }, "boto3-stubs": { "extras": [ @@ -1415,17 +1418,16 @@ "sha256:78093a0bf5a03bc66a79d6cddb9f0eb67b67ed6b008cba4cf394c0c9d11de2c1", "sha256:bb55fe97f474ea800c762592d81369bb6c23a8e53a5b2d8497145f87c1d7640c" ], - "index": "pypi", "markers": "python_version >= '3.8'", "version": "==1.34.75" }, "botocore": { "hashes": [ - "sha256:92560f8fbdaa9dd221212a3d3a7609219ba0bbf308c13571674c0cda9d8f39e1", - "sha256:fd7d8742007c220f897cb126b8916ca0cf3724a739d4d716aa5385d7f9d8aeb1" + "sha256:0a3fbbe018416aeefa8978454fb0b8129adbaf556647b72269bf02e4bf1f4161", + "sha256:0f302aa76283d4df62b4fbb6d3d20115c1a8957fc02171257fc93904d69d5636" ], "markers": "python_version >= '3.8'", - "version": "==1.34.66" + "version": "==1.34.83" }, "botocore-stubs": { "hashes": [ @@ -1677,12 +1679,12 @@ }, "django-debug-toolbar": { "hashes": [ - "sha256:af99128c06e8e794479e65ab62cc6c7d1e74e1c19beb44dcbf9bad7a9c017327", - "sha256:bc7fdaafafcdedefcc67a4a5ad9dac96efd6e41db15bc74d402a54a2ba4854dc" + "sha256:0b0dddee5ea29b9cb678593bc0d7a6d76b21d7799cb68e091a2148341a80f3c4", + "sha256:e09b7dcb8417b743234dfc57c95a7c1d1d87a88844abd13b4c5387f807b31bf6" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==4.2.0" + "version": "==4.3.0" }, "django-extensions": { "hashes": [ @@ -1747,19 +1749,19 @@ }, "faker": { "hashes": [ - "sha256:5fb5aa9749d09971e04a41281ae3ceda9414f683d4810a694f8a8eebb8f9edec", - "sha256:9978025e765ba79f8bf6154c9630a9c2b7f9c9b0f175d4ad5e04b19a82a8d8d6" + "sha256:73b1e7967b0ceeac42fc99a8c973bb49e4499cc4044d20d17ab661d5cb7eda1d", + "sha256:97c7874665e8eb7b517f97bf3b59f03bf3f07513fe2c159e98b6b9ea6b9f2b3d" ], "markers": "python_version >= '3.8'", - "version": "==24.3.0" + "version": "==24.9.0" }, "filelock": { "hashes": [ - "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e", - "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c" + "sha256:404e5e9253aa60ad457cae1be07c0f0ca90a63931200a47d9b6a6af84fd7b45f", + "sha256:d13f466618bfde72bd2c18255e269f72542c6e70e7bac83a0232d6b1cc5c8cf4" ], "markers": "python_version >= '3.8'", - "version": "==3.13.1" + "version": "==3.13.4" }, "flake8": { "hashes": [ @@ -1797,11 +1799,11 @@ }, "idna": { "hashes": [ - "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", - "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f" + "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc", + "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0" ], "markers": "python_version >= '3.5'", - "version": "==3.6" + "version": "==3.7" }, "ipython": { "hashes": [ @@ -1921,37 +1923,37 @@ }, "mypy": { "hashes": [ - "sha256:159aa9acb16086b79bbb0016145034a1a05360626046a929f84579ce1666b315", - "sha256:258b22210a4a258ccd077426c7a181d789d1121aca6db73a83f79372f5569ae0", - "sha256:26f71b535dfc158a71264e6dc805a9f8d2e60b67215ca0bfa26e2e1aa4d4d373", - "sha256:26fb32e4d4afa205b24bf645eddfbb36a1e17e995c5c99d6d00edb24b693406a", - "sha256:2fc3a600f749b1008cc75e02b6fb3d4db8dbcca2d733030fe7a3b3502902f161", - "sha256:32cb59609b0534f0bd67faebb6e022fe534bdb0e2ecab4290d683d248be1b275", - "sha256:330857f9507c24de5c5724235e66858f8364a0693894342485e543f5b07c8693", - "sha256:361da43c4f5a96173220eb53340ace68cda81845cd88218f8862dfb0adc8cddb", - "sha256:4a465ea2ca12804d5b34bb056be3a29dc47aea5973b892d0417c6a10a40b2d65", - "sha256:51cb1323064b1099e177098cb939eab2da42fea5d818d40113957ec954fc85f4", - "sha256:57b10c56016adce71fba6bc6e9fd45d8083f74361f629390c556738565af8eeb", - "sha256:596fae69f2bfcb7305808c75c00f81fe2829b6236eadda536f00610ac5ec2243", - "sha256:5d627124700b92b6bbaa99f27cbe615c8ea7b3402960f6372ea7d65faf376c14", - "sha256:6ac9c21bfe7bc9f7f1b6fae441746e6a106e48fc9de530dea29e8cd37a2c0cc4", - "sha256:82cb6193de9bbb3844bab4c7cf80e6227d5225cc7625b068a06d005d861ad5f1", - "sha256:8f772942d372c8cbac575be99f9cc9d9fb3bd95c8bc2de6c01411e2c84ebca8a", - "sha256:9fece120dbb041771a63eb95e4896791386fe287fefb2837258925b8326d6160", - "sha256:a156e6390944c265eb56afa67c74c0636f10283429171018446b732f1a05af25", - "sha256:a9ec1f695f0c25986e6f7f8778e5ce61659063268836a38c951200c57479cc12", - "sha256:abed92d9c8f08643c7d831300b739562b0a6c9fcb028d211134fc9ab20ccad5d", - "sha256:b031b9601f1060bf1281feab89697324726ba0c0bae9d7cd7ab4b690940f0b92", - "sha256:c543214ffdd422623e9fedd0869166c2f16affe4ba37463975043ef7d2ea8770", - "sha256:d28ddc3e3dfeab553e743e532fb95b4e6afad51d4706dd22f28e1e5e664828d2", - "sha256:f33592ddf9655a4894aef22d134de7393e95fcbdc2d15c1ab65828eee5c66c70", - "sha256:f6b0e77db9ff4fda74de7df13f30016a0a663928d669c9f2c057048ba44f09bb", - "sha256:f757063a83970d67c444f6e01d9550a7402322af3557ce7630d3c957386fa8f5", - "sha256:ff0cedc84184115202475bbb46dd99f8dcb87fe24d5d0ddfc0fe6b8575c88d2f" + "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6", + "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913", + "sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129", + "sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc", + "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974", + "sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374", + "sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150", + "sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03", + "sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9", + "sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02", + "sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89", + "sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2", + "sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d", + "sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3", + "sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612", + "sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e", + "sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3", + "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e", + "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd", + "sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04", + "sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed", + "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185", + "sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf", + "sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b", + "sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4", + "sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f", + "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.5.1" + "version": "==1.9.0" }, "mypy-boto3-s3": { "hashes": [ @@ -1986,11 +1988,11 @@ }, "parso": { "hashes": [ - "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0", - "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75" + "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", + "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d" ], "markers": "python_version >= '3.6'", - "version": "==0.8.3" + "version": "==0.8.4" }, "pathspec": { "hashes": [ @@ -2162,11 +2164,11 @@ }, "setuptools": { "hashes": [ - "sha256:0ff4183f8f42cd8fa3acea16c45205521a4ef28f73c6391d8a25e92893134f2e", - "sha256:c21c49fb1042386df081cb5d86759792ab89efca84cf114889191cd09aacc80c" + "sha256:659e902e587e77fab8212358f5b03977b5f0d18d4724310d4a093929fee4ca1a", + "sha256:b6df12d754b505e4ca283c61582d5578db83ae2f56a979b3bc9a8754705ae3bf" ], "markers": "python_version >= '3.8'", - "version": "==69.2.0" + "version": "==69.4.0" }, "six": { "hashes": [ @@ -2234,11 +2236,11 @@ }, "types-requests": { "hashes": [ - "sha256:47872893d65a38e282ee9f277a4ee50d1b28bd592040df7d1fdaffdf3779937d", - "sha256:b1c1b66abfb7fa79aae09097a811c4aa97130eb8831c60e47aee4ca344731ca5" + "sha256:4428df33c5503945c74b3f42e82b181e86ec7b724620419a2966e2de604ce1a1", + "sha256:6216cdac377c6b9a040ac1c0404f7284bd13199c0e1bb235f4324627e8898cf5" ], "markers": "python_version >= '3.8'", - "version": "==2.31.0.20240311" + "version": "==2.31.0.20240406" }, "types-s3transfer": { "hashes": [ @@ -2250,11 +2252,11 @@ }, "typing-extensions": { "hashes": [ - "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", - "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb" + "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0", + "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a" ], "markers": "python_version >= '3.8'", - "version": "==4.10.0" + "version": "==4.11.0" }, "urllib3": { "hashes": [ @@ -2471,11 +2473,11 @@ }, "idna": { "hashes": [ - "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", - "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f" + "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc", + "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0" ], "markers": "python_version >= '3.5'", - "version": "==3.6" + "version": "==3.7" }, "imagesize": { "hashes": [ @@ -2759,7 +2761,7 @@ "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" ], - "markers": "python_version >= '3.6'", + "markers": "python_version >= '3.8'", "version": "==2.2.1" } } From 87cd40c367b2daf614cb8ea41d85452286d54346 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Mon, 15 Apr 2024 00:15:46 +0530 Subject: [PATCH 05/15] Make care single source of truth for asset config on middleware (#2000) * add routes for middleware to fetch asset config * cache middleware jwks * allow asset authentication for assetbed * squash migrations * fix key cache issue * fix assetbed filter for middlewares * return asset presets from patient_from_asset route itself * add is_parsed_by_ocr flag to indicate ocr use for automated round * improve regex match for hostname query * squash migrations * Discard changes to config/settings/local.py * update migrations * add permission classes to test views * take jwks as kwarg for jwt generation * split open id auth response for easier mocking * test middleware auth * remove indexes * remove unnessary comments * use request mocks to test openid auth * make push config logic async * improve query * fix inconsistent validation * fix signal * Relock dependencies * update migrations --------- Co-authored-by: vigneshhari --- Pipfile | 1 + Pipfile.lock | 51 ++++--- care/facility/api/serializers/asset.py | 90 +++++++++++- .../api/serializers/patient_consultation.py | 17 ++- care/facility/api/viewsets/asset.py | 75 +++++++++- care/facility/api/viewsets/mixins/access.py | 4 +- care/facility/api/viewsets/open_id.py | 5 +- care/facility/api/viewsets/patient.py | 4 +- .../api/viewsets/patient_consultation.py | 38 +++++- care/facility/apps.py | 5 +- .../0427_dailyround_is_parsed_by_ocr.py | 17 +++ care/facility/models/daily_round.py | 1 + care/facility/signals/__init__.py | 1 + care/facility/signals/asset_updates.py | 45 ++++++ care/facility/tasks/push_asset_config.py | 94 +++++++++++++ care/facility/tests/test_middleware_auth.py | 129 ++++++++++++++++++ care/utils/jwks/token_generator.py | 6 +- config/api_router.py | 3 + config/authentication.py | 67 ++++++++- config/health_views.py | 15 +- config/urls.py | 17 ++- 21 files changed, 623 insertions(+), 62 deletions(-) create mode 100644 care/facility/migrations/0427_dailyround_is_parsed_by_ocr.py create mode 100644 care/facility/signals/__init__.py create mode 100644 care/facility/signals/asset_updates.py create mode 100644 care/facility/tasks/push_asset_config.py create mode 100644 care/facility/tests/test_middleware_auth.py diff --git a/Pipfile b/Pipfile index a3f62a7553..c5998678e8 100644 --- a/Pipfile +++ b/Pipfile @@ -64,6 +64,7 @@ ipython = "==8.15.0" isort = "==5.12.0" mypy = "==1.9.0" pre-commit = "==3.4.0" +requests-mock = "==1.12.1" tblib = "==2.0.0" watchdog = "==3.0.0" werkzeug = "==2.3.8" diff --git a/Pipfile.lock b/Pipfile.lock index 5d4f755705..f0236827cb 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "945c853ca13288b642bd6d4b53e5d4b23f8465266978ce18ed96d09403e015b1" + "sha256": "c46c81d23a92a9dd50b9e5f581fa161adde772bcee80da44f781191d081053c3" }, "pipfile-spec": 6, "requires": { @@ -104,11 +104,11 @@ }, "botocore": { "hashes": [ - "sha256:0a3fbbe018416aeefa8978454fb0b8129adbaf556647b72269bf02e4bf1f4161", - "sha256:0f302aa76283d4df62b4fbb6d3d20115c1a8957fc02171257fc93904d69d5636" + "sha256:a2b309bf5594f0eb6f63f355ade79ba575ce8bf672e52e91da1a7933caa245e6", + "sha256:da1ae0a912e69e10daee2a34dafd6c6c106450d20b8623665feceb2d96c173eb" ], "markers": "python_version >= '3.8'", - "version": "==1.34.83" + "version": "==1.34.84" }, "celery": { "hashes": [ @@ -1248,11 +1248,11 @@ }, "sqlparse": { "hashes": [ - "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3", - "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c" + "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93", + "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663" ], - "markers": "python_version >= '3.5'", - "version": "==0.4.4" + "markers": "python_version >= '3.8'", + "version": "==0.5.0" }, "text-unidecode": { "hashes": [ @@ -1312,7 +1312,7 @@ "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" ], - "markers": "python_version >= '3.8'", + "markers": "python_version >= '3.6'", "version": "==2.2.1" }, "vine": { @@ -1423,11 +1423,11 @@ }, "botocore": { "hashes": [ - "sha256:0a3fbbe018416aeefa8978454fb0b8129adbaf556647b72269bf02e4bf1f4161", - "sha256:0f302aa76283d4df62b4fbb6d3d20115c1a8957fc02171257fc93904d69d5636" + "sha256:a2b309bf5594f0eb6f63f355ade79ba575ce8bf672e52e91da1a7933caa245e6", + "sha256:da1ae0a912e69e10daee2a34dafd6c6c106450d20b8623665feceb2d96c173eb" ], "markers": "python_version >= '3.8'", - "version": "==1.34.83" + "version": "==1.34.84" }, "botocore-stubs": { "hashes": [ @@ -2154,6 +2154,15 @@ "markers": "python_version >= '3.7'", "version": "==2.31.0" }, + "requests-mock": { + "hashes": [ + "sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563", + "sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401" + ], + "index": "pypi", + "markers": "python_version >= '3.5'", + "version": "==1.12.1" + }, "s3transfer": { "hashes": [ "sha256:5683916b4c724f799e600f41dd9e10a9ff19871bf87623cc8f491cb4f5fa0a19", @@ -2164,11 +2173,11 @@ }, "setuptools": { "hashes": [ - "sha256:659e902e587e77fab8212358f5b03977b5f0d18d4724310d4a093929fee4ca1a", - "sha256:b6df12d754b505e4ca283c61582d5578db83ae2f56a979b3bc9a8754705ae3bf" + "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987", + "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32" ], "markers": "python_version >= '3.8'", - "version": "==69.4.0" + "version": "==69.5.1" }, "six": { "hashes": [ @@ -2180,11 +2189,11 @@ }, "sqlparse": { "hashes": [ - "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3", - "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c" + "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93", + "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663" ], - "markers": "python_version >= '3.5'", - "version": "==0.4.4" + "markers": "python_version >= '3.8'", + "version": "==0.5.0" }, "stack-data": { "hashes": [ @@ -2263,7 +2272,7 @@ "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" ], - "markers": "python_version >= '3.8'", + "markers": "python_version >= '3.6'", "version": "==2.2.1" }, "virtualenv": { @@ -2761,7 +2770,7 @@ "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" ], - "markers": "python_version >= '3.8'", + "markers": "python_version >= '3.6'", "version": "==2.2.1" } } diff --git a/care/facility/api/serializers/asset.py b/care/facility/api/serializers/asset.py index 4d793a15da..62692a02bd 100644 --- a/care/facility/api/serializers/asset.py +++ b/care/facility/api/serializers/asset.py @@ -1,7 +1,9 @@ from datetime import datetime from django.core.cache import cache -from django.db import transaction +from django.db import models, transaction +from django.db.models import F, Value +from django.db.models.functions import Cast, Coalesce, NullIf from django.shortcuts import get_object_or_404 from django.utils.timezone import now from drf_spectacular.utils import extend_schema_field @@ -31,6 +33,7 @@ UserDefaultAssetLocation, ) from care.users.api.serializers.user import UserBaseMinimumSerializer +from care.utils.assetintegration.asset_classes import AssetClasses from care.utils.assetintegration.hl7monitor import HL7MonitorAsset from care.utils.assetintegration.onvif import OnvifAsset from care.utils.assetintegration.ventilator import VentilatorAsset @@ -210,13 +213,58 @@ def validate(self, attrs): ): raise ValidationError({"asset_class": "Cannot change asset class"}) + if meta := attrs.get("meta"): + current_location = attrs.get( + "current_location", self.instance.current_location + ) + ip_address = meta.get("local_ip_address") + middleware_hostname = ( + meta.get("middleware_hostname") + or current_location.middleware_address + or current_location.facility.middleware_address + ) + if ip_address and middleware_hostname: + asset_using_ip = ( + Asset.objects.annotate( + resolved_middleware_hostname=Coalesce( + NullIf( + Cast( + F("meta__middleware_hostname"), models.CharField() + ), + Value('""'), + ), + NullIf( + F("current_location__middleware_address"), Value("") + ), + F("current_location__facility__middleware_address"), + output_field=models.CharField(), + ) + ) + .filter( + asset_class__in=[ + AssetClasses.ONVIF.name, + AssetClasses.HL7MONITOR.name, + ], + current_location__facility=current_location.facility_id, + resolved_middleware_hostname=middleware_hostname, + meta__local_ip_address=ip_address, + ) + .exclude(id=self.instance.id if self.instance else None) + .only("name") + .first() + ) + if asset_using_ip: + raise ValidationError( + f"IP Address {ip_address} is already in use by {asset_using_ip.name} asset" + ) + return super().validate(attrs) def create(self, validated_data): last_serviced_on = validated_data.pop("last_serviced_on", None) note = validated_data.pop("note", None) with transaction.atomic(): - asset_instance = super().create(validated_data) + asset_instance: Asset = super().create(validated_data) if last_serviced_on or note: asset_service = AssetService( asset=asset_instance, serviced_on=last_serviced_on, note=note @@ -226,7 +274,7 @@ def create(self, validated_data): asset_instance.save(update_fields=["last_service"]) return asset_instance - def update(self, instance, validated_data): + def update(self, instance: Asset, validated_data): user = self.context["request"].user with transaction.atomic(): if validated_data.get("last_serviced_on") and ( @@ -271,11 +319,45 @@ def update(self, instance, validated_data): asset=instance, performed_by=user, ).save() - updated_instance = super().update(instance, validated_data) + updated_instance: Asset = super().update(instance, validated_data) cache.delete(f"asset:{instance.external_id}") return updated_instance +class AssetConfigSerializer(ModelSerializer): + id = UUIDField(source="external_id") + type = CharField(source="asset_class") + description = CharField(default="") + ip_address = CharField(default="") + access_key = CharField(default="") + username = CharField(default="") + password = CharField(default="") + port = serializers.IntegerField(default=80) + + def to_representation(self, instance: Asset): + data = super().to_representation(instance) + data["ip_address"] = instance.meta.get("local_ip_address") + if camera_access_key := instance.meta.get("camera_access_key"): + values = camera_access_key.split(":") + if len(values) == 3: + data["username"], data["password"], data["access_key"] = values + return data + + class Meta: + model = Asset + fields = ( + "id", + "name", + "type", + "description", + "ip_address", + "access_key", + "username", + "password", + "port", + ) + + class AssetTransactionSerializer(ModelSerializer): id = UUIDField(source="external_id", read_only=True) asset = AssetBareMinimumSerializer(read_only=True) diff --git a/care/facility/api/serializers/patient_consultation.py b/care/facility/api/serializers/patient_consultation.py index bfb18b1669..ad0cd8ece3 100644 --- a/care/facility/api/serializers/patient_consultation.py +++ b/care/facility/api/serializers/patient_consultation.py @@ -10,7 +10,10 @@ from care.abdm.utils.api_call import AbdmGateway from care.facility.api.serializers import TIMESTAMP_FIELDS from care.facility.api.serializers.asset import AssetLocationSerializer -from care.facility.api.serializers.bed import ConsultationBedSerializer +from care.facility.api.serializers.bed import ( + AssetBedSerializer, + ConsultationBedSerializer, +) from care.facility.api.serializers.consultation_diagnosis import ( ConsultationCreateDiagnosisSerializer, ConsultationDiagnosisSerializer, @@ -765,14 +768,14 @@ def create(self, validated_data): raise NotImplementedError -class PatientConsultationIDSerializer(serializers.ModelSerializer): - consultation_id = serializers.UUIDField(source="external_id", read_only=True) - patient_id = serializers.UUIDField(source="patient.external_id", read_only=True) - bed_id = serializers.UUIDField(source="current_bed.bed.external_id", read_only=True) +class PatientConsultationIDSerializer(serializers.Serializer): + consultation_id = serializers.UUIDField(read_only=True) + patient_id = serializers.UUIDField(read_only=True) + bed_id = serializers.UUIDField(read_only=True) + asset_beds = AssetBedSerializer(many=True, read_only=True) class Meta: - model = PatientConsultation - fields = ("consultation_id", "patient_id", "bed_id") + fields = ("consultation_id", "patient_id", "bed_id", "asset_beds") class EmailDischargeSummarySerializer(serializers.Serializer): diff --git a/care/facility/api/viewsets/asset.py b/care/facility/api/viewsets/asset.py index 45299b7c79..b3ae295a56 100644 --- a/care/facility/api/viewsets/asset.py +++ b/care/facility/api/viewsets/asset.py @@ -1,6 +1,9 @@ +import re + from django.conf import settings from django.core.cache import cache -from django.db.models import Exists, OuterRef, Q, Subquery +from django.db.models import CharField, Exists, F, OuterRef, Q, Subquery, Value +from django.db.models.functions import Cast, Coalesce, NullIf from django.db.models.signals import post_save from django.dispatch import receiver from django.http import Http404 @@ -9,7 +12,7 @@ from django_filters import rest_framework as filters from django_filters.constants import EMPTY_VALUES from djqscsv import render_to_csv_response -from drf_spectacular.utils import extend_schema, inline_serializer +from drf_spectacular.utils import OpenApiParameter, extend_schema, inline_serializer from dry_rest_permissions.generics import DRYPermissions from rest_framework import exceptions from rest_framework import filters as drf_filters @@ -29,6 +32,7 @@ from rest_framework.viewsets import GenericViewSet from care.facility.api.serializers.asset import ( + AssetConfigSerializer, AssetLocationSerializer, AssetSerializer, AssetServiceSerializer, @@ -58,6 +62,7 @@ from care.utils.filters.choicefilter import CareChoiceFilter, inverse_choices from care.utils.queryset.asset_location import get_asset_location_queryset from care.utils.queryset.facility import get_facility_queryset +from config.authentication import MiddlewareAuthentication inverse_asset_type = inverse_choices(AssetTypeChoices) inverse_asset_status = inverse_choices(StatusChoices) @@ -413,6 +418,72 @@ def operate_assets(self, request, *args, **kwargs): ) +class AssetRetrieveConfigViewSet(ListModelMixin, GenericViewSet): + queryset = Asset.objects.all() + authentication_classes = [MiddlewareAuthentication] + permission_classes = [IsAuthenticated] + serializer_class = AssetConfigSerializer + + @extend_schema( + tags=["asset"], + parameters=[ + OpenApiParameter( + name="middleware_hostname", + location=OpenApiParameter.QUERY, + ) + ], + ) + def list(self, request, *args, **kwargs): + """ + This API is used by the middleware to retrieve assets and their configurations + for a given facility and middleware hostname. + """ + middleware_hostname = request.query_params.get("middleware_hostname") + if not middleware_hostname: + return Response( + {"middleware_hostname": "Middleware hostname is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if match := re.match(r"^(https?://)?([^\s/]+)/?$", middleware_hostname): + middleware_hostname = match.group(2) # extract the hostname from the URL + else: + return Response( + {"middleware_hostname": "Invalid middleware hostname"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + queryset = ( + self.get_queryset() + .filter( + current_location__facility=self.request.user.facility, + asset_class__in=[ + AssetClasses.ONVIF.name, + AssetClasses.HL7MONITOR.name, + ], + ) + .annotate( + resolved_middleware_hostname=Coalesce( + NullIf( + Cast(F("meta__middleware_hostname"), CharField()), + Value('""'), + ), + NullIf(F("current_location__middleware_address"), Value("")), + F("current_location__facility__middleware_address"), + output_field=CharField(), + ) + ) + .filter(resolved_middleware_hostname=middleware_hostname) + .exclude( + Q(meta__local_ip_address__isnull=True) + | Q(meta__local_ip_address__exact=""), + ) + ).only("external_id", "meta", "description", "name", "asset_class") + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + class AssetTransactionFilter(filters.FilterSet): qr_code_id = filters.CharFilter(field_name="asset__qr_code_id") external_id = filters.CharFilter(field_name="asset__external_id") diff --git a/care/facility/api/viewsets/mixins/access.py b/care/facility/api/viewsets/mixins/access.py index 28cebe4314..5cb5ee2c73 100644 --- a/care/facility/api/viewsets/mixins/access.py +++ b/care/facility/api/viewsets/mixins/access.py @@ -1,6 +1,6 @@ from care.facility.models.mixins.permissions.asset import DRYAssetPermissions from care.users.models import User -from config.authentication import MiddlewareAuthentication +from config.authentication import MiddlewareAssetAuthentication class UserAccessMixin: @@ -55,7 +55,7 @@ class AssetUserAccessMixin: asset_permissions = (DRYAssetPermissions,) def get_authenticators(self): - return [MiddlewareAuthentication()] + super().get_authenticators() + return [MiddlewareAssetAuthentication()] + super().get_authenticators() def get_permissions(self): """ diff --git a/care/facility/api/viewsets/open_id.py b/care/facility/api/viewsets/open_id.py index f131eafe3f..0f2cd2f910 100644 --- a/care/facility/api/viewsets/open_id.py +++ b/care/facility/api/viewsets/open_id.py @@ -1,10 +1,12 @@ from django.conf import settings +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_page from rest_framework.generics import GenericAPIView from rest_framework.permissions import AllowAny from rest_framework.response import Response -class OpenIdConfigView(GenericAPIView): +class PublicJWKsView(GenericAPIView): """ Retrieve the OpenID Connect configuration """ @@ -12,5 +14,6 @@ class OpenIdConfigView(GenericAPIView): authentication_classes = () permission_classes = (AllowAny,) + @method_decorator(cache_page(60 * 60 * 24)) def get(self, *args, **kwargs): return Response(settings.JWKS.as_dict()) diff --git a/care/facility/api/viewsets/patient.py b/care/facility/api/viewsets/patient.py index 634035ab13..e329a597df 100644 --- a/care/facility/api/viewsets/patient.py +++ b/care/facility/api/viewsets/patient.py @@ -86,7 +86,7 @@ from config.authentication import ( CustomBasicAuthentication, CustomJWTAuthentication, - MiddlewareAuthentication, + MiddlewareAssetAuthentication, ) REVERSE_FACILITY_TYPES = covert_choice_dict(FACILITY_TYPES) @@ -355,7 +355,7 @@ class PatientViewSet( authentication_classes = [ CustomBasicAuthentication, CustomJWTAuthentication, - MiddlewareAuthentication, + MiddlewareAssetAuthentication, ] permission_classes = (IsAuthenticated, DRYPermissions) lookup_field = "external_id" diff --git a/care/facility/api/viewsets/patient_consultation.py b/care/facility/api/viewsets/patient_consultation.py index df5d207c3f..4a31f6354e 100644 --- a/care/facility/api/viewsets/patient_consultation.py +++ b/care/facility/api/viewsets/patient_consultation.py @@ -19,6 +19,7 @@ PatientConsultationSerializer, ) from care.facility.api.viewsets.mixins.access import AssetUserAccessMixin +from care.facility.models.bed import AssetBed, ConsultationBed from care.facility.models.file_upload import FileUpload from care.facility.models.mixins.permissions.asset import IsAssetUser from care.facility.models.patient_consultation import PatientConsultation @@ -228,11 +229,22 @@ def email_discharge_summary(self, request, *args, **kwargs): ) @action(detail=False, methods=["GET"]) def patient_from_asset(self, request): - consultation = ( - PatientConsultation.objects.select_related("patient") + consultation_bed = ( + ConsultationBed.objects.filter( + Q(assets=request.user.asset) + | Q(bed__in=request.user.asset.bed_set.all()), + end_date__isnull=True, + ) .order_by("-id") + .first() + ) + if not consultation_bed: + raise NotFound({"detail": "No consultation bed found for this asset"}) + + consultation = ( + PatientConsultation.objects.order_by("-id") .filter( - current_bed__bed__in=request.user.asset.bed_set.all(), + current_bed=consultation_bed, patient__is_active=True, ) .only("external_id", "patient__external_id") @@ -240,7 +252,25 @@ def patient_from_asset(self, request): ) if not consultation: raise NotFound({"detail": "No consultation found for this asset"}) - return Response(PatientConsultationIDSerializer(consultation).data) + + asset_beds = [] + if preset_name := request.query_params.get("preset_name", None): + asset_beds = AssetBed.objects.filter( + asset__current_location=request.user.asset.current_location, + bed=consultation_bed.bed, + meta__preset_name__icontains=preset_name, + ).select_related("bed", "asset") + + return Response( + PatientConsultationIDSerializer( + { + "patient_id": consultation.patient.external_id, + "consultation_id": consultation.external_id, + "bed_id": consultation_bed.bed.external_id, + "asset_beds": asset_beds, + } + ).data + ) def dev_preview_discharge_summary(request, consultation_id): diff --git a/care/facility/apps.py b/care/facility/apps.py index 14abcf74f7..6bc19b0840 100644 --- a/care/facility/apps.py +++ b/care/facility/apps.py @@ -7,7 +7,4 @@ class FacilityConfig(AppConfig): verbose_name = _("Facility Management") def ready(self): - try: - import care.facility.signals # noqa F401 - except ImportError: - pass + import care.facility.signals # noqa F401 diff --git a/care/facility/migrations/0427_dailyround_is_parsed_by_ocr.py b/care/facility/migrations/0427_dailyround_is_parsed_by_ocr.py new file mode 100644 index 0000000000..ce10216628 --- /dev/null +++ b/care/facility/migrations/0427_dailyround_is_parsed_by_ocr.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.10 on 2024-04-14 18:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("facility", "0426_alter_fileupload_file_type"), + ] + + operations = [ + migrations.AddField( + model_name="dailyround", + name="is_parsed_by_ocr", + field=models.BooleanField(default=False), + ), + ] diff --git a/care/facility/models/daily_round.py b/care/facility/models/daily_round.py index b9ae08eedf..0b5b78ec3c 100644 --- a/care/facility/models/daily_round.py +++ b/care/facility/models/daily_round.py @@ -187,6 +187,7 @@ class InsulinIntakeFrequencyType(enum.Enum): rounds_type = models.IntegerField( choices=RoundsTypeChoice, default=RoundsType.NORMAL.value ) + is_parsed_by_ocr = models.BooleanField(default=False) # Critical Care Attributes diff --git a/care/facility/signals/__init__.py b/care/facility/signals/__init__.py new file mode 100644 index 0000000000..1a1bcaa2d6 --- /dev/null +++ b/care/facility/signals/__init__.py @@ -0,0 +1 @@ +from .asset_updates import * # noqa diff --git a/care/facility/signals/asset_updates.py b/care/facility/signals/asset_updates.py new file mode 100644 index 0000000000..2b6338e1aa --- /dev/null +++ b/care/facility/signals/asset_updates.py @@ -0,0 +1,45 @@ +from django.db.models.signals import post_delete, post_save, pre_save +from django.dispatch import receiver + +from care.facility.api.serializers.asset import AssetConfigSerializer +from care.facility.models.asset import Asset +from care.facility.tasks.push_asset_config import ( + delete_asset_from_middleware_task, + push_config_to_middleware_task, +) + + +@receiver(pre_save, sender=Asset) +def save_asset_fields_before_update( + sender, instance, raw, using, update_fields, **kwargs +): + if raw: + return + + if instance.pk: + instance._previous_values = { + "hostname": instance.resolved_middleware.get("hostname"), + } + + +@receiver(post_save, sender=Asset) +def update_asset_config_on_middleware( + sender, instance, created, raw, using, update_fields, **kwargs +): + if raw or (update_fields and "meta" not in update_fields): + return + + new_hostname = instance.resolved_middleware.get("hostname") + old_hostname = getattr(instance, "_previous_values", {}).get("hostname") + push_config_to_middleware_task.s( + new_hostname, + instance.external_id, + AssetConfigSerializer(instance).data, + old_hostname, + ) + + +@receiver(post_delete, sender=Asset) +def delete_asset_on_middleware(sender, instance, using, **kwargs): + hostname = instance.resolved_middleware.get("hostname") + delete_asset_from_middleware_task.s(hostname, instance.external_id) diff --git a/care/facility/tasks/push_asset_config.py b/care/facility/tasks/push_asset_config.py new file mode 100644 index 0000000000..acccce370d --- /dev/null +++ b/care/facility/tasks/push_asset_config.py @@ -0,0 +1,94 @@ +""" +This module provides helper functions to push changes in asset configuration to the middleware. +""" + +from logging import Logger + +import requests +from celery import shared_task +from celery.utils.log import get_task_logger + +from care.utils.jwks.token_generator import generate_jwt + +logger: Logger = get_task_logger(__name__) + + +def _get_headers() -> dict: + return { + "Authorization": "Care_Bearer " + generate_jwt(), + "Content-Type": "application/json", + } + + +def create_asset_on_middleware(hostname: str, data: dict) -> dict: + if not data.get("ip_address"): + logger.error("IP Address is required") + try: + response = requests.post( + f"https://{hostname}/api/assets", + json=data, + headers=_get_headers(), + timeout=25, + ) + response.raise_for_status() + response_json = response.json() + logger.info(f"Pushed Asset Configuration to Middleware: {response_json}") + return response_json + except Exception as e: + logger.error(f"Error Pushing Asset Configuration to Middleware: {e}") + return {"error": str(e)} + + +def delete_asset_from_middleware(hostname: str, asset_id: str) -> dict: + try: + response = requests.delete( + f"https://{hostname}/api/assets/{asset_id}", + headers=_get_headers(), + timeout=25, + ) + response.raise_for_status() + response_json = response.json() + logger.info(f"Deleted Asset from Middleware: {response_json}") + return response_json + except Exception as e: + logger.error(f"Error Deleting Asset from Middleware: {e}") + return {"error": str(e)} + + +def update_asset_on_middleware(hostname: str, asset_id: str, data: dict) -> dict: + if not data.get("ip_address"): + logger.error("IP Address is required") + return {"error": "IP Address is required"} + try: + response = requests.put( + f"https://{hostname}/api/assets/{asset_id}", + json=data, + headers=_get_headers(), + timeout=25, + ) + response.raise_for_status() + response_json = response.json() + logger.info(f"Updated Asset Configuration on Middleware: {response_json}") + return response_json + except Exception as e: + logger.error(f"Error Updating Asset Configuration on Middleware: {e}") + return {"error": str(e)} + + +@shared_task +def push_config_to_middleware_task( + hostname: str, + asset_id: str, + data: dict, + old_hostname: str | None = None, +) -> dict: + if not old_hostname: + create_asset_on_middleware(hostname, data) + if old_hostname != hostname: + delete_asset_from_middleware(old_hostname, asset_id) + return update_asset_on_middleware(hostname, asset_id, data) + + +@shared_task +def delete_asset_from_middleware_task(hostname: str, asset_id: str) -> dict: + return delete_asset_from_middleware(hostname, asset_id) diff --git a/care/facility/tests/test_middleware_auth.py b/care/facility/tests/test_middleware_auth.py new file mode 100644 index 0000000000..bdb9453550 --- /dev/null +++ b/care/facility/tests/test_middleware_auth.py @@ -0,0 +1,129 @@ +import json + +import requests_mock +from authlib.jose import JsonWebKey +from rest_framework import status +from rest_framework.test import APITestCase + +from care.utils.jwks.token_generator import generate_jwt +from care.utils.tests.test_utils import TestUtils, override_cache + + +class MiddlewareAuthTestCase(TestUtils, APITestCase): + @classmethod + def setUpTestData(cls): + cls.state = cls.create_state() + cls.district = cls.create_district(cls.state) + cls.local_body = cls.create_local_body(cls.district) + cls.super_user = cls.create_super_user("su", cls.district) + cls.facility = cls.create_facility( + cls.super_user, + cls.district, + cls.local_body, + middleware_address="test-middleware.net", + ) + cls.asset_location = cls.create_asset_location(cls.facility) + cls.asset = cls.create_asset(cls.asset_location) + + def setUp(self) -> None: + self.private_key = JsonWebKey.generate_key("RSA", 2048, is_private=True) + self.public_key = json.dumps({"keys": [self.private_key.as_dict()]}) + + def test_middleware_asset_authentication_unsuccessful(self): + response = self.client.get("/middleware/verify-asset") + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + @requests_mock.Mocker() + def test_middleware_asset_authentication_successful(self, mock_get_public_key): + mock_get_public_key.get( + "https://test-middleware.net/.well-known/openid-configuration/", + text=self.public_key, + ) + token = generate_jwt( + claims={"asset_id": str(self.asset.external_id)}, + jwks=self.private_key, + ) + + response = self.client.get( + "/middleware/verify-asset", + headers={ + "Authorization": f"Middleware_Bearer {token}", + "X-Facility-Id": self.facility.external_id, + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data["username"], "asset" + str(self.asset.external_id) + ) + + def test_middleware_authentication_unsuccessful(self): + response = self.client.get("/middleware/verify") + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + @requests_mock.Mocker() + def test_middleware_authentication_successful(self, mock_get_public_key): + mock_get_public_key.get( + "https://test-middleware.net/.well-known/openid-configuration/", + text=self.public_key, + ) + token = generate_jwt(jwks=self.private_key) + + response = self.client.get( + "/middleware/verify", + headers={ + "Authorization": f"Middleware_Bearer {token}", + "X-Facility-Id": self.facility.external_id, + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data["username"], "middleware" + str(self.facility.external_id) + ) + + @override_cache + @requests_mock.Mocker() + def test_middleware_authentication_cached_successful(self, mock_get_public_key): + mock_get_public_key.get( + "https://test-middleware.net/.well-known/openid-configuration/", + text=self.public_key, + ) + token = generate_jwt(jwks=self.private_key) + self.client.get( + "/middleware/verify", + headers={ + "Authorization": f"Middleware_Bearer {token}", + "X-Facility-Id": self.facility.external_id, + }, + ) + + response = self.client.get( + "/middleware/verify", + headers={ + "Authorization": f"Middleware_Bearer {token}", + "X-Facility-Id": self.facility.external_id, + }, + ) + self.assertEqual(mock_get_public_key.call_count, 1) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data["username"], "middleware" + str(self.facility.external_id) + ) + + @requests_mock.Mocker() + def test_middleware_authentication_invalid_token(self, mock_get_public_key): + mock_get_public_key.get( + "https://test-middleware.net/.well-known/openid-configuration/", + text=self.public_key, + ) + + token = generate_jwt(jwks=JsonWebKey.generate_key("RSA", 2048, is_private=True)) + + response = self.client.get( + "/middleware/verify", + headers={ + "Authorization": f"Middleware_Bearer {token}", + "X-Facility-Id": self.facility.external_id, + }, + ) + self.assertEqual(mock_get_public_key.call_count, 1) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/care/utils/jwks/token_generator.py b/care/utils/jwks/token_generator.py index fe06421fb3..d6a169334b 100644 --- a/care/utils/jwks/token_generator.py +++ b/care/utils/jwks/token_generator.py @@ -4,9 +4,11 @@ from django.conf import settings -def generate_jwt(claims=None, exp=60): +def generate_jwt(claims=None, exp=60, jwks=None): if claims is None: claims = {} + if jwks is None: + jwks = settings.JWKS header = {"alg": "RS256"} time = int(datetime.now().timestamp()) payload = { @@ -14,4 +16,4 @@ def generate_jwt(claims=None, exp=60): "exp": time + exp, **claims, } - return jwt.encode(header, payload, settings.JWKS).decode("utf-8") + return jwt.encode(header, payload, jwks).decode("utf-8") diff --git a/config/api_router.py b/config/api_router.py index 4e17320300..82b330cc1c 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -14,6 +14,7 @@ AssetLocationViewSet, AssetPublicQRViewSet, AssetPublicViewSet, + AssetRetrieveConfigViewSet, AssetServiceViewSet, AssetTransactionViewSet, AssetViewSet, @@ -201,6 +202,8 @@ # facility_nested_router.register("burn_rate", FacilityInventoryBurnRateViewSet) router.register("asset", AssetViewSet) +router.register("asset_config", AssetRetrieveConfigViewSet) + asset_nested_router = NestedSimpleRouter(router, r"asset", lookup="asset") asset_nested_router.register(r"availability", AvailabilityViewSet) asset_nested_router.register(r"service_records", AssetServiceViewSet) diff --git a/config/authentication.py b/config/authentication.py index cfea6116ae..dbad94d669 100644 --- a/config/authentication.py +++ b/config/authentication.py @@ -1,9 +1,12 @@ import json +import logging from datetime import datetime import jwt import requests from django.conf import settings +from django.contrib.auth.models import AnonymousUser +from django.core.cache import cache from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from drf_spectacular.extensions import OpenApiAuthenticationExtension @@ -12,11 +15,33 @@ from rest_framework.authentication import BasicAuthentication from rest_framework_simplejwt.authentication import JWTAuthentication from rest_framework_simplejwt.exceptions import AuthenticationFailed, InvalidToken +from rest_framework_simplejwt.tokens import Token from care.facility.models import Facility from care.facility.models.asset import Asset from care.users.models import User +logger = logging.getLogger(__name__) + + +def jwk_response_cache_key(url: str) -> str: + return f"jwk_response:{url}" + + +class MiddlewareUser(AnonymousUser): + """ + Read-only user class for middleware authentication + """ + + def __init__(self, facility, *args, **kwargs): + super().__init__(*args, **kwargs) + self.facility = facility + self.username = f"middleware{facility.external_id}" + + @property + def is_authenticated(self): + return True + class CustomJWTAuthentication(JWTAuthentication): def authenticate_header(self, request): @@ -49,15 +74,26 @@ class MiddlewareAuthentication(JWTAuthentication): auth_header_type = "Middleware_Bearer" auth_header_type_bytes = auth_header_type.encode(HTTP_HEADER_ENCODING) + def get_public_key(self, url): + public_key_json = cache.get(jwk_response_cache_key(url)) + if not public_key_json: + res = requests.get(url) + res.raise_for_status() + public_key_json = res.json() + cache.set(jwk_response_cache_key(url), public_key_json, timeout=60 * 5) + return public_key_json["keys"][0] + def open_id_authenticate(self, url, token): - public_key = requests.get(url) - jwk = public_key.json()["keys"][0] - public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk)) + public_key_response = self.get_public_key(url) + public_key = jwt.algorithms.RSAAlgorithm.from_jwk(public_key_response) return jwt.decode(token, key=public_key, algorithms=["RS256"]) def authenticate_header(self, request): return f'{self.auth_header_type} realm="{self.www_authenticate_realm}"' + def get_user(self, _: Token, facility: Facility): + return MiddlewareUser(facility=facility) + def authenticate(self, request): header = self.get_header(request) if header is None: @@ -116,10 +152,12 @@ def get_validated_token(self, url, raw_token): try: return self.open_id_authenticate(url, raw_token) except Exception as e: - print(e) + logger.info(e, "Token: ", raw_token) raise InvalidToken({"detail": "Given token not valid for any token type"}) + +class MiddlewareAssetAuthentication(MiddlewareAuthentication): def get_user(self, validated_token, facility): """ Attempts to find and return a user using the given validated token. @@ -188,7 +226,7 @@ def get_validated_token(self, url, token): try: return self.open_id_authenticate(url, token) except Exception as e: - print(e) + logger.info(e, "Token: ", token) raise InvalidToken({"detail": f"Invalid Authorization token: {e}"}) def get_user(self, validated_token): @@ -238,6 +276,25 @@ def get_security_definition(self, auto_schema): } +class MiddlewareAssetAuthenticationScheme(OpenApiAuthenticationExtension): + target_class = "config.authentication.MiddlewareAssetAuthentication" + name = "middlewareAssetAuth" + + def get_security_definition(self, auto_schema): + return { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": _( + "Used for authenticating requests from the middleware on behalf of assets. " + "The scheme requires a valid JWT token in the Authorization header " + "along with the facility id in the X-Facility-Id header. " + "--The value field is just for preview, filling it will show allowed " + "endpoints.--" + ), + } + + class CustomBasicAuthenticationScheme(OpenApiAuthenticationExtension): target_class = "config.authentication.CustomBasicAuthentication" name = "basicAuth" diff --git a/config/health_views.py b/config/health_views.py index 529137378b..4b1febf74f 100644 --- a/config/health_views.py +++ b/config/health_views.py @@ -1,12 +1,25 @@ +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from care.users.api.serializers.user import UserBaseMinimumSerializer -from config.authentication import MiddlewareAuthentication +from config.authentication import ( + MiddlewareAssetAuthentication, + MiddlewareAuthentication, +) class MiddlewareAuthenticationVerifyView(APIView): authentication_classes = [MiddlewareAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request): + return Response(UserBaseMinimumSerializer(request.user).data) + + +class MiddlewareAssetAuthenticationVerifyView(APIView): + authentication_classes = [MiddlewareAssetAuthentication] + permission_classes = [IsAuthenticated] def get(self, request): return Response(UserBaseMinimumSerializer(request.user).data) diff --git a/config/urls.py b/config/urls.py index a79e444d4f..b59954a17d 100644 --- a/config/urls.py +++ b/config/urls.py @@ -10,7 +10,7 @@ ) from care.abdm.urls import abdm_urlpatterns -from care.facility.api.viewsets.open_id import OpenIdConfigView +from care.facility.api.viewsets.open_id import PublicJWKsView from care.facility.api.viewsets.patient_consultation import ( dev_preview_discharge_summary, ) @@ -27,7 +27,10 @@ ResetPasswordRequestToken, ) from config import api_router -from config.health_views import MiddlewareAuthenticationVerifyView +from config.health_views import ( + MiddlewareAssetAuthenticationVerifyView, + MiddlewareAuthenticationVerifyView, +) from .auth_views import AnnotatedTokenVerifyView, TokenObtainPairView, TokenRefreshView from .views import home_view, ping @@ -91,12 +94,12 @@ ), # Health check urls path("middleware/verify", MiddlewareAuthenticationVerifyView.as_view()), - path( - ".well-known/openid-configuration", - OpenIdConfigView.as_view(), - name="openid-configuration", - ), + path("middleware/verify-asset", MiddlewareAssetAuthenticationVerifyView.as_view()), path("health/", include("healthy_django.urls", namespace="healthy_django")), + # OpenID Connect + path(".well-known/jwks.json", PublicJWKsView.as_view(), name="jwks-json"), + # TODO: Remove the config url as its not a standard implementation + path(".well-known/openid-configuration", PublicJWKsView.as_view()), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) if settings.ENABLE_ABDM: From 5e666ca580ad7c5af11c3d276cd7f4fd1f31c5a1 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Mon, 15 Apr 2024 00:31:58 +0530 Subject: [PATCH 06/15] update plugin docs (#2075) Co-authored-by: Vignesh Hari --- docs/pluggable-apps/configuration.md | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/pluggable-apps/configuration.md b/docs/pluggable-apps/configuration.md index adfeed3e86..b017af6aa6 100644 --- a/docs/pluggable-apps/configuration.md +++ b/docs/pluggable-apps/configuration.md @@ -36,8 +36,8 @@ manager = PlugManager(plugs) Each plugin will define their own config variables with some defaults, they can also pick the values from the environment variables if required. The order of precedence is as follows: -- Environment variables - Configs defined in the `plug_config.py` +- Environment variables - Default values defined in the plugin diff --git a/setup.py b/setup.py index 996cb5544b..a473e55c30 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ include_package_data=True, install_requires=[], author="Open Healthcare Network", - author_email="care@ops.ohc.network", + author_email="info@ohc.network", description="A Django app for managing healthcare across hospitals and care centers.", license="MIT", keywords="django care ohc", From 40e7aa41bc14441917a3462f83d76896a1ab6399 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Mon, 15 Apr 2024 00:32:56 +0530 Subject: [PATCH 07/15] update devcontainer config (#2051) * update devcontainer config * remove chromium from devcontainer build * avoid rebuilds on restart * add badge * print docker logs in case of failure in test workflow --- .devcontainer/devcontainer.json | 41 +++++++++++++-------------------- .env.example | 20 ++++++++++++++++ .github/dependabot.yml | 5 ++++ .github/workflows/test-base.yml | 10 +++++++- README.md | 1 + docker-compose.yaml | 6 +++++ 6 files changed, 57 insertions(+), 26 deletions(-) create mode 100644 .env.example diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 795e29dd5c..d487346c8d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,33 +1,24 @@ // For format details, see https://aka.ms/devcontainer.json. For config options, see the -// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose +// README at: https://github.com/devcontainers/templates/tree/main/src/python { - "name": "Care", - "dockerComposeFile": [ - "../docker-compose.yaml", - "../docker-compose.local.yaml" - ], + "name": "Care", "hostRequirements": { "cpus": 4 }, - "waitFor": "onCreateCommand", - "service": "backend", - "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", - "postCreateCommand": "python manage.py migrate && python manage.py collectstatic --noinput && python manage.py load_dummy_data", - "postAttachCommand": { - "server": "python manage.py runserver" - }, - "customizations": { - "vscode": { - "extensions": [ - "ms-python.python" - ] - } - }, - "portsAttributes": { - "8000": { - "label": "Application", - "onAutoForward": "openPreview" + "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + "ghcr.io/devcontainers/features/node:1": {}, + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/devcontainers-contrib/features/pipenv:2": {}, + "ghcr.io/devcontainers-contrib/features/direnv:1": {}, + "ghcr.io/devcontainers-contrib/features/apt-get-packages:1": { + "packages": "build-essential,libjpeg-dev,zlib1g-dev,libpq-dev,gettext,wget,curl,gnupg", + "preserve_apt_list": false } }, - "forwardPorts": [9000] + "postCreateCommand": "echo 'eval \"$(direnv hook bash)\"' >> ~/.bashrc && cp .env.example .env", + "postAttachCommand": "make up", + "forwardPorts": [8000, 9000, 4000] } diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..091bea02fd --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_HOST=db +POSTGRES_DB=care +POSTGRES_PORT=5432 +DATABASE_URL=postgres://postgres:postgres@localhost:5433/care +REDIS_URL=redis://localhost:6380 +CELERY_BROKER_URL=redis://localhost:6380/0 + +FIDELIUS_URL=http://localhost:8092 + +DJANGO_DEBUG=False + +BUCKET_REGION=ap-south-1 +BUCKET_KEY=key +BUCKET_SECRET=secret +BUCKET_ENDPOINT=http://localhost:4566 +BUCKET_EXTERNAL_ENDPOINT=http://localhost:4566 +FILE_UPLOAD_BUCKET=patient-bucket +FACILITY_S3_BUCKET=facility-bucket diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 41484c2e8d..e9a27996a6 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -23,3 +23,8 @@ updates: directory: "/" schedule: interval: "weekly" + + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/test-base.yml b/.github/workflows/test-base.yml index e295ac88c9..b58e829aa0 100644 --- a/.github/workflows/test-base.yml +++ b/.github/workflows/test-base.yml @@ -30,7 +30,15 @@ jobs: files: docker-compose.yaml,docker-compose.local.yaml - name: Start services - run: docker compose -f docker-compose.yaml -f docker-compose.local.yaml up -d --wait --no-build + run: | + docker compose \ + -f docker-compose.yaml \ + -f docker-compose.local.yaml \ + up -d --wait ||\ + docker compose \ + -f docker-compose.yaml \ + -f docker-compose.local.yaml \ + logs - name: Check migrations run: make checkmigration diff --git a/README.md b/README.md index ce601d095b..c4ac20362f 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ [![Cookiecutter Django](https://img.shields.io/badge/built%20with-Cookiecutter%20Django-ff69b4.svg)](https://github.com/pydanny/cookiecutter-django/) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![Chat](https://img.shields.io/badge/-Join%20us%20on%20slack-7b1c7d?logo=slack)](https://slack.coronasafe.in/) +[![Open in Dev Containers](https://img.shields.io/static/v1?label=&message=Open%20in%20Dev%20Containers&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/coronasafe/care) This is the backend for care. an open source platform for managing patients, health workers, and hospitals. diff --git a/docker-compose.yaml b/docker-compose.yaml index 7959c927d3..11cfcc0a9c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -12,12 +12,16 @@ services: - ./docker/.prebuilt.env volumes: - postgres-data:/var/lib/postgresql/data + ports: + - "5433:5432" redis: image: redis/redis-stack-server:6.2.6-v10 restart: unless-stopped volumes: - redis-data:/data + ports: + - "6380:6379" localstack: image: localstack/localstack:latest @@ -37,6 +41,8 @@ services: fidelius: image: khavinshankar/fidelius:latest restart: unless-stopped + ports: + - "8092:8090" volumes: postgres-data: From 32a9b0b496fcb398ddd84be871976df88a6961ee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Apr 2024 12:45:57 +0530 Subject: [PATCH 08/15] Bump the boto group with 2 updates (#2076) Bumps the boto group with 2 updates: [boto3](https://github.com/boto/boto3) and [boto3-stubs](https://github.com/youtype/mypy_boto3_builder). Updates `boto3` from 1.34.75 to 1.34.84 - [Release notes](https://github.com/boto/boto3/releases) - [Changelog](https://github.com/boto/boto3/blob/develop/CHANGELOG.rst) - [Commits](https://github.com/boto/boto3/compare/1.34.75...1.34.84) Updates `boto3-stubs` from 1.34.75 to 1.34.84 - [Release notes](https://github.com/youtype/mypy_boto3_builder/releases) - [Commits](https://github.com/youtype/mypy_boto3_builder/commits) --- updated-dependencies: - dependency-name: boto3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: boto - dependency-name: boto3-stubs dependency-type: direct:development update-type: version-update:semver-patch dependency-group: boto ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Pipfile | 4 ++-- Pipfile.lock | 17 +++++++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/Pipfile b/Pipfile index c5998678e8..d4ec3623ad 100644 --- a/Pipfile +++ b/Pipfile @@ -6,7 +6,7 @@ name = "pypi" [packages] argon2-cffi = "==23.1.0" authlib = "==1.3.0" -boto3 = "==1.34.75" +boto3 = "==1.34.84" celery = "==5.3.6" django = "==4.2.10" django-environ = "==0.11.2" @@ -48,7 +48,7 @@ redis-om = "==0.2.1" [dev-packages] black = "==24.3.0" -boto3-stubs = {extras = ["s3", "boto3"], version = "==1.34.75"} +boto3-stubs = {extras = ["s3", "boto3"], version = "==1.34.84"} coverage = "==7.4.0" debugpy = "==1.8.1" django-coverage-plugin = "==3.1.0" diff --git a/Pipfile.lock b/Pipfile.lock index f0236827cb..6473b6e5ca 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "c46c81d23a92a9dd50b9e5f581fa161adde772bcee80da44f781191d081053c3" + "sha256": "73c334f2423e700407b90c54bc4a9d1d0668bbae2a9ccc596c42d46611b934f4" }, "pipfile-spec": 6, "requires": { @@ -95,12 +95,12 @@ }, "boto3": { "hashes": [ - "sha256:ba5d2104bba4370766036d64ad9021eb6289d154265852a2a821ec6a5e816faa", - "sha256:eaec72fda124084105a31bcd67eafa1355b34df6da70cadae0c0f262d8a4294f" + "sha256:7a02f44af32095946587d748ebeb39c3fa15b9d7275307ff612a6760ead47e04", + "sha256:91e6343474173e9b82f603076856e1d5b7b68f44247bdd556250857a3f16b37b" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.34.75" + "version": "==1.34.84" }, "botocore": { "hashes": [ @@ -1312,7 +1312,7 @@ "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d", "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19" ], - "markers": "python_version >= '3.6'", + "markers": "python_version >= '3.8'", "version": "==2.2.1" }, "vine": { @@ -1415,11 +1415,12 @@ "s3" ], "hashes": [ - "sha256:78093a0bf5a03bc66a79d6cddb9f0eb67b67ed6b008cba4cf394c0c9d11de2c1", - "sha256:bb55fe97f474ea800c762592d81369bb6c23a8e53a5b2d8497145f87c1d7640c" + "sha256:73bbb509a69c4ac8cce038afb1510686b88398cbd46d5df1e3238fce66df9af5", + "sha256:dd8b6147297b5aefd52212645179c96c4b5bcb4e514667dca6170485c1d4954a" ], + "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==1.34.75" + "version": "==1.34.84" }, "botocore": { "hashes": [ From 830a82bdd253ba023b5bd2e4487c4210bd889482 Mon Sep 17 00:00:00 2001 From: Ashesh <3626859+Ashesh3@users.noreply.github.com> Date: Mon, 15 Apr 2024 14:42:29 +0530 Subject: [PATCH 09/15] Handle missing instance.resolved_middleware in asset_updates (#2084) --- care/facility/signals/asset_updates.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/care/facility/signals/asset_updates.py b/care/facility/signals/asset_updates.py index 2b6338e1aa..9ca84cc5b7 100644 --- a/care/facility/signals/asset_updates.py +++ b/care/facility/signals/asset_updates.py @@ -26,7 +26,11 @@ def save_asset_fields_before_update( def update_asset_config_on_middleware( sender, instance, created, raw, using, update_fields, **kwargs ): - if raw or (update_fields and "meta" not in update_fields): + if ( + raw + or (update_fields and "meta" not in update_fields) + or (instance.resolved_middleware is None) + ): return new_hostname = instance.resolved_middleware.get("hostname") From ee3052f54bf3b26cbc24f329f52b56a7ed646b32 Mon Sep 17 00:00:00 2001 From: Ashesh <3626859+Ashesh3@users.noreply.github.com> Date: Mon, 15 Apr 2024 18:21:35 +0530 Subject: [PATCH 10/15] Fix: Handle missing resolved_middleware in asset_updates.py (#2087) --- care/facility/signals/asset_updates.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/care/facility/signals/asset_updates.py b/care/facility/signals/asset_updates.py index 9ca84cc5b7..5d728e0482 100644 --- a/care/facility/signals/asset_updates.py +++ b/care/facility/signals/asset_updates.py @@ -13,7 +13,7 @@ def save_asset_fields_before_update( sender, instance, raw, using, update_fields, **kwargs ): - if raw: + if raw or instance.resolved_middleware is None: return if instance.pk: @@ -45,5 +45,7 @@ def update_asset_config_on_middleware( @receiver(post_delete, sender=Asset) def delete_asset_on_middleware(sender, instance, using, **kwargs): + if instance.resolved_middleware is None: + return hostname = instance.resolved_middleware.get("hostname") delete_asset_from_middleware_task.s(hostname, instance.external_id) From babadb35ec360592086337495979a32e15059011 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Tue, 16 Apr 2024 09:06:12 +0530 Subject: [PATCH 11/15] Remove deploy jobs from GitHub workflows (#2058) --- .github/workflows/deployment.yaml | 319 +----------------------------- 1 file changed, 6 insertions(+), 313 deletions(-) diff --git a/.github/workflows/deployment.yaml b/.github/workflows/deployment.yaml index b0c92bdcbc..7091760538 100644 --- a/.github/workflows/deployment.yaml +++ b/.github/workflows/deployment.yaml @@ -50,7 +50,7 @@ jobs: ghcr.io/${{ github.repository }} ${{ secrets.DOCKER_HUB_USERNAME }}/${{ github.event.repository.name }} tags: | - type=raw,value=production-latest,enable=${{ github.ref == 'refs/heads/v*' }} + type=raw,value=production-latest,enable=${{ startsWith(github.event.ref, 'refs/tags/v') }} type=raw,value=production-latest-${{ github.run_number }}-{{date 'YYYYMMDD'}}-{{sha}},enable=${{ startsWith(github.event.ref, 'refs/tags/v') }} type=raw,value=staging-latest,enable=${{ github.ref == 'refs/heads/staging' }} type=raw,value=staging-latest-${{ github.run_number }}-{{date 'YYYYMMDD'}}-{{sha}},enable=${{ github.ref == 'refs/heads/staging' }} @@ -199,316 +199,9 @@ jobs: name: Staging-GCP url: https://care-staging-api.ohc.network/ steps: - - name: Checkout Kube Config - uses: actions/checkout@v3 - with: - repository: coronasafe/care-staging-gcp - token: ${{ secrets.GIT_ACCESS_TOKEN }} - path: kube - ref: main - - # Setup gcloud CLI - - uses: google-github-actions/setup-gcloud@94337306dda8180d967a56932ceb4ddcf01edae7 - with: - service_account_key: ${{ secrets.GKE_SA_KEY }} - project_id: ${{ secrets.GKE_PROJECT }} - - # Get the GKE credentials so we can deploy to the cluster - - uses: google-github-actions/get-gke-credentials@fb08709ba27618c31c09e014e1d8364b02e5042e - with: - cluster_name: ${{ secrets.GKE_CLUSTER }} - location: ${{ secrets.GKE_ZONE }} - credentials: ${{ secrets.GKE_SA_KEY }} - - - name: install kubectl - uses: azure/setup-kubectl@v3.0 - with: - version: "v1.23.6" - id: install - - - name: Deploy Care Production Manipur - run: | - mkdir -p $HOME/.kube/ - cd kube/deployments/ - sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-backend.yaml - sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-celery-beat.yaml - sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-celery-worker.yaml - kubectl apply -f care-backend.yaml - kubectl apply -f care-celery-beat.yaml - kubectl apply -f care-celery-worker.yaml - - deploy-production-manipur: - needs: notify-release - name: Deploy to GKE Manipur - runs-on: ubuntu-latest - environment: - name: Production-Manipur - url: https://careapi.mn.gov.in - steps: - - name: Checkout Kube Config - uses: actions/checkout@v3 - with: - repository: coronasafe/mn-care-infra - token: ${{ secrets.GIT_ACCESS_TOKEN }} - path: kube - ref: main - - # Setup gcloud CLI - - uses: google-github-actions/setup-gcloud@94337306dda8180d967a56932ceb4ddcf01edae7 - with: - service_account_key: ${{ secrets.GKE_SA_KEY }} - project_id: ${{ secrets.GKE_PROJECT }} - - # Get the GKE credentials so we can deploy to the cluster - - uses: google-github-actions/get-gke-credentials@fb08709ba27618c31c09e014e1d8364b02e5042e - with: - cluster_name: ${{ secrets.GKE_CLUSTER }} - location: ${{ secrets.GKE_ZONE }} - credentials: ${{ secrets.GKE_SA_KEY }} - - - name: install kubectl - uses: azure/setup-kubectl@v3.0 - with: - version: "v1.23.6" - id: install - - - name: Deploy Care Production Manipur - run: | - mkdir -p $HOME/.kube/ - cd kube/deployments/ - sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-backend.yaml - sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-celery-beat.yaml - sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-celery-worker.yaml - kubectl apply -f care-backend.yaml - kubectl apply -f care-celery-beat.yaml - kubectl apply -f care-celery-worker.yaml - - deploy-production-karnataka: - needs: notify-release - name: Deploy to GKE Karnataka - runs-on: ubuntu-latest - environment: - name: Production-Karnataka - url: https://careapi.karnataka.care - steps: - - name: Checkout Kube Config - uses: actions/checkout@v3 - with: - repository: coronasafe/ka-care-infra - token: ${{ secrets.GIT_ACCESS_TOKEN }} - path: kube - ref: main - - # Setup gcloud CLI - - uses: google-github-actions/setup-gcloud@94337306dda8180d967a56932ceb4ddcf01edae7 - with: - service_account_key: ${{ secrets.GKE_SA_KEY }} - project_id: ${{ secrets.GKE_PROJECT }} - - # Get the GKE credentials so we can deploy to the cluster - - uses: google-github-actions/get-gke-credentials@fb08709ba27618c31c09e014e1d8364b02e5042e - with: - cluster_name: ${{ secrets.GKE_CLUSTER }} - location: ${{ secrets.GKE_ZONE }} - credentials: ${{ secrets.GKE_SA_KEY }} - - - name: install kubectl - uses: azure/setup-kubectl@v3.0 - with: - version: "v1.23.6" - id: install - - - name: Deploy Care Production Karnataka - run: | - mkdir -p $HOME/.kube/ - cd kube/deployments/ - sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-backend.yaml - sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-celery-beat.yaml - sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-celery-worker.yaml - kubectl apply -f care-backend.yaml - kubectl apply -f care-celery-beat.yaml - kubectl apply -f care-celery-worker.yaml - - deploy-production-assam: - needs: notify-release - name: Deploy to GKE Assam - runs-on: ubuntu-latest - environment: - name: Production-Assam - url: https://careapi.assam.gov.in - steps: - - name: Checkout Kube Config - uses: actions/checkout@v3 - with: - repository: coronasafe/as-care-infra - token: ${{ secrets.GIT_ACCESS_TOKEN }} - path: kube - ref: main - - # Setup gcloud CLI - - uses: google-github-actions/setup-gcloud@94337306dda8180d967a56932ceb4ddcf01edae7 - with: - service_account_key: ${{ secrets.GKE_SA_KEY }} - project_id: ${{ secrets.GKE_PROJECT }} - - # Get the GKE credentials so we can deploy to the cluster - - uses: google-github-actions/get-gke-credentials@fb08709ba27618c31c09e014e1d8364b02e5042e - with: - cluster_name: ${{ secrets.GKE_CLUSTER }} - location: ${{ secrets.GKE_ZONE }} - credentials: ${{ secrets.GKE_SA_KEY }} - - - name: install kubectl - uses: azure/setup-kubectl@v3.0 - with: - version: "v1.23.6" - id: install - - - name: Deploy Care Production Assam - run: | - mkdir -p $HOME/.kube/ - cd kube/deployments/ - sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-backend.yaml - sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-celery-beat.yaml - sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-celery-worker.yaml - kubectl apply -f care-backend.yaml - kubectl apply -f care-celery-beat.yaml - kubectl apply -f care-celery-worker.yaml - - deploy-production-sikkim: - needs: notify-release - name: Deploy to GKE Sikkim - runs-on: ubuntu-latest - environment: - name: Production-Sikkim - url: https://careapi.sikkim.gov.in - steps: - - name: Checkout Kube Config - uses: actions/checkout@v3 - with: - repository: coronasafe/sk-care-infra - token: ${{ secrets.GIT_ACCESS_TOKEN }} - path: kube - ref: main - - # Setup gcloud CLI - - uses: google-github-actions/setup-gcloud@94337306dda8180d967a56932ceb4ddcf01edae7 - with: - service_account_key: ${{ secrets.GKE_SA_KEY }} - project_id: ${{ secrets.GKE_PROJECT }} - - # Get the GKE credentials so we can deploy to the cluster - - uses: google-github-actions/get-gke-credentials@fb08709ba27618c31c09e014e1d8364b02e5042e - with: - cluster_name: ${{ secrets.GKE_CLUSTER }} - location: ${{ secrets.GKE_ZONE }} - credentials: ${{ secrets.GKE_SA_KEY }} - - - name: install kubectl - uses: azure/setup-kubectl@v3.0 - with: - version: "v1.23.6" - id: install - - - name: Deploy Care Production Sikkim - run: | - mkdir -p $HOME/.kube/ - cd kube/deployments/ - sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-backend.yaml - sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-celery-beat.yaml - sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-celery-worker.yaml - kubectl apply -f care-backend.yaml - kubectl apply -f care-celery-beat.yaml - kubectl apply -f care-celery-worker.yaml - - deploy-production-nagaland: - needs: notify-release - name: Deploy to GKE Nagaland - runs-on: ubuntu-latest - environment: - name: Production - Nagaland - url: https://careapi.nagaland.gov.in - steps: - - name: Checkout Kube Config - uses: actions/checkout@v3 - with: - repository: coronasafe/nl-care-infra - token: ${{ secrets.GIT_ACCESS_TOKEN }} - path: kube - ref: main - - # Setup gcloud CLI - - uses: google-github-actions/setup-gcloud@94337306dda8180d967a56932ceb4ddcf01edae7 - with: - service_account_key: ${{ secrets.GKE_SA_KEY }} - project_id: ${{ secrets.GKE_PROJECT }} - - # Get the GKE credentials, so we can deploy to the cluster - - uses: google-github-actions/get-gke-credentials@fb08709ba27618c31c09e014e1d8364b02e5042e - with: - cluster_name: ${{ secrets.GKE_CLUSTER }} - location: ${{ secrets.GKE_ZONE }} - credentials: ${{ secrets.GKE_SA_KEY }} - - - name: install kubectl - uses: azure/setup-kubectl@v3.0 - with: - version: "v1.23.6" - id: install - - - name: Deploy Care Production Nagaland - run: | - mkdir -p $HOME/.kube/ - cd kube/deployments/ - sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-backend.yaml - sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-celery-beat.yaml - sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-celery-worker.yaml - kubectl apply -f care-backend.yaml - kubectl apply -f care-celery-beat.yaml - kubectl apply -f care-celery-worker.yaml - - deploy-production-meghalaya: - needs: notify-release - name: Deploy to GKE Meghalaya - runs-on: ubuntu-latest - environment: - name: Production-Meghalaya - url: https://careapi.meghealth.gov.in - steps: - - name: Checkout Kube Config - uses: actions/checkout@v3 - with: - repository: coronasafe/ml-care-infra - token: ${{ secrets.GIT_ACCESS_TOKEN }} - path: kube - ref: main - - # Setup gcloud CLI - - uses: google-github-actions/setup-gcloud@94337306dda8180d967a56932ceb4ddcf01edae7 - with: - service_account_key: ${{ secrets.GKE_SA_KEY }} - project_id: ${{ secrets.GKE_PROJECT }} - - # Get the GKE credentials, so we can deploy to the cluster - - uses: google-github-actions/get-gke-credentials@fb08709ba27618c31c09e014e1d8364b02e5042e - with: - cluster_name: ${{ secrets.GKE_CLUSTER }} - location: ${{ secrets.GKE_ZONE }} - credentials: ${{ secrets.GKE_SA_KEY }} - - - name: install kubectl - uses: azure/setup-kubectl@v3.0 - with: - version: "v1.23.6" - id: install - - - name: Deploy Care Production Nagaland + - name: Trigger deploy run: | - mkdir -p $HOME/.kube/ - cd kube/deployments/ - sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-backend.yaml - sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-celery-beat.yaml - sed -i -e "s/_BUILD_NUMBER_/${GITHUB_RUN_NUMBER}/g" care-celery-worker.yaml - kubectl apply -f care-backend.yaml - kubectl apply -f care-celery-beat.yaml - kubectl apply -f care-celery-worker.yaml + COMMIT_SHA=${{ github.sha }} + JSON='{ "substitutions": { "care_be_tag":"'"$COMMIT_SHA"'", "care_fe_tag": "", "metabase_tag": "" } }' + curl --location ${{ secrets.STAGING_GCP_DEPLOY_URL }} \ + --header 'Content-Type: application/json' --data "$JSON" From 56eb29361677b6931eba3f6a7f1c25ac5719b128 Mon Sep 17 00:00:00 2001 From: Gigin George Date: Tue, 16 Apr 2024 15:30:30 +0530 Subject: [PATCH 12/15] Update deployment.yaml | Add Quotes --- .github/workflows/deployment.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deployment.yaml b/.github/workflows/deployment.yaml index 7091760538..60271c2e42 100644 --- a/.github/workflows/deployment.yaml +++ b/.github/workflows/deployment.yaml @@ -203,5 +203,5 @@ jobs: run: | COMMIT_SHA=${{ github.sha }} JSON='{ "substitutions": { "care_be_tag":"'"$COMMIT_SHA"'", "care_fe_tag": "", "metabase_tag": "" } }' - curl --location ${{ secrets.STAGING_GCP_DEPLOY_URL }} \ + curl --location '${{ secrets.STAGING_GCP_DEPLOY_URL }}' \ --header 'Content-Type: application/json' --data "$JSON" From eff9687d4bd81f033b88da86a07830c8fd2272b1 Mon Sep 17 00:00:00 2001 From: Gigin George Date: Tue, 16 Apr 2024 15:31:30 +0530 Subject: [PATCH 13/15] Update deployment.yaml | Switch to Double Quotes --- .github/workflows/deployment.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deployment.yaml b/.github/workflows/deployment.yaml index 60271c2e42..83508b8027 100644 --- a/.github/workflows/deployment.yaml +++ b/.github/workflows/deployment.yaml @@ -203,5 +203,5 @@ jobs: run: | COMMIT_SHA=${{ github.sha }} JSON='{ "substitutions": { "care_be_tag":"'"$COMMIT_SHA"'", "care_fe_tag": "", "metabase_tag": "" } }' - curl --location '${{ secrets.STAGING_GCP_DEPLOY_URL }}' \ + curl --location "${{ secrets.STAGING_GCP_DEPLOY_URL }}" \ --header 'Content-Type: application/json' --data "$JSON" From 338b510d6ea681825f878e1593487b180b2b7dc9 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Thu, 18 Apr 2024 14:56:02 +0530 Subject: [PATCH 14/15] fix patient sorting filters (#2086) Co-authored-by: Vignesh Hari --- care/facility/api/viewsets/patient.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/care/facility/api/viewsets/patient.py b/care/facility/api/viewsets/patient.py index e329a597df..0a18f48909 100644 --- a/care/facility/api/viewsets/patient.py +++ b/care/facility/api/viewsets/patient.py @@ -301,6 +301,7 @@ def filter_queryset(self, request, queryset, view): q_filters = Q(facility__id__in=allowed_facilities) if view.action == "retrieve": q_filters |= Q(consultations__facility__id__in=allowed_facilities) + queryset = queryset.distinct("id") q_filters |= Q(last_consultation__assigned_to=request.user) q_filters |= Q(assigned_to=request.user) queryset = queryset.filter(q_filters) @@ -340,7 +341,7 @@ def filter_queryset(self, request, queryset, view): ) ).order_by(ordering) - return queryset.distinct(ordering.lstrip("-") if ordering else "id") + return queryset @extend_schema_view(history=extend_schema(tags=["patient"])) From 2c023e1d06c694157a32fcecc11f9b6d051e1d75 Mon Sep 17 00:00:00 2001 From: Aakash Singh Date: Thu, 18 Apr 2024 14:56:57 +0530 Subject: [PATCH 15/15] fix query bugs while resolving asset hostname (#2099) Co-authored-by: Vignesh Hari --- care/facility/api/serializers/asset.py | 14 +- care/facility/api/viewsets/asset.py | 8 +- care/facility/tests/test_asset_api.py | 183 +++++++++++++++++- care/facility/tests/test_middleware_config.py | 139 +++++++++++++ care/utils/tests/test_utils.py | 1 - 5 files changed, 327 insertions(+), 18 deletions(-) create mode 100644 care/facility/tests/test_middleware_config.py diff --git a/care/facility/api/serializers/asset.py b/care/facility/api/serializers/asset.py index 62692a02bd..88ad84fa52 100644 --- a/care/facility/api/serializers/asset.py +++ b/care/facility/api/serializers/asset.py @@ -3,7 +3,8 @@ from django.core.cache import cache from django.db import models, transaction from django.db.models import F, Value -from django.db.models.functions import Cast, Coalesce, NullIf +from django.db.models.fields.json import KT +from django.db.models.functions import Coalesce, NullIf from django.shortcuts import get_object_or_404 from django.utils.timezone import now from drf_spectacular.utils import extend_schema_field @@ -214,8 +215,8 @@ def validate(self, attrs): raise ValidationError({"asset_class": "Cannot change asset class"}) if meta := attrs.get("meta"): - current_location = attrs.get( - "current_location", self.instance.current_location + current_location = ( + attrs.get("current_location") or self.instance.current_location ) ip_address = meta.get("local_ip_address") middleware_hostname = ( @@ -227,12 +228,7 @@ def validate(self, attrs): asset_using_ip = ( Asset.objects.annotate( resolved_middleware_hostname=Coalesce( - NullIf( - Cast( - F("meta__middleware_hostname"), models.CharField() - ), - Value('""'), - ), + NullIf(KT("meta__middleware_hostname"), Value("")), NullIf( F("current_location__middleware_address"), Value("") ), diff --git a/care/facility/api/viewsets/asset.py b/care/facility/api/viewsets/asset.py index b3ae295a56..fbc53f817d 100644 --- a/care/facility/api/viewsets/asset.py +++ b/care/facility/api/viewsets/asset.py @@ -3,7 +3,8 @@ from django.conf import settings from django.core.cache import cache from django.db.models import CharField, Exists, F, OuterRef, Q, Subquery, Value -from django.db.models.functions import Cast, Coalesce, NullIf +from django.db.models.fields.json import KT +from django.db.models.functions import Coalesce, NullIf from django.db.models.signals import post_save from django.dispatch import receiver from django.http import Http404 @@ -464,10 +465,7 @@ def list(self, request, *args, **kwargs): ) .annotate( resolved_middleware_hostname=Coalesce( - NullIf( - Cast(F("meta__middleware_hostname"), CharField()), - Value('""'), - ), + NullIf(KT("meta__middleware_hostname"), Value("")), NullIf(F("current_location__middleware_address"), Value("")), F("current_location__facility__middleware_address"), output_field=CharField(), diff --git a/care/facility/tests/test_asset_api.py b/care/facility/tests/test_asset_api.py index 7bbb458e92..48d934a0f7 100644 --- a/care/facility/tests/test_asset_api.py +++ b/care/facility/tests/test_asset_api.py @@ -3,6 +3,7 @@ from rest_framework.test import APITestCase from care.facility.models import Asset, Bed +from care.utils.assetintegration.asset_classes import AssetClasses from care.utils.tests.test_utils import TestUtils @@ -31,7 +32,6 @@ def test_list_assets(self): def test_create_asset(self): sample_data = { "name": "Test Asset", - "current_location": self.asset_location.pk, "asset_type": 50, "location": self.asset_location.external_id, } @@ -41,7 +41,6 @@ def test_create_asset(self): def test_create_asset_with_warranty_past(self): sample_data = { "name": "Test Asset", - "current_location": self.asset_location.pk, "asset_type": 50, "location": self.asset_location.external_id, "warranty_amc_end_of_validity": "2000-04-01", @@ -57,7 +56,6 @@ def test_retrieve_asset(self): def test_update_asset(self): sample_data = { "name": "Updated Test Asset", - "current_location": self.asset_location.pk, "asset_type": 50, "location": self.asset_location.external_id, } @@ -166,3 +164,182 @@ def test_asset_filter_warranty_amc_end_of_validity(self): self.assertNotIn( str(asset1.external_id), [asset["id"] for asset in response.data["results"]] ) + + +class AssetConfigValidationTestCase(TestUtils, APITestCase): + @classmethod + def setUpTestData(cls) -> None: + cls.state = cls.create_state() + cls.district = cls.create_district(cls.state) + cls.local_body = cls.create_local_body(cls.district) + cls.super_user = cls.create_super_user("su", cls.district) + cls.hostname = "test-middleware.com" + cls.facility = cls.create_facility( + cls.super_user, + cls.district, + cls.local_body, + middleware_address=cls.hostname, + ) + cls.asset_location = cls.create_asset_location(cls.facility) + cls.user = cls.create_user("staff", cls.district, home_facility=cls.facility) + + def test_create_asset_with_unique_ip(self): + sample_data = { + "name": "Test Asset", + "asset_type": 50, + "location": self.asset_location.external_id, + "asset_class": AssetClasses.HL7MONITOR.name, + "meta": {"local_ip_address": "192.168.1.14"}, + } + response = self.client.post("/api/v1/asset/", sample_data, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_create_asset_with_duplicate_ip(self): + self.create_asset( + self.asset_location, + name="I was here first", + asset_class=AssetClasses.HL7MONITOR.name, + meta={"local_ip_address": "192.168.1.14"}, + ) + sample_data = { + "name": "Test Asset", + "asset_type": 50, + "location": self.asset_location.external_id, + "asset_class": AssetClasses.HL7MONITOR.name, + "meta": {"local_ip_address": "192.168.1.14"}, + } + response = self.client.post("/api/v1/asset/", sample_data, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("I was here first", response.json()["non_field_errors"][0]) + + def test_create_asset_with_duplicate_ip_same_hostname_on_location(self): + test_location = self.create_asset_location( + self.facility, middleware_address=self.hostname + ) + self.create_asset( + test_location, + name="I was here first", + asset_class=AssetClasses.HL7MONITOR.name, + meta={"local_ip_address": "192.168.1.14"}, + ) + sample_data = { + "name": "Test Asset", + "asset_type": 50, + "location": test_location.external_id, + "asset_class": AssetClasses.HL7MONITOR.name, + "meta": {"local_ip_address": "192.168.1.14"}, + } + response = self.client.post("/api/v1/asset/", sample_data, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("I was here first", response.json()["non_field_errors"][0]) + + def test_create_asset_with_duplicate_ip_same_hostname_on_asset(self): + self.create_asset( + self.asset_location, + name="I was here first", + asset_class=AssetClasses.HL7MONITOR.name, + meta={ + "local_ip_address": "192.168.1.14", + "middleware_hostname": self.hostname, + }, + ) + sample_data = { + "name": "Test Asset", + "asset_type": 50, + "location": self.asset_location.external_id, + "asset_class": AssetClasses.HL7MONITOR.name, + "meta": {"local_ip_address": "192.168.1.14"}, + } + response = self.client.post("/api/v1/asset/", sample_data, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("I was here first", response.json()["non_field_errors"][0]) + + def test_create_asset_with_duplicate_ip_same_hostname_on_location_asset(self): + test_location = self.create_asset_location( + self.facility, middleware_address=self.hostname + ) + self.create_asset( + test_location, + name="I was here first", + asset_class=AssetClasses.HL7MONITOR.name, + meta={ + "local_ip_address": "192.168.1.14", + "middleware_hostname": self.hostname, + }, + ) + sample_data = { + "name": "Test Asset", + "asset_type": 50, + "location": test_location.external_id, + "asset_class": AssetClasses.HL7MONITOR.name, + "meta": {"local_ip_address": "192.168.1.14"}, + } + response = self.client.post("/api/v1/asset/", sample_data, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("I was here first", response.json()["non_field_errors"][0]) + + def test_create_asset_with_duplicate_ip_different_hostname_on_location(self): + test_location = self.create_asset_location( + self.facility, middleware_address="not-test-middleware.com" + ) + self.create_asset( + self.asset_location, + name="I was here first", + asset_class=AssetClasses.HL7MONITOR.name, + meta={"local_ip_address": "192.168.1.14"}, + ) + sample_data = { + "name": "Test Asset", + "asset_type": 50, + "location": test_location.external_id, + "asset_class": AssetClasses.HL7MONITOR.name, + "meta": {"local_ip_address": "192.168.1.14"}, + } + response = self.client.post("/api/v1/asset/", sample_data, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_create_asset_with_duplicate_ip_different_hostname_on_asset(self): + self.create_asset( + self.asset_location, + name="I was here first", + asset_class=AssetClasses.HL7MONITOR.name, + meta={"local_ip_address": "192.168.1.14"}, + ) + sample_data = { + "name": "Test Asset", + "asset_type": 50, + "location": self.asset_location.external_id, + "asset_class": AssetClasses.HL7MONITOR.name, + "meta": { + "local_ip_address": "192.168.1.14", + "middleware_hostname": "not-test-middleware.com", + }, + } + response = self.client.post("/api/v1/asset/", sample_data, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_create_asset_with_duplicate_ip_different_hostname_on_location_asset(self): + test_location = self.create_asset_location( + self.facility, middleware_address="not-test-middleware.com" + ) + self.create_asset( + test_location, + name="I was here first", + asset_class=AssetClasses.HL7MONITOR.name, + meta={ + "local_ip_address": "192.168.1.14", + "middleware_hostname": self.hostname, + }, + ) + sample_data = { + "name": "Test Asset", + "asset_type": 50, + "location": test_location.external_id, + "asset_class": AssetClasses.HL7MONITOR.name, + "meta": { + "local_ip_address": "192.168.1.14", + "middleware_hostname": "not-test-middleware.com", + }, + } + response = self.client.post("/api/v1/asset/", sample_data, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) diff --git a/care/facility/tests/test_middleware_config.py b/care/facility/tests/test_middleware_config.py new file mode 100644 index 0000000000..26305c6261 --- /dev/null +++ b/care/facility/tests/test_middleware_config.py @@ -0,0 +1,139 @@ +from rest_framework import status +from rest_framework.test import APITestCase + +from care.utils.assetintegration.asset_classes import AssetClasses +from care.utils.tests.test_utils import TestUtils +from config.authentication import MiddlewareUser + + +class MiddlewareConfigTestCase(TestUtils, APITestCase): + @classmethod + def setUpTestData(cls) -> None: + cls.state = cls.create_state() + cls.district = cls.create_district(cls.state) + cls.local_body = cls.create_local_body(cls.district) + cls.super_user = cls.create_super_user("su", cls.district) + cls.hostname = "test-middleware.com" + cls.facility = cls.create_facility( + cls.super_user, + cls.district, + cls.local_body, + middleware_address=cls.hostname, + ) + cls.middleware_user = MiddlewareUser(facility=cls.facility) + + def setUp(self) -> None: + self.client.force_authenticate(user=self.middleware_user) + + def test_fetch_middleware_config_hostname_on_facility(self): + test_location = self.create_asset_location(self.facility) + test_asset = self.create_asset( + test_location, + asset_class=AssetClasses.HL7MONITOR.name, + meta={"local_ip_address": "192.168.1.14"}, + ) + response = self.client.get( + f"/api/v1/asset_config/?middleware_hostname={self.hostname}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data[0]["id"], str(test_asset.external_id)) + + def test_fetch_middleware_config_hostname_on_facility_location(self): + test_location = self.create_asset_location( + self.facility, middleware_address=self.hostname + ) + test_asset = self.create_asset( + test_location, + asset_class=AssetClasses.HL7MONITOR.name, + meta={"local_ip_address": "192.168.1.14"}, + ) + response = self.client.get( + f"/api/v1/asset_config/?middleware_hostname={self.facility.middleware_address}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data[0]["id"], str(test_asset.external_id)) + + def test_fetch_middleware_config_hostname_on_facility_asset(self): + test_location = self.create_asset_location(self.facility) + test_asset = self.create_asset( + test_location, + asset_class=AssetClasses.HL7MONITOR.name, + meta={ + "local_ip_address": "192.168.1.14", + "middleware_hostname": self.hostname, + }, + ) + response = self.client.get( + f"/api/v1/asset_config/?middleware_hostname={self.facility.middleware_address}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data[0]["id"], str(test_asset.external_id)) + + def test_fetch_middleware_config_hostname_on_facility_location_asset(self): + test_location = self.create_asset_location( + self.facility, middleware_address=self.hostname + ) + test_asset = self.create_asset( + test_location, + asset_class=AssetClasses.HL7MONITOR.name, + meta={ + "local_ip_address": "192.168.1.14", + "middleware_hostname": self.hostname, + }, + ) + response = self.client.get( + f"/api/v1/asset_config/?middleware_hostname={self.facility.middleware_address}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data[0]["id"], str(test_asset.external_id)) + + def test_fetch_middleware_config_different_hostname_on_location_same_on_asset(self): + test_location = self.create_asset_location( + self.facility, middleware_address="not-test-middleware.com" + ) + test_asset = self.create_asset( + test_location, + asset_class=AssetClasses.HL7MONITOR.name, + meta={ + "local_ip_address": "192.168.1.14", + "middleware_hostname": self.hostname, + }, + ) + response = self.client.get( + f"/api/v1/asset_config/?middleware_hostname={self.facility.middleware_address}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data[0]["id"], str(test_asset.external_id)) + + def test_fetch_middleware_config_different_hostname_on_location(self): + test_location = self.create_asset_location( + self.facility, middleware_address="not-test-middleware.com" + ) + self.create_asset( + test_location, + asset_class=AssetClasses.HL7MONITOR.name, + meta={"local_ip_address": "192.168.1.14"}, + ) + response = self.client.get( + f"/api/v1/asset_config/?middleware_hostname={self.facility.middleware_address}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, []) + + def test_fetch_middleware_config_different_hostname_on_asset_same_on_location(self): + test_location = self.create_asset_location( + self.facility, middleware_address=self.hostname + ) + test_asset = self.create_asset( + test_location, + asset_class=AssetClasses.HL7MONITOR.name, + meta={ + "local_ip_address": "192.168.1.14", + "middleware_hostname": "not-test-middleware.com", + }, + ) + response = self.client.get( + "/api/v1/asset_config/?middleware_hostname=not-test-middleware.com" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data[0]["id"], str(test_asset.external_id)) diff --git a/care/utils/tests/test_utils.py b/care/utils/tests/test_utils.py index 6a34c198ca..a6c2307312 100644 --- a/care/utils/tests/test_utils.py +++ b/care/utils/tests/test_utils.py @@ -385,7 +385,6 @@ def create_asset_location(cls, facility: Facility, **kwargs) -> AssetLocation: "name": "asset1 location", "location_type": 1, "facility": facility, - "middleware_address": "example.com", } data.update(kwargs) return AssetLocation.objects.create(**data)