├── dtbase ├── __init__.py ├── core │ ├── __init__.py │ ├── exc.py │ ├── constants.py │ └── utils.py ├── backend │ ├── __init__.py │ ├── run_localdb.sh │ ├── database │ │ ├── __init__.py │ │ ├── users.py │ │ ├── sensor_locations.py │ │ └── queries.py │ ├── routers │ │ ├── __init__.py │ │ ├── auth.py │ │ └── user.py │ ├── main.py │ ├── exc.py │ ├── run.sh │ ├── config.py │ ├── models.py │ ├── create_app.py │ └── auth.py ├── frontend │ ├── __init__.py │ ├── .prettierignore │ ├── app │ │ ├── base │ │ │ ├── static │ │ │ │ ├── node_modules │ │ │ │ ├── images │ │ │ │ │ └── favicon.ico │ │ │ │ ├── typescript │ │ │ │ │ ├── datatables.ts │ │ │ │ │ ├── service_details.ts │ │ │ │ │ ├── interfaces.ts │ │ │ │ │ ├── utility.ts │ │ │ │ │ ├── location_form.ts │ │ │ │ │ ├── sensor_edit_form.ts │ │ │ │ │ ├── readings.ts │ │ │ │ │ ├── locations_table.ts │ │ │ │ │ ├── sensor_type_form.ts │ │ │ │ │ ├── location_schema_form.ts │ │ │ │ │ └── sensor_list_table.ts │ │ │ │ └── css │ │ │ │ │ ├── login.css │ │ │ │ │ └── custom.css │ │ │ ├── __init__.py │ │ │ ├── forms.py │ │ │ ├── templates │ │ │ │ ├── errors │ │ │ │ │ ├── backend_not_found.html │ │ │ │ │ ├── page_401.html │ │ │ │ │ ├── page_403.html │ │ │ │ │ ├── page_404.html │ │ │ │ │ └── page_500.html │ │ │ │ ├── login │ │ │ │ │ └── login.html │ │ │ │ ├── base_site.html │ │ │ │ └── site_template │ │ │ │ │ └── sidebar.html │ │ │ └── routes.py │ │ ├── home │ │ │ ├── __init__.py │ │ │ ├── routes.py │ │ │ └── templates │ │ │ │ └── index.html │ │ ├── models │ │ │ ├── __init__.py │ │ │ └── templates │ │ │ │ └── models.html │ │ ├── users │ │ │ ├── __init__.py │ │ │ ├── routes.py │ │ │ └── templates │ │ │ │ └── users.html │ │ ├── sensors │ │ │ ├── __init__.py │ │ │ └── templates │ │ │ │ ├── sensor_form.html │ │ │ │ ├── sensor_edit_form.html │ │ │ │ ├── sensor_list_table.html │ │ │ │ ├── sensor_type_form.html │ │ │ │ ├── time_series_plots.html │ │ │ │ └── readings.html │ │ ├── locations │ │ │ ├── __init__.py │ │ │ ├── templates │ │ │ │ ├── location_form.html │ │ │ │ ├── locations_table.html │ │ │ │ └── location_schema_form.html │ │ │ └── routes.py │ │ └── services │ │ │ ├── __init__.py │ │ │ └── templates │ │ │ └── services.html │ ├── .prettierrc │ ├── exc.py │ ├── tsconfig.json │ ├── frontend_app.py │ ├── .eslintrc │ ├── webpack.config.js │ ├── run.sh │ ├── config.py │ ├── package.json │ ├── user.py │ └── utils.py ├── ingress │ └── __init__.py ├── models │ ├── __init__.py │ └── utils │ │ ├── __init__.py │ │ └── sensor_data.py ├── services │ └── __init__.py └── functions │ ├── .dockerignore │ ├── local.settings.json │ ├── host.json │ ├── arima │ ├── function.json │ └── __init__.py │ ├── hodmd │ ├── function.json │ └── __init__.py │ └── ingress_weather │ ├── function.json │ └── __init__.py ├── tests ├── __init__.py ├── test_frontend_home.py ├── test_frontend_login.py ├── utils.py ├── test_sensor_locations.py ├── test_model_utils.py ├── test_api_auth.py ├── test_services_base.py ├── test_model_hodmd.py ├── test_frontend_services.py ├── test_users.py ├── upload_synthetic_data.py ├── test_model_arima.py ├── test_db.py ├── test_frontend_users.py ├── test_api_user.py └── test_ingress_weather.py ├── media └── README.md ├── infrastructure └── Pulumi.yaml ├── examples ├── images │ └── dtbase_frontend.png └── README.md ├── dockerfiles ├── Dockerfile.functions ├── Dockerfile.backend └── Dockerfile.frontend ├── .github └── workflows │ ├── pre-commit.yaml │ ├── backend-docker.yaml │ ├── frontend-docker.yaml │ ├── functions-docker.yaml │ └── tests.yaml ├── .pre-commit-config.yaml ├── LICENSE ├── compose.yaml ├── .gitignore ├── .secrets └── dtenv_template.sh └── pyproject.toml /dtbase/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dtbase/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dtbase/backend/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dtbase/frontend/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dtbase/ingress/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dtbase/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dtbase/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dtbase/backend/run_localdb.sh: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dtbase/models/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dtbase/backend/database/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dtbase/backend/routers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dtbase/functions/.dockerignore: -------------------------------------------------------------------------------- 1 | local.settings.json 2 | -------------------------------------------------------------------------------- /media/README.md: -------------------------------------------------------------------------------- 1 | Figures and such, used in documentation. 2 | -------------------------------------------------------------------------------- /dtbase/frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *templates* 3 | -------------------------------------------------------------------------------- /dtbase/frontend/app/base/static/node_modules: -------------------------------------------------------------------------------- 1 | ../../../node_modules/ -------------------------------------------------------------------------------- /infrastructure/Pulumi.yaml: -------------------------------------------------------------------------------- 1 | name: dtbase-azure 2 | runtime: python 3 | description: DTBase on Azure 4 | -------------------------------------------------------------------------------- /dtbase/frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": false, 4 | "printWidth": 88, 5 | "singleQuote": false 6 | } 7 | -------------------------------------------------------------------------------- /dtbase/frontend/exc.py: -------------------------------------------------------------------------------- 1 | class AuthorizationError(Exception): 2 | pass 3 | 4 | 5 | class BackendApiError(Exception): 6 | pass 7 | -------------------------------------------------------------------------------- /examples/images/dtbase_frontend.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alan-turing-institute/DTBase/HEAD/examples/images/dtbase_frontend.png -------------------------------------------------------------------------------- /dtbase/backend/main.py: -------------------------------------------------------------------------------- 1 | """Script that starts the FastAPI backend.""" 2 | from dtbase.backend.create_app import create_app 3 | 4 | app = create_app() 5 | -------------------------------------------------------------------------------- /dtbase/frontend/app/base/static/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alan-turing-institute/DTBase/HEAD/dtbase/frontend/app/base/static/images/favicon.ico -------------------------------------------------------------------------------- /dtbase/functions/local.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "FUNCTIONS_WORKER_RUNTIME": "python", 5 | "AzureWebJobsStorage": "" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /dtbase/core/exc.py: -------------------------------------------------------------------------------- 1 | class BackendCallError(Exception): 2 | """An error to be raised when an API request for the backend does not return the 3 | expected, successful result.""" 4 | 5 | pass 6 | -------------------------------------------------------------------------------- /dtbase/frontend/app/base/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | blueprint = Blueprint( 4 | "base_blueprint", 5 | __name__, 6 | url_prefix="", 7 | template_folder="templates", 8 | static_folder="static", 9 | ) 10 | -------------------------------------------------------------------------------- /dtbase/frontend/app/home/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | blueprint = Blueprint( 4 | "home_blueprint", 5 | __name__, 6 | url_prefix="/home", 7 | template_folder="templates", 8 | static_folder="static", 9 | ) 10 | -------------------------------------------------------------------------------- /dtbase/frontend/app/models/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | blueprint = Blueprint( 4 | "models_blueprint", 5 | __name__, 6 | url_prefix="/models", 7 | template_folder="templates", 8 | static_folder="static", 9 | ) 10 | -------------------------------------------------------------------------------- /dtbase/frontend/app/users/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | blueprint = Blueprint( 4 | "users_blueprint", 5 | __name__, 6 | url_prefix="/users", 7 | template_folder="templates", 8 | static_folder="static", 9 | ) 10 | -------------------------------------------------------------------------------- /dtbase/frontend/app/sensors/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | blueprint = Blueprint( 4 | "sensors_blueprint", 5 | __name__, 6 | url_prefix="/sensors", 7 | template_folder="templates", 8 | static_folder="static", 9 | ) 10 | -------------------------------------------------------------------------------- /dtbase/frontend/app/locations/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | blueprint = Blueprint( 4 | "locations_blueprint", 5 | __name__, 6 | url_prefix="/locations", 7 | template_folder="templates", 8 | static_folder="static", 9 | ) 10 | -------------------------------------------------------------------------------- /dtbase/frontend/app/services/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | blueprint = Blueprint( 4 | "services_blueprint", 5 | __name__, 6 | url_prefix="/services", 7 | template_folder="templates", 8 | static_folder="static", 9 | ) 10 | -------------------------------------------------------------------------------- /dtbase/backend/exc.py: -------------------------------------------------------------------------------- 1 | class RowExistsError(Exception): 2 | pass 3 | 4 | 5 | class RowMissingError(Exception): 6 | pass 7 | 8 | 9 | class TooManyRowsError(Exception): 10 | pass 11 | 12 | 13 | class DatabaseConnectionError(Exception): 14 | pass 15 | -------------------------------------------------------------------------------- /dtbase/backend/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export LC_ALL=C.UTF-8 4 | export LANG=C.UTF-8 5 | 6 | if [ -n "$1" ] && [ "$1" -gt "-1" ] 7 | then 8 | bport=$1 9 | else 10 | bport=5000 11 | fi 12 | 13 | uvicorn main:app --port $bport --host 0.0.0.0 --reload 14 | -------------------------------------------------------------------------------- /dtbase/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "es6", 5 | "noEmitOnError": false, 6 | "sourceMap": true, 7 | "moduleResolution": "node", 8 | "strict": true 9 | }, 10 | "include": ["app/base/static/typescript/**/*.ts"], 11 | "exclude": ["node_modules"] 12 | } 13 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile.functions: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/azure-functions/python:4-python3.10-appservice 2 | 3 | ENV AzureWebJobsScriptRoot=/home/site/wwwroot \ 4 | AzureFunctionsJobHost__Logging__Console__IsEnabled=true 5 | 6 | COPY dtbase /dtbase 7 | COPY pyproject.toml / 8 | RUN pip install . 9 | 10 | COPY dtbase/functions/. /home/site/wwwroot 11 | -------------------------------------------------------------------------------- /dtbase/functions/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingSettings": { 6 | "isEnabled": true, 7 | "excludedTypes": "Request" 8 | } 9 | } 10 | }, 11 | "extensionBundle": { 12 | "id": "Microsoft.Azure.Functions.ExtensionBundle", 13 | "version": "[2.*, 3.0.0)" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /dtbase/frontend/app/home/routes.py: -------------------------------------------------------------------------------- 1 | """ 2 | A module for the main dashboard actions 3 | """ 4 | 5 | from flask import render_template 6 | from flask_login import login_required 7 | 8 | from dtbase.frontend.app.home import blueprint 9 | 10 | 11 | @blueprint.route("/index") 12 | @login_required 13 | def index() -> str: 14 | """Index page.""" 15 | 16 | return render_template("index.html") 17 | -------------------------------------------------------------------------------- /dtbase/functions/arima/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "scriptFile": "__init__.py", 3 | "bindings": [ 4 | { 5 | "authLevel": "Function", 6 | "type": "httpTrigger", 7 | "direction": "in", 8 | "name": "req", 9 | "methods": [ 10 | "get", 11 | "post" 12 | ] 13 | }, 14 | { 15 | "type": "http", 16 | "direction": "out", 17 | "name": "$return" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /dtbase/functions/hodmd/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "scriptFile": "__init__.py", 3 | "bindings": [ 4 | { 5 | "authLevel": "Function", 6 | "type": "httpTrigger", 7 | "direction": "in", 8 | "name": "req", 9 | "methods": [ 10 | "get", 11 | "post" 12 | ] 13 | }, 14 | { 15 | "type": "http", 16 | "direction": "out", 17 | "name": "$return" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /dtbase/functions/ingress_weather/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "scriptFile": "__init__.py", 3 | "bindings": [ 4 | { 5 | "authLevel": "Function", 6 | "type": "httpTrigger", 7 | "direction": "in", 8 | "name": "req", 9 | "methods": [ 10 | "get", 11 | "post" 12 | ] 13 | }, 14 | { 15 | "type": "http", 16 | "direction": "out", 17 | "name": "$return" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /tests/test_frontend_home.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test that the DTBase homepage loads 3 | """ 4 | from flask.testing import FlaskClient 5 | 6 | 7 | def test_home(auth_frontend_client: FlaskClient) -> None: 8 | with auth_frontend_client as client: 9 | response = client.get("/home/index") 10 | assert response.status_code == 200 11 | html_content = response.data.decode("utf-8") 12 | assert "Welcome to DTBase" in html_content 13 | -------------------------------------------------------------------------------- /dtbase/frontend/frontend_app.py: -------------------------------------------------------------------------------- 1 | import os 2 | from sys import exit 3 | 4 | from dtbase.frontend.app import create_app 5 | from dtbase.frontend.config import config_dict 6 | 7 | get_config_mode = os.environ.get("DT_CONFIG_MODE", "Production") 8 | 9 | try: 10 | config_mode = config_dict[get_config_mode.capitalize()] 11 | except KeyError: 12 | exit("Error: Invalid DT_CONFIG_MODE environment variable entry.") 13 | 14 | app = create_app(config_mode) 15 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile.backend: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | 3 | # Upgrade various packages 4 | RUN apt update 5 | RUN apt dist-upgrade -y 6 | RUN python3 -m pip install --upgrade pip 7 | 8 | # Copy dtbase over and install it 9 | RUN mkdir DTBase 10 | WORKDIR DTBase 11 | ADD dtbase dtbase 12 | ADD pyproject.toml pyproject.toml 13 | RUN python3 -m pip install . 14 | 15 | # Launch the webapp 16 | WORKDIR /DTBase/dtbase/backend 17 | EXPOSE 5000 18 | CMD ["./run.sh"] 19 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | A series of guides to demonstrate some common workflows using DTBase. These guides aim to expand on the developer docs to offer the developer some support in using DTBase for the first time. 4 | 5 | Note, in some places, these notebooks aren't necessarily designed to be run, but rather as documentation where images, code and text can live happily together. 6 | 7 | If the user does want to interact with the notebooks, then they need to install jupyter notebooks: 8 | 9 | `pip install jupyter notebook` 10 | -------------------------------------------------------------------------------- /dtbase/frontend/app/home/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% block title %} Home {% endblock title %} 4 | 5 | {% block content %} 6 |
7 |
8 |

