Merge pull request 'merge experimental' (#1) from experimental into master
Reviewed-on: #1
2
.dockerignore
Normal file
@ -0,0 +1,2 @@
|
||||
**/.venv/
|
||||
**/__pycache__/
|
12
.gitignore
vendored
@ -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
|
||||
|
@ -1,7 +1,5 @@
|
||||
{
|
||||
"plugins": {
|
||||
"postcss-import": {},
|
||||
"tailwindcss/nesting": {},
|
||||
"tailwindcss": {},
|
||||
}
|
||||
"plugins": {
|
||||
"tailwindcss": {},
|
||||
}
|
||||
}
|
||||
|
10
Caddyfile
Normal file
@ -0,0 +1,10 @@
|
||||
:8000 {
|
||||
handle * {
|
||||
reverse_proxy * medwings-django:8000
|
||||
}
|
||||
|
||||
log {
|
||||
output stderr
|
||||
format console
|
||||
}
|
||||
}
|
94
README.md
@ -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
|
||||
```
|
||||
|
6
app/authentication/README.md
Normal 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.
|
6
app/authentication/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuthenticationConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'authentication'
|
12
app/authentication/forms.py
Normal 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")
|
64
app/authentication/templates/authentication/login.html
Normal 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 %}
|
4
app/authentication/templates/authentication/logout.html
Normal file
@ -0,0 +1,4 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% block content %}
|
||||
<p>You have been logged out.</p>
|
||||
{% endblock content %}
|
@ -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 %}
|
@ -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 %}
|
@ -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 %}
|
12
app/authentication/urls.py
Normal 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
@ -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
@ -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
@ -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
@ -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')
|
||||
}
|
28
app/core/templates/core/base.html
Normal 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>
|
3
app/core/templates/core/footer.html
Normal file
@ -0,0 +1,3 @@
|
||||
<div class="flex gap-4 justify-center items-center p-2 bg-primary-100 text-sm text-background">
|
||||
<p>© 2023 Julian Lobbes</p>
|
||||
</div>
|
55
app/core/templates/core/navbar.html
Normal file
7
app/core/urls.py
Normal 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
@ -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
@ -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
@ -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
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class GotifyConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'gotify'
|
24
app/gotify/management/commands/notify_all.py
Normal 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)
|
32
app/gotify/migrations/0001_initial.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
0
app/gotify/migrations/__init__.py
Normal file
79
app/gotify/models.py
Normal 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
@ -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
@ -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
6
app/medwings/apps.py
Normal 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
@ -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']
|
87
app/medwings/migrations/0001_initial.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
0
app/medwings/migrations/__init__.py
Normal file
123
app/medwings/models.py
Normal 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
|
146
app/medwings/templates/medwings/dashboard.html
Normal 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 %}
|
56
app/medwings/templates/medwings/index.html
Normal file
210
app/medwings/templates/medwings/mews-continue.html
Normal 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 %}
|
51
app/medwings/templates/medwings/mews-init.html
Normal 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
@ -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
@ -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
@ -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
@ -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
|
Before Width: | Height: | Size: 670 KiB After Width: | Height: | Size: 670 KiB |
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 146 KiB |
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
BIN
app/static/medwings/images/logo/medwings-logo.png
Normal file
After Width: | Height: | Size: 12 KiB |
154
app/static/medwings/images/logo/medwings-logo.svg
Normal 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" /> <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)"> <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" /> <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" /> </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"> <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" /> <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" /> <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" /> </g></g> </svg>
|
After Width: | Height: | Size: 6.5 KiB |
31
app/static/medwings/images/misc/hamburger.svg
Normal 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
@ -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
116
app/withings/api.py
Normal 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
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class WithingsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'withings'
|
41
app/withings/migrations/0001_initial.py
Normal 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')),
|
||||
],
|
||||
),
|
||||
]
|
0
app/withings/migrations/__init__.py
Normal file
90
app/withings/models.py
Normal 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()
|
@ -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
@ -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;
|
||||
}
|
||||
}
|