diff --git a/.env b/.env deleted file mode 100644 index beb5120..0000000 --- a/.env +++ /dev/null @@ -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 diff --git a/README.md b/README.md index 2aa9952..708c201 100644 --- a/README.md +++ b/README.md @@ -91,10 +91,31 @@ Steps to create a new user's channel on gotify: # Deployment -This section is incomplete. - -1. Build the asset bundle: +Build the asset bundle: ```bash 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= +PG_HOST=medwings-postgres +PG_PORT=5432 +GOTIFY_USER= +GOTIFY_PASSWORD= +WITHINGS_CLIENT_ID= +WITHINGS_CLIENT_SECRET= +``` + +Substitute each `` 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 diff --git a/app/authentication/templates/authentication/register-continue.html b/app/authentication/templates/authentication/register-continue.html new file mode 100644 index 0000000..129883f --- /dev/null +++ b/app/authentication/templates/authentication/register-continue.html @@ -0,0 +1,12 @@ +{% extends 'core/base.html' %} +{% load static %} +{% block title %} + Medwings | Sign Up +{% endblock title %} + +{% block content %} +
+

Register

+

{{ auth_code }}

+
+{% endblock content %} diff --git a/app/authentication/templates/authentication/register-finalize.html b/app/authentication/templates/authentication/register-finalize.html new file mode 100644 index 0000000..c27d9e7 --- /dev/null +++ b/app/authentication/templates/authentication/register-finalize.html @@ -0,0 +1,12 @@ +{% extends 'core/base.html' %} +{% load static %} +{% block title %} + Medwings | Sign Up +{% endblock title %} + +{% block content %} +
+

Register

+

Nothing to see here.

+
+{% endblock content %} diff --git a/app/authentication/templates/authentication/register-init.html b/app/authentication/templates/authentication/register-init.html new file mode 100644 index 0000000..b245439 --- /dev/null +++ b/app/authentication/templates/authentication/register-init.html @@ -0,0 +1,21 @@ +{% extends 'core/base.html' %} +{% load static %} +{% block title %} + Medwings | Sign Up +{% endblock title %} + +{% block content %} +
+

Register

+

+ Something something glad you're signing up. +

+
+

To get started, please allow us to access your health data

+ Link Withings Account +
+

+ Something something why this is necessary. +

+
+{% endblock content %} diff --git a/app/authentication/urls.py b/app/authentication/urls.py index 37f71ff..96477d2 100644 --- a/app/authentication/urls.py +++ b/app/authentication/urls.py @@ -1,8 +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"), ] diff --git a/app/authentication/views.py b/app/authentication/views.py index 91ea44a..02275df 100644 --- a/app/authentication/views.py +++ b/app/authentication/views.py @@ -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') diff --git a/app/core/settings.py b/app/core/settings.py index a320b08..eddc481 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -13,24 +13,21 @@ 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 = [] # Application definition - INSTALLED_APPS = [ 'core', 'authentication', @@ -44,7 +41,6 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', ] - MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -54,9 +50,7 @@ MIDDLEWARE = [ 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] - ROOT_URLCONF = 'core.urls' - TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', @@ -72,13 +66,11 @@ TEMPLATES = [ }, }, ] - WSGI_APPLICATION = 'core.wsgi.application' # Database # https://docs.djangoproject.com/en/4.2/ref/settings/#databases - DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', @@ -93,7 +85,6 @@ DATABASES = { # Password validation # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators - AUTH_PASSWORD_VALIDATORS = [ { 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', @@ -115,25 +106,30 @@ 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'), +} +GOTIFY_CONFIG = { + 'USERNAME': getenv('GOTIFY_USER'), + 'PASSWORD': getenv('GOTIFY_PASSWORD'), +} diff --git a/app/medwings/templates/medwings/dashboard.html b/app/medwings/templates/medwings/dashboard.html new file mode 100644 index 0000000..a040c59 --- /dev/null +++ b/app/medwings/templates/medwings/dashboard.html @@ -0,0 +1,12 @@ +{% extends 'core/base.html' %} +{% load static %} +{% block title %} + Medwings | Dashboard +{% endblock title %} + +{% block content %} +
+

Dashboard

+ There is nothing here yet. +
+{% endblock content %} diff --git a/app/medwings/templates/medwings/index.html b/app/medwings/templates/medwings/index.html index 61de164..4865289 100644 --- a/app/medwings/templates/medwings/index.html +++ b/app/medwings/templates/medwings/index.html @@ -5,7 +5,47 @@ {% endblock title %} {% block content %} -