Welcome to {{config['WEBSITE_NAME']}}

9 |

A generalised app for digital twins.

10 | Get started 11 |
12 |
13 | {% endblock content %} 14 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yaml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | on: [push, pull_request] 3 | env: 4 | PYTHON_VERSION: "3.10" 5 | jobs: 6 | pre-commit: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: actions/setup-python@v4 11 | with: 12 | python-version: ${{ env.PYTHON_VERSION }} 13 | - name: Install dependencies 14 | shell: bash 15 | run: | 16 | python -m ensurepip 17 | python -m pip install --upgrade pip 18 | python -m pip install .[dev] 19 | npm install --prefix dtbase/frontend/ --include=dev 20 | - uses: pre-commit/action@v3.0.0 21 | -------------------------------------------------------------------------------- /dockerfiles/Dockerfile.frontend: -------------------------------------------------------------------------------- 1 | FROM python:3.10-slim 2 | 3 | # Upgrade various packages 4 | RUN apt update 5 | RUN apt dist-upgrade -y 6 | RUN python3 -m pip install --upgrade pip 7 | 8 | # Install nodejs/npm (requires first installing curl) 9 | RUN apt install -y curl 10 | RUN curl -fsSL https://deb.nodesource.com/setup_21.x | bash - 11 | RUN apt install -y nodejs 12 | 13 | # Copy dtbase over and install it 14 | RUN mkdir DTBase 15 | WORKDIR DTBase 16 | ADD dtbase dtbase 17 | ADD pyproject.toml pyproject.toml 18 | RUN python3 -m pip install . 19 | RUN npm install --prefix dtbase/frontend/ 20 | 21 | # Launch the webapp 22 | WORKDIR /DTBase/dtbase/frontend 23 | EXPOSE 8000 24 | CMD ["./run.sh"] 25 | -------------------------------------------------------------------------------- /dtbase/frontend/app/base/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms.fields import EmailField, PasswordField, SelectField, StringField, URLField 3 | 4 | 5 | class LoginForm(FlaskForm): 6 | email = EmailField("Email", id="email_login") 7 | password = PasswordField("Password", id="pwd_login") 8 | 9 | 10 | class NewUserForm(FlaskForm): 11 | email = EmailField("Email", id="email_login") 12 | password = PasswordField("Password", id="pwd_login") 13 | 14 | 15 | class NewServiceForm(FlaskForm): 16 | name = StringField("Name", id="name") 17 | url = URLField("URL", id="url") 18 | http_method = SelectField( 19 | "HTTP method", choices=["POST", "GET", "PUT", "DELETE"], id="http_method" 20 | ) 21 | -------------------------------------------------------------------------------- /dtbase/frontend/app/base/static/typescript/datatables.ts: -------------------------------------------------------------------------------- 1 | import DataTable from "datatables.net-bs5" 2 | import "datatables.net-buttons-bs5" 3 | import "datatables.net-buttons/js/buttons.html5" 4 | import "datatables.net-fixedheader-bs5" 5 | import "datatables.net-responsive-bs5" 6 | 7 | export function initialiseDataTable(selector: string): void { 8 | const table = new DataTable(selector, { 9 | buttons: ["copy", "csv"], 10 | fixedHeader: true, 11 | responsive: true, 12 | }) 13 | table.buttons().container().appendTo(table.table().container()) 14 | } 15 | 16 | declare global { 17 | interface Window { 18 | initialiseDataTable: (selector: string) => void 19 | } 20 | } 21 | window.initialiseDataTable = initialiseDataTable 22 | -------------------------------------------------------------------------------- /dtbase/frontend/app/base/static/typescript/service_details.ts: -------------------------------------------------------------------------------- 1 | export function validateJSON(event: Event, buttonsToDisable: HTMLButtonElement[] = []) { 2 | const target = event.target as HTMLTextAreaElement 3 | let valid = true 4 | try { 5 | JSON.parse(target.value) 6 | } catch (e) { 7 | valid = false 8 | } 9 | // is-invalid is a bootstrap class 10 | if (!valid) { 11 | target.classList.add("is-invalid") 12 | } else { 13 | target.classList.remove("is-invalid") 14 | } 15 | 16 | for (const button of buttonsToDisable) { 17 | button.disabled = !valid 18 | } 19 | } 20 | 21 | declare global { 22 | interface Window { 23 | validateJSON: (event: Event) => void 24 | } 25 | } 26 | window.validateJSON = validateJSON 27 | -------------------------------------------------------------------------------- /dtbase/functions/arima/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from azure.functions import HttpRequest, HttpResponse 6 | 7 | from dtbase.models.arima import ArimaModel 8 | 9 | 10 | def main(req: HttpRequest) -> HttpResponse: 11 | logging.info("Starting Arima function.") 12 | 13 | try: 14 | config = req.get_json() 15 | except ValueError: 16 | if req.get_body(): 17 | return HttpResponse("Malformed body, JSON expected", status_code=400) 18 | config = None 19 | 20 | arima = ArimaModel(config) 21 | arima() 22 | 23 | logging.info("Finished Arima function.") 24 | return HttpResponse("Successfully ran Arima.", status_code=200) 25 | -------------------------------------------------------------------------------- /dtbase/functions/hodmd/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | 5 | from azure.functions import HttpRequest, HttpResponse 6 | 7 | from dtbase.models.hodmd import HODMDModel 8 | 9 | 10 | def main(req: HttpRequest) -> HttpResponse: 11 | logging.info("Starting HODMD function.") 12 | 13 | try: 14 | config = req.get_json() 15 | except ValueError: 16 | if req.get_body(): 17 | return HttpResponse("Malformed body, JSON expected", status_code=400) 18 | config = None 19 | 20 | model = HODMDModel(config) 21 | model() 22 | 23 | logging.info("Finished HODMD function.") 24 | return HttpResponse("Successfully ran HODMD.", status_code=200) 25 | -------------------------------------------------------------------------------- /dtbase/frontend/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 6, 4 | "sourceType": "module", 5 | "ecmaFeatures": { 6 | "jsx": true 7 | } 8 | }, 9 | "env": { 10 | "browser": true, 11 | "jquery": true 12 | }, 13 | "rules": { 14 | "@typescript-eslint/no-explicit-any": "off", 15 | "consistent-return": 2, 16 | "no-else-return": 1, 17 | "space-unary-ops": 0, 18 | "require-jsdoc": 0, 19 | "no-unused-vars": 0, 20 | "no-undef": 0, 21 | "camelcase": 0 22 | }, 23 | "extends": [ 24 | "eslint:recommended", 25 | "plugin:@typescript-eslint/recommended", 26 | "prettier" 27 | ], 28 | "parser": "@typescript-eslint/parser", 29 | "plugins": ["@typescript-eslint"], 30 | "root": true 31 | } 32 | -------------------------------------------------------------------------------- /dtbase/frontend/app/base/templates/errors/backend_not_found.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% block title %} Page 404 {% endblock title %} 4 | 5 | {% block stylesheets %} 6 | {{ super() }} 7 | {% endblock stylesheets %} 8 | 9 | {% block body %} 10 |
11 |
12 |
13 |

404

14 |

Backend API not found

15 |

No response was received from the DTBase backend.

16 |

Try logging in again?

17 |
18 |
19 |
20 | {% endblock body %} 21 | 22 | {% block footer %}{% endblock footer %} 23 | 24 | {% block javascripts %} 25 | {{ super() }} 26 | {% endblock javascripts %} 27 | -------------------------------------------------------------------------------- /dtbase/frontend/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | const glob = require("glob") 3 | 4 | // Dynamically generate entry points 5 | const entries = {} 6 | glob.sync("./app/base/static/typescript/**/*.ts").forEach((filePath) => { 7 | const entry = path.basename(filePath).replace(/\.[^.]+$/, "") // remove extension 8 | entries[entry] = "./" + filePath 9 | }) 10 | 11 | module.exports = { 12 | entry: entries, 13 | devtool: "source-map", 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.ts$/, 18 | use: "ts-loader", 19 | exclude: /node_modules/, 20 | }, 21 | ], 22 | }, 23 | resolve: { 24 | extensions: [".ts", ".js"], 25 | }, 26 | output: { 27 | filename: "[name].js", 28 | path: path.resolve(__dirname, "./app/base/static/javascript"), 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /dtbase/frontend/app/base/templates/errors/page_401.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% block title %} Page 401 {% endblock title %} 4 | 5 | {% block stylesheets %} 6 | {{ super() }} 7 | {% endblock stylesheets %} 8 | 9 | {% block sidebar %}{% endblock sidebar %} 10 | 11 | {% block top_navigation %}{% endblock top_navigation %} 12 | 13 | {% block content %} 14 |
15 |
16 |
17 |

401

18 |

Authentication Failure

19 |

Something went wrong when trying to authenticate you as a user. Possible 20 | reasons are wrong password, wrong username, or an error at our end.

