|
| 1 | +"""Checks child permissions via the Allow2 Service API. |
| 2 | +
|
| 3 | +Results are cached per user for a configurable TTL to avoid |
| 4 | +excessive API calls during a single page load or request cycle. |
| 5 | +""" |
| 6 | + |
| 7 | +from __future__ import annotations |
| 8 | + |
| 9 | +import json |
| 10 | +from typing import Any, Dict, List, Optional, Union |
| 11 | + |
| 12 | +from allow2_service.cache_interface import CacheInterface |
| 13 | +from allow2_service.exceptions.api_error import ApiError |
| 14 | +from allow2_service.exceptions.unpaired import UnpairedError |
| 15 | +from allow2_service.http_response import HttpResponse |
| 16 | +from allow2_service.models.check_result import CheckResult |
| 17 | + |
| 18 | + |
| 19 | +class PermissionChecker: |
| 20 | + """Checks child permissions via the Allow2 Service API.""" |
| 21 | + |
| 22 | + DEFAULT_CACHE_TTL = 60 |
| 23 | + |
| 24 | + def __init__( |
| 25 | + self, |
| 26 | + http_client: Any, |
| 27 | + cache: CacheInterface, |
| 28 | + service_host: str, |
| 29 | + cache_ttl: int = DEFAULT_CACHE_TTL, |
| 30 | + ) -> None: |
| 31 | + self._http_client = http_client |
| 32 | + self._cache = cache |
| 33 | + self._service_host = service_host |
| 34 | + self._cache_ttl = cache_ttl |
| 35 | + |
| 36 | + def check( |
| 37 | + self, |
| 38 | + access_token: str, |
| 39 | + user_id: str, |
| 40 | + activities: List[Any], |
| 41 | + timezone: Optional[str] = None, |
| 42 | + log: bool = True, |
| 43 | + use_cache: bool = True, |
| 44 | + ) -> CheckResult: |
| 45 | + """Check permissions for a user's linked child account. |
| 46 | +
|
| 47 | + Activities can be specified in several formats: |
| 48 | +
|
| 49 | + 1. List of dicts (Allow2 API format): |
| 50 | + ``[{"id": 1, "log": True}, {"id": 3, "log": True}]`` |
| 51 | +
|
| 52 | + 2. Simple list of activity IDs (auto-expanded with log=True): |
| 53 | + ``[1, 3, 8]`` |
| 54 | +
|
| 55 | + 3. Legacy dict format: |
| 56 | + ``{1: 1, 3: 1, 8: 1}`` |
| 57 | +
|
| 58 | + Args: |
| 59 | + access_token: Valid OAuth2 access token. |
| 60 | + user_id: The integrating application's user ID (for cache keying). |
| 61 | + activities: Activity IDs to check (see format options above). |
| 62 | + timezone: IANA timezone (e.g., "Australia/Brisbane"). |
| 63 | + log: Whether to log usage (default True). |
| 64 | + use_cache: Whether to use cached results (default True). |
| 65 | +
|
| 66 | + Returns: |
| 67 | + The permission check result. |
| 68 | +
|
| 69 | + Raises: |
| 70 | + UnpairedError: If the API returns 401/403 (account unlinked). |
| 71 | + ApiError: On other API failures. |
| 72 | + """ |
| 73 | + activities = self._normalize_activities(activities) |
| 74 | + |
| 75 | + # Check cache first |
| 76 | + if use_cache: |
| 77 | + cache_key = self._build_cache_key(user_id, activities) |
| 78 | + cached = self._cache.get(cache_key) |
| 79 | + |
| 80 | + if cached is not None: |
| 81 | + try: |
| 82 | + data = json.loads(cached) |
| 83 | + return CheckResult.from_api_response(data) |
| 84 | + except (json.JSONDecodeError, ValueError): |
| 85 | + self._cache.delete(cache_key) |
| 86 | + |
| 87 | + payload: Dict[str, Any] = { |
| 88 | + "access_token": access_token, |
| 89 | + "activities": activities, |
| 90 | + "log": log, |
| 91 | + } |
| 92 | + |
| 93 | + if timezone is not None: |
| 94 | + payload["tz"] = timezone |
| 95 | + |
| 96 | + response: HttpResponse = self._http_client.post( |
| 97 | + self._service_host + "/serviceapi/check", |
| 98 | + payload, |
| 99 | + ) |
| 100 | + |
| 101 | + # 401/403 = unpaired |
| 102 | + if response.is_unauthorized(): |
| 103 | + raise UnpairedError(user_id) |
| 104 | + |
| 105 | + if not response.is_success(): |
| 106 | + body = None |
| 107 | + try: |
| 108 | + body = response.json() |
| 109 | + except (json.JSONDecodeError, ValueError): |
| 110 | + pass |
| 111 | + raise ApiError( |
| 112 | + message="Permission check failed: HTTP %d" % response.status_code, |
| 113 | + http_status_code=response.status_code, |
| 114 | + response_body=body, |
| 115 | + ) |
| 116 | + |
| 117 | + data = response.json() |
| 118 | + result = CheckResult.from_api_response(data) |
| 119 | + |
| 120 | + # Cache the raw response |
| 121 | + if use_cache: |
| 122 | + cache_key = self._build_cache_key(user_id, activities) |
| 123 | + self._cache.set(cache_key, json.dumps(data), self._cache_ttl) |
| 124 | + |
| 125 | + return result |
| 126 | + |
| 127 | + def is_allowed( |
| 128 | + self, |
| 129 | + access_token: str, |
| 130 | + user_id: str, |
| 131 | + activity_ids: List[int], |
| 132 | + timezone: Optional[str] = None, |
| 133 | + ) -> bool: |
| 134 | + """Convenience method: check if specific activities are all allowed. |
| 135 | +
|
| 136 | + Args: |
| 137 | + access_token: Valid OAuth2 access token. |
| 138 | + user_id: The integrating application's user ID. |
| 139 | + activity_ids: Activity IDs to check. |
| 140 | + timezone: IANA timezone. |
| 141 | +
|
| 142 | + Returns: |
| 143 | + True if all specified activities are allowed. |
| 144 | +
|
| 145 | + Raises: |
| 146 | + UnpairedError: If the API returns 401/403. |
| 147 | + ApiError: On other API failures. |
| 148 | + """ |
| 149 | + result = self.check(access_token, user_id, activity_ids, timezone) |
| 150 | + |
| 151 | + for activity in result.activities: |
| 152 | + if not activity.allowed: |
| 153 | + return False |
| 154 | + |
| 155 | + return True |
| 156 | + |
| 157 | + def invalidate_cache(self, user_id: str, activities: List[Any]) -> None: |
| 158 | + """Invalidate the cached check result for a user. |
| 159 | +
|
| 160 | + Args: |
| 161 | + user_id: The integrating application's user ID. |
| 162 | + activities: The same activities list used in check(). |
| 163 | + """ |
| 164 | + cache_key = self._build_cache_key(user_id, activities) |
| 165 | + self._cache.delete(cache_key) |
| 166 | + |
| 167 | + @staticmethod |
| 168 | + def _normalize_activities( |
| 169 | + activities: Union[List[Any], Dict[int, Any]], |
| 170 | + ) -> List[Dict[str, Any]]: |
| 171 | + """Normalize activities into the standard list-of-dicts format. |
| 172 | +
|
| 173 | + Accepts: |
| 174 | + - ``[{"id": 1, "log": True}, ...]`` -- passed through as-is |
| 175 | + - ``[1, 3, 8]`` -- expanded to ``[{"id": 1, "log": True}, ...]`` |
| 176 | + - ``{1: 1, 3: 1}`` -- legacy dict format, converted |
| 177 | +
|
| 178 | + Args: |
| 179 | + activities: Activities in any supported format. |
| 180 | +
|
| 181 | + Returns: |
| 182 | + Normalized list of activity dicts. |
| 183 | + """ |
| 184 | + # Handle dict input (legacy associative format) |
| 185 | + if isinstance(activities, dict): |
| 186 | + normalized: List[Dict[str, Any]] = [] |
| 187 | + for act_id, flag in activities.items(): |
| 188 | + normalized.append({"id": int(act_id), "log": bool(flag)}) |
| 189 | + return normalized |
| 190 | + |
| 191 | + if not activities: |
| 192 | + return [] |
| 193 | + |
| 194 | + first_value = activities[0] |
| 195 | + |
| 196 | + # Format 1: list of dicts [{"id": 1, "log": True}, ...] |
| 197 | + if isinstance(first_value, dict) and "id" in first_value: |
| 198 | + return list(activities) |
| 199 | + |
| 200 | + # Format 2: simple list of IDs [1, 3, 8] |
| 201 | + normalized = [] |
| 202 | + for act_id in activities: |
| 203 | + normalized.append({"id": int(act_id), "log": True}) |
| 204 | + return normalized |
| 205 | + |
| 206 | + @staticmethod |
| 207 | + def _build_cache_key(user_id: str, activities: List[Any]) -> str: |
| 208 | + """Build a deterministic cache key for a user + activities combination. |
| 209 | +
|
| 210 | + Args: |
| 211 | + user_id: The user ID. |
| 212 | + activities: Normalized activities list. |
| 213 | +
|
| 214 | + Returns: |
| 215 | + A cache key string. |
| 216 | + """ |
| 217 | + ids = [] |
| 218 | + for a in activities: |
| 219 | + if isinstance(a, int): |
| 220 | + ids.append(a) |
| 221 | + else: |
| 222 | + ids.append(a["id"]) |
| 223 | + ids.sort() |
| 224 | + act_suffix = "_".join(str(i) for i in ids) |
| 225 | + |
| 226 | + return "allow2_check_%s_%s" % (user_id, act_suffix) |
0 commit comments