Merge branch 'release/0.1.0'

This commit is contained in:
Jörn-Michael Miehe 2023-11-16 16:07:13 +01:00
commit 0fca6d5f96
115 changed files with 13388 additions and 77 deletions

26
.dockerignore Normal file
View 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
View 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
View 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
View 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 &ndash; Upcoming Events &ndash; Public Announcements &ndash; News &ndash; 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 &ndash; Yet Always Recognizable** <br />
Change the Logo &ndash; Put your own Title &ndash; Publish Pictures &ndash; Create Event Lists &ndash; Customize the News Ticker Appearance &ndash; Adjust Item Rotation Speed
- **Responsive Design** <br />
OVDashboard is made for the big screen &ndash; but it also shines in your visitors' mobile browsers!
- **Locale Aware** <br />
No localized strings anywhere &ndash; 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

View file

@ -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 -

View file

@ -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",
"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"
"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.vscode-pylance",
"be5invis.toml"
],
"ms-python.black-formatter",
"ms-python.flake8",
"ms-python.isort",
"ms-python.vscode-pylance"
]
}
},
// 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"
}

View 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
View file

@ -0,0 +1,4 @@
[flake8]
max-line-length = 80
select = C,E,F,I,W,B,B950
extend-ignore = E203, E501

View file

@ -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
}
]

View file

@ -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",
}

View file

@ -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 &ndash; 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

View 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)

View 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.")

View 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)

View 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()

View 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)

View 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,
)

View 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))

View 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,
)

View 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()

View 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()

View file

@ -0,0 +1,5 @@
from .v1 import router as v1_router
__all__ = [
"v1_router",
]

View 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"]

View 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)

View 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))

View 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)

View 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

View 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}"},
)

View 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

View 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

View 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)

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View 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

View 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!

View 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

View 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

View file

@ -0,0 +1 @@
# OVDashboard Title

View file

@ -1,6 +0,0 @@
def main() -> None:
print("Hello World")
if __name__ == "__main__":
main()

1594
api/poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -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
View 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
View 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
View 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
View 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
View 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."

View 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))

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

BIN
doc/ovdashboard_en.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

3
ui/.browserslistrc Normal file
View file

@ -0,0 +1,3 @@
> 1%
last 2 versions
not dead

View 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"

View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
module.exports = {
presets: ["@vue/cli-plugin-babel/preset"],
};

40
ui/package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 799 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View 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
View 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
View file

@ -0,0 +1,2 @@
User-agent: *
Disallow:

56
ui/src/App.vue Normal file
View 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>

View 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"
}
]
}
]

View 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"
]

View 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
View 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

View 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>

View 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&nbsp;
<a class="blue-grey--text" :href="server_host">{{ server_name }}</a>
</span>
<span class="d-flex thw-heading-font">
Version: {{ version }} &ndash; 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>

View 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>

View 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>

View 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);
}
}

View 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>

View 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>

View 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>

View 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));
}
}
}

View 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>

View 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>

View 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);
}
}

View 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>

View 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>

Some files were not shown because too many files have changed in this diff Show more