21 |
22 |
23 |
24 | {% endblock content %} 25 | 26 | {% block footer %}{% endblock footer %} 27 | 28 | {% block javascripts %} 29 | {{ super() }} 30 | {% endblock javascripts %} 31 | -------------------------------------------------------------------------------- /dtbase/frontend/app/base/static/typescript/interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface LocationIdentifier { 2 | id: number 3 | name: string 4 | datatype: string 5 | units: string 6 | } 7 | 8 | export interface LocationSchema { 9 | id: number 10 | name: string 11 | description: string 12 | identifiers: LocationIdentifier[] 13 | } 14 | 15 | export interface Location { 16 | id: number 17 | [key: string]: number | string | boolean 18 | } 19 | 20 | export interface ModelScenario { 21 | id: number 22 | model_id: number 23 | model_name: string 24 | description: string | null 25 | } 26 | 27 | export interface TimeseriesDataPoint { 28 | timestamp: string 29 | value: T 30 | } 31 | 32 | export interface Sensor { 33 | id: number 34 | name: string 35 | notes: string 36 | sensor_type_id: number 37 | sensor_type_name: string 38 | unique_identifier: string 39 | } 40 | 41 | export interface SensorMeasure { 42 | datatype: string 43 | id: number 44 | name: string 45 | units: string 46 | } 47 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v3.2.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-yaml 10 | - id: check-added-large-files 11 | - repo: https://github.com/psf/black 12 | rev: 22.3.0 13 | hooks: 14 | - id: black 15 | language: python 16 | - repo: https://github.com/astral-sh/ruff-pre-commit 17 | # Ruff version. 18 | rev: v0.1.3 19 | hooks: 20 | - id: ruff 21 | args: [dtbase, infrastructure, --fix] 22 | - repo: https://github.com/pre-commit/mirrors-prettier 23 | rev: v3.1.0 24 | hooks: 25 | - id: prettier 26 | files: ^dtbase/frontend 27 | args: [--ignore-path=dtbase/frontend/.prettierignore] 28 | - repo: https://github.com/pre-commit/mirrors-eslint 29 | rev: v8.56.0 30 | hooks: 31 | - id: eslint 32 | exclude: ^dtbase/frontend/node_modules 33 | files: \.tsx?$ 34 | types: [file] 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 The Alan Turing Institute 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres:16.2 4 | restart: always 5 | ports: 6 | - "5432:5432" 7 | environment: 8 | - POSTGRES_USER=${DT_SQL_USER} 9 | - POSTGRES_PASSWORD=${DT_SQL_PASS} 10 | volumes: 11 | - app_data:/var/lib/postgresql/data 12 | backend: 13 | build: 14 | context: . 15 | dockerfile: dockerfiles/Dockerfile.backend 16 | image: dtbase_backend 17 | ports: 18 | - "5000:5000" 19 | depends_on: 20 | - db 21 | environment: 22 | - DT_SQL_USER=${DT_SQL_USER} 23 | - DT_SQL_PASS=${DT_SQL_PASS} 24 | - DT_SQL_HOST=db 25 | - DT_SQL_PORT=5432 26 | - DT_SQL_DBNAME=${DT_SQL_DBNAME} 27 | - DT_DEFAULT_USER_PASS=${DT_DEFAULT_USER_PASS} 28 | - DT_JWT_SECRET_KEY=${DT_JWT_SECRET_KEY} 29 | frontend: 30 | build: 31 | context: . 32 | dockerfile: dockerfiles/Dockerfile.frontend 33 | image: dtbase_frontend 34 | ports: 35 | - "8000:8000" 36 | depends_on: 37 | - backend 38 | environment: 39 | - DT_BACKEND_URL=http://backend:5000 40 | - DT_FRONT_SECRET_KEY=${DT_FRONT_SECRET_KEY} 41 | volumes: 42 | app_data: 43 | -------------------------------------------------------------------------------- /dtbase/frontend/app/base/static/typescript/utility.ts: -------------------------------------------------------------------------------- 1 | interface XYDataPoint { 2 | x: T1 3 | y: T2 4 | } 5 | 6 | // Zip to arrays [x0, x1, x2, ...] and [y0, y1, y2, ...] into 7 | // [{x: x0, y: 01}, {x: x1, y: y1}, {x: x2, y: y2}, ...] 8 | // Used to gather data for plots into a format preferred by Chart.js 9 | export function dictionary_scatter(x: T1[], y: T2[]): XYDataPoint[] { 10 | const value_array: XYDataPoint[] = [] 11 | for (let j = 0; j < y.length; j++) { 12 | const mydict = { x: x[j], y: y[j] } 13 | value_array.push(mydict) 14 | } 15 | return value_array 16 | } 17 | 18 | // function used to toggle the visibility of the password field 19 | export function passwordToggle(): void { 20 | const passwordField = document.getElementById("pwd_login") as HTMLInputElement 21 | const passwordFieldType = passwordField.getAttribute("type") 22 | if (passwordFieldType === "password") { 23 | passwordField.setAttribute("type", "text") 24 | } else { 25 | passwordField.setAttribute("type", "password") 26 | } 27 | } 28 | 29 | declare global { 30 | interface Window { 31 | passwordToggle: () => void 32 | } 33 | } 34 | window.passwordToggle = passwordToggle 35 | -------------------------------------------------------------------------------- /tests/test_frontend_login.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from flask.testing import FlaskClient 4 | 5 | 6 | def test_elements(frontend_client: FlaskClient) -> None: 7 | with frontend_client as client: 8 | # Get the login page 9 | response = client.get("/login", follow_redirects=True) 10 | 11 | assert response.status_code == 200 12 | 13 | decode = response.data.decode() 14 | 15 | # Check if there's a form with the username and password inputs 16 | username_input = re.search(r']*name="email"[^>]*>', decode) 17 | password_input = re.search(r']*name="password"[^>]*>', decode) 18 | assert username_input is not None, "Username input not found" 19 | assert password_input is not None, "Password input not found" 20 | 21 | # Check the login button 22 | login_button = re.search(r"]*name=\"login\"[^>]*>", decode) 23 | assert login_button is not None, "Login button not found" 24 | 25 | # Find the password toggle button 26 | eye_button = re.search( 27 | r"]*id=\"show-password\"[^>]*>", response.data.decode() 28 | ) 29 | assert eye_button is not None, "Password toggle button not found" 30 | -------------------------------------------------------------------------------- /dtbase/frontend/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export LC_ALL=C.UTF-8 4 | export LANG=C.UTF-8 5 | 6 | export FLASK_APP=frontend_app.py 7 | 8 | if [ -n "$1" ] && [ "$1" -gt "-1" ] 9 | then 10 | bport=$1 11 | else 12 | bport=8000 13 | fi 14 | 15 | if [ -z "$DT_CONFIG_MODE" ] || [ "$DT_CONFIG_MODE" == "Production" ]; then 16 | debug=false 17 | if [ -z "$FLASK_DEBUG" ]; then 18 | export FLASK_DEBUG=false 19 | fi 20 | else 21 | debug=true 22 | if [ -z "$FLASK_DEBUG" ]; then 23 | export FLASK_DEBUG=true 24 | fi 25 | fi 26 | 27 | npm install 28 | if [ "$debug" == "false" ]; then 29 | # We are using development mode for webpack for as long as typescript transpilation 30 | # raises errors. In production a single error halts the transpilation. 31 | npx webpack --mode=development 32 | flask run --host=0.0.0.0 --port $bport 33 | else 34 | # This runs webpack and the flask app concurrently. 35 | # This only matters when in development/debug mode, in which case we run webpack with 36 | # --watch, to have it retranspile and bundle every time a source file is changed. 37 | npx concurrently --kill-others -n webpack,flask "npx webpack --mode=development --watch" "flask run --host=0.0.0.0 --port $bport" 38 | fi 39 | -------------------------------------------------------------------------------- /dtbase/frontend/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class Config: 5 | SECRET_KEY = os.environ.get("DT_FRONT_SECRET_KEY", None) 6 | # THEME SUPPORT 7 | # if set then url_for('static', filename='', theme='') 8 | # will add the theme name to the static URL: 9 | # /static//filename 10 | # DEFAULT_THEME = "themes/dark" 11 | DEFAULT_THEME = None 12 | 13 | 14 | class ProductionConfig(Config): 15 | DEBUG = False 16 | DISABLE_REGISTER = True 17 | 18 | # Security 19 | SESSION_COOKIE_HTTPONLY = True 20 | REMEMBER_COOKIE_HTTPONLY = True 21 | REMEMBER_COOKIE_DURATION = 3600 22 | 23 | 24 | class TestConfig(Config): 25 | DEBUG = False 26 | DISABLE_REGISTER = True 27 | 28 | # Security 29 | SESSION_COOKIE_HTTPONLY = True 30 | REMEMBER_COOKIE_HTTPONLY = True 31 | REMEMBER_COOKIE_DURATION = 3600 32 | 33 | # Testing 34 | TESTING = True 35 | 36 | 37 | class DebugConfig(Config): 38 | DEBUG = True 39 | DISABLE_REGISTER = True 40 | 41 | 42 | class AutoLoginConfig(DebugConfig): 43 | pass 44 | 45 | 46 | config_dict = { 47 | "Production": ProductionConfig, 48 | "Test": TestConfig, 49 | "Debug": DebugConfig, 50 | "Auto-login": AutoLoginConfig, 51 | } 52 | -------------------------------------------------------------------------------- /dtbase/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "DTBase", 3 | "description": "This is the web front-end of the DTBase Digital Twin Template. It is not meant to be installed as a package, we only use npm to manage dependencies and development configurations.", 4 | "homepage": "https://github.com/alan-turing-institute/DTBase", 5 | "license": "MIT", 6 | "author": "Research Engineering Group @ The Alan Turing Institute", 7 | "scripts": { 8 | "lint": "eslint ." 9 | }, 10 | "dependencies": { 11 | "bootstrap": "^5.2.3", 12 | "chart.js": "^4.4.1", 13 | "chartjs-adapter-moment": "^1.0.1", 14 | "datatables.net-bs5": "^1.13.4", 15 | "datatables.net-buttons-bs5": "^2.3.6", 16 | "datatables.net-fixedheader-bs5": "^3.3.2", 17 | "datatables.net-responsive-bs5": "^2.4.1", 18 | "font-awesome": "^4.7.0", 19 | "glob": "^10.3.10", 20 | "moment": "^2.29.4", 21 | "ts-loader": "^9.5.1", 22 | "webpack": "^5.89.0", 23 | "webpack-cli": "^5.1.4" 24 | }, 25 | "devDependencies": { 26 | "@typescript-eslint/eslint-plugin": "^7.1.0", 27 | "@typescript-eslint/parser": "^7.1.0", 28 | "eslint": "^8.56.0", 29 | "eslint-config-prettier": "^9.1.0", 30 | "prettier": "^3.1.0", 31 | "typescript": "^5.3.3" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/backend-docker.yaml: -------------------------------------------------------------------------------- 1 | name: "Build and push backend to Docker image" 2 | 3 | on: 4 | push: 5 | branches: [main, develop, test-actions] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Login to Docker Hub 13 | uses: docker/login-action@v1 14 | with: 15 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 16 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 17 | - uses: actions/checkout@v3 18 | - name: 'Build main' 19 | if: ${{ github.ref == 'refs/heads/main' }} 20 | run: | 21 | docker build -f dockerfiles/Dockerfile.backend -t turingcropapp/dtbase-backend:main . 22 | docker push turingcropapp/dtbase-backend:main 23 | - name: 'Build dev' 24 | if: ${{ github.ref == 'refs/heads/develop' }} 25 | run: | 26 | docker build -f dockerfiles/Dockerfile.backend -t turingcropapp/dtbase-backend:dev . 27 | docker push turingcropapp/dtbase-backend:dev 28 | - name: 'Build test-actions' 29 | if: ${{ github.ref == 'refs/heads/test-actions' }} 30 | run: | 31 | docker build -f dockerfiles/Dockerfile.backend -t turingcropapp/dtbase-backend:test-actions . 32 | docker push turingcropapp/dtbase-backend:test-actions 33 | -------------------------------------------------------------------------------- /.github/workflows/frontend-docker.yaml: -------------------------------------------------------------------------------- 1 | name: "Build and push frontend to Docker image" 2 | 3 | on: 4 | push: 5 | branches: [main, develop, test-actions] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Login to Docker Hub 13 | uses: docker/login-action@v1 14 | with: 15 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 16 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 17 | - uses: actions/checkout@v3 18 | - name: 'Build main' 19 | if: ${{ github.ref == 'refs/heads/main' }} 20 | run: | 21 | docker build -f dockerfiles/Dockerfile.frontend -t turingcropapp/dtbase-frontend:main . 22 | docker push turingcropapp/dtbase-frontend:main 23 | - name: 'Build dev' 24 | if: ${{ github.ref == 'refs/heads/develop' }} 25 | run: | 26 | docker build -f dockerfiles/Dockerfile.frontend -t turingcropapp/dtbase-frontend:dev . 27 | docker push turingcropapp/dtbase-frontend:dev 28 | - name: 'Build test-actions' 29 | if: ${{ github.ref == 'refs/heads/test-actions' }} 30 | run: | 31 | docker build -f dockerfiles/Dockerfile.frontend -t turingcropapp/dtbase-frontend:test-actions . 32 | docker push turingcropapp/dtbase-frontend:test-actions 33 | -------------------------------------------------------------------------------- /.github/workflows/functions-docker.yaml: -------------------------------------------------------------------------------- 1 | name: "Build and push Docker image for Azure functions" 2 | 3 | on: 4 | push: 5 | branches: [main, develop, test-actions] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Login to Docker Hub 13 | uses: docker/login-action@v1 14 | with: 15 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 16 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 17 | - uses: actions/checkout@v3 18 | - name: 'Build main' 19 | if: ${{ github.ref == 'refs/heads/main' }} 20 | run: | 21 | docker build -f dockerfiles/Dockerfile.functions -t turingcropapp/dtbase-functions:main . 22 | docker push turingcropapp/dtbase-functions:main 23 | - name: 'Build dev' 24 | if: ${{ github.ref == 'refs/heads/develop' }} 25 | run: | 26 | docker build -f dockerfiles/Dockerfile.functions -t turingcropapp/dtbase-functions:dev . 27 | docker push turingcropapp/dtbase-functions:dev 28 | - name: 'Build test-actions' 29 | if: ${{ github.ref == 'refs/heads/test-actions' }} 30 | run: | 31 | docker build -f dockerfiles/Dockerfile.functions -t turingcropapp/dtbase-functions:test-actions . 32 | docker push turingcropapp/dtbase-functions:test-actions 33 | -------------------------------------------------------------------------------- /dtbase/frontend/app/base/static/typescript/location_form.ts: -------------------------------------------------------------------------------- 1 | import { LocationSchema } from "./interfaces" 2 | 3 | export function updateForm(schemas: LocationSchema[]): void { 4 | const schemaId = (document.getElementById("schema") as HTMLSelectElement).value 5 | const identifiersDiv = document.getElementById("identifiers") as HTMLDivElement 6 | identifiersDiv.innerHTML = "" 7 | 8 | // find the selected schema 9 | const selectedSchema = schemas.find((schema) => schema.name == schemaId) 10 | 11 | // If there's no selected schema or it has no identifiers, then return. 12 | if (!selectedSchema || !selectedSchema.identifiers) return 13 | 14 | // Add form fields for each identifier 15 | for (const identifier of selectedSchema.identifiers) { 16 | const identifierDiv = document.createElement("div") 17 | identifierDiv.className = "form-group" 18 | identifierDiv.innerHTML = ` 19 | 20 | 21 | ` 22 | identifiersDiv.appendChild(identifierDiv) 23 | } 24 | } 25 | 26 | declare global { 27 | interface Window { 28 | updateForm: (selector: LocationSchema[]) => void 29 | } 30 | } 31 | window.updateForm = updateForm 32 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | from httpx import Response 3 | 4 | TEST_USER_EMAIL = "test@test.com" 5 | TEST_USER_PASSWORD = "test" 6 | 7 | 8 | def get_token( 9 | client: TestClient, email: str = TEST_USER_EMAIL, password: str = TEST_USER_PASSWORD 10 | ) -> Response: 11 | """Get an authentication token. 12 | 13 | By default uses the test user, defined above, but email and password can also be 14 | provided as keyword arguments. 15 | """ 16 | type_data = {"email": email, "password": password} 17 | response = client.post("/auth/login", json=type_data) 18 | return response 19 | 20 | 21 | def can_login( 22 | client: TestClient, email: str = TEST_USER_EMAIL, password: str = TEST_USER_PASSWORD 23 | ) -> bool: 24 | """Return true if the given credentials can be used to log in.""" 25 | response = get_token(client, email=email, password=password) 26 | body = response.json 27 | return ( 28 | response.status_code == 200 29 | and body is not None 30 | and set(body().keys()) == {"access_token", "refresh_token"} 31 | ) 32 | 33 | 34 | def assert_unauthorized(client: TestClient, method: str, endpoint: str) -> None: 35 | """Assert that calling the given endpoint with the given client returns 401.""" 36 | method_func = getattr(client, method) 37 | response = method_func(endpoint) 38 | assert response.status_code == 401 39 | -------------------------------------------------------------------------------- /dtbase/frontend/app/base/templates/errors/page_403.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% block title %} Page 403 {% endblock title %} 4 | 5 | {% block stylesheets %} 6 | {{ super() }} 7 | {% endblock stylesheets %} 8 | 9 | {% block sidebar %}{% endblock sidebar %} 10 | 11 | {% block top_navigation %}{% endblock top_navigation %} 12 | 13 | {% block content %} 14 |
15 |
16 |
17 |

