Redesign1 #1
5 changed files with 203 additions and 22 deletions
|
|
@ -6,26 +6,41 @@ def generate_crud_blueprint(model, service):
|
||||||
@bp.get('/')
|
@bp.get('/')
|
||||||
def list_items():
|
def list_items():
|
||||||
items = service.list(request.args)
|
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('/<int:id>')
|
@bp.get('/<int:id>')
|
||||||
def get_item(id):
|
def get_item(id):
|
||||||
item = service.get(id, request.args)
|
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('/')
|
@bp.post('/')
|
||||||
def create_item():
|
def create_item():
|
||||||
obj = service.create(request.json)
|
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('/<int:id>')
|
@bp.patch('/<int:id>')
|
||||||
def update_item(id):
|
def update_item(id):
|
||||||
obj = service.update(id, request.json)
|
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('/<int:id>')
|
@bp.delete('/<int:id>')
|
||||||
def delete_item(id):
|
def delete_item(id):
|
||||||
service.delete(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
|
return bp
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,34 @@
|
||||||
from typing import Type, TypeVar, Generic, Optional
|
from typing import Any, Type, TypeVar, Generic, Optional, Protocol, runtime_checkable, cast
|
||||||
from sqlalchemy.orm import Load, Session, raiseload, with_polymorphic
|
from sqlalchemy.orm import Load, Session, raiseload, with_polymorphic, Mapper
|
||||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||||
|
from sqlalchemy.orm.util import AliasedClass
|
||||||
|
from sqlalchemy.engine import Engine, Connection
|
||||||
from sqlalchemy import inspect, text
|
from sqlalchemy import inspect, text
|
||||||
from crudkit.core.base import Version
|
from crudkit.core.base import Version
|
||||||
from crudkit.core.spec import CRUDSpec
|
from crudkit.core.spec import CRUDSpec
|
||||||
from crudkit.backend import BackendInfo, make_backend_info
|
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):
|
def _is_truthy(val):
|
||||||
return str(val).lower() in ('1', 'true', 'yes', 'on')
|
return str(val).lower() in ('1', 'true', 'yes', 'on')
|
||||||
|
|
@ -25,7 +47,9 @@ class CRUDService(Generic[T]):
|
||||||
self.polymorphic = polymorphic
|
self.polymorphic = polymorphic
|
||||||
self.supports_soft_delete = hasattr(model, 'is_deleted')
|
self.supports_soft_delete = hasattr(model, 'is_deleted')
|
||||||
# Cache backend info once. If not provided, derive from session bind.
|
# 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):
|
def get_query(self):
|
||||||
if self.polymorphic:
|
if self.polymorphic:
|
||||||
|
|
@ -35,7 +59,7 @@ class CRUDService(Generic[T]):
|
||||||
|
|
||||||
# Helper: default ORDER BY for MSSQL when paginating without explicit order
|
# Helper: default ORDER BY for MSSQL when paginating without explicit order
|
||||||
def _default_order_by(self, root_alias):
|
def _default_order_by(self, root_alias):
|
||||||
mapper = inspect(self.model)
|
mapper: Mapper[Any] = cast(Mapper[Any], inspect(self.model))
|
||||||
cols = []
|
cols = []
|
||||||
for col in mapper.primary_key:
|
for col in mapper.primary_key:
|
||||||
try:
|
try:
|
||||||
|
|
@ -64,11 +88,9 @@ class CRUDService(Generic[T]):
|
||||||
spec.parse_includes()
|
spec.parse_includes()
|
||||||
|
|
||||||
for parent_alias, relationship_attr, target_alias in spec.get_join_paths():
|
for parent_alias, relationship_attr, target_alias in spec.get_join_paths():
|
||||||
query = query.join(
|
rel_attr = cast(InstrumentedAttribute, relationship_attr)
|
||||||
target_alias,
|
target = cast(Any, target_alias)
|
||||||
relationship_attr.of_type(target_alias),
|
query = query.join(target, rel_attr.of_type(target), isouter=True)
|
||||||
isouter=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if params:
|
if params:
|
||||||
root_fields, rel_field_names, root_field_names = spec.parse_fields()
|
root_fields, rel_field_names, root_field_names = spec.parse_fields()
|
||||||
|
|
@ -123,11 +145,9 @@ class CRUDService(Generic[T]):
|
||||||
spec.parse_includes()
|
spec.parse_includes()
|
||||||
|
|
||||||
for parent_alias, relationship_attr, target_alias in spec.get_join_paths():
|
for parent_alias, relationship_attr, target_alias in spec.get_join_paths():
|
||||||
query = query.join(
|
rel_attr = cast(InstrumentedAttribute, relationship_attr)
|
||||||
target_alias,
|
target = cast(Any, target_alias)
|
||||||
relationship_attr.of_type(target_alias),
|
query = query.join(target, rel_attr.of_type(target), isouter=True)
|
||||||
isouter=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if params:
|
if params:
|
||||||
root_fields, rel_field_names, root_field_names = spec.parse_fields()
|
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:
|
if hard or not self.supports_soft_delete:
|
||||||
self.session.delete(obj)
|
self.session.delete(obj)
|
||||||
else:
|
else:
|
||||||
obj.is_deleted = True
|
soft = cast(_SoftDeletable, obj)
|
||||||
|
soft.is_deleted = True
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
self._log_version("delete", obj, actor)
|
self._log_version("delete", obj, actor)
|
||||||
return obj
|
return obj
|
||||||
|
|
|
||||||
25
crudkit/integration.py
Normal file
25
crudkit/integration.py
Normal file
|
|
@ -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)
|
||||||
120
crudkit/registry.py
Normal file
120
crudkit/registry.py
Normal file
|
|
@ -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/<modelname> 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
|
||||||
|
|
@ -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}")
|
print(f"[render_table] url_for failed: endpoint={spec}: params={params}")
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return url_for(spec["endpoint"], **params)
|
return url_for('crudkit.' + spec["endpoint"], **params)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[render_table] url_for failed: endpoint={spec['endpoint']} params={params} err={e}")
|
print(f"[render_table] url_for failed: endpoint={spec['endpoint']} params={params} err={e}")
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue