merge experimental #1

Merged
jlobbes merged 25 commits from experimental into master 2023-08-17 15:16:51 +01:00
17 changed files with 292 additions and 39 deletions
Showing only changes of commit 9fe2365831 - Show all commits

8
.env
View File

@ -1,8 +0,0 @@
TIMEZONE=Europe/Berlin
PG_NAME=medwings
PG_USER=medwings
PG_PASSWORD=medwings
PG_HOST=medwings-postgres
PG_PORT=5432
GOTIFY_USER=gotify
GOTIFY_PASSWORD=gotify

View File

@ -91,10 +91,31 @@ Steps to create a new user's channel on gotify:
# Deployment # Deployment
This section is incomplete. Build the asset bundle:
1. Build the asset bundle:
```bash ```bash
npm run build npm run build
``` ```
In the root directory, create a file named `.env` and fill it with environment variables containing your access and connection credentials:
```env
TIMEZONE=Europe/Berlin
PG_NAME=medwings
PG_USER=medwings
PG_PASSWORD=<secret>
PG_HOST=medwings-postgres
PG_PORT=5432
GOTIFY_USER=<secret>
GOTIFY_PASSWORD=<secret>
WITHINGS_CLIENT_ID=<secret>
WITHINGS_CLIENT_SECRET=<secret>
```
Substitute each `<secret>` with your information as follows:
- `PG_PASSWORD`: A random string, at least 32 characters
- `GOTIFY_USER`: Can be a username of your choice, for the Gotify server admin user
- `GOTIFY_password`: A random string, at least 8 characters
- `WITHINGS_CLIENT_ID`: Your Withings Developer API Client ID
- `WITHINGS_CLIENT_SECRET`: Your Withings Developer API Client Secret

View File