403

18 |

Access Denied

19 |

Full authentication is required to access this resource. Report this? 20 |

21 |
22 |

Search

23 |
24 | 32 |
33 |
34 |
35 |
36 |
37 | {% endblock content %} 38 | 39 | {% block footer %}{% endblock footer %} 40 | 41 | {% block javascripts %} 42 | {{ super() }} 43 | {% endblock javascripts %} 44 | -------------------------------------------------------------------------------- /dtbase/frontend/app/base/templates/errors/page_404.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% block title %} Page 404 {% endblock title %} 4 | 5 | {% block stylesheets %} 6 | {{ super() }} 7 | {% endblock stylesheets %} 8 | 9 | {% block sidebar %}{% endblock sidebar %} 10 | 11 | {% block top_navigation %}{% endblock top_navigation %} 12 | 13 | {% block content %} 14 |
15 |
16 |
17 |

404

18 |

Sorry but we couldn't find this page

19 |

This page you are looking for does not exist Report this? 20 |

21 |
22 |

Search

23 |
24 | 32 |
33 |
34 |
35 |
36 |
37 | {% endblock content %} 38 | 39 | {% block footer %}{% endblock footer %} 40 | 41 | {% block javascripts %} 42 | {{ super() }} 43 | {% endblock javascripts %} 44 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: "Unit tests" 2 | 3 | on: 4 | push: 5 | branches: [main, develop, test-actions] 6 | pull_request: 7 | branches: [main, develop] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.10", "3.11", "3.12"] 16 | env: 17 | DT_SQL_TESTHOST: "localhost" 18 | DT_SQL_TESTUSER: "postgres" 19 | DT_SQL_TESTPASS: "postgres" 20 | DT_SQL_TESTDBNAME: "test_db" 21 | DT_SQL_TESTPORT: "5432" 22 | DT_DEFAULT_USER_PASS: "password" 23 | DT_JWT_SECRET_KEY: "this is a very random secret key" 24 | steps: 25 | - uses: actions/setup-python@v4 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | architecture: x64 29 | - uses: actions/checkout@v3 30 | - uses: harmon758/postgresql-action@v1 31 | with: 32 | postgresql version: "11" 33 | postgresql db: cropdb 34 | postgresql user: postgres 35 | postgresql password: postgres 36 | - run: git submodule update --init --recursive 37 | - run: pip install .[dev] 38 | - run: | 39 | export PYTHONPATH=$PYTHONPATH:$(pwd) 40 | pytest --cov -vv 41 | - name: Upload coverage reports to Codecov 42 | run: | 43 | curl -Os https://uploader.codecov.io/latest/linux/codecov 44 | chmod +x codecov 45 | ./codecov -t ${CODECOV_TOKEN} 46 | -------------------------------------------------------------------------------- /dtbase/frontend/app/sensors/templates/sensor_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% block title %} Sensors {% endblock title %} 4 | 5 | {% block content %} 6 |

Add New Sensor

7 |
8 |
9 | 10 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 |
25 | {% endblock content %} 26 | -------------------------------------------------------------------------------- /dtbase/frontend/app/base/templates/errors/page_500.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% block title %} Page 500 {% endblock title %} 4 | 5 | {% block stylesheets %} 6 | {{ super() }} 7 | {% endblock stylesheets %} 8 | 9 | {% block sidebar %}{% endblock sidebar %} 10 | 11 | {% block top_navigation %}{% endblock top_navigation %} 12 | 13 | {% block content %} 14 |
15 |
16 |
17 |

500

18 |

Internal Server Error

19 |

We track these errors automatically, but if the problem persists feel free to contact us. In the meantime, try refreshing. Report this? 20 |

21 |
22 |

Search

23 |
24 | 32 |
33 |
34 |
35 |
36 |
37 | {% endblock content %} 38 | 39 | {% block footer %}{% endblock footer %} 40 | 41 | {% block javascripts %} 42 | {{ super() }} 43 | {% endblock javascripts %} 44 | -------------------------------------------------------------------------------- /dtbase/frontend/app/locations/templates/location_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% block title %} Locations {% endblock title %} 4 | 5 | {% block content %} 6 |

Add New Location

