Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Plugin Support #2036

Merged
merged 14 commits into from
Apr 12, 2024
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Generated by Django 4.2.10 on 2024-04-02 11:49

from django.db import migrations, models
sainak marked this conversation as resolved.
Show resolved Hide resolved


class Migration(migrations.Migration):
dependencies = [
("facility", "0423_patientconsultation_consent_records_and_more"),
]

operations = [
migrations.AlterField(
model_name="fileupload",
name="file_category",
field=models.CharField(
choices=[
("UNSPECIFIED", "Unspecified"),
("XRAY", "Xray"),
("AUDIO", "Audio"),
("IDENTITY_PROOF", "Identity Proof"),
],
default="UNSPECIFIED",
max_length=100,
),
),
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,
),
),
]
125 changes: 74 additions & 51 deletions care/facility/models/file_upload.py
Original file line number Diff line number Diff line change
@@ -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 FileCategory(enum.Enum):
class BaseFileUpload(models.Model):
class FileCategory(models.TextChoices):
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
sainak marked this conversation as resolved.
Show resolved Hide resolved
)
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):
Expand All @@ -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
):
Expand Down Expand Up @@ -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
PATIENT = 1
CONSULTATION = 2
SAMPLE_MANAGEMENT = 3
CLAIM = 4
DISCHARGE_SUMMARY = 5
COMMUNICATION = 6
CONSENT_RECORD = 7

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]
17 changes: 9 additions & 8 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -77,7 +77,6 @@
}
}


# URLS
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf
Expand Down Expand Up @@ -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
# ------------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -485,7 +489,6 @@
)
SEND_SMS_NOTIFICATION = False


# Cloud and Buckets
# ------------------------------------------------------------------------------

Expand Down Expand Up @@ -567,7 +570,6 @@
else FACILITY_S3_BUCKET_ENDPOINT,
)


# for setting the shifting mode
PEACETIME_MODE = env.bool("PEACETIME_MODE", default=True)

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))]
4 changes: 3 additions & 1 deletion docker/dev.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/*

Expand All @@ -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 \
Expand Down
5 changes: 4 additions & 1 deletion docker/prod.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions install_plugins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from plug_config import manager

manager.install()
21 changes: 21 additions & 0 deletions plug_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import environ

from plugs.manager import PlugManager
from plugs.plug import Plug

env = environ.Env()

scribe_plug = Plug(
name="care_scribe",
sainak marked this conversation as resolved.
Show resolved Hide resolved
package_name="git+https://github.com/coronasafe/care_scribe.git",
version="@scribe",
configs={
"TRANSCRIBE_SERVICE_PROVIDER_API_KEY": env(
sainak marked this conversation as resolved.
Show resolved Hide resolved
"TRANSCRIBE_SERVICE_PROVIDER_API_KEY"
),
},
)

plugs = [scribe_plug]

manager = PlugManager(plugs)
28 changes: 28 additions & 0 deletions plugs/manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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]
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
10 changes: 10 additions & 0 deletions plugs/plug.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading