diff --git a/package/src/tue_api_wrapper/__init__.py b/package/src/tue_api_wrapper/__init__.py index d57478c..1900c9a 100644 --- a/package/src/tue_api_wrapper/__init__.py +++ b/package/src/tue_api_wrapper/__init__.py @@ -19,7 +19,8 @@ IliasRootPage, TimetableResult, ) -from .portal_service import PortalService +from .portal_cache import CacheConfig, PortalCache +from .portal_service import PortalService, clear_portal_cache, configure_portal_cache from .sdk import TuebingenAuthenticatedClient, TuebingenPublicClient, UniversityCredentials __all__ = [ @@ -42,9 +43,13 @@ "IliasForumTopic", "IliasRootPage", "MoodleClient", + "CacheConfig", "PortalService", + "PortalCache", "TimetableResult", "TuebingenAuthenticatedClient", "TuebingenPublicClient", "UniversityCredentials", + "clear_portal_cache", + "configure_portal_cache", ] diff --git a/package/src/tue_api_wrapper/api_routes_alma_registration.py b/package/src/tue_api_wrapper/api_routes_alma_registration.py index 95f5ad5..ce759d7 100644 --- a/package/src/tue_api_wrapper/api_routes_alma_registration.py +++ b/package/src/tue_api_wrapper/api_routes_alma_registration.py @@ -45,6 +45,8 @@ def alma_course_registration( planelement_id: str = "", ) -> dict[str, object]: try: - return serialize(register_for_course(_alma_client(), url, planelement_id=planelement_id or None)) + result = serialize(register_for_course(_alma_client(), url, planelement_id=planelement_id or None)) + portal_service.invalidate_portal_cache() + return result except AlmaError as error: raise _translate_error(error) from error diff --git a/package/src/tue_api_wrapper/api_routes_edit_actions.py b/package/src/tue_api_wrapper/api_routes_edit_actions.py index 7f62ae9..d2e0452 100644 --- a/package/src/tue_api_wrapper/api_routes_edit_actions.py +++ b/package/src/tue_api_wrapper/api_routes_edit_actions.py @@ -74,7 +74,9 @@ def alma_studyservice_report(trigger_name: str = "", term_id: str = "") -> Respo @router.post("/api/ilias/favorites") def ilias_add_favorite(url: str = Query(..., min_length=1)) -> dict[str, object]: try: - return serialize(add_to_favorites(portal_service._ilias_client(), url=url)) + result = serialize(add_to_favorites(portal_service._ilias_client(), url=url)) + portal_service.invalidate_portal_cache() + return result except AlmaError as error: raise _translate_error(error) from error @@ -93,12 +95,14 @@ def ilias_waitlist_join( accept_agreement: bool = False, ) -> dict[str, object]: try: - return serialize( + result = serialize( join_waitlist( portal_service._ilias_client(), url=url, accept_agreement=accept_agreement, ) ) + portal_service.invalidate_portal_cache() + return result except AlmaError as error: raise _translate_error(error) from error diff --git a/package/src/tue_api_wrapper/api_routes_mail.py b/package/src/tue_api_wrapper/api_routes_mail.py index ca81a47..8e99d8d 100644 --- a/package/src/tue_api_wrapper/api_routes_mail.py +++ b/package/src/tue_api_wrapper/api_routes_mail.py @@ -79,8 +79,10 @@ def mail_move_message(uid: str, request: MoveMessageRequest) -> dict[str, object try: client = _mail_client() try: - return serialize(client.move_message(uid, mailbox=request.mailbox, destination=request.destination)) + result = serialize(client.move_message(uid, mailbox=request.mailbox, destination=request.destination)) finally: client.close() + portal_service.invalidate_portal_cache() + return result except AlmaError as error: raise _translate_error(error) from error diff --git a/package/src/tue_api_wrapper/portal_cache.py b/package/src/tue_api_wrapper/portal_cache.py new file mode 100644 index 0000000..822309d --- /dev/null +++ b/package/src/tue_api_wrapper/portal_cache.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from dataclasses import dataclass +from hashlib import sha256 +from threading import RLock +import time +from typing import Callable, TypeVar + +T = TypeVar("T") +CacheKey = tuple[object, ...] + + +@dataclass(frozen=True, slots=True) +class CacheConfig: + enabled: bool = False + ttl_seconds: float = 60.0 + + def __post_init__(self) -> None: + if self.ttl_seconds <= 0: + raise ValueError("ttl_seconds must be positive") + + +@dataclass(slots=True) +class _CacheEntry: + expires_at: float + value: object + + +class PortalCache: + def __init__( + self, + config: CacheConfig | None = None, + *, + clock: Callable[[], float] = time.monotonic, + ) -> None: + self._config = config or CacheConfig() + self._clock = clock + self._entries: dict[CacheKey, _CacheEntry] = {} + self._lock = RLock() + + @property + def config(self) -> CacheConfig: + return self._config + + def configure( + self, + *, + enabled: bool | None = None, + ttl_seconds: float | None = None, + ) -> CacheConfig: + next_config = CacheConfig( + enabled=self._config.enabled if enabled is None else enabled, + ttl_seconds=self._config.ttl_seconds if ttl_seconds is None else ttl_seconds, + ) + with self._lock: + self._config = next_config + if not next_config.enabled: + self._entries.clear() + return next_config + + def clear(self) -> None: + with self._lock: + self._entries.clear() + + def invalidate(self, *, prefix: CacheKey | None = None) -> None: + with self._lock: + if prefix is None: + self._entries.clear() + return + for key in [existing for existing in self._entries if existing[: len(prefix)] == prefix]: + self._entries.pop(key, None) + + def get_or_load(self, key: CacheKey, loader: Callable[[], T]) -> T: + if not self._config.enabled: + return loader() + + cached = self._get(key) + if cached is not None: + return cached + + value = loader() + with self._lock: + self._entries[key] = _CacheEntry( + expires_at=self._clock() + self._config.ttl_seconds, + value=value, + ) + return value + + def _get(self, key: CacheKey) -> T | None: + with self._lock: + entry = self._entries.get(key) + if entry is None: + return None + if entry.expires_at <= self._clock(): + self._entries.pop(key, None) + return None + return entry.value # type: ignore[return-value] + + +_DEFAULT_PORTAL_CACHE = PortalCache() + + +def default_portal_cache() -> PortalCache: + return _DEFAULT_PORTAL_CACHE + + +def configure_portal_cache(*, enabled: bool, ttl_seconds: float = 60.0) -> CacheConfig: + return _DEFAULT_PORTAL_CACHE.configure(enabled=enabled, ttl_seconds=ttl_seconds) + + +def clear_portal_cache() -> None: + _DEFAULT_PORTAL_CACHE.clear() + + +def credential_scope(namespace: str, *parts: str | None) -> str: + payload = "\0".join(part or "" for part in parts) + if not payload: + return f"{namespace}:anonymous" + digest = sha256(payload.encode("utf-8")).hexdigest()[:16] + return f"{namespace}:{digest}" diff --git a/package/src/tue_api_wrapper/portal_service.py b/package/src/tue_api_wrapper/portal_service.py index 3d0fc3a..b2f67b0 100644 --- a/package/src/tue_api_wrapper/portal_service.py +++ b/package/src/tue_api_wrapper/portal_service.py @@ -8,6 +8,13 @@ from .dashboard_builder import build_dashboard_payload from .ilias_client import IliasClient from .mail_client import MailClient +from .portal_cache import ( + PortalCache, + clear_portal_cache, + configure_portal_cache, + credential_scope, + default_portal_cache, +) from .portal_common import DEFAULT_DASHBOARD_TERM, normalize_dashboard_term, serialize from .portal_search import ( build_dashboard_search_index, @@ -17,6 +24,16 @@ class PortalService: + """Authenticated portal facade with optional process-local in-memory caching. + + The built-in cache is intentionally small and local-only. It helps a local + sidecar avoid duplicate upstream reads, but app-level deployments that need + broader caching guarantees should own their own caching layer. + """ + + def __init__(self, cache: PortalCache | None = None) -> None: + self._cache = cache or default_portal_cache() + def _alma_client(self) -> AlmaClient: username, password = read_uni_credentials() if not username or not password: @@ -85,21 +102,65 @@ def build_dashboard( limit: int = 8, include_course_assignments: bool = True, ) -> dict[str, Any]: - return build_dashboard_payload( - term_label=term_label, - limit=limit, - include_course_assignments=include_course_assignments, - load_alma_client=self._alma_client, - load_ilias_client=self._ilias_client, - load_mail_panel=self._mail_panel, + normalized_term = normalize_dashboard_term(term_label) + key = ( + "portal", + "dashboard", + *self._credential_scopes(), + normalized_term, + limit, + include_course_assignments, + ) + return self._cache.get_or_load( + key, + lambda: build_dashboard_payload( + term_label=normalized_term, + limit=limit, + include_course_assignments=include_course_assignments, + load_alma_client=self._alma_client, + load_ilias_client=self._ilias_client, + load_mail_panel=self._mail_panel, + ), ) def build_search_index(self, *, term_label: str = DEFAULT_DASHBOARD_TERM) -> list[dict[str, Any]]: - dashboard = self.build_dashboard(term_label=normalize_dashboard_term(term_label), limit=12) - return build_dashboard_search_index(dashboard) + normalized_term = normalize_dashboard_term(term_label) + key = ( + "portal", + "search_index", + *self._credential_scopes(), + normalized_term, + ) + return self._cache.get_or_load( + key, + lambda: build_dashboard_search_index( + self.build_dashboard(term_label=normalized_term, limit=12) + ), + ) def search(self, query: str, *, term_label: str = DEFAULT_DASHBOARD_TERM) -> list[dict[str, Any]]: return search_dashboard_index(query, self.build_search_index(term_label=term_label)) def fetch_item(self, item_id: str, *, term_label: str = DEFAULT_DASHBOARD_TERM) -> dict[str, Any]: return fetch_dashboard_index_item(item_id, self.build_search_index(term_label=term_label)) + + def invalidate_portal_cache(self) -> None: + self._cache.invalidate(prefix=("portal",)) + + def _credential_scopes(self) -> tuple[str, str]: + uni_username, uni_password = read_uni_credentials() + mail_username, mail_password = read_mail_credentials() + return ( + credential_scope("uni", uni_username, uni_password), + credential_scope("mail", mail_username, mail_password), + ) + + +__all__ = [ + "DEFAULT_DASHBOARD_TERM", + "PortalService", + "clear_portal_cache", + "configure_portal_cache", + "normalize_dashboard_term", + "serialize", +] diff --git a/package/tests/test_portal_cache.py b/package/tests/test_portal_cache.py new file mode 100644 index 0000000..51939b2 --- /dev/null +++ b/package/tests/test_portal_cache.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from tue_api_wrapper import api_routes_edit_actions, api_server +from tue_api_wrapper.portal_cache import CacheConfig, PortalCache +from tue_api_wrapper.portal_service import PortalService, clear_portal_cache, configure_portal_cache + + +class PortalCacheTests(unittest.TestCase): + def tearDown(self) -> None: + clear_portal_cache() + configure_portal_cache(enabled=False, ttl_seconds=60.0) + + def test_disabled_cache_keeps_loading(self) -> None: + service = PortalService(cache=PortalCache(CacheConfig(enabled=False, ttl_seconds=30.0))) + calls = {"count": 0} + + with patch("tue_api_wrapper.portal_service.read_uni_credentials", return_value=("student", "secret")), patch( + "tue_api_wrapper.portal_service.read_mail_credentials", + return_value=("student", "secret"), + ), patch( + "tue_api_wrapper.portal_service.build_dashboard_payload", + side_effect=lambda **_: self._dashboard(calls), + ): + first = service.build_dashboard(term_label="Sommer 2026") + second = service.build_dashboard(term_label="Sommer 2026") + + self.assertEqual(first["call"], 1) + self.assertEqual(second["call"], 2) + self.assertEqual(calls["count"], 2) + + def test_enabled_cache_reuses_dashboard_until_ttl_expires(self) -> None: + now = {"value": 100.0} + cache = PortalCache(CacheConfig(enabled=True, ttl_seconds=10.0), clock=lambda: now["value"]) + service = PortalService(cache=cache) + calls = {"count": 0} + + with patch("tue_api_wrapper.portal_service.read_uni_credentials", return_value=("student", "secret")), patch( + "tue_api_wrapper.portal_service.read_mail_credentials", + return_value=("student", "secret"), + ), patch( + "tue_api_wrapper.portal_service.build_dashboard_payload", + side_effect=lambda **_: self._dashboard(calls), + ): + first = service.build_dashboard(term_label="Sommer 2026") + second = service.build_dashboard(term_label="Sommer 2026") + now["value"] = 111.0 + third = service.build_dashboard(term_label="Sommer 2026") + + self.assertEqual(first["call"], 1) + self.assertEqual(second["call"], 1) + self.assertEqual(third["call"], 2) + self.assertEqual(calls["count"], 2) + + def test_cache_isolated_by_parameters_and_credentials(self) -> None: + service = PortalService(cache=PortalCache(CacheConfig(enabled=True, ttl_seconds=30.0))) + calls = {"count": 0} + credentials = {"uni": ("student-a", "secret-a"), "mail": ("student-a", "secret-a")} + + def fake_uni_credentials(): + return credentials["uni"] + + def fake_mail_credentials(): + return credentials["mail"] + + with patch("tue_api_wrapper.portal_service.read_uni_credentials", side_effect=fake_uni_credentials), patch( + "tue_api_wrapper.portal_service.read_mail_credentials", + side_effect=fake_mail_credentials, + ), patch( + "tue_api_wrapper.portal_service.build_dashboard_payload", + side_effect=lambda **_: self._dashboard(calls), + ): + service.build_dashboard(term_label="Sommer 2026") + service.build_dashboard(term_label="Winter 2025/26") + credentials["uni"] = ("student-b", "secret-b") + credentials["mail"] = ("student-b", "secret-b") + service.build_dashboard(term_label="Sommer 2026") + + self.assertEqual(calls["count"], 3) + + def test_mutation_route_invalidates_cached_dashboard(self) -> None: + calls = {"count": 0} + configure_portal_cache(enabled=True, ttl_seconds=60.0) + + with patch("tue_api_wrapper.portal_service.read_uni_credentials", return_value=("student", "secret")), patch( + "tue_api_wrapper.portal_service.read_mail_credentials", + return_value=("student", "secret"), + ), patch( + "tue_api_wrapper.portal_service.build_dashboard_payload", + side_effect=lambda **_: self._dashboard(calls), + ), patch.object( + api_routes_edit_actions.portal_service, + "_ilias_client", + return_value=object(), + ), patch( + "tue_api_wrapper.api_routes_edit_actions.add_to_favorites", + return_value={"status": "submitted"}, + ): + first = api_server.portal_service.build_dashboard(term_label="Sommer 2026") + second = api_server.portal_service.build_dashboard(term_label="Sommer 2026") + mutation = api_routes_edit_actions.ilias_add_favorite( + url="https://ovidius.uni-tuebingen.de/ilias.php?cmd=addToDesk", + ) + third = api_server.portal_service.build_dashboard(term_label="Sommer 2026") + + self.assertEqual(first["call"], 1) + self.assertEqual(second["call"], 1) + self.assertEqual(mutation["status"], "submitted") + self.assertEqual(third["call"], 2) + self.assertEqual(calls["count"], 2) + + @staticmethod + def _dashboard(calls: dict[str, int]) -> dict[str, object]: + calls["count"] += 1 + return {"call": calls["count"], "items": []} + + +if __name__ == "__main__": + unittest.main()