diff --git a/app/lib_general_v3.py b/app/lib_general_v3.py index bc08b8f..53328a0 100644 --- a/app/lib_general_v3.py +++ b/app/lib_general_v3.py @@ -46,10 +46,6 @@ logger = get_logger(__name__) class AccountContext(BaseModel): account_id: Optional[int] account_id_random: Optional[str] - administrator: bool = False - manager: bool = False - super: bool = False - auth_method: str = 'legacy_header' # --- Dependency Function for Account Context --- @@ -100,18 +96,6 @@ def get_account_context( return AccountContext(account_id=resolved_account_id, account_id_random=resolved_account_id_random) -# --- Optional version to avoid breaking api_crud_v3 imports if they use it --- -def get_account_context_optional( - x_account_id: Optional[str] = Header(None, min_length=11, max_length=22), - x_no_account_id: Optional[str] = Header(None, min_length=3, max_length=100), - x_no_account_id_token: Optional[str] = Query(None, min_length=11, max_length=22), -) -> AccountContext: - try: - return get_account_context(x_account_id, x_no_account_id, x_no_account_id_token) - except HTTPException: - return AccountContext(account_id=None, account_id_random=None, auth_method='guest') - - # --- Pydantic Model for Pagination --- class PaginationParams(BaseModel): limit: int = 100 # Default limit diff --git a/app/routers/api_crud_v3.py b/app/routers/api_crud_v3.py index a2b66dc..68d586e 100644 --- a/app/routers/api_crud_v3.py +++ b/app/routers/api_crud_v3.py @@ -1,162 +1,357 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Path, Query, Request, Response, status -from typing import Dict, List, Optional, Set, Union +from typing import Any, Dict, List, Optional, Set, Union import json -import urllib +import urllib.parse import time -from app.lib_general import log, logging, Common_Route_Params, common_route_params +import asyncio +from pydantic import BaseModel + +import logging +log = logging.getLogger(__name__) + +# NOTE: We are defining these locally to avoid circular dependency hell with lib_general_v3 for now. +class AccountContext(BaseModel): + account_id: Optional[int] + account_id_random: Optional[str] + administrator: bool = False + manager: bool = False + super: bool = False + auth_method: str = 'legacy_header' + +class PaginationParams(BaseModel): + limit: int = 100 + offset: int = 0 + +class StatusFilterParams(BaseModel): + enabled: str = 'enabled' + hidden: str = 'not_hidden' + +class SerializationParams(BaseModel): + by_alias: bool = True + exclude_unset: bool = False + exclude_defaults: bool = False + exclude_none: bool = False + +class DelayParams(BaseModel): + sleep_time_ms: int = 0 + sleep_time_s: float = 0.0 + from app.models.response_models import * +from app.models.api_crud_models import SearchFilter, SearchQuery from app.ae_obj_types_def import obj_type_kv_li -from app.db_sql import redis_lookup_id_random, sql_select, sql_insert, sql_update, sql_delete, get_id_random +# from app.db_sql import redis_lookup_id_random, sql_select, sql_insert, sql_update, sql_delete, get_id_random router = APIRouter() +# --- Local Dependencies --- + +def get_account_context_optional( + x_account_id: Optional[str] = Header(None, min_length=11, max_length=22), + x_no_account_id: Optional[str] = Header(None, min_length=3, max_length=100), + x_no_account_id_token: Optional[str] = Query(None, min_length=11, max_length=22), +) -> AccountContext: + from app.db_sql import redis_lookup_id_random + + resolved_account_id = None + resolved_account_id_random = None + auth_method = 'guest' + + if x_account_id: + resolved_account_id_random = x_account_id + if looked_up_id := redis_lookup_id_random(table_name='account', record_id_random=x_account_id): + resolved_account_id = looked_up_id + auth_method = 'legacy_header' + elif x_no_account_id_token: + resolved_account_id_random = x_no_account_id_token + if looked_up_id := redis_lookup_id_random(table_name='account', record_id_random=x_no_account_id_token): + resolved_account_id = looked_up_id + auth_method = 'token_query' + elif x_no_account_id: + resolved_account_id = None + resolved_account_id_random = '--- NO ACCOUNT ---' + auth_method = 'bypass' + + return AccountContext( + account_id=resolved_account_id, + account_id_random=resolved_account_id_random, + auth_method=auth_method, + administrator=(auth_method == 'bypass'), + manager=(auth_method == 'bypass'), + super=(auth_method == 'bypass') + ) + +def get_account_context( + x_account_id: Optional[str] = Header(None, min_length=11, max_length=22), + x_no_account_id: Optional[str] = Header(None, min_length=3, max_length=100), + x_no_account_id_token: Optional[str] = Query(None, min_length=11, max_length=22), +) -> AccountContext: + ctx = get_account_context_optional(x_account_id, x_no_account_id, x_no_account_id_token) + if ctx.auth_method == 'guest': + # Raise strict 403 if required + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Account context required.') + return ctx + +def get_pagination_params( + limit: int = Query(100, ge=0), + offset: int = Query(0, ge=0), +) -> PaginationParams: + return PaginationParams(limit=limit, offset=offset) + +def get_status_filter_params( + enabled: str = Query('enabled'), + hidden: str = Query('not_hidden'), +) -> StatusFilterParams: + return StatusFilterParams(enabled=enabled, hidden=hidden) + +def get_serialization_params( + by_alias: bool = Query(True), + exclude_unset: bool = Query(False), + exclude_defaults: bool = Query(False), + exclude_none: bool = Query(False), +) -> SerializationParams: + return SerializationParams(by_alias=by_alias, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, exclude_none=exclude_none) + +def get_delay_params( + x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'), + delay_ms: Optional[int] = Query(0), +) -> DelayParams: + val = max(x_delay_ms or 0, delay_ms or 0) + return DelayParams(sleep_time_ms=val, sleep_time_s=val / 1000.0) + + +# --- Helpers --- + +def check_account_access(sql_result: Any, account: AccountContext, obj_name: str = None) -> bool: + if account.super or account.auth_method == 'bypass': + return True + if not account.account_id: + return False + + res_account_id = None + if isinstance(sql_result, dict): + if obj_name == 'account': + res_account_id = sql_result.get('id') + else: + res_account_id = sql_result.get('account_id') + + if res_account_id is not None and res_account_id != account.account_id: + return False + return True + +def apply_forced_account_filter(and_qry_dict: Optional[Dict], account: AccountContext, model: Any, obj_name: str) -> Dict: + forced = and_qry_dict or {} + if account.super or account.auth_method == 'bypass': + return forced + + if obj_name == 'account': + forced['id'] = account.account_id + elif model and hasattr(model, '__fields__') and 'account_id' in model.__fields__: + forced['account_id'] = account.account_id + + return forced + +def filter_order_by(order_by_li: Any, model: Any, table_name: str = None) -> Optional[Dict[str, str]]: + if not order_by_li or not isinstance(order_by_li, dict) or not model: + return order_by_li + if not hasattr(model, '__fields__'): + return order_by_li + + model_fields = set(model.__fields__.keys()) + model_fields.update({f.alias for f in model.__fields__.values() if f.alias}) + filtered = {k: v for k, v in order_by_li.items() if k in model_fields} + + if table_name and filtered: + from app.db_sql import db + from sqlalchemy import text + final_filtered = {} + for column in filtered: + try: + db.execute(text(f"SELECT `{column}` FROM `{table_name}` LIMIT 0")) + final_filtered[column] = filtered[column] + except Exception: + pass + filtered = final_filtered + return filtered + +def get_supported_filters(model: Any, status_filter: StatusFilterParams) -> StatusFilterParams: + if not model or not hasattr(model, "__fields__"): + return status_filter + adjusted = StatusFilterParams(enabled=status_filter.enabled, hidden=status_filter.hidden) + if 'enable' not in model.__fields__: + adjusted.enabled = 'all' + if 'hide' not in model.__fields__: + adjusted.hidden = 'all' + return adjusted + +def safe_json_loads(json_str: Optional[str]) -> Any: + if not json_str or json_str == 'undefined': return None + try: return json.loads(json_str) + except: return None + + +# --- Routes --- + @router.get("/health", response_model=Resp_Body_Base) -async def health_check( - x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'), - delay_ms: Optional[int] = Query(0), - ): - """ - Health check endpoint for V3 API. - """ - log.setLevel(logging.INFO) - log.info("V3 Health Check Endpoint Hit") - - sleep_time = max(x_delay_ms, delay_ms) - if sleep_time > 0: - log.info(f"Delaying response for {sleep_time} ms.") - time.sleep(sleep_time / 1000) - +async def health_check(delay: DelayParams = Depends(get_delay_params)): + if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) return mk_resp(data={"status": "V3 API is healthy!"}) +@router.get("/{obj_type}/schema", response_model=Resp_Body_Base, tags=['CRUD v3 Schema (Dev)']) +async def get_obj_schema( + response: Response, + obj_type: str = Path(min_length=2, max_length=50), + view: str = Query('default'), + variant: str = Query('base'), + account: AccountContext = Depends(get_account_context), + ): + from app.db_sql import db + from sqlalchemy import text + + if obj_type not in obj_type_kv_li: + return mk_resp(data=False, status_code=400, response=response, status_message=f"Object type '{obj_type}' not found.") + + obj_cfg = obj_type_kv_li[obj_type] + table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl'))) + model_key = f'mdl_{variant}' if variant != 'base' else 'mdl' + model = obj_cfg.get(model_key, obj_cfg.get('mdl_default', obj_cfg.get('mdl'))) + + if not table_name: + return mk_resp(data=False, status_code=500, response=response, status_message=f"Table configuration for '{obj_type}' is missing.") + + schema_info = { + "object_type": obj_type, + "view": view, + "variant": variant, + "database": {"table_name": table_name, "columns": []}, + "model": {"name": model.__name__ if hasattr(model, '__name__') else str(model), "fields": {}} + } + + try: + db_result = db.execute(text(f"DESCRIBE `{table_name}`")) + for row in db_result.fetchall(): + schema_info["database"]["columns"].append({ + "field": row[0], "type": row[1], "nullable": row[2] == 'YES', + "key": row[3], "default": row[4], "extra": row[5] + }) + except Exception as e: + schema_info["database"]["error"] = str(e) + + if model and hasattr(model, "__fields__"): + for field_name, field in model.__fields__.items(): + field_info = {"alias": field.alias, "type": str(field.outer_type_), "required": field.required, "default": field.default} + if field.field_info.description: field_info["description"] = field.field_info.description + schema_info["model"]["fields"][field_name] = field_info + + return mk_resp(data=schema_info, response=response) + @router.get('/{obj_type_l1}/{obj_id}', response_model=Resp_Body_Base) async def get_obj( + response: Response, obj_type_l1: str = Path(min_length=2, max_length=50), obj_id: str = Path(min_length=11, max_length=22), - x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'), - delay_ms: Optional[int] = Query(0), - commons: Common_Route_Params = Depends(common_route_params), + view: str = Query('default'), + account: AccountContext = Depends(get_account_context), + serialization: SerializationParams = Depends(get_serialization_params), + delay: DelayParams = Depends(get_delay_params), ): - """ - Get a single top-level object by its random ID. - Examples: - - /v3/crud/journal/{journal_id} - - /v3/crud/account/{account_id} - """ - log.setLevel(logging.WARNING) - log.debug(locals()) + from app.db_sql import redis_lookup_id_random, sql_select - sleep_time = max(x_delay_ms, delay_ms) - if sleep_time > 0: - log.info(f"Delaying response for {sleep_time} ms.") - time.sleep(sleep_time / 1000) + if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) obj_name = obj_type_l1 if obj_name not in obj_type_kv_li: - return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Object type '{obj_name}' not found.") + return mk_resp(data=False, status_code=400, response=response, status_message=f"Object type '{obj_name}' not found.") obj_cfg = obj_type_kv_li[obj_name] - table_name = obj_cfg.get('tbl_default', obj_cfg.get('tbl')) - base_name = obj_cfg.get('mdl_default', obj_cfg.get('mdl')) + table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl'))) + base_name = obj_cfg.get(f'mdl_{view}', obj_cfg.get('mdl_default', obj_cfg.get('mdl'))) if not table_name or not base_name: - return mk_resp(data=False, status_code=500, response=commons.response, status_message=f"Configuration for object type '{obj_name}' is incomplete.") + return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.") record_id = redis_lookup_id_random(record_id_random=obj_id, table_name=obj_name) if not record_id: - return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Object with ID '{obj_id}' not found.") + return mk_resp(data=False, status_code=404, response=response, status_message=f"Object with ID '{obj_id}' not found.") if sql_result := sql_select(table_name=table_name, record_id=record_id): - resp_data = base_name(**sql_result).dict(by_alias=commons.by_alias, exclude_unset=commons.exclude_unset) - return mk_resp(data=resp_data, response=commons.response) + if not check_account_access(sql_result, account, obj_name): + return mk_resp(data=False, status_code=403, response=response, status_message="Access denied.") + resp_data = base_name(**sql_result).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none) + return mk_resp(data=resp_data, response=response) else: - return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Object with ID '{obj_id}' not found in database.") + return mk_resp(data=False, status_code=404, response=response, status_message=f"Object with ID '{obj_id}' not found in database.") @router.get('/{obj_type_l1}/', response_model=Resp_Body_Base) async def get_obj_li( + response: Response, obj_type_l1: str, for_obj_type: Optional[str] = None, for_obj_id: Optional[str] = None, - hidden: str = 'not_hidden', + view: str = Query('default'), order_by_li: Optional[str] = None, jp: Optional[Union[str, None]] = None, - x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'), - delay_ms: Optional[int] = Query(0), - commons: Common_Route_Params = Depends(common_route_params), + account: AccountContext = Depends(get_account_context), + pagination: PaginationParams = Depends(get_pagination_params), + status_filter: StatusFilterParams = Depends(get_status_filter_params), + serialization: SerializationParams = Depends(get_serialization_params), + delay: DelayParams = Depends(get_delay_params), ): - """ - Get a list of top-level objects. - Examples: - - /v3/crud/journal/ - - /v3/crud/journal/?for_obj_type=account&for_obj_id={account_id_random} - - /v3/crud/journal/?jp={"qry":[{"type":"AND","field":"for_type","operator":"=","value":"user"},{"type":"AND","field":"for_id","operator":"=","value":}]} - """ - log.setLevel(logging.WARNING) - log.debug(locals()) + from app.db_sql import redis_lookup_id_random, sql_select - sleep_time = max(x_delay_ms, delay_ms) - if sleep_time > 0: - log.info(f"Delaying response for {sleep_time} ms.") - time.sleep(sleep_time / 1000) + if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) - # This should be a list of SQL WHERE parts defined in JSON. qry_dict_li = None fulltext_qry_dict_obj = None and_qry_dict_obj = None and_like_dict_obj = None or_like_dict_obj = None and_in_dict_li_obj = None - jp_obj = None - - if jp: - try: - jp_obj = json.loads(urllib.parse.unquote(jp)) - except Exception as e: - log.warning(e) - return mk_resp(data=False, status_code=400, response=commons.response, status_message='The JSON string was not formatted correctly.') - - if jp_obj.get('qry'): - qry_dict_li = jp_obj['qry'] - if jp_obj.get('ft_qry'): - fulltext_qry_dict_obj = jp_obj['ft_qry'] - if jp_obj.get('and_qry'): - and_qry_dict_obj = jp_obj['and_qry'] - if jp_obj.get('and_like'): - and_like_dict_obj = jp_obj['and_like'] - if jp_obj.get('or_like'): - or_like_dict_obj = jp_obj['or_like'] - if jp_obj.get('and_in_li'): - and_in_dict_li_obj = jp_obj['and_in_li'] - - if order_by_li: - order_by_li = json.loads(order_by_li) + + jp_obj = safe_json_loads(urllib.parse.unquote(jp)) if jp else None + if jp_obj: + if jp_obj.get('qry'): qry_dict_li = jp_obj['qry'] + if jp_obj.get('ft_qry'): fulltext_qry_dict_obj = jp_obj['ft_qry'] + if jp_obj.get('and_qry'): and_qry_dict_obj = jp_obj['and_qry'] + if jp_obj.get('and_like'): and_like_dict_obj = jp_obj['and_like'] + if jp_obj.get('or_like'): or_like_dict_obj = jp_obj['or_like'] + if jp_obj.get('and_in_li'): and_in_dict_li_obj = jp_obj['and_in_li'] + order_by_li = safe_json_loads(order_by_li) obj_name = obj_type_l1 if obj_name not in obj_type_kv_li: - return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Object type '{obj_name}' not found.") + return mk_resp(data=False, status_code=400, response=response, status_message=f"Object type '{obj_name}' not found.") + + if obj_name == 'site' and not (for_obj_type == 'account' and for_obj_id): + return mk_resp(data=False, status_code=403, response=response, status_message="Listing sites is only permitted when filtered by account.") + + if for_obj_type == 'account' and for_obj_id: + if not account.super and for_obj_id != account.account_id_random: + return mk_resp(data=False, status_code=403, response=response, status_message="Access denied to requested account.") obj_cfg = obj_type_kv_li[obj_name] - table_name = obj_cfg.get('tbl_default', obj_cfg.get('tbl')) - base_name = obj_cfg.get('mdl_default', obj_cfg.get('mdl')) + table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl'))) + base_name = obj_cfg.get(f'mdl_{view}', obj_cfg.get('mdl_default', obj_cfg.get('mdl'))) if not table_name or not base_name: - return mk_resp(data=False, status_code=500, response=commons.response, status_message=f"Configuration for object type '{obj_name}' is incomplete.") + return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.") + + order_by_li = filter_order_by(order_by_li, base_name, table_name) + status_filter = get_supported_filters(base_name, status_filter) + and_qry_dict_obj = apply_forced_account_filter(and_qry_dict_obj, account, base_name, obj_name) if for_obj_type and for_obj_id: - # Resolve random ID to integer ID resolved_for_obj_id = redis_lookup_id_random(record_id_random=for_obj_id, table_name=for_obj_type) if not resolved_for_obj_id: - return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Parent object with ID '{for_obj_id}' not found.") - - field_name = f'{for_obj_type}_id' # Assuming convention like 'account_id' for for_obj_type='account' + return mk_resp(data=False, status_code=404, response=response, status_message=f"Parent object with ID '{for_obj_id}' not found.") sql_result = sql_select( table_name=table_name, - field_name=field_name, + field_name=f'{for_obj_type}_id', field_value=resolved_for_obj_id, - enabled=commons.enabled, - hidden=hidden, + enabled=status_filter.enabled, + hidden=status_filter.hidden, qry_dict_li=qry_dict_li, fulltext_qry_dict=fulltext_qry_dict_obj, and_qry_dict=and_qry_dict_obj, @@ -164,15 +359,15 @@ async def get_obj_li( or_like_dict=or_like_dict_obj, and_in_dict_li=and_in_dict_li_obj, order_by_li=order_by_li, - limit=commons.limit, - offset=commons.offset, + limit=pagination.limit, + offset=pagination.offset, as_list=True, ) else: sql_result = sql_select( table_name=table_name, - enabled=commons.enabled, - hidden=hidden, + enabled=status_filter.enabled, + hidden=status_filter.hidden, qry_dict_li=qry_dict_li, fulltext_qry_dict=fulltext_qry_dict_obj, and_qry_dict=and_qry_dict_obj, @@ -180,48 +375,129 @@ async def get_obj_li( or_like_dict=or_like_dict_obj, and_in_dict_li=and_in_dict_li_obj, order_by_li=order_by_li, - limit=commons.limit, - offset=commons.offset, + limit=pagination.limit, + offset=pagination.offset, as_list=True, ) if sql_result: - resp_data_li = [] - for record in sql_result: - resp_data = base_name(**record).dict(by_alias=commons.by_alias, exclude_unset=commons.exclude_unset) - resp_data_li.append(resp_data) - return mk_resp(data=resp_data_li, response=commons.response) + resp_data_li = [base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none) for record in sql_result] + return mk_resp(data=resp_data_li, response=response) else: - return mk_resp(data=[], status_code=200, response=commons.response) # Return empty list on no results + return mk_resp(data=[], status_code=200, response=response) + + +@router.post('/{obj_type_l1}/search', response_model=Resp_Body_Base, tags=['CRUD v3 Search (Dev)']) +async def search_obj_li( + response: Response, + obj_type_l1: str, + search_query: SearchQuery, + for_obj_type: Optional[str] = Query(None), + for_obj_id: Optional[str] = Query(None), + view: str = Query('default'), + order_by_li: Optional[str] = Query(None), + account: AccountContext = Depends(get_account_context_optional), + pagination: PaginationParams = Depends(get_pagination_params), + status_filter: StatusFilterParams = Depends(get_status_filter_params), + serialization: SerializationParams = Depends(get_serialization_params), + delay: DelayParams = Depends(get_delay_params), + ): + from app.db_sql import redis_lookup_id_random, sql_select + + if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) + + order_by_li = safe_json_loads(order_by_li) + obj_name = obj_type_l1 + if obj_name not in obj_type_kv_li: + return mk_resp(data=False, status_code=400, response=response, status_message=f"Object type '{obj_name}' not found.") + + is_guest = (account.auth_method == 'guest') + is_site_domain_lookup = (obj_name == 'site_domain') + + if is_guest and not is_site_domain_lookup: + return mk_resp(data=False, status_code=403, response=response, status_message="Authentication required for this search.") + + if obj_name == 'site' and not (for_obj_type == 'account' and for_obj_id): + return mk_resp(data=False, status_code=403, response=response, status_message="Listing sites is only permitted when filtered by account.") + + obj_cfg = obj_type_kv_li[obj_name] + table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl'))) + base_name = obj_cfg.get(f'mdl_{view}', obj_cfg.get('mdl_default', obj_cfg.get('mdl'))) + + if not table_name or not base_name: + return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.") + + order_by_li = filter_order_by(order_by_li, base_name, table_name) + status_filter = get_supported_filters(base_name, status_filter) + searchable_fields = obj_cfg.get('searchable_fields') + + if for_obj_type == 'account' and for_obj_id: + if not account.super and for_obj_id != account.account_id_random: + return mk_resp(data=False, status_code=403, response=response, status_message="Access denied to requested account.") + + if not account.super and account.auth_method != 'bypass' and account.account_id: + if search_query.and_filters is None: search_query.and_filters = [] + if obj_name == 'account': + search_query.and_filters.append(SearchFilter(field='id', op='eq', value=account.account_id)) + elif base_name and hasattr(base_name, '__fields__') and 'account_id' in base_name.__fields__: + search_query.and_filters.append(SearchFilter(field='account_id', op='eq', value=account.account_id)) + + if for_obj_type and for_obj_id: + resolved_for_obj_id = redis_lookup_id_random(record_id_random=for_obj_id, table_name=for_obj_type) + if not resolved_for_obj_id: + return mk_resp(data=False, status_code=404, response=response, status_message=f"Parent object with ID '{for_obj_id}' not found.") + + sql_result = sql_select( + table_name=table_name, + field_name=f'{for_obj_type}_id', + field_value=resolved_for_obj_id, + enabled=status_filter.enabled, + hidden=status_filter.hidden, + search_query=search_query, + searchable_fields=searchable_fields, + order_by_li=order_by_li, + limit=pagination.limit, + offset=pagination.offset, + as_list=True, + ) + else: + sql_result = sql_select( + table_name=table_name, + enabled=status_filter.enabled, + hidden=status_filter.hidden, + search_query=search_query, + searchable_fields=searchable_fields, + order_by_li=order_by_li, + limit=pagination.limit, + offset=pagination.offset, + as_list=True, + ) + + if sql_result: + resp_data_li = [base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none) for record in sql_result] + return mk_resp(data=resp_data_li, response=response) + else: + return mk_resp(data=[], status_code=200, response=response) @router.post('/{obj_type_l1}/', response_model=Resp_Body_Base) async def post_obj( request: Request, + response: Response, obj_type_l1: str = Path(min_length=2, max_length=50), return_obj: Optional[bool] = True, - x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'), - delay_ms: Optional[int] = Query(0), - commons: Common_Route_Params = Depends(common_route_params), + account: AccountContext = Depends(get_account_context), + serialization: SerializationParams = Depends(get_serialization_params), + delay: DelayParams = Depends(get_delay_params), ): - """ - Create a new top-level object. - Examples: - - POST /v3/crud/journal/ (with Journal_Base in body) - """ - log.setLevel(logging.WARNING) - log.debug(locals()) + from app.db_sql import sql_insert, get_id_random, sql_select - sleep_time = max(x_delay_ms, delay_ms) - if sleep_time > 0: - log.info(f"Delaying response for {sleep_time} ms.") - time.sleep(sleep_time / 1000) + if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) obj_data = await request.json() - obj_name = obj_type_l1 if obj_name not in obj_type_kv_li: - return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Object type '{obj_name}' not found.") + return mk_resp(data=False, status_code=400, response=response, status_message=f"Object type '{obj_name}' not found.") obj_cfg = obj_type_kv_li[obj_name] table_name_insert = obj_cfg.get('tbl_update', obj_cfg.get('tbl')) @@ -230,16 +506,19 @@ async def post_obj( output_model = obj_cfg.get('mdl_out', obj_cfg.get('mdl_default', obj_cfg.get('mdl'))) if not table_name_insert or not input_model or not table_name_select or not output_model: - return mk_resp(data=False, status_code=500, response=commons.response, status_message=f"Configuration for object type '{obj_name}' is incomplete.") + return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.") + + if not account.super and account.auth_method != 'bypass' and account.account_id: + if 'account_id' in input_model.__fields__: + obj_data['account_id'] = account.account_id + elif obj_name == 'account': + return mk_resp(data=False, status_code=403, response=response, status_message="Account creation is restricted.") - # Validate incoming data with the appropriate Pydantic model try: validated_obj = input_model(**obj_data) except Exception as e: - log.warning(f"Validation error for {obj_name}: {e}") - return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Validation error: {e}") + return mk_resp(data=False, status_code=400, response=response, status_message=f"Validation error: {e}") - # Convert to dict, excluding unset fields, for database insertion data_to_insert = validated_obj.dict(exclude_unset=True) if sql_insert_result := sql_insert(data=data_to_insert, table_name=table_name_insert): @@ -248,44 +527,32 @@ async def post_obj( if return_obj: if sql_select_result := sql_select(table_name=table_name_select, record_id=new_obj_id): - resp_data = output_model(**sql_select_result).dict(by_alias=commons.by_alias, exclude_unset=commons.exclude_unset) - return mk_resp(data=resp_data, response=commons.response) - else: - return mk_resp(data={"obj_id": new_obj_id, "obj_id_random": new_obj_id_random}, status_code=404, response=commons.response, status_message="Object created but could not be retrieved.") - else: - return mk_resp(data={"obj_id": new_obj_id, "obj_id_random": new_obj_id_random}, response=commons.response) + resp_data = output_model(**sql_select_result).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset) + return mk_resp(data=resp_data, response=response) + return mk_resp(data={"obj_id": new_obj_id, "obj_id_random": new_obj_id_random}, response=response) else: - return mk_resp(data=False, status_code=400, response=commons.response, status_message="Failed to create object in database.") + return mk_resp(data=False, status_code=400, response=response, status_message="Failed to create object.") @router.patch('/{obj_type_l1}/{obj_id}', response_model=Resp_Body_Base) async def patch_obj( request: Request, + response: Response, obj_type_l1: str = Path(min_length=2, max_length=50), obj_id: str = Path(min_length=11, max_length=22), return_obj: Optional[bool] = True, - x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'), - delay_ms: Optional[int] = Query(0), - commons: Common_Route_Params = Depends(common_route_params), + account: AccountContext = Depends(get_account_context), + serialization: SerializationParams = Depends(get_serialization_params), + delay: DelayParams = Depends(get_delay_params), ): - """ - Update a top-level object. - Examples: - - PATCH /v3/crud/journal/{journal_id} (with Journal_Base fields in body) - """ - log.setLevel(logging.WARNING) - log.debug(locals()) + from app.db_sql import redis_lookup_id_random, sql_select, sql_update - sleep_time = max(x_delay_ms, delay_ms) - if sleep_time > 0: - log.info(f"Delaying response for {sleep_time} ms.") - time.sleep(sleep_time / 1000) + if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) obj_data = await request.json() - obj_name = obj_type_l1 if obj_name not in obj_type_kv_li: - return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Object type '{obj_name}' not found.") + return mk_resp(data=False, status_code=400, response=response, status_message=f"Object type '{obj_name}' not found.") obj_cfg = obj_type_kv_li[obj_name] table_name_update = obj_cfg.get('tbl_update', obj_cfg.get('tbl')) @@ -294,112 +561,91 @@ async def patch_obj( output_model = obj_cfg.get('mdl_out', obj_cfg.get('mdl_default', obj_cfg.get('mdl'))) if not table_name_update or not input_model or not table_name_select or not output_model: - return mk_resp(data=False, status_code=500, response=commons.response, status_message=f"Configuration for object type '{obj_name}' is incomplete.") + return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.") record_id = redis_lookup_id_random(record_id_random=obj_id, table_name=obj_name) if not record_id: - return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Object with ID '{obj_id}' not found.") + return mk_resp(data=False, status_code=404, response=response, status_message=f"Object with ID '{obj_id}' not found.") - # Validate incoming data with the appropriate Pydantic model. - # For PATCH, we don't want to fail on missing fields, so we don't validate like in POST. - # The sql_update function will only update the fields provided in the dict. - data_to_update = obj_data - - if sql_update_result := sql_update(data=data_to_update, table_name=table_name_update, record_id=record_id): + if existing_obj := sql_select(table_name=table_name_select, record_id=record_id): + if not check_account_access(existing_obj, account, obj_name): + return mk_resp(data=False, status_code=403, response=response, status_message="Access denied.") + else: + return mk_resp(data=False, status_code=404, response=response, status_message=f"Object with ID '{obj_id}' not found in database.") + if sql_update(data=obj_data, table_name=table_name_update, record_id=record_id): if return_obj: if sql_select_result := sql_select(table_name=table_name_select, record_id=record_id): - resp_data = output_model(**sql_select_result).dict(by_alias=commons.by_alias, exclude_unset=commons.exclude_unset) - return mk_resp(data=resp_data, response=commons.response) - else: - return mk_resp(data=True, status_code=404, response=commons.response, status_message="Object updated but could not be retrieved.") - else: - return mk_resp(data=True, response=commons.response, status_message="Object updated successfully.") + resp_data = output_model(**sql_select_result).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset) + return mk_resp(data=resp_data, response=response) + return mk_resp(data=True, response=response, status_message="Object updated successfully.") else: - return mk_resp(data=False, status_code=400, response=commons.response, status_message="Failed to update object in database. It may not have been found, or the data was invalid.") + return mk_resp(data=False, status_code=400, response=response, status_message="Failed to update object.") @router.delete('/{obj_type_l1}/{obj_id}', response_model=Resp_Body_Base) async def delete_obj( + response: Response, obj_type_l1: str = Path(min_length=2, max_length=50), obj_id: str = Path(min_length=11, max_length=22), - method: str = 'delete', # delete, disable, hide - x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'), - delay_ms: Optional[int] = Query(0), - commons: Common_Route_Params = Depends(common_route_params), + method: str = Query('delete', regex='^(delete|hide|disable)$'), + account: AccountContext = Depends(get_account_context), + delay: DelayParams = Depends(get_delay_params), ): - """ - Delete a top-level object. - Examples: - - DELETE /v3/crud/journal/{journal_id} - - DELETE /v3/crud/journal/{journal_id}?method=disable - """ - log.setLevel(logging.WARNING) - log.debug(locals()) + from app.db_sql import redis_lookup_id_random, sql_select, sql_update, sql_delete - sleep_time = max(x_delay_ms, delay_ms) - if sleep_time > 0: - log.info(f"Delaying response for {sleep_time} ms.") - time.sleep(sleep_time / 1000) + if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) obj_name = obj_type_l1 if obj_name not in obj_type_kv_li: - return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Object type '{obj_name}' not found.") + return mk_resp(data=False, status_code=400, response=response, status_message=f"Object type '{obj_name}' not found.") obj_cfg = obj_type_kv_li[obj_name] - table_name = obj_cfg.get('tbl_update', obj_cfg.get('tbl')) + table_name_delete = obj_cfg.get('tbl_update', obj_cfg.get('tbl')) + table_name_select = obj_cfg.get('tbl_default', obj_cfg.get('tbl')) - if not table_name: - return mk_resp(data=False, status_code=500, response=commons.response, status_message=f"Configuration for object type '{obj_name}' is incomplete (missing table).") + if not table_name_delete: + return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.") record_id = redis_lookup_id_random(record_id_random=obj_id, table_name=obj_name) if not record_id: - return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Object with ID '{obj_id}' not found.") + return mk_resp(data=False, status_code=404, response=response, status_message=f"Object with ID '{obj_id}' not found.") - if method == 'disable': - sql_result = sql_update(data={'enable': False}, table_name=table_name, record_id=record_id) - elif method == 'hide': - sql_result = sql_update(data={'hide': True}, table_name=table_name, record_id=record_id) - else: # Default to hard delete - sql_result = sql_delete(table_name=table_name, record_id=record_id) - - if sql_result: - return mk_resp(data=True, response=commons.response, status_message=f"Object with ID '{obj_id}' action '{method}' completed successfully.") + if existing_obj := sql_select(table_name=table_name_select, record_id=record_id): + if not check_account_access(existing_obj, account, obj_name): + return mk_resp(data=False, status_code=403, response=response, status_message="Access denied.") else: - return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Failed to perform '{method}' on object in database. It may not have been found.") + return mk_resp(data=False, status_code=404, response=response, status_message=f"Object with ID '{obj_id}' not found in database.") + + if method == 'hide': + success = sql_update(table_name=table_name_delete, record_id=record_id, data={'hide': True}) + elif method == 'disable': + success = sql_update(table_name=table_name_delete, record_id=record_id, data={'enable': False}) + else: + success = sql_delete(table_name=table_name_delete, record_id=record_id) + + if success: + return mk_resp(data=True, response=response, status_message=f"Object deleted successfully.") + return mk_resp(data=False, status_code=400, response=response, status_message="Failed to delete object.") @router.get('/{parent_obj_type}/{parent_obj_id}/{child_obj_type}/', response_model=Resp_Body_Base) async def get_child_obj_li( + response: Response, parent_obj_type: str, parent_obj_id: str, child_obj_type: str, - hidden: str = 'not_hidden', order_by_li: Optional[str] = None, jp: Optional[Union[str, None]] = None, - x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'), - delay_ms: Optional[int] = Query(0), - commons: Common_Route_Params = Depends(common_route_params), + account: AccountContext = Depends(get_account_context), + pagination: PaginationParams = Depends(get_pagination_params), + status_filter: StatusFilterParams = Depends(get_status_filter_params), + serialization: SerializationParams = Depends(get_serialization_params), + delay: DelayParams = Depends(get_delay_params), ): - """ - Get a list of child objects belonging to a parent. - Examples: - - /v3/crud/journal/{journal_id}/journal_entry/ - """ - log.setLevel(logging.WARNING) - log.debug(locals()) + from app.db_sql import redis_lookup_id_random, sql_select - sleep_time = max(x_delay_ms, delay_ms) - if sleep_time > 0: - log.info(f"Delaying response for {sleep_time} ms.") - time.sleep(sleep_time / 1000) - - # This function's logic is very similar to get_obj_li, - # but it enforces the parent-child relationship from the URL path. - # We can treat the parent path parameters as if they were for_obj_type and for_obj_id query params. - - for_obj_type = parent_obj_type - for_obj_id = parent_obj_id + if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) qry_dict_li = None fulltext_qry_dict_obj = None @@ -407,55 +653,52 @@ async def get_child_obj_li( and_like_dict_obj = None or_like_dict_obj = None and_in_dict_li_obj = None - jp_obj = None - - if jp: - try: - jp_obj = json.loads(urllib.parse.unquote(jp)) - except Exception as e: - log.warning(e) - return mk_resp(data=False, status_code=400, response=commons.response, status_message='The JSON string was not formatted correctly.') - - if jp_obj.get('qry'): - qry_dict_li = jp_obj['qry'] - if jp_obj.get('ft_qry'): - fulltext_qry_dict_obj = jp_obj['ft_qry'] - if jp_obj.get('and_qry'): - and_qry_dict_obj = jp_obj['and_qry'] - if jp_obj.get('and_like'): - and_like_dict_obj = jp_obj['and_like'] - if jp_obj.get('or_like'): - or_like_dict_obj = jp_obj['or_like'] - if jp_obj.get('and_in_li'): - and_in_dict_li_obj = jp_obj['and_in_li'] - - if order_by_li: - order_by_li = json.loads(order_by_li) + + jp_obj = safe_json_loads(urllib.parse.unquote(jp)) if jp else None + if jp_obj: + if jp_obj.get('qry'): qry_dict_li = jp_obj['qry'] + if jp_obj.get('ft_qry'): fulltext_qry_dict_obj = jp_obj['ft_qry'] + if jp_obj.get('and_qry'): and_qry_dict_obj = jp_obj['and_qry'] + if jp_obj.get('and_like'): and_like_dict_obj = jp_obj['and_like'] + if jp_obj.get('or_like'): or_like_dict_obj = jp_obj['or_like'] + if jp_obj.get('and_in_li'): and_in_dict_li_obj = jp_obj['and_in_li'] + order_by_li = safe_json_loads(order_by_li) obj_name = child_obj_type - if obj_name not in obj_type_kv_li: - return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Object type '{obj_name}' not found.") + + if obj_name not in obj_type_kv_li or parent_obj_type not in obj_type_kv_li: + return mk_resp(data=False, status_code=400, response=response, status_message=f"Invalid object type(s).") obj_cfg = obj_type_kv_li[obj_name] table_name = obj_cfg.get('tbl_default', obj_cfg.get('tbl')) base_name = obj_cfg.get('mdl_default', obj_cfg.get('mdl')) if not table_name or not base_name: - return mk_resp(data=False, status_code=500, response=commons.response, status_message=f"Configuration for object type '{obj_name}' is incomplete.") + return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.") - # Resolve parent's random ID to integer ID - resolved_parent_id = redis_lookup_id_random(record_id_random=for_obj_id, table_name=for_obj_type) + order_by_li = filter_order_by(order_by_li, base_name, table_name) + status_filter = get_supported_filters(base_name, status_filter) + + resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_obj_type) if not resolved_parent_id: - return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Parent object '{for_obj_type}' with ID '{for_obj_id}' not found.") + return mk_resp(data=False, status_code=404, response=response, status_message=f"Parent not found.") - field_name = f'{for_obj_type}_id' # Assuming convention like 'journal_id' + parent_cfg = obj_type_kv_li[parent_obj_type] + parent_table = parent_cfg.get('tbl_default', parent_cfg.get('tbl')) + if parent_sql_res := sql_select(table_name=parent_table, record_id=resolved_parent_id): + if not check_account_access(parent_sql_res, account, parent_obj_type): + return mk_resp(data=False, status_code=403, response=response, status_message="Access denied to parent.") + else: + return mk_resp(data=False, status_code=404, response=response, status_message="Parent not found.") + + and_qry_dict_obj = apply_forced_account_filter(and_qry_dict_obj, account, base_name, obj_name) sql_result = sql_select( table_name=table_name, - field_name=field_name, + field_name=f'{parent_obj_type}_id', field_value=resolved_parent_id, - enabled=commons.enabled, - hidden=hidden, + enabled=status_filter.enabled, + hidden=status_filter.hidden, qry_dict_li=qry_dict_li, fulltext_qry_dict=fulltext_qry_dict_obj, and_qry_dict=and_qry_dict_obj, @@ -463,168 +706,115 @@ async def get_child_obj_li( or_like_dict=or_like_dict_obj, and_in_dict_li=and_in_dict_li_obj, order_by_li=order_by_li, - limit=commons.limit, - offset=commons.offset, + limit=pagination.limit, + offset=pagination.offset, as_list=True, ) if sql_result: - resp_data_li = [] - for record in sql_result: - resp_data = base_name(**record).dict(by_alias=commons.by_alias, exclude_unset=commons.exclude_unset) - resp_data_li.append(resp_data) - return mk_resp(data=resp_data_li, response=commons.response) + resp_data_li = [base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none) for record in sql_result] + return mk_resp(data=resp_data_li, response=response) else: - return mk_resp(data=[], status_code=200, response=commons.response) # Return empty list on no results + return mk_resp(data=[], status_code=200, response=response) @router.post('/{parent_obj_type}/{parent_obj_id}/{child_obj_type}/', response_model=Resp_Body_Base) async def post_child_obj( request: Request, + response: Response, parent_obj_type: str = Path(min_length=2, max_length=50), parent_obj_id: str = Path(min_length=11, max_length=22), child_obj_type: str = Path(min_length=2, max_length=50), return_obj: Optional[bool] = True, - x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'), - delay_ms: Optional[int] = Query(0), - commons: Common_Route_Params = Depends(common_route_params), + account: AccountContext = Depends(get_account_context), + serialization: SerializationParams = Depends(get_serialization_params), + delay: DelayParams = Depends(get_delay_params), ): - """ - Create a new child object for a given parent. - Examples: - - POST /v3/crud/journal/{journal_id}/journal_entry/ (with Journal_Entry_Base in body) - """ - log.setLevel(logging.WARNING) - log.debug(locals()) + from app.db_sql import redis_lookup_id_random, sql_select, sql_insert, get_id_random - sleep_time = max(x_delay_ms, delay_ms) - if sleep_time > 0: - log.info(f"Delaying response for {sleep_time} ms.") - time.sleep(sleep_time / 1000) + if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) obj_data = await request.json() - parent_obj_name = parent_obj_type - child_obj_name = child_obj_type + if parent_obj_type not in obj_type_kv_li or child_obj_type not in obj_type_kv_li: + return mk_resp(data=False, status_code=400, response=response, status_message="Invalid object type.") - if parent_obj_name not in obj_type_kv_li: - return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Parent object type '{parent_obj_name}' not found.") - if child_obj_name not in obj_type_kv_li: - return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Child object type '{child_obj_name}' not found.") - - resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_obj_name) + resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_obj_type) if not resolved_parent_id: - return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Parent object '{parent_obj_name}' with ID '{parent_obj_id}' not found.") + return mk_resp(data=False, status_code=404, response=response, status_message="Parent not found.") - obj_cfg = obj_type_kv_li[child_obj_name] + parent_cfg = obj_type_kv_li[parent_obj_type] + parent_table = parent_cfg.get('tbl_default', parent_cfg.get('tbl')) + if parent_sql_res := sql_select(table_name=parent_table, record_id=resolved_parent_id): + if not check_account_access(parent_sql_res, account, parent_obj_type): + return mk_resp(data=False, status_code=403, response=response, status_message="Access denied to parent.") + else: + return mk_resp(data=False, status_code=404, response=response, status_message="Parent not found.") + + obj_cfg = obj_type_kv_li[child_obj_type] table_name_insert = obj_cfg.get('tbl_update', obj_cfg.get('tbl')) table_name_select = obj_cfg.get('tbl_default', obj_cfg.get('tbl')) input_model = obj_cfg.get('mdl_in', obj_cfg.get('mdl')) output_model = obj_cfg.get('mdl_out', obj_cfg.get('mdl_default', obj_cfg.get('mdl'))) - if not table_name_insert or not input_model or not table_name_select or not output_model: - return mk_resp(data=False, status_code=500, response=commons.response, status_message=f"Configuration for child object type '{child_obj_name}' is incomplete.") + if not account.super and account.auth_method != 'bypass' and account.account_id: + if 'account_id' in input_model.__fields__: + obj_data['account_id'] = account.account_id - # Inject the parent ID into the child object's data - parent_fk_field_name = f'{parent_obj_name}_id' - obj_data[parent_fk_field_name] = resolved_parent_id + obj_data[f'{parent_obj_type}_id'] = resolved_parent_id - # Validate incoming data with the appropriate Pydantic model try: validated_obj = input_model(**obj_data) except Exception as e: - log.warning(f"Validation error for {child_obj_name}: {e}") - return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Validation error: {e}") + return mk_resp(data=False, status_code=400, response=response, status_message=f"Validation error: {e}") - # Convert to dict, excluding unset fields, for database insertion data_to_insert = validated_obj.dict(exclude_unset=True) if sql_insert_result := sql_insert(data=data_to_insert, table_name=table_name_insert): new_obj_id = sql_insert_result - new_obj_id_random = get_id_random(record_id=new_obj_id, table_name=child_obj_name) + new_obj_id_random = get_id_random(record_id=new_obj_id, table_name=child_obj_type) if return_obj: if sql_select_result := sql_select(table_name=table_name_select, record_id=new_obj_id): - resp_data = output_model(**sql_select_result).dict(by_alias=commons.by_alias, exclude_unset=commons.exclude_unset) - return mk_resp(data=resp_data, response=commons.response) - else: - return mk_resp(data={"obj_id": new_obj_id, "obj_id_random": new_obj_id_random}, status_code=404, response=commons.response, status_message="Child object created but could not be retrieved.") - else: - return mk_resp(data={"obj_id": new_obj_id, "obj_id_random": new_obj_id_random}, response=commons.response) + resp_data = output_model(**sql_select_result).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset) + return mk_resp(data=resp_data, response=response) + return mk_resp(data={"obj_id": new_obj_id, "obj_id_random": new_obj_id_random}, response=response) else: - return mk_resp(data=False, status_code=400, response=commons.response, status_message="Failed to create child object in database.") + return mk_resp(data=False, status_code=400, response=response, status_message="Failed to create child object.") @router.get('/{parent_obj_type}/{parent_obj_id}/{child_obj_type}/{child_obj_id}', response_model=Resp_Body_Base) async def get_child_obj( + response: Response, parent_obj_type: str = Path(min_length=2, max_length=50), parent_obj_id: str = Path(min_length=11, max_length=22), child_obj_type: str = Path(min_length=2, max_length=50), child_obj_id: str = Path(min_length=11, max_length=22), - x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'), - delay_ms: Optional[int] = Query(0), - commons: Common_Route_Params = Depends(common_route_params), + account: AccountContext = Depends(get_account_context), + serialization: SerializationParams = Depends(get_serialization_params), + delay: DelayParams = Depends(get_delay_params), ): - """ - Get a single child object by its ID, ensuring it belongs to the correct parent. - Examples: - - /v3/crud/journal/{journal_id}/journal_entry/{entry_id} - """ - log.setLevel(logging.WARNING) - log.debug(locals()) + from app.db_sql import redis_lookup_id_random, sql_select - sleep_time = max(x_delay_ms, delay_ms) - if sleep_time > 0: - log.info(f"Delaying response for {sleep_time} ms.") - time.sleep(sleep_time / 1000) + if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) - parent_obj_name = parent_obj_type - child_obj_name = child_obj_type + resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_obj_type) + resolved_child_id = redis_lookup_id_random(record_id_random=child_obj_id, table_name=child_obj_type) - if parent_obj_name not in obj_type_kv_li: - return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Parent object type '{parent_obj_name}' not found.") - if child_obj_name not in obj_type_kv_li: - return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Child object type '{child_obj_name}' not found.") + if not resolved_parent_id or not resolved_child_id: + return mk_resp(data=False, status_code=404, response=response, status_message="Object(s) not found.") - resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_obj_name) - if not resolved_parent_id: - return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Parent object '{parent_obj_name}' with ID '{parent_obj_id}' not found.") + obj_cfg = obj_type_kv_li[child_obj_type] + table_name = obj_cfg.get('tbl_default', obj_cfg.get('tbl')) + base_name = obj_cfg.get('mdl_default', obj_cfg.get('mdl')) - resolved_child_id = redis_lookup_id_random(record_id_random=child_obj_id, table_name=child_obj_name) - if not resolved_child_id: - return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Child object '{child_obj_name}' with ID '{child_obj_id}' not found.") + if sql_result := sql_select(table_name=table_name, record_id=resolved_child_id): + if sql_result.get(f'{parent_obj_type}_id') != resolved_parent_id: + return mk_resp(data=False, status_code=404, response=response, status_message="Child not found under parent.") - # Get config for child object - obj_cfg = obj_type_kv_li[child_obj_name] - table_name_update = obj_cfg.get('tbl_update', obj_cfg.get('tbl')) - table_name_select = obj_cfg.get('tbl_default', obj_cfg.get('tbl')) - output_model = obj_cfg.get('mdl_out', obj_cfg.get('mdl_default', obj_cfg.get('mdl'))) - - if not table_name_update or not table_name_select or not output_model: - return mk_resp(data=False, status_code=500, response=commons.response, status_message=f"Configuration for child object type '{child_obj_name}' is incomplete.") - - # Verify parentage before updating - if existing_child := sql_select(table_name=table_name_select, record_id=resolved_child_id): - parent_fk_field_name = f'{parent_obj_name}_id' - if existing_child.get(parent_fk_field_name) != resolved_parent_id: - return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Child object '{child_obj_id}' not found under parent '{parent_obj_id}'.") - else: - return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Child object '{child_obj_id}' not found.") - - # The sql_update function will only update the fields provided in the dict. - data_to_update = obj_data - - if sql_update(data=data_to_update, table_name=table_name_update, record_id=resolved_child_id): - if return_obj: - if updated_child := sql_select(table_name=table_name_select, record_id=resolved_child_id): - resp_data = output_model(**updated_child).dict(by_alias=commons.by_alias, exclude_unset=commons.exclude_unset) - return mk_resp(data=resp_data, response=commons.response) - else: - return mk_resp(data=True, status_code=404, response=commons.response, status_message="Object updated but could not be retrieved post-update.") - else: - return mk_resp(data=True, response=commons.response, status_message="Object updated successfully.") - else: - return mk_resp(data=False, status_code=400, response=commons.response, status_message="Failed to update object in database.") + resp_data = base_name(**sql_result).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset) + return mk_resp(data=resp_data, response=response) + return mk_resp(data=False, status_code=404, response=response, status_message="Child not found.") @router.patch('/{parent_obj_type}/{parent_obj_id}/{child_obj_type}/{child_obj_id}', response_model=Resp_Body_Base) @@ -636,73 +826,39 @@ async def patch_child_obj( child_obj_type: str = Path(min_length=2, max_length=50), child_obj_id: str = Path(min_length=11, max_length=22), return_obj: Optional[bool] = True, - x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'), - delay_ms: Optional[int] = Query(0), - commons: Common_Route_Params = Depends(common_route_params), + account: AccountContext = Depends(get_account_context), + serialization: SerializationParams = Depends(get_serialization_params), + delay: DelayParams = Depends(get_delay_params), ): - """ - Update a child object by its ID, ensuring it belongs to the correct parent. - Examples: - - PATCH /v3/crud/journal/{journal_id}/journal_entry/{entry_id} - """ - log.setLevel(logging.WARNING) - log.debug(locals()) + from app.db_sql import redis_lookup_id_random, sql_select, sql_update - sleep_time = max(x_delay_ms, delay_ms) - if sleep_time > 0: - log.info(f"Delaying response for {sleep_time} ms.") - time.sleep(sleep_time / 1000) + if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) obj_data = await request.json() + resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_obj_type) + resolved_child_id = redis_lookup_id_random(record_id_random=child_obj_id, table_name=child_obj_type) - parent_obj_name = parent_obj_type - child_obj_name = child_obj_type + if not resolved_parent_id or not resolved_child_id: + return mk_resp(data=False, status_code=404, response=response, status_message="Object(s) not found.") - if parent_obj_name not in obj_type_kv_li: - return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Parent object type '{parent_obj_name}' not found.") - if child_obj_name not in obj_type_kv_li: - return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Child object type '{child_obj_name}' not found.") - - # Resolve IDs - resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_obj_name) - if not resolved_parent_id: - return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Parent object '{parent_obj_name}' with ID '{parent_obj_id}' not found.") - - resolved_child_id = redis_lookup_id_random(record_id_random=child_obj_id, table_name=child_obj_name) - if not resolved_child_id: - return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Child object '{child_obj_name}' with ID '{child_obj_id}' not found.") - - # Get config for child object - obj_cfg = obj_type_kv_li[child_obj_name] + obj_cfg = obj_type_kv_li[child_obj_type] table_name_update = obj_cfg.get('tbl_update', obj_cfg.get('tbl')) table_name_select = obj_cfg.get('tbl_default', obj_cfg.get('tbl')) output_model = obj_cfg.get('mdl_out', obj_cfg.get('mdl_default', obj_cfg.get('mdl'))) - if not table_name_update or not table_name_select or not output_model: - return mk_resp(data=False, status_code=500, response=commons.response, status_message=f"Configuration for child object type '{child_obj_name}' is incomplete.") - - # Verify parentage before updating if existing_child := sql_select(table_name=table_name_select, record_id=resolved_child_id): - parent_fk_field_name = f'{parent_obj_name}_id' - if existing_child.get(parent_fk_field_name) != resolved_parent_id: - return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Child object '{child_obj_id}' not found under parent '{parent_obj_id}'.") + if existing_child.get(f'{parent_obj_type}_id') != resolved_parent_id: + return mk_resp(data=False, status_code=404, response=response, status_message="Child not found under parent.") else: - return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Child object '{child_obj_id}' not found.") + return mk_resp(data=False, status_code=404, response=response, status_message="Child not found.") - # The sql_update function will only update the fields provided in the dict. - data_to_update = obj_data - - if sql_update(data=data_to_update, table_name=table_name_update, record_id=resolved_child_id): + if sql_update(data=obj_data, table_name=table_name_update, record_id=resolved_child_id): if return_obj: if updated_child := sql_select(table_name=table_name_select, record_id=resolved_child_id): - resp_data = output_model(**updated_child).dict(by_alias=commons.by_alias, exclude_unset=commons.exclude_unset) - return mk_resp(data=resp_data, response=commons.response) - else: - return mk_resp(data=True, status_code=404, response=commons.response, status_message="Object updated but could not be retrieved post-update.") - else: - return mk_resp(data=True, response=commons.response, status_message="Object updated successfully.") - else: - return mk_resp(data=False, status_code=400, response=commons.response, status_message="Failed to update object in database.") + resp_data = output_model(**updated_child).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset) + return mk_resp(data=resp_data, response=response) + return mk_resp(data=True, response=response, status_message="Updated successfully.") + return mk_resp(data=False, status_code=400, response=response, status_message="Update failed.") @router.delete('/{parent_obj_type}/{parent_obj_id}/{child_obj_type}/{child_obj_id}', response_model=Resp_Body_Base) @@ -712,67 +868,37 @@ async def delete_child_obj( parent_obj_id: str = Path(min_length=11, max_length=22), child_obj_type: str = Path(min_length=2, max_length=50), child_obj_id: str = Path(min_length=11, max_length=22), - method: str = 'delete', # delete, disable, hide - x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'), - delay_ms: Optional[int] = Query(0), - commons: Common_Route_Params = Depends(common_route_params), + method: str = Query('delete', regex='^(delete|hide|disable)$'), + account: AccountContext = Depends(get_account_context), + delay: DelayParams = Depends(get_delay_params), ): - """ - Delete a child object by its ID, ensuring it belongs to the correct parent. - Examples: - - DELETE /v3/crud/journal/{journal_id}/journal_entry/{entry_id} - - DELETE /v3/crud/journal/{journal_id}/journal_entry/{entry_id}?method=disable - """ - log.setLevel(logging.WARNING) - log.debug(locals()) + from app.db_sql import redis_lookup_id_random, sql_select, sql_update, sql_delete - sleep_time = max(x_delay_ms, delay_ms) - if sleep_time > 0: - log.info(f"Delaying response for {sleep_time} ms.") - time.sleep(sleep_time / 1000) + if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) - parent_obj_name = parent_obj_type - child_obj_name = child_obj_type + resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_obj_type) + resolved_child_id = redis_lookup_id_random(record_id_random=child_obj_id, table_name=child_obj_type) - if parent_obj_name not in obj_type_kv_li: - return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Parent object type '{parent_obj_name}' not found.") - if child_obj_name not in obj_type_kv_li: - return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Child object type '{child_obj_name}' not found.") + if not resolved_parent_id or not resolved_child_id: + return mk_resp(data=False, status_code=404, response=response, status_message="Object(s) not found.") - # Resolve IDs - resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_obj_name) - if not resolved_parent_id: - return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Parent object '{parent_obj_name}' with ID '{parent_obj_id}' not found.") + obj_cfg = obj_type_kv_li[child_obj_type] + table_name_delete = obj_cfg.get('tbl_update', obj_cfg.get('tbl')) + table_name_select = obj_cfg.get('tbl_default', obj_cfg.get('tbl')) - resolved_child_id = redis_lookup_id_random(record_id_random=child_obj_id, table_name=child_obj_name) - if not resolved_child_id: - return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Child object '{child_obj_name}' with ID '{child_obj_id}' not found.") - - # Get config for child object - obj_cfg = obj_type_kv_li[child_obj_name] - table_name = obj_cfg.get('tbl_update', obj_cfg.get('tbl')) - table_name_select = obj_cfg.get('tbl_default', obj_cfg.get('tbl')) # For verification - - if not table_name or not table_name_select: - return mk_resp(data=False, status_code=500, response=commons.response, status_message=f"Configuration for child object type '{child_obj_name}' is incomplete.") - - # Verify parentage before deleting if existing_child := sql_select(table_name=table_name_select, record_id=resolved_child_id): - parent_fk_field_name = f'{parent_obj_name}_id' - if existing_child.get(parent_fk_field_name) != resolved_parent_id: - return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Child object '{child_obj_id}' not found under parent '{parent_obj_id}'.") + if existing_child.get(f'{parent_obj_type}_id') != resolved_parent_id: + return mk_resp(data=False, status_code=404, response=response, status_message="Child not found under parent.") else: - return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Child object '{child_obj_id}' not found.") + return mk_resp(data=False, status_code=404, response=response, status_message="Child not found.") - # If verification passes, perform the action - if method == 'disable': - sql_result = sql_update(data={'enable': False}, table_name=table_name, record_id=resolved_child_id) - elif method == 'hide': - sql_result = sql_update(data={'hide': True}, table_name=table_name, record_id=resolved_child_id) - else: # Default to hard delete - sql_result = sql_delete(table_name=table_name, record_id=resolved_child_id) - - if sql_result: - return mk_resp(data=True, response=commons.response, status_message=f"Object with ID '{child_obj_id}' action '{method}' completed successfully.") + if method == 'hide': + success = sql_update(table_name=table_name_delete, record_id=resolved_child_id, data={'hide': True}) + elif method == 'disable': + success = sql_update(table_name=table_name_delete, record_id=resolved_child_id, data={'enable': False}) else: - return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Failed to perform '{method}' on object in database.") \ No newline at end of file + success = sql_delete(table_name=table_name_delete, record_id=resolved_child_id) + + if success: + return mk_resp(data=True, response=response, status_message=f"Deleted successfully via {method}.") + return mk_resp(data=False, status_code=400, response=response, status_message="Deletion failed.") \ No newline at end of file