import os import time import logging import requests logger = logging.getLogger(__name__) POCKETBASE_URL = os.getenv('POCKETBASE_URL', 'http://pocketbase:8090') ADMIN_EMAIL = os.getenv('POCKETBASE_ADMIN_EMAIL') ADMIN_PASSWORD = os.getenv('POCKETBASE_ADMIN_PASSWORD') class PocketBaseError(Exception): """Raised when a PocketBase REST call fails after the request completes.""" pass class PocketBaseClient: """ Thin REST wrapper around a self-hosted PocketBase instance. This is the ONLY place in the codebase that knows PocketBase's HTTP API shape (auth endpoint, record CRUD paths, filter syntax). Every repository in repositories/ uses this client instead of calling `requests` directly, so a PocketBase version/API change is a one-file fix. Data flow: collection + operation + params → ensure valid superuser token (auto re-auth on expiry) → HTTP call to PocketBase REST API → JSON response → plain dict/list returned to the calling repository """ def __init__(self): self._token = None self._token_expires_at = 0 def _authenticate(self): """ Authenticates as a PocketBase superuser and caches the token. Data flow: POCKETBASE_ADMIN_EMAIL + PASSWORD → POST /api/collections/_superusers/auth-with-password → token cached in memory with a conservative TTL """ response = requests.post( f'{POCKETBASE_URL}/api/collections/_superusers/auth-with-password', json={'identity': ADMIN_EMAIL, 'password': ADMIN_PASSWORD}, timeout=10 ) if response.status_code != 200: raise PocketBaseError( f'PocketBase admin auth failed: {response.status_code} {response.text[:200]}' ) data = response.json() self._token = data['token'] # PocketBase admin tokens are long-lived (default 7 days) — refresh # an hour early so we never get caught by an in-flight 401. self._token_expires_at = time.time() + (6 * 24 * 3600) def _headers(self): # Re-authenticate lazily — only when there is no cached token or # it is past our conservative expiry estimate. if not self._token or time.time() >= self._token_expires_at: self._authenticate() return {'Authorization': self._token} def _request(self, method, path, **kwargs): """ Issues one authenticated request, retrying once on a 401 in case the cached token was revoked or expired earlier than estimated. Data flow: method + path + kwargs → authenticated request → on 401: force re-auth → retry once → non-2xx after retry: raise PocketBaseError → 2xx: return parsed JSON (or None for 204/empty bodies) """ url = f'{POCKETBASE_URL}{path}' response = requests.request(method, url, headers=self._headers(), timeout=15, **kwargs) if response.status_code == 401: self._token = None response = requests.request(method, url, headers=self._headers(), timeout=15, **kwargs) if response.status_code >= 400: raise PocketBaseError( f'{method} {path} failed: {response.status_code} {response.text[:300]}' ) if not response.content: return None return response.json() # ── CRUD ──────────────────────────────────────────────────────── def list(self, collection, filter_str=None, sort=None, page=1, per_page=50): """ Lists records in a collection, optionally filtered/sorted. Data flow: collection + filter/sort/pagination → GET /api/collections/{collection}/records → { items, page, totalItems, ... } → items list returned """ params = {'page': page, 'perPage': per_page} if filter_str: params['filter'] = filter_str if sort: params['sort'] = sort data = self._request('GET', f'/api/collections/{collection}/records', params=params) return data.get('items', []) if data else [] def get_first(self, collection, filter_str): """Returns the first record matching filter_str, or None if no match.""" items = self.list(collection, filter_str=filter_str, per_page=1) return items[0] if items else None def get_one(self, collection, record_id): """Returns a single record by id, or None if it does not exist.""" try: return self._request('GET', f'/api/collections/{collection}/records/{record_id}') except PocketBaseError as e: if '404' in str(e): return None raise def create(self, collection, data): """Creates a record and returns the created record (including its id).""" return self._request('POST', f'/api/collections/{collection}/records', json=data) def update(self, collection, record_id, data): """Patches a record and returns the updated record.""" return self._request('PATCH', f'/api/collections/{collection}/records/{record_id}', json=data) def delete(self, collection, record_id): """Deletes a record. Returns True on success.""" self._request('DELETE', f'/api/collections/{collection}/records/{record_id}') return True def auth_refresh(self, user_token): """ Validates a frontend-issued PocketBase user JWT and returns the associated auth record (email, id, etc.) — used by auth_service to resolve `tenant_id` from a request's Authorization header without the backend needing its own JWT-verification secret. Data flow: user_token (Bearer JWT from the Vue dashboard) → POST /api/collections/users/auth-refresh → { record: { email, id, ... } } → record dict returned, or None if the token is invalid/expired """ try: response = requests.post( f'{POCKETBASE_URL}/api/collections/users/auth-refresh', headers={'Authorization': f'Bearer {user_token}'}, timeout=10 ) except requests.RequestException as e: logger.error(f'PocketBase auth-refresh request failed: {str(e)}') return None if response.status_code != 200: return None return response.json().get('record') # Module-level singleton — every repository imports this same instance so # the auth token is cached once per process, not once per repository. pb_client = PocketBaseClient()