Merge pull request 'merge experimental' (#1) from experimental into master

Reviewed-on: #1
This commit is contained in:
Julian Lobbes 2023-08-17 16:16:50 +02:00
commit ac019c20c2
210 changed files with 4535 additions and 7149 deletions

2
.dockerignore Normal file
View File

@ -0,0 +1,2 @@
**/.venv/
**/__pycache__/

12
.gitignore vendored
View File

@ -1,17 +1,19 @@
# Secrets
/.env
# Locally installed environments
/.venv/
/app/.venv/
/node_modules/
# Local development database
# Local development data
/.postgres/
/.gotify/
# Cache files and directories
**/*.pyc
**/__pycache__/
/.parcel-cache/
# Bundled files
/dist/
/app/static/dist/
# Latex compiled files
**/*.aux

View File

@ -1,7 +1,5 @@
{
  "plugins": {
    "postcss-import": {},
    "tailwindcss/nesting": {},
    "tailwindcss": {},
  }
"plugins": {
"tailwindcss": {},
}
}

10
Caddyfile Normal file
View File

@ -0,0 +1,10 @@
:8000 {
handle * {
reverse_proxy * medwings-django:8000
}
log {
output stderr
format console
}
}

View File

@ -1,57 +1,89 @@
# MEDWings
Mobile Early Deterioration Warning System.
Medwings is the *Mobile Early Deterioration Warning System*.
It is a proof of concept for a remote patient monitoring system, designed for use by elevated-risk patients
outside of direct medical supervision, such as at home or on the go.
# MEWS
The application utilizes smart medical devices to access vitals data gathered by users remotely.
Data is aggregated, and a clinical early warning score is calculated based on the readings.
The following vital signs need to be recorded for a MEWS calculation:
The Medwings application is split into different modules, each handling a specific responsibility:
* Heart Rate
* SPO2
* Blood Pressure
* Body Temperature
* Respiration Rate
- [core](./app/core/README.md): global files and configuration
- [authentication](./app/authentication/README.md): user creation and login/logout management
- [medwings](./app/medwings/README.md): everything related to vitals data and MEWS calculation
- [gotify](./app/gotify/README.md): interfaces to the notification server
- [withings](./app/withings/README.md): interfaces to the Withings API
A detailed explanation and formula [can be found here](https://www.mdcalc.com/calc/1875/modified-early-warning-score-mews-clinical-deterioration#evidence).
You can read more about each module and its functionality in each section mentioned above.
# Handheld Devices
## Development
We have procured the following devices for vitals data measurement:
### Sensitive Configuration Data
* [Withings Scanwatch](https://www.withings.com/de/en/scanwatch)
* Heart Rate, SPO2
* [Withings Thermo](https://www.withings.com/de/en/thermo)
* Body Surface Temperature
* [WIthings BPM Core](https://www.withings.com/de/en/bpm-core)
* Blood Pressure
To avoid leaking sensitive configuration data, such as database passwords or API keys, all such values are stored in the
`.env`-file.
## API Access
Prior to running the application, you must create a file called `.env` in the project root.
The file contains the following environment variables:
Data is gathered by taking measurements using the devices, either actively (BP sleeve, thermometer) or passively (smartwatch).
The devices are connected to the Withings mobile app.
The mobile app then regularly pushes gathered data to the Withings cloud.
```conf
TIMEZONE=Europe/Berlin
PG_NAME=medwings
PG_USER=medwings
PG_PASSWORD=secret
PG_HOST=medwings-postgres
PG_PORT=5432
GOTIFY_USER=gotify
GOTIFY_PASSWORD=secret
GOTIFY_HOST=medwings-gotify
GOTIFY_PUBLIC_URL=https://notifications.medwings.example.com/
WITHINGS_CLIENT_ID=abc123myClientId
WITHINGS_CLIENT_SECRET=abc123myClientSecret
```
The Withings Dev Free plan allows for 120 API requests per minute.
Access to vitals data is available through the [Withings API](https://developer.withings.com/).
You should set the values of the following variables:
A detailed [API integration guide](https://developer.withings.com/developer-guide/v3/integration-guide/public-health-data-api/public-health-data-api-overview/),
as well as an [API reference guide](https://developer.withings.com/api-reference) are available online.
| variable | description | value |
|----------|-------------|-------|
| PG_PASSWORD | password for the PostgreSQL admin user | a random string of 32 characters |
| GOTIFY_USER | name of the Gotify admin user | a random string of 32 characters |
| GOTIFY_PASSWORD | password for the Gotify admin user | a random string of 32 characters |
| GOTIFY_PUBLIC_URL | URL where your public Gotify server can be reached | this depends on your deployment environment |
| WITHINGS_CLIENT_ID | Your Withings API client id | see [Withings API](./app/withings/README.md#api-access) |
| WITHINGS_CLIENT_SECRET | Your Withings API client secret | see [Withings API](./app/withings/README.md#api-access) |
# Development
To start the development compose-stack, run the following command:
### Starting the dev environment
Once your environment vars are set up, you can run the backend and webserver, by running the following command:
```bash
sudo docker-compose -f development.docker-compose.yml up --force-recreate --build --remove-orphans
```
Run [alembic](https://alembic.sqlalchemy.org/en/latest/) database migrations inside the running container like so:
In a separate terminal, you should also start the frontend asset bundler:
```bash
sudo docker exec -w /app/backend/database -it backend alembic upgrade head
npm run start
```
To run commands inside the backend container, run the following:
It supports file watching and automatic recompilation of the project's CSS and JS bundle.
#### Running commands inside the container
To run commands inside the django container, run the following:
```bash
sudo docker exec -it backend <command>
sudo docker exec -itu django medwings-django <command>
```
Run database migrations inside the running container like so:
```bash
sudo docker exec -itu medwings-django python manage.py migrate
```
To enter django's interactive shell, run:
```bash
sudo docker exec -itu medwings-django python manage.py shell
```

View File

@ -0,0 +1,6 @@
# Authentication
This module handles user management, such as user login and user registration.
This also includes handling the oauth2 token retrieval in the background, which is necessary to retrieve
data from the Withings Public Health Cloud on behalf of users.

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class AuthenticationConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'authentication'

View File

@ -0,0 +1,12 @@
from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User
class CustomUserCreationForm(UserCreationForm):
first_name = forms.CharField(required=True)
last_name = forms.CharField(required=True)
email = forms.EmailField(required=True)
class Meta:
model = User
fields = ("username", "first_name", "last_name", "email", "password1", "password2")

View File

@ -0,0 +1,64 @@
{% extends 'core/base.html' %}
{% load static %}
{% load widget_tweaks %}
{% block title %}
Medwings | Log In
{% endblock title %}
{% block content %}
<div class="flex flex-col justify-center items-center gap-2 py-4">
<h1>Log In</h1>
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
<fieldset class="flex flex-col gap-4 items-center max-w-sm">
<legend>Please enter your login details</legend>
{% if form.non_field_errors %}
<div class="flex flex-col gap-2 status-message error">
{% for error in form.non_field_errors %}
<p class="error">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
<div class="flex flex-col gap-8">
<div class="flex flex-col">
{% render_field form.username|add_error_class:"error" %}
<label for="{{ form.username.id_for_label }}">
{% render_field form.username.label %}
</label>
{% if form.username.errors %}
<div class="flex flex-col gap-2 status-message error">
{% for error in form.username.errors %}
<p class="error">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
<div class="flex flex-col">
{% render_field form.password|add_error_class:"error" %}
<label for="{{ form.password.id_for_label }}">
{% render_field form.password.label %}
</label>
{% if form.password.errors %}
<div class="flex flex-col gap-2 status-message error">
{% for error in form.password.errors %}
<p class="error">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
</div>
<input class="max-w-64" type="submit" value="Log In">
</fieldset>
</form>
{# Assumes you set up the password_reset view in your URLconf #}
{% comment %}<p><a href="{% url 'password_reset' %}">Lost password?</a></p>{% endcomment %}
</div>
{% endblock content %}

View File

@ -0,0 +1,4 @@
{% extends 'core/base.html' %}
{% block content %}
<p>You have been logged out.</p>
{% endblock content %}

View File

@ -0,0 +1,145 @@
{% extends 'core/base.html' %}
{% load static %}
{% load widget_tweaks %}
{% block title %}
Medwings | Sign Up
{% endblock title %}
{% block content %}
<div class="flex flex-col justify-center items-center gap-2 py-4 mx-4 max-w-4xl">
<h2>Register</h2>
<form method="post">
{% csrf_token %}
<fieldset class="flex flex-col gap-4 items-center max-w-sm sm:max-w-lg">
<legend>Please enter your profile information</legend>
{% if form.non_field_errors %}
<div class="flex flex-col gap-2 status-message error">
{% for error in form.non_field_errors %}
<p class="error">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
<div class="flex flex-col sm:flex-none sm:grid sm:grid-cols-2 gap-6">
<div class="flex flex-col">
{% render_field user_form.first_name|add_error_class:"error" %}
<label for="{{ user_form.first_name.id_for_label }}">
{% render_field user_form.first_name.label %}
</label>
{% if user_form.first_name.errors %}
<div class="flex flex-col gap-2 status-message error">
{% for error in user_form.first_name.errors %}
<p class="error">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
<div class="flex flex-col">
{% render_field user_form.last_name|add_error_class:"error" %}
<label for="{{ user_form.last_name.id_for_label }}">
{% render_field user_form.last_name.label %}
</label>
{% if user_form.last_name.errors %}
<div class="flex flex-col gap-2 status-message error">
{% for error in user_form.last_name.errors %}
<p class="error">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
<div class="flex flex-col">
{% render_field profile_form.date_of_birth|add_error_class:"error" %}
<label for="{{ profile_form.date_of_birth.id_for_label }}">
{% render_field profile_form.date_of_birth.label %}
</label>
{% if profile_form.date_of_birth.errors %}
<div class="flex flex-col gap-2 status-message error">
{% for error in profile_form.date_of_birth.errors %}
<p class="error">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
<div class="flex flex-col">
{% render_field profile_form.sex|add_error_class:"error" %}
<label for="{{ profile_form.sex.id_for_label }}">
{% render_field profile_form.sex.label %}
</label>
{% if profile_form.sex.errors %}
<div class="flex flex-col gap-2 status-message error">
{% for error in profile_form.sex.errors %}
<p class="error">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
<div class="flex flex-col">
{% render_field user_form.email|add_error_class:"error" %}
<label for="{{ user_form.email.id_for_label }}">
{% render_field user_form.email.label %}
</label>
{% if user_form.email.errors %}
<div class="flex flex-col gap-2 status-message error">
{% for error in user_form.email.errors %}
<p class="error">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
<div class="flex flex-col">
{% render_field user_form.username|add_error_class:"error" %}
<label for="{{ user_form.username.id_for_label }}">
{% render_field user_form.username.label %}
</label>
{% if user_form.username.errors %}
<div class="flex flex-col gap-2 status-message error">
{% for error in user_form.username.errors %}
<p class="error">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
<div class="flex flex-col">
{% render_field user_form.password1|add_error_class:"error" %}
<label for="{{ user_form.password1.id_for_label }}">
{% render_field user_form.password1.label %}
</label>
{% if user_form.password1.errors %}
<div class="flex flex-col gap-2 status-message error">
{% for error in user_form.password1.errors %}
<p class="error">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
<div class="flex flex-col">
{% render_field user_form.password2|add_error_class:"error" %}
<label for="{{ user_form.password2.id_for_label }}">
{% render_field user_form.password2.label %}
</label>
{% if user_form.password2.errors %}
<div class="flex flex-col gap-2 status-message error">
{% for error in user_form.password2.errors %}
<p class="error">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
</div>
<input class="max-w-64" type="submit" value="Register">
</fieldset>
</form>
</div>
{% endblock content %}

View File

@ -0,0 +1,19 @@
{% extends 'core/base.html' %}
{% load static %}
{% block title %}
Medwings | Sign Up
{% endblock title %}
{% block content %}
<div class="flex flex-col justify-center items-center gap-8 py-4 mx-4 max-w-lg">
<h2>Register</h2>
<p>To finalize your registration and receive regular notifications, please take the following steps:</p>
<div class="flex call-to-action-box">
<ol class="font-semibold p-0 sm:p-4">
<li>Install the Gotify App on your smartphone, available on <a href="https://f-droid.org/en/packages/com.github.gotify/">F-Droid</a> or on the <a href="https://play.google.com/store/apps/details?id=com.github.gotify">Google Play Store</a>.</li>
<li>Open the app, and connect to our notification server <code class="text-xs sm:text-sm">{{ gotify_public_url }}</code> and log in using your Medwings username and password.</li>
</ol>
</div>
<p>All set! You can now <a href="{% url 'login' %}">log in</a> to view your data or <a href="{% url 'mews-init' %}">take your first MEWS measurement</a>.
</div>
{% endblock content %}

View File

@ -0,0 +1,21 @@
{% extends 'core/base.html' %}
{% load static %}
{% block title %}
Medwings | Sign Up
{% endblock title %}
{% block content %}
<div class="flex flex-col justify-center items-center gap-2 py-4 mx-4 max-w-4xl">
<h2>Register</h2>
<p>
Something something glad you're signing up.
</p>
<div class="flex flex-col gap-2 items-center call-to-action-box">
<p class="font-semibold">To get started, please allow us to access your health data</p>
<a class="btn text-lg" href="{{ auth_url }}">Link Withings Account</a>
</div>
<p>
Something something why this is necessary.
</p>
</div>
{% endblock content %}

View File

@ -0,0 +1,12 @@
from django.urls import path
from django.contrib.auth import views as auth_views
from . import views
urlpatterns = [
path("login/", auth_views.LoginView.as_view(template_name="authentication/login.html"), name="login"),
path("logout/", auth_views.LogoutView.as_view(template_name="authentication/logout.html"), name="logout"),
path("register/init/", views.register_init, name="register-init"),
path("register/continue/", views.register_continue, name="register-continue"),
path("register/finalize/", views.register_finalize, name="register-finalize"),
]

136
app/authentication/views.py Normal file
View File

@ -0,0 +1,136 @@
from urllib.parse import urlencode
from uuid import uuid4
import logging
from django.shortcuts import redirect, render
from django.conf import settings
from django.urls import reverse
from django.core.exceptions import PermissionDenied
from django.http import HttpResponseBadRequest
from django.utils.dateparse import parse_datetime
import withings.api
import withings.models
import gotify.api
import gotify.models
from medwings.forms import ProfileForm
from .forms import CustomUserCreationForm
def register_init(request):
if request.user.is_authenticated:
raise PermissionDenied('You are already registered and logged in.')
# Generate a unique token and save it for later
request.session.flush()
registration_sequence_token = str(uuid4())
request.session['registration_sequence_token'] = registration_sequence_token
auth_url_base = 'https://account.withings.com/oauth2_user/authorize2'
auth_url_params = {
'response_type': 'code',
'client_id': settings.WITHINGS_CONFIG['CLIENT_ID'],
'scope': 'user.metrics,user.activity',
'redirect_uri': request.build_absolute_uri(reverse('register-continue')),
'state': registration_sequence_token
}
auth_url = f"{auth_url_base}?{urlencode(auth_url_params)}"
context = {
"auth_url": auth_url
}
return render(request, 'authentication/register-init.html', context)
def register_continue(request):
if request.user.is_authenticated:
raise PermissionDenied('You are already registered and logged in.')
authorization_code = request.GET.get('code')
authorization_state = request.GET.get('state')
if not authorization_code:
return HttpResponseBadRequest()
if not authorization_state:
return HttpResponseBadRequest()
if not request.session.get('registration_sequence_token', None) == authorization_state:
return HttpResponseBadRequest()
if request.method == 'GET':
# Fetch access and refresh tokens and save them to session storage
redirect_uri = request.build_absolute_uri(reverse('register-continue'))
response_data = withings.api.fetch_initial_tokens(authorization_code, redirect_uri)
if response_data['status'] != 0:
return HttpResponseBadRequest()
withings.api.save_tokens_to_session(request, response_data)
user_form = CustomUserCreationForm()
profile_form = ProfileForm()
else:
user_form = CustomUserCreationForm(request.POST)
profile_form = ProfileForm(request.POST)
if user_form.is_valid() and profile_form.is_valid():
user = user_form.save(commit=False)
profile = profile_form.save(commit=False)
profile.user = user
user_password = request.POST.get('password1')
gotify_user_info = gotify.api.create_user(user.username, user_password)
gotify_app_info = gotify.api.create_application(user.username, user_password)
gotify.api.upload_application_picture(user.username, user_password, gotify_app_info['id'])
gotify_user = gotify.models.GotifyUser(
user=user,
id=gotify_user_info['id']
)
gotify_app = gotify.models.GotifyApplication(
user=gotify_user,
id=gotify_app_info['id'],
token=gotify_app_info['token']
)
withings_api_account = withings.models.ApiAccount(
user=user,
userid=request.session.get('withings_userid')
)
withings_access_token = withings.models.AccessToken(
account=withings_api_account,
value=request.session.get('withings_access_token'),
expires=parse_datetime(request.session.get('withings_access_token_expiry'))
)
withings_refresh_token = withings.models.RefreshToken(
account=withings_api_account,
value=request.session.get('withings_refresh_token'),
expires=parse_datetime(request.session.get('withings_refresh_token_expiry'))
)
for instance in [
user, profile,
gotify_user, gotify_app,
withings_api_account, withings_access_token, withings_refresh_token
]:
instance.save()
request.session.flush()
withings_api_account.update_records()
return redirect('register-finalize')
context = {
'user_form': user_form,
'profile_form': profile_form,
}
return render(request, 'authentication/register-continue.html', context)
def register_finalize(request):
if request.user.is_authenticated:
raise PermissionDenied('You are already registered and logged in.')
context = {
"gotify_public_url": settings.GOTIFY_CONFIG['PUBLIC_URL']
}
return render(request, 'authentication/register-finalize.html', context)

26
app/core/README.md Normal file
View File

@ -0,0 +1,26 @@
# Core
This module is the main entrypoint for the application.
It provides global configuration variables and shared files.
## Templates
Shared template files are defined in [./templates/core/](./templates/core/).
These include the base template, which all others inherit from, as well as the navigation bar and footer of the site.
## Static files
Static files are stored in [/app/static/](../app/static/) and served here by the webserver.
**Warning:** files stored in this directory are publicly available.
You can access static files in your Django template as follows:
```html
{% load static %}
...
<img src="{% static 'medwings/images/devices/withings-scanwatch.webp' %}" alt="A Withings Scanwatch.">
```

16
app/core/asgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
ASGI config for core project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
application = get_asgi_application()

142
app/core/settings.py Normal file
View File

@ -0,0 +1,142 @@
"""
Django settings for core project.
Generated by 'django-admin startproject' using Django 4.2.3.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.2/ref/settings/
"""
from pathlib import Path
from os import getenv
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-s^q)z%f-7=1h5b00ctki2*-w=#3!k@p-#sq%=eajw)x2axx-e5'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = [
'localhost',
'127.0.0.1',
'192.168.2.141'
]
# Application definition
INSTALLED_APPS = [
'widget_tweaks',
'core',
'authentication',
'medwings',
'withings',
'gotify',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'core.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'core.wsgi.application'
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': getenv('PG_NAME', 'medwings'),
'USER': getenv('PG_USER', 'medwings'),
'PASSWORD': getenv('PG_PASSWORD', 'medwings'),
'HOST': getenv('PG_HOST', 'medwings-postgres'),
'PORT': getenv('PG_PORT', '5432'),
}
}
# Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
LOGIN_REDIRECT_URL = 'home'
LOGIN_URL = 'login'
LOGOUT_REDIRECT_URL = 'home'
# Internationalization
# https://docs.djangoproject.com/en/4.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = getenv('TZ', 'Europe/Berlin')
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = 'static/'
STATICFILES_DIRS = [
BASE_DIR / 'static',
]
# Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
WITHINGS_CONFIG = {
'CLIENT_ID': getenv('WITHINGS_CLIENT_ID'),
'CLIENT_SECRET': getenv('WITHINGS_CLIENT_SECRET'),
'ENDPOINT_URL_OAUTH2': 'https://wbsapi.withings.net/v2/oauth2'
}
GOTIFY_CONFIG = {
'USERNAME': getenv('GOTIFY_USER'),
'PASSWORD': getenv('GOTIFY_PASSWORD'),
'HOST': getenv('GOTIFY_HOST'),
'PUBLIC_URL': getenv('GOTIFY_PUBLIC_URL')
}

View File

@ -0,0 +1,28 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Medwings{% endblock title %}</title>
<meta name="description" content="{% block description %}An early warning system for medical deterioration.{% endblock description %}">
<meta name="author" content="{% block author %}Julian Lobbes{% endblock author %}">
<link rel="stylesheet" href="{% static 'dist/main.css' %}">
<link rel="icon" type="image/png" href="{% static 'medwings/images/logo/medwings-logo.png' %}">
</head>
<body class="global">
<header class="global">
{% include 'core/navbar.html' %}
</header>
<main class="global">
{% block content %}
{% endblock content %}
</main>
<footer class="global">
{% include 'core/footer.html' %}
</footer>
<script src="{% static 'dist/main.js' %}"></script>
</body>
</html>

View File

@ -0,0 +1,3 @@
<div class="flex gap-4 justify-center items-center p-2 bg-primary-100 text-sm text-background">
<p>&copy; 2023 Julian Lobbes</p>
</div>

File diff suppressed because one or more lines are too long

7
app/core/urls.py Normal file
View File

@ -0,0 +1,7 @@
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path('', include('medwings.urls')),
path('auth/', include('authentication.urls')),
]

16
app/core/wsgi.py Normal file
View File

@ -0,0 +1,16 @@
"""
WSGI config for core project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
application = get_wsgi_application()

8
app/gotify/README.md Normal file
View File

@ -0,0 +1,8 @@
# Gotify
This module provides interfaces for a [Gotify Notfication Server](https://gotify.net/),
which allows the application to send push notifications to user's phones.
Gotify is a separate microservice which allows users to subscribe to a notification channel.
The Gotify instance is created as a separate Docker container, and Medwings handles the creation
of users, applications and messages behind the scenes.

75
app/gotify/api.py Normal file
View File

@ -0,0 +1,75 @@
import requests
from requests.auth import HTTPBasicAuth
from django.conf import settings
from .models import GotifyUser
def create_user(username: str, password: str) -> dict:
"""Creates a user on the Gotify instance.
:param username: The name of the user to be created.
:param password: The password of the user to be created.
"""
data = {
'admin': False,
'name': username,
'pass': password
}
response = requests.post(
url=f"http://{settings.GOTIFY_CONFIG['HOST']}/user",
auth=HTTPBasicAuth(
settings.GOTIFY_CONFIG['USERNAME'],
settings.GOTIFY_CONFIG['PASSWORD'],
),
json=data
)
if response is not None:
response.raise_for_status()
return response.json()
def create_application(username: str, password: str) -> dict:
"""Creates an application on the Gotify instance.
:param username: The user for whom an application will be created.
:param password: The user's password.
"""
data = {
'defaultPriority': 6,
'description': 'A remote patient health monitoring system.',
'name': 'Medwings'
}
response = requests.post(
url=f"http://{settings.GOTIFY_CONFIG['HOST']}/application",
auth=HTTPBasicAuth(username, password),
json=data
)
if response is not None:
response.raise_for_status()
return response.json()
def upload_application_picture(username: str, password: str, app_id: int):
with open('/app/static/medwings/images/logo/medwings-logo.png', 'rb') as image_file:
response = requests.post(
url=f"http://{settings.GOTIFY_CONFIG['HOST']}/application/{app_id}/image",
auth=HTTPBasicAuth(
username,
password,
),
files={
'file': image_file
}
)
if response is not None:
response.raise_for_status()

6
app/gotify/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class GotifyConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'gotify'

View File

@ -0,0 +1,24 @@
from django.urls import reverse
from django.core.management.base import BaseCommand
from gotify import models
class Command(BaseCommand):
help = 'Notifies all users to take a vitals measurement now.'
def handle(self, *args, **kwargs):
applications = models.GotifyApplication.objects.all()
for application in applications:
message_text = f"Hello {application.user.user.first_name}. Please take your next vitals measurement now."
message_title = "Medwings Measurement Prompt"
url = reverse('mews-init')
message = models.GotifyMessage(
message=message_text,
title=message_title,
url=url
)
application.send_message(message)

View File

@ -0,0 +1,32 @@
# Generated by Django 4.2.3 on 2023-07-30 21:15
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='GotifyUser',
fields=[
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)),
('id', models.PositiveIntegerField(verbose_name='Gotify User ID')),
],
),
migrations.CreateModel(
name='GotifyApplication',
fields=[
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='gotify.gotifyuser')),
('id', models.PositiveIntegerField(verbose_name='Gotify Application ID')),
('token', models.CharField(max_length=256, verbose_name='Gotify Application Token')),
],
),
]

View File

79
app/gotify/models.py Normal file
View File

@ -0,0 +1,79 @@
from enum import Enum
import json
from django.db import models
from django.contrib.auth.models import User
from django.conf import settings
import requests
class GotifyUser(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True)
id = models.PositiveIntegerField(verbose_name="Gotify User ID")
class GotifyMessageType(Enum):
plain = "text/plain"
markdown = "text/markdown"
class GotifyMessage():
type: GotifyMessageType
message: str
title: str | None
priority: int
url: str | None
def __init__(self, message: str, title: str | None = None, priority: int = 5, url: str | None = None, type: str = 'text/plain'):
self.message = message
self.title = title
if not 0 <= priority <= 10:
raise ValueError(f"Priority must be 0 to 10.")
self.priority = priority
self.url = url
self.type = GotifyMessageType(type)
def as_dict(self) -> dict:
obj = {
"message": self.message,
"priority": self.priority,
"extras": {
"client::display": {
"contentType": self.type.value
}
}
}
if self.title:
obj["title"] = self.title
if self.url:
obj["extras"]["client::notification"] = {
"click": {
"url": self.url
}
}
return obj
class GotifyApplication(models.Model):
user = models.OneToOneField(GotifyUser, on_delete=models.CASCADE, primary_key=True)
id = models.PositiveIntegerField(verbose_name="Gotify Application ID")
token = models.CharField(max_length=256, verbose_name="Gotify Application Token")
def send_message(self, message: GotifyMessage):
endpoint_url = f"http://{settings.GOTIFY_CONFIG['HOST']}/message"
headers = {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"
}
response = requests.post(
url=endpoint_url,
headers=headers,
data=json.dumps(message.as_dict())
)
if response is not None:
response.raise_for_status()

22
app/manage.py Executable file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

16
app/medwings/README.md Normal file
View File

@ -0,0 +1,16 @@
# Medwings
This module provides handles vitals data, MEWS calculations and the user interfaces for capturing and viewing the data.
## MEWS
The following vital signs need to be recorded for a MEWS calculation:
* Heart Rate
* SPO2
* Blood Pressure
* Body Temperature
* Respiration Rate
A detailed explanation and formula
[can be found here](https://www.mdcalc.com/calc/1875/modified-early-warning-score-mews-clinical-deterioration#evidence).

0
app/medwings/__init__.py Normal file
View File

6
app/medwings/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class MedwingsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'medwings'

18
app/medwings/forms.py Normal file
View File

@ -0,0 +1,18 @@
from django import forms
from medwings.models import Profile, RespirationScoreRecord
class ProfileForm(forms.ModelForm):
class Meta:
model = Profile
fields = ['date_of_birth', 'sex']
widgets = {
'date_of_birth': forms.DateInput(attrs={'type': 'date'}),
}
class RespirationScoreForm(forms.ModelForm):
class Meta:
model = RespirationScoreRecord
fields = ['value_severity']

View File

@ -0,0 +1,87 @@
# Generated by Django 4.2.3 on 2023-07-30 21:15
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import medwings.validators
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='BloodPressureRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('recorded', models.DateTimeField(unique=True, validators=[medwings.validators.AbstractRecordValidator.recorded], verbose_name='Time at which measurement was taken')),
('value_systolic_mmhg', models.PositiveIntegerField(validators=[medwings.validators.BloodPressureRecordValidator.value_systolic_mmhg], verbose_name='Systolic Blood Pressure (mmhg)')),
('value_diastolic_mmhg', models.PositiveIntegerField(validators=[medwings.validators.BloodPressureRecordValidator.value_diastolic_mmhg], verbose_name='Diastolic Blood Pressure (mmhg)')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='BodyTempRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('recorded', models.DateTimeField(unique=True, validators=[medwings.validators.AbstractRecordValidator.recorded], verbose_name='Time at which measurement was taken')),
('value_celsius', models.DecimalField(decimal_places=2, max_digits=5, unique=True, validators=[medwings.validators.BodyTempRecordValidator.value_celsius], verbose_name='Body Temperature (°C)')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='HeartRateRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('recorded', models.DateTimeField(unique=True, validators=[medwings.validators.AbstractRecordValidator.recorded], verbose_name='Time at which measurement was taken')),
('value_bpm', models.PositiveIntegerField(validators=[medwings.validators.HeartRateRecordValidator.value_bpm], verbose_name='Heart Rate (bpm)')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Profile',
fields=[
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)),
('date_of_birth', models.DateField(validators=[medwings.validators.PersonValidator.date_of_birth], verbose_name='Date of birth')),
('sex', models.CharField(choices=[('F', 'Female'), ('M', 'Male')], max_length=1, verbose_name='Sex assigned at birth')),
],
),
migrations.CreateModel(
name='Spo2LevelRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('recorded', models.DateTimeField(unique=True, validators=[medwings.validators.AbstractRecordValidator.recorded], verbose_name='Time at which measurement was taken')),
('value_percent', models.PositiveIntegerField(validators=[medwings.validators.Spo2LevelRecordValidator.value_percent], verbose_name='SPO2 (%)')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='RespirationScoreRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('recorded', models.DateTimeField(unique=True, validators=[medwings.validators.AbstractRecordValidator.recorded], verbose_name='Time at which measurement was taken')),
('value_severity', models.PositiveIntegerField(choices=[(0, 'No shortness of breath'), (1, 'A little shortness of breath'), (2, 'Severe shortness of breath')], validators=[medwings.validators.RespirationScoreRecordValidator.value_severity], verbose_name='Shortness Of Breath Severity')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='MewsRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('recorded', models.DateTimeField(unique=True, validators=[medwings.validators.AbstractRecordValidator.recorded], verbose_name='Time at which measurement was calculated')),
('value_n', models.PositiveIntegerField(validators=[medwings.validators.MewsRecordValidator.value_n], verbose_name='Modified Early Warning Score')),
('blood_pressure_record', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='medwings.bloodpressurerecord')),
('body_temp_record', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='medwings.bodytemprecord')),
('heart_rate_record', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='medwings.heartraterecord')),
('respiration_score_record', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='medwings.respirationscorerecord')),
('spo2_level_record', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='medwings.spo2levelrecord')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

123
app/medwings/models.py Normal file
View File

@ -0,0 +1,123 @@
from django.db import models
from django.contrib.auth.models import User
from . import validators
class Profile(models.Model):
SEX_FEMALE = "F"
SEX_MALE = "M"
SEX_CHOICES = [
(SEX_FEMALE, "Female"),
(SEX_MALE, "Male")
]
user = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True)
date_of_birth = models.DateField(validators=[validators.PersonValidator.date_of_birth], verbose_name="Date of birth")
sex = models.CharField(max_length=1, choices=SEX_CHOICES, verbose_name="Sex assigned at birth")
class BloodPressureRecord(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
recorded = models.DateTimeField(validators=[validators.BloodPressureRecordValidator.recorded], unique=True, verbose_name="Time at which measurement was taken")
value_systolic_mmhg = models.PositiveIntegerField(validators=[validators.BloodPressureRecordValidator.value_systolic_mmhg], verbose_name="Systolic Blood Pressure (mmhg)")
value_diastolic_mmhg = models.PositiveIntegerField(validators=[validators.BloodPressureRecordValidator.value_diastolic_mmhg], verbose_name="Diastolic Blood Pressure (mmhg)")
class BodyTempRecord(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
recorded = models.DateTimeField(validators=[validators.BodyTempRecordValidator.recorded], unique=True, verbose_name="Time at which measurement was taken")
value_celsius = models.DecimalField(max_digits=5, decimal_places=2, validators=[validators.BodyTempRecordValidator.value_celsius], unique=True, verbose_name="Body Temperature (\u00B0C)")
class HeartRateRecord(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
recorded = models.DateTimeField(validators=[validators.HeartRateRecordValidator.recorded], unique=True, verbose_name="Time at which measurement was taken")
value_bpm = models.PositiveIntegerField(validators=[validators.HeartRateRecordValidator.value_bpm], verbose_name="Heart Rate (bpm)")
class Spo2LevelRecord(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
recorded = models.DateTimeField(validators=[validators.Spo2LevelRecordValidator.recorded], unique=True, verbose_name="Time at which measurement was taken")
value_percent = models.PositiveIntegerField(validators=[validators.Spo2LevelRecordValidator.value_percent], verbose_name="SPO2 (\u0025)")
class RespirationScoreRecord(models.Model):
SEVERITY_NONE = 0
SEVERITY_LOW = 1
SEVERITY_HIGH = 2
SEVERITY_CHOICES = [
(SEVERITY_NONE, "No shortness of breath"),
(SEVERITY_LOW, "A little shortness of breath"),
(SEVERITY_HIGH, "Severe shortness of breath"),
]
user = models.ForeignKey(User, on_delete=models.CASCADE)
recorded = models.DateTimeField(validators=[validators.RespirationScoreRecordValidator.recorded], unique=True, verbose_name="Time at which measurement was taken")
value_severity = models.PositiveIntegerField(choices=SEVERITY_CHOICES, validators=[validators.RespirationScoreRecordValidator.value_severity], verbose_name="Shortness Of Breath Severity")
class MewsRecord(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
recorded = models.DateTimeField(validators=[validators.MewsRecordValidator.recorded], unique=True, verbose_name="Time at which measurement was calculated")
value_n = models.PositiveIntegerField(validators=[validators.MewsRecordValidator.value_n], verbose_name="Modified Early Warning Score")
blood_pressure_record = models.ForeignKey(BloodPressureRecord, on_delete=models.CASCADE)
body_temp_record = models.ForeignKey(BodyTempRecord, on_delete=models.CASCADE)
heart_rate_record = models.ForeignKey(HeartRateRecord, on_delete=models.CASCADE)
spo2_level_record = models.ForeignKey(Spo2LevelRecord, on_delete=models.CASCADE)
respiration_score_record = models.ForeignKey(RespirationScoreRecord, on_delete=models.CASCADE)
@staticmethod
def calculate_mews_value(
blood_pressure_systolic_value_mmhg: int,
body_temp_value_celsius: float,
heart_rate_value_bpm: int,
spo2_level_value_percent: int,
respiration_score_value_severity: int,
):
mews_value = 0
if blood_pressure_systolic_value_mmhg <= 70:
mews_value += 3
elif blood_pressure_systolic_value_mmhg <= 80:
mews_value += 2
elif blood_pressure_systolic_value_mmhg <= 100:
mews_value += 1
elif blood_pressure_systolic_value_mmhg < 200:
mews_value += 0
else:
mews_value += 2
if heart_rate_value_bpm < 40:
mews_value += 2
elif heart_rate_value_bpm <= 50:
mews_value += 1
elif heart_rate_value_bpm <= 100:
mews_value += 0
elif heart_rate_value_bpm <= 110:
mews_value += 1
elif heart_rate_value_bpm < 130:
mews_value += 2
else:
mews_value += 3
if respiration_score_value_severity == 1:
mews_value += 1
elif respiration_score_value_severity == 2:
mews_value += 2
if spo2_level_value_percent < 90:
mews_value += 2
elif spo2_level_value_percent < 95:
mews_value += 1
if body_temp_value_celsius < 35:
mews_value += 2
elif body_temp_value_celsius <= 38.4:
mews_value += 0
else:
mews_value += 2
return mews_value

View File

@ -0,0 +1,146 @@
{% extends 'core/base.html' %}
{% load static %}
{% block title %}
Medwings | Dashboard
{% endblock title %}
{% block content %}
<div class="flex flex-col justify-center items-center gap-8 py-4 px-8 w-full h-full">
<h1 class="w-full text-center">Dashboard</h1>
<p class="text-secondary font-semibold">This page shows all of your recorded health data.</p>
<div class="flex flex-col justify-center items-center gap-2 p-4 w-full max-h-96">
<h2 class="w-full text-secondary-400 text-center">MEWS</h2>
{% if mews_data %}
<canvas id="mewsChart"></canvas>
{% else %}
<div class="flex flex-col justify-center items-center h-32 bg-primary-200/25 px-16 rounded-lg">
<p class="italic text-center text-primary/25 font-semibold text-lg px-4">There is no data yet</p>
</div>
{% endif %}
</div>
<div class="flex flex-col justify-center items-center gap-2 p-4 w-full max-h-96">
<h2 class="w-full text-secondary-400 text-center">Blood Pressure</h2>
{% if blood_pressure_data %}
<canvas id="bloodPressureChart"></canvas>
{% else %}
<div class="flex flex-col justify-center items-center h-32 bg-primary-200/25 px-16 rounded-lg">
<p class="italic text-center text-primary/25 font-semibold text-lg px-4">There is no data yet</p>
</div>
{% endif %}
</div>
<div class="flex flex-col justify-center items-center gap-2 p-4 w-full max-h-96">
<h2 class="w-full text-secondary-400 text-center">Body Temperature</h2>
{% if body_temp_data %}
<canvas id="bodyTempChart"></canvas>
{% else %}
<div class="flex flex-col justify-center items-center h-32 bg-primary-200/25 px-16 rounded-lg">
<p class="italic text-center text-primary/25 font-semibold text-lg px-4">There is no data yet</p>
</div>
{% endif %}
</div>
<div class="flex flex-col justify-center items-center gap-2 p-4 w-full max-h-96">
<h2 class="w-full text-secondary-400 text-center">Heart Rate</h2>
{% if heart_rate_data %}
<canvas id="heartRateChart"></canvas>
{% else %}
<div class="flex flex-col justify-center items-center h-32 bg-primary-200/25 px-16 rounded-lg">
<p class="italic text-center text-primary/25 font-semibold text-lg px-4">There is no data yet</p>
</div>
{% endif %}
</div>
<div class="flex flex-col justify-center items-center gap-2 p-4 w-full max-h-96">
<h2 class="w-full text-secondary-400 text-center">Blood Oxygenation</h2>
{% if spo2_level_data %}
<canvas id="spo2LevelChart"></canvas>
{% else %}
<div class="flex flex-col justify-center items-center h-32 bg-primary-200/25 px-16 rounded-lg">
<p class="italic text-center text-primary/25 font-semibold text-lg px-4">There is no data yet</p>
</div>
{% endif %}
</div>
<div class="flex flex-col justify-center items-center gap-2 p-4 w-full max-h-96">
<h2 class="w-full text-secondary-400 text-center">Respiration Score</h2>
{% if respiration_score_data %}
<canvas id="respirationScoreChart"></canvas>
{% else %}
<div class="flex flex-col justify-center items-center h-32 bg-primary-200/25 px-16 rounded-lg">
<p class="italic text-center text-primary/25 font-semibold text-lg px-4">There is no data yet</p>
</div>
{% endif %}
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.3.2/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/date-fns@2.30.0/index.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
<script>
async function createChart(id, data, label) {
new Chart(document.getElementById(id), {
type: 'scatter',
data: {
datasets: [{
label: label,
data: data,
backgroundColor: 'rgba(75, 192, 192, 0.2)', // color for point fill
borderColor: 'rgba(75, 192, 192, 1)', // color for point border
pointBackgroundColor: 'rgba(75, 192, 192, 1)', // color for point fill (on hover)
pointBorderColor: '#fff', // color for point border (on hover)
pointBorderWidth: 1, // border width for point (on hover)
}]
},
options: {
scales: {
x: {
type: 'time',
position: 'bottom'
},
y: {
type: 'linear',
position: 'left'
}
}
}
});
}
const mewsData = JSON.parse('{{ mews_data|safe }}');
const bloodPressureData = JSON.parse('{{ blood_pressure_data|safe }}');
const bodyTempData = JSON.parse('{{ body_temp_data|safe }}');
const heartRateData = JSON.parse('{{ heart_rate_data|safe }}');
const spo2LevelData = JSON.parse('{{ spo2_level_data|safe }}');
const respirationScoreData = JSON.parse('{{ respiration_score_data|safe }}');
for (let dataset of [mewsData, bloodPressureData, bodyTempData, heartRateData, spo2LevelData, respirationScoreData]) {
dataset.forEach((data) => {
data.x = new Date(data.x * 1000).toISOString();
});
}
{% if mews_data %}
createChart("mewsChart", mewsData, "Modified Early Warning Score");
{% endif %}
{% if blood_pressure_data %}
createChart("bloodPressureChart", bloodPressureData, "Systolic Blood Pressure (mmHg)");
{% endif %}
{% if body_temp_data %}
createChart("bodyTempChart", bodyTempData, "Body Temperature (°C)");
{% endif %}
{% if heart_rate_data %}
createChart("heartRateChart", heartRateData, "Heart Rate (bpm)");
{% endif %}
{% if spo2_level_data %}
createChart("spo2LevelChart", spo2LevelData, "Blood Oxygenation (%)");
{% endif %}
{% if respiration_score_data %}
createChart("respirationScoreChart", respirationScoreData, "Respiration Score");
{% endif %}
</script>
{% endblock content %}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,210 @@
{% extends 'core/base.html' %}
{% load static %}
{% load widget_tweaks %}
{% block title %}
Medwings | Take a measurement
{% endblock title %}
{% block content %}
<div class="flex flex-col justify-center items-center gap-2 py-4 mx-4 max-w-4xl">
<h1>Record your health status</h1>
<div id="help-div" class="flex flex-col gap-2 items-center justify-center call-to-action-box w-full text-center sm:text-start h-32">
<p class="fadeout font-semibold">Please start measuring your vitals using your devices now.</p>
<p class="fadeout">Your measurement results will be synchronized automatically.</p>
<p class="fadein hidden font-semibold text-success-200">All done! Thank you for taking a measurement.</p>
<a class="btn-outline fadein hidden" href="{% url 'dashboard' %}">Go to Dashboard</a>
</div>
<div id="pageContainer" class="grid grid-cols-3 gap-6 text-xl justify-center items-center w-full p-4 border border-secondary rounded-md overflow-hidden relative">
<div class="font-semibold text-center sm:text-start col-span-2">
<p>Blood Pressure (systolic)</p>
</div>
<div id="bloodPressureLoader" class="loader loader-bar loader--loading"></div>
<div id="bloodPressureValue" class="loader loader-value hidden">
<p><span id="bloodPressureValueNumber"></span> mmHG</p>
</div>
<div class="col-span-3 h-px bg-secondary"></div>
<div class="font-semibold text-center sm:text-start col-span-2">
<p>Body Temperature</p>
</div>
<div id="bodyTempLoader" class="loader loader-bar loader--loading"></div>
<div id="bodyTempValue" class="loader loader-value hidden">
<p><span id="bodyTempValueNumber"></span> °C</p>
</div>
<div class="col-span-3 h-px bg-secondary"></div>
<div class="font-semibold text-center sm:text-start col-span-2">
<p>Heart Rate</p>
</div>
<div id="heartRateLoader" class="loader loader-bar loader--loading"></div>
<div id="heartRateValue" class="loader loader-value hidden">
<p><span id="heartRateValueNumber"></span> bpm</p>
</div>
<div class="col-span-3 h-px bg-secondary"></div>
<div class="font-semibold text-center sm:text-start col-span-2">
<p>Blood Oxygenation</p>
</div>
<div id="spo2Loader" class="loader loader-bar loader--loading"></div>
<div id="spo2Value" class="loader loader-value hidden">
<p><span id="spo2ValueNumber"></span> %</p>
</div>
<div class="col-span-3 h-px bg-secondary"></div>
<div class="font-semibold text-center sm:text-start col-span-2">
<p>Respiration Score</p>
</div>
<div id="respirationScoreLoader" class="loader loader-bar loader--loading"></div>
<div id="respirationScoreValue" class="loader loader-value hidden">
<p><span id="respirationScoreValueNumber"></span></p>
</div>
<div class="col-span-3 h-0.5 bg-secondary"></div>
<div class="font-semibold text-center sm:text-start col-span-2">
<p>MEWS</p>
</div>
<div id="mewsLoader" class="loader loader-bar loader--loading"></div>
<div id="mewsValue" class="loader loader-value hidden">
<p><span id="mewsValueNumber"></span></p>
</div>
</div>
</div>
<script>
const elements = {
"bloodPressure": {
"loader": document.getElementById("bloodPressureLoader"),
"value": document.getElementById("bloodPressureValue"),
"number": document.getElementById("bloodPressureValueNumber")
},
"bodyTemp": {
"loader": document.getElementById("bodyTempLoader"),
"value": document.getElementById("bodyTempValue"),
"number": document.getElementById("bodyTempValueNumber")
},
"heartRate": {
"loader": document.getElementById("heartRateLoader"),
"value": document.getElementById("heartRateValue"),
"number": document.getElementById("heartRateValueNumber")
},
"spo2": {
"loader": document.getElementById("spo2Loader"),
"value": document.getElementById("spo2Value"),
"number": document.getElementById("spo2ValueNumber")
},
"respirationScore": {
"loader": document.getElementById("respirationScoreLoader"),
"value": document.getElementById("respirationScoreValue"),
"number": document.getElementById("respirationScoreValueNumber")
},
"mews": {
"loader": document.getElementById("mewsLoader"),
"value": document.getElementById("mewsValue"),
"number": document.getElementById("mewsValueNumber")
},
}
function showError(message) {
let pageContainerDiv = document.getElementById("pageContainer");
pageContainerDiv.innerHTML = `<div class="col-span-3 flex justify-center items-center status-message error"><p>${message}</p></div>`
}
const pageTimeout = 10 * 60 * 1000;
setTimeout(() => {
if (!fetchingComplete)
showError('Your measurement timed out. Please <a class="underline" href="/mews/init/">start again</a>.')
}, pageTimeout);
let fetchingData = false;
let fetchingComplete = false;
const pollingInterval = 5000;
let currentData = {
"blood_pressure_value": null,
"body_temp_value": null,
"heart_rate_value": null,
"spo2_level_value": null,
"respiration_score_value": null,
"mews_value": null
};
const helpDiv = document.getElementById('help-div');
async function fetchData() {
if (fetchingData || fetchingComplete) return;
fetchingFata = true;
fetch("{% url 'mews-status' %}")
.then(response => {
if (!response.ok) {
throw new Error('There was an error while retrieving your data.');
}
return response.json();
})
.then(data => {
const names = {
"blood_pressure_value": "bloodPressure",
"body_temp_value": "bodyTemp",
"heart_rate_value": "heartRate",
"spo2_level_value": "spo2",
"respiration_score_value": "respirationScore",
"mews_value": "mews"
}
for (let type of Object.keys(names)) {
if (currentData[type] === null && data[type] !== null) {
currentData[type] = data[type];
setTimeout(() => {
loadValue(names[type], data[type])
}, Math.floor(Math.random() * (1500 - 500 + 1)) + 500);
}
}
if (Object.values(currentData).every(value => value !== null)) {
fetchingComplete = true;
helpDiv.classList.add('help-div--changing');
for (let element of document.getElementsByClassName('fadeout')) {
element.addEventListener('animationend', () => {
element.remove();
for (let element of document.getElementsByClassName('fadein')) {
element.classList.remove('hidden');
}
})
}
}
fetchingData = false;
if (!fetchingComplete) {
setTimeout(() => {
fetchData();
}, pollingInterval)
}
})
.catch(error => {
showError(error)
});
}
fetchData();
function loadValue(element, value) {
const loaderDiv = elements[element]["loader"];
const valueDiv = elements[element]["value"];
const numberSpan = elements[element]["number"];
loaderDiv.classList.remove("loader--loading");
loaderDiv.classList.add("loader--finished");
setTimeout(function() {
loaderDiv.classList.remove("loader--finished");
loaderDiv.classList.add("hidden");
valueDiv.classList.remove('hidden');
valueDiv.classList.add('loader--value');
numberSpan.textContent = value;
}, 2000);
}
</script>
{% endblock content %}

View File

@ -0,0 +1,51 @@
{% extends 'core/base.html' %}
{% load static %}
{% load widget_tweaks %}
{% block title %}
Medwings | Take a measurement
{% endblock title %}
{% block content %}
<div class="flex flex-col justify-center items-center gap-6 sm:gap-12 py-4 mx-4 max-w-xl">
<h1>Record your health status</h1>
<div class="flex flex-col justify-center items-center gap-2 call-to-action-box">
<div class="flex justify-center items-center gap-4">
<img class="max-h-24 max-w-24 drop-shadow-lg saturate-50" src="{% static 'medwings/images/devices/withings-bpm-core.webp' %}" alt="A Withings BPM core.">
<img class="max-h-24 max-w-24 drop-shadow-lg saturate-50" src="{% static 'medwings/images/devices/withings-thermo.webp' %}" alt="A Withings Thermo.">
<img class="max-h-24 max-w-24 drop-shadow-lg saturate-50" src="{% static 'medwings/images/devices/withings-scanwatch.webp' %}" alt="A Withings Scanwatch.">
</div>
<p class="text-center sm:text-start font-semibold text-lg">Before you begin, please have your Withings devices ready for taking measurements.</p>
</div>
<form method="post">
{% csrf_token %}
<fieldset class="flex flex-col gap-4 items-center">
<legend>To get started, please answer the following question:</legend>
{% if respiration_score_form.non_field_errors %}
<div class="flex flex-col gap-2 status-message error">
{% for error in respiration_score_form.non_field_errors %}
<p class="error">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
<div class="flex flex-col sm:flex-row gap-x-2">
<label class="font-semibold" for="{{ respiration_score_form.value_severity.id_for_label }}">
Are you experiencing any shortness of breath?
</label>
{% render_field respiration_score_form.value_severity|add_error_class:"error" %}
</div>
{% if respiration_score_form.value_severity.errors %}
<div class="flex flex-col gap-2 status-message error">
{% for error in respiration_score_form.value_severity.errors %}
<p class="error">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
<button class="btn" type="submit">Continue</button>
</fieldset>
</form>
</div>
{% endblock content %}

11
app/medwings/urls.py Normal file
View File

@ -0,0 +1,11 @@
from django.urls import path
from . import views
urlpatterns = [
path("", views.index, name="home"),
path("dashboard/", views.dashboard, name="dashboard"),
path("mews/init/", views.mews_init, name="mews-init"),
path("mews/continue/", views.mews_continue, name="mews-continue"),
path("mews/status/", views.mews_status, name="mews-status"),
]

109
app/medwings/validators.py Normal file
View File

@ -0,0 +1,109 @@
from datetime import date, datetime
from abc import ABC
from django.core.exceptions import ValidationError
class DateValidator:
@staticmethod
def past_date(value: date, name: str = "date"):
if value > date.today():
raise ValidationError(f"The {name} cannot be in the future.")
@staticmethod
def past_datetime(value: datetime, name: str = "timestamp"):
if value > datetime.now():
raise ValidationError(f"The {name} cannot be in the future.")
class PersonValidator:
@staticmethod
def date_of_birth(value):
DateValidator.past_date(value, "date of birth")
class AbstractRecordValidator(ABC):
@staticmethod
def recorded(value):
DateValidator.past_datetime(value, "time when the value was recorded")
class BloodPressureRecordValidator(AbstractRecordValidator):
MIN_VALUE_SYSTOLIC_MMHG = 0
MAX_VALUE_SYSTOLIC_MMHG = 1000
MIN_VALUE_DIASTOLIC_MMHG = 0
MAX_VALUE_DIASTOLIC_MMHG = 1000
@staticmethod
def value_systolic_mmhg(value: int):
if value < BloodPressureRecordValidator.MIN_VALUE_SYSTOLIC_MMHG:
raise ValidationError(f"Systolic Blood Pressure cannot be below {BloodPressureRecordValidator.MIN_VALUE_SYSTOLIC_MMHG}")
if value > BloodPressureRecordValidator.MAX_VALUE_SYSTOLIC_MMHG:
raise ValidationError(f"Systolic Blood Pressure cannot be above {BloodPressureRecordValidator.MAX_VALUE_SYSTOLIC_MMHG}")
@staticmethod
def value_diastolic_mmhg(value: int):
if value < BloodPressureRecordValidator.MIN_VALUE_DIASTOLIC_MMHG:
raise ValidationError(f"Diastolic Blood Pressure cannot be below {BloodPressureRecordValidator.MIN_VALUE_DIASTOLIC_MMHG}")
if value > BloodPressureRecordValidator.MAX_VALUE_DIASTOLIC_MMHG:
raise ValidationError(f"Diastolic Blood Pressure cannot be above {BloodPressureRecordValidator.MAX_VALUE_DIASTOLIC_MMHG}")
class BodyTempRecordValidator(AbstractRecordValidator):
MIN_VALUE_CELSIUS = 0
MAX_VALUE_CELSIUS = 100
@staticmethod
def value_celsius(value: int):
if value < BodyTempRecordValidator.MIN_VALUE_CELSIUS:
raise ValidationError(f"Body Temperature cannot be below {BodyTempRecordValidator.MIN_VALUE_CELSIUS}")
if value > BodyTempRecordValidator.MAX_VALUE_CELSIUS:
raise ValidationError(f"Body Temperature cannot be above {BodyTempRecordValidator.MAX_VALUE_CELSIUS}")
class HeartRateRecordValidator(AbstractRecordValidator):
MIN_VALUE_BPM = 0
MAX_VALUE_BPM = 1000
@staticmethod
def value_bpm(value: int):
if value < HeartRateRecordValidator.MIN_VALUE_BPM:
raise ValidationError(f"Heart Rate cannot be below {HeartRateRecordValidator.MIN_VALUE_BPM}")
if value > HeartRateRecordValidator.MAX_VALUE_BPM:
raise ValidationError(f"Heart Rate cannot be above {HeartRateRecordValidator.MAX_VALUE_BPM}")
class RespirationScoreRecordValidator(AbstractRecordValidator):
MIN_VALUE_SEVERITY = 0
MAX_VALUE_SEVERITY = 2
@staticmethod
def value_severity(value: int):
if value < RespirationScoreRecordValidator.MIN_VALUE_SEVERITY:
raise ValidationError(f"Respiratory Inhibition Severity cannot be below {RespirationScoreRecordValidator.MIN_VALUE_SEVERITY}")
if value > RespirationScoreRecordValidator.MAX_VALUE_SEVERITY:
raise ValidationError(f"Respiratory Inhibition Severity cannot be above {RespirationScoreRecordValidator.MAX_VALUE_SEVERITY}")
class Spo2LevelRecordValidator(AbstractRecordValidator):
MIN_VALUE_PERCENT = 0
MAX_VALUE_PERCENT = 100
@staticmethod
def value_percent(value: int):
if value < Spo2LevelRecordValidator.MIN_VALUE_PERCENT:
raise ValidationError(f"SPO2 cannot be below {Spo2LevelRecordValidator.MIN_VALUE_PERCENT}")
if value > Spo2LevelRecordValidator.MAX_VALUE_PERCENT:
raise ValidationError(f"SPO2 cannot be above {Spo2LevelRecordValidator.MAX_VALUE_PERCENT}")
class MewsRecordValidator(AbstractRecordValidator):
MIN_VALUE_N = 0
MAX_VALUE_N = 100
@staticmethod
def value_n(value: int):
if value < MewsRecordValidator.MIN_VALUE_N:
raise ValidationError(f"MEWS cannot be below {MewsRecordValidator.MIN_VALUE_N}")
if value > MewsRecordValidator.MAX_VALUE_N:
raise ValidationError(f"MEWS cannot be above {MewsRecordValidator.MAX_VALUE_N}")

117
app/medwings/views.py Normal file
View File

@ -0,0 +1,117 @@
from datetime import timedelta
import json
from django.shortcuts import redirect, render
from django.http import HttpResponse, JsonResponse
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods
from django.utils import timezone
from . import models
from . import forms
@require_http_methods(["GET"])
def index(request):
return render(request, 'medwings/index.html')
@login_required
@require_http_methods(["GET"])
def dashboard(request):
mews_records = models.MewsRecord.objects.filter(user=request.user)
blood_pressure_records = models.BloodPressureRecord.objects.filter(user=request.user)
body_temp_records = models.BodyTempRecord.objects.filter(user=request.user)
heart_rate_records = models.HeartRateRecord.objects.filter(user=request.user)
spo2_level_records = models.Spo2LevelRecord.objects.filter(user=request.user)
respiration_score_records = models.RespirationScoreRecord.objects.filter(user=request.user)
context = {
"mews_data": json.dumps([{"x": record.recorded.timestamp(), "y": record.value_n} for record in mews_records]),
"blood_pressure_data": json.dumps([{"x": record.recorded.timestamp(), "y": record.value_systolic_mmhg} for record in blood_pressure_records]),
"body_temp_data": json.dumps([{"x": record.recorded.timestamp(), "y": float(record.value_celsius)} for record in body_temp_records]),
"heart_rate_data": json.dumps([{"x": record.recorded.timestamp(), "y": record.value_bpm} for record in heart_rate_records]),
"spo2_level_data": json.dumps([{"x": record.recorded.timestamp(), "y": record.value_percent} for record in spo2_level_records]),
"respiration_score_data": json.dumps([{"x": record.recorded.timestamp(), "y": record.value_severity} for record in respiration_score_records]),
}
return render(request, 'medwings/dashboard.html', context)
@login_required
@require_http_methods(["GET", "POST"])
def mews_init(request):
if request.method == 'POST':
respiration_score_form = forms.RespirationScoreForm(request.POST)
if respiration_score_form.is_valid():
respiration_score = respiration_score_form.save(commit=False)
respiration_score.recorded = timezone.now()
respiration_score.user = request.user
respiration_score.save()
return redirect('mews-continue')
else:
respiration_score_form = forms.RespirationScoreForm()
context = {
'respiration_score_form': respiration_score_form,
}
return render(request, 'medwings/mews-init.html', context)
@login_required
def mews_continue(request):
return render(request, 'medwings/mews-continue.html')
@require_http_methods(["GET"])
def mews_status(request):
if not request.user.is_authenticated:
return HttpResponse('Unauthorized', status=401)
request.user.apiaccount.update_records()
ten_minutes_ago = timezone.now() - timedelta(minutes=10)
blood_pressure_record = models.BloodPressureRecord.objects.filter(user=request.user).filter(recorded__gte=ten_minutes_ago).order_by('-recorded').first()
body_temp_record = models.BodyTempRecord.objects.filter(user=request.user).filter(recorded__gte=ten_minutes_ago).order_by('-recorded').first()
heart_rate_record = models.HeartRateRecord.objects.filter(user=request.user).filter(recorded__gte=ten_minutes_ago).order_by('-recorded').first()
spo2_level_record = models.Spo2LevelRecord.objects.filter(user=request.user).filter(recorded__gte=ten_minutes_ago).order_by('-recorded').first()
respiration_score_record = models.RespirationScoreRecord.objects.filter(user=request.user).filter(recorded__gte=ten_minutes_ago).order_by('-recorded').first()
data = {
'blood_pressure_value': blood_pressure_record.value_systolic_mmhg if blood_pressure_record else None,
'body_temp_value': body_temp_record.value_celsius if body_temp_record else None,
'heart_rate_value': heart_rate_record.value_bpm if heart_rate_record else None,
'spo2_level_value': spo2_level_record.value_percent if spo2_level_record else None,
'respiration_score_value': respiration_score_record.value_severity if respiration_score_record else None,
'mews_value': None,
}
if (blood_pressure_record
and body_temp_record
and heart_rate_record
and spo2_level_record
and respiration_score_record
):
mews_record = models.MewsRecord(
user=request.user, recorded=timezone.now(),
value_n=models.MewsRecord.calculate_mews_value(
blood_pressure_record.value_systolic_mmhg,
body_temp_record.value_celsius,
heart_rate_record.value_bpm,
spo2_level_record.value_percent,
respiration_score_record.value_severity
),
blood_pressure_record=blood_pressure_record,
body_temp_record=body_temp_record,
heart_rate_record=heart_rate_record,
respiration_score_record=respiration_score_record,
spo2_level_record=spo2_level_record
)
mews_record.save()
data['mews_value'] = mews_record.value_n
return JsonResponse(data)

14
app/requirements.txt Normal file
View File

@ -0,0 +1,14 @@
asgiref==3.7.2
certifi==2023.7.22
charset-normalizer==3.2.0
Django==4.2.3
django-widget-tweaks==1.4.12
djangorestframework==3.14.0
idna==3.4
psycopg==3.1.9
psycopg-binary==3.1.9
pytz==2023.3
requests==2.31.0
sqlparse==0.4.4
typing_extensions==4.7.1
urllib3==2.0.4

View File

Before

Width:  |  Height:  |  Size: 670 KiB

After

Width:  |  Height:  |  Size: 670 KiB

View File

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 146 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,154 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
id="Layer_1"
viewBox="0 0 501.31603 501.31603"
xml:space="preserve"
sodipodi:docname="medwings-logo.svg"
width="501.31601"
height="501.31601"
inkscape:version="1.3 (0e150ed6c4, 2023-07-21)"
inkscape:export-filename="medwings-logo.png"
inkscape:export-xdpi="49.022972"
inkscape:export-ydpi="49.022972"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs9"><linearGradient
id="linearGradient12"
inkscape:collect="always"><stop
style="stop-color:#b90000;stop-opacity:1;"
offset="0"
id="stop12" /><stop
style="stop-color:#ee6464;stop-opacity:1;"
offset="1"
id="stop13" /></linearGradient><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect12"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,8,0,1 @ F,0,0,1,0,8,0,1 @ F,0,0,1,0,8,0,1 @ F,0,0,1,0,8,0,1"
radius="8"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect11"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,8,0,1 @ F,0,0,1,0,8,0,1 @ F,0,0,1,0,8,0,1 @ F,0,0,1,0,8,0,1"
radius="8"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect10"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,8,0,1 @ F,0,0,1,0,8,0,1 @ F,0,0,1,0,8,0,1 @ F,0,0,1,0,8,0,1"
radius="8"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient12"
id="linearGradient13"
x1="114.10844"
y1="427.41153"
x2="402.21704"
y2="163.0332"
gradientUnits="userSpaceOnUse" /></defs><sodipodi:namedview
id="namedview9"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="1.1352357"
inkscape:cx="264.26231"
inkscape:cy="205.24373"
inkscape:window-width="1900"
inkscape:window-height="1004"
inkscape:window-x="10"
inkscape:window-y="38"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1" />&#10; &#10; &#10; &#10; &#10; <g
id="all"
inkscape:label="all"
transform="translate(-5.3419952,-4.8412612)"><g
id="wings"
inkscape:label="wings"
style="fill:#6aa4bf;fill-opacity:1;stroke:#000000;stroke-width:5;stroke-dasharray:none;stroke-opacity:1"
transform="translate(0,48)">&#10; <path
style="fill:#6aa4bf;fill-opacity:1;stroke:#000000;stroke-width:5;stroke-dasharray:none;stroke-opacity:1"
d="m 398.266,86.092 c -25.518,0 -46.104,19.473 -46.104,44.992 v 41.853 l 39.925,33.359 h 16.01 c 13.263,0 23.503,-12.09 23.503,-25.355 v -14.365 h 8.517 c 13.263,0 23.886,-12.398 23.886,-25.661 v -16.01 c 21.95,0 40.155,-14.773 40.155,-38.813 z"
id="path1" />&#10; <path
style="fill:#6aa4bf;fill-opacity:1;stroke:#000000;stroke-width:5;stroke-dasharray:none;stroke-opacity:1"
d="m 113.734,86.092 c 25.518,0 46.104,19.473 46.104,44.992 v 41.853 l -39.925,33.359 h -16.01 C 90.64,206.296 80.4,194.206 80.4,180.941 v -14.365 h -8.517 c -13.263,0 -23.886,-12.398 -23.886,-25.661 v -16.01 c -24.041,0 -40.155,-14.773 -40.155,-38.813 z"
id="path2" />&#10; </g><g
id="heart"
inkscape:label="heart"><path
style="fill:url(#linearGradient13);fill-opacity:1;stroke:#000000;stroke-width:5;stroke-dasharray:none;stroke-opacity:1"
d="m 376.286,188.964 c -31.262,-31.262 -81.949,-31.262 -113.21,0 -0.296,0.296 -3.427,3.427 -8.491,8.491 -3.383,-3.383 -5.422,-5.422 -5.66,-5.66 -31.262,-31.262 -81.948,-31.262 -113.21,0 -31.262,31.262 -31.262,81.949 0,113.21 0.239,0.239 118.871,118.871 118.871,118.871 0,0 121.406,-121.406 121.702,-121.702 31.26,-31.262 31.26,-81.948 -0.002,-113.21 z"
id="path3" /><path
style="fill:#ffffff"
d="m 367.906,244.969 h -15.679 c 0,-17.769 -14.698,-32.174 -32.468,-32.174 v -15.679 c 27.177,10e-4 48.147,21.722 48.147,47.853 z"
id="path7" /></g><g
id="blips"
inkscape:label="blips"
style="stroke:#000000;stroke-width:5;stroke-dasharray:none;stroke-opacity:1">&#10; <path
x="247.64101"
y="86.086998"
style="fill:#36c9a3;stroke:#000000;stroke-width:5;stroke-dasharray:none;stroke-opacity:1"
width="15.679"
height="48.081001"
id="rect7"
inkscape:path-effect="#path-effect12"
sodipodi:type="rect"
d="m 255.64101,86.086998 a 7.8411428,7.8411428 46.172867 0 1 7.679,8 V 126.168 a 8,8 135 0 1 -8,8 7.8411428,7.8411428 46.172867 0 1 -7.679,-8 V 94.086998 a 8,8 135 0 1 8,-8 z" />&#10; <path
x="284.17801"
y="111.923"
transform="matrix(-0.7071,-0.7071,0.7071,-0.7071,407.3551,426.5414)"
style="fill:#36c9a3;stroke:#000000;stroke-width:5.00005;stroke-dasharray:none;stroke-opacity:1"
width="15.679"
height="33.963001"
id="rect8"
inkscape:path-effect="#path-effect11"
sodipodi:type="rect"
d="m 292.17801,111.923 a 7.8411428,7.8411428 46.172867 0 1 7.679,8 v 17.963 a 8,8 135 0 1 -8,8 7.8411428,7.8411428 46.172867 0 1 -7.679,-8 v -17.963 a 8,8 135 0 1 8,-8 z" />&#10; <path
x="202.99899"
y="121.072"
transform="matrix(-0.7071,-0.7071,0.7071,-0.7071,284.3756,375.6156)"
style="fill:#36c9a3;stroke:#000000;stroke-width:5.00005;stroke-dasharray:none;stroke-opacity:1"
width="33.963001"
height="15.679"
id="rect9"
inkscape:path-effect="#path-effect10"
sodipodi:type="rect"
d="m 210.99899,121.072 h 17.963 a 8,8 45 0 1 8,8 7.8411428,7.8411428 136.17287 0 1 -8,7.679 h -17.963 a 8,8 45 0 1 -8,-8 7.8411428,7.8411428 136.17287 0 1 8,-7.679 z" />&#10; </g></g>&#10;</svg>

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@ -0,0 +1,31 @@
<svg width="512mm" height="512mm" viewBox="0 0 512 512" id="hamburger">
<g id="hamburger-all" transform="translate(0,-18.119038)">
<path
style="fill:#010202;fill-opacity:1;stroke:#000000;stroke-width:2.2225;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:15.6;stroke-dasharray:none;stroke-opacity:1"
id="hamburger-top"
width="427.90082"
height="45.731606"
x="46.167351"
y="69.705063"
d="M 69.450684,69.705063 H 450.78484 a 23.283333,23.283333 45 0 1 23.28333,23.283333 22.869615,22.869615 136.04611 0 1 -23.28333,22.448274 H 69.450684 A 23.283333,23.283333 45 0 1 46.167351,92.153335 22.869615,22.869615 136.04611 0 1 69.450684,69.705063 Z"
transform="translate(-4.1177597,40.551704)" />
<path
style="fill:#010202;fill-opacity:1;stroke:#000000;stroke-width:2.2225;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:15.6;stroke-dasharray:none;stroke-opacity:1"
id="hamburger-middle"
width="427.90082"
height="45.731606"
x="46.167351"
y="69.705063"
d="M 69.450684,69.705063 H 450.78484 a 23.283333,23.283333 45 0 1 23.28333,23.283333 22.869615,22.869615 136.04611 0 1 -23.28333,22.448274 H 69.450684 A 23.283333,23.283333 45 0 1 46.167351,92.153335 22.869615,22.869615 136.04611 0 1 69.450684,69.705063 Z"
transform="translate(-4.1177597,181.54817)" />
<path
style="fill:#010202;fill-opacity:1;stroke:#000000;stroke-width:2.2225;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:15.6;stroke-dasharray:none;stroke-opacity:1"
id="hamburger-bottom"
width="427.90082"
height="45.731606"
x="46.167351"
y="69.705063"
d="M 69.450684,69.705063 H 450.78484 a 23.283333,23.283333 45 0 1 23.28333,23.283333 22.869615,22.869615 136.04611 0 1 -23.28333,22.448274 H 69.450684 A 23.283333,23.283333 45 0 1 46.167351,92.153335 22.869615,22.869615 136.04611 0 1 69.450684,69.705063 Z"
transform="translate(-4.1177597,322.54464)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

235
app/withings/README.md Normal file
View File

@ -0,0 +1,235 @@
# Withings
This module provides interfaces used to communicate with the Withings Public API.
## Smart Devices Used
We use the following devices for vitals data measurement:
* [Withings Scanwatch](https://www.withings.com/de/en/scanwatch)
* Heart Rate, SPO2
* [Withings Thermo](https://www.withings.com/de/en/thermo)
* Body Surface Temperature
* [WIthings BPM Core](https://www.withings.com/de/en/bpm-core)
* Blood Pressure
## API Access
Data is gathered by taking measurements using the devices, either actively (BP sleeve, thermometer) or passively (smartwatch).
The devices are connected to the Withings mobile app.
The mobile app then regularly pushes gathered data to the Withings cloud.
The Withings Dev Free plan allows for 120 API requests per minute.
Access to vitals data is available through the [Withings API](https://developer.withings.com/).
A detailed [API integration guide](https://developer.withings.com/developer-guide/v3/integration-guide/public-health-data-api/public-health-data-api-overview/),
as well as an [API reference guide](https://developer.withings.com/api-reference) are available online.
### Token expiry
When the access token expires, HTTP status `200 OK` is returned, but the response body is as follows:
```json
{
"status": 401,
"body": {},
"error": "XRequestID: Not provided invalid_token: The access token provided is invalid"
}
```
### Fetching health data
Health records can be fetched via GET request as follows:
```http
https://wbsapi.withings.net/measure?action=getmeas&meastypes=9,10,54,71,11
```
The type of vitals measurement is mapped as follows:
| Code | Type | Unit |
|------|--------------------------|------|
| 9 | Diastolic Blood Pressure | mmHg |
| 10 | Systolic Blood Pressure | mmHg |
| 11 | Heart Rate | bpm |
| 54 | SP02 | % |
| 71 | Body Temperature | °C |
Note the `unit`-field in the response.
For body temperature, the `unit`-field has the value `-3`.
This means that to get the body temperature in °C, you must multiply the `value` by `10^(-3)`.
The time of measurement can be parsed from the `measuregrps`'s `date` field.
A successful response looks like so:
```json
{
"status": 0,
"body": {
"updatetime": 1690491663,
"timezone": "Europe/Berlin",
"measuregrps": [
{
"grpid": 4716596696,
"attrib": 0,
"date": 1690491576,
"created": 1690491663,
"modified": 1690491663,
"category": 1,
"deviceid": "c405eb8e0e053f6601e151c7e43c04e29aad6956",
"hash_deviceid": "c405eb8e0e053f6601e151c7e43c04e29aad6956",
"measures": [
{
"value": 89,
"type": 9,
"unit": 0,
"algo": 0,
"fm": 3
},
{
"value": 109,
"type": 10,
"unit": 0,
"algo": 0,
"fm": 3
},
{
"value": 88,
"type": 11,
"unit": 0,
"algo": 0,
"fm": 3
}
],
"modelid": 44,
"model": "BPM Core",
"comment": null
},
{
"grpid": 4716596681,
"attrib": 0,
"date": 1690491236,
"created": 1690491662,
"modified": 1690491662,
"category": 1,
"deviceid": "c405eb8e0e053f6601e151c7e43c04e29aad6956",
"hash_deviceid": "c405eb8e0e053f6601e151c7e43c04e29aad6956",
"measures": [
{
"value": 65,
"type": 9,
"unit": 0,
"algo": 0,
"fm": 3
},
{
"value": 92,
"type": 10,
"unit": 0,
"algo": 0,
"fm": 3
},
{
"value": 88,
"type": 11,
"unit": 0,
"algo": 0,
"fm": 3
}
],
"modelid": 44,
"model": "BPM Core",
"comment": null
},
{
"grpid": 4712963495,
"attrib": 0,
"date": 1690375238,
"created": 1690375243,
"modified": 1690375243,
"category": 1,
"deviceid": "dbf7f61809d5fb350a16a50f6af6e826f0746082",
"hash_deviceid": "dbf7f61809d5fb350a16a50f6af6e826f0746082",
"measures": [
{
"value": 99,
"type": 54,
"unit": 0,
"algo": 33619971,
"fm": 3,
"apppfmid": 9,
"appliver": 2741,
"algo_params": {
"1": 0,
"2": 15
}
}
],
"modelid": null,
"model": null,
"comment": null,
"is_inconclusive": false
},
{
"grpid": 4712927310,
"attrib": 1,
"date": 1690374434,
"created": 1690374456,
"modified": 1690374486,
"category": 1,
"deviceid": "1d453daf947378fac40677e7a085eea73750b061",
"hash_deviceid": "1d453daf947378fac40677e7a085eea73750b061",
"measures": [
{
"value": 37370,
"type": 71,
"unit": -3,
"algo": 0,
"fm": 0
}
],
"modelid": null,
"model": null,
"comment": null
},
{
"grpid": 4712911433,
"attrib": 0,
"date": 1690373994,
"created": 1690374078,
"modified": 1690374078,
"category": 1,
"deviceid": "c405eb8e0e053f6601e151c7e43c04e29aad6956",
"hash_deviceid": "c405eb8e0e053f6601e151c7e43c04e29aad6956",
"measures": [
{
"value": 88,
"type": 9,
"unit": 0,
"algo": 0,
"fm": 3
},
{
"value": 124,
"type": 10,
"unit": 0,
"algo": 0,
"fm": 3
},
{
"value": 70,
"type": 11,
"unit": 0,
"algo": 0,
"fm": 3
}
],
"modelid": null,
"model": null,
"comment": null
}
]
}
}
```

0
app/withings/__init__.py Normal file
View File

116
app/withings/api.py Normal file
View File

@ -0,0 +1,116 @@
from datetime import datetime, timedelta
from random import randint
import requests
import pytz
from django.conf import settings
from django.utils import timezone
from django.contrib.auth.models import User
from urllib.parse import urlencode
from medwings import models as mm
def fetch_initial_tokens(authorization_code, redirect_uri):
data = {
'action': 'requesttoken',
'client_id': settings.WITHINGS_CONFIG['CLIENT_ID'],
'client_secret': settings.WITHINGS_CONFIG['CLIENT_SECRET'],
'grant_type': 'authorization_code',
'code': authorization_code,
'redirect_uri': redirect_uri
}
response = requests.post(
url=settings.WITHINGS_CONFIG['ENDPOINT_URL_OAUTH2'],
json=data
)
if response is not None:
response.raise_for_status()
return response.json()
def mock_fetch_initial_tokens(authorization_code, redirect_uri):
response = {
"status": 0,
"body": {
"userid": f"{randint(1, 5000)}",
"access_token": "a075f8c14fb8df40b08ebc8508533dc332a6910a",
"refresh_token": "f631236f02b991810feb774765b6ae8e6c6839ca",
"expires_in": 10800,
"scope": "user.info,user.metrics",
"csrf_token": "PACnnxwHTaBQOzF7bQqwFUUotIuvtzSM",
"token_type": "Bearer"
}
}
return response
def save_tokens_to_session(request, response_data):
request.session['withings_userid'] = response_data['body']['userid']
request.session['withings_access_token'] = response_data['body']['access_token']
request.session['withings_refresh_token'] = response_data['body']['refresh_token']
now = timezone.now()
request.session['withings_access_token_expiry'] = (now + timedelta(seconds=response_data['body']['expires_in'])).isoformat()
request.session['withings_refresh_token_expiry'] = (now + timedelta(days=365)).isoformat()
def parse_getmeas_response(response_body: dict, user: User) -> list:
body = response_body['body']
records = []
timezone = pytz.timezone(body['timezone'])
for measure_group in body['measuregrps']:
recorded = timezone.localize(datetime.fromtimestamp(measure_group['date']))
blood_pressure_systolic_value = None
blood_pressure_diastolic_value = None
body_temperature_value = None
heart_rate_value = None
spo2_level_value = None
for measure in measure_group['measures']:
measure_type = measure['type']
measure_value = measure['value']
measure_unit = measure['unit']
measure_value_adjusted = measure_value * (10 ** measure_unit)
if measure_type == 9:
blood_pressure_diastolic_value = measure_value_adjusted
elif measure_type == 10:
blood_pressure_systolic_value = measure_value_adjusted
elif measure_type == 11:
heart_rate_value = measure_value_adjusted
elif measure_type == 54:
spo2_level_value = measure_value_adjusted
elif measure_type == 71:
body_temperature_value = measure_value_adjusted
if blood_pressure_systolic_value and blood_pressure_diastolic_value:
records.append(mm.BloodPressureRecord(
user=user,
recorded=recorded,
value_systolic_mmhg=blood_pressure_systolic_value,
value_diastolic_mmhg=blood_pressure_diastolic_value
))
if body_temperature_value:
records.append(mm.BodyTempRecord(
user=user,
recorded=recorded,
value_celsius=body_temperature_value
))
if heart_rate_value:
records.append(mm.HeartRateRecord(
user=user,
recorded=recorded,
value_bpm=heart_rate_value
))
if spo2_level_value:
records.append(mm.Spo2LevelRecord(
user=user,
recorded=recorded,
value_percent=spo2_level_value
))
return records

6
app/withings/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class WithingsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'withings'

View File

@ -0,0 +1,41 @@
# Generated by Django 4.2.3 on 2023-07-30 21:15
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='ApiAccount',
fields=[
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)),
('userid', models.PositiveIntegerField(verbose_name='Withings API User ID')),
('last_update', models.DateTimeField(default=None, null=True, verbose_name='Time of last synchronization with Withings API')),
],
),
migrations.CreateModel(
name='AccessToken',
fields=[
('account', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='withings.apiaccount')),
('value', models.CharField(max_length=256, verbose_name='Withings API Access Token')),
('expires', models.DateTimeField(verbose_name='Time of expiration')),
],
),
migrations.CreateModel(
name='RefreshToken',
fields=[
('account', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='withings.apiaccount')),
('value', models.CharField(max_length=256, verbose_name='Withings API Refresh Token')),
('expires', models.DateTimeField(verbose_name='Time of expiration')),
],
),
]

View File

90
app/withings/models.py Normal file
View File

@ -0,0 +1,90 @@
from datetime import datetime, timedelta
from django.db import models, IntegrityError
from django.contrib.auth.models import User
from django.conf import settings
from django.utils import timezone
import requests
from . import api
class AccessToken(models.Model):
account = models.OneToOneField("ApiAccount", on_delete=models.CASCADE, primary_key=True)
value = models.CharField(max_length=256, verbose_name="Withings API Access Token")
expires = models.DateTimeField(verbose_name="Time of expiration")
class RefreshToken(models.Model):
account = models.OneToOneField("ApiAccount", on_delete=models.CASCADE, primary_key=True)
value = models.CharField(max_length=256, verbose_name="Withings API Refresh Token")
expires = models.DateTimeField(verbose_name="Time of expiration")
class ApiAccount(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True)
userid = models.PositiveIntegerField(verbose_name="Withings API User ID")
last_update = models.DateTimeField(null=True, default=None, verbose_name="Time of last synchronization with Withings API")
def refresh_tokens(self):
data = {
'action': 'requesttoken',
'client_id': settings.WITHINGS_CONFIG['CLIENT_ID'],
'client_secret': settings.WITHINGS_CONFIG['CLIENT_SECRET'],
'grant_type': 'refresh_token',
'refresh_token': self.refreshtoken.value
}
response = requests.post(
url=settings.WITHINGS_CONFIG['ENDPOINT_URL_OAUTH2'],
json=data
)
if response is not None:
response.raise_for_status()
response_data = response.json()
now = timezone.now()
self.accesstoken.value = response_data['body']['access_token']
self.accesstoken.expires = now + timedelta(seconds=response_data['body']['expires_in'])
self.refreshtoken.value = response_data['body']['refresh_token']
self.refreshtoken.expires = now + timedelta(days=365)
self.accesstoken.save()
self.refreshtoken.save()
def get_measurements(self, since: datetime | None = None) -> list:
if self.accesstoken.expires < timezone.now():
self.refresh_tokens()
params={
'action': 'getmeas',
'meastypes': '9,10,11,54,71'
}
if since:
params['lastupdate'] = str(int(since.timestamp()))
response = requests.get(
url="https://wbsapi.withings.net/measure",
params=params,
headers={
'Authorization': f"Bearer {self.accesstoken.value}"
}
)
if response is not None:
response.raise_for_status()
data = response.json()
if data['status'] != 0:
raise RuntimeError(f"Received status {data['status']} while retrieving measurements: {data['error']}")
return api.parse_getmeas_response(data, self.user)
def update_records(self):
records = self.get_measurements(self.last_update)
for record in records:
try:
record.save()
except IntegrityError:
pass
self.last_update = timezone.now()
self.save()

View File

@ -1,109 +1,109 @@
/* Kanit */
@font-face {
font-family: 'Kanit';
src: url('/frontend/assets/fonts/kanit/kanit-thin.ttf') format('truetype');
src: url('/assets/fonts/kanit/kanit-thin.ttf') format('truetype');
font-weight: 100;
font-style: normal;
}
@font-face {
font-family: 'Kanit';
src: url('/frontend/assets/fonts/kanit/kanit-thin-italic.ttf') format('truetype');
src: url('/assets/fonts/kanit/kanit-thin-italic.ttf') format('truetype');
font-weight: 100;
font-style: italic;
}
@font-face {
font-family: 'Kanit';
src: url('/frontend/assets/fonts/kanit/kanit-extralight.ttf') format('truetype');
src: url('/assets/fonts/kanit/kanit-extralight.ttf') format('truetype');
font-weight: 200;
font-style: normal;
}
@font-face {
font-family: 'Kanit';
src: url('/frontend/assets/fonts/kanit/kanit-extralight-italic.ttf') format('truetype');
src: url('/assets/fonts/kanit/kanit-extralight-italic.ttf') format('truetype');
font-weight: 200;
font-style: italic;
}
@font-face {
font-family: 'Kanit';
src: url('/frontend/assets/fonts/kanit/kanit-light.ttf') format('truetype');
src: url('/assets/fonts/kanit/kanit-light.ttf') format('truetype');
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: 'Kanit';
src: url('/frontend/assets/fonts/kanit/kanit-light-italic.ttf') format('truetype');
src: url('/assets/fonts/kanit/kanit-light-italic.ttf') format('truetype');
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: 'Kanit';
src: url('/frontend/assets/fonts/kanit/kanit-regular.ttf') format('truetype');
src: url('/assets/fonts/kanit/kanit-regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Kanit';
src: url('/frontend/assets/fonts/kanit/kanit-regular-italic.ttf') format('truetype');
src: url('/assets/fonts/kanit/kanit-regular-italic.ttf') format('truetype');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Kanit';
src: url('/frontend/assets/fonts/kanit/kanit-medium.ttf') format('truetype');
src: url('/assets/fonts/kanit/kanit-medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: 'Kanit';
src: url('/frontend/assets/fonts/kanit/kanit-medium-italic.ttf') format('truetype');
src: url('/assets/fonts/kanit/kanit-medium-italic.ttf') format('truetype');
font-weight: 500;
font-style: italic;
}
@font-face {
font-family: 'Kanit';
src: url('/frontend/assets/fonts/kanit/kanit-semibold.ttf') format('truetype');
src: url('/assets/fonts/kanit/kanit-semibold.ttf') format('truetype');
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: 'Kanit';
src: url('/frontend/assets/fonts/kanit/kanit-semibold-italic.ttf') format('truetype');
src: url('/assets/fonts/kanit/kanit-semibold-italic.ttf') format('truetype');
font-weight: 600;
font-style: italic;
}
@font-face {
font-family: 'Kanit';
src: url('/frontend/assets/fonts/kanit/kanit-bold.ttf') format('truetype');
src: url('/assets/fonts/kanit/kanit-bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Kanit';
src: url('/frontend/assets/fonts/kanit/kanit-bold-italic.ttf') format('truetype');
src: url('/assets/fonts/kanit/kanit-bold-italic.ttf') format('truetype');
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: 'Kanit';
src: url('/frontend/assets/fonts/kanit/kanit-extrabold.ttf') format('truetype');
src: url('/assets/fonts/kanit/kanit-extrabold.ttf') format('truetype');
font-weight: 800;
font-style: normal;
}
@font-face {
font-family: 'Kanit';
src: url('/frontend/assets/fonts/kanit/kanit-extrabold-italic.ttf') format('truetype');
src: url('/assets/fonts/kanit/kanit-extrabold-italic.ttf') format('truetype');
font-weight: 800;
font-style: italic;
}
@font-face {
font-family: 'Kanit';
src: url('/frontend/assets/fonts/kanit/kanit-black.ttf') format('truetype');
src: url('/assets/fonts/kanit/kanit-black.ttf') format('truetype');
font-weight: 900;
font-style: normal;
}
@font-face {
font-family: 'Kanit';
src: url('/frontend/assets/fonts/kanit/kanit-black-italic.ttf') format('truetype');
src: url('/assets/fonts/kanit/kanit-black-italic.ttf') format('truetype');
font-weight: 900;
font-style: italic;
}
@ -112,109 +112,109 @@
/* Montserrat */
@font-face {
font-family: 'Montserrat';
src: url('/frontend/assets/fonts/montserrat/montserrat-thin.ttf') format('truetype');
src: url('/assets/fonts/montserrat/montserrat-thin.ttf') format('truetype');
font-weight: 100;
font-style: normal;
}
@font-face {
font-family: 'Montserrat';
src: url('/frontend/assets/fonts/montserrat/montserrat-thin-italic.ttf') format('truetype');
src: url('/assets/fonts/montserrat/montserrat-thin-italic.ttf') format('truetype');
font-weight: 100;
font-style: italic;
}
@font-face {
font-family: 'Montserrat';
src: url('/frontend/assets/fonts/montserrat/montserrat-extralight.ttf') format('truetype');
src: url('/assets/fonts/montserrat/montserrat-extralight.ttf') format('truetype');
font-weight: 200;
font-style: normal;
}
@font-face {
font-family: 'Montserrat';
src: url('/frontend/assets/fonts/montserrat/montserrat-extralight-italic.ttf') format('truetype');
src: url('/assets/fonts/montserrat/montserrat-extralight-italic.ttf') format('truetype');
font-weight: 200;
font-style: italic;
}
@font-face {
font-family: 'Montserrat';
src: url('/frontend/assets/fonts/montserrat/montserrat-light.ttf') format('truetype');
src: url('/assets/fonts/montserrat/montserrat-light.ttf') format('truetype');
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: 'Montserrat';
src: url('/frontend/assets/fonts/montserrat/montserrat-light-italic.ttf') format('truetype');
src: url('/assets/fonts/montserrat/montserrat-light-italic.ttf') format('truetype');
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: 'Montserrat';
src: url('/frontend/assets/fonts/montserrat/montserrat-regular.ttf') format('truetype');
src: url('/assets/fonts/montserrat/montserrat-regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Montserrat';
src: url('/frontend/assets/fonts/montserrat/montserrat-regular-italic.ttf') format('truetype');
src: url('/assets/fonts/montserrat/montserrat-regular-italic.ttf') format('truetype');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Montserrat';
src: url('/frontend/assets/fonts/montserrat/montserrat-medium.ttf') format('truetype');
src: url('/assets/fonts/montserrat/montserrat-medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: 'Montserrat';
src: url('/frontend/assets/fonts/montserrat/montserrat-medium-italic.ttf') format('truetype');
src: url('/assets/fonts/montserrat/montserrat-medium-italic.ttf') format('truetype');
font-weight: 500;
font-style: italic;
}
@font-face {
font-family: 'Montserrat';
src: url('/frontend/assets/fonts/montserrat/montserrat-semibold.ttf') format('truetype');
src: url('/assets/fonts/montserrat/montserrat-semibold.ttf') format('truetype');
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: 'Montserrat';
src: url('/frontend/assets/fonts/montserrat/montserrat-semibold-italic.ttf') format('truetype');
src: url('/assets/fonts/montserrat/montserrat-semibold-italic.ttf') format('truetype');
font-weight: 600;
font-style: italic;
}
@font-face {
font-family: 'Montserrat';
src: url('/frontend/assets/fonts/montserrat/montserrat-bold.ttf') format('truetype');
src: url('/assets/fonts/montserrat/montserrat-bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Montserrat';
src: url('/frontend/assets/fonts/montserrat/montserrat-bold-italic.ttf') format('truetype');
src: url('/assets/fonts/montserrat/montserrat-bold-italic.ttf') format('truetype');
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: 'Montserrat';
src: url('/frontend/assets/fonts/montserrat/montserrat-extrabold.ttf') format('truetype');
src: url('/assets/fonts/montserrat/montserrat-extrabold.ttf') format('truetype');
font-weight: 800;
font-style: normal;
}
@font-face {
font-family: 'Montserrat';
src: url('/frontend/assets/fonts/montserrat/montserrat-extrabold-italic.ttf') format('truetype');
src: url('/assets/fonts/montserrat/montserrat-extrabold-italic.ttf') format('truetype');
font-weight: 800;
font-style: italic;
}
@font-face {
font-family: 'Montserrat';
src: url('/frontend/assets/fonts/montserrat/montserrat-black.ttf') format('truetype');
src: url('/assets/fonts/montserrat/montserrat-black.ttf') format('truetype');
font-weight: 900;
font-style: normal;
}
@font-face {
font-family: 'Montserrat';
src: url('/frontend/assets/fonts/montserrat/montserrat-black-italic.ttf') format('truetype');
src: url('/assets/fonts/montserrat/montserrat-black-italic.ttf') format('truetype');
font-weight: 900;
font-style: italic;
}
@ -223,97 +223,97 @@
/* SourceCodePro */
@font-face {
font-family: 'SourceCodePro';
src: url('/frontend/assets/fonts/sourcecodepro/sourcecodepro-extralight.ttf') format('truetype');
src: url('/assets/fonts/sourcecodepro/sourcecodepro-extralight.ttf') format('truetype');
font-weight: 200;
font-style: normal;
}
@font-face {
font-family: 'SourceCodePro';
src: url('/frontend/assets/fonts/sourcecodepro/sourcecodepro-extralight-italic.ttf') format('truetype');
src: url('/assets/fonts/sourcecodepro/sourcecodepro-extralight-italic.ttf') format('truetype');
font-weight: 200;
font-style: italic;
}
@font-face {
font-family: 'SourceCodePro';
src: url('/frontend/assets/fonts/sourcecodepro/sourcecodepro-light.ttf') format('truetype');
src: url('/assets/fonts/sourcecodepro/sourcecodepro-light.ttf') format('truetype');
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: 'SourceCodePro';
src: url('/frontend/assets/fonts/sourcecodepro/sourcecodepro-light-italic.ttf') format('truetype');
src: url('/assets/fonts/sourcecodepro/sourcecodepro-light-italic.ttf') format('truetype');
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: 'SourceCodePro';
src: url('/frontend/assets/fonts/sourcecodepro/sourcecodepro-regular.ttf') format('truetype');
src: url('/assets/fonts/sourcecodepro/sourcecodepro-regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'SourceCodePro';
src: url('/frontend/assets/fonts/sourcecodepro/sourcecodepro-regular-italic.ttf') format('truetype');
src: url('/assets/fonts/sourcecodepro/sourcecodepro-regular-italic.ttf') format('truetype');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'SourceCodePro';
src: url('/frontend/assets/fonts/sourcecodepro/sourcecodepro-medium.ttf') format('truetype');
src: url('/assets/fonts/sourcecodepro/sourcecodepro-medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: 'SourceCodePro';
src: url('/frontend/assets/fonts/sourcecodepro/sourcecodepro-medium-italic.ttf') format('truetype');
src: url('/assets/fonts/sourcecodepro/sourcecodepro-medium-italic.ttf') format('truetype');
font-weight: 500;
font-style: italic;
}
@font-face {
font-family: 'SourceCodePro';
src: url('/frontend/assets/fonts/sourcecodepro/sourcecodepro-semibold.ttf') format('truetype');
src: url('/assets/fonts/sourcecodepro/sourcecodepro-semibold.ttf') format('truetype');
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: 'SourceCodePro';
src: url('/frontend/assets/fonts/sourcecodepro/sourcecodepro-semibold-italic.ttf') format('truetype');
src: url('/assets/fonts/sourcecodepro/sourcecodepro-semibold-italic.ttf') format('truetype');
font-weight: 600;
font-style: italic;
}
@font-face {
font-family: 'SourceCodePro';
src: url('/frontend/assets/fonts/sourcecodepro/sourcecodepro-bold.ttf') format('truetype');
src: url('/assets/fonts/sourcecodepro/sourcecodepro-bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'SourceCodePro';
src: url('/frontend/assets/fonts/sourcecodepro/sourcecodepro-bold-italic.ttf') format('truetype');
src: url('/assets/fonts/sourcecodepro/sourcecodepro-bold-italic.ttf') format('truetype');
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: 'SourceCodePro';
src: url('/frontend/assets/fonts/sourcecodepro/sourcecodepro-extrabold.ttf') format('truetype');
src: url('/assets/fonts/sourcecodepro/sourcecodepro-extrabold.ttf') format('truetype');
font-weight: 800;
font-style: normal;
}
@font-face {
font-family: 'SourceCodePro';
src: url('/frontend/assets/fonts/sourcecodepro/sourcecodepro-extrabold-italic.ttf') format('truetype');
src: url('/assets/fonts/sourcecodepro/sourcecodepro-extrabold-italic.ttf') format('truetype');
font-weight: 800;
font-style: italic;
}
@font-face {
font-family: 'SourceCodePro';
src: url('/frontend/assets/fonts/sourcecodepro/sourcecodepro-black.ttf') format('truetype');
src: url('/assets/fonts/sourcecodepro/sourcecodepro-black.ttf') format('truetype');
font-weight: 900;
font-style: normal;
}
@font-face {
font-family: 'SourceCodePro';
src: url('/frontend/assets/fonts/sourcecodepro/sourcecodepro-black-italic.ttf') format('truetype');
src: url('/assets/fonts/sourcecodepro/sourcecodepro-black-italic.ttf') format('truetype');
font-weight: 900;
font-style: italic;
}
@ -322,58 +322,88 @@
/* Lora */
@font-face {
font-family: 'Lora';
src: url('/frontend/assets/fonts/lora/lora-regular.ttf') format('truetype');
src: url('/assets/fonts/lora/lora-regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Lora';
src: url('/frontend/assets/fonts/lora/lora-regular-italic.ttf') format('truetype');
src: url('/assets/fonts/lora/lora-regular-italic.ttf') format('truetype');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Lora';
src: url('/frontend/assets/fonts/lora/lora-medium.ttf') format('truetype');
src: url('/assets/fonts/lora/lora-medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: 'Lora';
src: url('/frontend/assets/fonts/lora/lora-medium-italic.ttf') format('truetype');
src: url('/assets/fonts/lora/lora-medium-italic.ttf') format('truetype');
font-weight: 500;
font-style: italic;
}
@font-face {
font-family: 'Lora';
src: url('/frontend/assets/fonts/lora/lora-semibold.ttf') format('truetype');
src: url('/assets/fonts/lora/lora-semibold.ttf') format('truetype');
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: 'Lora';
src: url('/frontend/assets/fonts/lora/lora-semibold-italic.ttf') format('truetype');
src: url('/assets/fonts/lora/lora-semibold-italic.ttf') format('truetype');
font-weight: 600;
font-style: italic;
}
@font-face {
font-family: 'Lora';
src: url('/frontend/assets/fonts/lora/lora-bold.ttf') format('truetype');
src: url('/assets/fonts/lora/lora-bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Lora';
src: url('/frontend/assets/fonts/lora/lora-bold-italic.ttf') format('truetype');
src: url('/assets/fonts/lora/lora-bold-italic.ttf') format('truetype');
font-weight: 700;
font-style: italic;
}
/* NotoColorEmoji */
/* PlayfairDisplaySC */
@font-face {
font-family: 'NotoColorEmoji';
src: url('/frontend/assets/fonts/notocoloremoji/notocoloremoji-regular.ttf') format('truetype');
font-family: 'PlayfairDisplaySC';
src: url('/assets/fonts/playfairdisplaysc/playfairdisplaysc-regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'PlayfairDisplaySC';
src: url('/assets/fonts/playfairdisplaysc/playfairdisplaysc-regular-italic.ttf') format('truetype');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'PlayfairDisplaySC';
src: url('/assets/fonts/playfairdisplaysc/playfairdisplaysc-bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'PlayfairDisplaySC';
src: url('/assets/fonts/playfairdisplaysc/playfairdisplaysc-bold-italic.ttf') format('truetype');
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: 'PlayfairDisplaySC';
src: url('/assets/fonts/playfairdisplaysc/playfairdisplaysc-black.ttf') format('truetype');
font-weight: 900;
font-style: normal;
}
@font-face {
font-family: 'PlayfairDisplaySC';
src: url('/assets/fonts/playfairdisplaysc/playfairdisplaysc-black-italic.ttf') format('truetype');
font-weight: 900;
font-style: italic;
}

363
assets/css/styles.css Normal file
View File

@ -0,0 +1,363 @@
@import "./fonts.css";
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
h1, h2, h3, h4, h5, h6 {
font-family: Kanit;
font-weight: 600;
font-style: normal;
text-align: center;
line-height: 1;
}
h1 {
font-size: 3rem;
@apply text-primary-200;
}
h1.title {
@apply font-title font-bold;
@apply underline text-primary-200/90;
}
h2 {
font-size: 2.6rem;
}
h3 {
font-size: 2.2rem;
}
h4 {
font-size: 1.8rem;
}
h5 {
font-size: 1.4rem;
}
h6 {
font-size: 1rem;
}
p {
@apply text-center sm:text-start;
}
input {
@apply rounded rounded-md drop-shadow-sm px-2 py-1;
@apply bg-secondary/25;
@apply w-full;
}
input[type="submit"] {
@apply rounded rounded-lg drop-shadow-md px-4 py-2;
@apply bg-secondary hover:bg-secondary-600;
@apply cursor-pointer;
@apply font-semibold;
@apply hover:drop-shadow-xl;
}
select {
@apply rounded rounded-md drop-shadow-sm px-2 py-1;
@apply bg-secondary/25;
@apply w-full;
}
select.error {
@apply border-2 border-failure/50;
}
input.error {
@apply border-2 border-failure/50;
}
fieldset {
@apply border border-secondary p-4;
}
legend {
@apply text-sm text-secondary px-2;
}
label {
@apply text-sm text-secondary/75 font-semibold;
}
a:not(.btn, .btn-outline) {
@apply underline text-secondary-300;
}
div.call-to-action-box a {
@apply text-secondary-200;
}
ul, ol {
list-style-position: outside;
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal;
}
li {
@apply text-start;
}
code {
@apply bg-neutral-800/75 rounded-md;
@apply px-1 py-0.5;
@apply text-neutral-200 font-mono;
}
.btn {
text-align: center;
@apply rounded rounded-lg drop-shadow-md px-2 py-1;
@apply bg-accent hover:bg-accent-600;
@apply font-semibold text-primary-100 hover:text-primary-200;
@apply hover:drop-shadow-xl;
@apply border-2 border-accent hover:border-accent-700;
@apply transition-all;
}
.btn-outline {
text-align: center;
@apply rounded rounded-lg drop-shadow-md px-2 py-1;
@apply bg-transparent hover:bg-accent-600;
@apply font-semibold text-accent hover:text-primary-100;
@apply hover:drop-shadow-xl;
@apply border-2 border-accent hover:border-accent-700;
@apply transition-all;
}
body.global {
min-height: 100vh;
width: 100%;
display: flex;
flex-direction: column;
@apply bg-gradient-to-b from-primary-900/75 via-background to-primary-900/75;
}
header.global, main.global, footer.global {
width: 100%;
}
main.global {
display: flex;
flex-direction: column;
flex-grow: 1;
justify-content: center;
align-items: center;
}
div.status-message {
display: flex;
width: 100%;
justify-content: center;
align-items: center;
border-radius: 0.5rem;
padding: 0.5rem;
}
div.status-message.error {
@apply italic text-failure-300 bg-failure/50 border border-failure;
}
div.call-to-action-box {
@apply bg-gradient-to-r from-secondary-300/75 to-secondary-500/75;
@apply rounded-md py-4 px-6;
@apply text-center sm:text-start;
}
.loader {
overflow: hidden;
}
.loader.loader-bar {
@apply h-1 bg-accent rounded rounded-full;
}
.loader.loader-value {
@apply font-bold text-end;
}
.loader--loading {
animation: loading 2s infinite linear;
}
.loader--finished {
animation: finished 1.5s ease-in both;
}
.loader--value {
animation: loaded 1s ease-out both;
}
@keyframes loading {
0% {
transform: scaleX(0%) translateX(-100%);
transform-origin: left;
}
50% {
transform: scaleX(50%);
transform-origin: center;
}
100% {
transform: scaleX(0%) translateX(100%);
transform-origin: right;
}
}
@keyframes finished {
0% {
transform: scaleX(0%);
transform-origin: center;
}
50% {
transform: scaleX(100%);
transform-origin: center;
opacity: 1;
}
100% {
transform: scaleX(100%) translateX(1000%);
transform-origin: right;
opacity: 0;
}
}
@keyframes loaded {
0% {
transform: translateX(1000%);
opacity: 0;
}
100% {
transform: translateX(0%);
opacity: 1;
}
}
#logo-wings {
animation: logowings 2s ease-in-out infinite alternate;
}
#logo-wings--subtle {
animation: logowings-subtle 2s ease-in-out infinite alternate;
}
#logo-heart {
animation: logoheart 1s linear infinite;
transform-origin: center;
}
#logo-heart--subtle {
animation: logoheart-subtle 1s linear infinite;
transform-origin: center;
}
#logo-blips {
animation: logoblips 6s linear infinite;
transform-origin: bottom;
transform-box: fill-box;
}
#logo-blips--subtle {
animation: logoblips-subtle 6s linear infinite;
transform-origin: bottom;
transform-box: fill-box;
}
@keyframes logowings {
0% {
transform: translateY(0%);
}
100% {
transform: translateY(5%);
}
}
@keyframes logowings-subtle {
0% {
transform: translateY(5%);
}
100% {
transform: translateY(8%);
}
}
@keyframes logoheart {
0%, 25%, 50%, 100% {
transform: scale(100%);
}
12%, 37% {
transform: scale(105%);
}
}
@keyframes logoheart-subtle {
0%, 25%, 50%, 100% {
transform: scale(100%);
}
12%, 37% {
transform: scale(102%);
}
}
@keyframes logoblips {
0% {
transform: rotateY(0deg)
}
5%, 100% {
transform: rotateY(360deg);
}
}
@keyframes logoblips-subtle {
0% {
transform: rotateY(0deg)
}
5%, 100% {
transform: rotateY(360deg);
}
}
path.hamburger-path {
@apply fill-secondary stroke-secondary;
}
#hamburger-content.hamburger--closed {
@apply hidden sm:flex;
}
#hamburger-content.hamburger--open {
@apply flex;
}
#hamburger-svg.hamburger--open {
transform-origin: center;
transform: rotate(90deg);
}
#hamburger-svg.hamburger--closed {
transform-origin: center;
transform: rotate(0deg);
}
.help-div--changing p.fadeout {
animation: 1s fadeout ease-in-out both;
}
.help-div--changing p.fadein, a.fadein {
animation: 1s fadein ease-in-out 1s both;
}
@keyframes fadeout {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes fadein {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}

Some files were not shown because too many files have changed in this diff Show More