7 |
8 |
9 | 10 | 23 |
24 |
25 | 26 |
27 | {% endblock content %} 28 | 29 | {% block javascripts %} 30 | {{ super() }} 31 | 32 | 42 | {% endblock javascripts %} 43 | -------------------------------------------------------------------------------- /dtbase/backend/config.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | 3 | 4 | class Config: 5 | # PostgreSQL database 6 | SQLALCHEMY_DATABASE_URI = "postgresql://{}:{}@{}:{}/{}".format( 7 | environ.get("DT_SQL_USER"), 8 | environ.get("DT_SQL_PASS"), 9 | environ.get("DT_SQL_HOST"), 10 | environ.get("DT_SQL_PORT"), 11 | environ.get("DT_SQL_DBNAME"), 12 | ) 13 | 14 | SQLALCHEMY_TRACK_MODIFICATIONS = False 15 | 16 | 17 | class ProductionConfig(Config): 18 | # PostgreSQL database 19 | SQL_CONNECTION_STRING = "postgresql://{}:{}@{}:{}".format( 20 | environ.get("DT_SQL_USER"), 21 | environ.get("DT_SQL_PASS"), 22 | environ.get("DT_SQL_HOST"), 23 | environ.get("DT_SQL_PORT"), 24 | ) 25 | SQL_DBNAME = environ.get("DT_SQL_DBNAME") 26 | 27 | 28 | class TestConfig(Config): 29 | DEBUG = False 30 | DISABLE_REGISTER = True 31 | 32 | # Security 33 | SESSION_COOKIE_HTTPONLY = True 34 | REMEMBER_COOKIE_HTTPONLY = True 35 | REMEMBER_COOKIE_DURATION = 3600 36 | 37 | # Testing 38 | TESTING = True 39 | 40 | # PostgreSQL database 41 | SQL_CONNECTION_STRING = "postgresql://{}:{}@{}:{}".format( 42 | environ.get("DT_SQL_TESTUSER"), 43 | environ.get("DT_SQL_TESTPASS"), 44 | environ.get("DT_SQL_TESTHOST"), 45 | environ.get("DT_SQL_TESTPORT"), 46 | ) 47 | SQL_DBNAME = environ.get("DT_SQL_TESTDBNAME") 48 | 49 | 50 | class DebugConfig(Config): 51 | DEBUG = True 52 | DISABLE_REGISTER = True 53 | 54 | 55 | config_dict = {"Production": ProductionConfig, "Test": TestConfig, "Debug": DebugConfig} 56 | -------------------------------------------------------------------------------- /dtbase/frontend/app/users/routes.py: -------------------------------------------------------------------------------- 1 | """Routes for the users section of the site.""" 2 | from flask import flash, render_template, request 3 | from flask_login import current_user, login_required 4 | 5 | from dtbase.frontend.app.base.forms import NewUserForm 6 | from dtbase.frontend.app.users import blueprint 7 | 8 | 9 | @blueprint.route("/index", methods=["GET", "POST"]) 10 | @login_required 11 | def index() -> str: 12 | """The index page of users.""" 13 | new_user_form = NewUserForm(request.form) 14 | if request.method == "POST": 15 | if "submitDelete" in request.form: 16 | payload = {"email": request.form["email"]} 17 | response = current_user.backend_call( 18 | "post", "/user/delete-user", payload=payload 19 | ) 20 | if response.status_code == 200: 21 | flash("User deleted successfully", "success") 22 | else: 23 | flash("Failed to delete user", "error") 24 | else: 25 | new_user_form.validate_on_submit() 26 | payload = { 27 | "email": request.form["email"], 28 | "password": request.form["password"], 29 | } 30 | response = current_user.backend_call( 31 | "post", "/user/create-user", payload=payload 32 | ) 33 | if response.status_code == 201: 34 | flash("User created successfully", "success") 35 | else: 36 | flash("Failed to create user", "error") 37 | users = current_user.backend_call("get", "/user/list-users").json() 38 | return render_template("users.html", new_user_form=new_user_form, users=users) 39 | -------------------------------------------------------------------------------- /dtbase/frontend/app/base/static/typescript/sensor_edit_form.ts: -------------------------------------------------------------------------------- 1 | export function deleteSensor(): void { 2 | // Perform the delete action, e.g., show a confirmation dialog 3 | if (confirm("Are you sure you want to delete this sensor?")) { 4 | // Delete the sensor 5 | fetch(window.location.href, { 6 | method: "DELETE", 7 | }).then(function (response) { 8 | if (response.ok) { 9 | // If the sensor was deleted successfully, close the popup window 10 | window.close() 11 | } else { 12 | // If the sensor was not deleted successfully, show an alert 13 | alert("Sensor not deleted") 14 | } 15 | }) 16 | } else { 17 | // Do nothing 18 | alert("Sensor not deleted") 19 | } 20 | } 21 | 22 | export function editSensor(event: Event): void { 23 | event.preventDefault() // prevent the form from submitting normally 24 | 25 | const form = event.target as HTMLFormElement 26 | const url = form.action // get the form's action URL 27 | const formData = new FormData(form) // get the form data 28 | 29 | // send the form data to the server 30 | fetch(url, { 31 | method: "POST", 32 | body: formData, 33 | }) 34 | .then((response) => { 35 | if (!response.ok) { 36 | throw new Error("Network response was not ok") 37 | } else { 38 | window.close() 39 | } 40 | }) 41 | .catch((error) => { 42 | // handle error - don't close the window if there was an error 43 | console.error("Error:", error) 44 | }) 45 | } 46 | 47 | declare global { 48 | interface Window { 49 | deleteSensor: () => void 50 | editSensor: (event: Event) => void 51 | } 52 | } 53 | 54 | window.deleteSensor = deleteSensor 55 | window.editSensor = editSensor 56 | -------------------------------------------------------------------------------- /dtbase/frontend/app/sensors/templates/sensor_edit_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% block title %} Sensors {% endblock title %} 4 | 5 | {% block sidebar %} {# Omit the sidebar content #} {% endblock sidebar %} 6 | 7 | {% block content %} 8 |
9 |

Edit Sensor

10 | 11 |

You are editing:

12 | {% for key, value in all_args.items() %} 13 |

- {{ key }}: {{ value }}

14 | {% endfor %} 15 | 16 |
17 |
18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 |
27 |
28 | {% endblock content %} 29 | 30 | {% block javascripts %} 31 | {{ super() }} 32 | 33 | 36 | {% endblock javascripts %} 37 | -------------------------------------------------------------------------------- /dtbase/frontend/app/base/templates/login/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% block title %} Login {% endblock title %} 4 | 5 | {% block stylesheets %} 6 | {{ super() }} 7 | 8 | {% endblock stylesheets %} 9 | 10 | {% block body_class %}login{% endblock body_class %} 11 | 12 | {% block body %} 13 |
14 | 37 |
38 | 39 | {% block javascripts %} 40 | {{ super() }} 41 | 42 | 45 | {% endblock javascripts %} 46 | {% endblock body %} 47 | -------------------------------------------------------------------------------- /dtbase/frontend/app/services/templates/services.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% block title %} Services {% endblock title %} 4 | 5 | {% block content %} 6 |
7 |

Services

8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | {% for s in services %} 19 | 20 | 21 | 29 | 30 | {% endfor %} 31 | 32 |
NameDelete
{{s.name}} 22 |
23 | 24 | 27 |
28 |
33 | 34 | 35 |

Add a service

