From 9f69b10ad795e8d1e48b723dc8469e430bd6bd18 Mon Sep 17 00:00:00 2001 From: triou <34629112+tevariou@users.noreply.github.com> Date: Tue, 15 Dec 2020 11:17:35 +0100 Subject: [PATCH] Link frontend and backend (#3) * Add a login page and link frontend and backend * Replace basic authentication with JWT auth * Fix yarn timeout * Shared volume between api and nginx is now limited to django static files and the socket file * Change env variables management * The uwsgi server is run by a dedicated non root user --- .env | 13 -- README.md | 6 +- api/.env | 14 ++ api/.gitignore | 1 + api/Dockerfile | 18 +- api/README.md | 61 +++++- .../api/migrations/0005_auto_20201209_1524.py | 19 ++ api/avc_forms/api/models.py | 3 +- api/avc_forms/asgi.py | 16 -- api/avc_forms/settings.py | 33 ++-- api/avc_forms/urls.py | 6 + api/avc_forms/uwsgi.ini | 7 +- api/docker-entrypoint.sh | 10 +- api/manage.py | 7 + api/requirements.txt | 6 +- app/.dockerignore | 2 + app/.env | 5 + app/.yarnrc | 1 + app/Dockerfile | 24 ++- app/package.json | 4 +- app/src/App.tsx | 10 +- app/src/components/CSVUploadButton.tsx | 4 +- app/src/components/Dialog.tsx | 48 +++++ app/src/components/NavigationBar.tsx | 57 ++++++ app/src/components/Notifier.tsx | 57 ++++++ app/src/components/inputCards/DateInput.tsx | 58 +++--- app/src/constants.ts | 12 ++ app/src/locales/en/translation.json | 10 +- app/src/locales/es/translation.json | 10 +- app/src/locales/fr/translation.json | 10 +- app/src/navigation/AppNavigator.tsx | 60 ++---- app/src/navigation/Private.tsx | 39 ++++ app/src/screens/AVCTableViewer.tsx | 49 +++-- app/src/screens/Login.tsx | 60 ++++++ app/src/screens/PatientForm.tsx | 29 ++- app/src/services/api.ts | 36 ++++ app/src/services/auth.ts | 105 ++++++++++ app/src/state/fakePatientData.json | 1 - app/src/state/notifSlice.ts | 64 ++++++ app/src/state/patientFormSlice.ts | 183 ++++++++++-------- app/src/state/store.ts | 6 +- app/src/state/user.ts | 89 +++++++++ app/src/utils/tokenManager.ts | 55 ++++++ app/yarn.lock | 37 +++- docker-compose.yml | 31 ++- nginx/.env | 2 + nginx/templates/nginx.conf.template | 6 +- 47 files changed, 1098 insertions(+), 286 deletions(-) delete mode 100644 .env create mode 100644 api/.env create mode 100644 api/.gitignore create mode 100644 api/avc_forms/api/migrations/0005_auto_20201209_1524.py delete mode 100644 api/avc_forms/asgi.py create mode 100644 app/.dockerignore create mode 100644 app/.env create mode 100644 app/.yarnrc create mode 100644 app/src/components/Dialog.tsx create mode 100644 app/src/components/NavigationBar.tsx create mode 100644 app/src/components/Notifier.tsx create mode 100644 app/src/constants.ts create mode 100644 app/src/navigation/Private.tsx create mode 100644 app/src/screens/Login.tsx create mode 100644 app/src/services/api.ts create mode 100644 app/src/services/auth.ts delete mode 100644 app/src/state/fakePatientData.json create mode 100644 app/src/state/notifSlice.ts create mode 100644 app/src/state/user.ts create mode 100644 app/src/utils/tokenManager.ts create mode 100644 nginx/.env diff --git a/.env b/.env deleted file mode 100644 index 49d5c92..0000000 --- a/.env +++ /dev/null @@ -1,13 +0,0 @@ -POSTGRES_DB=avc -POSTGRES_USER=postgres -POSTGRES_PASSWORD=password -POSTGRES_HOST=db -POSTGRES_PORT=5432 -DJANGO_SUPERUSER_PASSWORD=password -DJANGO_SUPERUSER_USERNAME=admin -DJANGO_SUPERUSER_EMAIL=admin@arkhn.com -DJANGO_SECRET_KEY=secret -DJANGO_DEBUG=1 -DJANGO_ALLOWED_HOSTS="localhost 127.0.0.1 [::1]" -NGINX_PORT=8080 -NGINX_HOST=localhost diff --git a/README.md b/README.md index d0f0267..90f064b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # AVC Forms ## Usage -Run `docker-compose up -d` and visit `host:port` (default `localhost:8080`) +Run `docker-compose up`. +Visit `host:port` (default `localhost:8080`). +Default user Admin credentials are `{ username: admin, password: admin }`. -## Configuration -Set up your custom configuration in the `.env` file diff --git a/api/.env b/api/.env new file mode 100644 index 0000000..d47b521 --- /dev/null +++ b/api/.env @@ -0,0 +1,14 @@ +# DJANGO_SECRET should be a crypto secure random 50 bytes key +DJANGO_SECRET_KEY=${DJANGO_SECRET_KEY:-secret} +DJANGO_ALLOWED_HOSTS="${DJANGO_ALLOWED_HOST:-localhost 127.0.0.1}" +DJANGO_CORS_ALLOWED_ORIGINS="${DJANGO_CORS_ALLOWED_ORIGINS:-http://localhost:8080 http://localhost:3000}" +DJANGO_SUPERUSER_USERNAME=${DJANGO_SUPERUSER_USERNAME:-admin} +DJANGO_SUPERUSER_PASSWORD=${DJANGO_SUPERUSER_PASSWORD:-admin} +DJANGO_SUPERUSER_EMAIL=${DJANGO_SUPERUSER_EMAIL:-admin@arkhn.com} +# DJANGO_DEBUG should be equal to 0 in production +DJANGO_DEBUG=${DJANGO_DEBUG:-1} +POSTGRES_DB=${POSTGRES_DB:-postgres} +POSTGRES_USER=${POSTGRES_USER:-postgres} +POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password} +POSTGRES_HOST=${POSTGRES_HOST:-db} +POSTGRES_PORT=${POSTGRES_PORT:-5432} diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 0000000..11ee758 --- /dev/null +++ b/api/.gitignore @@ -0,0 +1 @@ +.env.local diff --git a/api/Dockerfile b/api/Dockerfile index f29201c..92a308c 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,20 +1,22 @@ FROM python:3.9.0-slim-buster as builder WORKDIR /tmp -ENV PYTHONDONTWRITEBYTECODE 1 -ENV PYTHONUNBUFFERED 1 COPY ./requirements.txt . RUN apt-get update && apt-get install -y libpq-dev gcc RUN pip wheel --no-cache-dir --no-deps --wheel-dir /tmp/wheels -r requirements.txt FROM python:3.9.0-slim-buster -ENV DJANGO_SETTINGS_MODULE=avc_forms.settings -ENV PYTHONPATH=/api RUN apt-get update && \ apt-get install -y --no-install-recommends netcat libpq-dev && \ apt-get autoremove -y && \ apt-get clean -COPY --from=builder /tmp/wheels /wheels -RUN pip install --no-cache /wheels/* -WORKDIR /api -COPY . . +RUN groupadd -r api && useradd --create-home --no-log-init -r -g api api +USER api:api +WORKDIR /home/api +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 +ENV PYTHONPATH=/home/api +ENV PATH /home/api/.local/bin:${PATH} +COPY --from=builder --chown=api:api /tmp/wheels wheels +RUN pip install --user --no-cache wheels/* && rm -rf wheels +COPY --chown=api:api . . ENTRYPOINT ["sh", "docker-entrypoint.sh"] diff --git a/api/README.md b/api/README.md index 5027ddb..a14285e 100644 --- a/api/README.md +++ b/api/README.md @@ -1,18 +1,59 @@ # AVC Forms API +## Recommended requirements +* Python 3.9 +* Pip 20.3.1 +* Postresql 13.1 +* Prerequesites for [psycopg](https://www.psycopg.org/docs/install.html) + ## Usage +* Run a postgresql database `postgres` at `localhost:5432` (user `postgres`, no password) +* Install dependencies `pip install -r requirements.txt` +* Apply migration `python manage.py migrate` +* Create an admin user `python manage.py createsuperuser` +* Run `python manage.py runserver` +* Visit `localhost:8080` and login -* API root at `host:port/api` -* Admin interface at `host:port/api/admin` +## Base configuration override for local development +* Add environment variables in a `.env.local` file in the API project directory ## Routes -* `/api-auth/login` accepts basic authentication -* `/api-auth/logout` +* API documentation at `host:port/api` +* Admin interface at `host:port/api/admin` + +* `/token/` + * `POST` request + ```json + { + "username": "username", + "password": "password" + } + ``` + * Response + ```json + { + "refresh": "refresh_jwt_token", + "access": "access_jwt_token" + } + ``` +* `/token/refresh/` + * Request + ```shell script + curl \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{"refresh":"refresh_token_jwt"}' \ + http://host:port/api/token/refresh/ + ``` + * Response + ```json + { "access": "access_jwt_token" } + ``` * `/users` * `/patients` -```json -{ -"code": "unique_text_field", -"data": { } -} -``` + * Example request + ```shell script + curl \ + -H "Bearer access_jwt_token" + http://host:port/api/patients + ``` diff --git a/api/avc_forms/api/migrations/0005_auto_20201209_1524.py b/api/avc_forms/api/migrations/0005_auto_20201209_1524.py new file mode 100644 index 0000000..9f34016 --- /dev/null +++ b/api/avc_forms/api/migrations/0005_auto_20201209_1524.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1.4 on 2020-12-09 15:24 + +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0004_auto_20201125_1839'), + ] + + operations = [ + migrations.AlterField( + model_name='patient', + name='code', + field=models.TextField(default=uuid.uuid4, unique=True), + ), + ] diff --git a/api/avc_forms/api/models.py b/api/avc_forms/api/models.py index e5d5583..a70f1de 100644 --- a/api/avc_forms/api/models.py +++ b/api/avc_forms/api/models.py @@ -1,8 +1,9 @@ from django.db import models +import uuid class Patient(models.Model): - code = models.TextField(default='', unique=True) + code = models.TextField(default=uuid.uuid4, unique=True) data = models.JSONField() created_by = models.ForeignKey('auth.User', related_name='+', on_delete=models.RESTRICT) created_at = models.DateTimeField(auto_now_add=True) diff --git a/api/avc_forms/asgi.py b/api/avc_forms/asgi.py deleted file mode 100644 index 3e8c3fc..0000000 --- a/api/avc_forms/asgi.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -ASGI config for avc_forms project. - -It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ -""" - -import os - -from django.core.asgi import get_asgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'avc_forms.settings') - -application = get_asgi_application() diff --git a/api/avc_forms/settings.py b/api/avc_forms/settings.py index 42c55b9..1946c6a 100644 --- a/api/avc_forms/settings.py +++ b/api/avc_forms/settings.py @@ -21,18 +21,22 @@ # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'secret') +SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'secret') # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = int(os.getenv('DJANGO_DEBUG', 0)) - -ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS").split(" ") +DEBUG = bool(int(os.environ.get('DJANGO_DEBUG', 1))) +ALLOWED_HOSTS = os.getenv('DJANGO_ALLOWED_HOSTS').split(' ') if os.getenv('DJANGO_ALLOWED_HOSTS') else [] +CORS_ALLOW_ALL_ORIGINS = DEBUG +CORS_ALLOWED_ORIGINS = os.environ\ + .get('DJANGO_CORS_ALLOWED_ORIGINS', 'http://localhost:8080 http://localhost:3000')\ + .split(' ') # Application definition INSTALLED_APPS = [ 'avc_forms.api', + 'corsheaders', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -45,6 +49,7 @@ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', + 'corsheaders.middleware.CorsMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', @@ -79,11 +84,11 @@ DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', - 'NAME': os.getenv("POSTGRES_DB", 'avc'), - 'USER': os.getenv("POSTGRES_USER", 'postgres'), - 'PASSWORD': os.getenv("POSTGRES_PASSWORD", ''), - 'HOST': os.getenv("POSTGRES_HOST", 'localhost'), - 'PORT': os.getenv("POSTGRES_PORT", 5432), + 'NAME': os.environ.get('POSTGRES_DB', 'postgres'), + 'USER': os.environ.get('POSTGRES_USER', 'postgres'), + 'PASSWORD': os.environ.get('POSTGRES_PASSWORD', ''), + 'HOST': os.environ.get('POSTGRES_HOST', 'localhost'), + 'PORT': os.environ.get('POSTGRES_PORT', 5432), } } @@ -123,12 +128,14 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.1/howto/static-files/ - -PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) -STATIC_ROOT = os.path.join(PROJECT_DIR, 'django_static') +STATIC_ROOT = '/tmp/django_static' STATIC_URL = '/django_static/' REST_FRAMEWORK = { 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', - 'PAGE_SIZE': 42 + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'rest_framework.authentication.SessionAuthentication' + ), + 'PAGE_SIZE': 100 } diff --git a/api/avc_forms/urls.py b/api/avc_forms/urls.py index 9450a5b..70a1bca 100644 --- a/api/avc_forms/urls.py +++ b/api/avc_forms/urls.py @@ -18,6 +18,10 @@ from django.contrib import admin from rest_framework import routers from avc_forms.api import views +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, +) router = routers.DefaultRouter() router.register(r'users', views.UserViewSet) @@ -28,5 +32,7 @@ urlpatterns = [ path('', include(router.urls)), path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), + path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path('admin/', admin.site.urls) ] diff --git a/api/avc_forms/uwsgi.ini b/api/avc_forms/uwsgi.ini index be0f2c8..6500929 100644 --- a/api/avc_forms/uwsgi.ini +++ b/api/avc_forms/uwsgi.ini @@ -1,13 +1,14 @@ [uwsgi] -chdir=/api/avc_forms +chdir=/home/api/avc_forms mount = /api=avc_forms.wsgi:application manage-script-name=true master=True pidfile=/tmp/project-master.pid vacuum=True max-requests=5000 -env=LANG=en_US.UTF-8 processes=5 threads=2 -socket=avc_forms.sock +socket=/tmp/avc_forms.sock chmod-socket=666 +uid=api +gid=api diff --git a/api/docker-entrypoint.sh b/api/docker-entrypoint.sh index cb116f6..2479c1b 100644 --- a/api/docker-entrypoint.sh +++ b/api/docker-entrypoint.sh @@ -1,5 +1,9 @@ #!/bin/bash +# Collect static files +echo "Collect static files" +python manage.py collectstatic --noinput + # Wait for db echo "Waiting for postgres..." while ! nc -z "$POSTGRES_HOST" "$POSTGRES_PORT"; do @@ -12,10 +16,6 @@ python manage.py migrate # Create superuser echo "Create superuser" -django-admin createsuperuser --noinput - -# Collect static files -echo "Collect static files" -python manage.py collectstatic --noinput +python manage.py createsuperuser --noinput exec "$@" diff --git a/api/manage.py b/api/manage.py index 79dbb85..33f6822 100755 --- a/api/manage.py +++ b/api/manage.py @@ -2,10 +2,17 @@ """Django's command-line utility for administrative tasks.""" import os import sys +from pathlib import Path +from dotenv import load_dotenv def main(): """Run administrative tasks.""" + + # Load optional .env.local file in the project directory to override base configuration + env_path = Path('.') / '.env.local' + load_dotenv(dotenv_path=env_path) + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'avc_forms.settings') try: from django.core.management import execute_from_command_line diff --git a/api/requirements.txt b/api/requirements.txt index af28db2..49ad13d 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -1,9 +1,13 @@ asgiref==3.3.1 -Django==3.1.3 +Django==3.1.4 +django-cors-headers==3.6.0 django-filter==2.4.0 djangorestframework==3.12.2 +djangorestframework-simplejwt==4.6.0 Markdown==3.3.3 psycopg2==2.8.6 +PyJWT==1.7.1 +python-dotenv==0.15.0 pytz==2020.4 sqlparse==0.4.1 uWSGI==2.0.19.1 diff --git a/app/.dockerignore b/app/.dockerignore new file mode 100644 index 0000000..2e4598f --- /dev/null +++ b/app/.dockerignore @@ -0,0 +1,2 @@ +./node_modules +./build diff --git a/app/.env b/app/.env new file mode 100644 index 0000000..57ac552 --- /dev/null +++ b/app/.env @@ -0,0 +1,5 @@ +# REACT_APP_API_URL=${APP_API_URL} +# REACT_APP_AUTH_API_URL=${APP_API_AUTH} + +REACT_APP_API_URL=http://localhost:8080/api +REACT_APP_AUTH_API_URL=http://localhost:8080/api/token/ diff --git a/app/.yarnrc b/app/.yarnrc new file mode 100644 index 0000000..788570f --- /dev/null +++ b/app/.yarnrc @@ -0,0 +1 @@ +network-timeout 600000 diff --git a/app/Dockerfile b/app/Dockerfile index 8f9fb26..dd3f381 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -1,15 +1,13 @@ -#FROM node:15.3.0-alpine3.10 as builder -#WORKDIR /app -#ENV PATH /app/node_modules/.bin:$PATH -#COPY ./package.json . -#RUN yarn -#COPY . . -#RUN yarn build -# -#FROM busybox -#WORKDIR /app -#COPY --from=builder /app/build . +FROM node:15.3.0 as builder +WORKDIR /app +ENV PATH /app/node_modules/.bin:$PATH +COPY ./package.json . +COPY ./.yarnrc . +COPY ./yarn.lock . +RUN yarn +COPY . . +RUN yarn build -FROM busybox +FROM busybox:1.32.0 WORKDIR /app -COPY ./build . +COPY --from=builder /app/build build diff --git a/app/package.json b/app/package.json index 9787639..a7a7e59 100644 --- a/app/package.json +++ b/app/package.json @@ -3,18 +3,20 @@ "version": "0.1.0", "private": true, "dependencies": { - "@arkhn/ui": "^1.7.4", + "@arkhn/ui": "^1.9.2", "@date-io/date-fns": "1.x", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.56", "@material-ui/pickers": "^3.2.10", "@reduxjs/toolkit": "^1.4.0", + "axios": "^0.21.0", "date-fns": "^2.16.1", "i18next": "^19.8.3", "i18next-browser-languagedetector": "^6.0.1", "js-file-download": "^0.4.12", "lodash": "^4.17.20", + "notistack": "^1.0.2", "react": "^17.0.1", "react-country-flag": "^2.3.0", "react-dom": "^17.0.1", diff --git a/app/src/App.tsx b/app/src/App.tsx index e608fd1..e934e0d 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -8,6 +8,8 @@ import { import AppNavigator from "./navigation/AppNavigator"; import theme from "./style/theme"; import store from "./state/store"; +import { SnackbarProvider } from "notistack"; +import Notifier from "components/Notifier"; function App() { return ( @@ -15,7 +17,13 @@ function App() { }> - + + + + diff --git a/app/src/components/CSVUploadButton.tsx b/app/src/components/CSVUploadButton.tsx index 771e09a..fff7743 100644 --- a/app/src/components/CSVUploadButton.tsx +++ b/app/src/components/CSVUploadButton.tsx @@ -4,7 +4,7 @@ import { GetApp } from "@material-ui/icons"; import { useTranslation } from "react-i18next"; import { CSVReader } from "react-papaparse"; import { useAppDispatch } from "state/store"; -import { importPatientData } from "state/patientFormSlice"; +import { setPatientEntriesThunk } from "state/patientFormSlice"; import { formatPatientDataForImport } from "utils/formUtils"; type CSVUploadButtonProps = { @@ -33,7 +33,7 @@ const CSVUploadButton: React.FC = ({ fabClassName }) => { return patient; }); const patients = patientData.map(formatPatientDataForImport); - dispatch(importPatientData(patients)); + dispatch(setPatientEntriesThunk(patients)); }; const handleOnError = (...params: any) => {}; diff --git a/app/src/components/Dialog.tsx b/app/src/components/Dialog.tsx new file mode 100644 index 0000000..de3bcb6 --- /dev/null +++ b/app/src/components/Dialog.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { + Dialog as MuiDialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, + Button, +} from "@material-ui/core"; + +type Props = { + open: boolean; + onClose?: () => void; + onAgree?: () => void; + title?: string; + content?: string; + refuseButtonTitle?: string; + agreeButtonTitle?: string; +}; + +const Dialog: React.FC = ({ + open, + title, + content, + refuseButtonTitle, + agreeButtonTitle, + onClose, + onAgree, +}) => { + return ( + + {title} + + {content} + + + + + + + ); +}; + +export default Dialog; diff --git a/app/src/components/NavigationBar.tsx b/app/src/components/NavigationBar.tsx new file mode 100644 index 0000000..3b4f24a --- /dev/null +++ b/app/src/components/NavigationBar.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { NavBar } from "@arkhn/ui"; +import { Link } from "react-router-dom"; + +import { ReactComponent as Logo } from "../assets/img/arkhn-logo.svg"; +import { Typography, IconButton, makeStyles } from "@material-ui/core"; +import { ExitToApp } from "@material-ui/icons"; +import LanguageSelect from "./LanguageSelect"; +import { useAppSelector, useAppDispatch } from "state/store"; +import { logout } from "state/user"; + +const useStyles = makeStyles(() => ({ + logo: { + height: 27, + width: 21, + marginRight: 16, + }, + link: { + display: "flex", + textDecoration: "none", + width: "fit-content", + }, + titleContainer: { + flexGrow: 1, + }, +})); + +const NavigationBar: React.FC<{}> = () => { + const classes = useStyles(); + const dispatch = useAppDispatch(); + const { user } = useAppSelector((state) => state); + + return ( + +
+ + + + AVC Forms + + +
+ {user && user.access && ( + dispatch(logout())}> + + + )} + + + } + /> + ); +}; + +export default NavigationBar; diff --git a/app/src/components/Notifier.tsx b/app/src/components/Notifier.tsx new file mode 100644 index 0000000..79de801 --- /dev/null +++ b/app/src/components/Notifier.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { useAppSelector, useAppDispatch } from "state/store"; +import { useSnackbar } from "notistack"; +import { removeSnackbar } from "state/notifSlice"; +import { useTranslation } from "react-i18next"; + +let displayed: string[] = []; + +const Notifier: React.FC<{}> = () => { + const dispatch = useAppDispatch(); + const { notifications } = useAppSelector((state) => state.notif); + const { enqueueSnackbar, closeSnackbar } = useSnackbar(); + const { t } = useTranslation(); + + const storeDisplayed = (id: string) => { + displayed = [...displayed, id]; + }; + + const removeDisplayed = (id: string) => { + displayed = [...displayed.filter((key) => id !== key)]; + }; + + React.useEffect(() => { + notifications.forEach( + ({ key, message, options = {}, dismissed = false }) => { + if (dismissed) { + // dismiss snackbar using notistack + closeSnackbar(key); + return; + } + + // do nothing if snackbar is already displayed + if (displayed.includes(key)) return; + + // display snackbar using notistack + enqueueSnackbar(t(message), { + key, + ...options, + onClose: (event, reason, myKey) => + options.onClose && options.onClose(event, reason, myKey), + onExited: (event, myKey) => { + // remove this snackbar from redux store + dispatch(removeSnackbar(myKey.toString())); + removeDisplayed(myKey.toString()); + }, + }); + + // keep track of snackbars that we've displayed + storeDisplayed(key); + } + ); + }, [notifications, closeSnackbar, enqueueSnackbar, t, dispatch]); + + return null; +}; + +export default Notifier; diff --git a/app/src/components/inputCards/DateInput.tsx b/app/src/components/inputCards/DateInput.tsx index 11254de..56fcc80 100644 --- a/app/src/components/inputCards/DateInput.tsx +++ b/app/src/components/inputCards/DateInput.tsx @@ -13,34 +13,38 @@ type DateInputProps = { onChange: (date: Date | null) => void; error?: boolean; helperText?: string; + disabled?: boolean; }; -const DateInput: React.FC = ({ name, ...props }) => { - const { t } = useTranslation(); - return ( - - - {t(name)} - - - - - - - - ); -}; +const DateInput: React.FC = React.forwardRef( + ({ name, ...props }, ref) => { + const { t } = useTranslation(); + return ( + + + {t(name)} + + + + + + + + ); + } +); export default DateInput; diff --git a/app/src/constants.ts b/app/src/constants.ts new file mode 100644 index 0000000..9b6e442 --- /dev/null +++ b/app/src/constants.ts @@ -0,0 +1,12 @@ +export const ID_TOKEN_STORAGE_KEY = "ARKHN_ID_TOKEN"; +export const TOKEN_DATA_STORAGE_KEY = "ARKHN_TOKEN_DATA"; +export const STATE_STORAGE_KEY = "ARKHN_AUTH_STATE"; + +export const { + REACT_APP_API_URL: API_URL, + REACT_APP_AUTH_API_URL: AUTH_API_URL, +} = process.env; + +export const ACCES_TOKEN = "access"; +export const REFRESH_TOKEN = "refresh"; +export const USERNAME_KEY = "username"; diff --git a/app/src/locales/en/translation.json b/app/src/locales/en/translation.json index 40634b7..60b76f9 100644 --- a/app/src/locales/en/translation.json +++ b/app/src/locales/en/translation.json @@ -137,5 +137,13 @@ "intracranialThrombectomy": "Intracranial thrombectomy", "intracranialVesselThrombolysis": "Thrombolysis of intracranial vessel", "alteplase": "Alteplase", - "tenecteplase": "Tenecteplase" + "tenecteplase": "Tenecteplase", + "authenticationError": "Authentication Failed: Wrong Login or Password", + "patientAddSuccess": "Patient(s) added successfully !", + "patientAddFailure": "Patient(s) add failure", + "patientDeleteSuccess": "Patient(s) deleted", + "deletePatientsTitle": "Delete patients", + "deletePatientsQuestion": "Are you sure to delete patient row(s) ?", + "editSuccess": "Patient successfully edited", + "editFailure": "Patient edition failed" } diff --git a/app/src/locales/es/translation.json b/app/src/locales/es/translation.json index e5c7167..90a51a1 100644 --- a/app/src/locales/es/translation.json +++ b/app/src/locales/es/translation.json @@ -137,5 +137,13 @@ "intracranialThrombectomy": "Trombectomía intracraneal", "intracranialVesselThrombolysis": "Trombólisis de vaso intracraneal", "alteplase": "Alteplasa", - "tenecteplase": "Tenecteplasa" + "tenecteplase": "Tenecteplasa", + "authenticationError": "Error de autenticación: nombre de usuario o contraseña incorrectos", + "patientAddSuccess": "Paciente(s) agregado correctamente !", + "patientAddFailure": "Paciente(s) agregan falla", + "patientDeleteSuccess": "Paciente(s) eliminado", + "deletePatientsTitle": "Eliminar pacientes", + "deletePatientsQuestion": "¿Está seguro de eliminar las filas de pacientes?", + "editSuccess": "Paciente editado con éxito", + "editFailure": "Error en la edición del paciente" } diff --git a/app/src/locales/fr/translation.json b/app/src/locales/fr/translation.json index c8179ff..282cb4b 100644 --- a/app/src/locales/fr/translation.json +++ b/app/src/locales/fr/translation.json @@ -137,5 +137,13 @@ "intracranialThrombectomy": "Thrombectomie intracrânienne", "intracranialVesselThrombolysis": "Thrombolyse du vaisseau intracrânien", "alteplase": "Alteplase", - "tenecteplase": "Tenectéplase" + "tenecteplase": "Tenectéplase", + "authenticationError": "Erreur d'authentification: Mauvais Login ou Mot de passe", + "patientAddSuccess": "Ajout de patient(s) réussi !", + "patientAddFailure": "Echec d'ajout de patient(s)", + "patientDeleteSuccess": "Patient(s) supprimé(s)", + "deletePatientsTitle": "Supprimer patients", + "deletePatientsQuestion": "Êtes-vous sûr(e) de vouloir supprimer la selection ?", + "editSuccess": "Patient modifié avec succès", + "editFailure": "Echec d'édition du patient" } diff --git a/app/src/navigation/AppNavigator.tsx b/app/src/navigation/AppNavigator.tsx index f480dec..ed4ff5f 100644 --- a/app/src/navigation/AppNavigator.tsx +++ b/app/src/navigation/AppNavigator.tsx @@ -1,13 +1,12 @@ import React from "react"; -import { BrowserRouter, Route, Link } from "react-router-dom"; +import { BrowserRouter, Route, Switch } from "react-router-dom"; -import { NavBar } from "@arkhn/ui"; -import { makeStyles, Theme, createStyles, Typography } from "@material-ui/core"; - -import LanguageSelect from "../components/LanguageSelect"; -import { ReactComponent as Logo } from "../assets/img/arkhn-logo.svg"; +import { makeStyles, Theme, createStyles } from "@material-ui/core"; import AVCTableViewer from "../screens/AVCTableViewer"; import PatientForm from "../screens/PatientForm"; +import PrivateRoute from "./Private"; +import Login from "screens/Login"; +import NavigationBar from "components/NavigationBar"; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -15,19 +14,6 @@ const useStyles = makeStyles((theme: Theme) => paddingTop: theme.spacing(8), marginTop: theme.spacing(4), }, - logo: { - height: 27, - width: 21, - marginRight: 16, - }, - link: { - display: "flex", - textDecoration: "none", - width: "fit-content", - }, - titleContainer: { - flexGrow: 1, - }, }) ); @@ -36,31 +22,19 @@ const AppNavigator: React.FC<{}> = () => { return ( <> - -
- - - - AVC Forms - - -
- - - } - /> +
- - - - - - - - - + + + + + + + + + + +
diff --git a/app/src/navigation/Private.tsx b/app/src/navigation/Private.tsx new file mode 100644 index 0000000..441ad0a --- /dev/null +++ b/app/src/navigation/Private.tsx @@ -0,0 +1,39 @@ +import React, { useEffect } from "react"; +import { Route } from "react-router"; +import { Redirect } from "react-router-dom"; +import { getTokens } from "utils/tokenManager"; +import { useAppSelector, useAppDispatch } from "state/store"; +import { login } from "state/user"; + +type Props = React.ComponentProps; + +const PrivateRoute: React.FC = (props) => { + const { access, refresh, username } = getTokens(); + const dispatch = useAppDispatch(); + const { user } = useAppSelector((state) => state); + + useEffect(() => { + if (!user && access && refresh && username) { + dispatch(login({ access, refresh, username })); + } + }, [user, dispatch, access, refresh, username]); + + if (!access || !refresh || !username) { + return ( + ( + + )} + /> + ); + } + + return ; +}; + +export default PrivateRoute; diff --git a/app/src/screens/AVCTableViewer.tsx b/app/src/screens/AVCTableViewer.tsx index 82318de..8887cd3 100644 --- a/app/src/screens/AVCTableViewer.tsx +++ b/app/src/screens/AVCTableViewer.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import clsx from "clsx"; import { useHistory } from "react-router-dom"; import { v4 as uuid } from "uuid"; @@ -9,8 +9,9 @@ import { jsonToCSV } from "react-papaparse"; import { useAppSelector, useAppDispatch } from "state/store"; import { createPatientData, - deletePatientEntry, PatientData, + getPatientsThunk, + deletePatientEntryThunk, } from "state/patientFormSlice"; import { formatPatientDataForExport } from "utils/formUtils"; @@ -18,6 +19,7 @@ import { Container, Fab, makeStyles, Paper } from "@material-ui/core"; import TableViewer from "components/TableViewer"; import CSVUploadButton from "components/CSVUploadButton"; import CSVExportButton from "components/CSVExportButton"; +import Dialog from "components/Dialog"; import { Add, Delete } from "@material-ui/icons"; @@ -52,6 +54,8 @@ const AVCTableViewer: React.FC<{}> = () => { const dispatch = useAppDispatch(); const { t } = useTranslation(); const [selectedRowIds, setSelectedRowIds] = useState([]); + const [patientIdsToDelete, setPatientIdsToDelete] = useState([]); + const [isDialogOpen, setDialogOpen] = useState(false); const { data, columns } = useAppSelector((state) => ({ data: state.patientForm.patients, columns: state.patientForm.patientColumnData, @@ -59,22 +63,30 @@ const AVCTableViewer: React.FC<{}> = () => { const onAddPatientClick = () => { const newPatient = createPatientData(uuid()); - history.push(`/patient_form`, newPatient); + history.push(`/patient_form`, { patient: newPatient, creation: true }); }; const onEditPatient = (patientId: string) => { const patient = data.find((item) => item.id === patientId); - patient && history.push(`/patient_form`, patient); + patient && history.push(`/patient_form`, { patient, creation: false }); }; - const onDeletePatient = (patientId: string) => { - dispatch(deletePatientEntry([patientId])); - setSelectedRowIds(selectedRowIds.filter((id) => id !== patientId)); + const openDialog = (patientId?: string) => { + if (patientId) { + setPatientIdsToDelete([patientId]); + } else { + setPatientIdsToDelete(selectedRowIds); + } + setDialogOpen(true); }; - const onDeleteSelection = () => { - dispatch(deletePatientEntry(selectedRowIds)); - setSelectedRowIds([]); + const onDelete = () => { + dispatch(deletePatientEntryThunk(patientIdsToDelete)); + setSelectedRowIds( + selectedRowIds.filter((id) => !patientIdsToDelete.includes(id)) + ); + setPatientIdsToDelete([]); + setDialogOpen(false); }; const exportToCsv = (optionIndex: number) => { @@ -107,6 +119,10 @@ const AVCTableViewer: React.FC<{}> = () => { fileDownload(csv, "patientForms.csv"); }; + useEffect(() => { + dispatch(getPatientsThunk()); + }, [dispatch]); + return (
@@ -132,7 +148,7 @@ const AVCTableViewer: React.FC<{}> = () => { openDialog()} > @@ -142,12 +158,21 @@ const AVCTableViewer: React.FC<{}> = () => { + setDialogOpen(false)} + onAgree={onDelete} + /> ); }; diff --git a/app/src/screens/Login.tsx b/app/src/screens/Login.tsx new file mode 100644 index 0000000..7fa0ee0 --- /dev/null +++ b/app/src/screens/Login.tsx @@ -0,0 +1,60 @@ +import React, { useEffect } from "react"; +import { FormBuilder } from "@arkhn/ui"; +import { FormInputProperty } from "@arkhn/ui/lib/Form/InputTypes"; +import { Button, CircularProgress, Container } from "@material-ui/core"; +import { loginThunk } from "state/user"; +import { useAppDispatch, useAppSelector } from "state/store"; +import { useHistory } from "react-router-dom"; +import { useTranslation } from "react-i18next"; + +type LoginData = { + username: string; + password: string; +}; + +const Login: React.FC<{}> = () => { + const dispatch = useAppDispatch(); + const history = useHistory(); + const { user } = useAppSelector((state) => state); + const { t } = useTranslation(); + const properties: FormInputProperty[] = [ + { + type: "text", + name: "username", + label: "Login", + validationRules: { required: t("requiredField") }, + }, + { + type: "text", + name: "password", + password: true, + label: "Password", + validationRules: { required: t("requiredField") }, + }, + ]; + + useEffect(() => { + if (user && user.access) { + history.push("/avc_viewer"); + } + }, [user, history]); + + const onSubmit = (data: LoginData) => { + dispatch(loginThunk(data)); + }; + + return ( + + + properties={properties} + formId="login-form" + submit={onSubmit} + /> + + + ); +}; + +export default Login; diff --git a/app/src/screens/PatientForm.tsx b/app/src/screens/PatientForm.tsx index 123ab8b..a779c03 100644 --- a/app/src/screens/PatientForm.tsx +++ b/app/src/screens/PatientForm.tsx @@ -12,7 +12,11 @@ import { useLocation, useHistory } from "react-router-dom"; import { useForm, SubmitHandler, Controller } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useAppDispatch } from "state/store"; -import { PatientData, setPatientEntry } from "state/patientFormSlice"; +import { + PatientData, + editPatientEntryThunk, + setPatientEntriesThunk, +} from "state/patientFormSlice"; import DateInput from "components/inputCards/DateInput"; import TextInput from "components/inputCards/TextInput"; @@ -45,8 +49,8 @@ const PatientForm: React.FC<{}> = () => { const history = useHistory(); const dispatch = useAppDispatch(); const { t } = useTranslation(); - const location = useLocation(); - const patient = location.state; + const location = useLocation<{ patient: PatientData; creation: boolean }>(); + const { patient, creation } = location.state; const { register, handleSubmit, @@ -86,7 +90,11 @@ const PatientForm: React.FC<{}> = () => { } const onSubmit: SubmitHandler = (data) => { - dispatch(setPatientEntry({ ...data, id: patient.id })); + if (creation) { + dispatch(setPatientEntriesThunk([{ ...data, id: patient.id }])); + } else { + dispatch(editPatientEntryThunk({ ...data, id: patient.id })); + } history.push("/avc_viewer"); }; @@ -599,6 +607,9 @@ const PatientForm: React.FC<{}> = () => { render={({ ...props }) => ( = () => { title={t("firstImagingASPECTSScore")} name="firstImagingASPECTSScore" type="number" + disabled={watch("firstImagingType")?.includes( + ImagingType.notPerformed + )} inputRef={register({ min: { value: 0, message: t("noNegativeNumber") }, max: { value: 10, message: t("noMoreThan", { max: 10 }) }, @@ -783,6 +797,7 @@ const PatientForm: React.FC<{}> = () => { render={({ ...props }) => ( = () => { render={({ ...props }) => ( = () => { title={t("followingImagingASPECTSScore")} name="followingImagingASPECTSScore" type="number" + disabled={watch("followingImaging")?.includes( + ImagingType.notPerformed + )} inputRef={register({ min: { value: 0, message: t("noNegativeNumber") }, max: { value: 10, message: t("noMoreThan", { max: 10 }) }, diff --git a/app/src/services/api.ts b/app/src/services/api.ts new file mode 100644 index 0000000..176632a --- /dev/null +++ b/app/src/services/api.ts @@ -0,0 +1,36 @@ +import axios from "axios"; +import { ACCES_TOKEN, API_URL } from "../constants"; +import { refreshToken, deleteTokens } from "../utils/tokenManager"; + +const api = axios.create({ + baseURL: API_URL, +}); + +api.interceptors.request.use((config) => { + const token = localStorage.getItem(ACCES_TOKEN); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +api.interceptors.response.use( + (response) => { + return response; + }, + async function (error) { + const originalRequest = error.config; + if (error.response.status === 401) { + const success = await refreshToken(); + if (!success) { + deleteTokens(); + return Promise.reject(error); + } + return api(originalRequest); + } else { + return Promise.reject(error); + } + } +); + +export default api; diff --git a/app/src/services/auth.ts b/app/src/services/auth.ts new file mode 100644 index 0000000..7f22f15 --- /dev/null +++ b/app/src/services/auth.ts @@ -0,0 +1,105 @@ +import { deleteTokens } from "../utils/tokenManager"; +import { PatientData } from "state/patientFormSlice"; +import { + formatPatientDataForExport, + formatPatientDataForImport, +} from "utils/formUtils"; +import api from "./api"; + +export const login = async ( + username: string, + password: string +): Promise<{ username: string; access: string; refresh: string } | null> => { + try { + const authTokenResponse = await api.post<{ + access: string; + refresh: string; + }>("token/", { username, password }); + + const { access, refresh } = authTokenResponse.data; + + if (access && refresh) { + return { access, refresh, username }; + } else { + return null; + } + } catch (error) { + return null; + } +}; + +export const logout = () => { + deleteTokens(); +}; + +export const getPatients = async () => { + const patientsResponse = await api.get<{ + count: number; + results: { data: { [dataKey: string]: string }; code: string }[]; + }>("patients/"); + const { count, results } = patientsResponse.data; + + if (count > 0) { + const patients: PatientData[] = results.map((res) => ({ + ...formatPatientDataForImport(res.data), + id: res.code, + })); + + return patients; + } else { + return []; + } +}; + +export const addPatient = async (patient: PatientData): Promise => { + const addPatientResponse = await api.post("patients/", { + data: formatPatientDataForExport()(patient), + }); + + return addPatientResponse.status === 201; +}; + +const getPatientUrl = async (patientId: string): Promise => { + const patientsResponse = await api.get<{ + count: number; + results: { + data: { [dataKey: string]: string }; + code: string; + url: string; + }[]; + }>("patients/"); + const { results } = patientsResponse.data; + const patient = results.find((r) => r.code === patientId); + if (patient) { + return patient.url; + } else { + return null; + } +}; + +export const deletePatient = async (patientId: string): Promise => { + const patientUrl = await getPatientUrl(patientId); + if (patientUrl) { + await api.delete(patientUrl); + return true; + } else { + return false; + } +}; + +export const editPatient = async (patient: PatientData): Promise => { + const patientUrl = await getPatientUrl(patient.id); + + if (patientUrl) { + try { + await api.put(patientUrl, { + data: formatPatientDataForExport()(patient), + }); + return true; + } catch (error) { + return false; + } + } + + return false; +}; diff --git a/app/src/state/fakePatientData.json b/app/src/state/fakePatientData.json deleted file mode 100644 index 1dd8992..0000000 --- a/app/src/state/fakePatientData.json +++ /dev/null @@ -1 +0,0 @@ -[{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}] diff --git a/app/src/state/notifSlice.ts b/app/src/state/notifSlice.ts new file mode 100644 index 0000000..14965c9 --- /dev/null +++ b/app/src/state/notifSlice.ts @@ -0,0 +1,64 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { OptionsObject } from "notistack"; + +type Notification = { + key: string; + message: string; + options?: OptionsObject; + dismissed?: boolean; +}; + +interface NotifState { + notifications: Notification[]; +} + +const initialState: NotifState = { + notifications: [], +}; + +const notifSlice = createSlice({ + name: "notif", + initialState, + reducers: { + enqueueSnackbar: ( + state: NotifState, + action: PayloadAction<{ + key?: string; + message: string; + options?: OptionsObject; + dismissed?: boolean; + }> + ) => { + const notif = action.payload; + state.notifications.push({ + ...notif, + key: notif.key || (new Date().getTime() + Math.random()).toString(), + }); + }, + closeSnackbar: ( + state: NotifState, + action: PayloadAction + ) => { + const key = action.payload; + const dismissAll = !key; + state.notifications = state.notifications.map((notif) => + dismissAll || notif.key === key + ? { ...notif, dismissed: true } + : { ...notif } + ); + }, + removeSnackbar: (state: NotifState, action: PayloadAction) => { + const key = action.payload; + state.notifications = state.notifications.filter( + (notif) => notif.key !== key + ); + }, + }, +}); + +export default notifSlice.reducer; +export const { + closeSnackbar, + enqueueSnackbar, + removeSnackbar, +} = notifSlice.actions; diff --git a/app/src/state/patientFormSlice.ts b/app/src/state/patientFormSlice.ts index 81f2d05..e1cc0d8 100644 --- a/app/src/state/patientFormSlice.ts +++ b/app/src/state/patientFormSlice.ts @@ -1,6 +1,4 @@ -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import data from "./fakePatientData.json"; -import { v4 as uuid } from "uuid"; +import { createSlice, PayloadAction, createAsyncThunk } from "@reduxjs/toolkit"; import { IdRegionEnum, PatientSex, @@ -17,6 +15,14 @@ import { ThrombolyticTreatmentType, mTICIScore, } from "../utils/enums"; +import { RootState } from "./store"; +import { + getPatients, + addPatient, + deletePatient, + editPatient, +} from "services/auth"; +import { enqueueSnackbar } from "./notifSlice"; export type PatientData = { firstName: string | null; @@ -80,80 +86,13 @@ export type PatientColumnData = { export type PatientFormState = { patients: PatientData[]; patientColumnData: PatientColumnData; + loading: boolean; + requestId?: string; }; const initialState: PatientFormState = { - patients: data.map((item) => ({ - ...item, - firstName: "Pierre", - lastName: "Dupont", - IPP: "123456", - finalmTICIScore: mTICIScore._0, - initialmTiciScore: mTICIScore["2c"], - age: 4, - sex: PatientSex.male, - areaResident: true, - regionId: IdRegionEnum["FR-OCCEWEST"], - id: uuid(), - symptomsOnsetDate: new Date(), - previousMRS: 4, - comorbilities: [Comorbilities.cerebrovascularAccident], - riskFactor: [RiskFactors.atrialFibrillation, RiskFactors.dyslipidemia], - diastolicBloodPressure: 3, - systolicBloodPressure: 14, - bloodGlucose: 24, - INR: 42, - priorAnticoagulationTherapy: true, - anticoagulationType: [AnticoagulationType.apixaban], - priorAntiplateletTherapy: true, - antiplateletType: [ - AntiplateletType.acetylsalicylic_acid, - AntiplateletType.clopidogrel, - ], - hospitalArrivalDate: new Date(), - initialNIHSS: 3, - diagnostic: AVCDiagnostic.intracranialHemorrhage, - firstImagingType: [ImagingType.notPerformed], - firstImagingDate: new Date(), - firstImagingASPECTSScore: 3, - affectedVessels: [ - AffectedVesselType.leftAnteriorCerebralArtery, - AffectedVesselType.leftPosteriorCerebralArtery, - ], - administeredReperfusionTreatment: [ - ReperfusionTreatmentType.cerebralArteryThrombolysisByIntravenousInfusion, - ], - IVThrombolysisDate: new Date(), - thrombolyticTreatmentType: ThrombolyticTreatmentType.alteplase, - EVTTransfert: false, - OtherEVTCenterDate: new Date(), - followingImaging: [ - ImagingType.arteriography, - ImagingType.brainMRIAndBrainStem, - ImagingType.carotidArteryDopplerAssessment, - ], - followingImagingDate: new Date(), - followingImagingASPECTSScore: 4, - NIHSSPrearteriography: 12, - arterialPunctureDate: new Date(), - arteriographyAffectedVessel: [ - AffectedVesselType.leftVertebralArtery, - AffectedVesselType.rightInternalCarotidArtery, - AffectedVesselType.rightPosteriorCerebralArtery, - ], - - EVTModality: EVTModalityType.mechanicalThrombectomy, - deviceModel: "machin truc", - numberOfPasses: 4, - balloonUse: true, - stent: StentType.none, - newTerritoriesEmbolization: true, - revascularizationOrEOPDate: new Date(), - neuroImagingUnder36Hrs: true, - sich: false, - threeMonthsMRS: 6, - deathDate: new Date(), - })), + loading: false, + patients: [], patientColumnData: [ { dataKey: "firstName", @@ -352,6 +291,80 @@ export const createPatientData = (patientId: string): PatientData => ({ deathDate: null, }); +const getPatientsThunk = createAsyncThunk< + PatientData[], + void, + { state: RootState } +>("patientForm/getPatients", async () => { + return await getPatients(); +}); + +const deletePatientEntryThunk = createAsyncThunk< + void, + string[], + { state: RootState } +>("patientForm/deletePatient", async (patientIds, { dispatch }) => { + const deletePromises = patientIds.map(deletePatient); + const deleteResults = await Promise.all(deletePromises); + if (!deleteResults.includes(false)) { + dispatch(patientFormSlice.actions.deletePatientEntry(patientIds)); + dispatch( + enqueueSnackbar({ + message: "patientDeleteSuccess", + options: { variant: "success" }, + }) + ); + } +}); + +const setPatientEntriesThunk = createAsyncThunk< + void, + PatientData[], + { state: RootState } +>("patientForm/setPatientEntry", async (patients, { dispatch }) => { + const addPromises = patients.map(addPatient); + const addResults = await Promise.all(addPromises); + + if (!addResults.includes(false)) { + dispatch(getPatientsThunk()); + dispatch( + enqueueSnackbar({ + message: "patientAddSuccess", + options: { variant: "success" }, + }) + ); + } else { + dispatch( + enqueueSnackbar({ + message: "patientAddFailure", + options: { variant: "error" }, + }) + ); + } +}); + +const editPatientEntryThunk = createAsyncThunk< + void, + PatientData, + { state: RootState } +>("patientForm/editPatientEntry", async (patient, { dispatch }) => { + const editResult = await editPatient(patient); + + if (editResult) { + dispatch(getPatientsThunk()); + dispatch( + enqueueSnackbar({ + message: "editSuccess", + options: { variant: "success" }, + }) + ); + } else { + dispatch( + enqueueSnackbar({ message: "editFailure", options: { variant: "error" } }) + ); + } +}); + const patientFormSlice = createSlice({ name: "patientForm", initialState, @@ -387,11 +400,23 @@ const patientFormSlice = createSlice({ ); }, }, + extraReducers: (builder) => { + builder.addCase(getPatientsThunk.pending, (state, { meta }) => { + state.loading = true; + state.requestId = meta.requestId; + }); + builder.addCase(getPatientsThunk.fulfilled, (state, { payload, meta }) => { + state.patients = payload; + state.loading = state.requestId !== meta.requestId; + }); + }, }); -export const { - setPatientEntry, - deletePatientEntry, - importPatientData, -} = patientFormSlice.actions; +export const { setPatientEntry } = patientFormSlice.actions; +export { + getPatientsThunk, + setPatientEntriesThunk, + deletePatientEntryThunk, + editPatientEntryThunk, +}; export default patientFormSlice.reducer; diff --git a/app/src/state/store.ts b/app/src/state/store.ts index c9ba1de..b076446 100644 --- a/app/src/state/store.ts +++ b/app/src/state/store.ts @@ -5,9 +5,11 @@ import { } from "@reduxjs/toolkit"; import { useDispatch, TypedUseSelectorHook, useSelector } from "react-redux"; -import patientForm from "./patientFormSlice"; +import patientForm from "state/patientFormSlice"; +import user from "state/user"; +import notif from "state/notifSlice"; -const rootReducer = combineReducers({ patientForm }); +const rootReducer = combineReducers({ patientForm, user, notif }); const store = configureStore({ reducer: rootReducer, diff --git a/app/src/state/user.ts b/app/src/state/user.ts new file mode 100644 index 0000000..98c7ca7 --- /dev/null +++ b/app/src/state/user.ts @@ -0,0 +1,89 @@ +import { + createAction, + createSlice, + PayloadAction, + createAsyncThunk, +} from "@reduxjs/toolkit"; +import { login as apiLogin, logout as apiLogout } from "services/auth"; +import { setTokens } from "utils/tokenManager"; +import { enqueueSnackbar } from "./notifSlice"; + +export type UserState = null | { + username?: string; + refresh?: string; + access?: string; + requestId?: string; + loading?: boolean; +}; + +const initialState: UserState = null; + +/** + * Logout action is defined outside of the userSlice because it is being used by all reducers + */ +const logout = createAction("LOGOUT"); + +/** + * + */ +const loginThunk = createAsyncThunk< + void, + { username: string; password: string }, + { state: UserState; rejectValue: void } +>( + "user/login", + async ({ password, username }, { dispatch, rejectWithValue }) => { + const userCredentials = await apiLogin(username, password); + if (userCredentials) { + setTokens(userCredentials); + dispatch(userSlice.actions.login(userCredentials)); + } else { + dispatch( + enqueueSnackbar({ + message: "authenticationError", + options: { variant: "error" }, + }) + ); + rejectWithValue(); + } + } +); + +const userSlice = createSlice({ + name: "user", + initialState: initialState as UserState, + reducers: { + login: (state: UserState, action: PayloadAction) => { + return { ...state, ...action.payload }; + }, + }, + extraReducers: (builder) => { + builder.addCase(logout, () => { + apiLogout(); + return initialState; + }); + builder.addCase(loginThunk.pending, (state, { meta }) => { + return { + ...state, + loading: true, + requestId: meta.requestId, + }; + }); + builder.addCase(loginThunk.fulfilled, (state, { meta, payload }) => { + return { + ...state, + loading: meta.requestId !== state?.requestId, + }; + }); + builder.addCase(loginThunk.rejected, (state, { meta }) => { + return { + ...state, + loading: meta.requestId !== state?.requestId, + }; + }); + }, +}); + +export default userSlice.reducer; +export { loginThunk, logout }; +export const { login } = userSlice.actions; diff --git a/app/src/utils/tokenManager.ts b/app/src/utils/tokenManager.ts new file mode 100644 index 0000000..eb84d19 --- /dev/null +++ b/app/src/utils/tokenManager.ts @@ -0,0 +1,55 @@ +import { + ACCES_TOKEN, + REFRESH_TOKEN, + AUTH_API_URL, + USERNAME_KEY, +} from "../constants"; + +export const getTokens = () => ({ + access: localStorage.getItem(ACCES_TOKEN), + refresh: localStorage.getItem(REFRESH_TOKEN), + username: localStorage.getItem(USERNAME_KEY), +}); + +export const setTokens = ({ + access, + refresh, + username, +}: { + access?: string; + refresh?: string; + username?: string; +}) => { + access && localStorage.setItem(ACCES_TOKEN, access); + refresh && localStorage.setItem(REFRESH_TOKEN, refresh); + username && localStorage.setItem(USERNAME_KEY, username); +}; + +export const deleteTokens = () => { + localStorage.removeItem(ACCES_TOKEN); + localStorage.removeItem(REFRESH_TOKEN); + localStorage.removeItem(USERNAME_KEY); +}; + +export const refreshToken = async (): Promise => { + const { refresh } = getTokens(); + + if (refresh) { + const data = new FormData(); + data.set("refresh", refresh); + + const accessTokenRequest = new Request(`${AUTH_API_URL}refresh/`, { + method: "POST", + body: data, + }); + const refreshResponse = await fetch(accessTokenRequest); + + if (refreshResponse.status === 200) { + const { access } = await refreshResponse.json(); + setTokens({ access }); + return true; + } + } + + return false; +}; diff --git a/app/yarn.lock b/app/yarn.lock index 6eddd80..8c992c9 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -2,16 +2,21 @@ # yarn lockfile v1 -"@arkhn/ui@^1.7.4": - version "1.7.7" - resolved "https://registry.yarnpkg.com/@arkhn/ui/-/ui-1.7.7.tgz#20e86131d67b118cba4736f7a6c0ca44a631547d" - integrity sha512-WiwZKfsxyVLywPJ0L+7DAPDGQX71rua18Y2IApW3JUDSTYFnT4ug+BN5H+91HDQy5mh7xkLX+Kg0mrxUjJsddw== +"@arkhn/ui@^1.9.2": + version "1.9.2" + resolved "https://registry.yarnpkg.com/@arkhn/ui/-/ui-1.9.2.tgz#4ddf7d7c1e8fb7d4c345435254009bce969c4bfb" + integrity sha512-1J63Kh2B+Eh1gtlUzc3TJYxG1iZcBcos0vNv7sDcAYdR5RaQdW1j5nduPttl5hn4U28oyWR/RUUIUxr+W/Z+wg== dependencies: + "@date-io/date-fns" "1.x" "@material-ui/core" "^4.11.0" "@material-ui/icons" "^4.9.1" + "@material-ui/lab" "^4.0.0-alpha.56" + "@material-ui/pickers" "^3.2.10" + date-fns "^2.16.1" material-ui-popup-state "^1.6.1" react-beautiful-dnd "^13.0.0" react-draggable "^4.4.3" + react-hook-form "^6.11.3" react-modal "^3.11.2" react-virtualized "^9.22.1" @@ -2617,6 +2622,13 @@ axe-core@^4.0.2: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.1.0.tgz#93d395e6262ecdde5cb52a5d06533d0a0c7bb4cd" integrity sha512-9atDIOTDLsWL+1GbBec6omflaT5Cxh88J0GtJtGfCVIXpI02rXHkju59W5mMqWa7eiC5OR168v3TK3kUKBW98g== +axios@^0.21.0: + version "0.21.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.0.tgz#26df088803a2350dff2c27f96fef99fe49442aca" + integrity sha512-fmkJBknJKoZwem3/IKSSLpkdNXZeBu5Q7GA/aRsr2btgrptmSCxi2oFjZHqGdK9DoTil9PIHlPIZw2EcRJXRvw== + dependencies: + follow-redirects "^1.10.0" + axobject-query@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be" @@ -3391,7 +3403,7 @@ clone-deep@^4.0.1: kind-of "^6.0.2" shallow-clone "^3.0.0" -clsx@^1.0.2, clsx@^1.0.4: +clsx@^1.0.2, clsx@^1.0.4, clsx@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188" integrity sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA== @@ -5232,7 +5244,7 @@ flush-write-stream@^1.0.0: inherits "^2.0.3" readable-stream "^2.3.6" -follow-redirects@^1.0.0: +follow-redirects@^1.0.0, follow-redirects@^1.10.0: version "1.13.0" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.0.tgz#b42e8d93a2a7eea5ed88633676d6597bc8e384db" integrity sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA== @@ -7861,6 +7873,14 @@ normalize-url@^3.0.0: resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559" integrity sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg== +notistack@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/notistack/-/notistack-1.0.2.tgz#8f5ad3f6f027e3756cdd5cdb63e3142bb99c38f0" + integrity sha512-CWdh8JTpbdpy9TE7Bvc3fbY/BryWozpXoKVyU6VseouD9akZ0DJACghMJEoc1C3j3smH8CLf1UBvFXCxhESshg== + dependencies: + clsx "^1.1.0" + hoist-non-react-statics "^3.3.0" + npm-run-path@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" @@ -9418,6 +9438,11 @@ react-error-overlay@^6.0.8: resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.8.tgz#474ed11d04fc6bda3af643447d85e9127ed6b5de" integrity sha512-HvPuUQnLp5H7TouGq3kzBeioJmXms1wHy9EGjz2OURWBp4qZO6AfGEcnxts1D/CbwPLRAgTMPCEgYhA3sEM4vw== +react-hook-form@^6.11.3: + version "6.12.2" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-6.12.2.tgz#aa67f47da4730a7bce44768583bdf5b749eed521" + integrity sha512-O72E2DXyk7djFqyy6eYi5yESGweKe0CNHHPS0Mx4JazpLbE4Ox+66ldZ23f0J5ZN/krEjDWRD+hUfg5Shvfhtw== + react-hook-form@^6.9.5: version "6.11.3" resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-6.11.3.tgz#7de409d4e100452ac0d3255780b3af3d3027d87a" diff --git a/docker-compose.yml b/docker-compose.yml index 1013c73..8869b08 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,41 +1,40 @@ version: "3.8" services: - db: - image: postgres:13.1 - env_file: - - ./.env - volumes: - - pg_data:/var/lib/postgresql/data - api: build: ./api + restart: always command: uwsgi --ini avc_forms/uwsgi.ini env_file: - - ./.env + - ./api/.env depends_on: - db volumes: - - api_data:/api + - api_data:/tmp app: build: ./app - env_file: - - ./.env volumes: - app_data:/app + db: + image: postgres:13.1 + env_file: + - ./api/.env + volumes: + - pg_data:/var/lib/postgresql/data + nginx: image: nginx:1.19.5 restart: always env_file: - - ./.env + - ./nginx/.env ports: - - "8080:8080" + - "${HOST_PORT:-8080}:${NGINX_PORT:-8080}" volumes: - - api_data:/api - - app_data:/app - - ./nginx/templates:/etc/nginx/templates + - api_data:/api + - app_data:/app + - ./nginx/templates:/etc/nginx/templates depends_on: - api - app diff --git a/nginx/.env b/nginx/.env new file mode 100644 index 0000000..0fc1800 --- /dev/null +++ b/nginx/.env @@ -0,0 +1,2 @@ +NGINX_PORT=${NGINX_PORT:-8080} +NGINX_HOST=${NGINX_HOST:-localhost} diff --git a/nginx/templates/nginx.conf.template b/nginx/templates/nginx.conf.template index 0eb4b60..6c232a3 100644 --- a/nginx/templates/nginx.conf.template +++ b/nginx/templates/nginx.conf.template @@ -1,5 +1,5 @@ upstream django { - server unix:/api/avc_forms/avc_forms.sock; + server unix:/api/avc_forms.sock; } # configuration of the server @@ -12,7 +12,7 @@ server { client_max_body_size 75M; location /django_static { - alias /api/avc_forms/django_static; + alias /api/django_static; } location /api { @@ -21,7 +21,7 @@ server { } location / { - root /app; + root /app/build; index index.html index.htm; } }