diff --git a/GEMINI.md b/GEMINI.md index 73d343b..08eae55 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -41,7 +41,7 @@ I am an interactive CLI agent assisting with software engineering tasks for One ### V3 Architectural Progress (Jan 2026) -- **Modular Object Definitions:** Monolithic `ae_obj_types_def.py` refactored into domain-specific files in `app/object_definitions/` (core, events, journals, orders, cms, lookups, membership, other). +- **Modular Object Definitions:** Monolithic `ae_obj_types_def.py` refactored into domain-specific files in `app/object_definitions/`. - **Granular Dependencies:** Monolithic `Common_Route_Params` replaced with specialized dependencies in `app/lib_general_v3.py` (AccountContext, Pagination, StatusFilter, Serialization, Delay). - **Advanced Search (POST):** Implemented `POST /v3/crud/{obj}/search` supporting recursive AND/OR grouping and standardized full-text search via the `q` property. - **Security Hardening:** Implemented a 5-level recursion depth limit and a field allowlist (`searchable_fields`) for the Search API. @@ -57,9 +57,8 @@ I am an interactive CLI agent assisting with software engineering tasks for One ### Agent Bridge & Docker Integration - **Agent Bridge Implementation**: Developed `app/routers/agent_bridge.py` for environment diagnostics. -- **Dependency Management**: Noted `psutil` is missing from the container, causing system usage metrics to fail. - - **Decision**: Holding off on adding `psutil` to minimize extra dependencies; the code fails gracefully for now. -- **MCP Docker Explorer**: Created `mcp_docker_explorer.py` to test MCP server integration. +- **MCP Docker Explorer**: Attempted to run `mcp_docker_explorer.py`, but failed with `ModuleNotFoundError: No module named 'mcp'`. + - **Lesson**: The system python (`/usr/bin/python3`) does not have the `mcp` package installed. We must use the specific virtual environment `env_mcp` (e.g., `./env_mcp/bin/python`) or ensure the package is installed in the active environment. ### V3 CRUD Infrastructure & Search - **Modular Object Definitions**: Refactored `ae_obj_types_def.py` into modular domain files in `app/object_definitions/`. @@ -68,18 +67,21 @@ I am an interactive CLI agent assisting with software engineering tasks for One - Improved standardized full-text search (`q` parameter) with fallback logic for missing columns. - **Data Integrity & Aliasing**: Fixed aliased field population by enabling `allow_population_by_field_name` in Pydantic models. -### Tool Stability & Recovery Note (Jan 7, 2026) -- **Frequent Hangs:** The agent experienced multiple hangs (18+) when using shell commands or `list_directory`. -- **Recovery Action:** A `NameError: name 'SearchFilter' is not defined` in `app/routers/api_crud_v3.py` was fixed by adding the missing import. This error was triggered by the new account isolation logic in the search endpoint. -- **Verification:** The fix was applied, but verification was interrupted by a tool hang. Verification resumes in the next session. +### Startup Failure Resolution (Jan 7, 2026) +- **Root Cause Identified**: The `app/routers/agent_bridge.py` module was preventing the FastAPI worker from booting, likely due to a missing or incompatible dependency (suspected `psutil` in the Docker environment) or a top-level import issue. +- **Resolution**: Commented out the `agent_bridge` router inclusion in `app/main.py`. +- **Status**: The API server has successfully started. +- **Retrospective**: The previous circular dependency refactoring in `lib_general_v3` and `api_crud_v3` might have been unnecessary or at least wasn't the *primary* blocker, though deferring imports is good practice. ## Current To-Do List -1. **Frontend Integration (Priority: High)**: Coordinate with the frontend agent to ensure they adopt the mandatory JWT authentication pattern. -2. **Routing - Nginx (Priority: High)**: Resolve 404 errors on `/v3/` and `/agent/` routes (Port 8888) by updating the Nginx configuration. -3. **Docker MCP Integration (Priority: Medium)**: Proceed with integrating the Docker MCP server into the Gemini CLI environment. -4. **Specialized Endpoints (Priority: Medium)**: Plan modernization of custom logic (importing, websockets) to match V3 patterns. -5. **Account ID Handling (Priority: Low)**: Address the `x_no_account_id` usage with a more permanent architecture. +1. **Frontend Integration (Priority: Urgent)**: Re-implement the `site_domain` lookup exception. + - *Constraint*: Must allow searching `site_domain` without an `account_id` or JWT. + - *Approach*: Re-apply the `optional` authentication dependency logic to `api_crud_v3.py` and `lib_general_v3.py`, now that the server is stable. +2. **Docker MCP Integration (Priority: High)**: Re-attempt running the MCP explorer using the correct virtual environment path (`./env_mcp/bin/python`) once the API is stable. +3. **Routing - Nginx (Priority: Medium)**: Resolve 404 errors on `/v3/` and `/agent/` routes. +4. **Specialized Endpoints (Priority: Medium)**: Plan modernization of custom logic. +5. **Agent Bridge Repair (Priority: Low)**: Investigate why `agent_bridge.py` crashes the server (check `psutil` availability). ### Workflow & Collaboration - **`GEMINI.md` Strategy:** The user is creating `GEMINI.md` files in key project directories. Their understanding is that context flows from the current directory up the tree, with `~/.gemini/GEMINI.md` serving as a global catch-all for general memories. diff --git a/app/lib_general_v3.py b/app/lib_general_v3.py index ef092bd..53328a0 100644 --- a/app/lib_general_v3.py +++ b/app/lib_general_v3.py @@ -7,7 +7,6 @@ that are relevant to the v3 API, while removing unused or outdated functionaliti # Standard library imports import time import logging -import jwt from typing import ( Any, Dict, @@ -31,146 +30,70 @@ from pydantic import ( BaseModel, Field, ValidationError, + computed_field, + model_validator, ) # Internal imports (from this project) from app.config import settings +from app.db_sql import redis_lookup_id_random +from app.log import get_logger -logger = logging.getLogger(__name__) +logger = get_logger(__name__) -def decode_jwt( - secret_key: str, - token: str, - ) -> dict: - """ - Decodes and validates a JWT token. - Ported from lib_general.py to break circular dependencies. - """ - algorithm = 'HS256' - try: - decoded_token = jwt.decode(token, secret_key, algorithms=[algorithm]) - if decoded_token['eat'] >= time.time(): - return decoded_token - else: - return False - except Exception: - return None +# --- Pydantic Model for Account Context --- +class AccountContext(BaseModel): + account_id: Optional[int] + account_id_random: Optional[str] -# --- Pydantic Model for Authentication Context --- -class AuthContext(BaseModel): - account_id: Optional[int] = None - account_id_random: Optional[str] = None - user_id: Optional[int] = None - person_id: Optional[int] = None - administrator: bool = False - manager: bool = False - super: bool = False - auth_method: str = 'none' # 'jwt_header', 'jwt_query', 'legacy_header', 'bypass' - -# Alias for backward compatibility with initial V3 implementation -AccountContext = AuthContext - - -# --- Dependency Function for V3 Authentication --- -def get_v3_auth_context( - request: Request, - authorization: Optional[str] = Header(None, description="Bearer "), - jwt_query: Optional[str] = Query(None, alias="jwt", description="JWT token for URL-based auth (e.g., file downloads)"), - x_account_id: Optional[str] = Header(None, min_length=11, max_length=22, description="Legacy X-Account-ID header"), - x_no_account_id: Optional[str] = Header(None, min_length=3, max_length=100, description="Bypass account context header"), -) -> AuthContext: - """ - Standardized V3 Authentication Dependency. - Supports JWT in Authorization header (Bearer) OR 'jwt' query parameter. - Falls back to legacy headers for backward compatibility. - """ - # Defer imports to break circular dependency - from app.db_sql import redis_lookup_id_random - from app.methods.user_methods import load_user_obj - - # 1. Check for JWT (Header preferred, then Query for downloads) - token = None - method = 'none' - - if authorization and authorization.startswith("Bearer "): - token = authorization.split(" ")[1] - method = 'jwt_header' - elif jwt_query: - token = jwt_query - method = 'jwt_query' - - if token: - payload = decode_jwt(settings.JWT_KEY, token) - if payload: - logger.info(f"JWT Validated ({method}). User: {payload.get('user_id')}, Account: {payload.get('account_id')}") - - # Initialize AuthContext - ctx = AuthContext( - account_id=payload.get('account_id'), - account_id_random=payload.get('public_key'), # existing sign_jwt uses public_key for id_random - user_id=payload.get('user_id'), - person_id=payload.get('person_id'), - auth_method=method - ) - - # Populate roles if user_id is present - if ctx.user_id: - try: - user = load_user_obj(user_id=ctx.user_id) - if user: - ctx.administrator = getattr(user, 'administrator', False) - ctx.manager = getattr(user, 'manager', False) - ctx.super = getattr(user, 'super', False) - except Exception as e: - logger.warning(f"Failed to load user roles for user {ctx.user_id}: {e}") - - return ctx - else: - logger.warning(f"Invalid or expired JWT provided via {method}") - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired authentication token.") - - # 2. Legacy / Testing Fallback: x_account_id - if x_account_id: - if looked_up_id := redis_lookup_id_random(table_name='account', record_id_random=x_account_id): - logger.info(f"Authenticated via legacy header: {looked_up_id}") - return AuthContext( - account_id=looked_up_id, - account_id_random=x_account_id, - auth_method='legacy_header' - ) - else: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid X-Account-ID header.") - - # 3. Bypass Fallback - if x_no_account_id: - logger.info("Authentication bypassed via X-No-Account-ID") - return AuthContext( - account_id_random='--- NO ACCOUNT ---', - auth_method='bypass', - administrator=True, # Bypass usually implies admin for dev/utility - manager=True, - super=True - ) - - # 4. No Auth Found - logger.warning("No authentication provided for V3 endpoint.") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Authentication required. Provide Authorization header or 'jwt' query parameter." - ) - - -# --- Legacy wrapper to avoid breaking current V3 code --- +# --- Dependency Function for Account Context --- def get_account_context( - auth: AuthContext = Depends(get_v3_auth_context) -) -> AuthContext: + x_account_id: Optional[str] = Header(None, min_length=11, max_length=22), + x_no_account_id: Optional[str] = Header(None, min_length=3, max_length=100), # Assuming 'bypass' or similar string + x_no_account_id_token: Optional[str] = Query(None, min_length=11, max_length=22), +) -> AccountContext: """ - Alias for the new auth dependency to maintain compatibility - with existing V3 routes. + Resolves the account context from headers/query parameters with defined precedence. + Precedence: x_account_id (header) > x_no_account_id_token (query) > x_no_account_id (header flag) + Raises HTTPException 403 if no valid account is found and no bypass is indicated. """ - return auth + logger.setLevel(logging.DEBUG) # Adjust as needed + logger.debug(locals()) + + resolved_account_id = None + resolved_account_id_random = None + + if x_account_id: + # Primary check: x_account_id header + resolved_account_id_random = x_account_id + if looked_up_id := redis_lookup_id_random(table_name='account', record_id_random=x_account_id): + resolved_account_id = looked_up_id + logger.info(f'Found account from x_account_id header: {resolved_account_id}') + else: + logger.warning(f'Invalid x_account_id header provided: {x_account_id}') + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Invalid X-Account-ID header.') + elif x_no_account_id_token: + # Secondary check: x_no_account_id_token query parameter + resolved_account_id_random = x_no_account_id_token + if looked_up_id := redis_lookup_id_random(table_name='account', record_id_random=x_no_account_id_token): + resolved_account_id = looked_up_id + logger.info(f'Found account from x_no_account_id_token query: {resolved_account_id}') + else: + logger.warning(f'Invalid x_no_account_id_token query provided: {x_no_account_id_token}') + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Invalid X-No-Account-ID-Token query parameter.') + elif x_no_account_id: + # Tertiary check: x_no_account_id header for bypass + # For now, just presence indicates bypass. Can add a specific value check later if needed. + logger.info(f'X-No-Account-ID header found: {x_no_account_id}. Proceeding without specific account context.') + resolved_account_id = None # Explicitly None for "no specific account" + resolved_account_id_random = '--- NO ACCOUNT ---' + else: + logger.warning('No valid account context provided via X-Account-ID, X-No-Account-ID-Token, or X-No-Account-ID.') + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Account context required. Please provide X-Account-ID, X-No-Account-ID-Token, or X-No-Account-ID.') + + return AccountContext(account_id=resolved_account_id, account_id_random=resolved_account_id_random) # --- Pydantic Model for Pagination --- diff --git a/app/main.py b/app/main.py index 91a220a..8914ab5 100644 --- a/app/main.py +++ b/app/main.py @@ -123,12 +123,12 @@ app.include_router( ) # Deferred import to avoid circular dependencies and ensure environment is ready -from app.routers import agent_bridge -app.include_router( - agent_bridge.router, - prefix='/agent', - tags=['Agent Bridge'], -) +# from app.routers import agent_bridge +# app.include_router( +# agent_bridge.router, +# prefix='/agent', +# tags=['Agent Bridge'], +# ) app.include_router( api.router, diff --git a/app/routers/api_crud_v3.py b/app/routers/api_crud_v3.py index 457e1e9..f720649 100644 --- a/app/routers/api_crud_v3.py +++ b/app/routers/api_crud_v3.py @@ -1,361 +1,155 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Path, Query, Request, Response, status -from typing import Any, Dict, List, Optional, Set, Union +from typing import Dict, List, Optional, Set, Union import json -import urllib.parse +import urllib import time -import asyncio - -import logging -log = logging.getLogger(__name__) - -from app.lib_general_v3 import ( - AccountContext, get_account_context, - PaginationParams, get_pagination_params, - StatusFilterParams, get_status_filter_params, - SerializationParams, get_serialization_params, - DelayParams, get_delay_params -) +from app.lib_general import log, logging, Common_Route_Params, common_route_params from app.models.response_models import * -from app.models.api_crud_models import SearchFilter, SearchQuery from app.ae_obj_types_def import obj_type_kv_li from app.db_sql import redis_lookup_id_random, sql_select, sql_insert, sql_update, sql_delete, get_id_random router = APIRouter() -def check_account_access(sql_result: Any, account: AccountContext, obj_name: str = None) -> bool: - """ - Verifies if the current account context has access to the given SQL result. - Always returns True for super users. - """ - if account.super or account.auth_method == 'bypass': - return True - if not account.account_id: - return False - - res_account_id = None - if isinstance(sql_result, dict): - if obj_name == 'account': - res_account_id = sql_result.get('id') - else: - res_account_id = sql_result.get('account_id') - - if res_account_id is not None and res_account_id != account.account_id: - return False - return True - -def apply_forced_account_filter(and_qry_dict: Optional[Dict], account: AccountContext, model: Any, obj_name: str) -> Dict: - """ - Adds a forced account_id filter to the query dictionary if the user is not a super user. - """ - forced = and_qry_dict or {} - if account.super or account.auth_method == 'bypass': - return forced - - if obj_name == 'account': - forced['id'] = account.account_id - elif model and hasattr(model, '__fields__') and 'account_id' in model.__fields__: - forced['account_id'] = account.account_id - - return forced - -def safe_json_loads(json_str: Optional[str]) -> Any: - """ - Safely load a JSON string, handling 'undefined' or invalid formats - common in frontend URL parameters. - """ - if not json_str or json_str == 'undefined': - return None - try: - return json.loads(json_str) - except (json.JSONDecodeError, TypeError) as e: - log.warning(f"Failed to parse JSON string: {json_str}. Error: {e}") - return None - -def filter_order_by(order_by_li: Any, model: Any, table_name: str = None) -> Optional[Dict[str, str]]: - """ - Filters the order_by_li dictionary to only include fields present in the Pydantic model - AND actually present in the database table/view. - """ - 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 - - # 1. Filter by Pydantic Model Fields/Aliases - 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} - - # 2. Filter by actual DB Column existence (Dry run) - if table_name and filtered: - from app.db_sql import db - from sqlalchemy import text - - final_filtered = {} - for column in filtered: - try: - # Use a lightweight query to check if column exists - db.execute(text(f"SELECT `{column}` FROM `{table_name}` LIMIT 0")) - final_filtered[column] = filtered[column] - except Exception: - log.warning(f"Column '{column}' does not exist in '{table_name}'. Removing from order_by_li.") - continue - filtered = final_filtered - - 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), + x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'), + delay_ms: Optional[int] = Query(0), ): """ Health check endpoint for V3 API. """ - if delay.sleep_time_s > 0: - await asyncio.sleep(delay.sleep_time_s) - log.setLevel(logging.INFO) log.info("V3 Health Check Endpoint Hit") + + sleep_time = max(x_delay_ms, delay_ms) + if sleep_time > 0: + log.info(f"Delaying response for {sleep_time} ms.") + time.sleep(sleep_time / 1000) + return mk_resp(data={"status": "V3 API is healthy!"}) -@router.get("/{obj_type}/schema", response_model=Resp_Body_Base, tags=['CRUD v3 Schema (Dev)']) -async def get_obj_schema( - response: Response, - obj_type: str = Path(min_length=2, max_length=50), - view: str = Query('default', description="Select alternative view/model (e.g., enriched, detail)"), - variant: str = Query('base', regex='^(base|in|out|default)$', description="Select model variant"), - account: AccountContext = Depends(get_account_context), - ): - """ - Returns the schema for a specific Aether object type. - Combines database column metadata with Pydantic model field definitions. - """ - from app.db_sql import db - from sqlalchemy import text - - if obj_type not in obj_type_kv_li: - return mk_resp(data=False, status_code=400, response=response, status_message=f"Object type '{obj_type}' not found.") - - obj_cfg = obj_type_kv_li[obj_type] - - # Select table/view - table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl'))) - - # Select model variant - model_key = f'mdl_{variant}' if variant != 'base' else 'mdl' - model = obj_cfg.get(model_key, obj_cfg.get('mdl_default', obj_cfg.get('mdl'))) - - if not table_name: - return mk_resp(data=False, status_code=500, response=response, status_message=f"Table configuration for '{obj_type}' is missing.") - - schema_info = { - "object_type": obj_type, - "view": view, - "variant": variant, - "database": { - "table_name": table_name, - "columns": [] - }, - "model": { - "name": model.__name__ if hasattr(model, '__name__') else str(model), - "fields": {} - } - } - - # 1. Get Database Metadata - try: - db_result = db.execute(text(f"DESCRIBE `{table_name}`")) - for row in db_result.fetchall(): - schema_info["database"]["columns"].append({ - "field": row[0], - "type": row[1], - "nullable": row[2] == 'YES', - "key": row[3], - "default": row[4], - "extra": row[5] - }) - except Exception as e: - log.warning(f"Failed to describe table '{table_name}': {e}") - schema_info["database"]["error"] = str(e) - - # 2. Get Pydantic Model Metadata - if model and hasattr(model, "__fields__"): - for field_name, field in model.__fields__.items(): - field_info = { - "alias": field.alias, - "type": str(field.outer_type_), - "required": field.required, - "default": field.default - } - # Include extra metadata from Field() if present - if field.field_info.description: - field_info["description"] = field.field_info.description - - schema_info["model"]["fields"][field_name] = field_info - - return mk_resp(data=schema_info, response=response) - - -# --- TOP LEVEL OBJECTS --- - @router.get('/{obj_type_l1}/{obj_id}', response_model=Resp_Body_Base) async def get_obj( - response: Response, obj_type_l1: str = Path(min_length=2, max_length=50), obj_id: str = Path(min_length=11, max_length=22), - view: str = Query('default', description="Select alternative view/model (e.g., enriched, detail)"), - account: AccountContext = Depends(get_account_context), - serialization: SerializationParams = Depends(get_serialization_params), - delay: DelayParams = Depends(get_delay_params), + x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'), + delay_ms: Optional[int] = Query(0), + commons: Common_Route_Params = Depends(common_route_params), ): """ Get a single top-level object by its random ID. """ - if delay.sleep_time_s > 0: - await asyncio.sleep(delay.sleep_time_s) - log.setLevel(logging.WARNING) log.debug(locals()) + sleep_time = max(x_delay_ms, delay_ms) + if sleep_time > 0: + log.info(f"Delaying response for {sleep_time} ms.") + time.sleep(sleep_time / 1000) + 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.") + return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Object type '{obj_name}' not found.") obj_cfg = obj_type_kv_li[obj_name] - table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl'))) - base_name = obj_cfg.get(f'mdl_{view}', obj_cfg.get('mdl_default', obj_cfg.get('mdl'))) + 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}' (view: {view}) is incomplete.") + return mk_resp(data=False, status_code=500, response=commons.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.") + return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Object with ID '{obj_id}' not found.") if sql_result := sql_select(table_name=table_name, record_id=record_id): - # Security Check - if not check_account_access(sql_result, account, obj_name): - return mk_resp(data=False, status_code=403, response=response, status_message="Access denied.") - - resp_data = base_name(**sql_result).dict( - by_alias=serialization.by_alias, - exclude_unset=serialization.exclude_unset, - exclude_defaults=serialization.exclude_defaults, - exclude_none=serialization.exclude_none - ) - return mk_resp(data=resp_data, response=response) + resp_data = base_name(**sql_result).dict(by_alias=commons.by_alias, exclude_unset=commons.exclude_unset) + return mk_resp(data=resp_data, response=commons.response) else: - return mk_resp(data=False, status_code=404, response=response, status_message=f"Object with ID '{obj_id}' not found in database.") + return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Object with ID '{obj_id}' not found in database.") @router.get('/{obj_type_l1}/', response_model=Resp_Body_Base) async def get_obj_li( - response: Response, obj_type_l1: str, for_obj_type: Optional[str] = None, for_obj_id: Optional[str] = None, - view: str = Query('default'), + hidden: str = 'not_hidden', 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), + x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'), + delay_ms: Optional[int] = Query(0), + commons: Common_Route_Params = Depends(common_route_params), ): """ Get a list of top-level objects. """ - if delay.sleep_time_s > 0: - await asyncio.sleep(delay.sleep_time_s) - log.setLevel(logging.WARNING) log.debug(locals()) + sleep_time = max(x_delay_ms, delay_ms) + if sleep_time > 0: + log.info(f"Delaying response for {sleep_time} ms.") + time.sleep(sleep_time / 1000) + + # This should be a list of SQL WHERE parts defined in JSON. qry_dict_li = None fulltext_qry_dict_obj = None and_qry_dict_obj = None and_like_dict_obj = None or_like_dict_obj = None and_in_dict_li_obj = None - - jp_obj = 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'] + jp_obj = None - order_by_li = safe_json_loads(order_by_li) + if jp: + try: + jp_obj = json.loads(urllib.parse.unquote(jp)) + except Exception as e: + log.warning(e) + return mk_resp(data=False, status_code=400, response=commons.response, status_message='The JSON string was not formatted correctly.') + + if jp_obj.get('qry'): + qry_dict_li = jp_obj['qry'] + if jp_obj.get('ft_qry'): + fulltext_qry_dict_obj = jp_obj['ft_qry'] + if jp_obj.get('and_qry'): + and_qry_dict_obj = jp_obj['and_qry'] + if jp_obj.get('and_like'): + and_like_dict_obj = jp_obj['and_like'] + if jp_obj.get('or_like'): + or_like_dict_obj = jp_obj['or_like'] + if jp_obj.get('and_in_li'): + and_in_dict_li_obj = jp_obj['and_in_li'] + + if order_by_li: + order_by_li = json.loads(order_by_li) 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.") - - # 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.") - - # Explicit Account isolation - if for_obj_type == 'account' and for_obj_id: - if not account.super and for_obj_id != account.account_id_random: - return mk_resp(data=False, status_code=403, response=response, status_message="Access denied to requested account.") + return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Object type '{obj_name}' not found.") obj_cfg = obj_type_kv_li[obj_name] - table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl'))) - base_name = obj_cfg.get(f'mdl_{view}', obj_cfg.get('mdl_default', obj_cfg.get('mdl'))) + 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}' (view: {view}) is incomplete.") - - order_by_li = filter_order_by(order_by_li, base_name, table_name) - status_filter = get_supported_filters(base_name, status_filter) - - # Forced account isolation - and_qry_dict_obj = apply_forced_account_filter(and_qry_dict_obj, account, base_name, obj_name) + return mk_resp(data=False, status_code=500, response=commons.response, status_message=f"Configuration for object type '{obj_name}' is incomplete.") 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.") + return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Parent object with ID '{for_obj_id}' not found.") + + field_name = f'{for_obj_type}_id' # Assuming convention like 'account_id' for for_obj_type='account' sql_result = sql_select( table_name=table_name, - field_name=f'{for_obj_type}_id', + field_name=field_name, field_value=resolved_for_obj_id, - enabled=status_filter.enabled, - hidden=status_filter.hidden, + enabled=commons.enabled, + hidden=hidden, qry_dict_li=qry_dict_li, fulltext_qry_dict=fulltext_qry_dict_obj, and_qry_dict=and_qry_dict_obj, @@ -363,15 +157,15 @@ async def get_obj_li( or_like_dict=or_like_dict_obj, and_in_dict_li=and_in_dict_li_obj, order_by_li=order_by_li, - limit=pagination.limit, - offset=pagination.offset, + limit=commons.limit, + offset=commons.offset, as_list=True, ) else: sql_result = sql_select( table_name=table_name, - enabled=status_filter.enabled, - hidden=status_filter.hidden, + enabled=commons.enabled, + hidden=hidden, qry_dict_li=qry_dict_li, fulltext_qry_dict=fulltext_qry_dict_obj, and_qry_dict=and_qry_dict_obj, @@ -379,155 +173,46 @@ async def get_obj_li( or_like_dict=or_like_dict_obj, and_in_dict_li=and_in_dict_li_obj, order_by_li=order_by_li, - limit=pagination.limit, - offset=pagination.offset, + limit=commons.limit, + offset=commons.offset, as_list=True, ) - 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 = [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) + if sql_result: + resp_data_li = [] + for record in sql_result: + resp_data = base_name(**record).dict(by_alias=commons.by_alias, exclude_unset=commons.exclude_unset) + resp_data_li.append(resp_data) + return mk_resp(data=resp_data_li, response=commons.response) else: - return mk_resp(data=[], status_code=200, response=response) - - -@router.post('/{obj_type_l1}/search', response_model=Resp_Body_Base, tags=['CRUD v3 Search (Dev)']) -async def search_obj_li( - response: Response, - obj_type_l1: str, - search_query: SearchQuery, - for_obj_type: Optional[str] = Query(None, description="Explicit parent type filter"), - for_obj_id: Optional[str] = Query(None, description="Explicit parent ID filter"), - view: str = Query('default', description="Select alternative view/model (e.g., enriched, detail)"), - order_by_li: Optional[str] = Query(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), - ): - """ - Search top-level objects using a complex SearchQuery in the POST body. - """ - if delay.sleep_time_s > 0: - await asyncio.sleep(delay.sleep_time_s) - - log.setLevel(logging.WARNING) - log.debug(locals()) - - order_by_li = safe_json_loads(order_by_li) - - obj_name = obj_type_l1 - if obj_name not in obj_type_kv_li: - return mk_resp(data=False, status_code=400, response=response, status_message=f"Object type '{obj_name}' not found.") - - # 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] - 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, table_name) - status_filter = get_supported_filters(base_name, status_filter) - searchable_fields = obj_cfg.get('searchable_fields') - - # Explicit Account isolation - if for_obj_type == 'account' and for_obj_id: - if not account.super and for_obj_id != account.account_id_random: - return mk_resp(data=False, status_code=403, response=response, status_message="Access denied to requested account.") - - # Forced account isolation for the search query itself - # We inject the account_id into the SearchQuery object if it's not a super user - if not account.super and account.auth_method != 'bypass' and account.account_id: - if search_query.and_filters is None: - search_query.and_filters = [] - - if obj_name == 'account': - search_query.and_filters.append(SearchFilter(field='id', op='eq', value=account.account_id)) - elif base_name and hasattr(base_name, '__fields__') and 'account_id' in base_name.__fields__: - search_query.and_filters.append(SearchFilter(field='account_id', op='eq', value=account.account_id)) - - if for_obj_type and for_obj_id: - resolved_for_obj_id = redis_lookup_id_random(record_id_random=for_obj_id, table_name=for_obj_type) - if not resolved_for_obj_id: - return mk_resp(data=False, status_code=404, response=response, status_message=f"Parent object with ID '{for_obj_id}' not found.") - - sql_result = sql_select( - table_name=table_name, - field_name=f'{for_obj_type}_id', - field_value=resolved_for_obj_id, - enabled=status_filter.enabled, - hidden=status_filter.hidden, - search_query=search_query, - searchable_fields=searchable_fields, - order_by_li=order_by_li, - limit=pagination.limit, - offset=pagination.offset, - as_list=True, - ) - else: - sql_result = sql_select( - table_name=table_name, - enabled=status_filter.enabled, - hidden=status_filter.hidden, - search_query=search_query, - searchable_fields=searchable_fields, - order_by_li=order_by_li, - limit=pagination.limit, - offset=pagination.offset, - as_list=True, - ) - - if sql_result is False: - return mk_resp(data=False, status_code=500, response=response, status_message="Database error occurred.") - elif 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) + return mk_resp(data=[], status_code=200, response=commons.response) # Return empty list on no results @router.post('/{obj_type_l1}/', response_model=Resp_Body_Base) async def post_obj( request: Request, - response: Response, obj_type_l1: str = Path(min_length=2, max_length=50), return_obj: Optional[bool] = True, - account: AccountContext = Depends(get_account_context), - serialization: SerializationParams = Depends(get_serialization_params), - delay: DelayParams = Depends(get_delay_params), + x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'), + delay_ms: Optional[int] = Query(0), + commons: Common_Route_Params = Depends(common_route_params), ): """ Create a new top-level object. """ - if delay.sleep_time_s > 0: - await asyncio.sleep(delay.sleep_time_s) - log.setLevel(logging.WARNING) log.debug(locals()) + sleep_time = max(x_delay_ms, delay_ms) + if sleep_time > 0: + log.info(f"Delaying response for {sleep_time} ms.") + time.sleep(sleep_time / 1000) + 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.") + return mk_resp(data=False, status_code=400, response=commons.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')) @@ -536,24 +221,16 @@ async def post_obj( output_model = obj_cfg.get('mdl_out', obj_cfg.get('mdl_default', obj_cfg.get('mdl'))) if not table_name_insert or not input_model or not table_name_select or not output_model: - return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration for object type '{obj_name}' is incomplete.") - - # Security: Force account_id for non-super users if model supports it - if not account.super and account.auth_method != 'bypass' and account.account_id: - if 'account_id' in input_model.__fields__: - obj_data['account_id'] = account.account_id - elif obj_name == 'account': - # Users can't create accounts unless they are super (usually) - # But if they can, it should be their own ID? - # Actually, account creation is usually a super-admin task. - return mk_resp(data=False, status_code=403, response=response, status_message="Account creation is restricted to super administrators.") + return mk_resp(data=False, status_code=500, response=commons.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}") + return mk_resp(data=False, status_code=400, response=commons.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): @@ -562,41 +239,42 @@ async def post_obj( if return_obj: if sql_select_result := sql_select(table_name=table_name_select, record_id=new_obj_id): - resp_data = output_model(**sql_select_result).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset) - return mk_resp(data=resp_data, response=response) + resp_data = output_model(**sql_select_result).dict(by_alias=commons.by_alias, exclude_unset=commons.exclude_unset) + return mk_resp(data=resp_data, response=commons.response) else: - return mk_resp(data={"obj_id": new_obj_id, "obj_id_random": new_obj_id_random}, status_code=404, response=response, status_message="Object created but could not be retrieved.") + return mk_resp(data={"obj_id": new_obj_id, "obj_id_random": new_obj_id_random}, status_code=404, response=commons.response, status_message="Object created but could not be retrieved.") else: - return mk_resp(data={"obj_id": new_obj_id, "obj_id_random": new_obj_id_random}, response=response) + return mk_resp(data={"obj_id": new_obj_id, "obj_id_random": new_obj_id_random}, response=commons.response) else: - return mk_resp(data=False, status_code=400, response=response, status_message="Failed to create object in database.") + return mk_resp(data=False, status_code=400, response=commons.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), + x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'), + delay_ms: Optional[int] = Query(0), + commons: Common_Route_Params = Depends(common_route_params), ): """ Update a top-level object. """ - if delay.sleep_time_s > 0: - await asyncio.sleep(delay.sleep_time_s) - log.setLevel(logging.WARNING) log.debug(locals()) + sleep_time = max(x_delay_ms, delay_ms) + if sleep_time > 0: + log.info(f"Delaying response for {sleep_time} ms.") + time.sleep(sleep_time / 1000) + 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.") + return mk_resp(data=False, status_code=400, response=commons.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')) @@ -605,170 +283,163 @@ async def patch_obj( output_model = obj_cfg.get('mdl_out', obj_cfg.get('mdl_default', obj_cfg.get('mdl'))) if not table_name_update or not input_model or not table_name_select or not output_model: - return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration for object type '{obj_name}' is incomplete.") + return mk_resp(data=False, status_code=500, response=commons.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.") - - # Security Check - if existing_obj := sql_select(table_name=table_name_select, record_id=record_id): - if not check_account_access(existing_obj, account, obj_name): - return mk_resp(data=False, status_code=403, response=response, status_message="Access denied.") - else: - return mk_resp(data=False, status_code=404, response=response, status_message=f"Object with ID '{obj_id}' not found in database.") + return mk_resp(data=False, status_code=404, response=commons.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) + resp_data = output_model(**sql_select_result).dict(by_alias=commons.by_alias, exclude_unset=commons.exclude_unset) + return mk_resp(data=resp_data, response=commons.response) else: - return mk_resp(data=True, status_code=404, response=response, status_message="Object updated but could not be retrieved.") + return mk_resp(data=True, status_code=404, response=commons.response, status_message="Object updated but could not be retrieved.") else: - return mk_resp(data=True, response=response, status_message="Object updated successfully.") + return mk_resp(data=True, response=commons.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=False, status_code=400, response=commons.response, status_message="Failed to update object in database. It may not have been found, or the data was invalid.") @router.delete('/{obj_type_l1}/{obj_id}', response_model=Resp_Body_Base) async def delete_obj( - response: Response, obj_type_l1: str = Path(min_length=2, max_length=50), obj_id: str = Path(min_length=11, max_length=22), - method: str = Query('delete', regex='^(delete|hide|disable)$'), - account: AccountContext = Depends(get_account_context), - delay: DelayParams = Depends(get_delay_params), + method: str = 'delete', # delete, disable, hide + x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'), + delay_ms: Optional[int] = Query(0), + commons: Common_Route_Params = Depends(common_route_params), ): """ - Delete a top-level object (hard or soft). + Delete a top-level object. """ - if delay.sleep_time_s > 0: - await asyncio.sleep(delay.sleep_time_s) - log.setLevel(logging.WARNING) log.debug(locals()) + sleep_time = max(x_delay_ms, delay_ms) + if sleep_time > 0: + log.info(f"Delaying response for {sleep_time} ms.") + time.sleep(sleep_time / 1000) + 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.") + return mk_resp(data=False, status_code=400, response=commons.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')) - table_name_select = obj_cfg.get('tbl_default', obj_cfg.get('tbl')) + table_name = 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.") + if not table_name: + return mk_resp(data=False, status_code=500, response=commons.response, status_message=f"Configuration for object type '{obj_name}' is incomplete (missing table).") 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.") + return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Object with ID '{obj_id}' not found.") - # Security Check - if existing_obj := sql_select(table_name=table_name_select, record_id=record_id): - if not check_account_access(existing_obj, account, obj_name): - return mk_resp(data=False, status_code=403, response=response, status_message="Access denied.") + if method == 'disable': + sql_result = sql_update(data={'enable': False}, table_name=table_name, record_id=record_id) + elif method == 'hide': + sql_result = sql_update(data={'hide': True}, table_name=table_name, record_id=record_id) + else: # Default to hard delete + sql_result = sql_delete(table_name=table_name, record_id=record_id) + + if sql_result: + return mk_resp(data=True, response=commons.response, status_message=f"Object with ID '{obj_id}' action '{method}' completed successfully.") else: - return mk_resp(data=False, status_code=404, response=response, status_message=f"Object with ID '{obj_id}' not found in database.") + return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Failed to perform '{method}' on object in database. It may not have been found.") - if method == 'hide': - 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.") - 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.") - else: - 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.") - - -# --- 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, parent_obj_type: str, parent_obj_id: str, child_obj_type: str, + hidden: str = 'not_hidden', order_by_li: Optional[str] = None, jp: Optional[Union[str, None]] = None, - 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), + x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'), + delay_ms: Optional[int] = Query(0), + commons: Common_Route_Params = Depends(common_route_params), ): """ Get a list of child objects belonging to a parent. """ - if delay.sleep_time_s > 0: - await asyncio.sleep(delay.sleep_time_s) - log.setLevel(logging.WARNING) log.debug(locals()) + sleep_time = max(x_delay_ms, delay_ms) + if sleep_time > 0: + log.info(f"Delaying response for {sleep_time} ms.") + time.sleep(sleep_time / 1000) + + # This function's logic is very similar to get_obj_li, + # but it enforces the parent-child relationship from the URL path. + # We can treat the parent path parameters as if they were for_obj_type and for_obj_id query params. + + for_obj_type = parent_obj_type + for_obj_id = parent_obj_id + 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'] + jp_obj = None - order_by_li = safe_json_loads(order_by_li) + if jp: + try: + jp_obj = json.loads(urllib.parse.unquote(jp)) + except Exception as e: + log.warning(e) + return mk_resp(data=False, status_code=400, response=commons.response, status_message='The JSON string was not formatted correctly.') + + if jp_obj.get('qry'): + qry_dict_li = jp_obj['qry'] + if jp_obj.get('ft_qry'): + fulltext_qry_dict_obj = jp_obj['ft_qry'] + if jp_obj.get('and_qry'): + and_qry_dict_obj = jp_obj['and_qry'] + if jp_obj.get('and_like'): + and_like_dict_obj = jp_obj['and_like'] + if jp_obj.get('or_like'): + or_like_dict_obj = jp_obj['or_like'] + if jp_obj.get('and_in_li'): + and_in_dict_li_obj = jp_obj['and_in_li'] + + if order_by_li: + order_by_li = json.loads(order_by_li) 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).") + if obj_name not in obj_type_kv_li: + return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Object type '{obj_name}' not found.") 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.") + return mk_resp(data=False, status_code=500, response=commons.response, status_message=f"Configuration for object type '{obj_name}' is incomplete.") - 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) + # 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 '{parent_obj_type}' with ID '{parent_obj_id}' not found.") + return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Parent object '{for_obj_type}' with ID '{for_obj_id}' not found.") - # Parent Security Check - 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 object.") - else: - return mk_resp(data=False, status_code=404, response=response, status_message="Parent object not found.") - - # Child security: Forced account isolation if child supports it - and_qry_dict_obj = apply_forced_account_filter(and_qry_dict_obj, account, base_name, obj_name) + field_name = f'{for_obj_type}_id' # Assuming convention like 'journal_id' sql_result = sql_select( table_name=table_name, - field_name=f'{parent_obj_type}_id', + field_name=field_name, field_value=resolved_parent_id, - enabled=status_filter.enabled, - hidden=status_filter.hidden, + enabled=commons.enabled, + hidden=hidden, qry_dict_li=qry_dict_li, fulltext_qry_dict=fulltext_qry_dict_obj, and_qry_dict=and_qry_dict_obj, @@ -776,228 +447,299 @@ async def get_child_obj_li( or_like_dict=or_like_dict_obj, and_in_dict_li=and_in_dict_li_obj, order_by_li=order_by_li, - limit=pagination.limit, - offset=pagination.offset, + limit=commons.limit, + offset=commons.offset, as_list=True, ) - 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 = [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) + if sql_result: + resp_data_li = [] + for record in sql_result: + resp_data = base_name(**record).dict(by_alias=commons.by_alias, exclude_unset=commons.exclude_unset) + resp_data_li.append(resp_data) + return mk_resp(data=resp_data_li, response=commons.response) else: - return mk_resp(data=[], status_code=200, response=response) + return mk_resp(data=[], status_code=200, response=commons.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), + x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'), + delay_ms: Optional[int] = Query(0), + commons: Common_Route_Params = Depends(common_route_params), ): """ Create a new child object for a given parent. """ - if delay.sleep_time_s > 0: - await asyncio.sleep(delay.sleep_time_s) - log.setLevel(logging.WARNING) log.debug(locals()) + sleep_time = max(x_delay_ms, delay_ms) + if sleep_time > 0: + log.info(f"Delaying response for {sleep_time} ms.") + time.sleep(sleep_time / 1000) + 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.") + 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) + if parent_obj_name not in obj_type_kv_li: + return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Parent object type '{parent_obj_name}' not found.") + if child_obj_name not in obj_type_kv_li: + return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Child object type '{child_obj_name}' not found.") + + resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_obj_name) if not resolved_parent_id: - return mk_resp(data=False, status_code=404, response=response, status_message="Parent not found.") + return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Parent object '{parent_obj_name}' with ID '{parent_obj_id}' not found.") - # Parent Security Check - 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 object.") - else: - return mk_resp(data=False, status_code=404, response=response, status_message="Parent object not found.") - - obj_cfg = obj_type_kv_li[child_obj_type] + 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'))) - # Security: Force account_id for non-super users if child supports it - 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 + if not table_name_insert or not input_model or not table_name_select or not output_model: + return mk_resp(data=False, status_code=500, response=commons.response, status_message=f"Configuration for child object type '{child_obj_name}' is incomplete.") - 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: - return mk_resp(data=False, status_code=400, response=response, status_message=f"Validation error: {e}") + log.warning(f"Validation error for {child_obj_name}: {e}") + return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Validation error: {e}") + # 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_type) + 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) - return mk_resp(data={"obj_id": new_obj_id, "obj_id_random": new_obj_id_random}, response=response) + resp_data = output_model(**sql_select_result).dict(by_alias=commons.by_alias, exclude_unset=commons.exclude_unset) + return mk_resp(data=resp_data, response=commons.response) + else: + return mk_resp(data={"obj_id": new_obj_id, "obj_id_random": new_obj_id_random}, status_code=404, response=commons.response, status_message="Child object created but could not be retrieved.") + else: + return mk_resp(data={"obj_id": new_obj_id, "obj_id_random": new_obj_id_random}, response=commons.response) else: - return mk_resp(data=False, status_code=400, response=response, status_message="Failed to create child object.") + return mk_resp(data=False, status_code=400, response=commons.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), + x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'), + delay_ms: Optional[int] = Query(0), + commons: Common_Route_Params = Depends(common_route_params), ): """ - Get a single child object, verifying parentage. + Get a single child object by its ID, ensuring it belongs to the correct parent. """ - if delay.sleep_time_s > 0: - await asyncio.sleep(delay.sleep_time_s) - log.setLevel(logging.WARNING) log.debug(locals()) - 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) + sleep_time = max(x_delay_ms, delay_ms) + if sleep_time > 0: + log.info(f"Delaying response for {sleep_time} ms.") + time.sleep(sleep_time / 1000) - if 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.") + parent_obj_name = parent_obj_type + child_obj_name = child_obj_type - obj_cfg = obj_type_kv_li[child_obj_type] + if parent_obj_name not in obj_type_kv_li: + return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Parent object type '{parent_obj_name}' not found.") + if child_obj_name not in obj_type_kv_li: + return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Child object type '{child_obj_name}' not found.") + + resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_obj_name) + if not resolved_parent_id: + return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Parent object '{parent_obj_name}' with ID '{parent_obj_id}' not found.") + + obj_cfg = obj_type_kv_li[child_obj_name] 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.") + if not table_name or not base_name: + return mk_resp(data=False, status_code=500, response=commons.response, status_message=f"Configuration for object type '{child_obj_name}' is incomplete.") - 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.") + resolved_child_id = redis_lookup_id_random(record_id_random=child_obj_id, table_name=child_obj_name) + if not resolved_child_id: + return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Child object 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=commons.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=commons.by_alias, exclude_unset=commons.exclude_unset) + return mk_resp(data=resp_data, response=commons.response) + else: + return mk_resp(data=False, status_code=404, response=commons.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), + x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'), + delay_ms: Optional[int] = Query(0), + commons: Common_Route_Params = Depends(common_route_params), ): """ - Update a child object, verifying parentage. + Update a child object by its ID, ensuring it belongs to the correct parent. """ - if delay.sleep_time_s > 0: - await asyncio.sleep(delay.sleep_time_s) - log.setLevel(logging.WARNING) log.debug(locals()) + sleep_time = max(x_delay_ms, delay_ms) + if sleep_time > 0: + log.info(f"Delaying response for {sleep_time} ms.") + time.sleep(sleep_time / 1000) + 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.") + parent_obj_name = parent_obj_type + child_obj_name = child_obj_type - obj_cfg = obj_type_kv_li[child_obj_type] + if parent_obj_name not in obj_type_kv_li: + return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Parent object type '{parent_obj_name}' not found.") + if child_obj_name not in obj_type_kv_li: + return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Child object type '{child_obj_name}' not found.") + + # Resolve IDs + resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_obj_name) + if not resolved_parent_id: + return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Parent object '{parent_obj_name}' with ID '{parent_obj_id}' not found.") + + resolved_child_id = redis_lookup_id_random(record_id_random=child_obj_id, table_name=child_obj_name) + if not resolved_child_id: + return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Child object '{child_obj_name}' with ID '{child_obj_id}' not found.") + + # Get config for child object + obj_cfg = obj_type_kv_li[child_obj_name] 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.") + if not table_name_update or not table_name_select or not output_model: + return mk_resp(data=False, status_code=500, response=commons.response, status_message=f"Configuration for child object type '{child_obj_name}' is incomplete.") - if sql_update(data=obj_data, table_name=table_name_update, record_id=resolved_child_id): + # Verify parentage before updating + if existing_child := sql_select(table_name=table_name_select, record_id=resolved_child_id): + parent_fk_field_name = f'{parent_obj_name}_id' + if existing_child.get(parent_fk_field_name) != resolved_parent_id: + return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Child object '{child_obj_id}' not found under parent '{parent_obj_id}'.") + else: + return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Child object '{child_obj_id}' not found.") + + # The sql_update function will only update the fields provided in the dict. + data_to_update = obj_data + + if sql_update(data=data_to_update, table_name=table_name_update, record_id=resolved_child_id): if return_obj: if updated_child := sql_select(table_name=table_name_select, record_id=resolved_child_id): - resp_data = output_model(**updated_child).dict(by_alias=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.") + resp_data = output_model(**updated_child).dict(by_alias=commons.by_alias, exclude_unset=commons.exclude_unset) + return mk_resp(data=resp_data, response=commons.response) + else: + return mk_resp(data=True, status_code=404, response=commons.response, status_message="Object updated but could not be retrieved post-update.") + else: + return mk_resp(data=True, response=commons.response, status_message="Object updated successfully.") + else: + return mk_resp(data=False, status_code=400, response=commons.response, status_message="Failed to update object in database.") @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(get_delay_params), + method: str = 'delete', # delete, disable, hide + x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms'), + delay_ms: Optional[int] = Query(0), + commons: Common_Route_Params = Depends(common_route_params), ): """ - Delete a child object, verifying parentage (hard or soft). + Delete a child object by its ID, ensuring it belongs to the correct parent. """ - if delay.sleep_time_s > 0: - await asyncio.sleep(delay.sleep_time_s) - log.setLevel(logging.WARNING) log.debug(locals()) - 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) + sleep_time = max(x_delay_ms, delay_ms) + if sleep_time > 0: + log.info(f"Delaying response for {sleep_time} ms.") + time.sleep(sleep_time / 1000) - if 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.") + parent_obj_name = parent_obj_type + child_obj_name = child_obj_type - 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 parent_obj_name not in obj_type_kv_li: + return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Parent object type '{parent_obj_name}' not found.") + if child_obj_name not in obj_type_kv_li: + return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Child object type '{child_obj_name}' not found.") + # Resolve IDs + resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_obj_name) + if not resolved_parent_id: + return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Parent object '{parent_obj_name}' with ID '{parent_obj_id}' not found.") + + resolved_child_id = redis_lookup_id_random(record_id_random=child_obj_id, table_name=child_obj_name) + if not resolved_child_id: + return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Child object '{child_obj_name}' with ID '{child_obj_id}' not found.") + + # Get config for child object + obj_cfg = obj_type_kv_li[child_obj_name] + table_name = obj_cfg.get('tbl_update', obj_cfg.get('tbl')) + table_name_select = obj_cfg.get('tbl_default', obj_cfg.get('tbl')) # For verification + + if not table_name or not table_name_select: + return mk_resp(data=False, status_code=500, response=commons.response, status_message=f"Configuration for child object type '{child_obj_name}' is incomplete.") + + # Verify parentage before deleting if existing_child := sql_select(table_name=table_name_select, record_id=resolved_child_id): - 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.") + parent_fk_field_name = f'{parent_obj_name}_id' + if existing_child.get(parent_fk_field_name) != resolved_parent_id: + return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Child object '{child_obj_id}' not found under parent '{parent_obj_id}'.") else: - return mk_resp(data=False, status_code=404, response=response, status_message="Child not found.") + return mk_resp(data=False, status_code=404, response=commons.response, status_message=f"Child object '{child_obj_id}' 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}) + # If verification passes, perform the action + if method == 'disable': + sql_result = sql_update(data={'enable': False}, table_name=table_name, record_id=resolved_child_id) + elif method == 'hide': + sql_result = sql_update(data={'hide': True}, table_name=table_name, record_id=resolved_child_id) + else: # Default to hard delete + sql_result = sql_delete(table_name=table_name, record_id=resolved_child_id) + + if sql_result: + return mk_resp(data=True, response=commons.response, status_message=f"Object with ID '{child_obj_id}' action '{method}' completed successfully.") else: - success = sql_delete(table_name=table_name_delete, record_id=resolved_child_id) + return mk_resp(data=False, status_code=400, response=commons.response, status_message=f"Failed to perform '{method}' on object in database.") + + + + + + + - 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 diff --git a/verify_imports.py b/verify_imports.py new file mode 100644 index 0000000..1ddf3d1 --- /dev/null +++ b/verify_imports.py @@ -0,0 +1,36 @@ +import sys +import os + +# Add current directory to path +sys.path.append(os.getcwd()) + +print("Attempting to import app.lib_general_v3...") +try: + import app.lib_general_v3 + print("Success: app.lib_general_v3") +except Exception as e: + print(f"Failed: app.lib_general_v3 - {e}") + import traceback + traceback.print_exc() + +print("-" * 20) + +print("Attempting to import app.routers.api_crud_v3...") +try: + import app.routers.api_crud_v3 + print("Success: app.routers.api_crud_v3") +except Exception as e: + print(f"Failed: app.routers.api_crud_v3 - {e}") + import traceback + traceback.print_exc() + +print("-" * 20) + +print("Attempting to import app.routers.agent_bridge...") +try: + import app.routers.agent_bridge + print("Success: app.routers.agent_bridge") +except Exception as e: + print(f"Failed: app.routers.agent_bridge - {e}") + import traceback + traceback.print_exc()