Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion package/src/tue_api_wrapper/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = [
Expand All @@ -42,9 +43,13 @@
"IliasForumTopic",
"IliasRootPage",
"MoodleClient",
"CacheConfig",
"PortalService",
"PortalCache",
"TimetableResult",
"TuebingenAuthenticatedClient",
"TuebingenPublicClient",
"UniversityCredentials",
"clear_portal_cache",
"configure_portal_cache",
]
4 changes: 3 additions & 1 deletion package/src/tue_api_wrapper/api_routes_alma_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 6 additions & 2 deletions package/src/tue_api_wrapper/api_routes_edit_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
4 changes: 3 additions & 1 deletion package/src/tue_api_wrapper/api_routes_mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
120 changes: 120 additions & 0 deletions package/src/tue_api_wrapper/portal_cache.py
Original file line number Diff line number Diff line change
@@ -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}"
79 changes: 70 additions & 9 deletions package/src/tue_api_wrapper/portal_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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",
]
Loading
Loading