diff --git a/app/routers/api_crud_v3.py b/app/routers/api_crud_v3.py index 7189f75..7dfdfaf 100644 --- a/app/routers/api_crud_v3.py +++ b/app/routers/api_crud_v3.py @@ -545,311 +545,6 @@ async def delete_obj( 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, - order_by_li: Optional[str] = None, - jp: Optional[Union[str, None]] = None, - account: AccountContext = Depends(get_account_context), - pagination: PaginationParams = Depends(), - status_filter: StatusFilterParams = Depends(), - serialization: SerializationParams = Depends(), - delay: DelayParams = Depends(), - ): - """ - List Child Objects (One-to-Many). - - Retrieves a list of child objects associated with a specific parent. - 1. Verifies parent existence and user access to the parent. - 2. Filters children where `{parent_obj_type}_id` matches the parent's ID. - """ - from app.db_sql import redis_lookup_id_random, sql_select - - if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) - - 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 = 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 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=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) - - 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=response, status_message=f"Parent not found.") - - 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=f'{parent_obj_type}_id', - field_value=resolved_parent_id, - 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, - and_like_dict=and_like_dict_obj, - or_like_dict=or_like_dict_obj, - and_in_dict_li=and_in_dict_li_obj, - 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('/{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, - account: AccountContext = Depends(get_account_context), - serialization: SerializationParams = Depends(), - delay: DelayParams = Depends(), - ): - """ - Create Child Object. - - 1. Verifies Parent existence and access. - 2. Automatically links the new child to the parent (`{parent_obj_type}_id` = parent_id). - 3. Performs standard creation logic (validation, injection, sanitization). - """ - from app.db_sql import redis_lookup_id_random, sql_select, sql_insert, get_id_random - - if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) - - obj_data = await request.json() - - 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.") - - 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=response, status_message="Parent not found.") - - 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 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 - - obj_data[f'{parent_obj_type}_id'] = resolved_parent_id - - try: - validated_obj = input_model(**obj_data) - except Exception as e: - return mk_resp(data=False, status_code=400, response=response, status_message=f"Validation error: {e}") - - data_to_insert = validated_obj.dict(exclude_unset=True) - - # Sanitize payload (remove virtual fields and view-only fields) - sanitize_payload(data_to_insert, input_model) - - 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_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=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=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), - account: AccountContext = Depends(get_account_context), - serialization: SerializationParams = Depends(), - delay: DelayParams = Depends(), - ): - """ - Retrieve Child Object. - - Verifies that the child belongs to the specified parent. - """ - from app.db_sql import redis_lookup_id_random, sql_select - - if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) - - 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 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.") - - 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')) - - 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.") - - 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) -async def patch_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), - child_obj_id: str = Path(min_length=11, max_length=22), - return_obj: Optional[bool] = True, - account: AccountContext = Depends(get_account_context), - serialization: SerializationParams = Depends(), - delay: DelayParams = Depends(), - ): - """ - Update Child Object. - - Verifies that the child belongs to the specified parent before updating. - """ - from app.db_sql import redis_lookup_id_random, sql_select, sql_update - - 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) - - 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.") - - 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 existing_child := sql_select(table_name=table_name_select, record_id=resolved_child_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=response, status_message="Child not found.") - - # Sanitize payload (remove virtual fields and view-only fields) - sanitize_payload(obj_data, output_model) - - 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=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) -async def delete_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), - method: str = Query('delete', regex='^(delete|hide|disable)$'), - account: AccountContext = Depends(get_account_context), - delay: DelayParams = Depends(), - ): - """ - Delete Child Object. - - Verifies that the child belongs to the specified parent before deleting. - """ - from app.db_sql import redis_lookup_id_random, sql_select, sql_update, sql_delete - - if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) - - 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 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.") - - 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')) - - if existing_child := sql_select(table_name=table_name_select, record_id=resolved_child_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=response, status_message="Child not found.") - - 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: - 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 +# Nested / Child Routes are imported and included below to keep this file manageable. +from app.routers.api_crud_v3_nested import router as nested_router +router.include_router(nested_router) \ No newline at end of file diff --git a/app/routers/api_crud_v3_nested.py b/app/routers/api_crud_v3_nested.py new file mode 100644 index 0000000..2030a74 --- /dev/null +++ b/app/routers/api_crud_v3_nested.py @@ -0,0 +1,337 @@ +from fastapi import APIRouter, Depends, Path, Query, Request, Response +from typing import Optional, Union +import asyncio +import logging + +log = logging.getLogger(__name__) + +from app.lib_general_v3 import ( + AccountContext, get_account_context, + PaginationParams, StatusFilterParams, + SerializationParams, DelayParams +) +from app.lib_api_crud_v3 import ( + check_account_access, apply_forced_account_filter, filter_order_by, + get_supported_filters, safe_json_loads, sanitize_payload +) +from app.models.response_models import * +from app.ae_obj_types_def import obj_type_kv_li + +""" +Aether API V3 - Nested (Child) CRUD Router +------------------------------------------ +This router handles relational operations between parent and child objects. +Example: /person/{person_id}/journal/ +""" + +router = APIRouter() + +@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, + order_by_li: Optional[str] = None, + jp: Optional[Union[str, None]] = None, + account: AccountContext = Depends(get_account_context), + pagination: PaginationParams = Depends(), + status_filter: StatusFilterParams = Depends(), + serialization: SerializationParams = Depends(), + delay: DelayParams = Depends(), + ): + """ + List Child Objects (One-to-Many). + + Retrieves a list of child objects associated with a specific parent. + 1. Verifies parent existence and user access to the parent. + 2. Filters children where `{parent_obj_type}_id` matches the parent's ID. + """ + from app.db_sql import redis_lookup_id_random, sql_select + import urllib.parse + + if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) + + 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 = 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 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=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) + + 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=response, status_message=f"Parent not found.") + + 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=f'{parent_obj_type}_id', + field_value=resolved_parent_id, + 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, + and_like_dict=and_like_dict_obj, + or_like_dict=or_like_dict_obj, + and_in_dict_li=and_in_dict_li_obj, + 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('/{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, + account: AccountContext = Depends(get_account_context), + serialization: SerializationParams = Depends(), + delay: DelayParams = Depends(), + ): + """ + Create Child Object. + + 1. Verifies Parent existence and access. + 2. Automatically links the new child to the parent (`{parent_obj_type}_id` = parent_id). + 3. Performs standard creation logic (validation, injection, sanitization). + """ + from app.db_sql import redis_lookup_id_random, sql_select, sql_insert, get_id_random + + if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) + + obj_data = await request.json() + + 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.") + + 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=response, status_message="Parent not found.") + + 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 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 + + obj_data[f'{parent_obj_type}_id'] = resolved_parent_id + + try: + validated_obj = input_model(**obj_data) + except Exception as e: + return mk_resp(data=False, status_code=400, response=response, status_message=f"Validation error: {e}") + + data_to_insert = validated_obj.dict(exclude_unset=True) + + # Sanitize payload (remove virtual fields and view-only fields) + sanitize_payload(data_to_insert, input_model) + + 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_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=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=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), + account: AccountContext = Depends(get_account_context), + serialization: SerializationParams = Depends(), + delay: DelayParams = Depends(), + ): + """ + Retrieve Child Object. + + Verifies that the child belongs to the specified parent. + """ + from app.db_sql import redis_lookup_id_random, sql_select + + if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) + + 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 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.") + + 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')) + + 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.") + + 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) +async def patch_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), + child_obj_id: str = Path(min_length=11, max_length=22), + return_obj: Optional[bool] = True, + account: AccountContext = Depends(get_account_context), + serialization: SerializationParams = Depends(), + delay: DelayParams = Depends(), + ): + """ + Update Child Object. + + Verifies that the child belongs to the specified parent before updating. + """ + from app.db_sql import redis_lookup_id_random, sql_select, sql_update + + 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) + + 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.") + + 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 existing_child := sql_select(table_name=table_name_select, record_id=resolved_child_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=response, status_message="Child not found.") + + # Sanitize payload (remove virtual fields and view-only fields) + sanitize_payload(obj_data, output_model) + + 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=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) +async def delete_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), + method: str = Query('delete', regex='^(delete|hide|disable)$'), + account: AccountContext = Depends(get_account_context), + delay: DelayParams = Depends(), + ): + """ + Delete Child Object. + + Verifies that the child belongs to the specified parent before deleting. + """ + from app.db_sql import redis_lookup_id_random, sql_select, sql_update, sql_delete + + if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) + + 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 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.") + + 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')) + + if existing_child := sql_select(table_name=table_name_select, record_id=resolved_child_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=response, status_message="Child not found.") + + 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: + 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.") diff --git a/documentation/REFACTOR_API_CRUD_V3.md b/documentation/REFACTOR_API_CRUD_V3.md index 8a2440d..1af7d8f 100644 --- a/documentation/REFACTOR_API_CRUD_V3.md +++ b/documentation/REFACTOR_API_CRUD_V3.md @@ -8,20 +8,13 @@ 1. **Create `app/lib_api_crud_v3.py`**: DONE 2. **Update `app/routers/api_crud_v3.py`**: DONE (All endpoints now use `sanitize_payload`). -## Phase 2: Separate Child/Nested Routes - PLANNED +## Phase 2: Separate Child/Nested Routes - COMPLETED +**Objective:** Reduce file size by splitting standard CRUD from relational CRUD. -1. **Create `app/routers/api_crud_v3_nested.py`**: - * Move `get_child_obj_li` - * Move `post_child_obj` - * Move `get_child_obj` - * Move `patch_child_obj` - * Move `delete_child_obj` +1. **Create `app/routers/api_crud_v3_nested.py`**: DONE +2. **Update `app/routers/api_crud_v3.py`**: DONE (Included via `router.include_router`) -2. **Update `app/main.py` (or router inclusion)**: - * Ensure the new router is included, OR include it within `api_crud_v3.py` if preferred to keep a single import point. - -## Phase 3: Schema Introspection -**Objective:** Isolate database introspection logic. +## Phase 3: Schema Introspection - PLANNED 1. **Create `app/lib_schema_v3.py` (or similar)**: * Move the logic inside `get_obj_schema` (SQL `DESCRIBE` parsing, Pydantic introspection) to a helper function.