@ -0,0 +1,12 @@
{% 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>{{ auth_code }}</p>
</div>
{% endblock content %}

View File

@ -0,0 +1,12 @@
{% 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>Nothing to see here.</p>
</div>
{% endblock content %}

View File

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

View File

@ -1,8 +1,12 @@
from django.urls import path from django.urls import path
from django.contrib.auth import views as auth_views from django.contrib.auth import views as auth_views
from . import views
urlpatterns = [ urlpatterns = [
path("login/", auth_views.LoginView.as_view(template_name="authentication/login.html"), name="login"), 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("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"),
] ]

View File

@ -1,3 +1,71 @@
from django.shortcuts import render from urllib.parse import urlencode
from uuid import uuid4
# Create your views here. from django.shortcuts import render
from django.conf import settings
from django.urls import reverse
from django.core.exceptions import PermissionDenied
from django.http import HttpResponseBadRequest
import withings.api
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
spoof_protection_token = str(uuid4())
request.session['spoof_protection_token'] = spoof_protection_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': spoof_protection_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):
# Parse GET request parameters
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('spoof_protection_token', None) == authorization_state:
return HttpResponseBadRequest()
# Fetch access and refresh tokens and save them to session storage
redirect_uri = request.build_absolute_uri(reverse('register-continue'))
# DEBUG use an API mock
response_data = withings.api.mock_fetch_withings_tokens(authorization_code, redirect_uri)
if response_data['status'] != 0:
return HttpResponseBadRequest()
withings.api.save_tokens_to_session(request, response_data)
# TODO add user registration form
# TODO once user registration form is valid, make gotify API calls
# TODO once gotify is set up, create and save database objects
context = {}
return render(request, 'authentication/register-continue.html', context)
def register_finalize(request):
# TODO implement
return render(request, 'authentication/register-finalize.html')

View File

@ -13,24 +13,21 @@ https://docs.djangoproject.com/en/4.2/ref/settings/
from pathlib import Path from pathlib import Path
from os import getenv from os import getenv
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # 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' 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! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
ALLOWED_HOSTS = [] ALLOWED_HOSTS = []
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'core', 'core',
'authentication', 'authentication',
@ -44,7 +41,6 @@ INSTALLED_APPS = [
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
@ -54,9 +50,7 @@ MIDDLEWARE = [
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
] ]
ROOT_URLCONF = 'core.urls' ROOT_URLCONF = 'core.urls'
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',
@ -72,13 +66,11 @@ TEMPLATES = [
}, },
}, },
] ]
WSGI_APPLICATION = 'core.wsgi.application' WSGI_APPLICATION = 'core.wsgi.application'
# Database # Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases # https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.postgresql', 'ENGINE': 'django.db.backends.postgresql',
@ -93,7 +85,6 @@ DATABASES = {
# Password validation # Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ {
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
@ -115,25 +106,30 @@ LOGOUT_REDIRECT_URL = 'home'
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/4.2/topics/i18n/ # https://docs.djangoproject.com/en/4.2/topics/i18n/
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = 'en-us'
TIME_ZONE = getenv('TZ', 'Europe/Berlin') TIME_ZONE = getenv('TZ', 'Europe/Berlin')
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/ # https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = 'static/' STATIC_URL = 'static/'
STATICFILES_DIRS = [ STATICFILES_DIRS = [
BASE_DIR / 'static', BASE_DIR / 'static',
] ]
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
WITHINGS_CONFIG = {
'CLIENT_ID': getenv('WITHINGS_CLIENT_ID'),
'CLIENT_SECRET': getenv('WITHINGS_CLIENT_SECRET'),
}
GOTIFY_CONFIG = {
'USERNAME': getenv('GOTIFY_USER'),
'PASSWORD': getenv('GOTIFY_PASSWORD'),
}

View File

@ -0,0 +1,12 @@
{% extends 'core/base.html' %}
{% load static %}
{% block title %}
Medwings | Dashboard
{% endblock title %}
{% block content %}
<div class="flex flex-col justify-center items-center gap-2 py-4 mx-4 max-w-4xl">
<h1>Dashboard</h1>
<p1>There is nothing here yet.</p1>
</div>
{% endblock content %}

View File

@ -5,7 +5,47 @@
{% endblock title %} {% endblock title %}
{% block content %} {% block content %}
<div class="flex flex-col justify-center items-center gap-2 py-4 mx-4 max-w-4xl">
<h1>Welcome to Medwings</h1> <h1>Welcome to Medwings</h1>
<p1>There ain't much 'ere yet...</p1> <p1>Your personal health guardian</p1>
{% comment %}<img src="{% static 'medwings/images/devices/withings-thermo.webp' %}" alt="A withings thermometer.">{% endcomment %} {% comment %}<img src="{% static 'medwings/images/devices/withings-thermo.webp' %}" alt="A withings thermometer.">{% endcomment %}
<p>
We understand that after receiving medical care, you may still have concerns about your health, particularly if you're at
risk of sudden health changes.
That's where we come in.
</p>
<div class="flex flex-col gap-2 items-center call-to-action-box">
{% if not request.user.is_authenticated %}
<p class="font-semibold">To use the platform, please log in:</p>
<a class="btn max-w-fit" href="{% url 'login' %}">Log In</a>
<p class="font-semibold">If you do not have an account yet, please register:</p>
<a class="btn max-w-fit" href="{% url 'register-init' %}">Create An Account</a>
{% else %}
<p class="font-semibold">View your latest health data to stay up to date:</p>
<a class="btn text-lg" href="{% url 'dashboard' %}">Go to your personal dashboard</a>
{% endif %}
</div>
<p>
Our platform leverages smart medical sensor devices to keep track of your vital signs - such as heart rate,
blood pressure, and body temperature - providing you and your healthcare team with a detailed and continuous
picture of your health status.
</p>
<p>
Our unique feature is the ability to calculate your Modified Early Warning Score (MEWS) from your vitals data.
This system is used widely in healthcare settings to detect early signs of deterioration.
Now, it is available for you, right in the comfort of your home or on the go.
</p>
<p>
Prompted by periodic reminders, you'll be asked to take measurements which will be sent automatically to our platform.
Here, we calculate your MEWS and generate alerts if we detect an increased risk of health deterioration.
</p>
<p>
While we take care of your monitoring needs, you can enjoy your daily activities with peace of mind, knowing that a
dedicated team has your health in their sights.
Stay in control of your health with us, your personal health guardian.
</p>
<p>
Welcome aboard!
</p>
</div>
{% endblock content %} {% endblock content %}

View File

@ -4,4 +4,5 @@ from . import views
urlpatterns = [ urlpatterns = [
path("", views.index, name="home"), path("", views.index, name="home"),
path("dashboard/", views.dashboard, name="dashboard"),
] ]

View File

@ -3,3 +3,6 @@ from django.shortcuts import render
def index(request): def index(request):
return render(request, 'medwings/index.html') return render(request, 'medwings/index.html')
def dashboard(request):
return render(request, 'medwings/dashboard.html')

View File

@ -1,6 +1,11 @@
asgiref==3.7.2 asgiref==3.7.2
certifi==2023.7.22
charset-normalizer==3.2.0
Django==4.2.3 Django==4.2.3
idna==3.4
psycopg==3.1.9 psycopg==3.1.9
psycopg-binary==3.1.9 psycopg-binary==3.1.9
requests==2.31.0
sqlparse==0.4.4 sqlparse==0.4.4
typing_extensions==4.7.1 typing_extensions==4.7.1
urllib3==2.0.4

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

@ -0,0 +1,48 @@
from datetime import datetime, timedelta
from random import randint
import requests
from django.conf import settings
from urllib.parse import urlencode
def fetch_withings_tokens(authorization_code, redirect_uri):
token_url_base = "https://wbsapi.withings.net/v2/oauth2"
token_url_params = {
'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
}
token_url = f"{token_url_base}?{urlencode(token_url_params)}"
response = requests.get(token_url)
response.raise_for_status()
return response.json()
def mock_fetch_withings_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 = datetime.now()
request.session['withings_access_token_expiry'] = now + timedelta(seconds=response_data['body']['expires_in'])
request.session['withings_refresh_token_expiry'] = now + timedelta(days=365)

View File

@ -7,6 +7,7 @@ h1, h2, h3, h4, h5, h6 {
font-family: Kanit; font-family: Kanit;
font-weight: 600; font-weight: 600;
font-style: normal; font-style: normal;
text-align: center;
} }
h1 { h1 {
@ -47,6 +48,7 @@ input[type="submit"] {
} }
a.btn { a.btn {
text-align: center;
@apply rounded rounded-lg drop-shadow-md px-4 py-2; @apply rounded rounded-lg drop-shadow-md px-4 py-2;
@apply bg-accent-600; @apply bg-accent-600;
@apply font-semibold; @apply font-semibold;
@ -54,6 +56,7 @@ a.btn {
} }
a.btn-outline { a.btn-outline {
text-align: center;
@apply rounded rounded-lg drop-shadow-md px-4 py-2; @apply rounded rounded-lg drop-shadow-md px-4 py-2;
@apply text-accent-600 bg-accent-600/10; @apply text-accent-600 bg-accent-600/10;
@apply border-2 border-accent-600; @apply border-2 border-accent-600;
@ -73,7 +76,11 @@ header.global, main.global, footer.global {
} }
main.global { main.global {
display: flex;
flex-direction: column;
flex-grow: 1; flex-grow: 1;
justify-content: center;
align-items: center;
} }
div.status-message { div.status-message {
@ -88,3 +95,8 @@ div.status-message {
div.status-message.error { div.status-message.error {
@apply bg-failure/50; @apply bg-failure/50;
} }
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;
}

View File

@ -1,5 +1,5 @@
import './css/styles.css'; import './css/styles.css';
import 'htmx.org'; //import 'htmx.org';
import './ts/index.ts'; import './ts/index.ts';
window.htmx = require('htmx.org'); //window.htmx = require('htmx.org');

View File

@ -33,19 +33,25 @@ services:
expose: expose:
- "8000" - "8000"
volumes: volumes:
- ./app/manage.py:/app/manage.py:ro
- ./app/requirements.txt:/app/requirements.txt:ro
- ./app/core/:/app/core:ro
- ./app/authentication/:/app/authentication:ro - ./app/authentication/:/app/authentication:ro
- ./app/core/:/app/core:ro
- ./app/gotify/:/app/gotify:ro
- ./app/manage.py:/app/manage.py:ro
- ./app/medwings/:/app/medwings:ro - ./app/medwings/:/app/medwings:ro
- ./app/requirements.txt:/app/requirements.txt:ro
- ./app/static/:/app/static:ro - ./app/static/:/app/static:ro
- ./app/withings/:/app/withings:ro
environment: environment:
TZ: ${TIMEZONE}
PG_NAME: ${PG_NAME} PG_NAME: ${PG_NAME}
PG_USER: ${PG_USER} PG_USER: ${PG_USER}
PG_PASSWORD: ${PG_PASSWORD} PG_PASSWORD: ${PG_PASSWORD}
PG_HOST: ${PG_HOST} PG_HOST: ${PG_HOST}
PG_PORT: ${PG_PORT} PG_PORT: ${PG_PORT}
TZ: ${TIMEZONE} WITHINGS_CLIENT_ID: ${WITHINGS_CLIENT_ID}
WITHINGS_CLIENT_SECRET: ${WITHINGS_CLIENT_SECRET}
GOTIFY_USER: ${GOTIFY_USER}
GOTIFY_PASSWORD: ${GOTIFY_PASSWORD}
medwings-postgres: medwings-postgres:
image: postgres:alpine image: postgres:alpine
container_name: ${PG_HOST} container_name: ${PG_HOST}