36 |
37 | {{ new_service_form.hidden_tag() }} 38 | {% for field in new_service_form if field.widget.input_type != 'hidden' %} 39 | 41 |
42 | 43 | {{ field(class="form-control required", placeholder=field.label.text) }} 44 |
45 | {% endfor %} 46 | 47 |
48 | 49 |
50 |
51 |
52 | {% endblock content %} 53 | -------------------------------------------------------------------------------- /dtbase/frontend/app/base/static/typescript/readings.ts: -------------------------------------------------------------------------------- 1 | export function updateSensorSelector( 2 | sensorIdsByType: { [key: string]: string[] }, 3 | selectedSensor: string | null, 4 | ): void { 5 | const sensorSelector = document.getElementById("sensorSelector") as HTMLSelectElement 6 | const sensorTypeSelector = document.getElementById( 7 | "sensorTypeSelector", 8 | ) as HTMLSelectElement 9 | const selectedSensorType = sensorTypeSelector.value 10 | 11 | // Empty sensorSelector 12 | while (sensorSelector.firstChild) { 13 | sensorSelector.removeChild(sensorSelector.firstChild) 14 | } 15 | 16 | // Create new options for the sensorSelector 17 | if ( 18 | selectedSensorType != "" || 19 | selectedSensorType !== null || 20 | selectedSensor !== null || 21 | selectedSensor != "" 22 | ) { 23 | const defaultOption = document.createElement("option") 24 | defaultOption.value = "" 25 | defaultOption.selected = true 26 | defaultOption.disabled = true 27 | defaultOption.hidden = true 28 | defaultOption.text = "Choose sensor" 29 | sensorSelector.appendChild(defaultOption) 30 | } 31 | 32 | if (selectedSensorType != "") { 33 | const sensorIds = sensorIdsByType[selectedSensorType] 34 | for (let i = 0; i < sensorIds.length; i++) { 35 | const option = document.createElement("option") 36 | const id = sensorIds[i] 37 | option.value = id 38 | option.text = id 39 | if (id == selectedSensor) option.selected = true 40 | sensorSelector.appendChild(option) 41 | } 42 | } 43 | } 44 | 45 | declare global { 46 | interface Window { 47 | updateSensorSelector: ( 48 | sensorIdsByType: { [key: string]: string[] }, 49 | selectedSensor: string | null, 50 | ) => void 51 | } 52 | } 53 | 54 | window.updateSensorSelector = updateSensorSelector 55 | -------------------------------------------------------------------------------- /dtbase/backend/routers/auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | Module (routes.py) to handle API endpoints related to authentication 3 | """ 4 | from fastapi import APIRouter, Depends, HTTPException, status 5 | from sqlalchemy.orm import Session 6 | 7 | from dtbase.backend.auth import authenticate_refresh, create_token_pair 8 | from dtbase.backend.database import users 9 | from dtbase.backend.database.utils import db_session 10 | from dtbase.backend.models import ( 11 | LoginCredentials, 12 | MessageResponse, 13 | ParsedToken, 14 | TokenPair, 15 | ) 16 | 17 | router = APIRouter(prefix="/auth", tags=["auth"]) 18 | 19 | 20 | @router.post( 21 | "/login", 22 | status_code=status.HTTP_200_OK, 23 | responses={status.HTTP_401_UNAUTHORIZED: {"model": MessageResponse}}, 24 | ) 25 | def login( 26 | credentials: LoginCredentials, session: Session = Depends(db_session) 27 | ) -> TokenPair: 28 | """Generate a new authentication token.""" 29 | email = credentials.email 30 | password = credentials.password 31 | valid_login = users.check_password(email, password, session=session) 32 | if not valid_login: 33 | raise HTTPException( 34 | status_code=status.HTTP_401_UNAUTHORIZED, 35 | detail="Incorrect email or password", 36 | ) 37 | return create_token_pair(email) 38 | 39 | 40 | @router.post( 41 | "/refresh", 42 | status_code=status.HTTP_200_OK, 43 | responses={status.HTTP_401_UNAUTHORIZED: {"model": MessageResponse}}, 44 | ) 45 | def refresh(parsed_token: ParsedToken = Depends(authenticate_refresh)) -> TokenPair: 46 | """ 47 | Refresh an authentication token. 48 | 49 | Returns output similar to `/auth/login`, only the authentication method is different 50 | (checking the validity of a refresh token rather than email and password). 51 | """ 52 | email = parsed_token.sub 53 | return create_token_pair(email) 54 | -------------------------------------------------------------------------------- /dtbase/frontend/app/locations/templates/locations_table.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% block stylesheets %} 4 | {{ super() }} 5 | 6 | 7 | 8 | 9 | {% endblock stylesheets %} 10 | 11 | {% block title %} Locations {% endblock title %} 12 | 13 | {% block content %} 14 |

Locations

15 |
16 | 17 | 23 |
24 | 25 |
26 | {% endblock content %} 27 | 28 | {% block javascripts %} 29 | {{ super() }} 30 | 31 | 41 | {% endblock javascripts %} 42 | -------------------------------------------------------------------------------- /dtbase/frontend/app/sensors/templates/sensor_list_table.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% block stylesheets %} 4 | {{ super() }} 5 | 6 | 7 | 8 | 9 | {% endblock stylesheets %} 10 | 11 | {% block title %} Sensors {% endblock title %} 12 | 13 | {% block content %} 14 |

Sensors

15 |
16 | 17 | 23 |
24 | 25 |
26 | {% endblock content %} 27 | 28 | {% block javascripts %} 29 | {{ super() }} 30 | 31 | 41 | {% endblock javascripts %} 42 | -------------------------------------------------------------------------------- /dtbase/frontend/app/locations/templates/location_schema_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% block title %} Locations {% endblock title %} 4 | 5 | {% block content %} 6 |

Enter New Location Schema

7 |
8 |
9 | 10 | 11 |
12 |
13 | 14 | 15 |
16 | 17 | 18 |
19 | 20 |
21 | 27 | 28 |
29 |
30 | 31 | 32 |
33 | {% endblock content %} 34 | 35 | {% block javascripts %} 36 | {{ super()}} 37 | 38 | 39 | 43 | 44 | {% endblock javascripts %} 45 | -------------------------------------------------------------------------------- /dtbase/backend/models.py: -------------------------------------------------------------------------------- 1 | """This module contains the Pydantic models used in the API. 2 | 3 | Any model used by just one endpoint is defined next to the endpoint. This module only 4 | contains models used by multiple endpoints. 5 | """ 6 | from typing import Optional 7 | 8 | from pydantic import BaseModel, ConfigDict, Field, RootModel 9 | 10 | ValueType = float | bool | int | str 11 | 12 | 13 | class MessageResponse(BaseModel): 14 | detail: str 15 | 16 | 17 | class UserIdentifier(BaseModel): 18 | email: str 19 | 20 | 21 | class LoginCredentials(BaseModel): 22 | email: str 23 | password: str 24 | 25 | 26 | class TokenPair(BaseModel): 27 | access_token: str 28 | refresh_token: str 29 | 30 | 31 | class ParsedToken(BaseModel): 32 | sub: str 33 | exp: int 34 | token_type: str 35 | 36 | 37 | class LocationIdentifier(BaseModel): 38 | name: str 39 | units: Optional[str] 40 | datatype: str 41 | 42 | 43 | class LocationSchema(BaseModel): 44 | name: str 45 | description: Optional[str] 46 | identifiers: list[LocationIdentifier] 47 | 48 | 49 | Coordinates = RootModel[dict[str, ValueType]] 50 | 51 | 52 | class Model(BaseModel): 53 | name: str 54 | 55 | 56 | class ModelScenario(BaseModel): 57 | model_name: str 58 | description: Optional[str] 59 | 60 | # Needed because the field `model_name` conflicts with some Pydantic internals. 61 | model_config = ConfigDict(protected_namespaces=()) 62 | 63 | 64 | class ModelMeasure(BaseModel): 65 | name: str 66 | units: str 67 | datatype: str 68 | 69 | 70 | class ModelMeasureIdentifier(BaseModel): 71 | name: str 72 | units: str 73 | 74 | 75 | class SensorMeasure(BaseModel): 76 | name: str 77 | units: Optional[str] 78 | datatype: str 79 | 80 | 81 | class SensorType(BaseModel): 82 | name: str 83 | description: Optional[str] = Field(default=None) 84 | measures: list[SensorMeasure] 85 | 86 | 87 | class SensorMeasureIdentifier(BaseModel): 88 | name: str 89 | units: Optional[str] 90 | -------------------------------------------------------------------------------- /dtbase/functions/ingress_weather/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime as dt 4 | import logging 5 | from typing import Any 6 | 7 | from azure.functions import HttpRequest, HttpResponse 8 | from dateutil.parser import parse 9 | 10 | from dtbase.ingress.ingress_weather import OpenWeatherDataIngress 11 | 12 | 13 | def parse_datetime_argument(dt_str: str) -> str | dt.datetime: 14 | """Parse datetime argument from HTTP request. 15 | 16 | Return either a datetime object or the string "present". 17 | """ 18 | if dt_str == "present": 19 | return dt_str 20 | 21 | now = dt.datetime.now() 22 | tolerance = dt.timedelta(seconds=10) 23 | datetime = parse(dt_str) 24 | if now - tolerance <= datetime <= now + tolerance: 25 | return "present" 26 | return datetime 27 | 28 | 29 | def main(req: HttpRequest) -> HttpResponse: 30 | logging.info("Starting Open Weather Map ingress function.") 31 | 32 | req_body = req.get_json() 33 | params: dict[str, Any] = {} 34 | for parameter_name in { 35 | "dt_from", 36 | "dt_to", 37 | "api_key", 38 | "latitude", 39 | "longitude", 40 | }: 41 | parameter = req_body.get(parameter_name) 42 | if parameter is None: 43 | return HttpResponse( 44 | f"Must provide {parameter_name} in request body.", status_code=400 45 | ) 46 | params[parameter_name] = parameter 47 | 48 | try: 49 | params["dt_from"] = parse_datetime_argument(params["dt_from"]) 50 | params["dt_to"] = parse_datetime_argument(params["dt_to"]) 51 | except ValueError: 52 | return HttpResponse( 53 | "dt_from and dt_to must be ISO 8601 datetime strings or 'present'.", 54 | status_code=400, 55 | ) 56 | 57 | weather_ingress = OpenWeatherDataIngress() 58 | weather_ingress.ingress_data(**params) 59 | 60 | logging.info("Finished Open Weather Map Ingress.") 61 | return HttpResponse("Successfully ingressed weather data.", status_code=200) 62 | -------------------------------------------------------------------------------- /dtbase/frontend/app/sensors/templates/sensor_type_form.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% block title %} Sensors {% endblock title %} 4 | 5 | {% block content %} 6 |

Enter New Sensor Type

