Skip to content

Commit 50ab254

Browse files
Allow2CEOruvnet
andcommitted
Add core SDK modules
Allow2Client facade, OAuth2Manager, PermissionChecker, TokenStorageInterface, CacheInterface, HttpClient, and HttpResponse -- ported from PHP reference with matching API surface. Co-Authored-By: claude-flow <ruv@ruv.net>
1 parent 4c3b693 commit 50ab254

8 files changed

Lines changed: 1355 additions & 0 deletions

File tree

allow2_service/__init__.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Allow2 Parental Freedom Service SDK for Python.
2+
3+
Server-side integration for websites and web applications with user accounts.
4+
Handles OAuth2 pairing, permission checking, all 3 request types,
5+
voice codes (offline approval), and feedback via the Allow2 Service API.
6+
7+
See https://developer.allow2.com for documentation.
8+
"""
9+
10+
from allow2_service.cache_interface import CacheInterface
11+
from allow2_service.client import Allow2Client
12+
from allow2_service.http_client import HttpClient
13+
from allow2_service.http_response import HttpResponse
14+
from allow2_service.token_storage import TokenStorageInterface
15+
16+
from allow2_service.exceptions import (
17+
Allow2Error,
18+
ApiError,
19+
TokenExpiredError,
20+
UnpairedError,
21+
)
22+
23+
from allow2_service.models import (
24+
Activity,
25+
CheckResult,
26+
DayType,
27+
FeedbackCategory,
28+
OAuthTokens,
29+
RequestResult,
30+
RequestType,
31+
VoiceCodePair,
32+
)
33+
34+
from allow2_service.voice_code import VoiceCode
35+
36+
__version__ = "2.0.0a1"
37+
38+
__all__ = [
39+
# Client
40+
"Allow2Client",
41+
# Interfaces
42+
"CacheInterface",
43+
"TokenStorageInterface",
44+
# HTTP
45+
"HttpClient",
46+
"HttpResponse",
47+
# Exceptions
48+
"Allow2Error",
49+
"ApiError",
50+
"TokenExpiredError",
51+
"UnpairedError",
52+
# Models
53+
"Activity",
54+
"CheckResult",
55+
"DayType",
56+
"FeedbackCategory",
57+
"OAuthTokens",
58+
"RequestResult",
59+
"RequestType",
60+
"VoiceCodePair",
61+
# Voice Code
62+
"VoiceCode",
63+
]

allow2_service/cache_interface.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Interface for caching permission check results."""
2+
3+
from __future__ import annotations
4+
5+
from abc import ABC, abstractmethod
6+
from typing import Optional
7+
8+
9+
class CacheInterface(ABC):
10+
"""Interface for caching permission check results.
11+
12+
Simple key-value cache with TTL support. Values are serialized strings.
13+
"""
14+
15+
@abstractmethod
16+
def get(self, key: str) -> Optional[str]:
17+
"""Retrieve a cached value by key.
18+
19+
Args:
20+
key: Cache key.
21+
22+
Returns:
23+
The cached value, or None if not found or expired.
24+
"""
25+
26+
@abstractmethod
27+
def set(self, key: str, value: str, ttl: int = 60) -> None:
28+
"""Store a value in cache.
29+
30+
Args:
31+
key: Cache key.
32+
value: Value to cache.
33+
ttl: Time-to-live in seconds (default 60).
34+
"""
35+
36+
@abstractmethod
37+
def delete(self, key: str) -> None:
38+
"""Delete a cached value.
39+
40+
Args:
41+
key: Cache key.
42+
"""

allow2_service/checker.py

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
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

Comments
 (0)