commit 28d24981f822f269a24921bcb76a5592164f9ae2 Author: Vithor Jaeger Date: Mon Sep 19 10:53:16 2022 -0400 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2fd2d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/.venv +/.vscode +/dist +/poetry.lock +/pyproject.toml +/tests \ No newline at end of file diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..e69de29 diff --git a/pocketbase/__init__.py b/pocketbase/__init__.py new file mode 100644 index 0000000..3c79a1d --- /dev/null +++ b/pocketbase/__init__.py @@ -0,0 +1,3 @@ +__version__ = "0.1.0" + +from .client import Client, ClientResponseError diff --git a/pocketbase/__pycache__/__init__.cpython-310.pyc b/pocketbase/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..ef3a2c7 Binary files /dev/null and b/pocketbase/__pycache__/__init__.cpython-310.pyc differ diff --git a/pocketbase/__pycache__/client.cpython-310.pyc b/pocketbase/__pycache__/client.cpython-310.pyc new file mode 100644 index 0000000..e4f39b2 Binary files /dev/null and b/pocketbase/__pycache__/client.cpython-310.pyc differ diff --git a/pocketbase/__pycache__/utils.cpython-310.pyc b/pocketbase/__pycache__/utils.cpython-310.pyc new file mode 100644 index 0000000..0c3705f Binary files /dev/null and b/pocketbase/__pycache__/utils.cpython-310.pyc differ diff --git a/pocketbase/client.py b/pocketbase/client.py new file mode 100644 index 0000000..7688f95 --- /dev/null +++ b/pocketbase/client.py @@ -0,0 +1,144 @@ +from typing import Any +from urllib.parse import urlencode + +import httpx + +from pocketbase.services.admins import Admins +from pocketbase.services.collections import Collections +from pocketbase.services.logs import Logs +from pocketbase.services.realtime import Realtime +from pocketbase.services.records import Records +from pocketbase.services.users import Users +from pocketbase.services.settings import Settings +from pocketbase.stores.base_auth_store import BaseAuthStore + +# from pocketbase.stores.local_auth_store import LocalAuthStore + + +class ClientResponseError(Exception): + url: str = "" + status: int = 0 + data: dict = {} + is_abort: bool = False + original_error: Any = None + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args) + self.url = kwargs.get("url", "") + self.status = kwargs.get("status", 0) + self.data = kwargs.get("data", {}) + self.is_abort = kwargs.get("is_abort", False) + self.original_error = kwargs.get("original_error", None) + + +class Client: + base_url: str + lang: str + auth_store: BaseAuthStore + settings: Settings + admins: Admins + users: Users + collections: Collections + records: Records + logs: Logs + realtime: Realtime + + def __init__( + self, + base_url: str = "/", + lang: str = "en-US", + auth_store: BaseAuthStore = None, + ) -> None: + self.base_url = base_url + self.lang = lang + self.auth_store = auth_store or BaseAuthStore() # LocalAuthStore() + # services + self.admins = Admins(self) + self.users = Users(self) + self.records = Records(self) + self.collections = Collections(self) + self.logs = Logs(self) + self.settings = Settings(self) + self.realtime = Realtime(self) + + def cancel_request(self, cancel_key: str): + return self + + def cancel_all_requests(self): + return self + + def send(self, path: str, req_config: dict[str:Any]) -> Any: + """Sends an api http request.""" + config = {"method": "GET"} + config.update(req_config) + # check if Authorization header can be added + if self.auth_store.token and ( + not "headers" in config or "Authorization" not in config["headers"] + ): + auth_type = "Admin" + if hasattr(self.auth_store.model, "verified"): + auth_type = "User" + config["headers"] = config.get("headers", {}) + config["headers"].update( + {"Authorization": f"{auth_type} {self.auth_store.token}"} + ) + # build url + path + url = self.build_url(path) + # send the request + method = config.get("method", "GET") + params = config.get("params", None) + headers = config.get("headers", None) + body = config.get("body", None) + try: + response = httpx.request( + method=method, + url=url, + params=params, + headers=headers, + json=body, + timeout=120, + ) + except Exception as e: + raise ClientResponseError( + f"General request error. Original error: {e}", + original_error=e, + ) + try: + data = response.json() + except Exception: + data = None + if response.status_code >= 400: + raise ClientResponseError( + f"Response error. Status code:{response.status_code}", + url=response.url, + status=response.status_code, + data=data, + ) + return data + + def build_url(self, path: str) -> str: + url = self.base_url + if not self.base_url.endswith("/"): + url += "/" + if path.startswith("/"): + path = path[1:] + return url + path + + +if __name__ == "__main__": + from pocketbase.stores.local_auth_store import LocalAuthStore + + pb = Client(base_url="http://ares.olimpo:8090/", auth_store=LocalAuthStore()) + # pb.admins.auth_via_email("vaphes@gmail.com", "vaphes2007") + print(pb.auth_store.token) + books = pb.collections.get_one("books") + print("ok") + # sacd = "nwvgaw6iiibv4fp" + # book = { + # "author": sacd, + # "name": "A study in red", + # "rating": 4.5, + # "summary": "The worst Sherlock Homes book", + # } + # data = pb.records.create("books", book) + # print(data) diff --git a/pocketbase/models/__init__.py b/pocketbase/models/__init__.py new file mode 100644 index 0000000..f3795e9 --- /dev/null +++ b/pocketbase/models/__init__.py @@ -0,0 +1,6 @@ +from .admin import Admin +from .collection import Collection +from .external_auth import ExternalAuth +from .log_request import LogRequest +from .record import Record +from .user import User diff --git a/pocketbase/models/__pycache__/__init__.cpython-310.pyc b/pocketbase/models/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..1129ef2 Binary files /dev/null and b/pocketbase/models/__pycache__/__init__.cpython-310.pyc differ diff --git a/pocketbase/models/__pycache__/admin.cpython-310.pyc b/pocketbase/models/__pycache__/admin.cpython-310.pyc new file mode 100644 index 0000000..921f4c2 Binary files /dev/null and b/pocketbase/models/__pycache__/admin.cpython-310.pyc differ diff --git a/pocketbase/models/__pycache__/collection.cpython-310.pyc b/pocketbase/models/__pycache__/collection.cpython-310.pyc new file mode 100644 index 0000000..e1440fb Binary files /dev/null and b/pocketbase/models/__pycache__/collection.cpython-310.pyc differ diff --git a/pocketbase/models/__pycache__/log_request.cpython-310.pyc b/pocketbase/models/__pycache__/log_request.cpython-310.pyc new file mode 100644 index 0000000..c7d86fc Binary files /dev/null and b/pocketbase/models/__pycache__/log_request.cpython-310.pyc differ diff --git a/pocketbase/models/__pycache__/record.cpython-310.pyc b/pocketbase/models/__pycache__/record.cpython-310.pyc new file mode 100644 index 0000000..09b1fe3 Binary files /dev/null and b/pocketbase/models/__pycache__/record.cpython-310.pyc differ diff --git a/pocketbase/models/__pycache__/user.cpython-310.pyc b/pocketbase/models/__pycache__/user.cpython-310.pyc new file mode 100644 index 0000000..7ccb884 Binary files /dev/null and b/pocketbase/models/__pycache__/user.cpython-310.pyc differ diff --git a/pocketbase/models/admin.py b/pocketbase/models/admin.py new file mode 100644 index 0000000..cfd2e8c --- /dev/null +++ b/pocketbase/models/admin.py @@ -0,0 +1,17 @@ +from typing import Any, Union +import datetime + +from pocketbase.utils import to_datetime +from pocketbase.models.utils.base_model import BaseModel + + +class Admin(BaseModel): + avatar: int + email: str + last_reset_sent_at: Union[str, datetime.datetime] + + def load(self, data: dict[str:Any]) -> None: + super().load(data) + self.avatar = data.get("avatar", 0) + self.email = data.get("email", "") + self.last_reset_sent_at = to_datetime(data.get("lastResetSentAt", "")) diff --git a/pocketbase/models/collection.py b/pocketbase/models/collection.py new file mode 100644 index 0000000..e504ca0 --- /dev/null +++ b/pocketbase/models/collection.py @@ -0,0 +1,29 @@ +from typing import Any, Optional + +from pocketbase.models.utils.base_model import BaseModel +from pocketbase.models.utils.schema_field import SchemaField + + +class Collection(BaseModel): + name: str + schema: list[SchemaField] + system: bool + list_rule: Optional[str] + view_rule: Optional[str] + create_rule: Optional[str] + update_rule: Optional[str] + delete_rule: Optional[str] + + def load(self, data: dict[str:Any]) -> None: + super().load(data) + self.name = data.get("name", "") + self.system = data.get("system", False) + self.list_rule = data.get("listRule", None) + self.view_rule = data.get("viewRule", None) + self.create_rule = data.get("createRule", None) + self.update_rule = data.get("updateRule", None) + self.delete_rule = data.get("deleteRule", "") + schema = data.get("schema", []) + self.schema = [] + for field in schema: + self.schema.append(SchemaField(**field)) diff --git a/pocketbase/models/external_auth.py b/pocketbase/models/external_auth.py new file mode 100644 index 0000000..fd04769 --- /dev/null +++ b/pocketbase/models/external_auth.py @@ -0,0 +1,14 @@ +from typing import Any +from pocketbase.models.utils.base_model import BaseModel + + +class ExternalAuth(BaseModel): + user_id: str + provider: str + provider_id: str + + def load(self, data: dict[str:Any]) -> None: + super().load(data) + self.user_id = data.get("userId", "") + self.provider = data.get("provider", "") + self.provider_id = data.get("providerId", "") diff --git a/pocketbase/models/log_request.py b/pocketbase/models/log_request.py new file mode 100644 index 0000000..f18bd99 --- /dev/null +++ b/pocketbase/models/log_request.py @@ -0,0 +1,27 @@ +from typing import Any + +from pocketbase.models.utils.base_model import BaseModel + + +class LogRequest(BaseModel): + url: str + method: str + status: int + auth: str + remote_ip: str + user_ip: str + referer: str + user_agent: str + meta: dict[str:Any] + + def load(self, data: dict[str:Any]) -> None: + super().load(data) + self.url = data.get("url", "") + self.method = data.get("method", "") + self.status = data.get("status", 200) + self.auth = data.get("auth", "guest") + self.remote_ip = data.get("remoteIp", data.get("ip", "")) + self.user_ip = data.get("userIp", "") + self.referer = data.get("referer", "") + self.user_agent = data.get("userAgent", "") + self.meta = data.get("meta", {}) diff --git a/pocketbase/models/record.py b/pocketbase/models/record.py new file mode 100644 index 0000000..5341e1d --- /dev/null +++ b/pocketbase/models/record.py @@ -0,0 +1,30 @@ +from typing import Any + +from pocketbase.models.utils.base_model import BaseModel +from pocketbase.utils import camel_to_snake + + +class Record(BaseModel): + collection_id: str + collection_name: str + expand: dict[str:Any] + + def load(self, data: dict[str:Any]) -> None: + super().load(data) + for key, value in data.items(): + key = camel_to_snake(key).replace("@", "") + setattr(self, key, value) + self.collection_id = data.get("@collectionId", "") + self.collection_name = data.get("@collectionName", "") + expand = data.get("@expand", {}) + if expand: + self.expand = expand + self.load_expanded() + + @classmethod + def parse_expanded(cls, data: dict[str:Any]): + return cls(data) + + def load_expanded(self) -> None: + for key, value in self.expand.items(): + self.expand[key] = self.parse_expanded(value) diff --git a/pocketbase/models/user.py b/pocketbase/models/user.py new file mode 100644 index 0000000..f49be71 --- /dev/null +++ b/pocketbase/models/user.py @@ -0,0 +1,27 @@ +from typing import Any, Optional, Union +import datetime + +from pocketbase.utils import to_datetime +from pocketbase.models.record import Record +from pocketbase.models.utils.base_model import BaseModel + + +class User(BaseModel): + email: str + verified: bool + last_reset_sent_at: Union[str, datetime.datetime] + last_verification_sent_at: Union[str, datetime.datetime] + profile: Optional[Record] + + def load(self, data: dict[str:Any]) -> None: + super().load(data) + self.email = data.get("email", "") + self.verified = data.get("verified", "") + self.last_reset_sent_at = to_datetime(data.get("lastResetSentAt", "")) + self.last_verification_sent_at = to_datetime( + data.get("lastVerificationSentAt", "") + ) + profile = data.get("profile", None) + self.profile = None + if profile: + self.profile = Record(profile) diff --git a/pocketbase/models/utils/__init__.py b/pocketbase/models/utils/__init__.py new file mode 100644 index 0000000..b8ac5d8 --- /dev/null +++ b/pocketbase/models/utils/__init__.py @@ -0,0 +1,3 @@ +from .base_model import BaseModel +from .list_result import ListResult +from .schema_field import SchemaField diff --git a/pocketbase/models/utils/__pycache__/__init__.cpython-310.pyc b/pocketbase/models/utils/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..307dbed Binary files /dev/null and b/pocketbase/models/utils/__pycache__/__init__.cpython-310.pyc differ diff --git a/pocketbase/models/utils/__pycache__/base_model.cpython-310.pyc b/pocketbase/models/utils/__pycache__/base_model.cpython-310.pyc new file mode 100644 index 0000000..691b869 Binary files /dev/null and b/pocketbase/models/utils/__pycache__/base_model.cpython-310.pyc differ diff --git a/pocketbase/models/utils/__pycache__/list_result.cpython-310.pyc b/pocketbase/models/utils/__pycache__/list_result.cpython-310.pyc new file mode 100644 index 0000000..79060d6 Binary files /dev/null and b/pocketbase/models/utils/__pycache__/list_result.cpython-310.pyc differ diff --git a/pocketbase/models/utils/__pycache__/schema_field.cpython-310.pyc b/pocketbase/models/utils/__pycache__/schema_field.cpython-310.pyc new file mode 100644 index 0000000..605183d Binary files /dev/null and b/pocketbase/models/utils/__pycache__/schema_field.cpython-310.pyc differ diff --git a/pocketbase/models/utils/base_model.py b/pocketbase/models/utils/base_model.py new file mode 100644 index 0000000..0febc34 --- /dev/null +++ b/pocketbase/models/utils/base_model.py @@ -0,0 +1,26 @@ +from abc import ABC +from typing import Any, Union +import datetime + +from pocketbase.utils import to_datetime + + +class BaseModel(ABC): + id: str + created: Union[str, datetime.datetime] + updated: Union[str, datetime.datetime] + + def __init__(self, data: dict[str:Any] = {}) -> None: + super().__init__() + self.load(data) + + def load(self, data: dict[str:Any]) -> None: + """Loads `data` into the current model.""" + self.id = data.pop("id", "") + self.created = to_datetime(data.pop("created", "")) + self.updated = to_datetime(data.pop("updated", "")) + + @property + def is_new(self) -> bool: + """Returns whether the current loaded data represent a stored db record.""" + return not self.id or self.id == "00000000-0000-0000-0000-000000000000" diff --git a/pocketbase/models/utils/list_result.py b/pocketbase/models/utils/list_result.py new file mode 100644 index 0000000..2f3cb2c --- /dev/null +++ b/pocketbase/models/utils/list_result.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass, field + +from pocketbase.models.utils.base_model import BaseModel + + +@dataclass +class ListResult: + page: int = 1 + per_page: int = 0 + total_items: int = 0 + total_pages: int = 0 + items: list[BaseModel] = field(default_factory=list) diff --git a/pocketbase/models/utils/schema_field.py b/pocketbase/models/utils/schema_field.py new file mode 100644 index 0000000..7301d29 --- /dev/null +++ b/pocketbase/models/utils/schema_field.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class SchemaField: + id: str = "" + name: str = "" + type: str = "text" + system: bool = False + required: bool = False + unique: bool = False + options: dict[str:Any] = field(default_factory=dict) diff --git a/pocketbase/services/__init__.py b/pocketbase/services/__init__.py new file mode 100644 index 0000000..95100f3 --- /dev/null +++ b/pocketbase/services/__init__.py @@ -0,0 +1,7 @@ +from .admins import Admins, AdminAuthResponse +from .collections import Collections +from .logs import Logs, HourlyStats +from .realtime import Realtime +from .records import Records +from .settings import Settings +from .users import Users, UserAuthResponse, AuthMethodsList, AuthProviderInfo diff --git a/pocketbase/services/__pycache__/__init__.cpython-310.pyc b/pocketbase/services/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..129b274 Binary files /dev/null and b/pocketbase/services/__pycache__/__init__.cpython-310.pyc differ diff --git a/pocketbase/services/__pycache__/admins.cpython-310.pyc b/pocketbase/services/__pycache__/admins.cpython-310.pyc new file mode 100644 index 0000000..a88edd4 Binary files /dev/null and b/pocketbase/services/__pycache__/admins.cpython-310.pyc differ diff --git a/pocketbase/services/__pycache__/collections.cpython-310.pyc b/pocketbase/services/__pycache__/collections.cpython-310.pyc new file mode 100644 index 0000000..d14e255 Binary files /dev/null and b/pocketbase/services/__pycache__/collections.cpython-310.pyc differ diff --git a/pocketbase/services/__pycache__/logs.cpython-310.pyc b/pocketbase/services/__pycache__/logs.cpython-310.pyc new file mode 100644 index 0000000..5d194fb Binary files /dev/null and b/pocketbase/services/__pycache__/logs.cpython-310.pyc differ diff --git a/pocketbase/services/__pycache__/realtime.cpython-310.pyc b/pocketbase/services/__pycache__/realtime.cpython-310.pyc new file mode 100644 index 0000000..7257d1a Binary files /dev/null and b/pocketbase/services/__pycache__/realtime.cpython-310.pyc differ diff --git a/pocketbase/services/__pycache__/records.cpython-310.pyc b/pocketbase/services/__pycache__/records.cpython-310.pyc new file mode 100644 index 0000000..6de9063 Binary files /dev/null and b/pocketbase/services/__pycache__/records.cpython-310.pyc differ diff --git a/pocketbase/services/__pycache__/settings.cpython-310.pyc b/pocketbase/services/__pycache__/settings.cpython-310.pyc new file mode 100644 index 0000000..db4579c Binary files /dev/null and b/pocketbase/services/__pycache__/settings.cpython-310.pyc differ diff --git a/pocketbase/services/__pycache__/users.cpython-310.pyc b/pocketbase/services/__pycache__/users.cpython-310.pyc new file mode 100644 index 0000000..1edebaf Binary files /dev/null and b/pocketbase/services/__pycache__/users.cpython-310.pyc differ diff --git a/pocketbase/services/admins.py b/pocketbase/services/admins.py new file mode 100644 index 0000000..96d2d1a --- /dev/null +++ b/pocketbase/services/admins.py @@ -0,0 +1,110 @@ +from typing import Any +from pocketbase.models.utils.base_model import BaseModel +from pocketbase.services.utils.crud_service import CrudService +from pocketbase.models.admin import Admin + + +class AdminAuthResponse: + token: str + admin: Admin + + def __init__(self, token: str, admin: Admin, **kwargs) -> None: + self.token = token + self.admin = admin + for key, value in kwargs.items(): + setattr(self, key, value) + + +class Admins(CrudService): + def decode(self, data: dict[str:Any]) -> BaseModel: + return Admin(data) + + def base_crud_path(self) -> str: + return "/api/admins" + + def auth_response(self, response_data: dict) -> AdminAuthResponse: + """Prepare successful authorize response.""" + admin = self.decode(response_data.pop("admin", {})) + token = response_data.pop("token", "") + if token and admin: + self.client.auth_store.save(token, admin) + return AdminAuthResponse(token=token, admin=admin, **response_data) + + def auth_via_email( + self, email: str, password: str, body_params: dict = {}, query_params: dict = {} + ) -> AdminAuthResponse: + """ + Authenticate an admin account by its email and password + and returns a new admin token and data. + + On success this method automatically updates the client's AuthStore data. + """ + body_params.update({"email": email, "password": password}) + response_data = self.client.send( + self.base_crud_path() + "/auth-via-email", + { + "method": "POST", + "params": query_params, + "body": body_params, + "headers": {"Authorization": ""}, + }, + ) + return self.auth_response(response_data) + + def refresh( + self, body_params: dict = {}, query_params: dict = {} + ) -> AdminAuthResponse: + """ + Refreshes the current admin authenticated instance and + returns a new token and admin data. + + On success this method automatically updates the client's AuthStore data. + """ + return self.auth_response( + self.client.send( + self.base_crud_path() + "/refresh", + {"method": "POST", "params": query_params, "body": body_params}, + ) + ) + + def requestPasswordReset( + self, email: str, body_params: dict = {}, query_params: dict = {} + ) -> bool: + """Sends admin password reset request.""" + body_params.update({"email": email}) + self.client.send( + self.base_crud_path() + "/request-password-reset", + { + "method": "POST", + "params": query_params, + "body": body_params, + }, + ) + return True + + def confirmPasswordReset( + self, + password_reset_token: str, + password: str, + password_confirm: str, + body_params: dict = {}, + query_params: dict = {}, + ) -> AdminAuthResponse: + """Confirms admin password reset request.""" + body_params.update( + { + "token": password_reset_token, + "password": password, + "passwordConfirm": password_confirm, + } + ) + return self.auth_response( + self.client.send( + self.base_crud_path() + "/confirm-password-reset", + { + "method": "POST", + "params": query_params, + "body": body_params, + }, + ) + ) diff --git a/pocketbase/services/collections.py b/pocketbase/services/collections.py new file mode 100644 index 0000000..b4d55b4 --- /dev/null +++ b/pocketbase/services/collections.py @@ -0,0 +1,36 @@ +from typing import Any + +from pocketbase.services.utils.crud_service import CrudService +from pocketbase.models.utils.base_model import BaseModel +from pocketbase.models.collection import Collection + + +class Collections(CrudService): + def decode(self, data: dict[str:Any]) -> BaseModel: + return Collection(data) + + def base_crud_path(self) -> str: + return "/api/collections" + + def import_collections( + self, + collections: list[Collection], + delete_missing: bool = False, + query_params: dict = {}, + ) -> bool: + """ + Imports the provided collections. + + If `delete_missing` is `True`, all local collections and schema fields, + that are not present in the imported configuration, WILL BE DELETED + (including their related records data)! + """ + self.client.send( + self.base_crud_path() + "/import", + { + "method": "PUT", + "params": query_params, + "body": {"collections": collections, "deleteMissing": delete_missing}, + }, + ) + return True diff --git a/pocketbase/services/logs.py b/pocketbase/services/logs.py new file mode 100644 index 0000000..c720960 --- /dev/null +++ b/pocketbase/services/logs.py @@ -0,0 +1,58 @@ +from dataclasses import dataclass +from typing import Union +from urllib.parse import quote +import datetime + +from pocketbase.services.utils.base_service import BaseService +from pocketbase.models.utils.list_result import ListResult +from pocketbase.models.log_request import LogRequest +from pocketbase.utils import to_datetime + + +@dataclass +class HourlyStats: + total: int + date: Union[str, datetime.datetime] + + +class Logs(BaseService): + def get_request_list( + self, page: int = 1, per_page: int = 30, query_params: dict = {} + ) -> ListResult: + """Returns paginated logged requests list.""" + query_params.update({"page": page, "perPage": per_page}) + response_data = self.client.send( + "/api/logs/requests", + {"method": "GET", "params": query_params}, + ) + items: list[LogRequest] = [] + if "items" in response_data: + response_data["items"] = response_data["items"] or [] + for item in response_data["items"]: + items.append(LogRequest(item)) + return ListResult( + response_data.get("page", 1), + response_data.get("perPage", 0), + response_data.get("totalItems", 0), + response_data.get("totalPages", 0), + items, + ) + + def get_request(self, id: str, query_params: dict = {}) -> LogRequest: + """Returns a single logged request by its id.""" + return LogRequest( + self.client.send( + "/api/logs/requests/" + quote(id), + {"method": "GET", "params": query_params}, + ) + ) + + def get_requests_stats(self, query_params: dict = {}) -> list[HourlyStats]: + """Returns request logs statistics.""" + return [ + HourlyStats(total=stat["total"], date=to_datetime(stat["date"])) + for stat in self.client.send( + "/api/logs/requests/stats", + {"method": "GET", "params": query_params}, + ) + ] diff --git a/pocketbase/services/realtime.py b/pocketbase/services/realtime.py new file mode 100644 index 0000000..6efe659 --- /dev/null +++ b/pocketbase/services/realtime.py @@ -0,0 +1,49 @@ +from typing import Callable, Optional +from pocketbase.services.utils.base_service import BaseService +from pocketbase.models.record import Record + + +class Realtime(BaseService): + client_id: str + subscriptions: dict + + def subscribe(self, subscription: str, callback: Callable) -> None: + """Inits the sse connection (if not already) and register the subscription.""" + self.subscriptions[subscription] = callback + + def unsubscribe(self, subscription: Optional[str] = None) -> None: + """ + Unsubscribe from a subscription. + + If the `subscription` argument is not set, + then the client will unsubscribe from all registered subscriptions. + + The related sse connection will be autoclosed if after the + unsubscribe operations there are no active subscriptions left. + """ + pass + + def _submit_subscriptions(self) -> bool: + self.client.send( + "/api/realtime", + { + "method": "POST", + "body": { + "clientId": self.client_id, + "subscriptions": self.subscriptions.keys(), + }, + }, + ) + return True + + def _add_subscription_listeners(self) -> None: + pass + + def _remove_subscription_listeners(self) -> None: + pass + + def _connect(self) -> None: + pass + + def _disconnect(self) -> None: + pass diff --git a/pocketbase/services/records.py b/pocketbase/services/records.py new file mode 100644 index 0000000..e555918 --- /dev/null +++ b/pocketbase/services/records.py @@ -0,0 +1,26 @@ +from typing import Any +from urllib.parse import quote, urlencode + +from pocketbase.services.utils.sub_crud_service import SubCrudService +from pocketbase.models.utils.base_model import BaseModel +from pocketbase.models.record import Record + + +class Records(SubCrudService): + def decode(self, data: dict[str:Any]) -> BaseModel: + return Record(data) + + def base_crud_path(self, collection_id_or_name: str) -> str: + return "/api/collections/" + quote(collection_id_or_name) + "/records" + + def get_file_url( + self, record: Record, filename: str, query_params: dict = {} + ) -> str: + """Builds and returns an absolute record file url.""" + base_url = self.client.base_url + if base_url.endswith("/"): + base_url = base_url[:-1] + result = f"{base_url}/api/files/{record.collection_id}/{record.id}/{filename}" + if query_params: + results += "?" + urlencode(query_params) + return result diff --git a/pocketbase/services/settings.py b/pocketbase/services/settings.py new file mode 100644 index 0000000..5645167 --- /dev/null +++ b/pocketbase/services/settings.py @@ -0,0 +1,50 @@ +from pocketbase.services.utils.base_service import BaseService + + +class Settings(BaseService): + def get_all(self, query_params: dict = {}) -> dict: + """Fetch all available app settings.""" + return self.client.send( + "/api/settings", + {"method": "GET", "params": query_params}, + ) + + def update(self, body_params: dict = {}, query_params: dict = {}) -> dict: + """Bulk updates app settings.""" + return self.client.send( + "/api/settings", + { + "method": "PATCH", + "params": query_params, + "body": body_params, + }, + ) + + def test_s3(self, query_params: dict = {}) -> bool: + """Performs a S3 storage connection test.""" + self.client.send( + "/api/settings/test/s3", + {"method": "POST", "params": query_params}, + ) + return True + + def test_email( + self, to_email: str, email_template: str, query_params: dict = {} + ) -> bool: + """ + Sends a test email. + + The possible `email_template` values are: + - verification + - password-reset + - email-change + """ + self.client.send( + "/api/settings/test/email", + { + "method": "POST", + "params": query_params, + "body": {"email": to_email, "template": email_template}, + }, + ) + return True diff --git a/pocketbase/services/users.py b/pocketbase/services/users.py new file mode 100644 index 0000000..c574cc4 --- /dev/null +++ b/pocketbase/services/users.py @@ -0,0 +1,280 @@ +from dataclasses import dataclass +from typing import Any +from urllib.parse import quote +from pocketbase.services.utils.crud_service import CrudService +from pocketbase.models.utils.base_model import BaseModel +from pocketbase.models.user import User +from pocketbase.models.external_auth import ExternalAuth + + +class UserAuthResponse: + token: str + user: User + + def __init__(self, token: str, user: User, **kwargs) -> None: + self.token = token + self.user = user + for key, value in kwargs.items(): + setattr(self, key, value) + + +@dataclass +class AuthProviderInfo: + name: str + state: str + code_verifier: str + code_challenge: str + code_challenge_method: str + auth_url: str + + +@dataclass +class AuthMethodsList: + email_password: bool + auth_providers: list[AuthProviderInfo] + + +class Users(CrudService): + def decode(self, data: dict[str:Any]) -> BaseModel: + return User(data) + + def base_crud_path(self) -> str: + return "/api/users" + + def auth_response(self, response_data: Any) -> UserAuthResponse: + """Prepare successful authorization response.""" + user = self.decode(response_data.pop("user", {})) + token = response_data.pop("token", "") + if token and user: + self.client.auth_store.save(token, user) + return UserAuthResponse(token=token, user=user, **response_data) + + def list_auth_methods(self, query_params: dict = {}) -> AuthMethodsList: + """Returns all available application auth methods.""" + response_data = self.client.send( + self.base_crud_path() + "/auth-methods", + {"method": "GET", "params": query_params}, + ) + email_password = response_data.get("emailPassword", False) + auth_providers = [ + AuthProviderInfo(auth_provider) + for auth_provider in response_data.get("authProviders", []) + ] + return AuthMethodsList(email_password, auth_providers) + + def auth_via_email( + self, email: str, password: str, body_params: dict = {}, query_params: dict = {} + ) -> UserAuthResponse: + """ + Authenticate a user via its email and password. + + On success, this method also automatically updates + the client's AuthStore data and returns: + - new user authentication token + - the authenticated user model record + """ + body_params.update({"email": email, "password": password}) + response_data = self.client.send( + self.base_crud_path() + "/auth-via-email", + { + "method": "POST", + "params": query_params, + "body": body_params, + "headers": {"Authorization": ""}, + }, + ) + return self.auth_response(response_data) + + def auth_via_oauth2( + self, + provider: str, + code: str, + code_verifier: str, + redirect_url: str, + body_params: dict = {}, + query_params: dict = {}, + ) -> UserAuthResponse: + """ + Authenticate a user via OAuth2 client provider. + + On success, this method also automatically updates + the client's AuthStore data and returns: + - new user authentication token + - the authenticated user model record + - the OAuth2 user profile data (eg. name, email, avatar, etc.) + """ + body_params.update( + { + "provider": provider, + "code": code, + "codeVerifier": code_verifier, + "redirectUrl": redirect_url, + } + ) + response_data = self.client.send( + self.base_crud_path() + "/auth-via-oauth2", + { + "method": "POST", + "params": query_params, + "body": body_params, + "headers": {"Authorization": ""}, + }, + ) + return self.auth_response(response_data) + + def refresh( + self, body_params: dict = {}, query_params: dict = {} + ) -> UserAuthResponse: + """ + Refreshes the current user authenticated instance and + returns a new token and user data. + + On success this method also automatically updates the client's AuthStore data. + """ + return self.auth_response( + self.client.send( + self.base_crud_path() + "/refresh", + {"method": "POST", "params": query_params, "body": body_params}, + ) + ) + + def request_password_reset( + self, email: str, body_params: dict = {}, query_params: dict = {} + ) -> bool: + """Sends user password reset request.""" + body_params.update({"email": email}) + self.client.send( + self.base_crud_path() + "/request-password-reset", + { + "method": "POST", + "params": query_params, + "body": body_params, + }, + ) + return True + + def confirm_password_reset( + self, + password_reset_token: str, + password: str, + password_confirm: str, + body_params: dict = {}, + query_params: dict = {}, + ) -> UserAuthResponse: + """Confirms user password reset request.""" + body_params.update( + { + "token": password_reset_token, + "password": password, + "passwordConfirm": password_confirm, + } + ) + return self.auth_response( + self.client.send( + self.base_crud_path() + "/confirm-password-reset", + { + "method": "POST", + "params": query_params, + "body": body_params, + }, + ) + ) + + def request_verification( + self, email: str, body_params: dict = {}, query_params: dict = {} + ) -> bool: + """Sends user verification email request.""" + body_params.update({"email": email}) + self.client.send( + self.base_crud_path() + "/request-verification", + { + "method": "POST", + "params": query_params, + "body": body_params, + }, + ) + return True + + def confirm_verification( + self, verification_token: str, body_params: dict = {}, query_params: dict = {} + ) -> UserAuthResponse: + """Confirms user email verification request.""" + body_params.update( + { + "token": verification_token, + } + ) + return self.auth_response( + self.client.send( + self.base_crud_path() + "/confirm-verification", + { + "method": "POST", + "params": query_params, + "body": body_params, + }, + ) + ) + + def request_email_change( + self, new_email: str, body_params: dict = {}, query_params: dict = {} + ) -> bool: + """Sends an email change request to the authenticated user.""" + body_params.update({"newEmail": new_email}) + self.client.send( + self.base_crud_path() + "/request-email-change", + { + "method": "POST", + "params": query_params, + "body": body_params, + }, + ) + return True + + def confirm_email_change( + self, + email_change_token: str, + password: str, + body_params: dict = {}, + query_params: dict = {}, + ) -> UserAuthResponse: + """Confirms user new email address.""" + body_params.update( + { + "token": email_change_token, + "password": password, + } + ) + return self.auth_response( + self.client.send( + self.base_crud_path() + "/confirm-email-change", + { + "method": "POST", + "params": query_params, + "body": body_params, + }, + ) + ) + + def list_external_auths( + self, user_id: str, query_params: dict = {} + ) -> list[ExternalAuth]: + """Lists all linked external auth providers for the specified user.""" + response_data = self.client.send( + self.base_crud_path() + "/" + quote(user_id) + "/external-auths", + {"method": "GET", "params": query_params}, + ) + return [ExternalAuth(item) for item in response_data] + + def unlink_external_auth( + self, user_id: str, provider: str, query_params: dict = {} + ) -> bool: + """Unlink a single external auth provider from the specified user.""" + self.client.send( + self.base_crud_path() + + "/" + + quote(user_id) + + "/external-auths/" + + quote(provider), + {"method": "DELETE", "params": query_params}, + ) + return True diff --git a/pocketbase/services/utils/__init__.py b/pocketbase/services/utils/__init__.py new file mode 100644 index 0000000..a57fdcb --- /dev/null +++ b/pocketbase/services/utils/__init__.py @@ -0,0 +1,4 @@ +from .base_crud_service import BaseCrudService +from .base_service import BaseService +from .crud_service import CrudService +from .sub_crud_service import SubCrudService diff --git a/pocketbase/services/utils/__pycache__/__init__.cpython-310.pyc b/pocketbase/services/utils/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..5ccbe0f Binary files /dev/null and b/pocketbase/services/utils/__pycache__/__init__.cpython-310.pyc differ diff --git a/pocketbase/services/utils/__pycache__/base_crud_service.cpython-310.pyc b/pocketbase/services/utils/__pycache__/base_crud_service.cpython-310.pyc new file mode 100644 index 0000000..917c606 Binary files /dev/null and b/pocketbase/services/utils/__pycache__/base_crud_service.cpython-310.pyc differ diff --git a/pocketbase/services/utils/__pycache__/base_service.cpython-310.pyc b/pocketbase/services/utils/__pycache__/base_service.cpython-310.pyc new file mode 100644 index 0000000..41454d4 Binary files /dev/null and b/pocketbase/services/utils/__pycache__/base_service.cpython-310.pyc differ diff --git a/pocketbase/services/utils/__pycache__/crud_service.cpython-310.pyc b/pocketbase/services/utils/__pycache__/crud_service.cpython-310.pyc new file mode 100644 index 0000000..b156d15 Binary files /dev/null and b/pocketbase/services/utils/__pycache__/crud_service.cpython-310.pyc differ diff --git a/pocketbase/services/utils/__pycache__/sub_crud_service.cpython-310.pyc b/pocketbase/services/utils/__pycache__/sub_crud_service.cpython-310.pyc new file mode 100644 index 0000000..b9a0981 Binary files /dev/null and b/pocketbase/services/utils/__pycache__/sub_crud_service.cpython-310.pyc differ diff --git a/pocketbase/services/utils/base_crud_service.py b/pocketbase/services/utils/base_crud_service.py new file mode 100644 index 0000000..4444853 --- /dev/null +++ b/pocketbase/services/utils/base_crud_service.py @@ -0,0 +1,83 @@ +from abc import ABC +from urllib.parse import quote +from typing import Any + + +from pocketbase.models.utils.base_model import BaseModel +from pocketbase.models.utils.list_result import ListResult +from pocketbase.services.utils.base_service import BaseService + + +class BaseCrudService(BaseService, ABC): + def decode(self, data: dict[str:Any]) -> BaseModel: + """Response data decoder""" + + def _get_full_list( + self, base_path: str, batch_size: int = 100, query_params: dict = {} + ) -> list[BaseModel]: + + result: list[BaseModel] = [] + + def request(result: list[BaseModel], page: int) -> list[Any]: + list = self._get_list(base_path, page, batch_size, query_params) + items = list.items + total_items = list.total_items + result += items + if len(items) > 0 and total_items > len(result): + return request(result, page + 1) + return result + + return request(result, 1) + + def _get_list( + self, base_path: str, page: int = 1, per_page: int = 30, query_params: dict = {} + ) -> ListResult: + query_params.update({"page": page, "perPage": per_page}) + response_data = self.client.send( + base_path, {"method": "GET", "params": query_params} + ) + items: list[BaseModel] = [] + if "items" in response_data: + response_data["items"] = response_data["items"] or [] + for item in response_data["items"]: + items.append(self.decode(item)) + return ListResult( + response_data.get("page", 1), + response_data.get("perPage", 0), + response_data.get("totalItems", 0), + response_data.get("totalPages", 0), + items, + ) + + def _get_one(self, base_path: str, id: str, query_params: dict = {}) -> BaseModel: + return self.decode( + self.client.send( + f"{base_path}/{quote(id)}", {"method": "GET", "params": query_params} + ) + ) + + def _create( + self, base_path: str, body_params: dict = {}, query_params: dict = {} + ) -> BaseModel: + return self.decode( + self.client.send( + base_path, + {"method": "POST", "params": query_params, "body": body_params}, + ) + ) + + def _update( + self, base_path: str, id: str, body_params: dict = {}, query_params: dict = {} + ) -> BaseModel: + return self.decode( + self.client.send( + f"{base_path}/{quote(id)}", + {"method": "PATCH", "params": query_params, "body": body_params}, + ) + ) + + def _delete(self, base_path: str, id: str, query_params: dict = {}) -> bool: + self.client.send( + f"{base_path}/{quote(id)}", {"method": "DELETE", "params": query_params} + ) + return True diff --git a/pocketbase/services/utils/base_service.py b/pocketbase/services/utils/base_service.py new file mode 100644 index 0000000..10cb141 --- /dev/null +++ b/pocketbase/services/utils/base_service.py @@ -0,0 +1,7 @@ +from abc import ABC + + +class BaseService(ABC): + def __init__(self, client) -> None: + super().__init__() + self.client = client diff --git a/pocketbase/services/utils/crud_service.py b/pocketbase/services/utils/crud_service.py new file mode 100644 index 0000000..3402e83 --- /dev/null +++ b/pocketbase/services/utils/crud_service.py @@ -0,0 +1,34 @@ +from abc import ABC + +from pocketbase.models.utils.base_model import BaseModel +from pocketbase.models.utils.list_result import ListResult +from pocketbase.services.utils.base_crud_service import BaseCrudService + + +class CrudService(BaseCrudService, ABC): + def base_crud_path(self) -> str: + """Base path for the crud actions (without trailing slash, eg. '/admins').""" + + def get_full_list( + self, batch_size: int = 100, query_params: dict = {} + ) -> list[BaseModel]: + return self._get_full_list(self.base_crud_path(), batch_size, query_params) + + def get_list( + self, page: int = 1, per_page: int = 30, query_params: dict = {} + ) -> ListResult: + return self._get_list(self.base_crud_path(), page, per_page, query_params) + + def get_one(self, id: str, query_params: dict = {}) -> BaseModel: + return self._get_one(self.base_crud_path(), id, query_params) + + def create(self, body_params: dict = {}, query_params: dict = {}) -> BaseModel: + return self._create(self.base_crud_path(), body_params, query_params) + + def update( + self, id: str, body_params: dict = {}, query_params: dict = {} + ) -> BaseModel: + return self._update(self.base_crud_path(), id, body_params, query_params) + + def delete(self, id: str, query_params: dict = {}) -> bool: + return self._delete(self.base_crud_path(), id, query_params) diff --git a/pocketbase/services/utils/sub_crud_service.py b/pocketbase/services/utils/sub_crud_service.py new file mode 100644 index 0000000..aba13c2 --- /dev/null +++ b/pocketbase/services/utils/sub_crud_service.py @@ -0,0 +1,36 @@ +from abc import ABC + +from pocketbase.models.utils.base_model import BaseModel +from pocketbase.models.utils.list_result import ListResult +from pocketbase.services.utils.base_crud_service import BaseCrudService + + +class SubCrudService(BaseCrudService, ABC): + def base_crud_path(self) -> str: + """Base path for the crud actions (without trailing slash, eg. '/admins').""" + + def get_full_list( + self, sub: str, batch_size: int = 100, query_params: dict = {} + ) -> list[BaseModel]: + return self._get_full_list(self.base_crud_path(sub), batch_size, query_params) + + def get_list( + self, sub: str, page: int = 1, per_page: int = 30, query_params: dict = {} + ) -> ListResult: + return self._get_list(self.base_crud_path(sub), page, per_page, query_params) + + def get_one(self, sub: str, id: str, query_params: dict = {}) -> BaseModel: + return self._get_one(self.base_crud_path(sub), id, query_params) + + def create( + self, sub: str, body_params: dict = {}, query_params: dict = {} + ) -> BaseModel: + return self._create(self.base_crud_path(sub), body_params, query_params) + + def update( + self, sub: str, id: str, body_params: dict = {}, query_params: dict = {} + ) -> BaseModel: + return self._update(self.base_crud_path(sub), id, body_params, query_params) + + def delete(self, sub: str, id: str, query_params: dict = {}) -> bool: + return self._delete(self.base_crud_path(sub), id, query_params) diff --git a/pocketbase/stores/__init__.py b/pocketbase/stores/__init__.py new file mode 100644 index 0000000..115a6b1 --- /dev/null +++ b/pocketbase/stores/__init__.py @@ -0,0 +1,2 @@ +from .base_auth_store import BaseAuthStore +from .local_auth_store import LocalAuthStore diff --git a/pocketbase/stores/__pycache__/__init__.cpython-310.pyc b/pocketbase/stores/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..ab2faf0 Binary files /dev/null and b/pocketbase/stores/__pycache__/__init__.cpython-310.pyc differ diff --git a/pocketbase/stores/__pycache__/base_auth_store.cpython-310.pyc b/pocketbase/stores/__pycache__/base_auth_store.cpython-310.pyc new file mode 100644 index 0000000..0588049 Binary files /dev/null and b/pocketbase/stores/__pycache__/base_auth_store.cpython-310.pyc differ diff --git a/pocketbase/stores/__pycache__/local_auth_store.cpython-310.pyc b/pocketbase/stores/__pycache__/local_auth_store.cpython-310.pyc new file mode 100644 index 0000000..1d9f146 Binary files /dev/null and b/pocketbase/stores/__pycache__/local_auth_store.cpython-310.pyc differ diff --git a/pocketbase/stores/base_auth_store.py b/pocketbase/stores/base_auth_store.py new file mode 100644 index 0000000..a8ec7b0 --- /dev/null +++ b/pocketbase/stores/base_auth_store.py @@ -0,0 +1,42 @@ +from abc import ABC +from typing import Union, Optional + +from pocketbase.models.admin import Admin +from pocketbase.models.user import User + + +class BaseAuthStore(ABC): + """ + Base AuthStore class that is intended to be extended by all other + PocketBase AuthStore implementations. + """ + + base_token: str + base_model: Union[User, Admin, None] + + def __init__( + self, base_token: str = "", base_model: Optional[Union[User, Admin]] = None + ) -> None: + super().__init__() + self.base_token = base_token + self.base_model = base_model + + @property + def token(self) -> Union[str, None]: + """Retrieves the stored token (if any).""" + return self.base_token + + @property + def model(self) -> Union[User, Admin, None]: + """Retrieves the stored model data (if any).""" + return self.base_model + + def save(self, token: str = "", model: Optional[Union[User, Admin]] = None) -> None: + """Saves the provided new token and model data in the auth store.""" + self.base_token = token + self.base_model = model + + def clear(self) -> None: + """Removes the stored token and model data form the auth store.""" + self.base_token = None + self.base_model = None diff --git a/pocketbase/stores/local_auth_store.py b/pocketbase/stores/local_auth_store.py new file mode 100644 index 0000000..203f681 --- /dev/null +++ b/pocketbase/stores/local_auth_store.py @@ -0,0 +1,59 @@ +from typing import Any, Optional, Union +import pickle +import os + +from pocketbase.stores.base_auth_store import BaseAuthStore +from pocketbase.models.user import User +from pocketbase.models.admin import Admin + + +class LocalAuthStore(BaseAuthStore): + filename: str + filepath: str + + def __init__( + self, + filename: str = "pocketbase_auth.data", + filepath: str = "", + base_token: str = "", + base_model: Optional[Union[User, Admin]] = None, + ) -> None: + super().__init__(base_token, base_model) + self.filename = filename + self.filepath = filepath + self.complete_filepath = os.path.join(filepath, filename) + + @property + def token(self) -> str: + data = self._storage_get(self.complete_filepath) + if not data or not "token" in data: + return None + return data["token"] + + @property + def model(self) -> Union[User, Admin, None]: + data = self._storage_get(self.complete_filepath) + if not data or not "model" in data: + return None + return data["model"] + + def save(self, token: str = "", model: Optional[Union[User, Admin]] = None) -> None: + self._storage_set(self.complete_filepath, {"token": token, "model": model}) + super().save(token, model) + + def clear(self) -> None: + self._storage_remove(self.complete_filepath) + super().clear() + + def _storage_set(self, key: str, value: Any) -> None: + with open(key, "wb") as f: + pickle.dump(value, f) + + def _storage_get(self, key: str) -> Any: + with open(key, "rb") as f: + value = pickle.load(f) + return value + + def _storage_remove(self, key: str) -> None: + if os.path.exists(key): + os.remove(key) diff --git a/pocketbase/utils.py b/pocketbase/utils.py new file mode 100644 index 0000000..b7d68b1 --- /dev/null +++ b/pocketbase/utils.py @@ -0,0 +1,18 @@ +import re +import datetime +from typing import Union + + +def camel_to_snake(name: str) -> str: + name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name) + return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower() + + +def to_datetime( + str_datetime: str, format: str = "%Y-%m-%d %H:%M:%S" +) -> Union[datetime.datetime, str]: + str_datetime = str_datetime.split(".")[0] + try: + return datetime.datetime.strptime(str_datetime, format) + except Exception: + return str_datetime