diff --git a/app/db_sql.py b/app/db_sql.py index e11a990..0dd3eac 100644 --- a/app/db_sql.py +++ b/app/db_sql.py @@ -723,7 +723,7 @@ def sql_select( sql_search_qry = '' if search_query: log.info('Creating partial SQL string for complex SearchQuery.') - sql_search_qry, data_search = sql_search_qry_part(search_query, searchable_fields=searchable_fields) + sql_search_qry, data_search = sql_search_qry_part(search_query, searchable_fields=searchable_fields, table_name=table_name) data = {**data, **data_search} sql = text( @@ -830,7 +830,7 @@ def sql_select( sql_search_qry = '' if search_query: log.info('Creating partial SQL string for complex SearchQuery.') - sql_search_qry, data_search = sql_search_qry_part(search_query, searchable_fields=searchable_fields) + sql_search_qry, data_search = sql_search_qry_part(search_query, searchable_fields=searchable_fields, table_name=table_name) data = {**data, **data_search} # # NOTE: Version 3 of the fulltext search @@ -2052,7 +2052,7 @@ def sql_enable_part(table_name: str, enabled: str) -> bool|dict: sql = f'AND `{table_name}`.enable = false' enable = False elif enabled == 'all': - sql = f'AND (`{table_name}`.enable = true OR `{table_name}`.enable = false OR `{table_name}`.enable IS NULL)' + sql = '' enable = None log.debug(sql) @@ -2080,7 +2080,7 @@ def sql_hidden_part(table_name: str, hidden: str) -> bool|dict: sql = f'AND (`{table_name}`.hide = false OR `{table_name}`.hide IS NULL)' hide = False elif hidden == 'all': - sql = f'AND (`{table_name}`.hide = true OR `{table_name}`.hide = false OR `{table_name}`.hide IS NULL)' + sql = '' hide = None log.debug(sql) @@ -2109,11 +2109,13 @@ def sql_limit_offset_part(limit: int, offset: int = 0) -> bool|str: # ### BEGIN ### API DB SQL Methods ### sql_search_qry_part() ### # NEW 2026-01-02 # Updated to support complex POST-based searches with recursive logical grouping. +# Updated 2026-01-06 to handle missing default_qry_str column gracefully. @logger_reset def sql_search_qry_part( search_query: Any, # SearchQuery model instance searchable_fields: List[str]|None = None, # List of allowed fields max_depth: int = 5, # Maximum recursion depth + table_name: str|None = None, # Target table for schema validation ) -> tuple[str, dict]: """ Recursively builds a SQL WHERE clause from a SearchQuery model. @@ -2157,9 +2159,35 @@ def sql_search_qry_part( # Process 'query_string' (Standardized Full-Text Search) if hasattr(query_node, 'query_string') and query_node.query_string: - p_name = get_param_name() - clauses.append(f"MATCH( default_qry_str ) AGAINST( :{p_name} IN BOOLEAN MODE )") - data[p_name] = query_node.query_string + if query_node.query_string == '%': + # Wildcard: Skip filtering for this part + pass + else: + # Check if default_qry_str exists in this table/view + use_match = True + if table_name: + try: + db.execute(text(f"SELECT default_qry_str FROM `{table_name}` LIMIT 0")) + except: + use_match = False + else: + use_match = False # Safe default if no table_name + + if use_match: + p_name = get_param_name() + clauses.append(f"MATCH( default_qry_str ) AGAINST( :{p_name} IN BOOLEAN MODE )") + data[p_name] = query_node.query_string + elif searchable_fields: + # Fallback: OR LIKE across all searchable fields + like_clauses = [] + for field in searchable_fields: + # Skip internal/numeric fields for full-text-like search + if not any(x in field for x in ['_id', 'enable', 'hide', 'priority', 'sort', 'group', 'created_on', 'updated_on']): + f_p_name = get_param_name() + like_clauses.append(f"`{field}` LIKE :{f_p_name}") + data[f_p_name] = f"%{query_node.query_string}%" + if like_clauses: + clauses.append(f"({' OR '.join(like_clauses)})") # Process 'and' filters if hasattr(query_node, 'and_filters') and query_node.and_filters: diff --git a/app/models/account_models.py b/app/models/account_models.py index 197455b..a796c21 100644 --- a/app/models/account_models.py +++ b/app/models/account_models.py @@ -95,4 +95,5 @@ class Account_Base(BaseModel): class Config: underscore_attrs_are_private = True fields = base_fields + allow_population_by_field_name = True # ### END ### API Account Models ### Account_Base() ### diff --git a/app/object_definitions/events_general.py b/app/object_definitions/events_general.py index 27fa0f6..abe9abf 100644 --- a/app/object_definitions/events_general.py +++ b/app/object_definitions/events_general.py @@ -1,6 +1,7 @@ from app.models.event_models import * from app.models.event_file_models import * from app.models.event_device_models import * +from app.models.event_cfg_models import * events_general_obj_li = { 'event': { @@ -91,4 +92,22 @@ events_general_obj_li = { 'enable', 'hide', 'priority', 'group', 'notes', 'created_on', 'updated_on' ], }, + 'event_cfg': { + 'tbl': 'event_cfg', + 'tbl_default': 'event_cfg', + 'tbl_update': 'event_cfg', + 'mdl': Event_Cfg_Base, + 'mdl_default': Event_Cfg_Base, + 'mdl_in': Event_Cfg_Base, + 'mdl_out': Event_Cfg_Base, + # Legacy V2 keys: + 'table_name': 'event_cfg', + 'tbl_name_update': 'event_cfg', + 'base_name': Event_Cfg_Base, + # V3 Search Security: + 'searchable_fields': [ + 'event_cfg_id_random', 'event_id_random', 'enable', 'conference', + 'status', 'hide', 'priority', 'group', 'notes' + ], + }, } diff --git a/app/object_definitions/events_registration.py b/app/object_definitions/events_registration.py index 7a9901d..1c528bf 100644 --- a/app/object_definitions/events_registration.py +++ b/app/object_definitions/events_registration.py @@ -3,6 +3,7 @@ from app.models.event_badge_template_models import * from app.models.event_person_models import * from app.models.event_person_tracking_models import * from app.models.event_registration_models import * +from app.models.event_person_profile_models import * events_registration_obj_li = { 'event_badge': { @@ -66,6 +67,28 @@ events_registration_obj_li = { 'notes', 'created_on', 'updated_on' ], }, + 'event_person_profile': { + 'tbl': 'event_person_profile', + 'tbl_default': 'v_event_person_profile', + 'tbl_update': 'event_person_profile', + 'mdl': Event_Person_Profile_Base, + 'mdl_default': Event_Person_Profile_Base, + 'mdl_in': Event_Person_Profile_Base, + 'mdl_out': Event_Person_Profile_Base, + # Legacy V2 keys: + 'table_name': 'v_event_person_profile', + 'tbl_name_update': 'event_person_profile', + 'base_name': Event_Person_Profile_Base, + # V3 Search Security: + 'searchable_fields': [ + 'event_person_profile_id_random', 'account_id_random', + 'contact_id_random', 'event_id_random', 'event_person_id_random', + 'organization_id_random', 'pronouns', 'informal_name', 'given_name', + 'family_name', 'professional_title', 'full_name', 'affiliations', + 'email', 'enable', 'priority', 'group', 'notes', 'created_on', + 'updated_on' + ], + }, 'event_person_tracking': { 'tbl': 'event_person_tracking', 'tbl_default': 'v_event_person_tracking', diff --git a/app/object_definitions/orders.py b/app/object_definitions/orders.py index 56ce054..4e6f9e8 100644 --- a/app/object_definitions/orders.py +++ b/app/object_definitions/orders.py @@ -1,5 +1,7 @@ from app.models.order_models import * from app.models.order_cart_models import * +from app.models.product_models import * +from app.models.order_cfg_models import * order_obj_li = { 'order': { @@ -73,4 +75,42 @@ order_obj_li = { 'enable', 'hide', 'priority', 'group', 'created_on', 'updated_on' ], }, + 'product': { + 'tbl': 'product', + 'tbl_default': 'v_product', + 'tbl_update': 'product', + 'mdl': Product_Base, + 'mdl_default': Product_Base, + 'mdl_in': Product_Base, + 'mdl_out': Product_Base, + # Legacy V2 keys: + 'table_name': 'v_product', + 'tbl_name_update': 'product', + 'base_name': Product_Base, + # V3 Search Security: + 'searchable_fields': [ + 'product_id_random', 'account_id_random', 'for_type', 'for_id_random', + 'type_code', 'type_name', 'name', 'description', 'unit_price', + 'tax_rate', 'vat_rate', 'max_quantity', 'recurring', 'enable', + 'hide', 'priority', 'group', 'created_on', 'updated_on' + ], + }, + 'order_cfg': { + 'tbl': 'order_cfg', + 'tbl_default': 'order_cfg', + 'tbl_update': 'order_cfg', + 'mdl': Order_Cfg_Base, + 'mdl_default': Order_Cfg_Base, + 'mdl_in': Order_Cfg_Base, + 'mdl_out': Order_Cfg_Base, + # Legacy V2 keys: + 'table_name': 'order_cfg', + 'tbl_name_update': 'order_cfg', + 'base_name': Order_Cfg_Base, + # V3 Search Security: + 'searchable_fields': [ + 'account_id_random', 'account_name', 'default_no_reply_email', + 'confirm_email' + ], + }, } diff --git a/app/routers/api_crud_v3.py b/app/routers/api_crud_v3.py index 1949b7b..bed8f1e 100644 --- a/app/routers/api_crud_v3.py +++ b/app/routers/api_crud_v3.py @@ -35,18 +35,58 @@ def safe_json_loads(json_str: Optional[str]) -> Any: log.warning(f"Failed to parse JSON string: {json_str}. Error: {e}") return None +def filter_order_by(order_by_li: Any, model: Any) -> Optional[Dict[str, str]]: + """ + Filters the order_by_li dictionary to only include fields present in the Pydantic model. + This prevents SQL errors when the frontend requests ordering by fields that don't exist + on specific objects (e.g., 'priority' or 'sort' on 'account'). + """ + 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 + + # Get all field names and aliases from the model + 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 len(filtered) != len(order_by_li): + log.info(f"Filtered order_by_li. Removed fields: {set(order_by_li.keys()) - set(filtered.keys())}") + + return filtered + +def get_supported_filters(model: Any, status_filter: StatusFilterParams) -> StatusFilterParams: + """ + Adjusts the status filter based on what the model actually supports to avoid + SQL errors when filtering by non-existent columns (like 'hide' or 'enable'). + """ + if not model or not hasattr(model, "__fields__"): + return status_filter + + # Create a copy of the filter params + adjusted = StatusFilterParams( + enabled=status_filter.enabled, + hidden=status_filter.hidden + ) + + # Check for 'enable' and 'hide' fields in the model + if 'enable' not in model.__fields__: + adjusted.enabled = 'all' + + if 'hide' not in model.__fields__: + adjusted.hidden = 'all' + + return adjusted + @router.get("/health", response_model=Resp_Body_Base) async def health_check( delay: DelayParams = Depends(get_delay_params), ): """ Health check endpoint for V3 API. - - Architectural Choices: - - Non-blocking delay: Uses 'await asyncio.sleep' instead of 'time.sleep' to prevent - blocking the event loop, ensuring the Gunicorn worker can handle other requests. - - Granular Dependencies: Uses 'DelayParams' to handle optional latency simulation - consistently across all V3 endpoints via headers (X-Delay-ms) or query params (delay_ms). """ if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) @@ -56,6 +96,8 @@ async def health_check( return mk_resp(data={"status": "V3 API is healthy!"}) +# --- TOP LEVEL OBJECTS --- + @router.get('/{obj_type_l1}/{obj_id}', response_model=Resp_Body_Base) async def get_obj( response: Response, @@ -68,13 +110,6 @@ async def get_obj( ): """ Get a single top-level object by its random ID. - - Special Cases: - - Object Resolution: Random IDs (id_random) are resolved to internal integer IDs - using Redis for performance, falling back to SQL if not found. - - Consistency: Uses 'obj_type_kv_li' from ae_obj_types_def.py to map URL paths - to database views/tables and Pydantic models. - - View Selection: Use the 'view' parameter to fetch a richer model (e.g., view=enriched). """ if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) @@ -87,8 +122,6 @@ async def get_obj( 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] - - # Support view selection: tbl_{view} and mdl_{view} 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'))) @@ -100,7 +133,12 @@ async def get_obj( 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=serialization.by_alias, exclude_unset=serialization.exclude_unset) + 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=response, status_message=f"Object with ID '{obj_id}' not found in database.") @@ -123,14 +161,6 @@ async def get_obj_li( ): """ Get a list of top-level objects. - - Features: - - Status Filtering: Automatically filters by 'enabled' and 'hidden' status using - the StatusFilterParams dependency. - - Flexible Querying: Supports complex JSON-based queries via the 'jp' parameter. - - Contextual Filtering: Optionally filters by parent object relationship if - 'for_obj_type' and 'for_obj_id' are provided. - - View Selection: Fetch alternative views (e.g., ?view=enriched). """ if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) @@ -138,7 +168,6 @@ async def get_obj_li( log.setLevel(logging.WARNING) log.debug(locals()) - # 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 @@ -148,18 +177,12 @@ async def get_obj_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'] + 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) @@ -167,26 +190,28 @@ async def get_obj_li( 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.") + # Security Restrictions + 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] - - # Support view selection: tbl_{view} and mdl_{view} 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 for object type '{obj_name}' (view: {view}) is incomplete.") + order_by_li = filter_order_by(order_by_li, base_name) + status_filter = get_supported_filters(base_name, status_filter) + 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=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' - 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=status_filter.enabled, hidden=status_filter.hidden, @@ -218,14 +243,27 @@ async def get_obj_li( as_list=True, ) - if sql_result: + if sql_result is False: + return mk_resp(data=False, status_code=500, response=response, status_message="Database error occurred.") + elif sql_result: resp_data_li = [] for record in sql_result: - resp_data = base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset) - resp_data_li.append(resp_data) + obj_inst = base_name(**record) + obj_dict = obj_inst.dict( + by_alias=serialization.by_alias, + exclude_unset=serialization.exclude_unset, + exclude_defaults=serialization.exclude_defaults, + exclude_none=serialization.exclude_none + ) + # Ensure id_random is present if it exists on the instance + id_random_alias = base_name.__fields__['id_random'].alias if 'id_random' in base_name.__fields__ else 'id_random' + if id_random_alias not in obj_dict and hasattr(obj_inst, 'id_random'): + obj_dict[id_random_alias] = obj_inst.id_random + + resp_data_li.append(obj_dict) return mk_resp(data=resp_data_li, response=response) else: - return mk_resp(data=[], status_code=200, response=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)']) @@ -245,13 +283,6 @@ async def search_obj_li( ): """ Search top-level objects using a complex SearchQuery in the POST body. - - This endpoint supports: - - Recursive AND/OR grouping - - Operators: eq, ne, gt, gte, lt, lte, like, in, is_null, is_not_null - - Hybrid Filtering: Combines standard query params (enabled, hidden, for_obj_type) - with the complex logic in the body. - - View Selection: Fetch richer models via the 'view' parameter. """ if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) @@ -265,20 +296,22 @@ async def search_obj_li( 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.") + # Security Restrictions + 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] - - # Support view selection: tbl_{view} and mdl_{view} 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 for object type '{obj_name}' (view: {view}) is incomplete.") - # Get searchable fields for this object type + order_by_li = filter_order_by(order_by_li, base_name) + status_filter = get_supported_filters(base_name, status_filter) searchable_fields = obj_cfg.get('searchable_fields') if for_obj_type and for_obj_id: - # Resolve parentage context for search 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.") @@ -309,11 +342,24 @@ async def search_obj_li( as_list=True, ) - if sql_result: + if sql_result is False: + return mk_resp(data=False, status_code=500, response=response, status_message="Database error occurred.") + elif sql_result: resp_data_li = [] for record in sql_result: - resp_data = base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset) - resp_data_li.append(resp_data) + obj_inst = base_name(**record) + obj_dict = obj_inst.dict( + by_alias=serialization.by_alias, + exclude_unset=serialization.exclude_unset, + exclude_defaults=serialization.exclude_defaults, + exclude_none=serialization.exclude_none + ) + # Ensure id_random is present if it exists on the instance + id_random_alias = base_name.__fields__['id_random'].alias if 'id_random' in base_name.__fields__ else 'id_random' + if id_random_alias not in obj_dict and hasattr(obj_inst, 'id_random'): + obj_dict[id_random_alias] = obj_inst.id_random + + resp_data_li.append(obj_dict) return mk_resp(data=resp_data_li, response=response) else: return mk_resp(data=[], status_code=200, response=response) @@ -331,10 +377,6 @@ async def post_obj( ): """ Create a new top-level object. - - Validation: - - Uses 'mdl_in' from the object configuration to strictly validate incoming JSON data. - - 'data_to_insert' excludes unset fields to allow database defaults to apply. """ if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) @@ -357,14 +399,12 @@ async def post_obj( 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=response, status_message=f"Configuration for object type '{obj_name}' is incomplete.") - # 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=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): @@ -396,10 +436,6 @@ async def patch_obj( ): """ Update a top-level object. - - Behavior: - - Partial Updates: Unlike POST, PATCH does not perform strict full-model validation, - allowing partial updates of only the fields provided in the body. """ if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) @@ -426,13 +462,9 @@ async def patch_obj( if not record_id: 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 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=serialization.by_alias, exclude_unset=serialization.exclude_unset) @@ -442,7 +474,7 @@ async def patch_obj( else: return mk_resp(data=True, response=response, status_message="Object updated successfully.") else: - return mk_resp(data=False, status_code=400, response=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 in database.") @router.delete('/{obj_type_l1}/{obj_id}', response_model=Resp_Body_Base) @@ -450,17 +482,12 @@ 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 = Query('delete', regex='^(delete|hide|disable)$', description="Deletion method: delete (hard), hide (soft), disable (soft)"), + 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. - - Soft Delete: - - Use 'method=hide' to set 'hide=True'. - - Use 'method=disable' to set 'enable=False'. - - Default is 'method=delete' (hard database delete). + Delete a top-level object (hard or soft). """ if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) @@ -476,7 +503,7 @@ async def delete_obj( table_name_delete = obj_cfg.get('tbl_update', obj_cfg.get('tbl')) if not table_name_delete: - return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration for object type '{obj_name}' is incomplete (missing table for deletion).") + return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration for object type '{obj_name}' is incomplete.") record_id = redis_lookup_id_random(record_id_random=obj_id, table_name=obj_name) if not record_id: @@ -486,20 +513,21 @@ async def delete_obj( if sql_update(table_name=table_name_delete, record_id=record_id, data={'hide': True}): return mk_resp(data=True, response=response, status_message=f"Object with ID '{obj_id}' hidden successfully.") else: - return mk_resp(data=False, status_code=400, response=response, status_message="Failed to hide object. It may not support the 'hide' field.") + return mk_resp(data=False, status_code=400, response=response, status_message="Failed to hide object.") elif method == 'disable': if sql_update(table_name=table_name_delete, record_id=record_id, data={'enable': False}): return mk_resp(data=True, response=response, status_message=f"Object with ID '{obj_id}' disabled successfully.") else: - return mk_resp(data=False, status_code=400, response=response, status_message="Failed to disable object. It may not support the 'enable' field.") + return mk_resp(data=False, status_code=400, response=response, status_message="Failed to disable object.") else: - # Default: hard delete if sql_delete_result := sql_delete(table_name=table_name_delete, record_id=record_id): return mk_resp(data=True, response=response, status_message=f"Object with ID '{obj_id}' deleted successfully.") else: - return mk_resp(data=False, status_code=400, response=response, status_message="Failed to delete object in database. It may not have been found.") + return mk_resp(data=False, status_code=400, response=response, status_message="Failed to delete object.") +# --- CHILD / NESTED OBJECTS --- + @router.get('/{parent_obj_type}/{parent_obj_id}/{child_obj_type}/', response_model=Resp_Body_Base) async def get_child_obj_li( response: Response, @@ -516,10 +544,6 @@ async def get_child_obj_li( ): """ Get a list of child objects belonging to a parent. - - Nested URL Logic: - - This enforces parentage by using the parent's ID from the URL to filter the child list. - - Convention: Assumes the child table has a foreign key field named '{parent_obj_type}_id'. """ if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) @@ -527,13 +551,6 @@ async def get_child_obj_li( log.setLevel(logging.WARNING) log.debug(locals()) - # 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 - qry_dict_li = None fulltext_qry_dict_obj = None and_qry_dict_obj = None @@ -543,18 +560,12 @@ async def get_child_obj_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'] + 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) @@ -569,16 +580,16 @@ async def get_child_obj_li( if not table_name or not base_name: return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration for object type '{obj_name}' is incomplete.") - # 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) - if not resolved_parent_id: - return mk_resp(data=False, status_code=404, response=response, status_message=f"Parent object '{for_obj_type}' with ID '{for_obj_id}' not found.") + order_by_li = filter_order_by(order_by_li, base_name) + status_filter = get_supported_filters(base_name, status_filter) - field_name = f'{for_obj_type}_id' # Assuming convention like 'journal_id' + 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 object '{parent_obj_type}' with ID '{parent_obj_id}' not found.") 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=status_filter.enabled, hidden=status_filter.hidden, @@ -594,14 +605,27 @@ async def get_child_obj_li( as_list=True, ) - if sql_result: + if sql_result is False: + return mk_resp(data=False, status_code=500, response=response, status_message="Database error occurred.") + elif sql_result: resp_data_li = [] for record in sql_result: - resp_data = base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset) - resp_data_li.append(resp_data) + obj_inst = base_name(**record) + obj_dict = obj_inst.dict( + by_alias=serialization.by_alias, + exclude_unset=serialization.exclude_unset, + exclude_defaults=serialization.exclude_defaults, + exclude_none=serialization.exclude_none + ) + # Ensure id_random is present if it exists on the instance + id_random_alias = base_name.__fields__['id_random'].alias if 'id_random' in base_name.__fields__ else 'id_random' + if id_random_alias not in obj_dict and hasattr(obj_inst, 'id_random'): + obj_dict[id_random_alias] = obj_inst.id_random + + resp_data_li.append(obj_dict) return mk_resp(data=resp_data_li, response=response) else: - return mk_resp(data=[], status_code=200, response=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) @@ -618,10 +642,6 @@ async def post_child_obj( ): """ Create a new child object for a given parent. - - Logic: - - Auto-injection: Automatically resolves the parent's random ID and injects the - integer ID into the child's data before validation and insertion. """ if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) @@ -631,55 +651,39 @@ async def post_child_obj( 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=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=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=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] + 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=response, status_message=f"Configuration for child object type '{child_obj_name}' is incomplete.") + obj_data[f'{parent_obj_type}_id'] = resolved_parent_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 - - # 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=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=serialization.by_alias, exclude_unset=serialization.exclude_unset) return mk_resp(data=resp_data, response=response) - else: - return mk_resp(data={"obj_id": new_obj_id, "obj_id_random": new_obj_id_random}, status_code=404, response=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=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 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) @@ -694,11 +698,7 @@ async def get_child_obj( delay: DelayParams = Depends(get_delay_params), ): """ - Get a single child object by its ID, ensuring it belongs to the correct parent. - - Security: - - Verifies that the child object's foreign key correctly points to the parent - provided in the URL, preventing access to unrelated child objects. + Get a single child object, verifying parentage. """ if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) @@ -706,39 +706,23 @@ async def get_child_obj( log.setLevel(logging.WARNING) log.debug(locals()) - 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=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=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=response, status_message=f"Parent object '{parent_obj_name}' with ID '{parent_obj_id}' not found.") - - obj_cfg = obj_type_kv_li[child_obj_name] + 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 not table_name or not base_name: - return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration for object type '{child_obj_name}' is incomplete.") - - 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=response, status_message=f"Child object with ID '{child_obj_id}' not found.") - if sql_result := sql_select(table_name=table_name, record_id=resolved_child_id): - # Verify the child belongs to the parent - parent_fk_field_name = f'{parent_obj_name}_id' - if sql_result.get(parent_fk_field_name) != resolved_parent_id: - return mk_resp(data=False, status_code=404, response=response, status_message=f"Child object '{child_obj_id}' not found under parent '{parent_obj_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) - else: - return mk_resp(data=False, status_code=404, response=response, status_message=f"Child object with ID '{child_obj_id}' not found in database.") + 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) @@ -755,10 +739,7 @@ async def patch_child_obj( delay: DelayParams = Depends(get_delay_params), ): """ - Update a child object by its ID, ensuring it belongs to the correct parent. - - Verification: - - Like GET, PATCH verifies parentage before applying any updates to ensure data integrity. + Update a child object, verifying parentage. """ if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) @@ -767,55 +748,30 @@ async def patch_child_obj( log.debug(locals()) 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=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=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=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=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=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=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=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=serialization.by_alias, exclude_unset=serialization.exclude_unset) return mk_resp(data=resp_data, response=response) - else: - return mk_resp(data=True, status_code=404, response=response, status_message="Object updated but could not be retrieved post-update.") - else: - return mk_resp(data=True, response=response, status_message="Object updated successfully.") - else: - return mk_resp(data=False, status_code=400, response=response, status_message="Failed to update object in database.") + 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) @@ -825,20 +781,12 @@ 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 = Query('delete', regex='^(delete|hide|disable)$', description="Deletion method: delete (hard), hide (soft), disable (soft)"), + 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. - - Safety: - - Enforces parentage verification before deletion to prevent unauthorized data removal. - - Soft Delete: - - Use 'method=hide' to set 'hide=True'. - - Use 'method=disable' to set 'enable=False'. - - Default is 'method=delete' (hard database delete). + Delete a child object, verifying parentage (hard or soft). """ if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) @@ -846,573 +794,29 @@ async def delete_child_obj( log.setLevel(logging.WARNING) log.debug(locals()) - 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=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=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=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=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_delete = obj_cfg.get('tbl_update', obj_cfg.get('tbl')) - table_name_select = obj_cfg.get('tbl_default', obj_cfg.get('tbl')) # For verification + table_name_select = obj_cfg.get('tbl_default', obj_cfg.get('tbl')) - if not table_name_delete or not table_name_select: - return mk_resp(data=False, status_code=500, response=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=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=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 method == 'hide': - if sql_update(table_name=table_name_delete, record_id=resolved_child_id, data={'hide': True}): - return mk_resp(data=True, response=response, status_message=f"Object with ID '{child_obj_id}' hidden successfully.") - else: - return mk_resp(data=False, status_code=400, response=response, status_message="Failed to hide object. It may not support the 'hide' field.") + success = sql_update(table_name=table_name_delete, record_id=resolved_child_id, data={'hide': True}) elif method == 'disable': - if sql_update(table_name=table_name_delete, record_id=resolved_child_id, data={'enable': False}): - return mk_resp(data=True, response=response, status_message=f"Object with ID '{child_obj_id}' disabled successfully.") - else: - return mk_resp(data=False, status_code=400, response=response, status_message="Failed to disable object. It may not support the 'enable' field.") + success = sql_update(table_name=table_name_delete, record_id=resolved_child_id, data={'enable': False}) else: - # Default: hard delete - if sql_delete(table_name=table_name_delete, record_id=resolved_child_id): - return mk_resp(data=True, response=response, status_message=f"Object with ID '{child_obj_id}' deleted successfully.") - else: - return mk_resp(data=False, status_code=400, response=response, status_message="Failed to delete object in database.") + success = sql_delete(table_name=table_name_delete, record_id=resolved_child_id) - log.setLevel(logging.WARNING) - log.debug(locals()) - - 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=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')) - 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=response, status_message=f"Configuration for object type '{obj_name}' is incomplete.") - - # 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=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=obj_name) - - 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) - else: - return mk_resp(data={"obj_id": new_obj_id, "obj_id_random": new_obj_id_random}, status_code=404, response=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=response) - else: - return mk_resp(data=False, status_code=400, response=response, status_message="Failed to create object in database.") - - -@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, - 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) - """ - if delay.sleep_time_s > 0: - await asyncio.sleep(delay.sleep_time_s) - - log.setLevel(logging.WARNING) - log.debug(locals()) - - 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=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')) - 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_update or not input_model or not table_name_select or not output_model: - return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration for object type '{obj_name}' is incomplete.") - - 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=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 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=serialization.by_alias, exclude_unset=serialization.exclude_unset) - return mk_resp(data=resp_data, response=response) - else: - return mk_resp(data=True, status_code=404, response=response, status_message="Object updated but could not be retrieved.") - else: - return mk_resp(data=True, response=response, status_message="Object updated successfully.") - else: - return mk_resp(data=False, status_code=400, response=response, status_message="Failed to update object in database. It may not have been found, or the data was invalid.") - - -@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), - account: AccountContext = Depends(get_account_context), - delay: DelayParams = Depends(get_delay_params), - ): - """ - Delete a top-level object. - Examples: - - DELETE /v3/crud/journal/{journal_id} - """ - if delay.sleep_time_s > 0: - await asyncio.sleep(delay.sleep_time_s) - - log.setLevel(logging.WARNING) - log.debug(locals()) - - 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.") - - obj_cfg = obj_type_kv_li[obj_name] - table_name_delete = obj_cfg.get('tbl_update', obj_cfg.get('tbl')) - - if not table_name_delete: - return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration for object type '{obj_name}' is incomplete (missing table for deletion).") - - 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=response, status_message=f"Object with ID '{obj_id}' not found.") - - if sql_delete_result := sql_delete(table_name=table_name_delete, record_id=record_id): - return mk_resp(data=True, response=response, status_message=f"Object with ID '{obj_id}' deleted successfully.") - else: - return mk_resp(data=False, status_code=400, response=response, status_message="Failed to delete object in database. It may not have been found.") - - -@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(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/ - """ - if delay.sleep_time_s > 0: - await asyncio.sleep(delay.sleep_time_s) - - log.setLevel(logging.WARNING) - log.debug(locals()) - - # 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 - - 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=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) - - obj_name = child_obj_type - 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.") - - 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 for object type '{obj_name}' is incomplete.") - - # 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) - if not resolved_parent_id: - return mk_resp(data=False, status_code=404, response=response, status_message=f"Parent object '{for_obj_type}' with ID '{for_obj_id}' not found.") - - field_name = f'{for_obj_type}_id' # Assuming convention like 'journal_id' - - sql_result = sql_select( - table_name=table_name, - field_name=field_name, - 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 = [] - for record in sql_result: - resp_data = base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset) - resp_data_li.append(resp_data) - return mk_resp(data=resp_data_li, response=response) - else: - return mk_resp(data=[], status_code=200, response=response) # Return empty list on no results - - -@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(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) - """ - if delay.sleep_time_s > 0: - await asyncio.sleep(delay.sleep_time_s) - - log.setLevel(logging.WARNING) - log.debug(locals()) - - obj_data = await request.json() - - parent_obj_name = parent_obj_type - child_obj_name = child_obj_type - - if parent_obj_name not in obj_type_kv_li: - return mk_resp(data=False, status_code=400, response=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=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) - if not resolved_parent_id: - return mk_resp(data=False, status_code=404, response=response, status_message=f"Parent object '{parent_obj_name}' with ID '{parent_obj_id}' not found.") - - obj_cfg = obj_type_kv_li[child_obj_name] - 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=response, status_message=f"Configuration for child object type '{child_obj_name}' is incomplete.") - - # 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 - - # 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=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) - - 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) - else: - return mk_resp(data={"obj_id": new_obj_id, "obj_id_random": new_obj_id_random}, status_code=404, response=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=response) - else: - return mk_resp(data=False, status_code=400, response=response, status_message="Failed to create child object in database.") - - -@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(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} - """ - if delay.sleep_time_s > 0: - await asyncio.sleep(delay.sleep_time_s) - - log.setLevel(logging.WARNING) - log.debug(locals()) - - parent_obj_name = parent_obj_type - child_obj_name = child_obj_type - - if parent_obj_name not in obj_type_kv_li: - return mk_resp(data=False, status_code=400, response=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=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) - if not resolved_parent_id: - return mk_resp(data=False, status_code=404, response=response, status_message=f"Parent object '{parent_obj_name}' with ID '{parent_obj_id}' not found.") - - obj_cfg = obj_type_kv_li[child_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 for object type '{child_obj_name}' is incomplete.") - - 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=response, status_message=f"Child object with ID '{child_obj_id}' not found.") - - if sql_result := sql_select(table_name=table_name, record_id=resolved_child_id): - # Verify the child belongs to the parent - parent_fk_field_name = f'{parent_obj_name}_id' - if sql_result.get(parent_fk_field_name) != resolved_parent_id: - return mk_resp(data=False, status_code=404, response=response, status_message=f"Child object '{child_obj_id}' not found under parent '{parent_obj_id}'.") - - 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) - else: - return mk_resp(data=False, status_code=404, response=response, status_message=f"Child object with ID '{child_obj_id}' not found in database.") - - -@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(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} - """ - if delay.sleep_time_s > 0: - await asyncio.sleep(delay.sleep_time_s) - - log.setLevel(logging.WARNING) - log.debug(locals()) - - obj_data = await request.json() - - parent_obj_name = parent_obj_type - child_obj_name = child_obj_type - - if parent_obj_name not in obj_type_kv_li: - return mk_resp(data=False, status_code=400, response=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=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=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=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_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=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=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=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=serialization.by_alias, exclude_unset=serialization.exclude_unset) - return mk_resp(data=resp_data, response=response) - else: - return mk_resp(data=True, status_code=404, response=response, status_message="Object updated but could not be retrieved post-update.") - else: - return mk_resp(data=True, response=response, status_message="Object updated successfully.") - else: - return mk_resp(data=False, status_code=400, response=response, status_message="Failed to update object in database.") - - -@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), - 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} - """ - if delay.sleep_time_s > 0: - await asyncio.sleep(delay.sleep_time_s) - - log.setLevel(logging.WARNING) - log.debug(locals()) - - parent_obj_name = parent_obj_type - child_obj_name = child_obj_type - - if parent_obj_name not in obj_type_kv_li: - return mk_resp(data=False, status_code=400, response=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=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=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=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_delete = 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_delete or not table_name_select: - return mk_resp(data=False, status_code=500, response=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=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=response, status_message=f"Child object '{child_obj_id}' not found.") - - # If verification passes, delete the object - if sql_delete(table_name=table_name_delete, record_id=resolved_child_id): - return mk_resp(data=True, response=response, status_message=f"Object with ID '{child_obj_id}' deleted successfully.") - else: - return mk_resp(data=False, status_code=400, response=response, status_message="Failed to delete object in database.") \ No newline at end of file + 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.")