waldhart-availability

Read and process availability from Waldhart software
git clone https://git.in0rdr.ch/waldhart-availability.git
Log | Files | Refs | Pull requests |Archive | README

commit f5a57a7cced2052fe66f2f8d578ef61f325bc85e
parent c1e1da9f9ff2982c33c3fccb44f4820bb3af03c3
Author: Andreas Gruhler <andreas.gruhler@adfinis.com>
Date:   Mon,  2 Dec 2024 00:15:58 +0100

feat: read availability and update calendar

Diffstat:
A.gitignore | 6++++++
A.pytest.ini | 4++++
ADockerfile | 9+++++++++
Aenv | 10++++++++++
Arequirements.txt | 5+++++
Awaldhart_availability.py | 214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Awaldhart_availability_test.py | 257+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 505 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,6 @@ +.env +.coverage +cookies.txt +**.swp +.venv +__pycache__ diff --git a/.pytest.ini b/.pytest.ini @@ -0,0 +1,4 @@ +[pytest] +# https://docs.python.org/3/library/logging.html#levels +log_cli = true +log_cli_level = 20 diff --git a/Dockerfile b/Dockerfile @@ -0,0 +1,9 @@ +FROM docker.io/python:3.12 + +COPY waldhart_availability.py /app/ +COPY requirements.txt /app/ + +WORKDIR /app/ +RUN pip install -r requirements.txt + +CMD ["./waldhart_availability.py"] diff --git a/env b/env @@ -0,0 +1,10 @@ +export WALDHART_URL= +export WALDHART_USERNAME= +export WALDHART_PASSWORD= +export CALDAV_URL= +export CALDAV_CALENDAR_NAME= +export CALDAV_USERNAME= +export CALDAV_PASSWORD= +export SEASON=2024 # December - March next year +#export PYTHON_CALDAV_DEBUGMODE=DEBUG +#export LOGLEVEL=DEBUG diff --git a/requirements.txt b/requirements.txt @@ -0,0 +1,5 @@ +pytest +coverage +requests +requests_mock +caldav diff --git a/waldhart_availability.py b/waldhart_availability.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python + +import logging +import secrets +import os +import requests +import caldav +from datetime import datetime +from enum import Enum + +class Duration(Enum): + NV = 0 # not available, not skiing + AM = 1 # skiing at forenoon (08:00 - 12:00) + PM = 2 # skiing at afternoon (12:00 - 16:00) + ALLDAY = 3 # skiing all day + CALL = 4 # on-call, skiing when required + +class WaldhartAvailability: + """ + Read availability data from Waldhart software + """ + + def __init__(self, **kwargs): + """ + Init CalDAV client + """ + + # setup logger + self.logger = logging.getLogger(__name__) + LOGLEVEL = os.environ.get('LOGLEVEL', 'INFO').upper() + logging.basicConfig(level=LOGLEVEL, format="%(asctime)s %(message)s") + + # read input keyword arguments + if "waldhart_url" in kwargs: + self.waldhart_url = kwargs["waldhart_url"] + elif "WALDHART_URL" in os.environ: + self.waldhart_url = os.environ["WALDHART_URL"] + else: + raise NameError("WALDHART_URL undefined") + + if "caldav_calendar_name" in kwargs: + self.caldav_calendar_name = kwargs["caldav_calendar_name"] + elif "CALDAV_CALENDAR_NAME" in os.environ: + self.caldav_calendar_name = os.environ["CALDAV_CALENDAR_NAME"] + else: + raise NameError("WALDHART_CALENDAR_NAME undefined") + + if "waldhart_username" in kwargs: + self.waldhart_username = kwargs["waldhart_username"] + elif "WALDHART_USERNAME" in os.environ: + self.waldhart_username = os.environ["WALDHART_USERNAME"] + else: + raise NameError("WALDHART_USERNAME undefined") + + if "waldhart_password" in kwargs: + self.waldhart_password = kwargs["waldhart_password"] + elif "WALDHART_PASSWORD" in os.environ: + self.waldhart_password = os.environ["WALDHART_PASSWORD"] + else: + raise NameError("WALDHART_PASSWORD undefined") + + if "caldav_url" in kwargs: + self.caldav_url = kwargs["caldav_url"] + elif "CALDAV_URL" in os.environ: + self.caldav_url = os.environ["CALDAV_URL"] + else: + raise NameError("CALDAV_URL undefined") + + if "caldav_username" in kwargs: + self.caldav_username = kwargs["caldav_username"] + elif "CALDAV_USERNAME" in os.environ: + self.caldav_username = os.environ["CALDAV_USERNAME"] + else: + raise NameError("CALDAV_USERNAME undefined") + + if "caldav_password" in kwargs: + self.caldav_password = kwargs["caldav_password"] + elif "CALDAV_PASSWORD" in os.environ: + self.caldav_password = os.environ["CALDAV_PASSWORD"] + else: + raise NameError("CALDAV_PASSWORD undefined") + + if "season" in kwargs: + self.season = int(kwargs["season"]) + elif "SEASON" in os.environ: + self.season = int(os.environ["SEASON"]) + else: + raise NameError("SEASON undefined") + + # DAVClient + # * https://caldav.readthedocs.io/en/latest/caldav/davclient.html + # * https://github.com/python-caldav/caldav/blob/master/examples/basic_usage_examples.py + self.dav_client = caldav.DAVClient(url=self.caldav_url, + username=self.caldav_username, + password=self.caldav_password) + + self.logger.info(f"Connected to CalDAV API {self.caldav_url}") + + def login(self): + """ + Login to Waldhart session. + + Returns true when login was successful. + """ + + # set random session id + self.session = requests.Session() + self.session.cookies.set("session_id", secrets.token_hex(nbytes=20)) + + payload = { + "username": f"{self.waldhart_username}", + "password": f"{self.waldhart_password}" + } + response = self.session.post(f"{self.waldhart_url}/login", + data=payload) + + return response.status_code == 200 + + def read_availability(self, year: int = 2024, month: int = 12): + """ + Read availability data from Waldhart software. + + Required arguments are year and month. + """ + + self.logger.info(f"Reading availability for year '{year}' and month '{month}'") + + resp = self.session.get(f"{self.waldhart_url}/myavailability/myavailability_month/get_availability_json?year={year}&month={month}") + self.logger.debug(f"Received json response {resp.content}") + return resp.json() + + def update_availability(self, year: int = 2024, month: int = 12, day: int = 1, duration: Duration = Duration.NV): + principal = self.dav_client.principal() + calendars = principal.calendars() + self.logger.debug(f"Calendars: {calendars}") + calendar = [c for c in calendars if c.name == self.caldav_calendar_name] + + if len(calendar) <= 0: + raise Exception(f"Calendar with name {self.caldav_calendar_name} does not exist") + + self.logger.info(f"Selected calendar: {calendar[0]}") + + events = calendar[0].search( + start=datetime(year, month, day, 6), + end=datetime(year, month, day, 18), + event=True, + expand=False, # ignore recurrences + ) + + self.logger.debug(f"Found events: {events}") + + # remove events + for e in events: + e.delete() + + # don't create a blocker when not available for skiing or on-call + if duration in [Duration.NV, Duration.CALL]: + self.logger.debug(f"Not available (NV) or on-call, not creating any blocker") + return + + # create new event from Waldhart availability when skiing + start_time = 8 if duration == Duration.AM or duration == Duration.ALLDAY else 12 + end_time = 16 if duration == Duration.PM or duration == Duration.ALLDAY else 12 + e = calendar[0].save_event( + dtstart=datetime(year, month, day, start_time), + dtend=datetime(year, month, day, end_time), + summary="Skiing", + ) + + self.logger.debug(f"Created event: {e}") + + def duration_type(self, entry: str) -> Duration: + """ + Define duration type of a Waldhart availability entry. + """ + + self.logger.debug(f"Availability entry: {entry}") + + if entry["not_available"]: + return Duration.NV + elif entry["on_call"]: + return Duration.CALL + elif entry["pm"] and not entry["am"]: + return Duration.PM + elif entry["am"] and not entry["pm"]: + return Duration.AM + elif entry["pm"] and entry["am"]: + return Duration.ALLDAY + else: + return None + + +def enumerate_and_update(availability, year, month): + for i, key in enumerate(availability): + duration = waldhart.duration_type(availability[key]) + if duration == None: + raise Exception("Cannot determine availability") + waldhart.update_availability(year, month, int(key), duration) + +if __name__== "__main__": + waldhart = WaldhartAvailability() + waldhart.login() + + # read availability in current season + dec = waldhart.read_availability(waldhart.season, 12) + jan = waldhart.read_availability(waldhart.season + 1, 1) + feb = waldhart.read_availability(waldhart.season + 1, 2) + mar = waldhart.read_availability(waldhart.season + 1, 3) + + # update availability December - March + enumerate_and_update(dec, waldhart.season, 12) + enumerate_and_update(jan, waldhart.season + 1, 1) + enumerate_and_update(feb, waldhart.season + 1, 2) + enumerate_and_update(mar, waldhart.season + 1, 3) diff --git a/waldhart_availability_test.py b/waldhart_availability_test.py @@ -0,0 +1,257 @@ +import logging +import pytest +import requests +import requests_mock +from datetime import datetime + +from waldhart_availability import Duration +from waldhart_availability import WaldhartAvailability + +logger = logging.getLogger(__name__) + +class TestWaldhartAvailability: + """ + Test WaldhartAvailability functionality. + """ + + @requests_mock.Mocker(kw="mock") + def test_login(self, **kwargs): + kwargs['mock'].post("http://waldhart/login", text="OK") + kwargs['mock'].get("http://caldav", text="dav") + + waldhart = WaldhartAvailability(waldhart_url="http://waldhart", + waldhart_username="test", + waldhart_password="123", + caldav_url="http://caldav", + caldav_username="testc", + caldav_password="1234") + r = waldhart.login() + assert r + + @requests_mock.Mocker(kw="mock") + def test_read_availability(self, **kwargs): + kwargs['mock'].post("http://waldhart/login", text="OK") + kwargs['mock'].get("http://waldhart/myavailability/myavailability_month/get_availability_json?year=2025&month=1", text=JAN_3) + kwargs['mock'].get("http://caldav", text="dav") + + waldhart = WaldhartAvailability(waldhart_url="http://waldhart", + waldhart_username="test", + waldhart_password="123", + caldav_url="http://caldav", + caldav_username="testc", + caldav_password="1234") + waldhart.login() + + day = waldhart.read_availability(2025, 1)["3"] + logger.info(day) + + @requests_mock.Mocker(kw="mock") + def test_duration(self, **kwargs): + kwargs['mock'].post("http://waldhart/login", text="OK") + kwargs['mock'].get("http://caldav", text="dav") + + waldhart = WaldhartAvailability(waldhart_url="http://waldhart", + waldhart_username="test", + waldhart_password="123", + caldav_url="http://caldav", + caldav_username="testc", + caldav_password="1234") + waldhart.login() + + kwargs['mock'].get("http://waldhart/myavailability/myavailability_month/get_availability_json?year=2025&month=1", text=JAN_3) + day = waldhart.read_availability(2025, 1)["3"] + duration = waldhart.duration_type(day) + assert duration == Duration.ALLDAY + + kwargs['mock'].get("http://waldhart/myavailability/myavailability_month/get_availability_json?year=2025&month=1", text=JAN_6) + day = waldhart.read_availability(2025, 1)["6"] + duration = waldhart.duration_type(day) + assert duration == Duration.AM + + kwargs['mock'].get("http://waldhart/myavailability/myavailability_month/get_availability_json?year=2025&month=1", text=JAN_20) + day = waldhart.read_availability(2025, 1)["20"] + duration = waldhart.duration_type(day) + assert duration == Duration.PM + + kwargs['mock'].get("http://waldhart/myavailability/myavailability_month/get_availability_json?year=2025&month=1", text=JAN_26) + day = waldhart.read_availability(2025, 1)["26"] + duration = waldhart.duration_type(day) + assert duration == Duration.NV + + @requests_mock.Mocker(kw="mock") + def test_update_availability(self, **kwargs): + kwargs['mock'].post("http://waldhart/login", text="OK") + kwargs['mock'].get("mock://caldav", text="dav") + + # TODO: mock PROPFIND requests + #session = requests.Session() + #adapter = requests_mock.Adapter() + #adapter.register_uri("PROPFIND", "mock://caldav", text="dav") + #session.mount("mock://", adapter) + + waldhart = WaldhartAvailability(waldhart_url="http://waldhart", + waldhart_username="test", + waldhart_password="123", + caldav_url="mock://caldav", + caldav_username="testc", + caldav_password="1234") + waldhart.login() + + # expect ALLDAY + kwargs['mock'].get("http://waldhart/myavailability/myavailability_month/get_availability_json?year=2025&month=1", text=JAN_3) + day = waldhart.read_availability(2025, 1)["3"] + duration = waldhart.duration_type(day) + waldhart.update_availability(2025, 1, 3, duration) + + # expect forenoon (AM) + kwargs['mock'].get("http://waldhart/myavailability/myavailability_month/get_availability_json?year=2025&month=1", text=JAN_6) + day = waldhart.read_availability(2025, 1)["6"] + duration = waldhart.duration_type(day) + waldhart.update_availability(2025, 1, 6, duration) + + # expect afternoon (PM) + kwargs['mock'].get("http://waldhart/myavailability/myavailability_month/get_availability_json?year=2025&month=1", text=JAN_20) + day = waldhart.read_availability(2025, 1)["20"] + duration = waldhart.duration_type(day) + waldhart.update_availability(2025, 1, 20, duration) + + # expect not available (NV) + kwargs['mock'].get("http://waldhart/myavailability/myavailability_month/get_availability_json?year=2025&month=1", text=JAN_26) + day = waldhart.read_availability(2025, 1)["26"] + duration = waldhart.duration_type(day) + waldhart.update_availability(2025, 1, 26, duration) + + +# Waldhart json response mock + +JAN_3 = """ +{ + "3": { + "has_course_pm": false, + "has_course_md": false, + "pm": true, + "on_call": false, + "ready_3": null, + "ready_1": null, + "to_2": null, + "has_course_am": false, + "md": true, + "group_type": null, + "to_1": null, + "vacation": false, + "from_2": null, + "has_course_all_day": false, + "am": true, + "ready_2": null, + "from_3": null, + "ready_state": 1, + "from_1": null, + "ill": false, + "courses": null, + "show_hatching": true, + "not_available": false, + "group_type_2": null, + "to_3": null, + "has_course": false + } +} +""" + +JAN_6 = """ +{ + "6": { + "has_course_pm": false, + "has_course_md": false, + "pm": false, + "on_call": false, + "ready_3": 0, + "ready_1": 1, + "to_2": 840, + "has_course_am": false, + "md": false, + "group_type": null, + "to_1": 720, + "vacation": false, + "from_2": 720, + "has_course_all_day": false, + "am": true, + "ready_2": 0, + "from_3": 720, + "ready_state": 5, + "from_1": 480, + "ill": false, + "courses": null, + "show_hatching": true, + "not_available": false, + "group_type_2": null, + "to_3": 960, + "has_course": false + } +} +""" + +JAN_20 = """ +{ + "20": { + "has_course_pm": false, + "has_course_md": false, + "pm": true, + "on_call": false, + "ready_3": 1, + "ready_1": 0, + "to_2": 840, + "has_course_am": false, + "md": false, + "group_type": null, + "to_1": 720, + "vacation": false, + "from_2": 720, + "has_course_all_day": false, + "am": false, + "ready_2": 0, + "from_3": 720, + "ready_state": 5, + "from_1": 480, + "ill": false, + "courses": null, + "show_hatching": true, + "not_available": false, + "group_type_2": null, + "to_3": 960, + "has_course": false + } +} +""" + +JAN_26 = """ +{ + "26": { + "has_course_pm": false, + "has_course_md": false, + "pm": false, + "on_call": false, + "ready_3": null, + "ready_1": null, + "to_2": null, + "has_course_am": false, + "md": false, + "group_type": null, + "to_1": null, + "vacation": false, + "from_2": null, + "has_course_all_day": false, + "am": false, + "ready_2": null, + "from_3": null, + "ready_state": 0, + "from_1": null, + "ill": false, + "courses": null, + "show_hatching": true, + "not_available": true, + "group_type_2": null, + "to_3": null, + "has_course": false + } +} +"""