Merge branch 'release/0.1.0'
26
.dockerignore
Normal file
|
@ -0,0 +1,26 @@
|
|||
# commonly found
|
||||
**/.git
|
||||
**/.idea
|
||||
**/.DS_Store
|
||||
**/.vscode
|
||||
**/.devcontainer
|
||||
|
||||
**/dist
|
||||
**/.gitignore
|
||||
**/Dockerfile
|
||||
**/.dockerignore
|
||||
|
||||
# found in python and JS dirs
|
||||
**/__pycache__
|
||||
**/node_modules
|
||||
|
||||
# env files
|
||||
**/.env
|
||||
**/.env.local
|
||||
**/.env.*.local
|
||||
|
||||
# log files
|
||||
**/npm-debug.log*
|
||||
**/yarn-debug.log*
|
||||
**/yarn-error.log*
|
||||
**/pnpm-debug.log*
|
14
.drone.yml
Normal file
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
kind: pipeline
|
||||
name: default
|
||||
|
||||
steps:
|
||||
- name: ovdashboard
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: ldericher/ovdashboard
|
||||
auto_tag: true
|
||||
username:
|
||||
from_secret: DOCKER_USERNAME
|
||||
password:
|
||||
from_secret: DOCKER_PASSWORD
|
72
Dockerfile
Normal file
|
@ -0,0 +1,72 @@
|
|||
############
|
||||
# build ui #
|
||||
############
|
||||
|
||||
ARG NODE_VERSION=lts
|
||||
ARG PYTHON_VERSION=3.12-slim
|
||||
FROM node:${NODE_VERSION} AS build-ui
|
||||
|
||||
# env setup
|
||||
WORKDIR /usr/local/src/ovdashboard_ui
|
||||
|
||||
# install ovdashboard_ui dependencies
|
||||
COPY ui/package*.json ui/yarn*.lock ./
|
||||
RUN yarn install --production false
|
||||
|
||||
# copy and build ovdashboard_ui
|
||||
COPY ui ./
|
||||
RUN yarn build --dest /tmp/ovdashboard_ui/html
|
||||
|
||||
######################
|
||||
# python preparation #
|
||||
######################
|
||||
|
||||
ARG PYTHON_VERSION
|
||||
FROM python:${PYTHON_VERSION} as uvicorn-gunicorn
|
||||
|
||||
# where credit is due ...
|
||||
LABEL maintainer="Sebastian Ramirez <tiangolo@gmail.com>"
|
||||
WORKDIR /usr/local/share/uvicorn-gunicorn
|
||||
|
||||
# install uvicorn-gunicorn
|
||||
COPY "./deploy/mini-tiangolo/" "."
|
||||
|
||||
RUN set -ex; \
|
||||
chmod +x start.sh; \
|
||||
python3 -m pip --no-cache-dir install gunicorn;
|
||||
|
||||
CMD ["/usr/local/share/uvicorn-gunicorn/start.sh"]
|
||||
|
||||
###########
|
||||
# web app #
|
||||
###########
|
||||
|
||||
FROM uvicorn-gunicorn AS production
|
||||
|
||||
# env setup
|
||||
WORKDIR /usr/local/src/ovdashboard_api
|
||||
ENV \
|
||||
PRODUCTION_MODE="true" \
|
||||
PORT="8000" \
|
||||
MODULE_NAME="ovdashboard_api.app"
|
||||
EXPOSE 8000
|
||||
|
||||
COPY api ./
|
||||
RUN set -ex; \
|
||||
# install libs
|
||||
export DEBIAN_FRONTEND=noninteractive; \
|
||||
apt-get update; apt-get install --yes --no-install-recommends \
|
||||
libmagic1 \
|
||||
# need to build hiredis
|
||||
gcc \
|
||||
libc-dev \
|
||||
; rm -rf /var/lib/apt/lists/*; \
|
||||
\
|
||||
# install ovdashboard_api
|
||||
python3 -m pip --no-cache-dir install ./
|
||||
|
||||
# add prepared ovdashboard_ui
|
||||
COPY --from=build-ui /tmp/ovdashboard_ui /usr/local/share/ovdashboard_ui
|
||||
|
||||
# run as unprivileged user
|
||||
USER nobody
|
124
README.md
Normal file
|
@ -0,0 +1,124 @@
|
|||
# OVDashboard
|
||||
|
||||
A fancy dashboard for use in a [THW](https://en.wikipedia.org/wiki/Technisches_Hilfswerk)-Ortsverband (OV).
|
||||
|
||||
![Screenshot](./doc/ovdashboard_en.jpg)
|
||||
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Clean Look** <br />
|
||||
All that matters, at one glance! <br />
|
||||
*Date and Time – Upcoming Events – Public Announcements – News – Pictures*
|
||||
|
||||
- **Easy Install** <br />
|
||||
Set up a RaspberryPi, run the [installer script](./deploy/install.sh), done!
|
||||
|
||||
- **DAV Server Interface** <br />
|
||||
Update your content anytime, from anywhere!
|
||||
Basic [Markdown](https://www.markdownguide.org/) is enough! <br />
|
||||
Already have a [Nextcloud](https://nextcloud.com/) instance?
|
||||
OVDashboard will take it from there!
|
||||
|
||||
- **Customizable – Yet Always Recognizable** <br />
|
||||
Change the Logo – Put your own Title – Publish Pictures – Create Event Lists – Customize the News Ticker Appearance – Adjust Item Rotation Speed
|
||||
|
||||
- **Responsive Design** <br />
|
||||
OVDashboard is made for the big screen – but it also shines in your visitors' mobile browsers!
|
||||
|
||||
- **Locale Aware** <br />
|
||||
No localized strings anywhere – formats generated using [Luxon](https://moment.github.io/luxon/#/), which [respects your browsers' settings](./doc/ovdashboard_de.jpg)!
|
||||
|
||||
- **Hackable** <br />
|
||||
The Dashboard UI is created using [Vue](https://vuejs.org/) and the [Vuetify](https://vuetifyjs.com/) UI library. <br />
|
||||
*Like my layout, but want it for something completely different? Fork me!*
|
||||
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Make sure you have a WebDAV and CalDAV account available.
|
||||
For an all-in-one solution, consider setting up an account on a [Nextcloud](https://nextcloud.com/) instance! <br />
|
||||
On your WebDAV account, create a resource (directory) named `ovdashboard`[^1].
|
||||
|
||||
Your target device should be a Raspberry Pi Model 3 or later[^2]. You will need some accessories:
|
||||
- microSD card, class 10 or UHS (min. 8 GB)
|
||||
- network connectivity (bring WiFi credentials if applicable)
|
||||
- connection to a HDMI screen
|
||||
|
||||
It is also heavily advisable that you log into your device using SSH, so you should get another device (PC, tablet or smartphone) onto the same network as your OVDashboard.
|
||||
|
||||
[^1]: if named differently, you will need to adjust your compose file later on
|
||||
[^2]: other devices will also work, but might require extra installation steps
|
||||
|
||||
### Install Base System
|
||||
|
||||
OVDashboard is designed to run on a `DietPi` installation. Full installation documentation is available [at dietpi.com](https://dietpi.com/docs/deploy/). To quickly get up and running:
|
||||
|
||||
1. Download Image from [dietpi.com/#download](https://dietpi.com/#download)
|
||||
1. Uncompress Image and flash onto SD card – you might need "7zip", "balenaEtcher" and/or other tools
|
||||
1. Check the SD card, open "dietpi.txt" and change some options (full documentation [here](https://dietpi.com/docs/usage/#options-within-the-file)):
|
||||
- For WiFi, use `AUTO_SETUP_NET_WIFI_ENABLED=1` and `AUTO_SETUP_NET_WIFI_COUNTRY_CODE=DE`, and also edit "dietpi-wifi.txt" like `aWIFI_SSID[0]='OV WLAN'` and `aWIFI_KEY[0]='Strong_pa55w0rd'`
|
||||
- System options, e.g. `AUTO_SETUP_AUTOMATED=1`, `AUTO_SETUP_NET_HOSTNAME=OVDashboard`, `AUTO_SETUP_GLOBAL_PASSWORD=dietpi`, `AUTO_SETUP_LOCALE=de_DE.UTF-8`, `AUTO_SETUP_KEYBOARD_LAYOUT=de`, `AUTO_SETUP_TIMEZONE=Europe/Berlin`, `CONFIG_SERIAL_CONSOLE_ENABLE=0`
|
||||
1. Be sure to at least change the password (and remember it 🙂️), then put the SD card into your device and boot it. Let the first time setup finish, it will take a bit. It will let you know when it is done!
|
||||
1. Log into your device using SSH. By default, that's user name `root` and password `dietpi`
|
||||
|
||||
### Install OVDashboard
|
||||
|
||||
Download (and review) the [OVDashboard install script](//code.yavook.de/OEKZident.de/ovdashboard/raw/branch/master/deploy/install.sh), then run it from a terminal.
|
||||
|
||||
This can all be done after logging into your prepared device:
|
||||
- The safe way:
|
||||
1. download: `wget 'https://code.yavook.de/OEKZident.de/ovdashboard/raw/branch/master/deploy/install.sh'`
|
||||
1. read/edit: `nano install.sh`
|
||||
1. execute: `sh install.sh`
|
||||
- If you feel adventurous and do not want to review the script, just run `sh <( curl --proto '=https' --tlsv1.2 -sSf 'https://code.yavook.de/OEKZident.de/ovdashboard/raw/branch/master/deploy/install.sh' )`
|
||||
|
||||
> There will be some prompts during installation.
|
||||
>
|
||||
> - DietPi might ask: "Would you like DietPi to apply the recommended GPU memory split?". Choose **Yes**.
|
||||
> - DietPi will ask: "Would you like to configure the DietPi-AutoStart option?". **Cancel** that, this choice does not matter as it is changed by the installer.
|
||||
> - DietPi will ask: "Would you like to join DietPi-Survey?". It's up to you, but I'd suggest to **opt OUT**.
|
||||
> - The installer will ask for the connected screen's resolution. The default values should be fine, you can likely just hit **Return** here.
|
||||
> - The installer will ask for "display languages". This will affect some details on the connected screen. <br />
|
||||
For German, enter `de-DE,de,en-US,en`.
|
||||
> - The installer will ask you to "review the Docker Compose file" before starting the services. You will want to edit `WEBDAV__HOST`, `WEBDAV__USERNAME` and `WEBDAV__PASSWORD` at least. <br />
|
||||
Refer to [the "Settings"](TODO) for the full list of options.
|
||||
|
||||
Afterwards, reboot your device (`reboot` in the terminal).
|
||||
|
||||
If the install was successful, your OVDashboard should be showing up on the connected screen after rebooting.
|
||||
|
||||
> You will also be able to view your OVDashboard on any webbrowser in your network using `http://<device-ip>`. <br />
|
||||
> The device IP is displayed in the lower right region of the OVDashboard.
|
||||
|
||||
For a better understanding of your newly created OVDashboard, refer to the [about section](#about-the-default-ovdashboard-deployment).
|
||||
|
||||
## Configuration
|
||||
|
||||
### "Config" in your WebDAV share: `config.txt`
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
### "Settings" on your Device: `/opt/ovdashboard/docker-compose.yml`
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
## Updating your Device
|
||||
|
||||
<!-- TODO `/opt/ovdashboard` -->
|
||||
|
||||
## Setup for development and contribution
|
||||
|
||||
Refer to the specific README files for [the API](./api/README.md) and [the UI](./ui/README.md) to contribute to one of those sub-projects.
|
||||
|
||||
|
||||
## About the "default" OVDashboard deployment
|
||||
|
||||
Running the installer script carries out the following actions:
|
||||
|
||||
- install Chromium-Browser, Docker and Docker Compose
|
||||
- create the OVDashboard project at `/opt/ovdashboard`
|
||||
- start the OVDashboard project, this deploys the `ovdashboard` service from [`code.yavook.de`](https://code.yavook.de/OEKZident.de/-/packages/container/ovdashboard) and a [`redis` instance](https://redis.io/) to your device
|
||||
- set up your device to auto-boot into Chromium "kiosk" mode to display the local OVDashboard
|
|
@ -1,12 +1,28 @@
|
|||
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.224.2/containers/python-3/.devcontainer/base.Dockerfile
|
||||
# See here for image contents: https://github.com/devcontainers/images/blob/main/src/python/.devcontainer/Dockerfile
|
||||
|
||||
# [Choice] Python version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.10, 3.9, 3.8, 3.7, 3.6, 3-bullseye, 3.10-bullseye, 3.9-bullseye, 3.8-bullseye, 3.7-bullseye, 3.6-bullseye, 3-buster, 3.10-buster, 3.9-buster, 3.8-buster, 3.7-buster, 3.6-buster
|
||||
ARG VARIANT="3.10-bullseye"
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT}
|
||||
# [Choice] Python version (use -bookworm or -bullseye variants on local arm64/Apple Silicon):
|
||||
# - 3, 3.12, 3.11, 3.10, 3.9, 3.8
|
||||
# - 3-bookworm, 3.12-bookworm, 3.11-bookworm, 3.10-bookworm, 3.9-bookworm, 3.8-bookworm
|
||||
# - 3-bullseye, 3.12-bullseye, 3.11-bullseye, 3.10-bullseye, 3.9-bullseye, 3.8-bullseye
|
||||
# - 3-buster, 3.12-buster, 3.11-buster, 3.10-buster, 3.9-buster, 3.8-buster
|
||||
ARG VARIANT="3.12-bookworm"
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/python:1-${VARIANT}
|
||||
|
||||
# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10
|
||||
# Add "Poetry": https://python-poetry.org
|
||||
ARG POETRY_HOME="/usr/local"
|
||||
ENV POETRY_HOME="${POETRY_HOME}"
|
||||
RUN set -ex; \
|
||||
\
|
||||
curl -sSL https://install.python-poetry.org | python3 -; \
|
||||
poetry self add poetry-plugin-up;
|
||||
|
||||
# [Choice] Node.js version: none, lts/*, 18, 16, 14, 12, 10
|
||||
ARG NODE_VERSION="none"
|
||||
RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
|
||||
RUN set -ex; \
|
||||
\
|
||||
if [ "${NODE_VERSION}" != "none" ]; then \
|
||||
su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; \
|
||||
fi
|
||||
|
||||
# [Optional] If your pip requirements rarely change, uncomment this section to add them to the image.
|
||||
# COPY requirements.txt /tmp/pip-tmp/
|
||||
|
@ -20,15 +36,10 @@ RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/
|
|||
RUN set -ex; \
|
||||
\
|
||||
export DEBIAN_FRONTEND=noninteractive; \
|
||||
apt-get update; apt-get -y install --no-install-recommends \
|
||||
easy-rsa \
|
||||
apt-get update; apt-get install --yes --no-install-recommends \
|
||||
git-flow \
|
||||
openvpn \
|
||||
; rm -rf /var/lib/apt/lists/*; \
|
||||
ln -s /usr/share/easy-rsa/easyrsa /usr/local/bin;
|
||||
libmagic1 \
|
||||
; rm -rf /var/lib/apt/lists/*;
|
||||
|
||||
# [Optional] Uncomment this line to install global node packages.
|
||||
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
|
||||
|
||||
USER vscode
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||
|
|
|
@ -1,45 +1,39 @@
|
|||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
|
||||
// https://github.com/microsoft/vscode-dev-containers/tree/v0.224.2/containers/python-3
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/python
|
||||
{
|
||||
"name": "Python 3",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": "..",
|
||||
"args": {
|
||||
// Update 'VARIANT' to pick a Python version: 3, 3.10, 3.9, 3.8, 3.7, 3.6
|
||||
// Append -bullseye or -buster to pin to an OS version.
|
||||
// Use -bullseye variants on local on arm64/Apple Silicon.
|
||||
"VARIANT": "3.10",
|
||||
// Options
|
||||
"NODE_VERSION": "none"
|
||||
"name": "OVD API",
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "api",
|
||||
"workspaceFolder": "/workspaces/ovdashboard/${localWorkspaceFolderBasename}",
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
// Configure properties specific to VS Code.
|
||||
"vscode": {
|
||||
// Set *default* container specific settings.json values on container create.
|
||||
"settings": {
|
||||
"python.defaultInterpreterPath": "/usr/local/bin/python",
|
||||
"terminal.integrated.defaultProfile.linux": "zsh"
|
||||
},
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"be5invis.toml",
|
||||
"mhutchie.git-graph",
|
||||
"ms-python.python",
|
||||
"ms-python.black-formatter",
|
||||
"ms-python.flake8",
|
||||
"ms-python.isort",
|
||||
"ms-python.vscode-pylance"
|
||||
]
|
||||
}
|
||||
},
|
||||
// Set *default* container specific settings.json values on container create.
|
||||
"settings": {
|
||||
"python.defaultInterpreterPath": "/usr/local/bin/python",
|
||||
"python.linting.enabled": true,
|
||||
"python.linting.pylintEnabled": true,
|
||||
"python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8",
|
||||
"python.formatting.blackPath": "/usr/local/py-utils/bin/black",
|
||||
"python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf",
|
||||
"python.linting.banditPath": "/usr/local/py-utils/bin/bandit",
|
||||
"python.linting.flake8Path": "/usr/local/py-utils/bin/flake8",
|
||||
"python.linting.mypyPath": "/usr/local/py-utils/bin/mypy",
|
||||
"python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle",
|
||||
"python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle",
|
||||
"python.linting.pylintPath": "/usr/local/py-utils/bin/pylint"
|
||||
},
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"be5invis.toml"
|
||||
],
|
||||
// Use 'postStartCommand' to run commands after the container is started.
|
||||
"postStartCommand": "poetry install"
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "pip3 install --user -r requirements.txt",
|
||||
"postCreateCommand": "poetry install",
|
||||
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||
"remoteUser": "vscode"
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
32
api/.devcontainer/docker-compose.yml
Normal file
|
@ -0,0 +1,32 @@
|
|||
version: "3.8"
|
||||
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: "."
|
||||
dockerfile: "Dockerfile"
|
||||
args:
|
||||
# Update 'VARIANT' to pick a Python version.
|
||||
# Append -bookworm, -bullseye or -buster to pin to an OS version.
|
||||
# Use -bookworm or -bullseye variants on local on arm64/Apple Silicon.
|
||||
VARIANT: "3.12-bookworm"
|
||||
NODE_VERSION: "none"
|
||||
|
||||
environment:
|
||||
TZ: "Europe/Berlin"
|
||||
|
||||
volumes:
|
||||
- "../..:/workspaces/ovdashboard:cached"
|
||||
|
||||
# Overrides default command so things don't shut down after the process ends.
|
||||
command: "sleep infinity"
|
||||
|
||||
# Runs app on the same network as the redis container, allows "forwardPorts" in devcontainer.json function.
|
||||
network_mode: "service:redis"
|
||||
|
||||
# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
|
||||
# (Adding the "ports" property to this file will not forward from a Codespace.)
|
||||
|
||||
redis:
|
||||
image: "redis:7"
|
||||
restart: "unless-stopped"
|
4
api/.flake8
Normal file
|
@ -0,0 +1,4 @@
|
|||
[flake8]
|
||||
max-line-length = 80
|
||||
select = C,E,F,I,W,B,B950
|
||||
extend-ignore = E203, E501
|
14
api/.vscode/launch.json
vendored
|
@ -8,7 +8,19 @@
|
|||
"name": "Main Module",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"module": "ovkiosk.main",
|
||||
"module": "ovdashboard_api.main",
|
||||
"pythonArgs": [
|
||||
"-Xfrozen_modules=off",
|
||||
],
|
||||
"env": {
|
||||
"PYDEVD_DISABLE_FILE_VALIDATION": "1",
|
||||
"LOG_LEVEL": "DEBUG",
|
||||
"WEBDAV__CACHE_TTL": "30",
|
||||
"CALDAV__CACHE_TTL": "30",
|
||||
// "PRODUCTION_MODE": "true",
|
||||
// "WEBDAV__RETRIES": "5",
|
||||
// "WEBDAV__RETRY_DELAY": "1",
|
||||
},
|
||||
"justMyCode": true
|
||||
}
|
||||
]
|
||||
|
|
22
api/.vscode/settings.json
vendored
|
@ -1,16 +1,20 @@
|
|||
{
|
||||
"python.testing.pytestArgs": [
|
||||
"tests"
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true,
|
||||
"python.linting.enabled": true,
|
||||
"python.linting.pylintEnabled": false,
|
||||
"python.linting.flake8Enabled": true,
|
||||
"python.languageServer": "Pylance",
|
||||
"editor.formatOnSave": true,
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "ms-python.black-formatter"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": true
|
||||
},
|
||||
"git.closeDiffOnOperation": true
|
||||
"git.closeDiffOnOperation": true,
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
"python.analysis.diagnosticMode": "workspace",
|
||||
"python.testing.pytestArgs": [
|
||||
"test"
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true,
|
||||
"black-formatter.importStrategy": "fromEnvironment",
|
||||
"flake8.importStrategy": "fromEnvironment",
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
# OVDashboard API
|
||||
|
||||
This API enables the OVDashboard UI to run.
|
||||
|
||||
|
||||
## Quick Start
|
||||
|
||||
If you only want a working installation, it is highly recommended to use the `docker` image at [`TODO`](TODO).
|
||||
The image contains both the API and UI.
|
||||
Refer to the [main README](../README.md) for an in-depth how-to.
|
||||
|
||||
|
||||
## Setup for development and contribution
|
||||
|
||||
No need to fiddle around with specific python versions or even `virtualenv`s.
|
||||
You only need a "general purpose" development setup to get this project up and running for debug and contribution purposes:
|
||||
|
||||
- [Docker Engine](https://docs.docker.com/engine/install/)
|
||||
- [Visual Studio Code](https://code.visualstudio.com/)
|
||||
- [Remote - Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) (VSCode extension)
|
||||
|
||||
Once you open this directory in VSCode, you should be prompted to reopen it in a development container.
|
||||
If not, hit `Ctrl+Alt+P` and search for "reopen in container".
|
||||
|
||||
|
||||
## Running the API without the `docker` image
|
||||
|
||||
> You probably don't need this! Usually the image is good enough!
|
||||
|
||||
However, if you want to deploy the API on a cluster or with custom ASGI workers and/or process managers, go ahead.
|
||||
First, install the `ovdashboard_api` python package.
|
||||
Then you can:
|
||||
|
||||
- use `uvicorn` or another ASGI runner to start the app object `ovdashboard_api:app` <br />
|
||||
Example: `uvicorn 'ovdashboard_api:app'`
|
||||
- run the provided `ovdashboard-api` script – this is basically a shorthand for `uvicorn`
|
||||
- use `gunicorn` or another application server with ASGI workers <br />
|
||||
Example (ASGI workers provided by `uvicorn`): `gunicorn 'ovdashboard_api:app' --worker-class 'uvicorn.workers.UvicornWorker'`
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
The OVDashboard API is configured using environment variables or an `.env` file in the directory which it is run from.
|
||||
Refer to the [main README](../README.md) for the list of variables.
|
||||
|
||||
|
||||
## Installing the `ovdashboard_api` python package
|
||||
|
||||
If `git` is installed, install `ovdashboard_api` directly using this command:
|
||||
|
||||
python3 -m pip install 'git+https://TODO#egg=ovdashboard_api&subdirectory=api'
|
||||
|
||||
If installing `git` is not an option, just [download and extract this repository's archive](TODO), then use your local path instead of the `git+https://` URL for `pip install`.
|
||||
|
||||
|
||||
## Installation Dependencies
|
||||
|
||||
Refer to your distribution's manual for how to install these dependencies:
|
||||
|
||||
- Python 3.9 with pip <br />
|
||||
If `python3 --version` shows "Python 3.9" or later, and `python3 -m pip` does execute, your setup is working.
|
||||
- git (recommended)
|
||||
- libmagic
|
40
api/ovdashboard_api/__init__.py
Normal file
|
@ -0,0 +1,40 @@
|
|||
"""
|
||||
Package `ovdashboard_api`: Contains the API powering the
|
||||
"OVDashboard" application.
|
||||
|
||||
This file: Sets up logging.
|
||||
"""
|
||||
|
||||
from logging.config import dictConfig
|
||||
|
||||
from .core.settings import SETTINGS
|
||||
|
||||
# Logging configuration to be set for the server.
|
||||
# https://stackoverflow.com/a/67937084
|
||||
|
||||
LOG_CONFIG = dict(
|
||||
version=1,
|
||||
disable_existing_loggers=False,
|
||||
formatters={
|
||||
"default": {
|
||||
"()": "uvicorn.logging.DefaultFormatter",
|
||||
"fmt": "%(levelprefix)s [%(asctime)s] %(name)s: %(message)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
},
|
||||
},
|
||||
handlers={
|
||||
"default": {
|
||||
"formatter": "default",
|
||||
"class": "logging.StreamHandler",
|
||||
"stream": "ext://sys.stderr",
|
||||
},
|
||||
},
|
||||
loggers={
|
||||
"ovdashboard_api": {
|
||||
"handlers": ["default"],
|
||||
"level": SETTINGS.log_level,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
dictConfig(LOG_CONFIG)
|
87
api/ovdashboard_api/app.py
Normal file
|
@ -0,0 +1,87 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Main script for `ovdashboard_api` module.
|
||||
|
||||
Creates the main `FastAPI` app.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .core.dav.webdav import WebDAV
|
||||
from .core.settings import SETTINGS
|
||||
from .routers import v1_router
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(
|
||||
title="OVDashboard API",
|
||||
description="This API enables the `OVDashboard` service.",
|
||||
contact={
|
||||
"name": "Jörn-Michael Miehe",
|
||||
"email": "jmm@yavook.de",
|
||||
},
|
||||
license_info={
|
||||
"name": "MIT License",
|
||||
"url": "https://opensource.org/licenses/mit-license.php",
|
||||
},
|
||||
openapi_url=SETTINGS.openapi_url,
|
||||
docs_url=SETTINGS.docs_url,
|
||||
redoc_url=SETTINGS.redoc_url,
|
||||
)
|
||||
|
||||
app.include_router(v1_router)
|
||||
|
||||
_logger.info(
|
||||
"Production mode is %s.",
|
||||
"enabled" if SETTINGS.production_mode else "disabled",
|
||||
)
|
||||
|
||||
if SETTINGS.production_mode:
|
||||
# Mount frontend in production mode
|
||||
app.mount(
|
||||
path="/",
|
||||
app=StaticFiles(
|
||||
directory=SETTINGS.ui_directory,
|
||||
html=True,
|
||||
),
|
||||
name="frontend",
|
||||
)
|
||||
|
||||
def check_webdav(retry: int) -> bool | None:
|
||||
if WebDAV._webdav_client.check(""):
|
||||
return True
|
||||
|
||||
_logger.warning(
|
||||
"WebDAV connection to %s failed (try %d of %d)",
|
||||
repr(SETTINGS.webdav.url),
|
||||
retry + 1,
|
||||
SETTINGS.webdav.retries,
|
||||
)
|
||||
|
||||
if retry < SETTINGS.webdav.retries:
|
||||
_logger.debug("Retrying in %d seconds ...", SETTINGS.webdav.retry_delay)
|
||||
time.sleep(SETTINGS.webdav.retry_delay)
|
||||
|
||||
if not any(check_webdav(n) for n in range(SETTINGS.webdav.retries)):
|
||||
raise ConnectionError("WebDAV connection failed")
|
||||
|
||||
else:
|
||||
assert WebDAV._webdav_client.check("")
|
||||
|
||||
# Allow CORS in debug mode
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_credentials=True,
|
||||
allow_headers=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_origins=["*"],
|
||||
expose_headers=["*"],
|
||||
)
|
||||
|
||||
_logger.debug("WebDAV connection ok.")
|
83
api/ovdashboard_api/core/calevent.py
Normal file
|
@ -0,0 +1,83 @@
|
|||
"""
|
||||
Definition of an asyncio compatible CalDAV calendar.
|
||||
|
||||
Caches events using `timed_alru_cache`.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Self
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, StringConstraints
|
||||
from vobject.base import Component
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
type StrippedStr = Annotated[str, StringConstraints(strip_whitespace=True)]
|
||||
|
||||
|
||||
@functools.total_ordering
|
||||
class CalEvent(BaseModel):
|
||||
"""
|
||||
A CalDAV calendar event.
|
||||
|
||||
Properties are to be named as in the EVENT component of
|
||||
RFC5545 (iCalendar).
|
||||
|
||||
https://icalendar.org/iCalendar-RFC-5545/3-6-1-event-component.html
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
summary: StrippedStr = ""
|
||||
description: StrippedStr = ""
|
||||
dtstart: datetime = datetime.now()
|
||||
dtend: datetime = datetime.now()
|
||||
|
||||
def __lt__(self, other: Self) -> bool:
|
||||
"""
|
||||
Order Events by start time.
|
||||
"""
|
||||
|
||||
return self.dtstart < other.dtstart
|
||||
|
||||
def __eq__(self, other: Self) -> bool:
|
||||
"""
|
||||
Compare all properties.
|
||||
"""
|
||||
|
||||
return self.model_dump() == other.model_dump()
|
||||
|
||||
@classmethod
|
||||
def from_vevent(cls, event: Component) -> Self:
|
||||
"""
|
||||
Create a CalEvent instance from a `VObject.VEvent` object.
|
||||
"""
|
||||
|
||||
data = {}
|
||||
keys = ("summary", "description", "dtstart", "dtend", "duration")
|
||||
|
||||
for key in keys:
|
||||
try:
|
||||
data[key] = event.contents[key][0].value # type: ignore
|
||||
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
if "dtend" not in data:
|
||||
data["dtend"] = data["dtstart"]
|
||||
|
||||
if "duration" in data:
|
||||
try:
|
||||
data["dtend"] += data["duration"]
|
||||
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
_logger.warn(
|
||||
"Could not add duration %s to %s",
|
||||
repr(data["duration"]),
|
||||
repr(data["dtstart"]),
|
||||
)
|
||||
|
||||
del data["duration"]
|
||||
|
||||
return cls.model_validate(data)
|
107
api/ovdashboard_api/core/config.py
Normal file
|
@ -0,0 +1,107 @@
|
|||
"""
|
||||
Python representation of the "config.txt" file inside the WebDAV directory.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TickerUIConfig(BaseModel):
|
||||
"""
|
||||
Configuration for how the UI displays the ticker content.
|
||||
"""
|
||||
|
||||
color: str = "primary"
|
||||
|
||||
|
||||
class TickerConfig(TickerUIConfig):
|
||||
"""
|
||||
Section "[ticker]" in "config.txt".
|
||||
|
||||
Combined configuration for the ticker.
|
||||
"""
|
||||
|
||||
file_name: str = "ticker"
|
||||
separator: str = " +++ "
|
||||
comment_marker: str = "#"
|
||||
|
||||
|
||||
class ImageUIConfig(BaseModel):
|
||||
"""
|
||||
Configuration for how the UI displays the image carousel.
|
||||
"""
|
||||
|
||||
height: int = 300
|
||||
contain: bool = False
|
||||
speed: int = 10000
|
||||
|
||||
|
||||
class ImageConfig(ImageUIConfig):
|
||||
"""
|
||||
Sections "[image*]" in "config.txt".
|
||||
"""
|
||||
|
||||
mode: str = "RGB"
|
||||
save_params: dict[str, Any] = {
|
||||
"format": "JPEG",
|
||||
"quality": 85,
|
||||
}
|
||||
|
||||
|
||||
class CalendarUIConfig(BaseModel):
|
||||
"""
|
||||
Configuration for how the UI displays the calendar carousel.
|
||||
"""
|
||||
|
||||
speed: int = 10000
|
||||
|
||||
|
||||
class CalendarConfig(CalendarUIConfig):
|
||||
"""
|
||||
Sections "[calendar*]" in "config.txt".
|
||||
"""
|
||||
|
||||
future_days: int = 365
|
||||
aggregates: dict[str, list[str]] = {}
|
||||
|
||||
|
||||
class ServerUIConfig(BaseModel):
|
||||
"""
|
||||
Section "[server]" in "config.txt".
|
||||
"""
|
||||
|
||||
name: str = "OEKZident"
|
||||
host: str = "https://oekzident.de"
|
||||
|
||||
|
||||
class LogoUIConfig(BaseModel):
|
||||
"""
|
||||
Section "[logo]" in "config.txt".
|
||||
"""
|
||||
|
||||
above: str = "Technisches Hilfswerk"
|
||||
below: str = "OV Musterstadt"
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
"""
|
||||
Main representation of "config.txt".
|
||||
"""
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""
|
||||
Fake hash (the config is always the config)
|
||||
"""
|
||||
|
||||
return hash("config")
|
||||
|
||||
image_dir: str = "image"
|
||||
text_dir: str = "text"
|
||||
file_dir: str = "file"
|
||||
|
||||
logo: LogoUIConfig = LogoUIConfig()
|
||||
image: ImageConfig = ImageConfig()
|
||||
server: ServerUIConfig = ServerUIConfig()
|
||||
ticker: TickerConfig = TickerConfig()
|
||||
calendar: CalendarConfig = CalendarConfig()
|
90
api/ovdashboard_api/core/dav/caldav.py
Normal file
|
@ -0,0 +1,90 @@
|
|||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import cast
|
||||
|
||||
from asyncify import asyncify
|
||||
from cachetools import cachedmethod
|
||||
from caldav import Calendar, DAVClient, Event, Principal
|
||||
from vobject.base import Component, toVName
|
||||
|
||||
from ..calevent import CalEvent
|
||||
from ..config import Config
|
||||
from ..settings import SETTINGS
|
||||
from .helpers import REDIS, RedisCache, davkey
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CalDAV:
|
||||
_caldav_client = DAVClient(
|
||||
url=SETTINGS.caldav.url,
|
||||
username=SETTINGS.caldav.username,
|
||||
password=SETTINGS.caldav.password,
|
||||
)
|
||||
|
||||
_cache = RedisCache(
|
||||
cache=REDIS,
|
||||
ttl=SETTINGS.caldav.cache_ttl,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def principal(cls) -> Principal:
|
||||
"""
|
||||
Gets the `Principal` object of the main CalDAV client.
|
||||
"""
|
||||
|
||||
return cls._caldav_client.principal()
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
@asyncify
|
||||
@cachedmethod(cache=lambda cls: cls._cache, key=davkey("calendars"))
|
||||
def calendars(cls) -> list[str]:
|
||||
"""
|
||||
Asynchroneously lists all calendars using the main WebDAV client.
|
||||
"""
|
||||
|
||||
_logger.debug("calendars")
|
||||
return [str(cal.name) for cal in cls.principal.calendars()]
|
||||
|
||||
@classmethod
|
||||
@asyncify
|
||||
@cachedmethod(cache=lambda cls: cls._cache, key=davkey("get_calendar"))
|
||||
def get_calendar(cls, calendar_name: str) -> Calendar:
|
||||
"""
|
||||
Get a calendar by name using the CalDAV principal object.
|
||||
"""
|
||||
|
||||
return cls.principal.calendar(calendar_name)
|
||||
|
||||
@classmethod
|
||||
@asyncify
|
||||
@cachedmethod(cache=lambda cls: cls._cache, key=davkey("get_events", slice(1, 2)))
|
||||
def get_events(cls, calendar_name: str, cfg: Config) -> list[CalEvent]:
|
||||
"""
|
||||
Get a sorted list of events by CalDAV calendar name.
|
||||
"""
|
||||
|
||||
_logger.info(f"downloading {calendar_name!r} ...")
|
||||
|
||||
dt_start = datetime.combine(
|
||||
datetime.now().date(),
|
||||
datetime.min.time(),
|
||||
)
|
||||
dt_end = dt_start + timedelta(days=cfg.calendar.future_days)
|
||||
|
||||
search_result = cls.principal.calendar(calendar_name).search(
|
||||
start=dt_start,
|
||||
end=dt_end,
|
||||
expand=True,
|
||||
comp_class=Event,
|
||||
split_expanded=False,
|
||||
)
|
||||
|
||||
vevents = []
|
||||
for event in search_result:
|
||||
vobject = cast(Component, event.vobject_instance)
|
||||
vevents.extend(vobject.contents[toVName("vevent")])
|
||||
|
||||
return sorted(CalEvent.from_vevent(vevent) for vevent in vevents)
|
64
api/ovdashboard_api/core/dav/helpers.py
Normal file
|
@ -0,0 +1,64 @@
|
|||
import pickle
|
||||
from typing import Callable, Hashable
|
||||
|
||||
import requests
|
||||
from cachetools.keys import hashkey
|
||||
from CacheToolsUtils import RedisCache as __RedisCache
|
||||
from redis import Redis
|
||||
from redis.commands.core import ResponseT
|
||||
from redis.typing import EncodableT
|
||||
from webdav3.client import Client as __WebDAVclient
|
||||
|
||||
from ..settings import SETTINGS
|
||||
|
||||
|
||||
def davkey(
|
||||
name: str,
|
||||
slice: slice = slice(1, None),
|
||||
) -> Callable[..., tuple[Hashable, ...]]:
|
||||
def func(*args, **kwargs) -> tuple[Hashable, ...]:
|
||||
"""Return a cache key for use with cached methods."""
|
||||
|
||||
key = hashkey(name, *args[slice], **kwargs)
|
||||
return hashkey(*(str(key_item) for key_item in key))
|
||||
|
||||
return func
|
||||
|
||||
|
||||
class WebDAVclient(__WebDAVclient):
|
||||
def execute_request(
|
||||
self,
|
||||
action,
|
||||
path,
|
||||
data=None,
|
||||
headers_ext=None,
|
||||
) -> requests.Response:
|
||||
res = super().execute_request(action, path, data, headers_ext)
|
||||
|
||||
# the "Content-Length" header can randomly be missing on txt files,
|
||||
# this should fix that (probably serverside bug)
|
||||
if action == "download" and "Content-Length" not in res.headers:
|
||||
res.headers["Content-Length"] = str(len(res.text))
|
||||
|
||||
return res
|
||||
|
||||
|
||||
class RedisCache(__RedisCache):
|
||||
"""
|
||||
Redis handles <bytes>, so ...
|
||||
"""
|
||||
|
||||
def _serialize(self, s) -> EncodableT:
|
||||
return pickle.dumps(s)
|
||||
|
||||
def _deserialize(self, s: ResponseT):
|
||||
assert isinstance(s, bytes)
|
||||
return pickle.loads(s)
|
||||
|
||||
|
||||
REDIS = Redis(
|
||||
host=SETTINGS.redis.host,
|
||||
port=SETTINGS.redis.port,
|
||||
db=SETTINGS.redis.db,
|
||||
protocol=SETTINGS.redis.protocol,
|
||||
)
|
101
api/ovdashboard_api/core/dav/webdav.py
Normal file
|
@ -0,0 +1,101 @@
|
|||
import logging
|
||||
import re
|
||||
from io import BytesIO
|
||||
|
||||
from asyncify import asyncify
|
||||
from cachetools import cachedmethod
|
||||
|
||||
from ..settings import SETTINGS
|
||||
from .helpers import REDIS, RedisCache, WebDAVclient, davkey
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebDAV:
|
||||
_webdav_client = WebDAVclient(
|
||||
{
|
||||
"webdav_hostname": SETTINGS.webdav.url,
|
||||
"webdav_login": SETTINGS.webdav.username,
|
||||
"webdav_password": SETTINGS.webdav.password,
|
||||
}
|
||||
)
|
||||
|
||||
_cache = RedisCache(
|
||||
cache=REDIS,
|
||||
ttl=SETTINGS.webdav.cache_ttl,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@asyncify
|
||||
@cachedmethod(cache=lambda cls: cls._cache, key=davkey("list_files"))
|
||||
def list_files(
|
||||
cls,
|
||||
directory: str = "",
|
||||
*,
|
||||
regex: re.Pattern[str] = re.compile(""),
|
||||
) -> list[str]:
|
||||
"""
|
||||
List files in directory `directory` matching RegEx `regex`
|
||||
"""
|
||||
|
||||
_logger.debug(f"list_files {directory!r}")
|
||||
ls = cls._webdav_client.list(directory)
|
||||
|
||||
return [path for path in ls if regex.search(path)]
|
||||
|
||||
@classmethod
|
||||
@asyncify
|
||||
@cachedmethod(cache=lambda cls: cls._cache, key=davkey("exists"))
|
||||
def exists(cls, path: str) -> bool:
|
||||
"""
|
||||
`True` iff there is a WebDAV resource at `path`
|
||||
"""
|
||||
|
||||
_logger.debug(f"file_exists {path!r}")
|
||||
return cls._webdav_client.check(path)
|
||||
|
||||
@classmethod
|
||||
@asyncify
|
||||
@cachedmethod(cache=lambda cls: cls._cache, key=davkey("read_bytes"))
|
||||
def read_bytes(cls, path: str) -> bytes:
|
||||
"""
|
||||
Load WebDAV file from `path` as bytes
|
||||
"""
|
||||
|
||||
_logger.debug(f"read_bytes {path!r}")
|
||||
buffer = BytesIO()
|
||||
cls._webdav_client.download_from(buffer, path)
|
||||
buffer.seek(0)
|
||||
|
||||
return buffer.read()
|
||||
|
||||
@classmethod
|
||||
async def read_str(cls, path: str, encoding="utf-8") -> str:
|
||||
"""
|
||||
Load WebDAV file from `path` as string
|
||||
"""
|
||||
|
||||
_logger.debug(f"read_str {path!r}")
|
||||
return (await cls.read_bytes(path)).decode(encoding=encoding).strip()
|
||||
|
||||
@classmethod
|
||||
@asyncify
|
||||
def write_bytes(cls, path: str, buffer: bytes) -> None:
|
||||
"""
|
||||
Write bytes from `buffer` into WebDAV file at `path`
|
||||
"""
|
||||
|
||||
_logger.debug(f"write_bytes {path!r}")
|
||||
cls._webdav_client.upload_to(buffer, path)
|
||||
|
||||
# invalidate cache entry
|
||||
cls._cache.pop(davkey("read_bytes")(path))
|
||||
|
||||
@classmethod
|
||||
async def write_str(cls, path: str, content: str, encoding="utf-8") -> None:
|
||||
"""
|
||||
Write string from `content` into WebDAV file at `path`
|
||||
"""
|
||||
|
||||
_logger.debug(f"write_str {path!r}")
|
||||
await cls.write_bytes(path, content.encode(encoding=encoding))
|
60
api/ovdashboard_api/core/dav_common.py
Normal file
|
@ -0,0 +1,60 @@
|
|||
"""
|
||||
Definition of WebDAV and CalDAV clients.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from os import path
|
||||
from pathlib import Path
|
||||
|
||||
from .. import __file__ as OVD_INIT
|
||||
from .dav.webdav import WebDAV
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def webdav_ensure_path(remote_path: str) -> bool:
|
||||
if WebDAV._webdav_client.check(remote_path):
|
||||
_logger.debug(
|
||||
"WebDAV path %s found.",
|
||||
repr(remote_path),
|
||||
)
|
||||
return True
|
||||
|
||||
_logger.info(
|
||||
"WebDAV path %s not found, creating ...",
|
||||
repr(remote_path),
|
||||
)
|
||||
WebDAV._webdav_client.mkdir(remote_path)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_skel_path(skel_file: str) -> Path:
|
||||
skel_path = path.dirname(Path(OVD_INIT).absolute())
|
||||
return Path(skel_path).joinpath("skel", skel_file)
|
||||
|
||||
|
||||
def webdav_upload_skel(remote_path: str, *skel_files: str) -> None:
|
||||
for skel_file in skel_files:
|
||||
_logger.debug(
|
||||
"Creating WebDAV file %s ...",
|
||||
repr(skel_file),
|
||||
)
|
||||
|
||||
WebDAV._webdav_client.upload_file(
|
||||
f"{remote_path}/{skel_file}",
|
||||
get_skel_path(skel_file),
|
||||
)
|
||||
|
||||
|
||||
def webdav_ensure_files(remote_path: str, *file_names: str) -> None:
|
||||
missing_files = (
|
||||
file_name
|
||||
for file_name in file_names
|
||||
if not WebDAV._webdav_client.check(f"{remote_path}/{file_name}")
|
||||
)
|
||||
|
||||
webdav_upload_skel(
|
||||
remote_path,
|
||||
*missing_files,
|
||||
)
|
176
api/ovdashboard_api/core/settings.py
Normal file
|
@ -0,0 +1,176 @@
|
|||
"""
|
||||
Configuration definition.
|
||||
|
||||
Converts per-run (environment) variables and config files into the
|
||||
"python world" using `pydantic`.
|
||||
|
||||
Pydantic models might have convenience methods attached.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class DAVSettings(BaseModel):
|
||||
"""
|
||||
Connection to a DAV server.
|
||||
"""
|
||||
|
||||
protocol: str = "https"
|
||||
host: str = "example.com"
|
||||
|
||||
username: str = "ovd_user"
|
||||
password: str = "password"
|
||||
|
||||
cache_ttl: int = 60 * 10
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
"""
|
||||
Combined DAV URL.
|
||||
"""
|
||||
|
||||
return f"{self.protocol}://{self.host}"
|
||||
|
||||
|
||||
_DAV_DEFAULT = DAVSettings().model_dump()
|
||||
|
||||
|
||||
class CalDAVSettings(DAVSettings):
|
||||
"""
|
||||
Connection to a CalDAV server.
|
||||
"""
|
||||
|
||||
path: str = "/remote.php/dav"
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
"""
|
||||
Combined DAV URL.
|
||||
"""
|
||||
|
||||
return f"{super().url}{self.path}"
|
||||
|
||||
|
||||
class WebDAVSettings(CalDAVSettings):
|
||||
"""
|
||||
Connection to a WebDAV server.
|
||||
"""
|
||||
|
||||
path: str = "/remote.php/webdav"
|
||||
|
||||
config_filename: str = "config.txt"
|
||||
|
||||
disable_check: bool = False
|
||||
retries: int = 20
|
||||
retry_delay: int = 30
|
||||
prefix: str = "/ovdashboard"
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
"""
|
||||
Combined DAV URL.
|
||||
"""
|
||||
|
||||
return f"{super().url}{self.prefix}"
|
||||
|
||||
|
||||
_WEBDAV_DEFAULT = WebDAVSettings().model_dump()
|
||||
|
||||
|
||||
class RedisSettings(BaseModel):
|
||||
"""
|
||||
Connection to a redis server.
|
||||
"""
|
||||
|
||||
host: str = "redis"
|
||||
port: int = 6379
|
||||
db: int = 0
|
||||
protocol: int = 3
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""
|
||||
Per-run settings.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
extra="ignore",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
env_nested_delimiter="__",
|
||||
)
|
||||
|
||||
#####
|
||||
# general settings
|
||||
#####
|
||||
|
||||
log_level: str = "INFO"
|
||||
production_mode: bool = False
|
||||
ui_directory: str = "/usr/local/share/ovdashboard_ui/html"
|
||||
|
||||
# doesn't even have to be reachable
|
||||
ping_host: str = "1.0.0.0"
|
||||
ping_port: int = 1
|
||||
|
||||
#####
|
||||
# openapi settings
|
||||
#####
|
||||
|
||||
def __dev_value[T](self, value: T) -> T | None:
|
||||
if self.production_mode:
|
||||
return None
|
||||
|
||||
return value
|
||||
|
||||
@property
|
||||
def openapi_url(self) -> str | None:
|
||||
return self.__dev_value("/api/openapi.json")
|
||||
|
||||
@property
|
||||
def docs_url(self) -> str | None:
|
||||
return self.__dev_value("/api/docs")
|
||||
|
||||
@property
|
||||
def redoc_url(self) -> str | None:
|
||||
return self.__dev_value("/api/redoc")
|
||||
|
||||
#####
|
||||
# webdav settings
|
||||
#####
|
||||
|
||||
webdav: WebDAVSettings = WebDAVSettings()
|
||||
|
||||
#####
|
||||
# caldav settings
|
||||
#####
|
||||
|
||||
caldav: CalDAVSettings = CalDAVSettings()
|
||||
|
||||
#####
|
||||
# redis settings
|
||||
#####
|
||||
|
||||
redis: RedisSettings = RedisSettings()
|
||||
|
||||
@model_validator(mode="before")
|
||||
def validate_dav_settings(cls, data) -> dict[str, Any]:
|
||||
assert isinstance(data, dict)
|
||||
|
||||
# ensure both settings dicts are created
|
||||
for key in ("webdav", "caldav"):
|
||||
data[key] = data.get(key, {})
|
||||
|
||||
for key in _DAV_DEFAULT:
|
||||
# if "webdav" value is not specified, use default value
|
||||
data["webdav"][key] = data["webdav"].get(key, _WEBDAV_DEFAULT[key])
|
||||
|
||||
# if "caldav" value is not specified, use "webdav" value
|
||||
data["caldav"][key] = data["caldav"].get(key, data["webdav"][key])
|
||||
|
||||
return data
|
||||
|
||||
|
||||
SETTINGS = Settings()
|
20
api/ovdashboard_api/main.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
from uvicorn import run as uvicorn_run
|
||||
|
||||
from .core.settings import SETTINGS
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
If the `main` script is run, `uvicorn` is used to run the app.
|
||||
"""
|
||||
|
||||
uvicorn_run(
|
||||
app="ovdashboard_api.app:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=not SETTINGS.production_mode,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
5
api/ovdashboard_api/routers/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from .v1 import router as v1_router
|
||||
|
||||
__all__ = [
|
||||
"v1_router",
|
||||
]
|
23
api/ovdashboard_api/routers/v1/__init__.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
"""
|
||||
Package `routers`: Each module contains the path operations for their prefixes.
|
||||
|
||||
This file: Main API router definition.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import aggregate, calendar, file, image, misc, text, ticker
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
|
||||
router.include_router(misc.router)
|
||||
|
||||
router.include_router(text.router)
|
||||
router.include_router(ticker.router)
|
||||
router.include_router(image.router)
|
||||
router.include_router(file.router)
|
||||
|
||||
router.include_router(calendar.router)
|
||||
router.include_router(aggregate.router)
|
||||
|
||||
__all__ = ["router"]
|
141
api/ovdashboard_api/routers/v1/_common.py
Normal file
|
@ -0,0 +1,141 @@
|
|||
"""
|
||||
Dependables for defining Routers.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
import tomli_w
|
||||
from fastapi import Depends, HTTPException, params, status
|
||||
from webdav3.exceptions import RemoteResourceNotFound
|
||||
|
||||
from ...core.config import Config
|
||||
from ...core.dav.caldav import CalDAV
|
||||
from ...core.dav.webdav import WebDAV
|
||||
from ...core.settings import SETTINGS
|
||||
from ._list_manager import Dependable, DependableFn, ListManager
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_RESPONSE_OK = {
|
||||
status.HTTP_200_OK: {
|
||||
"description": "Operation successful",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def get_config() -> Config:
|
||||
"""
|
||||
Load the configuration instance from the server using `TOML`.
|
||||
"""
|
||||
|
||||
try:
|
||||
cfg_str = await WebDAV.read_str(SETTINGS.webdav.config_filename)
|
||||
cfg = Config.model_validate(tomllib.loads(cfg_str))
|
||||
|
||||
except RemoteResourceNotFound:
|
||||
_logger.warning(
|
||||
f"Config file {SETTINGS.webdav.config_filename!r} not found, creating ..."
|
||||
)
|
||||
|
||||
cfg = Config()
|
||||
cfg.calendar.aggregates["All Events"] = list(await CalDAV.calendars)
|
||||
|
||||
await WebDAV.write_str(
|
||||
SETTINGS.webdav.config_filename,
|
||||
tomli_w.dumps(cfg.model_dump()),
|
||||
)
|
||||
|
||||
return cfg
|
||||
|
||||
|
||||
def get_remote_path(
|
||||
path_name: str,
|
||||
) -> DependableFn[[], str]:
|
||||
async def _get_remote_path() -> str:
|
||||
cfg = await get_config()
|
||||
return getattr(cfg, path_name)
|
||||
|
||||
return _get_remote_path
|
||||
|
||||
|
||||
RP_FILE = get_remote_path("file_dir")
|
||||
RP_IMAGE = get_remote_path("image_dir")
|
||||
RP_TEXT = get_remote_path("text_dir")
|
||||
|
||||
|
||||
def get_file_lister(
|
||||
rp: DependableFn[[], str],
|
||||
*,
|
||||
re: re.Pattern[str],
|
||||
) -> Dependable[[], list[str]]:
|
||||
"""
|
||||
List files in remote `path` matching the RegEx `re`
|
||||
"""
|
||||
|
||||
async def _list_files(
|
||||
remote_path: str = Depends(rp),
|
||||
) -> list[str]:
|
||||
if isinstance(remote_path, params.Depends):
|
||||
remote_path = await rp()
|
||||
|
||||
_logger.debug("list %s", repr(remote_path))
|
||||
|
||||
try:
|
||||
return await WebDAV.list_files(remote_path, regex=re)
|
||||
|
||||
except RemoteResourceNotFound:
|
||||
_logger.error("WebDAV path %s lost!", repr(remote_path))
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
return Dependable(
|
||||
func=_list_files,
|
||||
responses={
|
||||
**_RESPONSE_OK,
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"description": "Remote path not found",
|
||||
"content": None,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
LM_FILE = ListManager.from_lister(
|
||||
get_file_lister(rp=RP_FILE, re=re.compile(r"[^/]$", flags=re.IGNORECASE))
|
||||
)
|
||||
LM_IMAGE = ListManager.from_lister(
|
||||
get_file_lister(
|
||||
rp=RP_IMAGE, re=re.compile(r"\.(gif|jpe?g|tiff?|png|bmp)$", flags=re.IGNORECASE)
|
||||
)
|
||||
)
|
||||
LM_TEXT = ListManager.from_lister(
|
||||
get_file_lister(rp=RP_TEXT, re=re.compile(r"\.(txt|md)$", flags=re.IGNORECASE))
|
||||
)
|
||||
|
||||
|
||||
async def list_calendar_names() -> list[str]:
|
||||
"""
|
||||
List calendar names
|
||||
"""
|
||||
|
||||
return await CalDAV.calendars
|
||||
|
||||
|
||||
LM_CALENDAR = ListManager.from_lister_fn(list_calendar_names)
|
||||
|
||||
|
||||
async def list_aggregate_names(
|
||||
cfg: Config = Depends(get_config),
|
||||
) -> list[str]:
|
||||
"""
|
||||
List aggregate calendar names
|
||||
"""
|
||||
|
||||
if isinstance(cfg, params.Depends):
|
||||
cfg = await get_config()
|
||||
|
||||
return list(cfg.calendar.aggregates.keys())
|
||||
|
||||
|
||||
LM_AGGREGATE = ListManager.from_lister_fn(list_aggregate_names)
|
88
api/ovdashboard_api/routers/v1/_list_manager.py
Normal file
|
@ -0,0 +1,88 @@
|
|||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Awaitable, Callable, Generic, ParamSpec, Self, TypeVar
|
||||
|
||||
from fastapi import Depends, HTTPException, params, status
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_RESPONSE_OK = {
|
||||
status.HTTP_200_OK: {"description": "Operation successful"},
|
||||
}
|
||||
|
||||
Params = ParamSpec("Params")
|
||||
Return = TypeVar("Return")
|
||||
|
||||
type DependableFn[**Params, Return] = Callable[Params, Awaitable[Return]]
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class Dependable(Generic[Params, Return]):
|
||||
func: DependableFn[Params, Return]
|
||||
responses: dict = field(default_factory=lambda: _RESPONSE_OK.copy())
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class ListManager:
|
||||
lister: Dependable[[], list[str]]
|
||||
filter: Dependable[[str], list[str]]
|
||||
getter: Dependable[[str], str]
|
||||
|
||||
@classmethod
|
||||
def from_lister(cls, lister: Dependable[[], list[str]]) -> Self:
|
||||
async def _filter_fn(
|
||||
prefix: str,
|
||||
names: list[str] = Depends(lister.func),
|
||||
) -> list[str]:
|
||||
"""
|
||||
Filters `names` from an async source for names starting with a given prefix.
|
||||
"""
|
||||
|
||||
if isinstance(names, params.Depends):
|
||||
names = await lister.func()
|
||||
|
||||
# _logger.debug("filter %s from %s", repr(prefix), repr(names))
|
||||
|
||||
return [item for item in names if item.lower().startswith(prefix.lower())]
|
||||
|
||||
async def _getter_fn(
|
||||
prefix: str,
|
||||
names: list[str] = Depends(_filter_fn),
|
||||
) -> str:
|
||||
"""
|
||||
Determines if a given prefix is unique in the async produced list `names`.
|
||||
|
||||
On success, produces the unique name with that prefix. Otherwise, throws a HTTPException.
|
||||
"""
|
||||
|
||||
if isinstance(names, params.Depends):
|
||||
names = await _filter_fn(prefix)
|
||||
|
||||
_logger.debug("get %s from %s", repr(prefix), repr(names))
|
||||
|
||||
match names:
|
||||
case [name]:
|
||||
return name
|
||||
|
||||
case []:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
case _:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
||||
|
||||
return cls(
|
||||
lister=lister,
|
||||
filter=Dependable(_filter_fn),
|
||||
getter=Dependable(
|
||||
func=_getter_fn,
|
||||
responses={
|
||||
**_RESPONSE_OK,
|
||||
status.HTTP_404_NOT_FOUND: {"description": "Prefix not found"},
|
||||
status.HTTP_409_CONFLICT: {"description": "Ambiguous prefix"},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_lister_fn(cls, lister_fn: DependableFn[[], list[str]]) -> Self:
|
||||
return cls.from_lister(Dependable(lister_fn))
|
62
api/ovdashboard_api/routers/v1/aggregate.py
Normal file
|
@ -0,0 +1,62 @@
|
|||
"""
|
||||
Router "aggregate" provides:
|
||||
|
||||
- listing aggregate calendars
|
||||
- finding aggregate calendars by name prefix
|
||||
- getting aggregate calendar events by name prefix
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ...core.calevent import CalEvent
|
||||
from ...core.config import Config
|
||||
from ...core.dav.caldav import CalDAV
|
||||
from ._common import LM_AGGREGATE, LM_CALENDAR, get_config
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/aggregate", tags=["calendar"])
|
||||
|
||||
|
||||
@router.on_event("startup")
|
||||
async def start_router() -> None:
|
||||
_logger.debug(f"{router.prefix} router starting.")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/list",
|
||||
responses=LM_AGGREGATE.lister.responses,
|
||||
)
|
||||
async def list_aggregate_calendars(
|
||||
names: list[str] = Depends(LM_AGGREGATE.lister.func),
|
||||
) -> list[str]:
|
||||
return names
|
||||
|
||||
|
||||
@router.get(
|
||||
"/find/{prefix}",
|
||||
responses=LM_AGGREGATE.filter.responses,
|
||||
)
|
||||
async def find_aggregate_calendars(
|
||||
names: list[str] = Depends(LM_AGGREGATE.filter.func),
|
||||
) -> list[str]:
|
||||
return names
|
||||
|
||||
|
||||
@router.get(
|
||||
"/get/{prefix}",
|
||||
responses=LM_AGGREGATE.getter.responses,
|
||||
)
|
||||
async def get_aggregate_calendar(
|
||||
cfg: Config = Depends(get_config),
|
||||
name: str = Depends(LM_AGGREGATE.getter.func),
|
||||
) -> list[CalEvent]:
|
||||
events: list[CalEvent] = []
|
||||
|
||||
for cal_prefix in cfg.calendar.aggregates[name]:
|
||||
cal_name = await LM_CALENDAR.getter.func(cal_prefix)
|
||||
events.extend(await CalDAV.get_events(cal_name, cfg))
|
||||
|
||||
return sorted(events)
|
62
api/ovdashboard_api/routers/v1/calendar.py
Normal file
|
@ -0,0 +1,62 @@
|
|||
"""
|
||||
Router "calendar" provides:
|
||||
|
||||
- listing calendars
|
||||
- finding calendars by name prefix
|
||||
- getting calendar events by calendar name prefix
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ...core.config import CalendarUIConfig, Config
|
||||
from ...core.dav.caldav import CalDAV, CalEvent
|
||||
from ._common import LM_CALENDAR, get_config
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/calendar", tags=["calendar"])
|
||||
|
||||
|
||||
@router.on_event("startup")
|
||||
async def start_router() -> None:
|
||||
_logger.debug(f"{router.prefix} router starting.")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/list",
|
||||
responses=LM_CALENDAR.lister.responses,
|
||||
)
|
||||
async def list_calendars(
|
||||
names: list[str] = Depends(LM_CALENDAR.lister.func),
|
||||
) -> list[str]:
|
||||
return names
|
||||
|
||||
|
||||
@router.get(
|
||||
"/find/{prefix}",
|
||||
responses=LM_CALENDAR.filter.responses,
|
||||
)
|
||||
async def find_calendars(
|
||||
names: list[str] = Depends(LM_CALENDAR.filter.func),
|
||||
) -> list[str]:
|
||||
return names
|
||||
|
||||
|
||||
@router.get(
|
||||
"/get/{prefix}",
|
||||
responses=LM_CALENDAR.getter.responses,
|
||||
)
|
||||
async def get_calendar(
|
||||
name: str = Depends(LM_CALENDAR.getter.func),
|
||||
cfg: Config = Depends(get_config),
|
||||
) -> list[CalEvent]:
|
||||
return await CalDAV.get_events(name, cfg)
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_ui_config(
|
||||
cfg: Config = Depends(get_config),
|
||||
) -> CalendarUIConfig:
|
||||
return cfg.calendar
|
77
api/ovdashboard_api/routers/v1/file.py
Normal file
|
@ -0,0 +1,77 @@
|
|||
"""
|
||||
Router "file" provides:
|
||||
|
||||
- listing files
|
||||
- finding files by name prefix
|
||||
- getting files by name prefix
|
||||
"""
|
||||
|
||||
import logging
|
||||
from io import BytesIO
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
from magic import Magic
|
||||
|
||||
from ...core.dav.webdav import WebDAV
|
||||
from ...core.dav_common import webdav_ensure_files, webdav_ensure_path
|
||||
from ._common import LM_FILE, RP_FILE
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_magic = Magic(mime=True)
|
||||
|
||||
router = APIRouter(prefix="/file", tags=["file"])
|
||||
|
||||
|
||||
@router.on_event("startup")
|
||||
async def start_router() -> None:
|
||||
_logger.debug(f"{router.prefix} router starting.")
|
||||
|
||||
remote_path = await RP_FILE()
|
||||
if not webdav_ensure_path(remote_path):
|
||||
webdav_ensure_files(
|
||||
remote_path,
|
||||
"logo.svg",
|
||||
"thw.svg",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/list",
|
||||
responses=LM_FILE.lister.responses,
|
||||
)
|
||||
async def list_files(
|
||||
names: list[str] = Depends(LM_FILE.lister.func),
|
||||
) -> list[str]:
|
||||
return names
|
||||
|
||||
|
||||
@router.get(
|
||||
"/find/{prefix}",
|
||||
responses=LM_FILE.filter.responses,
|
||||
)
|
||||
async def find_files_by_prefix(
|
||||
names: list[str] = Depends(LM_FILE.filter.func),
|
||||
) -> list[str]:
|
||||
return names
|
||||
|
||||
|
||||
@router.get(
|
||||
"/get/{prefix}",
|
||||
responses=LM_FILE.getter.responses,
|
||||
response_class=StreamingResponse,
|
||||
)
|
||||
async def get_file_by_prefix(
|
||||
remote_path: str = Depends(RP_FILE),
|
||||
name: str = Depends(LM_FILE.getter.func),
|
||||
) -> StreamingResponse:
|
||||
buffer = BytesIO(await WebDAV.read_bytes(f"{remote_path}/{name}"))
|
||||
|
||||
mime = _magic.from_buffer(buffer.read(2048))
|
||||
buffer.seek(0)
|
||||
|
||||
return StreamingResponse(
|
||||
content=buffer,
|
||||
media_type=mime,
|
||||
headers={"Content-Disposition": f"filename={name}"},
|
||||
)
|
97
api/ovdashboard_api/routers/v1/image.py
Normal file
|
@ -0,0 +1,97 @@
|
|||
"""
|
||||
Router "image" provides:
|
||||
|
||||
- listing image files
|
||||
- finding image files by name prefix
|
||||
- getting image files in a uniform format by name prefix
|
||||
"""
|
||||
|
||||
import logging
|
||||
from io import BytesIO
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
from PIL import Image
|
||||
|
||||
from ...core.config import Config, ImageUIConfig
|
||||
from ...core.dav.webdav import WebDAV
|
||||
from ...core.dav_common import webdav_ensure_files, webdav_ensure_path
|
||||
from ._common import LM_IMAGE, RP_IMAGE, get_config
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/image", tags=["image"])
|
||||
|
||||
|
||||
@router.on_event("startup")
|
||||
async def start_router() -> None:
|
||||
_logger.debug(f"{router.prefix} router starting.")
|
||||
|
||||
remote_path = await RP_IMAGE()
|
||||
if not webdav_ensure_path(remote_path):
|
||||
webdav_ensure_files(
|
||||
remote_path,
|
||||
"img1.jpg",
|
||||
"img2.jpg",
|
||||
"img3.jpg",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/list",
|
||||
responses=LM_IMAGE.lister.responses,
|
||||
)
|
||||
async def list_images(
|
||||
names: list[str] = Depends(LM_IMAGE.lister.func),
|
||||
) -> list[str]:
|
||||
return names
|
||||
|
||||
|
||||
@router.get(
|
||||
"/find/{prefix}",
|
||||
responses=LM_IMAGE.filter.responses,
|
||||
)
|
||||
async def find_images_by_prefix(
|
||||
names: list[str] = Depends(LM_IMAGE.filter.func),
|
||||
) -> list[str]:
|
||||
return names
|
||||
|
||||
|
||||
@router.get(
|
||||
"/get/{prefix}",
|
||||
responses=LM_IMAGE.getter.responses,
|
||||
response_class=StreamingResponse,
|
||||
)
|
||||
async def get_image_by_prefix(
|
||||
cfg: Config = Depends(get_config),
|
||||
remote_path: str = Depends(RP_IMAGE),
|
||||
name: str = Depends(LM_IMAGE.getter.func),
|
||||
) -> StreamingResponse:
|
||||
img = Image.open(BytesIO(await WebDAV.read_bytes(f"{remote_path}/{name}")))
|
||||
|
||||
img_buffer = BytesIO()
|
||||
|
||||
width, height = img.size
|
||||
target_height = cfg.image.height
|
||||
target_width = int(width * target_height / height)
|
||||
|
||||
img = img.resize(
|
||||
size=(target_width, target_height),
|
||||
resample=Image.LANCZOS,
|
||||
)
|
||||
|
||||
img.save(img_buffer, **cfg.image.save_params)
|
||||
img_buffer.seek(0)
|
||||
|
||||
return StreamingResponse(
|
||||
content=img_buffer,
|
||||
media_type="image/jpeg",
|
||||
headers={"Content-Disposition": f"filename={name}.jpg"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_ui_config(
|
||||
cfg: Config = Depends(get_config),
|
||||
) -> ImageUIConfig:
|
||||
return cfg.image
|
61
api/ovdashboard_api/routers/v1/misc.py
Normal file
|
@ -0,0 +1,61 @@
|
|||
"""
|
||||
Router "misc" provides:
|
||||
|
||||
- getting the project version
|
||||
- getting the device IP
|
||||
"""
|
||||
|
||||
import importlib.metadata
|
||||
import logging
|
||||
from socket import AF_INET, SOCK_DGRAM, socket
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ...core.config import Config, LogoUIConfig, ServerUIConfig
|
||||
from ...core.settings import SETTINGS
|
||||
from ._common import get_config
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/misc", tags=["misc"])
|
||||
|
||||
|
||||
@router.on_event("startup")
|
||||
async def start_router() -> None:
|
||||
_logger.debug(f"{router.prefix} router starting.")
|
||||
|
||||
|
||||
@router.get("/lanip")
|
||||
async def get_lan_ip() -> str:
|
||||
with socket(
|
||||
family=AF_INET,
|
||||
type=SOCK_DGRAM,
|
||||
) as s:
|
||||
try:
|
||||
s.settimeout(0)
|
||||
s.connect((SETTINGS.ping_host, SETTINGS.ping_port))
|
||||
IP = s.getsockname()[0]
|
||||
|
||||
except Exception:
|
||||
IP = "127.0.0.1"
|
||||
|
||||
return IP
|
||||
|
||||
|
||||
@router.get("/version")
|
||||
async def get_server_api_version() -> str:
|
||||
return importlib.metadata.version("ovdashboard_api")
|
||||
|
||||
|
||||
@router.get("/config/server")
|
||||
async def get_server_ui_config(
|
||||
cfg: Config = Depends(get_config),
|
||||
) -> ServerUIConfig:
|
||||
return cfg.server
|
||||
|
||||
|
||||
@router.get("/config/logo")
|
||||
async def get_logo_ui_config(
|
||||
cfg: Config = Depends(get_config),
|
||||
) -> LogoUIConfig:
|
||||
return cfg.logo
|
82
api/ovdashboard_api/routers/v1/text.py
Normal file
|
@ -0,0 +1,82 @@
|
|||
"""
|
||||
Router "text" provides:
|
||||
|
||||
- listing text files
|
||||
- finding text files by name prefix
|
||||
- getting text file raw content by name prefix
|
||||
- getting text file HTML content by name prefix (using Markdown)
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import markdown
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ...core.dav.webdav import WebDAV
|
||||
from ...core.dav_common import webdav_ensure_files, webdav_ensure_path
|
||||
from ._common import LM_TEXT, RP_TEXT
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/text", tags=["text"])
|
||||
|
||||
|
||||
@router.on_event("startup")
|
||||
async def start_router() -> None:
|
||||
_logger.debug(f"{router.prefix} router starting.")
|
||||
|
||||
remote_path = await RP_TEXT()
|
||||
if not webdav_ensure_path(remote_path):
|
||||
webdav_ensure_files(
|
||||
remote_path,
|
||||
"message.txt",
|
||||
"title.txt",
|
||||
"ticker.txt",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/list",
|
||||
responses=LM_TEXT.lister.responses,
|
||||
)
|
||||
async def list_texts(
|
||||
names: list[str] = Depends(LM_TEXT.lister.func),
|
||||
) -> list[str]:
|
||||
return names
|
||||
|
||||
|
||||
@router.get(
|
||||
"/find/{prefix}",
|
||||
responses=LM_TEXT.filter.responses,
|
||||
)
|
||||
async def find_texts_by_prefix(
|
||||
names: list[str] = Depends(LM_TEXT.filter.func),
|
||||
) -> list[str]:
|
||||
return names
|
||||
|
||||
|
||||
async def _get_raw_text_by_prefix(
|
||||
remote_path: str = Depends(RP_TEXT),
|
||||
name: str = Depends(LM_TEXT.getter.func),
|
||||
) -> str:
|
||||
return await WebDAV.read_str(f"{remote_path}/{name}")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/get/raw/{prefix}",
|
||||
responses=LM_TEXT.getter.responses,
|
||||
)
|
||||
async def get_raw_text_by_prefix(
|
||||
text: str = Depends(_get_raw_text_by_prefix),
|
||||
) -> str:
|
||||
return text
|
||||
|
||||
|
||||
@router.get(
|
||||
"/get/html/{prefix}",
|
||||
responses=LM_TEXT.getter.responses,
|
||||
)
|
||||
async def get_html_by_prefix(
|
||||
text: str = Depends(_get_raw_text_by_prefix),
|
||||
) -> str:
|
||||
return markdown.markdown(text)
|
90
api/ovdashboard_api/routers/v1/ticker.py
Normal file
|
@ -0,0 +1,90 @@
|
|||
"""
|
||||
Router "ticker" provides:
|
||||
|
||||
- getting the ticker's raw content
|
||||
- getting the ticker's HTML content (using Markdown)
|
||||
- getting the ticker's UI config
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Iterator
|
||||
|
||||
import markdown
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ...core.config import Config, TickerUIConfig
|
||||
from ...core.dav.webdav import WebDAV
|
||||
from ...core.dav_common import webdav_ensure_files, webdav_ensure_path
|
||||
from ._common import LM_TEXT, RP_TEXT, get_config
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/ticker", tags=["text"])
|
||||
|
||||
|
||||
@router.on_event("startup")
|
||||
async def start_router() -> None:
|
||||
_logger.debug(f"{router.prefix} router starting.")
|
||||
|
||||
remote_path = await RP_TEXT()
|
||||
if not webdav_ensure_path(remote_path):
|
||||
webdav_ensure_files(
|
||||
remote_path,
|
||||
"ticker.txt",
|
||||
)
|
||||
|
||||
|
||||
async def get_ticker_lines() -> Iterator[str]:
|
||||
cfg = await get_config()
|
||||
file_name = await LM_TEXT.getter.func(cfg.ticker.file_name)
|
||||
remote_path = await RP_TEXT()
|
||||
|
||||
ticker = await WebDAV.read_str(f"{remote_path}/{file_name}")
|
||||
|
||||
return (line.strip() for line in ticker.split("\n") if line.strip())
|
||||
|
||||
|
||||
async def get_ticker_content_lines(
|
||||
ticker_lines: Iterator[str] = Depends(get_ticker_lines),
|
||||
) -> Iterator[str]:
|
||||
cfg = await get_config()
|
||||
|
||||
return (
|
||||
line for line in ticker_lines if not line.startswith(cfg.ticker.comment_marker)
|
||||
)
|
||||
|
||||
|
||||
async def get_ticker_content(
|
||||
ticker_content_lines: Iterator[str] = Depends(get_ticker_content_lines),
|
||||
) -> str:
|
||||
ticker_content_padded = ["", *ticker_content_lines, ""]
|
||||
if len(ticker_content_padded) == 2:
|
||||
return ""
|
||||
|
||||
cfg = await get_config()
|
||||
ticker_content = cfg.ticker.separator.join(
|
||||
ticker_content_padded,
|
||||
)
|
||||
|
||||
return ticker_content.strip()
|
||||
|
||||
|
||||
@router.get("/html")
|
||||
async def get_ticker(
|
||||
ticker_content: str = Depends(get_ticker_content),
|
||||
) -> str:
|
||||
return markdown.markdown(ticker_content)
|
||||
|
||||
|
||||
@router.get("/raw")
|
||||
async def get_raw_ticker(
|
||||
ticker_content: str = Depends(get_ticker_content),
|
||||
) -> str:
|
||||
return ticker_content
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_ui_config(
|
||||
cfg: Config = Depends(get_config),
|
||||
) -> TickerUIConfig:
|
||||
return cfg.ticker
|
BIN
api/ovdashboard_api/skel/img1.jpg
Normal file
After Width: | Height: | Size: 57 KiB |
BIN
api/ovdashboard_api/skel/img2.jpg
Normal file
After Width: | Height: | Size: 38 KiB |
BIN
api/ovdashboard_api/skel/img3.jpg
Normal file
After Width: | Height: | Size: 27 KiB |
17
api/ovdashboard_api/skel/logo.svg
Normal file
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" version="1.1" width="800" height="800" id="svg2">
|
||||
<defs id="defs4"/>
|
||||
<g transform="translate(-109.88407,434.92474)" id="layer1">
|
||||
<path d="m 640.41889,184.96103 268.5471,-260.189372 c -16.91878,-2.776927 -50.54031,-5.781455 -82.49714,-11.593718 -3.29434,-20.81784 -9.09759,-41.20158 -16.35943,-60.96009 21.49698,-17.75033 42.99403,-35.50067 64.49101,-53.251 -10.2467,-23.25949 -23.44491,-45.0888 -37.93294,-65.91233 -26.13729,9.7485 -52.27465,19.49708 -78.41194,29.24558 -13.45855,-16.37731 -28.52766,-31.42514 -44.91133,-44.8509 9.75879,-26.10005 19.5175,-52.20009 29.27628,-78.30014 -20.74155,-14.65144 -42.795,-27.53554 -65.97562,-37.90481 -17.78186,21.47172 -35.56379,42.94343 -53.34565,64.41515 -19.7602,-7.37928 -40.21013,-12.89714 -61.01974,-16.33233 -4.63077,-27.50848 -9.26147,-55.01689 -13.89224,-82.52537 -25.33474,-2.30409 -50.83946,-2.29977 -76.17427,0 -4.63314,27.50848 -9.26628,55.01689 -13.89942,82.52537 -20.80365,3.47176 -41.25731,8.94823 -61.01973,16.33233 -17.7795,-21.47172 -35.55899,-42.94343 -53.33848,-64.41515 -23.22975,10.29354 -45.15468,23.35933 -65.97562,37.90481 9.75878,26.10005 19.51749,52.20009 29.27627,78.30014 -16.23901,13.48334 -31.55182,28.25456 -44.6603,44.8509 -26.21382,-9.7485 -52.42763,-19.49708 -78.64145,-29.24558 -14.54519,20.7933 -27.664,42.673 -37.96162,65.91233 21.49942,17.75033 42.99876,35.50067 64.49818,53.251 -7.39732,19.7321 -12.86916,40.17161 -16.3666,60.96009 -27.53828,4.621133 -55.07649,9.242266 -82.61476,13.863399 -2.1661,25.292643 -2.16825,50.761852 0,76.0543506 27.53827,4.6307065 55.07648,9.2614854 82.61476,13.8921914 3.48819,20.778688 8.935,41.221875 16.3666,60.931296 -21.49942,17.759907 -42.99876,35.519883 -64.49818,53.279793 10.37959,23.17527 23.30634,45.1782 37.96162,65.87634 26.21382,-9.73655 52.42763,-19.47304 78.64145,-29.20959 13.15775,16.54949 28.40272,31.38217 44.6603,44.84371 -9.75878,26.09284 -19.51749,52.18569 -29.27627,78.27854 20.82417,14.54059 42.7359,27.62933 65.97562,37.9048 17.77949,-21.47408 35.55898,-42.94825 53.33848,-64.42233 19.73502,7.45233 40.20596,12.98624 61.01973,16.36831 4.63314,27.49884 9.26628,54.99774 13.89942,82.49658 25.3366,2.32072 50.83766,2.31906 76.17427,0 4.63077,-27.49884 9.26147,-54.99774 13.89224,-82.49658 20.81585,-3.34392 41.28716,-8.92073 61.01974,-16.36831 17.78186,21.47408 35.56379,42.94825 53.34565,64.42233 23.19145,-10.35011 45.22934,-23.2602 65.97562,-37.9048 -9.75878,-26.09285 -19.51749,-52.1857 -29.27628,-78.27854 16.40117,-13.40482 31.40516,-28.51304 44.91133,-44.84371 26.13729,9.73655 52.27465,19.47304 78.41194,29.20959 14.60013,-20.72744 27.60333,-42.68149 37.93294,-65.87634 -21.49698,-17.75991 -42.99403,-35.519886 -64.49101,-53.279793 7.29849,-19.735549 13.07456,-40.127127 16.35943,-60.931296 27.54064,-4.630706 55.08129,-9.2614849 82.62193,-13.8921914 1.23617,-22.6678096 0.89736,-63.3635816 -0.12479,-78.3240316 z" id="path6152" style="fill:#FFFFFF;fill-opacity:1;fill-rule:nonzero;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10"/>
|
||||
</g>
|
||||
<metadata id="metadata3029">
|
||||
<rdf:RDF>
|
||||
<cc:Work rdf:about="">
|
||||
<dc:title/>
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
</svg>
|
After Width: | Height: | Size: 3.5 KiB |
10
api/ovdashboard_api/skel/message.txt
Normal file
|
@ -0,0 +1,10 @@
|
|||
# The API is working!
|
||||
|
||||
## Everything seems to be set up correctly
|
||||
|
||||
If you're reading this text in the dashboard, your OVDashboard is set up correctly.
|
||||
|
||||
A few files, including message.txt have been uploaded to your WebDAV server, and this
|
||||
message is already being served from there.
|
||||
|
||||
> Congratulations!
|
20
api/ovdashboard_api/skel/thw.svg
Normal file
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" version="1.1" width="800" height="800" id="svg2">
|
||||
<defs id="defs4"/>
|
||||
<g transform="translate(-109.88407,434.92474)" id="layer1">
|
||||
<path d="m 640.41889,184.96103 c -15.38166,25.59619 -30.76325,51.19237 -46.14492,76.78855 -9.50532,-15.7757 -19.01071,-31.55133 -28.51604,-47.32702 -36.42365,8.00838 -74.46423,8.02054 -110.88695,0 -9.5173,15.77569 -19.0346,31.55132 -28.5519,47.32702 -16.26691,-25.75742 -30.5453,-53.45406 -48.03891,-78.01524 C 299.24885,137.7956 250.23741,46.392684 255.10184,-45.04896 c 2.09259,-89.32909 55.97988,-174.14531 134.99126,-214.97922 0,-18.90439 0,-37.80886 0,-56.71325 80.13559,0 160.27126,0 240.40686,0 0,18.90439 0,37.80886 0,56.71325 79.74845,41.01148 133.28072,126.86918 135.29312,216.763971 3.16811,112.413742 -51.83307,183.454159 -125.37419,228.225239 z m 268.5471,-260.189372 c -16.91878,-2.776927 -50.54031,-5.781455 -82.49714,-11.593718 -3.29434,-20.81784 -9.09759,-41.20158 -16.35943,-60.96009 21.49698,-17.75033 42.99403,-35.50067 64.49101,-53.251 -10.2467,-23.25949 -23.44491,-45.0888 -37.93294,-65.91233 -26.13729,9.7485 -52.27465,19.49708 -78.41194,29.24558 -13.45855,-16.37731 -28.52766,-31.42514 -44.91133,-44.8509 9.75879,-26.10005 19.5175,-52.20009 29.27628,-78.30014 -20.74155,-14.65144 -42.795,-27.53554 -65.97562,-37.90481 -17.78186,21.47172 -35.56379,42.94343 -53.34565,64.41515 -19.7602,-7.37928 -40.21013,-12.89714 -61.01974,-16.33233 -4.63077,-27.50848 -9.26147,-55.01689 -13.89224,-82.52537 -25.33474,-2.30409 -50.83946,-2.29977 -76.17427,0 -4.63314,27.50848 -9.26628,55.01689 -13.89942,82.52537 -20.80365,3.47176 -41.25731,8.94823 -61.01973,16.33233 -17.7795,-21.47172 -35.55899,-42.94343 -53.33848,-64.41515 -23.22975,10.29354 -45.15468,23.35933 -65.97562,37.90481 9.75878,26.10005 19.51749,52.20009 29.27627,78.30014 -16.23901,13.48334 -31.55182,28.25456 -44.6603,44.8509 -26.21382,-9.7485 -52.42763,-19.49708 -78.64145,-29.24558 -14.54519,20.7933 -27.664,42.673 -37.96162,65.91233 21.49942,17.75033 42.99876,35.50067 64.49818,53.251 -7.39732,19.7321 -12.86916,40.17161 -16.3666,60.96009 -27.53828,4.621133 -55.07649,9.242266 -82.61476,13.863399 -2.1661,25.292643 -2.16825,50.761852 0,76.0543506 27.53827,4.6307065 55.07648,9.2614854 82.61476,13.8921914 3.48819,20.778688 8.935,41.221875 16.3666,60.931296 -21.49942,17.759907 -42.99876,35.519883 -64.49818,53.279793 10.37959,23.17527 23.30634,45.1782 37.96162,65.87634 26.21382,-9.73655 52.42763,-19.47304 78.64145,-29.20959 13.15775,16.54949 28.40272,31.38217 44.6603,44.84371 -9.75878,26.09284 -19.51749,52.18569 -29.27627,78.27854 20.82417,14.54059 42.7359,27.62933 65.97562,37.9048 17.77949,-21.47408 35.55898,-42.94825 53.33848,-64.42233 19.73502,7.45233 40.20596,12.98624 61.01973,16.36831 4.63314,27.49884 9.26628,54.99774 13.89942,82.49658 25.3366,2.32072 50.83766,2.31906 76.17427,0 4.63077,-27.49884 9.26147,-54.99774 13.89224,-82.49658 20.81585,-3.34392 41.28716,-8.92073 61.01974,-16.36831 17.78186,21.47408 35.56379,42.94825 53.34565,64.42233 23.19145,-10.35011 45.22934,-23.2602 65.97562,-37.9048 -9.75878,-26.09285 -19.51749,-52.1857 -29.27628,-78.27854 16.40117,-13.40482 31.40516,-28.51304 44.91133,-44.84371 26.13729,9.73655 52.27465,19.47304 78.41194,29.20959 14.60013,-20.72744 27.60333,-42.68149 37.93294,-65.87634 -21.49698,-17.75991 -42.99403,-35.519886 -64.49101,-53.279793 7.29849,-19.735549 13.07456,-40.127127 16.35943,-60.931296 27.54064,-4.630706 55.08129,-9.2614849 82.62193,-13.8921914 1.23617,-22.6678096 0.89736,-63.3635816 -0.12479,-78.3240316 z" id="path6152" style="fill:#FFFFFF;fill-opacity:1;fill-rule:nonzero;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10"/>
|
||||
<path d="m 619.09641,-305.35415 -217.32006,0 0,57.22431 53.31697,0 0,-11.63921 28.54473,0 0,106.78273 -11.42507,0 0,45.83703 76.17427,0 0,-45.83703 -11.40355,0 0,-106.5308 28.77423,0 0,11.38728 53.33848,0 0,-57.22431" id="path6156" style="fill:#FFFFFF;fill-opacity:1;fill-rule:evenodd;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10"/>
|
||||
<path d="m 392.08692,-217.65339 0,45.82983 11.65457,0 0,182.851475 -11.65457,-0.259129 0,45.837034 76.42529,0 0,-45.837034 -11.43224,0 0,-45.577905 106.68413,0 0,45.577905 -11.40354,0 0,45.837034 76.15274,0 0,-45.837034 -11.40354,0 0,-182.815486 11.40354,0.22314 0,-45.82983 -76.15274,0 0,45.82983 11.40354,0 0,91.184605 -106.68413,0 0,-91.184605 11.43224,0 0,-45.82983 -76.42529,0" id="path6160" style="fill:#FFFFFF;fill-opacity:1;fill-rule:evenodd;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10"/>
|
||||
<path d="m 510.42562,99.736396 83.84835,139.461764 109.90438,-182.822677 27.28962,0 0,-45.606697 -91.3001,0 0,45.606697 10.672,0 -56.5659,94.142987 -84.09937,-139.749684 -83.85552,139.749684 -56.3077,-94.142987 10.672,0 0,-45.606697 -91.5583,0 0,45.606697 27.54064,0 L 426.5701,239.19816 510.42562,99.736396" id="path6164" style="fill:#FFFFFF;fill-opacity:1;fill-rule:evenodd;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10"/>
|
||||
</g>
|
||||
<metadata id="metadata3029">
|
||||
<rdf:RDF>
|
||||
<cc:Work rdf:about="">
|
||||
<dc:title/>
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
</svg>
|
After Width: | Height: | Size: 5.5 KiB |
18
api/ovdashboard_api/skel/ticker.txt
Normal file
|
@ -0,0 +1,18 @@
|
|||
######################################################################
|
||||
# OVDashboard Ticker #
|
||||
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ #
|
||||
# This is the news ticker on the dashboard's bottom. #
|
||||
# #
|
||||
# Format: #
|
||||
# - Every line corresponds to one item in the ticker #
|
||||
# - Empty lines are ignored #
|
||||
# - Lines beginning with the "Comment Marker" (default: "#") are #
|
||||
# ignored #
|
||||
######################################################################
|
||||
|
||||
This is the first ticker item
|
||||
|
||||
This is the second ticker item, the empty line does not count
|
||||
Another ticker item
|
||||
# This also used to be a ticker item, but now it is inactive
|
||||
And another ticker item
|
1
api/ovdashboard_api/skel/title.txt
Normal file
|
@ -0,0 +1 @@
|
|||
# OVDashboard Title
|
|
@ -1,6 +0,0 @@
|
|||
def main() -> None:
|
||||
print("Hello World")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
1594
api/poetry.lock
generated
|
@ -1,14 +1,32 @@
|
|||
[tool.poetry]
|
||||
authors = ["Jörn-Michael Miehe <jmm@yavook.de>"]
|
||||
description = ""
|
||||
name = "ovkiosk"
|
||||
include = ["ovdashboard_api/skel/*"]
|
||||
name = "ovdashboard_api"
|
||||
version = "0.1.0"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
Markdown = "^3.5"
|
||||
Pillow = "^10.1.0"
|
||||
asyncify = "^0.9.2"
|
||||
cachetools = "^5.3.2"
|
||||
cachetoolsutils = "^8.2"
|
||||
caldav = "^1.3.6"
|
||||
fastapi = "^0.103.2"
|
||||
pydantic-settings = "^2.0.3"
|
||||
python = "^3.12"
|
||||
python-magic = "^0.4.27"
|
||||
redis = {extras = ["hiredis"], version = "^5.0.1"}
|
||||
tomli-w = "^1.0.0"
|
||||
uvicorn = {extras = ["standard"], version = "^0.23.2"}
|
||||
webdavclient3 = "^3.14.6"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
# pytest = "^5.2"
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = "^23.10.1"
|
||||
flake8 = "^6.1.0"
|
||||
flake8-isort = "^6.1.0"
|
||||
types-cachetools = "^5.3.0.6"
|
||||
pytest = "^7.4.3"
|
||||
|
||||
[build-system]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
|
224
api/test/test_settings.py
Normal file
|
@ -0,0 +1,224 @@
|
|||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from ovdashboard_api.core.settings import Settings
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="function")
|
||||
def patch_settings_env_file(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(os, "environ", {})
|
||||
monkeypatch.delitem(Settings.model_config, "env_file")
|
||||
|
||||
|
||||
def test_empty():
|
||||
s = Settings.model_validate({})
|
||||
|
||||
assert s.log_level == "INFO"
|
||||
assert s.production_mode is False
|
||||
assert s.ui_directory == "/usr/local/share/ovdashboard_ui/html"
|
||||
|
||||
assert s.ping_host == "1.0.0.0"
|
||||
assert s.ping_port == 1
|
||||
|
||||
assert s.openapi_url == "/api/openapi.json"
|
||||
assert s.docs_url == "/api/docs"
|
||||
assert s.redoc_url == "/api/redoc"
|
||||
|
||||
# webdav
|
||||
|
||||
assert s.webdav.protocol == "https"
|
||||
assert s.webdav.host == "example.com"
|
||||
|
||||
assert s.webdav.username == "ovd_user"
|
||||
assert s.webdav.password == "password"
|
||||
|
||||
assert s.webdav.cache_ttl == 600
|
||||
|
||||
assert s.webdav.path == "/remote.php/webdav"
|
||||
|
||||
assert s.webdav.config_filename == "config.txt"
|
||||
|
||||
assert s.webdav.disable_check is False
|
||||
assert s.webdav.retries == 20
|
||||
assert s.webdav.retry_delay == 30
|
||||
assert s.webdav.prefix == "/ovdashboard"
|
||||
|
||||
# caldav
|
||||
|
||||
assert s.caldav.protocol == "https"
|
||||
assert s.caldav.host == "example.com"
|
||||
|
||||
assert s.caldav.username == "ovd_user"
|
||||
assert s.caldav.password == "password"
|
||||
|
||||
assert s.caldav.cache_ttl == 600
|
||||
|
||||
assert s.caldav.path == "/remote.php/dav"
|
||||
|
||||
|
||||
def test_prod():
|
||||
s = Settings.model_validate({"production_mode": True})
|
||||
|
||||
assert s.log_level == "INFO"
|
||||
assert s.production_mode is True
|
||||
|
||||
assert s.openapi_url is None
|
||||
assert s.docs_url is None
|
||||
assert s.redoc_url is None
|
||||
|
||||
|
||||
def test_set_caldav():
|
||||
s = Settings.model_validate(
|
||||
{
|
||||
"caldav": {
|
||||
"protocol": "cd_protocol",
|
||||
"host": "cd_host",
|
||||
"username": "cd_username",
|
||||
"password": "cd_password",
|
||||
"cache_ttl": "0",
|
||||
"path": "cd_path",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# webdav
|
||||
|
||||
assert s.webdav.protocol == "https"
|
||||
assert s.webdav.host == "example.com"
|
||||
|
||||
assert s.webdav.username == "ovd_user"
|
||||
assert s.webdav.password == "password"
|
||||
|
||||
assert s.webdav.cache_ttl == 600
|
||||
|
||||
assert s.webdav.path == "/remote.php/webdav"
|
||||
|
||||
assert s.webdav.config_filename == "config.txt"
|
||||
|
||||
assert s.webdav.disable_check is False
|
||||
assert s.webdav.retries == 20
|
||||
assert s.webdav.retry_delay == 30
|
||||
assert s.webdav.prefix == "/ovdashboard"
|
||||
|
||||
# caldav
|
||||
|
||||
assert s.caldav.protocol == "cd_protocol"
|
||||
assert s.caldav.host == "cd_host"
|
||||
|
||||
assert s.caldav.username == "cd_username"
|
||||
assert s.caldav.password == "cd_password"
|
||||
|
||||
assert s.caldav.cache_ttl == 0
|
||||
|
||||
assert s.caldav.path == "cd_path"
|
||||
|
||||
|
||||
def test_set_webdav():
|
||||
s = Settings.model_validate(
|
||||
{
|
||||
"webdav": {
|
||||
"protocol": "wd_protocol",
|
||||
"host": "wd_host",
|
||||
"username": "wd_username",
|
||||
"password": "wd_password",
|
||||
"cache_ttl": "99",
|
||||
"path": "wd_path",
|
||||
"config_filename": "wd_config_filename",
|
||||
"disable_check": "true",
|
||||
"retries": "99",
|
||||
"retry_delay": "99",
|
||||
"prefix": "wd_prefix",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# webdav
|
||||
|
||||
assert s.webdav.protocol == "wd_protocol"
|
||||
assert s.webdav.host == "wd_host"
|
||||
|
||||
assert s.webdav.username == "wd_username"
|
||||
assert s.webdav.password == "wd_password"
|
||||
|
||||
assert s.webdav.cache_ttl == 99
|
||||
|
||||
assert s.webdav.path == "wd_path"
|
||||
|
||||
assert s.webdav.config_filename == "wd_config_filename"
|
||||
|
||||
assert s.webdav.disable_check is True
|
||||
assert s.webdav.retries == 99
|
||||
assert s.webdav.retry_delay == 99
|
||||
assert s.webdav.prefix == "wd_prefix"
|
||||
|
||||
# caldav
|
||||
|
||||
assert s.caldav.protocol == "wd_protocol"
|
||||
assert s.caldav.host == "wd_host"
|
||||
|
||||
assert s.caldav.username == "wd_username"
|
||||
assert s.caldav.password == "wd_password"
|
||||
|
||||
assert s.caldav.cache_ttl == 99
|
||||
|
||||
assert s.caldav.path == "/remote.php/dav"
|
||||
|
||||
|
||||
def test_set_caldav_webdav():
|
||||
s = Settings.model_validate(
|
||||
{
|
||||
"webdav": {
|
||||
"protocol": "wd_protocol",
|
||||
"host": "wd_host",
|
||||
"username": "wd_username",
|
||||
"password": "wd_password",
|
||||
"cache_ttl": "99",
|
||||
"path": "wd_path",
|
||||
"config_filename": "wd_config_filename",
|
||||
"disable_check": "true",
|
||||
"retries": "99",
|
||||
"retry_delay": "99",
|
||||
"prefix": "wd_prefix",
|
||||
},
|
||||
"caldav": {
|
||||
"protocol": "cd_protocol",
|
||||
"host": "cd_host",
|
||||
"username": "cd_username",
|
||||
"password": "cd_password",
|
||||
"cache_ttl": "0",
|
||||
"path": "cd_path",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# webdav
|
||||
|
||||
assert s.webdav.protocol == "wd_protocol"
|
||||
assert s.webdav.host == "wd_host"
|
||||
|
||||
assert s.webdav.username == "wd_username"
|
||||
assert s.webdav.password == "wd_password"
|
||||
|
||||
assert s.webdav.cache_ttl == 99
|
||||
|
||||
assert s.webdav.path == "wd_path"
|
||||
|
||||
assert s.webdav.config_filename == "wd_config_filename"
|
||||
|
||||
assert s.webdav.disable_check is True
|
||||
assert s.webdav.retries == 99
|
||||
assert s.webdav.retry_delay == 99
|
||||
assert s.webdav.prefix == "wd_prefix"
|
||||
|
||||
# caldav
|
||||
|
||||
assert s.caldav.protocol == "cd_protocol"
|
||||
assert s.caldav.host == "cd_host"
|
||||
|
||||
assert s.caldav.username == "cd_username"
|
||||
assert s.caldav.password == "cd_password"
|
||||
|
||||
assert s.caldav.cache_ttl == 0
|
||||
|
||||
assert s.caldav.path == "cd_path"
|
44
deploy/chores/check_version
Executable file
|
@ -0,0 +1,44 @@
|
|||
#!/bin/sh
|
||||
|
||||
script="$( readlink -f "${0}" )"
|
||||
script_dir="$( dirname "${script}" )"
|
||||
|
||||
git_version="$( \
|
||||
git rev-parse --abbrev-ref HEAD \
|
||||
| cut -d '/' -f 2
|
||||
)"
|
||||
|
||||
api_version="$( \
|
||||
grep '^version' "${script_dir}/../../api/pyproject.toml" \
|
||||
| sed -E 's/^version[^0-9]*((0|[1-9][0-9]*)[0-9\.]*[0-9]).*$/\1/'
|
||||
)"
|
||||
|
||||
ui_version="$( \
|
||||
python3 -c 'import sys, json; print(json.load(sys.stdin)["version"])' \
|
||||
< "${script_dir}/../../ui/package.json" \
|
||||
)"
|
||||
|
||||
install_version="$( \
|
||||
grep '^ovd_version' "${script_dir}/../install.sh" \
|
||||
| sed -E 's/^ovd_version[^0-9]*((0|[1-9][0-9]*)[0-9\.]*[0-9]).*$/\1/'
|
||||
)"
|
||||
|
||||
compose_version="$( \
|
||||
grep 'image: code\.yavook\.de/oekzident\.de/ovdashboard' "${script_dir}/../docker-compose.yml" \
|
||||
| sed -E 's/.*code\.yavook\.de\/oekzident\.de\/ovdashboard[^0-9]*((0|[1-9][0-9]*)[0-9\.]*[0-9]).*$/\1/'
|
||||
)"
|
||||
|
||||
if [ "${git_version}" = "${api_version}" ] \
|
||||
&& [ "${git_version}" = "${ui_version}" ] \
|
||||
&& [ "${git_version}" = "${install_version}" ] \
|
||||
&& [ "${git_version}" = "${compose_version}" ]; then
|
||||
mark="✅️"
|
||||
else
|
||||
mark="❌️"
|
||||
fi
|
||||
|
||||
echo "git: ${git_version}, api: ${api_version}, ui: ${ui_version}"
|
||||
echo "installer: ${install_version}, compose: ${compose_version}"
|
||||
echo ">>>>> RESULT: ${mark} <<<<<"
|
||||
|
||||
[ "${mark}" = "✅️" ] || exit 1
|
17
deploy/chores/docker_buildx
Executable file
|
@ -0,0 +1,17 @@
|
|||
#!/bin/sh
|
||||
|
||||
script="$( readlink -f "${0}" )"
|
||||
script_dir="$( dirname "${script}" )"
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
. "${script_dir}/check_version"
|
||||
|
||||
# defined in `check_version` script
|
||||
# shellcheck disable=SC2154
|
||||
echo "${git_version}" >/dev/null
|
||||
|
||||
docker buildx build \
|
||||
--pull --push \
|
||||
--tag "code.yavook.de/oekzident.de/ovdashboard:${git_version}" \
|
||||
--platform "linux/amd64,linux/arm64" \
|
||||
"${script_dir}/../.."
|
38
deploy/docker-compose.yml
Normal file
|
@ -0,0 +1,38 @@
|
|||
services:
|
||||
# shared cache
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: always
|
||||
pull_policy: always
|
||||
|
||||
# necessary for "host" network deployment,
|
||||
# but only listen on localhost
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
|
||||
app:
|
||||
image: code.yavook.de/oekzident.de/ovdashboard:0.1.0
|
||||
restart: always
|
||||
pull_policy: always
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
# "app" container needs host ip
|
||||
network_mode: host
|
||||
user: root
|
||||
|
||||
environment:
|
||||
# necessary for "host" network deployment
|
||||
PORT: "80"
|
||||
REDIS__HOST: "localhost"
|
||||
|
||||
# >>>>> USER VARIABLES <<<<<
|
||||
# you will want to adjust these!
|
||||
TZ: "Europe/Berlin"
|
||||
|
||||
WEBDAV__HOST: "example.com"
|
||||
WEBDAV__PATH: "/remote.php/webdav"
|
||||
WEBDAV__PREFIX: "/ovdashboard"
|
||||
|
||||
WEBDAV__USERNAME: "ovd_user"
|
||||
WEBDAV__PASSWORD: "password"
|
98
deploy/install.sh
Normal file
|
@ -0,0 +1,98 @@
|
|||
#!/bin/sh
|
||||
|
||||
#########
|
||||
# start #
|
||||
#########
|
||||
|
||||
# env setup
|
||||
ovd_version="0.1.0"
|
||||
export DEBIAN_FRONTEND="noninteractive"
|
||||
set -e
|
||||
|
||||
# banner
|
||||
echo "Installer for OVDashboard ${ovd_version}"
|
||||
echo "Waiting 10 seconds, press Ctrl+C to cancel installation ..."
|
||||
sleep 10
|
||||
|
||||
#################
|
||||
# prerequisites #
|
||||
#################
|
||||
|
||||
# 134: docker with compose
|
||||
# 113: chromium browser
|
||||
/boot/dietpi/dietpi-software install 134 113
|
||||
|
||||
# htpdate (timesync in restricted networks)
|
||||
# unclutter (hides mouse cursor)
|
||||
apt-get update && apt-get install --yes --no-install-recommends \
|
||||
htpdate unclutter
|
||||
|
||||
# activate unclutter
|
||||
echo '/usr/bin/unclutter -idle 0.1 &' > /etc/chromium.d/dietpi-unclutter
|
||||
|
||||
# chromium window size
|
||||
echo "Please enter your screen resolution!"
|
||||
|
||||
screen_x="$( cut -d ',' -f 1 '/sys/class/graphics/fb0/virtual_size' )"
|
||||
printf "Width [default: %d]: " "${screen_x}"
|
||||
read -r screen_x_in
|
||||
screen_x="${screen_x_in:-$screen_x}"
|
||||
sed -ri "s/^(SOFTWARE_CHROMIUM_RES_X=)[0-9]+$/\1${screen_x}/" '/boot/dietpi.txt'
|
||||
|
||||
screen_y="$( cut -d ',' -f 2 '/sys/class/graphics/fb0/virtual_size' )"
|
||||
printf "Height [default: %d]: " "${screen_y}"
|
||||
read -r screen_y_in
|
||||
screen_y="${screen_y_in:-$screen_y}"
|
||||
sed -ri "s/^(SOFTWARE_CHROMIUM_RES_Y=)[0-9]+$/\1${screen_y}/" '/boot/dietpi.txt'
|
||||
|
||||
# chromium autostart
|
||||
sed -ri "s/^(AUTO_SETUP_AUTOSTART_LOGIN_USER=).+$/\1dietpi/" '/boot/dietpi.txt' # run as "dietpi"
|
||||
sed -ri "s/^(SOFTWARE_CHROMIUM_AUTOSTART_URL=).+$/\1http:\/\/localhost\//" '/boot/dietpi.txt' # open "localhost"
|
||||
/boot/dietpi/dietpi-autostart 11 # 11: magic number for chromium autostart
|
||||
|
||||
# chromium language
|
||||
display_lang="en-US,en"
|
||||
printf "Enter display language(s) [default: %s]: " "${display_lang}"
|
||||
read -r display_lang_in
|
||||
display_lang="${display_lang_in:-${display_lang}}"
|
||||
|
||||
sudo -u dietpi mkdir -p '/home/dietpi/.config/chromium/Default'
|
||||
echo '{"intl":{"selected_languages":"'"${display_lang}"'"}}' \
|
||||
| sudo -u dietpi tee '/home/dietpi/.config/chromium/Default/Preferences' \
|
||||
> /dev/null
|
||||
|
||||
#######
|
||||
# app #
|
||||
#######
|
||||
|
||||
mkdir -p /opt/ovdashboard
|
||||
|
||||
# prepare compose project
|
||||
curl \
|
||||
--proto "=https" --tlsv1.2 -sSf \
|
||||
--output "/opt/ovdashboard/docker-compose.yml" \
|
||||
"https://code.yavook.de/OEKZident.de/ovdashboard/raw/tag/v${ovd_version}/deploy/docker-compose.yml"
|
||||
docker compose \
|
||||
--project-directory "/opt/ovdashboard" \
|
||||
pull
|
||||
|
||||
# review compose file
|
||||
echo "Please review the Docker Compose file before continuing! [hit Return]"
|
||||
read -r _RETURN
|
||||
nano "/opt/ovdashboard/docker-compose.yml"
|
||||
|
||||
# start server
|
||||
docker compose \
|
||||
--project-directory "/opt/ovdashboard" \
|
||||
up --detach
|
||||
|
||||
############
|
||||
# finalize #
|
||||
############
|
||||
|
||||
echo ""
|
||||
echo "#########################"
|
||||
echo "# OVDashboard Installed #"
|
||||
echo "#########################"
|
||||
echo ""
|
||||
echo "You can now reboot your device."
|
67
deploy/mini-tiangolo/gunicorn_conf.py
Normal file
|
@ -0,0 +1,67 @@
|
|||
import json
|
||||
import multiprocessing
|
||||
import os
|
||||
|
||||
workers_per_core_str = os.getenv("WORKERS_PER_CORE", "1")
|
||||
max_workers_str = os.getenv("MAX_WORKERS")
|
||||
use_max_workers = None
|
||||
if max_workers_str:
|
||||
use_max_workers = int(max_workers_str)
|
||||
web_concurrency_str = os.getenv("WEB_CONCURRENCY", None)
|
||||
|
||||
host = os.getenv("HOST", "0.0.0.0")
|
||||
port = os.getenv("PORT", "80")
|
||||
bind_env = os.getenv("BIND", None)
|
||||
use_loglevel = os.getenv("LOG_LEVEL", "info")
|
||||
if bind_env:
|
||||
use_bind = bind_env
|
||||
else:
|
||||
use_bind = f"{host}:{port}"
|
||||
|
||||
cores = multiprocessing.cpu_count()
|
||||
workers_per_core = float(workers_per_core_str)
|
||||
default_web_concurrency = workers_per_core * cores
|
||||
if web_concurrency_str:
|
||||
web_concurrency = int(web_concurrency_str)
|
||||
assert web_concurrency > 0
|
||||
else:
|
||||
web_concurrency = max(int(default_web_concurrency), 2)
|
||||
if use_max_workers:
|
||||
web_concurrency = min(web_concurrency, use_max_workers)
|
||||
accesslog_var = os.getenv("ACCESS_LOG", "-")
|
||||
use_accesslog = accesslog_var or None
|
||||
errorlog_var = os.getenv("ERROR_LOG", "-")
|
||||
use_errorlog = errorlog_var or None
|
||||
graceful_timeout_str = os.getenv("GRACEFUL_TIMEOUT", "120")
|
||||
timeout_str = os.getenv("TIMEOUT", "120")
|
||||
keepalive_str = os.getenv("KEEP_ALIVE", "5")
|
||||
|
||||
# Gunicorn config variables
|
||||
loglevel = use_loglevel
|
||||
workers = web_concurrency
|
||||
bind = use_bind
|
||||
errorlog = use_errorlog
|
||||
worker_tmp_dir = "/dev/shm"
|
||||
accesslog = use_accesslog
|
||||
graceful_timeout = int(graceful_timeout_str)
|
||||
timeout = int(timeout_str)
|
||||
keepalive = int(keepalive_str)
|
||||
|
||||
|
||||
# For debugging and testing
|
||||
log_data = {
|
||||
"loglevel": loglevel,
|
||||
"workers": workers,
|
||||
"bind": bind,
|
||||
"graceful_timeout": graceful_timeout,
|
||||
"timeout": timeout,
|
||||
"keepalive": keepalive,
|
||||
"errorlog": errorlog,
|
||||
"accesslog": accesslog,
|
||||
# Additional, non-gunicorn variables
|
||||
"workers_per_core": workers_per_core,
|
||||
"use_max_workers": use_max_workers,
|
||||
"host": host,
|
||||
"port": port,
|
||||
}
|
||||
print(json.dumps(log_data))
|
20
deploy/mini-tiangolo/start.sh
Normal file
|
@ -0,0 +1,20 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
MODULE_NAME=${MODULE_NAME:-"app.main"}
|
||||
VARIABLE_NAME=${VARIABLE_NAME:-"app"}
|
||||
export APP_MODULE="${APP_MODULE:-"$MODULE_NAME:$VARIABLE_NAME"}"
|
||||
export GUNICORN_CONF="${GUNICORN_CONF:-"/usr/local/share/uvicorn-gunicorn/gunicorn_conf.py"}"
|
||||
export WORKER_CLASS="${WORKER_CLASS:-"uvicorn.workers.UvicornWorker"}"
|
||||
|
||||
if [ -f "${PRE_START_PATH}" ] ; then
|
||||
echo "Running script ${PRE_START_PATH}"
|
||||
# shellcheck disable=SC1090
|
||||
. "${PRE_START_PATH}"
|
||||
fi
|
||||
|
||||
# Start Gunicorn
|
||||
exec gunicorn \
|
||||
-k "${WORKER_CLASS}" \
|
||||
-c "${GUNICORN_CONF}" \
|
||||
"${APP_MODULE}"
|
BIN
doc/ovdashboard_de.jpg
Normal file
After Width: | Height: | Size: 193 KiB |
BIN
doc/ovdashboard_en.jpg
Normal file
After Width: | Height: | Size: 192 KiB |
3
ui/.browserslistrc
Normal file
|
@ -0,0 +1,3 @@
|
|||
> 1%
|
||||
last 2 versions
|
||||
not dead
|
24
ui/.devcontainer/Dockerfile
Normal file
|
@ -0,0 +1,24 @@
|
|||
# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster
|
||||
ARG VARIANT=16-bookworm
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:1-${VARIANT}
|
||||
|
||||
# [Optional] Uncomment this section to install additional OS packages.
|
||||
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
# && apt-get -y install --no-install-recommends <your-package-list-here>
|
||||
|
||||
RUN set -ex; \
|
||||
\
|
||||
export DEBIAN_FRONTEND=noninteractive; \
|
||||
apt-get update; apt-get install --yes --no-install-recommends \
|
||||
git-flow \
|
||||
git-lfs \
|
||||
; rm -rf /var/lib/apt/lists/*; \
|
||||
su node -c "git lfs install"
|
||||
|
||||
# [Optional] Uncomment if you want to install an additional version of node using nvm
|
||||
# ARG EXTRA_NODE_VERSION=10
|
||||
# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"
|
||||
|
||||
# [Optional] Uncomment if you want to install more global node modules
|
||||
# RUN su node -c "npm install -g <your-package-list-here>"
|
||||
RUN su node -c "yarn global add @vue/cli"
|
43
ui/.devcontainer/devcontainer.json
Normal file
|
@ -0,0 +1,43 @@
|
|||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
|
||||
// https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/javascript-node
|
||||
{
|
||||
"name": "OVD UI",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": "..",
|
||||
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
|
||||
// Append -bullseye or -buster to pin to an OS version.
|
||||
// Use -bullseye variants on local arm64/Apple Silicon.
|
||||
"args": {
|
||||
"VARIANT": "20-bookworm"
|
||||
}
|
||||
},
|
||||
"containerEnv": {
|
||||
"TZ": "Europe/Berlin"
|
||||
},
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
// Configure properties specific to VS Code.
|
||||
"vscode": {
|
||||
// Set *default* container specific settings.json values on container create.
|
||||
"settings": {
|
||||
"terminal.integrated.defaultProfile.linux": "zsh"
|
||||
},
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"mhutchie.git-graph",
|
||||
"Syler.sass-indented",
|
||||
"Vue.volar"
|
||||
]
|
||||
}
|
||||
},
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "yarn install",
|
||||
"postStartCommand": "yarn install --production false",
|
||||
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||
"remoteUser": "node"
|
||||
}
|
18
ui/.eslintrc.js
Normal file
|
@ -0,0 +1,18 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
"plugin:vue/essential",
|
||||
"eslint:recommended",
|
||||
"@vue/typescript/recommended",
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
},
|
||||
rules: {
|
||||
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
|
||||
},
|
||||
};
|
23
ui/.gitignore
vendored
Normal file
|
@ -0,0 +1,23 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
# .vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
21
ui/.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"editor.formatOnSave": true,
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": true
|
||||
},
|
||||
"git.closeDiffOnOperation": true,
|
||||
"editor.tabSize": 2,
|
||||
"sass.disableAutoIndent": true,
|
||||
"sass.format.convert": false,
|
||||
"sass.format.deleteWhitespace": true,
|
||||
"prettier.trailingComma": "all",
|
||||
}
|
23
ui/.vscode/tasks.json
vendored
Normal file
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Vue UI",
|
||||
"type": "shell",
|
||||
"command": "vue",
|
||||
"args": [
|
||||
"ui"
|
||||
],
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "Vue Serve",
|
||||
"type": "shell",
|
||||
"command": "vue",
|
||||
"args": [
|
||||
"serve"
|
||||
],
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
43
ui/README.md
Normal file
|
@ -0,0 +1,43 @@
|
|||
# OVDashboard UI
|
||||
|
||||
|
||||
## Quick Start
|
||||
|
||||
If you only want a working installation, it is highly recommended to use the `docker` image at [`TODO`](TODO).
|
||||
The image contains both the API and UI.
|
||||
Refer to the [main README](../README.md) for an in-depth how-to.
|
||||
|
||||
|
||||
## Setup for development and contribution
|
||||
|
||||
No need to fiddle around with specific Node.js versions.
|
||||
You only need a "general purpose" development setup to get this project up and running for debug and contribution purposes:
|
||||
|
||||
- [Docker Engine](https://docs.docker.com/engine/install/)
|
||||
- [Visual Studio Code](https://code.visualstudio.com/)
|
||||
- [Remote - Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) (VSCode extension)
|
||||
|
||||
Once you open this directory in VSCode, you should be prompted to reopen it in a development container.
|
||||
If not, hit `Ctrl+Alt+P` and search for "reopen in container".
|
||||
|
||||
|
||||
## Running the UI without the `docker` image
|
||||
|
||||
> You probably don't need this! Usually the image is good enough!
|
||||
|
||||
However, if you want to deploy the UI on a cluster or any custom web server, go ahead.
|
||||
First, run `yarn build` in this directory - I'd recommend you use VSCode with a development container as described above.
|
||||
Alternatively, you can copy the `/html` directory from the `docker` image:
|
||||
|
||||
```sh
|
||||
id=$(docker create TODO)
|
||||
docker cp "${id}:/html" "/path/to/dist"
|
||||
docker rm -v "${id}"
|
||||
```
|
||||
|
||||
Then you can deploy the `dist` directory as the webroot using your favourite web server.
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
The OVDashboard UI is created using Vue.js. Even though the default config should fit most applications, you can refer to the [Configuration Reference](https://cli.vuejs.org/config/) for what can be configured additionally.
|
3
ui/babel.config.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
presets: ["@vue/cli-plugin-babel/preset"],
|
||||
};
|
40
ui/package.json
Normal file
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"name": "ovdashboard-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/color": "^3.0.3",
|
||||
"@types/luxon": "^3.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.9.0",
|
||||
"@typescript-eslint/parser": "^6.9.0",
|
||||
"@vue/cli-plugin-babel": "~5.0.0",
|
||||
"@vue/cli-plugin-eslint": "~5.0.0",
|
||||
"@vue/cli-plugin-pwa": "~5.0.0",
|
||||
"@vue/cli-plugin-typescript": "~5.0.0",
|
||||
"@vue/cli-service": "~5.0.0",
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"axios": "^1.6.0",
|
||||
"color": "^4.2.3",
|
||||
"core-js": "^3.8.3",
|
||||
"eslint": "^8.52.0",
|
||||
"eslint-plugin-vue": "^9.18.0",
|
||||
"luxon": "^3.0.3",
|
||||
"prettier": "^3.0.3",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"sass": "~1.69.5",
|
||||
"sass-loader": "^13.3.2",
|
||||
"typescript": "~5.2.2",
|
||||
"vue": "^2.7.15",
|
||||
"vue-class-component": "^7.2.3",
|
||||
"vue-cli-plugin-vuetify": "^2.5.5",
|
||||
"vue-property-decorator": "^9.1.2",
|
||||
"vue-template-compiler": "^2.6.14",
|
||||
"vuetify": "^2.7.1",
|
||||
"vuetify-loader": "^1.7.0"
|
||||
}
|
||||
}
|
BIN
ui/public/favicon.ico
Normal file
After Width: | Height: | Size: 4.2 KiB |
BIN
ui/public/img/icons/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 9.2 KiB |
BIN
ui/public/img/icons/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 29 KiB |
BIN
ui/public/img/icons/android-chrome-maskable-192x192.png
Normal file
After Width: | Height: | Size: 6.3 KiB |
BIN
ui/public/img/icons/android-chrome-maskable-512x512.png
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
ui/public/img/icons/apple-touch-icon-120x120.png
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
ui/public/img/icons/apple-touch-icon-152x152.png
Normal file
After Width: | Height: | Size: 4 KiB |
BIN
ui/public/img/icons/apple-touch-icon-180x180.png
Normal file
After Width: | Height: | Size: 4.6 KiB |
BIN
ui/public/img/icons/apple-touch-icon-60x60.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
ui/public/img/icons/apple-touch-icon-76x76.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
ui/public/img/icons/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 4.6 KiB |
BIN
ui/public/img/icons/favicon-16x16.png
Normal file
After Width: | Height: | Size: 799 B |
BIN
ui/public/img/icons/favicon-32x32.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
ui/public/img/icons/msapplication-icon-144x144.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
ui/public/img/icons/mstile-150x150.png
Normal file
After Width: | Height: | Size: 4.2 KiB |
3
ui/public/img/icons/safari-pinned-tab.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.00251 14.9297L0 1.07422H6.14651L8.00251 4.27503L9.84583 1.07422H16L8.00251 14.9297Z" fill="black"/>
|
||||
</svg>
|
After Width: | Height: | Size: 215 B |
29
ui/public/index.html
Normal file
|
@ -0,0 +1,29 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico" />
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/@mdi/font@latest/css/materialdesignicons.min.css"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong
|
||||
>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work
|
||||
properly without JavaScript enabled. Please enable it to
|
||||
continue.</strong
|
||||
>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
</body>
|
||||
</html>
|
2
ui/public/robots.txt
Normal file
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Disallow:
|
56
ui/src/App.vue
Normal file
|
@ -0,0 +1,56 @@
|
|||
<template>
|
||||
<v-app>
|
||||
<v-layout column fill-height>
|
||||
<TitleBar />
|
||||
<Dashboard>
|
||||
<div slot="left" class="d-flex flex-column fill-height">
|
||||
<Message />
|
||||
<ImageCarousel class="mt-auto" />
|
||||
</div>
|
||||
<div slot="right" class="d-flex flex-column fill-height">
|
||||
<CalendarCarousel />
|
||||
<DashboardInfo class="mt-auto" />
|
||||
</div>
|
||||
</Dashboard>
|
||||
<TickerBar />
|
||||
</v-layout>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-property-decorator";
|
||||
|
||||
import TitleBar from "./components/title/TitleBar.vue";
|
||||
import Dashboard from "./components/Dashboard.vue";
|
||||
import ImageCarousel from "./components/ImageCarousel.vue";
|
||||
import Message from "./components/Message.vue";
|
||||
import CalendarCarousel from "./components/calendar/CalendarCarousel.vue";
|
||||
import DashboardInfo from "./components/DashboardInfo.vue";
|
||||
import TickerBar from "./components/TickerBar.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
TitleBar,
|
||||
Dashboard,
|
||||
ImageCarousel,
|
||||
Message,
|
||||
CalendarCarousel,
|
||||
DashboardInfo,
|
||||
TickerBar,
|
||||
},
|
||||
})
|
||||
export default class App extends Vue {}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
body::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
body {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
</style>
|
113
ui/src/assets/calendar_testdata.json
Normal file
|
@ -0,0 +1,113 @@
|
|||
[
|
||||
{
|
||||
"title": "Lorem Ipsum",
|
||||
"events": [
|
||||
{
|
||||
"summary": "Lorem Ipsum",
|
||||
"description": "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.",
|
||||
"dtstart": "2022-09-08T07:00:00+00:00",
|
||||
"dtend": "2022-09-08T16:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"summary": "Sed ut perspiciatis unde omnis",
|
||||
"description": "Lorem ipsum dolor sit amet, consectetur",
|
||||
"dtstart": "2022-09-09T07:00:00+00:00",
|
||||
"dtend": "2022-09-09T09:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"summary": "At vero eos et accusamus",
|
||||
"description": "",
|
||||
"dtstart": "2022-09-10T07:00:00+00:00",
|
||||
"dtend": "2022-09-10T16:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Li Europan lingues",
|
||||
"events": [
|
||||
{
|
||||
"summary": "Occidental in fact, it va esser Occidental",
|
||||
"description": "Omnicos directe al desirabilite de un nov lingua franca: On refusa continuar payar custosi traductores. At solmen va esser necessi far uniform grammatica, pronunciation e plu sommun paroles. Ma quande lingues coalesce, li grammatica del resultant lingue es plu simplic e regulari quam ti del coalescent lingues.",
|
||||
"dtstart": "2022-09-08T07:00:00+00:00",
|
||||
"dtend": "2022-09-08T16:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"summary": "Membres del sam familie",
|
||||
"description": "Lor separat existentie es un myth.",
|
||||
"dtstart": "2022-09-09T07:00:00+00:00",
|
||||
"dtend": "2022-09-09T09:30:30+00:00"
|
||||
},
|
||||
{
|
||||
"summary": "On refusa continuar payar custosi traductores",
|
||||
"description": "",
|
||||
"dtstart": "2022-09-10T07:00:00+00:00",
|
||||
"dtend": "2022-09-20T16:00:00+00:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Vivamus elementum semper nisi",
|
||||
"events": [
|
||||
{
|
||||
"summary": "Phasellus viverra nulla 1",
|
||||
"description": "Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, sit amet adipiscing sem neque sed ipsum.",
|
||||
"dtstart": "2022-09-08T07:00:00+00:00",
|
||||
"dtend": "2022-09-08T16:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"summary": "Phasellus viverra nulla 2",
|
||||
"description": "Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, sit amet adipiscing sem neque sed ipsum.",
|
||||
"dtstart": "2022-09-08T07:00:00+00:00",
|
||||
"dtend": "2022-09-08T16:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"summary": "Phasellus viverra nulla 3",
|
||||
"description": "Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, sit amet adipiscing sem neque sed ipsum.",
|
||||
"dtstart": "2022-09-08T07:00:00+00:00",
|
||||
"dtend": "2022-09-08T16:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"summary": "Phasellus viverra nulla 4",
|
||||
"description": "Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, sit amet adipiscing sem neque sed ipsum.",
|
||||
"dtstart": "2022-09-08T07:00:00+00:00",
|
||||
"dtend": "2022-09-08T16:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"summary": "Phasellus viverra nulla 5",
|
||||
"description": "Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, sit amet adipiscing sem neque sed ipsum.",
|
||||
"dtstart": "2022-09-08T07:00:00+00:00",
|
||||
"dtend": "2022-09-08T16:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"summary": "Phasellus viverra nulla 6",
|
||||
"description": "Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, sit amet adipiscing sem neque sed ipsum.",
|
||||
"dtstart": "2022-09-08T07:00:00+00:00",
|
||||
"dtend": "2022-09-08T16:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"summary": "Phasellus viverra nulla 7",
|
||||
"description": "Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, sit amet adipiscing sem neque sed ipsum.",
|
||||
"dtstart": "2022-09-08T07:00:00+00:00",
|
||||
"dtend": "2022-09-08T16:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"summary": "Phasellus viverra nulla 8",
|
||||
"description": "Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, sit amet adipiscing sem neque sed ipsum.",
|
||||
"dtstart": "2022-09-08T07:00:00+00:00",
|
||||
"dtend": "2022-09-08T16:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"summary": "Phasellus viverra nulla 9",
|
||||
"description": "Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, sit amet adipiscing sem neque sed ipsum.",
|
||||
"dtstart": "2022-09-08T07:00:00+00:00",
|
||||
"dtend": "2022-09-08T16:00:00+00:00"
|
||||
},
|
||||
{
|
||||
"summary": "Phasellus viverra nulla 10",
|
||||
"description": "Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, sit amet adipiscing sem neque sed ipsum.",
|
||||
"dtstart": "2022-09-08T07:00:00+00:00",
|
||||
"dtend": "2022-09-08T16:00:00+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
6
ui/src/assets/image_testdata.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
[
|
||||
"https://cdn.vuetifyjs.com/images/carousel/squirrel.jpg",
|
||||
"https://cdn.vuetifyjs.com/images/carousel/sky.jpg",
|
||||
"https://cdn.vuetifyjs.com/images/carousel/bird.jpg",
|
||||
"https://cdn.vuetifyjs.com/images/carousel/planet.jpg"
|
||||
]
|
1
ui/src/assets/message_testdata.json
Normal file
|
@ -0,0 +1 @@
|
|||
"<h1>Lorem ipsum dolor sit amet</h1>\n<h2>Consectetuer adipiscing elit</h2>\n<ul>\n<li>In enim justo, rhoncus ut</li>\n<li>imperdiet a, venenatis vitae, justo</li>\n<li>Nullam dictum felis eu pede mollis pretium</li>\n</ul>\n<p>Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. </p>\n<ol>\n<li>Integer tincidunt</li>\n<li>Cras dapibus</li>\n<li>Vivamus elementum semper nisi</li>\n</ol>"
|
20
ui/src/assets/thw.svg
Normal file
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" version="1.1" width="800" height="800" id="svg2">
|
||||
<defs id="defs4"/>
|
||||
<g transform="translate(-109.88407,434.92474)" id="layer1">
|
||||
<path d="m 640.41889,184.96103 c -15.38166,25.59619 -30.76325,51.19237 -46.14492,76.78855 -9.50532,-15.7757 -19.01071,-31.55133 -28.51604,-47.32702 -36.42365,8.00838 -74.46423,8.02054 -110.88695,0 -9.5173,15.77569 -19.0346,31.55132 -28.5519,47.32702 -16.26691,-25.75742 -30.5453,-53.45406 -48.03891,-78.01524 C 299.24885,137.7956 250.23741,46.392684 255.10184,-45.04896 c 2.09259,-89.32909 55.97988,-174.14531 134.99126,-214.97922 0,-18.90439 0,-37.80886 0,-56.71325 80.13559,0 160.27126,0 240.40686,0 0,18.90439 0,37.80886 0,56.71325 79.74845,41.01148 133.28072,126.86918 135.29312,216.763971 3.16811,112.413742 -51.83307,183.454159 -125.37419,228.225239 z m 268.5471,-260.189372 c -16.91878,-2.776927 -50.54031,-5.781455 -82.49714,-11.593718 -3.29434,-20.81784 -9.09759,-41.20158 -16.35943,-60.96009 21.49698,-17.75033 42.99403,-35.50067 64.49101,-53.251 -10.2467,-23.25949 -23.44491,-45.0888 -37.93294,-65.91233 -26.13729,9.7485 -52.27465,19.49708 -78.41194,29.24558 -13.45855,-16.37731 -28.52766,-31.42514 -44.91133,-44.8509 9.75879,-26.10005 19.5175,-52.20009 29.27628,-78.30014 -20.74155,-14.65144 -42.795,-27.53554 -65.97562,-37.90481 -17.78186,21.47172 -35.56379,42.94343 -53.34565,64.41515 -19.7602,-7.37928 -40.21013,-12.89714 -61.01974,-16.33233 -4.63077,-27.50848 -9.26147,-55.01689 -13.89224,-82.52537 -25.33474,-2.30409 -50.83946,-2.29977 -76.17427,0 -4.63314,27.50848 -9.26628,55.01689 -13.89942,82.52537 -20.80365,3.47176 -41.25731,8.94823 -61.01973,16.33233 -17.7795,-21.47172 -35.55899,-42.94343 -53.33848,-64.41515 -23.22975,10.29354 -45.15468,23.35933 -65.97562,37.90481 9.75878,26.10005 19.51749,52.20009 29.27627,78.30014 -16.23901,13.48334 -31.55182,28.25456 -44.6603,44.8509 -26.21382,-9.7485 -52.42763,-19.49708 -78.64145,-29.24558 -14.54519,20.7933 -27.664,42.673 -37.96162,65.91233 21.49942,17.75033 42.99876,35.50067 64.49818,53.251 -7.39732,19.7321 -12.86916,40.17161 -16.3666,60.96009 -27.53828,4.621133 -55.07649,9.242266 -82.61476,13.863399 -2.1661,25.292643 -2.16825,50.761852 0,76.0543506 27.53827,4.6307065 55.07648,9.2614854 82.61476,13.8921914 3.48819,20.778688 8.935,41.221875 16.3666,60.931296 -21.49942,17.759907 -42.99876,35.519883 -64.49818,53.279793 10.37959,23.17527 23.30634,45.1782 37.96162,65.87634 26.21382,-9.73655 52.42763,-19.47304 78.64145,-29.20959 13.15775,16.54949 28.40272,31.38217 44.6603,44.84371 -9.75878,26.09284 -19.51749,52.18569 -29.27627,78.27854 20.82417,14.54059 42.7359,27.62933 65.97562,37.9048 17.77949,-21.47408 35.55898,-42.94825 53.33848,-64.42233 19.73502,7.45233 40.20596,12.98624 61.01973,16.36831 4.63314,27.49884 9.26628,54.99774 13.89942,82.49658 25.3366,2.32072 50.83766,2.31906 76.17427,0 4.63077,-27.49884 9.26147,-54.99774 13.89224,-82.49658 20.81585,-3.34392 41.28716,-8.92073 61.01974,-16.36831 17.78186,21.47408 35.56379,42.94825 53.34565,64.42233 23.19145,-10.35011 45.22934,-23.2602 65.97562,-37.9048 -9.75878,-26.09285 -19.51749,-52.1857 -29.27628,-78.27854 16.40117,-13.40482 31.40516,-28.51304 44.91133,-44.84371 26.13729,9.73655 52.27465,19.47304 78.41194,29.20959 14.60013,-20.72744 27.60333,-42.68149 37.93294,-65.87634 -21.49698,-17.75991 -42.99403,-35.519886 -64.49101,-53.279793 7.29849,-19.735549 13.07456,-40.127127 16.35943,-60.931296 27.54064,-4.630706 55.08129,-9.2614849 82.62193,-13.8921914 1.23617,-22.6678096 0.89736,-63.3635816 -0.12479,-78.3240316 z" id="path6152" style="fill:#FFFFFF;fill-opacity:1;fill-rule:nonzero;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10"/>
|
||||
<path d="m 619.09641,-305.35415 -217.32006,0 0,57.22431 53.31697,0 0,-11.63921 28.54473,0 0,106.78273 -11.42507,0 0,45.83703 76.17427,0 0,-45.83703 -11.40355,0 0,-106.5308 28.77423,0 0,11.38728 53.33848,0 0,-57.22431" id="path6156" style="fill:#FFFFFF;fill-opacity:1;fill-rule:evenodd;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10"/>
|
||||
<path d="m 392.08692,-217.65339 0,45.82983 11.65457,0 0,182.851475 -11.65457,-0.259129 0,45.837034 76.42529,0 0,-45.837034 -11.43224,0 0,-45.577905 106.68413,0 0,45.577905 -11.40354,0 0,45.837034 76.15274,0 0,-45.837034 -11.40354,0 0,-182.815486 11.40354,0.22314 0,-45.82983 -76.15274,0 0,45.82983 11.40354,0 0,91.184605 -106.68413,0 0,-91.184605 11.43224,0 0,-45.82983 -76.42529,0" id="path6160" style="fill:#FFFFFF;fill-opacity:1;fill-rule:evenodd;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10"/>
|
||||
<path d="m 510.42562,99.736396 83.84835,139.461764 109.90438,-182.822677 27.28962,0 0,-45.606697 -91.3001,0 0,45.606697 10.672,0 -56.5659,94.142987 -84.09937,-139.749684 -83.85552,139.749684 -56.3077,-94.142987 10.672,0 0,-45.606697 -91.5583,0 0,45.606697 27.54064,0 L 426.5701,239.19816 510.42562,99.736396" id="path6164" style="fill:#FFFFFF;fill-opacity:1;fill-rule:evenodd;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:10"/>
|
||||
</g>
|
||||
<metadata id="metadata3029">
|
||||
<rdf:RDF>
|
||||
<cc:Work rdf:about="">
|
||||
<dc:title/>
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
</svg>
|
After Width: | Height: | Size: 5.5 KiB |
21
ui/src/components/Dashboard.vue
Normal file
|
@ -0,0 +1,21 @@
|
|||
<template>
|
||||
<v-container fill-height class="pa-0">
|
||||
<v-layout class="flex-wrap">
|
||||
<v-col cols="12" sm="4">
|
||||
<slot name="left" />
|
||||
</v-col>
|
||||
<v-col cols="12" sm="8">
|
||||
<slot name="right" />
|
||||
</v-col>
|
||||
</v-layout>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-property-decorator";
|
||||
|
||||
@Component
|
||||
export default class Dashboard extends Vue {}
|
||||
</script>
|
||||
|
||||
<style></style>
|
60
ui/src/components/DashboardInfo.vue
Normal file
|
@ -0,0 +1,60 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-divider class="my-3" />
|
||||
<div class="d-flex flex-column align-end blue-grey--text text-body-2">
|
||||
<span class="d-flex thw-heading-font">
|
||||
ovdashboard powered by
|
||||
<a class="blue-grey--text" :href="server_host">{{ server_name }}</a>
|
||||
</span>
|
||||
<span class="d-flex thw-heading-font">
|
||||
Version: {{ version }} – IP: {{ lan_ip ? lan_ip : "?" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "@/ovd-vue";
|
||||
|
||||
@Component
|
||||
export default class DashboardInfo extends Vue {
|
||||
public server_host = "https://oekzident.de";
|
||||
public server_name = "OEKZident";
|
||||
public version = "0.0.1";
|
||||
public lan_ip = "0.0.0.0";
|
||||
|
||||
public created(): void {
|
||||
super.created();
|
||||
}
|
||||
|
||||
public beforeDestroy(): void {
|
||||
super.beforeDestroy();
|
||||
}
|
||||
|
||||
protected update(): void {
|
||||
// Update Server Config
|
||||
type ServerConfig = {
|
||||
host: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
this.$ovdashboard.api_get_object<ServerConfig>(
|
||||
"misc/config/server",
|
||||
(data) => {
|
||||
this.server_host = data.host;
|
||||
this.server_name = data.name;
|
||||
},
|
||||
);
|
||||
|
||||
// Update Version
|
||||
this.$ovdashboard.api_get_string("misc/version", (data) => {
|
||||
this.version = data;
|
||||
});
|
||||
|
||||
// Update IP
|
||||
this.$ovdashboard.api_get_string("misc/lanip", (data) => {
|
||||
this.lan_ip = data;
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
74
ui/src/components/ImageCarousel.vue
Normal file
|
@ -0,0 +1,74 @@
|
|||
<template>
|
||||
<v-carousel
|
||||
cycle
|
||||
v-if="urls.length > 0"
|
||||
:interval="speed"
|
||||
:height="height"
|
||||
:show-arrows="false"
|
||||
touchless
|
||||
hide-delimiters
|
||||
>
|
||||
<v-carousel-item
|
||||
v-for="url in urls"
|
||||
:key="url"
|
||||
:src="url"
|
||||
:contain="contain"
|
||||
/>
|
||||
</v-carousel>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "@/ovd-vue";
|
||||
|
||||
@Component
|
||||
export default class ImageCarousel extends Vue {
|
||||
public urls: string[] = require("@/assets/image_testdata.json");
|
||||
public height = 300;
|
||||
public contain = false;
|
||||
public speed = 10000;
|
||||
|
||||
public created(): void {
|
||||
super.created();
|
||||
}
|
||||
|
||||
public beforeDestroy(): void {
|
||||
super.beforeDestroy();
|
||||
}
|
||||
|
||||
protected update(): void {
|
||||
// Update Images
|
||||
this.$ovdashboard.api_get_list("image/list", (names) => {
|
||||
this.urls = names.map((name: string) =>
|
||||
this.$ovdashboard.api_url(`image/get/${name}`),
|
||||
);
|
||||
});
|
||||
|
||||
// Update Image Config
|
||||
type ImageConfig = {
|
||||
height: number;
|
||||
contain: boolean;
|
||||
speed: number;
|
||||
};
|
||||
|
||||
this.$ovdashboard.api_get_object<ImageConfig>("image/config", (cfg) => {
|
||||
this.height = cfg.height;
|
||||
this.contain = cfg.contain;
|
||||
this.speed = cfg.speed;
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-window {
|
||||
&-x-transition,
|
||||
&-x-reverse-transition,
|
||||
&-y-transition,
|
||||
&-y-reverse-transition {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: 1.5s cubic-bezier(0.25, 0.8, 0.5, 1) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
62
ui/src/components/Message.vue
Normal file
|
@ -0,0 +1,62 @@
|
|||
<template>
|
||||
<div v-html="html" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "@/ovd-vue";
|
||||
|
||||
@Component
|
||||
export default class Message extends Vue {
|
||||
public html = require("@/assets/message_testdata.json");
|
||||
|
||||
public created(): void {
|
||||
super.created();
|
||||
}
|
||||
|
||||
public beforeDestroy(): void {
|
||||
super.beforeDestroy();
|
||||
}
|
||||
|
||||
protected update(): void {
|
||||
// Update Message
|
||||
this.$ovdashboard.api_get_string(
|
||||
"text/get/html/message",
|
||||
(data) => (this.html = data),
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
div:deep() {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
ul,
|
||||
ol {
|
||||
font-family: "Neue Praxis", sans-serif !important;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
p {
|
||||
text-align: justify;
|
||||
hyphens: auto;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
</style>
|
12
ui/src/components/Model.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
export class Model {
|
||||
public get hash(): string {
|
||||
const str = JSON.stringify(this);
|
||||
|
||||
// source: https://gist.github.com/hyamamoto/fd435505d29ebfa3d9716fd2be8d42f0?permalink_comment_id=2775538#gistcomment-2775538
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++)
|
||||
hash = (Math.imul(31, hash) + str.charCodeAt(i)) | 0;
|
||||
|
||||
return new Uint32Array([hash])[0].toString(36);
|
||||
}
|
||||
}
|
118
ui/src/components/TickerBar.vue
Normal file
|
@ -0,0 +1,118 @@
|
|||
<template>
|
||||
<div v-if="content !== ''">
|
||||
<v-footer :color="color" :dark="is_dark" fixed>
|
||||
<span ref="marquee" class="text-h6" v-html="content" />
|
||||
</v-footer>
|
||||
<v-footer>
|
||||
<span class="text-h6" v-html="content" />
|
||||
</v-footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Ref, Vue, Watch } from "@/ovd-vue";
|
||||
import Color from "color";
|
||||
|
||||
@Component
|
||||
export default class TickerBar extends Vue {
|
||||
public content = "<p>changeme</p>";
|
||||
|
||||
public color = "primary";
|
||||
|
||||
@Ref("marquee")
|
||||
private readonly _marquee!: HTMLSpanElement;
|
||||
|
||||
public get is_dark(): boolean {
|
||||
return this.footer_color.isDark();
|
||||
}
|
||||
|
||||
private get footer_color(): Color {
|
||||
// try getting from vuetify theme
|
||||
const color = this.$vuetify.theme.themes.light[this.color];
|
||||
|
||||
if (typeof color === "string") {
|
||||
return Color(color);
|
||||
}
|
||||
|
||||
// fallback: parse color directly
|
||||
return Color(this.color);
|
||||
}
|
||||
|
||||
@Watch("content", { immediate: true })
|
||||
private set_marquee_duration(): void {
|
||||
this.$nextTick((): void => {
|
||||
const style = window.getComputedStyle(this._marquee);
|
||||
|
||||
const width =
|
||||
parseFloat(style.getPropertyValue("width")) -
|
||||
parseFloat(style.getPropertyValue("padding-left")) -
|
||||
parseFloat(style.getPropertyValue("padding-right"));
|
||||
|
||||
// 10 seconds + another second per 40px
|
||||
const duration = 10 + Math.round(width / 40);
|
||||
|
||||
this._marquee.style.setProperty("animation-duration", `${duration}s`);
|
||||
});
|
||||
}
|
||||
|
||||
public created(): void {
|
||||
super.created();
|
||||
}
|
||||
|
||||
public beforeDestroy(): void {
|
||||
super.beforeDestroy();
|
||||
}
|
||||
|
||||
protected update(): void {
|
||||
// Update Ticker
|
||||
this.$ovdashboard.api_get_string("ticker/html", (data) => {
|
||||
this.content = data;
|
||||
});
|
||||
|
||||
// Update Ticker Config
|
||||
type TickerConfig = {
|
||||
color: string;
|
||||
};
|
||||
|
||||
this.$ovdashboard.api_get_object<TickerConfig>("ticker/config", (data) => {
|
||||
this.color = data.color;
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-100%, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.v-footer {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
|
||||
&:first-child {
|
||||
z-index: 999;
|
||||
|
||||
> span {
|
||||
display: inline-block;
|
||||
padding-left: 100%;
|
||||
text-indent: 0;
|
||||
animation: marquee 30s linear infinite;
|
||||
|
||||
&:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep() * {
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
40
ui/src/components/calendar/Calendar.vue
Normal file
|
@ -0,0 +1,40 @@
|
|||
<template>
|
||||
<v-list class="py-0">
|
||||
<span class="text-h5 text-md-h4 text-truncate d-inline-block mb-2">
|
||||
{{ title }}
|
||||
</span>
|
||||
<template v-for="(event, index) in events">
|
||||
<EventItem :event="event" :key="`event-${index}`" />
|
||||
<v-divider
|
||||
v-if="index < events.length - 1"
|
||||
class="mx-5"
|
||||
:key="`event-div-${index}`"
|
||||
/>
|
||||
</template>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import EventItem from "./EventItem.vue";
|
||||
import { EventData } from "./EventModel";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
EventItem,
|
||||
},
|
||||
})
|
||||
export default class Calendar extends Vue {
|
||||
@Prop({ default: "CALENDAR" })
|
||||
public readonly title!: string;
|
||||
|
||||
@Prop({ default: () => [] })
|
||||
public readonly events!: EventData[];
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-list .v-divider {
|
||||
border-color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
</style>
|
134
ui/src/components/calendar/CalendarCarousel.vue
Normal file
|
@ -0,0 +1,134 @@
|
|||
<template>
|
||||
<v-carousel
|
||||
ref="main"
|
||||
cycle
|
||||
:interval="speed"
|
||||
height="auto"
|
||||
:show-arrows="false"
|
||||
touchless
|
||||
hide-delimiters
|
||||
>
|
||||
<v-carousel-item v-for="calendar in calendars" :key="calendar.hash">
|
||||
<Calendar :title="calendar.title" :events="calendar.events" />
|
||||
</v-carousel-item>
|
||||
</v-carousel>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Ref, Vue } from "@/ovd-vue";
|
||||
import Calendar from "./Calendar.vue";
|
||||
import { CalendarData, CalendarModel } from "./CalendarModel";
|
||||
import { EventData } from "./EventModel";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
Calendar,
|
||||
},
|
||||
})
|
||||
export default class CalendarCarousel extends Vue {
|
||||
private interval?: number;
|
||||
|
||||
private data: CalendarData[] = require("@/assets/calendar_testdata.json");
|
||||
public speed = 10000;
|
||||
|
||||
@Ref("main")
|
||||
private readonly _main?: Vue;
|
||||
|
||||
public created(): void {
|
||||
super.created();
|
||||
}
|
||||
|
||||
public beforeDestroy(): void {
|
||||
super.beforeDestroy();
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
|
||||
protected update(): void {
|
||||
// Update Calendar Aggregates
|
||||
this.$ovdashboard.api_get_list("aggregate/list", (names) => {
|
||||
this.$ovdashboard.api_get_object_multi<EventData[]>(
|
||||
names.map((name) => `aggregate/get/${name}`),
|
||||
(calendars) => {
|
||||
this.data = [];
|
||||
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
this.data.push({
|
||||
title: names[i],
|
||||
events: calendars[i],
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Update Calendar Config
|
||||
type CalendarConfig = {
|
||||
speed: number;
|
||||
};
|
||||
|
||||
this.$ovdashboard.api_get_object<CalendarConfig>(
|
||||
"calendar/config",
|
||||
(data) => {
|
||||
this.speed = data.speed;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private update_height(): void {
|
||||
const diff = 100;
|
||||
|
||||
if (this._main === undefined) return;
|
||||
|
||||
const parentElement = this._main.$el.parentElement;
|
||||
if (parentElement === null) return;
|
||||
|
||||
const divElement = this._main.$el.querySelector("div");
|
||||
if (divElement === null) return;
|
||||
|
||||
const divHeightPX = window
|
||||
.getComputedStyle(parentElement)
|
||||
.getPropertyValue("height");
|
||||
const maxHeight = parseFloat(divHeightPX) - diff;
|
||||
|
||||
divElement.style.setProperty("max-height", `${maxHeight}px`);
|
||||
}
|
||||
|
||||
public mounted(): void {
|
||||
this.update_height();
|
||||
this.interval = setInterval(this.update_height, 10000);
|
||||
}
|
||||
|
||||
public get calendars(): CalendarModel[] {
|
||||
const arr = [];
|
||||
|
||||
for (const json_data of this.data) {
|
||||
arr.push(new CalendarModel(json_data));
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "~vuetify/src/styles/settings/_variables";
|
||||
|
||||
.v-carousel:deep() > div {
|
||||
max-height: 500px;
|
||||
|
||||
@media #{map-get($display-breakpoints, "sm-and-down")} {
|
||||
min-height: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
.v-window {
|
||||
&-x-transition,
|
||||
&-x-reverse-transition,
|
||||
&-y-transition,
|
||||
&-y-reverse-transition {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: 1.5s cubic-bezier(0.25, 0.8, 0.5, 1) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
23
ui/src/components/calendar/CalendarModel.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { Model } from "@/components/Model";
|
||||
import { EventData, EventModel } from "./EventModel";
|
||||
|
||||
export type CalendarData = {
|
||||
title: string;
|
||||
events: EventData[];
|
||||
};
|
||||
|
||||
export class CalendarModel extends Model {
|
||||
public title: string;
|
||||
public events: EventModel[];
|
||||
|
||||
public constructor(json_data: CalendarData) {
|
||||
super();
|
||||
|
||||
this.title = json_data.title;
|
||||
|
||||
this.events = [];
|
||||
for (const event_data of json_data.events) {
|
||||
this.events.push(new EventModel(event_data));
|
||||
}
|
||||
}
|
||||
}
|
52
ui/src/components/calendar/EventDate.vue
Normal file
|
@ -0,0 +1,52 @@
|
|||
<template>
|
||||
<div class="event-date d-flex justify-center mr-1 mr-md-2">
|
||||
<div class="d-flex flex-column align-end">
|
||||
<div class="d-flex align-end">
|
||||
<span class="d-flex text-h4 text-md-h3">{{ day }}</span>
|
||||
<span class="d-flex text-h5 text-md-h4 blue-grey--text text--darken-1">
|
||||
{{ month }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="d-flex text-h6 text-md-h5 blue-grey--text text--lighten-1 mt-n1"
|
||||
>
|
||||
{{ time }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { DateTime } from "luxon";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
|
||||
@Component
|
||||
export default class EventDate extends Vue {
|
||||
@Prop()
|
||||
private readonly date!: DateTime;
|
||||
|
||||
public get day(): string {
|
||||
return this.date.toFormat("dd.");
|
||||
}
|
||||
|
||||
public get month(): string {
|
||||
return this.date.toFormat("MM.");
|
||||
}
|
||||
|
||||
public get time(): string {
|
||||
return this.date.toLocaleString(DateTime.TIME_24_SIMPLE);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "~vuetify/src/styles/settings/_variables";
|
||||
|
||||
.event-date {
|
||||
min-width: 95px;
|
||||
|
||||
@media #{map-get($display-breakpoints, "md-and-up")} {
|
||||
min-width: 130px;
|
||||
}
|
||||
}
|
||||
</style>
|
67
ui/src/components/calendar/EventItem.vue
Normal file
|
@ -0,0 +1,67 @@
|
|||
<template>
|
||||
<v-list-item class="pa-0" three-line>
|
||||
<EventDate :date="event.start" />
|
||||
<v-list-item-content>
|
||||
<v-list-item-title class="text-h6 text-md-h5 mt-0 mb-1">
|
||||
{{ event.summary }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle
|
||||
v-if="event.description"
|
||||
class="text-subtitle-2 text-md-subtitle-1 mt-0 mb-2"
|
||||
>
|
||||
{{ event.description }}
|
||||
</v-list-item-subtitle>
|
||||
<v-list-item-subtitle
|
||||
class="d-inline-block text-truncate thw-heading-font blue-grey--text text--darken-1 font-weight-bold ma-0"
|
||||
>
|
||||
{{ data_string }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { DateTime, DurationLikeObject } from "luxon";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import EventDate from "./EventDate.vue";
|
||||
import { EventModel } from "./EventModel";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
EventDate,
|
||||
},
|
||||
})
|
||||
export default class EventItem extends Vue {
|
||||
@Prop()
|
||||
public readonly event!: EventModel;
|
||||
|
||||
public get data_string(): string {
|
||||
const locale_string = this.event.start.toLocaleString(
|
||||
DateTime.DATETIME_MED_WITH_WEEKDAY,
|
||||
);
|
||||
|
||||
// decide which duration units to include
|
||||
const units: (keyof DurationLikeObject)[] = ["hours"];
|
||||
|
||||
if (this.event.duration.as("days") >= 1) {
|
||||
// include days if duration is at least one day
|
||||
units.push("days");
|
||||
}
|
||||
|
||||
if (!Number.isInteger(this.event.duration.as("hours"))) {
|
||||
// include minutes if duration in hours is not a whole number
|
||||
units.push("minutes");
|
||||
}
|
||||
|
||||
const duration_string = this.event.duration
|
||||
// "..." is the spread operator
|
||||
.shiftTo(...units)
|
||||
.mapUnits((x) => Math.round(x))
|
||||
.toHuman();
|
||||
|
||||
return `${locale_string} (${duration_string})`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style></style>
|
29
ui/src/components/calendar/EventModel.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { Model } from "@/components/Model";
|
||||
import { DateTime, Duration } from "luxon";
|
||||
|
||||
export type EventData = {
|
||||
summary: string;
|
||||
description: string;
|
||||
dtstart: string;
|
||||
dtend: string;
|
||||
};
|
||||
|
||||
export class EventModel extends Model {
|
||||
public summary: string;
|
||||
public description: string;
|
||||
public start: DateTime;
|
||||
public duration: Duration;
|
||||
|
||||
public constructor(json_data: EventData) {
|
||||
super();
|
||||
|
||||
this.summary = json_data.summary;
|
||||
this.description = json_data.description;
|
||||
this.start = DateTime.fromISO(json_data.dtstart).setLocale(
|
||||
navigator.language,
|
||||
);
|
||||
const end = DateTime.fromISO(json_data.dtend).setLocale(navigator.language);
|
||||
|
||||
this.duration = end.diff(this.start);
|
||||
}
|
||||
}
|
32
ui/src/components/title/Clock.vue
Normal file
|
@ -0,0 +1,32 @@
|
|||
<template>
|
||||
<span>{{ formatted }}</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { DateTime } from "luxon";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
|
||||
@Component
|
||||
export default class Clock extends Vue {
|
||||
public formatted = "";
|
||||
private interval?: number;
|
||||
|
||||
@Prop({ required: true })
|
||||
private readonly format!: string;
|
||||
|
||||
private update(): void {
|
||||
this.formatted = DateTime.now()
|
||||
.setLocale(navigator.language)
|
||||
.toFormat(this.format);
|
||||
}
|
||||
|
||||
public created(): void {
|
||||
this.update();
|
||||
this.interval = setInterval(this.update, 10000);
|
||||
}
|
||||
|
||||
public beforeDestroy(): void {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
}
|
||||
</script>
|
77
ui/src/components/title/THWLogo.vue
Normal file
|
@ -0,0 +1,77 @@
|
|||
<template>
|
||||
<div class="d-flex flex-column text-wrap">
|
||||
<div class="d-flex justify-start justify-md-end">
|
||||
<span class="d-none d-md-flex text-right thw-logo-font mr-2">
|
||||
{{ above }}
|
||||
</span>
|
||||
<v-img
|
||||
class="d-none d-sm-flex"
|
||||
max-width="56"
|
||||
max-height="56"
|
||||
:src="logo_url"
|
||||
/>
|
||||
</div>
|
||||
<v-divider class="d-none d-md-block my-1" />
|
||||
<span class="d-none d-md-flex thw-logo-font">
|
||||
{{ below }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "@/ovd-vue";
|
||||
|
||||
@Component
|
||||
export default class THWLogo extends Vue {
|
||||
public above = "Technisches Hilfswerk";
|
||||
public below = "OV Musterstadt";
|
||||
|
||||
public get logo_url(): string {
|
||||
return this.$ovdashboard.api_url("file/get/logo");
|
||||
}
|
||||
|
||||
public created(): void {
|
||||
super.created();
|
||||
}
|
||||
|
||||
public beforeDestroy(): void {
|
||||
super.beforeDestroy();
|
||||
}
|
||||
|
||||
protected update(): void {
|
||||
// Update Logo Config
|
||||
type LogoConfig = {
|
||||
above: string;
|
||||
below: string;
|
||||
};
|
||||
|
||||
this.$ovdashboard.api_get_object<LogoConfig>("misc/config/logo", (cfg) => {
|
||||
this.above = cfg.above;
|
||||
this.below = cfg.below;
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.flex-column {
|
||||
min-width: 250px;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.v-divider {
|
||||
border-width: 1px;
|
||||
border-color: white;
|
||||
}
|
||||
|
||||
div.flex-column > div > span:first-child {
|
||||
font-size: 28px;
|
||||
line-height: 28px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
div.flex-column > span:last-child {
|
||||
font-size: 15px;
|
||||
line-height: 15px;
|
||||
}
|
||||
</style>
|