diff --git a/crudkit/api/flask_api.py b/crudkit/api/flask_api.py index 46832c2..d238678 100644 --- a/crudkit/api/flask_api.py +++ b/crudkit/api/flask_api.py @@ -6,26 +6,41 @@ def generate_crud_blueprint(model, service): @bp.get('/') def list_items(): items = service.list(request.args) - return jsonify([item.as_dict() for item in items]) + try: + return jsonify([item.as_dict() for item in items]) + except Exception as e: + return jsonify({"status": "error", "error": str(e)}) @bp.get('/') def get_item(id): item = service.get(id, request.args) - return jsonify(item.as_dict()) + try: + return jsonify(item.as_dict()) + except Exception as e: + return jsonify({"status": "error", "error": str(e)}) @bp.post('/') def create_item(): obj = service.create(request.json) - return jsonify(obj.as_dict()) + try: + return jsonify(obj.as_dict()) + except Exception as e: + return jsonify({"status": "error", "error": str(e)}) @bp.patch('/') def update_item(id): obj = service.update(id, request.json) - return jsonify(obj.as_dict()) + try: + return jsonify(obj.as_dict()) + except Exception as e: + return jsonify({"status": "error", "error": str(e)}) @bp.delete('/') def delete_item(id): service.delete(id) - return '', 204 + try: + return jsonify({"status": "success"}), 204 + except Exception as e: + return jsonify({"status": "error", "error": str(e)}) return bp diff --git a/crudkit/core/service.py b/crudkit/core/service.py index 2e1c400..ed0063a 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -1,12 +1,34 @@ -from typing import Type, TypeVar, Generic, Optional -from sqlalchemy.orm import Load, Session, raiseload, with_polymorphic +from typing import Any, Type, TypeVar, Generic, Optional, Protocol, runtime_checkable, cast +from sqlalchemy.orm import Load, Session, raiseload, with_polymorphic, Mapper from sqlalchemy.orm.attributes import InstrumentedAttribute +from sqlalchemy.orm.util import AliasedClass +from sqlalchemy.engine import Engine, Connection from sqlalchemy import inspect, text from crudkit.core.base import Version from crudkit.core.spec import CRUDSpec from crudkit.backend import BackendInfo, make_backend_info -T = TypeVar("T") +@runtime_checkable +class _HasID(Protocol): + id: int + +@runtime_checkable +class _HasTable(Protocol): + __table__: Any + +@runtime_checkable +class _HasADict(Protocol): + def as_dict(self) -> dict: ... + +@runtime_checkable +class _SoftDeletable(Protocol): + is_deleted: bool + +class _CRUDModelProto(_HasID, _HasTable, _HasADict, Protocol): + """Minimal surface that our CRUD service relies on. Soft-delete is optional.""" + pass + +T = TypeVar("T", bound=_CRUDModelProto) def _is_truthy(val): return str(val).lower() in ('1', 'true', 'yes', 'on') @@ -25,7 +47,9 @@ class CRUDService(Generic[T]): self.polymorphic = polymorphic self.supports_soft_delete = hasattr(model, 'is_deleted') # Cache backend info once. If not provided, derive from session bind. - self.backend = backend or make_backend_info(self.session.get_bind()) + bind = self.session.get_bind() + eng: Engine = bind.engine if isinstance(bind, Connection) else cast(Engine, bind) + self.backend = backend or make_backend_info(eng) def get_query(self): if self.polymorphic: @@ -35,7 +59,7 @@ class CRUDService(Generic[T]): # Helper: default ORDER BY for MSSQL when paginating without explicit order def _default_order_by(self, root_alias): - mapper = inspect(self.model) + mapper: Mapper[Any] = cast(Mapper[Any], inspect(self.model)) cols = [] for col in mapper.primary_key: try: @@ -64,11 +88,9 @@ class CRUDService(Generic[T]): spec.parse_includes() for parent_alias, relationship_attr, target_alias in spec.get_join_paths(): - query = query.join( - target_alias, - relationship_attr.of_type(target_alias), - isouter=True - ) + rel_attr = cast(InstrumentedAttribute, relationship_attr) + target = cast(Any, target_alias) + query = query.join(target, rel_attr.of_type(target), isouter=True) if params: root_fields, rel_field_names, root_field_names = spec.parse_fields() @@ -123,11 +145,9 @@ class CRUDService(Generic[T]): spec.parse_includes() for parent_alias, relationship_attr, target_alias in spec.get_join_paths(): - query = query.join( - target_alias, - relationship_attr.of_type(target_alias), - isouter=True - ) + rel_attr = cast(InstrumentedAttribute, relationship_attr) + target = cast(Any, target_alias) + query = query.join(target, rel_attr.of_type(target), isouter=True) if params: root_fields, rel_field_names, root_field_names = spec.parse_fields() @@ -206,7 +226,8 @@ class CRUDService(Generic[T]): if hard or not self.supports_soft_delete: self.session.delete(obj) else: - obj.is_deleted = True + soft = cast(_SoftDeletable, obj) + soft.is_deleted = True self.session.commit() self._log_version("delete", obj, actor) return obj diff --git a/crudkit/integration.py b/crudkit/integration.py new file mode 100644 index 0000000..665e813 --- /dev/null +++ b/crudkit/integration.py @@ -0,0 +1,25 @@ +from __future__ import annotations +from typing import Type +from flask import Flask + +from crudkit.engines import CRUDKitRuntime + +from .registry import CRUDRegistry + +class CRUDKit: + def __init__(self, app: Flask, runtime: CRUDKitRuntime): + self.app = app + self.runtime = runtime + self.registry = CRUDRegistry(runtime) + + def register(self, model: Type, **kwargs): + return self.registry.register_class(self.app, model, **kwargs) + + def register_many(self, models: list[Type], **kwargs): + return self.registry.register_many(self.app, models, **kwargs) + + def get_model(self, key: str): + return self.registry.get_model(key) + + def get_service(self, model: Type): + return self.registry.get_service(model) diff --git a/crudkit/registry.py b/crudkit/registry.py new file mode 100644 index 0000000..9c66153 --- /dev/null +++ b/crudkit/registry.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from flask import Flask +from sqlalchemy.orm import Session + +from crudkit.core.service import CRUDService +from crudkit.api.flask_api import generate_crud_blueprint +from crudkit.engines import CRUDKitRuntime + +T = TypeVar("T") + +@dataclass +class Registered: + model: Type[Any] + service: CRUDService[Any] + blueprint_name: str + url_prefix: str + +class CRUDRegistry: + """ + Binds: + - name -> model class + - model class -> CRUDService (using CRUDKitRuntime.session_factory) + - model class -> Flask blueprint (via generate_crud_blueprint) + """ + def __init__(self, runtime: CRUDKitRuntime): + self._rt = runtime + self._models_by_key: Dict[str, Type[Any]] = {} + self._services_by_model: Dict[Type[Any], CRUDService[Any]] = {} + self._bps_by_model: Dict[Type[Any], Registered] = {} + + @staticmethod + def _key(model_or_name: Type[Any] | str) -> str: + return model_or_name.lower() if isinstance(model_or_name, str) else model_or_name.__name__.lower() + + def get_model(self, key: str) -> Optional[Type[Any]]: + return self._models_by_key.get(key.lower()) + + def get_service(self, model: Type[T]) -> Optional[CRUDService[T]]: + return cast(Optional[CRUDService[T]], self._services_by_model.get(model)) + + def is_registered(self, model: Type[Any]) -> bool: + return model in self._services_by_model + + def register_class( + self, + app: Flask, + model: Type[Any], + *, + url_prefix: Optional[str] = None, + blueprint_name: Optional[str] = None, + polymorphic: bool = False, + service_kwargs: Optional[dict] = None + ) -> Registered: + """ + Register a model: + - store name -> class + - create a CRUDService bound to a Session from runtime.session_factory + - attach backend into from runtime.backend + - mount Flask blueprint at /api/ by default + Idempotent for each model. + """ + key = self._key(model) + self._models_by_key.setdefault(key, model) + + svc = self._services_by_model.get(model) + if svc is None: + SessionMaker = self._rt.session_factory + if SessionMaker is None: + raise RuntimeError("CRUDKitRuntime.session_factory is not initialized.") + session: Session = SessionMaker() + svc = CRUDService( + model, + session=session, + polymorphic=polymorphic, + backend=self._rt.backend, + **(service_kwargs or {}), + ) + self._services_by_model[model] = svc + + reg = self._bps_by_model.get(model) + if reg: + return reg + + prefix = url_prefix or f"/api/{key}" + bp_name = blueprint_name or f"crudkit.{key}" + + bp = generate_crud_blueprint(model, svc) + bp.name = bp_name + app.register_blueprint(bp, url_prefix=prefix) + + reg = Registered(model=model, service=svc, blueprint_name=bp_name, url_prefix=prefix) + self._bps_by_model[model] = reg + return reg + + def register_many( + self, + app: Flask, + models: list[Type[Any]], + *, + base_prefix: str = "/api", + polymorphic: bool = False, + service_kwargs: Optional[dict] = None, + ) -> list[Registered]: + out: list[Registered] = [] + for m in models: + key = self._key(m) + out.append( + self.register_class( + app, + m, + url_prefix=f"{base_prefix}/{key}", + polymorphic=polymorphic, + service_kwargs=service_kwargs, + ) + ) + return out diff --git a/crudkit/ui/fragments.py b/crudkit/ui/fragments.py index 6a3b7a4..4fe4208 100644 --- a/crudkit/ui/fragments.py +++ b/crudkit/ui/fragments.py @@ -85,7 +85,7 @@ def _build_href(spec: Dict[str, Any], row: Dict[str, Any], obj) -> Optional[str] print(f"[render_table] url_for failed: endpoint={spec}: params={params}") return None try: - return url_for(spec["endpoint"], **params) + return url_for('crudkit.' + spec["endpoint"], **params) except Exception as e: print(f"[render_table] url_for failed: endpoint={spec['endpoint']} params={params} err={e}") return None