7 |
8 |
9 | 10 | 11 |
12 |
13 | 14 | 15 |
16 | 17 | 18 |
19 |
20 | 21 |
22 | 28 | 29 |
30 |
31 |
32 | 33 | 34 |
35 | {% endblock content %} 36 | 37 | {% block javascripts %} 38 | {{ super()}} 39 | 40 | 41 | 45 | 46 | {% endblock javascripts %} 47 | -------------------------------------------------------------------------------- /dtbase/frontend/app/base/static/typescript/locations_table.ts: -------------------------------------------------------------------------------- 1 | import { initialiseDataTable } from "./datatables" 2 | import { Location } from "./interfaces" 3 | 4 | export function updateLocationsTable(locations_for_each_schema: { 5 | [key: string]: Location[] 6 | }): void { 7 | const locationTableWrapper = document.getElementById( 8 | "locationTableWrapper", 9 | ) as HTMLDivElement 10 | try { 11 | const selectedSchema = (document.getElementById("schema") as HTMLSelectElement) 12 | .value 13 | if (!selectedSchema) { 14 | locationTableWrapper.innerHTML = "" 15 | return 16 | } 17 | 18 | const locations = locations_for_each_schema[selectedSchema] 19 | let tableContent = "" 20 | 21 | // Construct the table headers 22 | tableContent += "#" // Adding '#' column 23 | for (const key in locations[0]) { 24 | if (key !== "id") { 25 | // Exclude 'id' column 26 | tableContent += `${key}` 27 | } 28 | } 29 | tableContent += "" 30 | 31 | // Construct the table body 32 | tableContent += "" 33 | for (let i = 0; i < locations.length; i++) { 34 | tableContent += "" + (i + 1) + "" // Adding row number 35 | for (const key in locations[i]) { 36 | if (key !== "id") { 37 | // Exclude 'id' column 38 | tableContent += `${locations[i][key]}` 39 | } 40 | } 41 | tableContent += "" 42 | } 43 | tableContent += "" 44 | 45 | // Construct the full table and inject it into the DOM 46 | const fullTable = `${tableContent}
` 47 | 48 | locationTableWrapper.innerHTML = fullTable 49 | initialiseDataTable("#datatable") 50 | } catch (error) { 51 | console.error(error) 52 | } 53 | } 54 | 55 | declare global { 56 | interface Window { 57 | updateLocationsTable: (locations_for_each_schema: { 58 | [key: string]: Location[] 59 | }) => void 60 | } 61 | } 62 | 63 | window.updateLocationsTable = updateLocationsTable 64 | -------------------------------------------------------------------------------- /tests/test_sensor_locations.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test the functions for accessing the sensor location table. 3 | """ 4 | import datetime as dt 5 | 6 | from sqlalchemy.orm import Session 7 | 8 | from dtbase.backend.database import sensor_locations 9 | 10 | from . import test_locations, test_sensors 11 | 12 | COORDINATES1 = { 13 | "latitude": test_locations.LATITUDE1, 14 | "longitude": test_locations.LONGITUDE1, 15 | } 16 | COORDINATES2 = {"latitude": test_locations.LATITUDE3} 17 | DATE1 = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=2) 18 | DATE2 = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=1) 19 | 20 | 21 | def insert_sensors_and_locations(session: Session) -> None: 22 | """Insert some sensors and locations.""" 23 | test_sensors.insert_sensors(session) 24 | test_locations.insert_locations(session) 25 | 26 | 27 | def insert_sensor_locations(session: Session) -> None: 28 | """Insert some sensor locations.""" 29 | uniq_id = test_sensors.SENSOR_ID1 30 | sensor_locations.insert_sensor_location( 31 | uniq_id, "latlong", COORDINATES1, DATE1, session=session 32 | ) 33 | 34 | 35 | def test_insert_sensor_locations(session: Session) -> None: 36 | """Test inserting sensor locations.""" 37 | insert_sensors_and_locations(session) 38 | insert_sensor_locations(session) 39 | 40 | 41 | def test_get_sensor_locations(session: Session) -> None: 42 | """Test getting sensor locations.""" 43 | insert_sensors_and_locations(session) 44 | uniq_id = test_sensors.SENSOR_ID1 45 | locations = sensor_locations.get_location_history(uniq_id, session=session) 46 | assert len(locations) == 0 47 | 48 | insert_sensor_locations(session) 49 | locations = sensor_locations.get_location_history(uniq_id, session=session) 50 | assert len(locations) == 1 51 | assert all(locations[0][k] == v for k, v in COORDINATES1.items()) 52 | assert locations[0]["installation_datetime"] == DATE1 53 | sensor_locations.insert_sensor_location( 54 | uniq_id, "lat only", COORDINATES2, DATE2, session=session 55 | ) 56 | locations = sensor_locations.get_location_history(uniq_id, session=session) 57 | assert len(locations) == 2 58 | assert all(locations[0][k] == v for k, v in COORDINATES2.items()) 59 | assert locations[0]["installation_datetime"] == DATE2 60 | -------------------------------------------------------------------------------- /dtbase/frontend/app/users/templates/users.html: -------------------------------------------------------------------------------- 1 | {% extends "base_site.html" %} 2 | 3 | {% block title %} Users {% endblock title %} 4 | 5 | {% block stylesheets %} 6 | {{ super() }} 7 | {% endblock stylesheets %} 8 | 9 | {% block content %} 10 |
11 |

List of all users

12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {% for t_user in users: %} 23 | 24 | 25 | 33 | 34 | {% endfor %} 35 | 36 |
EmailDelete
{{ t_user }} 26 |
27 | 28 | 31 |
32 |
37 | 38 |
39 |

Create a user

40 | 41 |
42 | {{ new_user_form.hidden_tag() }} 43 | {% for field in new_user_form if field.widget.input_type != 'hidden' %} 44 | 46 |
47 | {{ field(class="form-control required", placeholder=field.label.text) }} 48 | {% if field.name == "password" %} 49 | 50 | {% endif %} 51 |
52 | {% endfor %} 53 | 54 |
55 | 56 |
57 |
58 |
59 |
60 | {% endblock content %} 61 | 62 | {% block javascripts %} 63 | {{ super() }} 64 | 65 | 66 | 70 | {% endblock javascripts %} 71 | -------------------------------------------------------------------------------- /tests/test_model_utils.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | 3 | import numpy as np 4 | import pandas as pd 5 | import pytest 6 | from fastapi.testclient import TestClient 7 | from sqlalchemy.orm import Session 8 | 9 | from dtbase.models.utils.sensor_data import downsample_data, get_sensor_data 10 | 11 | from .conftest import check_for_docker 12 | from .upload_synthetic_data import insert_trh_readings 13 | 14 | DOCKER_RUNNING = check_for_docker() 15 | 16 | 17 | @pytest.mark.skipif(not DOCKER_RUNNING, reason="requires docker") 18 | def test_get_sensor_data(conn_backend: TestClient, session: Session) -> None: 19 | # Insert synthetic data into database 20 | insert_trh_readings(session) 21 | 22 | now = dt.datetime.now() 23 | tables = get_sensor_data( 24 | sensors=["TRH1"], 25 | measures=[ 26 | {"name": "Temperature", "units": "Degrees C"}, 27 | {"name": "Humidity", "units": "Percent"}, 28 | ], 29 | dt_from=now - dt.timedelta(days=20), 30 | dt_to=now, 31 | ) 32 | 33 | assert isinstance(tables, list) 34 | assert len(tables) == 2 35 | for table in tables: 36 | assert set(table.keys()) == {"data", "sensor_unique_id", "measure"} 37 | assert set(table["measure"].keys()) == {"name", "units"} 38 | assert isinstance(table["data"], pd.DataFrame) 39 | assert set(table["data"].columns) == {"value"} 40 | assert table["data"].index.name == "timestamp" 41 | assert len(table["data"]) > 0 42 | 43 | 44 | def test_downsample_data() -> None: 45 | """Check that downsample_data returns a DataFrame with an equally spaced index.""" 46 | # Generate a random time series 47 | start_date = pd.Timestamp("2022-01-01") 48 | time_increments = [ 49 | pd.Timedelta(minutes=np.random.randint(1, 60)) for _ in range(1000) 50 | ] 51 | date_range = [start_date] 52 | for time_increment in time_increments: 53 | date_range.append(date_range[-1] + time_increment) 54 | values = [0.0] 55 | for _ in range(1, len(date_range)): 56 | values.append(values[-1] + np.random.normal()) 57 | df = pd.DataFrame({"timestamp": date_range, "value": values}).set_index("timestamp") 58 | 59 | frequency = pd.Timedelta(hours=1) 60 | downsampled_data = downsample_data(df, frequency) 61 | assert 0 < len(downsampled_data) <= len(df) 62 | diff = downsampled_data.index.to_series().diff() 63 | # Skip the first row because its NaT 64 | assert diff[1:].unique()[0] == frequency 65 | -------------------------------------------------------------------------------- /dtbase/frontend/app/base/routes.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Union 2 | 3 | from flask import ( 4 | abort, 5 | current_app, 6 | redirect, 7 | render_template, 8 | request, 9 | url_for, 10 | ) 11 | from flask_login import login_required, login_user, logout_user 12 | from werkzeug.wrappers import Response 13 | 14 | from dtbase.frontend.app.base import blueprint 15 | from dtbase.frontend.app.base.forms import LoginForm 16 | from dtbase.frontend.exc import AuthorizationError 17 | from dtbase.frontend.user import User 18 | from dtbase.frontend.utils import url_has_allowed_host_and_scheme 19 | 20 | 21 | @blueprint.route("/") 22 | def route_default() -> Response: 23 | return redirect(url_for("home_blueprint.index")) 24 | 25 | 26 | @blueprint.route("/