Compare commits

...

337 commits

Author SHA1 Message Date
11cd12febd Merge branch 'release/0.1.0' 2023-11-16 16:07:13 +01:00
1621561195 optimization: use slim python image variant
- hiredis compilation fixed
2023-11-16 15:44:09 +01:00
30566fe449 deploy scripts QoL changes 2023-11-16 15:32:02 +01:00
afbd9f4db8 README overhaul 2023-11-16 15:31:07 +01:00
6b1fda1ffe amendments after final integration test 2023-11-16 14:55:32 +01:00
0db0e92b13 optimization: image size 2023-11-16 14:55:02 +01:00
daa959bd14 check_version output format 2023-11-15 23:39:18 +01:00
525b711005 amendments after integration test #3 2023-11-15 23:39:18 +01:00
6d041e6918 amendments after integration test #2 2023-11-15 16:17:32 +01:00
f27aa8b284 add compose_version to check_version 2023-11-15 15:28:03 +01:00
02bede8446 "chore" script for docker buildx 2023-11-15 15:21:46 +01:00
eea1e7880a rename "install" -> "deploy" 2023-11-15 15:21:46 +01:00
b0a1c78384 script to check version 2023-11-15 14:45:04 +01:00
6e80373979 use non-slim python image (hiredis compilation) 2023-11-15 14:09:11 +01:00
f00f95edd3 adds to README 2023-11-15 13:55:40 +01:00
976506489c "dietpi" installer suspected working 2023-11-15 13:55:10 +01:00
caa30660dc TODOs in readme 2023-11-15 11:31:49 +01:00
a93f56ee65 README overhaul 2023-11-15 11:27:09 +01:00
72ae9e222c compose file 2023-11-15 11:19:12 +01:00
9faaee714a add "mini-tiangolo" to Dockerfile
- tiangolo/uvicorn-gunicorn is not available for ARM arch
2023-11-15 09:26:18 +01:00
ff4e35e2d4 add testing for core.settings.Settings 2023-11-15 09:23:50 +01:00
a5348a9987 main dockerfile fixes 2023-11-09 12:40:10 +01:00
5897d2f2c7 ignore extra entries in .env file 2023-11-09 12:16:42 +01:00
27de977bfe fix redis keying and de/serializing 2023-11-09 12:16:25 +01:00
5524c4094b redis caching for webdav 2023-11-09 12:13:48 +01:00
c284274e75 add redis container to api devcontainer 2023-11-09 11:22:42 +01:00
d9479eb2bc Merge branch 'feature/python3.12' into develop 2023-10-27 02:18:48 +02:00
77a0eddfd8 Merge branch 'feature/ui_upgrade' into feature/python3.12 2023-10-27 02:12:52 +02:00
2c54dd52cd main Dockerfile fixup (node version, libmagic) 2023-10-27 02:12:31 +02:00
a51053abe7 node: v18 -> v20 2023-10-27 00:46:43 +02:00
50f67e5ad5 api: minor fixes 2023-10-27 00:40:04 +02:00
a6fa709d52 Merge branch 'feature/dockerize' into feature/ui_upgrade 2023-10-27 00:35:11 +02:00
86081980e1 stricter "prettier" code style 2023-10-27 00:34:38 +02:00
c654a5a322 fix linter errors 2023-10-26 22:28:59 +00:00
4e63dccbf0 api hotfix: use now() instead of utcnow 2023-10-27 00:22:01 +02:00
3d33dd06a7 ui: jump to latest version 2 of vue and vuetify 2023-10-26 22:19:22 +00:00
9200c0bbb9 basic yarn upgrade 2023-10-26 22:04:12 +00:00
842d594e76 UI base container v0 -> v1 2023-10-26 21:58:18 +00:00
c9bef3bbe4 refac: naming convention 2023-10-26 23:17:38 +02:00
b942aeee23 don't use operator module 2023-10-26 23:12:51 +02:00
afc859dff7 bug: unhashable Config in CalDAV.get_events 2023-10-26 23:10:51 +02:00
0567edf87e project scaffolding options 2023-10-26 22:45:37 +02:00
1e5287b138 refac: async-cache -> cachetools 2023-10-26 22:42:26 +02:00
f424ad25a4 docstrings 2023-10-26 19:20:46 +02:00
246f8b8cac refac: ListManager naming convention 2023-10-26 19:17:16 +02:00
ac4aeeed7f refac: unused logger 2023-10-26 19:12:21 +02:00
1b57fcf43d flake8 linting 2023-10-26 19:10:20 +02:00
ec85614b51 refac: CalDAV.get_events 2023-10-26 19:04:26 +02:00
0a5f84eee5 refac: deprecation fix 2023-10-26 18:50:04 +02:00
0372866a98 wip: new Dockerfile 2023-10-26 18:28:00 +02:00
9551ee7729 refac: move stuff to and from _common 2023-10-26 18:26:51 +02:00
3a255db15a minor refacs (imports, unuseds) 2023-10-26 18:21:07 +02:00
b6798df29c refac: asyncify CalDAV.get_events 2023-10-26 18:04:03 +02:00
cc96889bc4 refac: fix aggregate router 2023-10-26 17:58:54 +02:00
c92398118b minor refac 2023-10-26 17:48:33 +02:00
cdf4ce4d16 refac: fix calendar router 2023-10-26 17:46:12 +02:00
fc2388dd12 "black" formatting 2023-10-26 16:43:35 +02:00
44a165c53e refac: use global RP_ and LM_ objects 2023-10-26 16:31:12 +02:00
c47773e70d refac: _common.ListManager 2023-10-26 15:58:02 +02:00
7fb3aa0f42 Dependable remove __call__ 2023-10-25 20:50:03 +02:00
e428fea3da refac: decorator calls 2023-10-24 19:39:43 +02:00
b7292af6ad wip: functionify routers._common 2023-10-23 23:44:09 +02:00
78b1359603 wip: functionify routers._common 2023-10-23 23:32:25 +02:00
e6509d85fc wip: functionify routers._common 2023-10-23 20:56:01 +02:00
06abdfb953 wip: functionify routers._common 2023-10-22 16:25:19 +02:00
2ca6377634 refac: webdav files prefixing issue 2023-10-22 12:49:34 +02:00
3d8cdca5cd make .txt work again 2023-10-22 12:49:04 +02:00
d2e77b9ae5 refac: fix routers text and ticker; issue with prefix does not work with directory 2023-10-20 13:40:15 +02:00
db1cc5f707 refac: fix image router 2023-10-20 13:12:53 +02:00
8f6243723b refac: fix file router startup 2023-10-20 13:12:44 +02:00
19c6772b88 refac: WebDAV.list_files usage; translate doc 2023-10-20 13:01:48 +02:00
17b9e274c1 weird bug with txt files (might be NC issue) 2023-10-20 12:51:51 +02:00
2b17aa5b8f refac: production mode startup logic 2023-10-20 11:50:52 +02:00
b8b1c30313 py3.12/refac: partly working 2023-10-20 10:43:15 +02:00
a585d97f9f py3.12/refac: AfterValidator -> StringConstraints 2023-10-19 13:58:13 +02:00
ddde3bd024 py3.12 and refactoring: "core" module [wip] 2023-10-18 18:55:54 +02:00
b0e95af44e py3.12: more styling and linting 2023-10-17 14:55:38 +02:00
d193d07fd6 py3.12: scaffolding 2023-10-17 14:48:52 +02:00
47ee2170ce fix deprecated calls 2023-10-17 14:45:56 +02:00
1655e64e0d Merge branch 'develop' into feature/python3.11 2023-10-16 20:12:20 +02:00
8609e09067 doc 2023-10-16 20:08:30 +02:00
6709284ed9 py3.11: fix basic linter errors 2023-10-16 20:06:55 +02:00
a2403ade59 py3.11: upgrade deps to latest 2023-10-16 20:06:22 +02:00
5f5d300e92 py3.11: update projext scaffold 2023-10-16 20:05:52 +02:00
22846c3d41 dependency update 2023-04-05 19:07:03 +02:00
db4686fdc5 async-lru -> async-cache 2022-10-28 11:06:02 +00:00
994dc827db main README 2022-10-08 02:44:29 +02:00
d92e759cae numbering 2022-10-08 02:44:29 +02:00
e8eacd8a6c README 2022-10-08 02:44:29 +02:00
4de9e07646 README 2022-10-08 02:44:29 +02:00
044e2a351e README files 2022-10-08 02:44:29 +02:00
2c88caead9 READMEs 2022-10-08 02:44:29 +02:00
a246ecb4fc default and thw logo files 2022-10-08 02:44:29 +02:00
61f61a6523 postCreateCommand -> postStartCommand 2022-10-08 02:44:29 +02:00
7cb118d000 CalendarCarousel bottom margin 2022-10-08 02:44:29 +02:00
824b932069 slot naming 2022-10-08 02:44:29 +02:00
41cf2ff90c DashboardInfo positioning, slotting 2022-10-08 02:44:29 +02:00
c3765c1db6 DashboardInfo self update 2022-10-08 02:44:29 +02:00
7f46c1761b CalendarCarousel self update 2022-10-08 02:44:29 +02:00
6f455869a5 Message self update 2022-10-08 02:44:29 +02:00
27fd17200b ImageCarousel self update 2022-10-08 02:44:29 +02:00
b6b71daa50 TickerBar self update 2022-10-08 02:44:29 +02:00
8b45718ea5 logo max dimensions 2022-10-08 02:44:29 +02:00
daa87c0255 footer margin 2022-10-08 02:44:29 +02:00
738be6e667 "Vue" implementation with interval 2022-10-08 02:44:29 +02:00
004723499b main README 2022-10-08 02:44:29 +02:00
73824b1755 main README 2022-10-08 02:44:29 +02:00
e13bd36d62 API readme 2022-10-08 02:44:29 +02:00
11522ac19b API readme 2022-10-08 02:44:29 +02:00
289cc01544 API doc 2022-10-08 02:41:30 +02:00
a0fb8fcc1c main Dockerfile, CI config 2022-09-20 00:55:52 +02:00
4b37075007 do not print 2022-09-19 13:24:35 +00:00
58f295cc54 correct usage of "duration" 2022-09-19 13:22:32 +00:00
80714978d3 use data["duration"] 2022-09-19 12:55:49 +00:00
29d6d1d2b5 empty ticker handling 2022-09-19 11:55:22 +00:00
16348530ee THWLogo from API 2022-09-19 11:55:12 +00:00
abedef1583 Upload files from skel directory 2022-09-19 11:42:43 +00:00
5867b1f3c4 "skel" directory 2022-09-19 10:13:06 +00:00
143e19490c lipsum -> message_testdata 2022-09-19 09:56:07 +00:00
00d17ee71f router import path 2022-09-19 09:35:18 +00:00
445abccd73 wrong import 2022-09-19 09:25:26 +00:00
889fae5a37 OVDashboardPlugin.api_version 2022-09-19 09:24:20 +00:00
a565125245 use Template strings 2022-09-19 09:17:53 +00:00
1b1e19b4ea poetry: main script 2022-09-18 22:50:45 +00:00
6457b6c1fd first test: skeleton dir 2022-09-18 22:42:28 +00:00
a01273a5e3 poetry update 2022-09-18 22:02:34 +00:00
8ef9d4ad2e add a "/file" router (static serve from WebDAV) 2022-09-18 21:59:02 +00:00
8bd87fb6f1 unrelated OS dependencies 2022-09-18 21:58:07 +00:00
bb0115808d Exception based webdav_check; app startup 2022-09-18 21:36:52 +00:00
53dd8d74fa routers -> routers.v1 2022-09-17 19:41:41 +00:00
fbf6c550d2 move main function, launch module directly 2022-09-17 19:13:59 +00:00
bcc833d3a6 API remove some SETTINGS 2022-09-17 19:00:52 +00:00
4f5e489950 yarn upgrade 2022-09-16 23:04:27 +00:00
bae63ca2db remove axios plugin, add axios dep 2022-09-16 23:02:10 +00:00
6f228171f0 don't install $axios 2022-09-16 22:58:03 +00:00
ff6fbe218d api_get_object_multi method 2022-09-16 22:57:12 +00:00
a7eed28bf3 some better naming 2022-09-16 22:29:03 +00:00
800cc87d51 query_simple -> get 2022-09-16 22:02:23 +00:00
208af9d55b better typing for queries 2022-09-16 21:45:59 +00:00
1ebaf9389f easier ovdashboard shim 2022-09-16 21:28:56 +00:00
ac13f60b81 move axios to ovdashboard plugin 2022-09-16 21:15:46 +00:00
9804fffbf2 get calendars from API 2022-09-16 14:59:56 +00:00
e0ba5c0566 get images from API 2022-09-16 14:50:45 +00:00
189da9bcf3 update interval 2022-09-16 14:29:55 +00:00
7df24feb00 use wrappers 2022-09-16 14:23:10 +00:00
5ffc886298 Simple API query wrappers 2022-09-16 14:16:53 +00:00
fc9a51da5d Simple API queries 2022-09-16 14:02:18 +00:00
ba7c2bd926 first API query from UI! 2022-09-15 22:51:50 +00:00
1d8748e63a add middlewares (uvicorn) 2022-09-15 22:41:23 +00:00
e8d075a85f typo 2022-09-15 22:35:31 +00:00
3a08a9b711 update routers 2022-09-15 22:32:11 +00:00
2865fd0a6d weird whitespace 2022-09-15 22:24:32 +00:00
a16ff9d98c needed more config 2022-09-15 22:19:23 +00:00
1a9f284447 ticker color variable 2022-09-15 22:14:45 +00:00
a55bcfb9e0 API: config change to meet the UI needs 2022-09-15 22:14:02 +00:00
89a7ffe977 responsivity: small screens 2022-09-15 20:30:25 +00:00
8efc40eb95 DashboardInfo improvement 2022-09-15 19:56:30 +00:00
695c9d26ae hide scrollbar 2022-09-15 19:49:41 +00:00
9bd627198c general layout improvement 2022-09-15 19:45:34 +00:00
cb05667d59 move Model.ts 2022-09-15 19:32:05 +00:00
a01b728889 slightly less code 2022-09-15 19:30:57 +00:00
2db0cb58f0 Message component (mainly styling) 2022-09-15 19:30:48 +00:00
5a77231282 Array syntax 2022-09-15 19:08:15 +00:00
2d8a75c1f6 ImageCarousel props 2022-09-15 18:08:04 +00:00
ba3a28d5eb directory title_bar -> title 2022-09-15 18:07:52 +00:00
63cd8ce1c2 syntax hiccup 2022-09-15 18:01:57 +00:00
17af91e1a8 refactor: App data fields 2022-09-15 18:01:10 +00:00
a1d8fe7c3d CalendarCarousel transition speed 2022-09-15 17:49:06 +00:00
7330f85398 ImageCarousel component and necessary Layout tweaks 2022-09-15 17:48:55 +00:00
52cffae464 DashboardInfo Link color 2022-09-15 16:30:15 +00:00
92f3140812 move more data to App.vue 2022-09-15 16:25:58 +00:00
bb5e27ffd5 DashboardInfo component 2022-09-15 15:46:09 +00:00
9d45ae0c39 CalendarCarousel dynamic max-height 2022-09-15 15:04:01 +00:00
006afa10d0 remove unnecessary padding 2022-09-15 14:16:43 +00:00
fe8cb1b693 refactor: move Model classes 2022-09-15 13:28:49 +00:00
5267825e8f truncate calendar title 2022-09-15 13:22:44 +00:00
d5d61a4a29 CalendarCarousel speed prop 2022-09-15 13:20:41 +00:00
6217ae2ecb refactor: event -> model, Hashable objects 2022-09-15 13:11:50 +00:00
5cfebd4c6f Calendar props, CalendarCarousel 2022-09-15 13:03:19 +00:00
75a7189889 Calendar Title 2022-09-15 12:20:48 +00:00
0488d18e9b data_string duration rendering 2022-09-15 11:54:05 +00:00
f8a4963733 Better CSS application 2022-09-15 01:24:00 +00:00
2c48f29851 ellipsis for EventItem's data_string 2022-09-15 01:13:12 +00:00
ef6582bc0a Add time to EventDate 2022-09-15 01:12:45 +00:00
e4056f3825 use correct locale 2022-09-15 00:40:49 +00:00
c58d707f49 better implementation of event.hash() 2022-09-15 00:25:31 +00:00
825f2d166e title_bar subdir 2022-09-15 00:11:22 +00:00
5b5b5acf7c add event hash and dividers between list items 2022-09-15 00:07:27 +00:00
edf5757feb use blue-grey text 2022-09-14 23:55:13 +00:00
9024f427d7 move stuff around 2022-09-14 23:37:16 +00:00
0c1632c7f8 EventDate formatting 2022-09-14 23:30:35 +00:00
d343785b51 EventDate component 2022-09-14 23:14:44 +00:00
c6dd690021 move Calendar component 2022-09-14 23:05:34 +00:00
41d3e9088e EventItem better formatting 2022-09-14 23:04:26 +00:00
459181e540 Calendar event data handling 2022-09-14 13:21:35 +00:00
77375f7095 deps fix @types 2022-09-14 13:15:10 +00:00
f59ec0dcbc crude calendar component 2022-09-13 21:30:20 +00:00
b60fe96de2 logo texts 2022-09-13 15:46:54 +00:00
26e8b41830 better marquee duration formula 2022-09-13 02:24:23 +00:00
1b6b421c57 TickerBar.variant -> TickerBar.color 2022-09-13 01:56:45 +00:00
2a569618ec crude dashboard layout 2022-09-13 01:54:02 +00:00
743ee3f13f prop defaults 2022-09-13 01:53:00 +00:00
7f8b7784c7 adjust ticker text color 2022-09-13 01:50:59 +00:00
a27f49e64c ticker fixed bottom 2022-09-13 00:06:35 +00:00
fdeab38203 hide empty ticker 2022-09-12 23:49:16 +00:00
f01cf0fbe0 private getter 2022-09-12 23:49:06 +00:00
5a274de64f prop typing 2022-09-12 22:37:55 +00:00
d599d4c23e whitespace 2022-09-12 22:35:58 +00:00
8dac502da5 optional param 2022-09-12 22:22:40 +00:00
07277602de "Vue" capitalization 2022-09-12 22:18:21 +00:00
84ebce761e vscode tabsize 2022-09-12 22:13:05 +00:00
8333952e7f "OVDashboard" and "Axios" typescript plugins 2022-09-12 22:12:32 +00:00
df541a7955 move TypeScript stuff 2022-09-12 21:15:57 +00:00
021e83e9d6 new syntax 2022-09-12 21:15:33 +00:00
25c05b6aab scoped style 2022-09-12 21:05:41 +00:00
add9214f6b slot -> prop 2022-09-12 13:02:03 +00:00
7f67007a12 TickerBar component 2022-09-12 13:01:58 +00:00
2c9efb1df8 Use Props, more classless scoped styling 2022-09-12 11:48:45 +00:00
bbddadef64 classless scoped styling 2022-09-12 11:36:46 +00:00
d0ccbb8dd0 responsivity fixes 2022-09-12 11:12:47 +00:00
2e4fe65127 add separate CSS for fonts 2022-09-12 11:07:24 +00:00
b1a62882d6 THW Logo layout optimization 2022-09-12 11:01:48 +00:00
e5010d2a2f THW logo + responsivity 2022-09-12 10:47:44 +00:00
0473a2c438 devcontainer postCreateCommand 2022-09-12 09:31:07 +00:00
e041ec48e7 THW font scheme 2022-09-12 00:39:19 +00:00
1846541a19 THW color scheme 2022-09-12 00:38:28 +00:00
d7de0a6a02 TitleBar title slot 2022-09-11 23:28:37 +00:00
ffafbdc3ab Clock font 2022-09-11 22:59:44 +00:00
bdeaa9a561 Clock update instantly + slower interval 2022-09-11 22:49:17 +00:00
042f5f6b18 THW font for title 2022-09-11 22:44:27 +00:00
3d3183c5f1 better titlebar layout 2022-09-11 22:24:48 +00:00
eaa4c17d4d title bar basic layout 2022-09-10 13:11:48 +00:00
0a19286261 renames 2022-09-10 02:19:45 +00:00
cd2cf8ca08 moment -> luxon 2022-09-10 02:11:36 +00:00
18bb455ec9 TS class components 2022-09-09 23:14:03 +00:00
d5171ee531 TitleBar with Clock component 2022-09-09 22:41:50 +00:00
295c0427b6 devcontainer: zsh 2022-09-09 16:48:28 +00:00
3a57127eaf Vuetify boilerplate 2022-09-09 16:38:12 +00:00
71be6ce189 Vue plugins 2022-09-09 16:33:24 +00:00
3fb1900a58 Vue+typescript boilerplate 2022-09-09 16:23:04 +00:00
487cd3d7fc VSCode boilerplate 2022-09-09 16:13:04 +00:00
1348869774 CORS and StaticFiles settings 2022-09-09 14:19:28 +00:00
0cdf49cf3e config documentation 2022-09-09 14:12:36 +00:00
72e3324141 default settings values handling 2022-09-09 03:16:26 +00:00
6b8d6f8bc7 SETTINGS.webdav_disable_check 2022-09-09 02:56:22 +00:00
fb5ca7bcda poetry update 2022-09-09 02:51:41 +00:00
21ac0b04cb add installed script 2022-09-09 02:50:59 +00:00
3926729e0c SETTINGS.main_{host,port} 2022-09-09 02:41:42 +00:00
f36272a6c0 some settings renames 2022-09-09 02:41:15 +00:00
564bac8ea4 documentation 2022-09-09 02:23:48 +00:00
a8c5180027 misc router 2022-09-09 02:21:52 +00:00
1f2b5a9607 get ticker ui config 2022-09-09 00:04:13 +00:00
ccbac0a455 split TickerConfig 2022-09-08 23:59:17 +00:00
83db799b96 ticker router 2022-09-08 23:44:03 +00:00
85327ce0b3 rename cal_aggregate -> aggregate 2022-09-08 23:39:42 +00:00
8b35bb7044 router startup log 2022-09-08 14:07:33 +00:00
43271bb6e3 configurable paths and names 2022-09-08 14:02:50 +00:00
baeff5c294 type checking "basic" 2022-09-08 00:37:59 +00:00
a02198dec0 webdav_check logic reversal 2022-09-07 12:57:38 +00:00
bd1d527d0e typo 2022-09-07 12:21:26 +00:00
946226c03a settings 2022-09-07 12:00:00 +00:00
3170313734 only "from"-imports 2022-09-07 00:29:32 +00:00
47aee73574 perform checks before running the API 2022-09-07 00:23:31 +00:00
c71049e930 warn -> warning 2022-09-07 00:17:55 +00:00
9349f5b756 log_level setting 2022-09-07 00:17:25 +00:00
ea85164063 utility object naming 2022-09-06 23:50:42 +00:00
e193410725 typing 2022-09-06 23:47:48 +00:00
33359aae43 logging and naming 2022-09-06 22:55:23 +00:00
f4b781912b get_ttl_hash without param 2022-09-06 22:45:23 +00:00
0de6b1d416 aggregate calendar router 2022-09-06 22:42:28 +00:00
36caa6db89 relative imports 2022-09-06 22:20:01 +00:00
a73dc51e25 allow decorator_args for timed_alru_cache 2022-09-06 07:55:52 +00:00
f99e45c7bb SETTINGS.cache_size 2022-09-06 07:54:44 +00:00
d09fe0f0e2 actually use config.image and config.calendar 2022-09-06 00:16:24 +00:00
1e9efd9cf3 calendar + aggregate config 2022-09-06 00:03:44 +00:00
e804ae68f8 Config for ticker and aggregate Calendars 2022-09-05 23:53:53 +00:00
a069fe5078 add license information to FastAPI object 2022-09-05 21:36:07 +00:00
c074bac3c8 webdav_prefix setting 2022-09-05 23:30:07 +02:00
8eeb3a61a7 DAV settings rework 2022-09-05 23:30:07 +02:00
1d62e59052 add "ticker" config component, move LogConfig to __init__ 2022-09-05 23:30:07 +02:00
077ac8efa5 correction using link 2022-09-05 23:30:07 +02:00
4da69740ae text router raw 2022-09-05 23:30:07 +02:00
bf92eadf3f brevity 2022-09-05 23:30:07 +02:00
09b7d59b39 use production mode switch for reloading 2022-09-05 23:30:07 +02:00
7a0925d60f DocStrings 2022-09-05 23:30:06 +02:00
d8ca1da9cb HTTP 404 if directory doesn't exist 2022-09-05 23:30:06 +02:00
972551d170 use Config.ticker_separator value 2022-09-05 23:30:06 +02:00
ea462b7459 unused import 2022-09-05 23:30:06 +02:00
20a32d82d6 SETTINGS.cache_seconds 2022-09-05 23:30:06 +02:00
c903144657 config creation 2022-09-05 23:30:06 +02:00
161e0e9628 logging setup 2022-09-05 23:30:06 +02:00
791a196e15 config.txt handling (missing aggregate calendars) 2022-09-05 23:30:06 +02:00
955aadfc86 nicer imports 2022-09-05 23:30:06 +02:00
77ee7c22e3 refactor main __init__ 2022-09-05 23:30:06 +02:00
105ca9d1bd .config -> .settings 2022-09-05 23:30:06 +02:00
2deda42194 CalEvent creation and validation 2022-09-05 23:30:06 +02:00
9d1329d8da sort calendar events 2022-09-05 23:30:06 +02:00
608f62b090 naming 2022-09-05 23:30:06 +02:00
fe31c9e52e async _get_calendar_events 2022-09-05 23:30:06 +02:00
ce2b729de6 DavCalendar class 2022-09-05 23:30:06 +02:00
46e39b97e9 naming 2022-09-05 23:30:06 +02:00
2c778b0d3a timed_alru_cache decorator 2022-09-05 23:30:06 +02:00
9b969b6024 dav_file dataclass 2022-09-05 23:30:06 +02:00
cf684ee5f9 PrefixUnique class, unified naming 2022-09-05 23:30:06 +02:00
f448ca79a6 calendar response model 2022-09-05 23:30:06 +02:00
f6937b5075 sync principal 2022-09-05 23:30:06 +02:00
559803ac0e use properties correctly 2022-09-05 23:30:06 +02:00
513a577914 works again, needs some work in routers._common 2022-09-05 23:30:06 +02:00
5034115281 wip/experiments 2022-09-05 23:30:06 +02:00
37b1a1ee68 quick and dirty vobject traversal 2022-09-05 23:30:06 +02:00
79c865552e quick and dirty calendar listing 2022-09-05 23:30:05 +02:00
eb69fa4ec6 don't expose CLIENT constant 2022-09-05 23:30:05 +02:00
b28d6fa4e7 Add FilePrefixLoader 2022-09-05 23:30:05 +02:00
749f56268c common "Iterator" stuff 2022-09-05 23:30:05 +02:00
9a350f6a71 text router rework: ticker comment "#", routing like image router 2022-09-05 23:30:05 +02:00
d934ba90b5 don't return None there 2022-09-05 23:30:05 +02:00
28339b22e0 Merge branch 'feature/timeout' into develop 2022-09-05 23:30:05 +02:00
c65aba82a4 rename project 2022-09-05 23:30:05 +02:00
e0c2ae1d2e remove apscheduler dep 2022-09-05 23:30:05 +02:00
72e33238c4 implement timeout instead of scheduling 2022-09-05 23:30:05 +02:00
0db71d0ecd get_image response class 2022-09-05 23:30:04 +02:00
12959153c1 download cache 2022-09-05 23:30:04 +02:00
21805b0d8a formatting 2022-09-05 23:30:04 +02:00
12f0c5cb8f image router 2022-09-05 23:30:04 +02:00
811575536f typing improvements 2022-09-05 23:30:04 +02:00
d4158e37fb DavFile async download 2022-09-05 23:30:04 +02:00
cfb813f787 refactor DavFile init 2022-09-05 23:30:04 +02:00
6859e29986 improved typing 2022-09-05 23:30:04 +02:00
3ffc72f065 don't expose scheduler 2022-09-05 23:30:04 +02:00
b040ede864 better ticker processing 2022-09-05 23:30:04 +02:00
bdb4933887 Improved scheduling using APScheduler 2022-09-05 23:30:04 +02:00
df7b031024 ticker content + markdown 2022-09-05 23:30:04 +02:00
a4289f620d getting some webdav files 2022-09-05 23:30:04 +02:00
fade34e224 webdavclient3 version 2022-09-05 23:30:04 +02:00
e49115e9e0 dev email, env file (reminder: create new token!) 2022-09-05 23:30:04 +02:00
6c8646813e Added PoC webdav functionality 2022-09-05 23:30:04 +02:00
352b508c48 empty FastAPI project 2022-09-05 23:30:04 +02:00
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",
"terminal.integrated.defaultProfile.linux": "zsh"
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"be5invis.toml",
"mhutchie.git-graph",
"ms-python.python",
"ms-python.black-formatter",
"ms-python.flake8",
"ms-python.isort",
"ms-python.vscode-pylance"
]
}
},
// Set *default* container specific settings.json values on container create.
"settings": {
"python.defaultInterpreterPath": "/usr/local/bin/python",
"python.linting.enabled": true,
"python.linting.pylintEnabled": true,
"python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8",
"python.formatting.blackPath": "/usr/local/py-utils/bin/black",
"python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf",
"python.linting.banditPath": "/usr/local/py-utils/bin/bandit",
"python.linting.flake8Path": "/usr/local/py-utils/bin/flake8",
"python.linting.mypyPath": "/usr/local/py-utils/bin/mypy",
"python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle",
"python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle",
"python.linting.pylintPath": "/usr/local/py-utils/bin/pylint"
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance",
"be5invis.toml"
],
// Use 'postStartCommand' to run commands after the container is started.
"postStartCommand": "poetry install"
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "pip3 install --user -r requirements.txt",
"postCreateCommand": "poetry install",
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode"
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

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