Welcome to Medwings

- There ain't much 'ere yet... - {% comment %}A withings thermometer.{% endcomment %} +
+

Welcome to Medwings

+ Your personal health guardian + {% comment %}A withings thermometer.{% endcomment %} +

+ 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. +

+
+ {% if not request.user.is_authenticated %} +

To use the platform, please log in:

+ Log In +

If you do not have an account yet, please register:

+ Create An Account + {% else %} +

View your latest health data to stay up to date:

+ Go to your personal dashboard + {% endif %} +
+

+ 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. +

+

+ 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. +

+

+ 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. +

+

+ 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. +

+

+ Welcome aboard! +

+
{% endblock content %} diff --git a/app/medwings/urls.py b/app/medwings/urls.py index 0a25a56..fa7d7c4 100644 --- a/app/medwings/urls.py +++ b/app/medwings/urls.py @@ -4,4 +4,5 @@ from . import views urlpatterns = [ path("", views.index, name="home"), + path("dashboard/", views.dashboard, name="dashboard"), ] diff --git a/app/medwings/views.py b/app/medwings/views.py index f8d7cb0..0b0aa29 100644 --- a/app/medwings/views.py +++ b/app/medwings/views.py @@ -3,3 +3,6 @@ from django.shortcuts import render def index(request): return render(request, 'medwings/index.html') + +def dashboard(request): + return render(request, 'medwings/dashboard.html') diff --git a/app/requirements.txt b/app/requirements.txt index 334498e..050dcb4 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1,6 +1,11 @@ asgiref==3.7.2 +certifi==2023.7.22 +charset-normalizer==3.2.0 Django==4.2.3 +idna==3.4 psycopg==3.1.9 psycopg-binary==3.1.9 +requests==2.31.0 sqlparse==0.4.4 typing_extensions==4.7.1 +urllib3==2.0.4 diff --git a/app/withings/api.py b/app/withings/api.py new file mode 100644 index 0000000..b2b16d0 --- /dev/null +++ b/app/withings/api.py @@ -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) diff --git a/assets/css/styles.css b/assets/css/styles.css index 1642238..f4915d3 100644 --- a/assets/css/styles.css +++ b/assets/css/styles.css @@ -7,6 +7,7 @@ h1, h2, h3, h4, h5, h6 { font-family: Kanit; font-weight: 600; font-style: normal; + text-align: center; } h1 { @@ -47,6 +48,7 @@ input[type="submit"] { } a.btn { + text-align: center; @apply rounded rounded-lg drop-shadow-md px-4 py-2; @apply bg-accent-600; @apply font-semibold; @@ -54,6 +56,7 @@ a.btn { } a.btn-outline { + text-align: center; @apply rounded rounded-lg drop-shadow-md px-4 py-2; @apply text-accent-600 bg-accent-600/10; @apply border-2 border-accent-600; @@ -73,7 +76,11 @@ header.global, main.global, footer.global { } main.global { + display: flex; + flex-direction: column; flex-grow: 1; + justify-content: center; + align-items: center; } div.status-message { @@ -88,3 +95,8 @@ div.status-message { div.status-message.error { @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; +} diff --git a/assets/main.js b/assets/main.js index eaab589..e21e131 100644 --- a/assets/main.js +++ b/assets/main.js @@ -1,5 +1,5 @@ import './css/styles.css'; -import 'htmx.org'; +//import 'htmx.org'; import './ts/index.ts'; -window.htmx = require('htmx.org'); +//window.htmx = require('htmx.org'); diff --git a/development.docker-compose.yml b/development.docker-compose.yml index 1aae4e1..fd4dfa6 100644 --- a/development.docker-compose.yml +++ b/development.docker-compose.yml @@ -33,19 +33,25 @@ services: expose: - "8000" 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/core/:/app/core:ro + - ./app/gotify/:/app/gotify:ro + - ./app/manage.py:/app/manage.py:ro - ./app/medwings/:/app/medwings:ro + - ./app/requirements.txt:/app/requirements.txt:ro - ./app/static/:/app/static:ro + - ./app/withings/:/app/withings:ro environment: + TZ: ${TIMEZONE} PG_NAME: ${PG_NAME} PG_USER: ${PG_USER} PG_PASSWORD: ${PG_PASSWORD} PG_HOST: ${PG_HOST} 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: image: postgres:alpine container_name: ${PG_HOST}