From 9c0aae9a6d080c12d6064f84f70a8c42f0fa4874 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Wed, 28 Jan 2026 14:36:41 -0500 Subject: [PATCH] docs: formalize Aether V4 Architecture Standards - Create ARCH__V4_CORE_STANDARDS.md to document V4 identity, lifecycle, and search patterns. - Standardize default_qry_str in Event_Badge_Base. - Clean up obsolete .snapshot files. - Update README baseline to v3.0.99. --- README.md | 2 +- app/lib_general_v3.py.snapshot | 171 ------- app/log.py.snapshot | 100 ---- app/main.py.snapshot | 625 ----------------------- app/models/event_badge_models.py | 2 +- app/models/response_models.py.snapshot | 134 ----- documentation/ARCH__V4_CORE_STANDARDS.md | 132 +++++ 7 files changed, 134 insertions(+), 1032 deletions(-) delete mode 100644 app/lib_general_v3.py.snapshot delete mode 100644 app/log.py.snapshot delete mode 100644 app/main.py.snapshot delete mode 100644 app/models/response_models.py.snapshot create mode 100644 documentation/ARCH__V4_CORE_STANDARDS.md diff --git a/README.md b/README.md index 9381abe..d7c63fe 100755 --- a/README.md +++ b/README.md @@ -99,4 +99,4 @@ The project maintains an exhaustive test suite under the `tests/` directory. --- ## 📜 Release Snapshot -Current Baseline: **`release/2026-01-28-v3_prod-snapshot`** (Stable v4.9.0). \ No newline at end of file +Current Baseline: **`release/2026-01-28-v3_prod-snapshot`** (Stable v3.0.99). \ No newline at end of file diff --git a/app/lib_general_v3.py.snapshot b/app/lib_general_v3.py.snapshot deleted file mode 100644 index 53328a0..0000000 --- a/app/lib_general_v3.py.snapshot +++ /dev/null @@ -1,171 +0,0 @@ -""" -This file contains general utility functions and helpers specifically for API v3. -It aims to provide a clean slate for new methods and refactor existing ones from lib_general.py -that are relevant to the v3 API, while removing unused or outdated functionalities. -""" - -# Standard library imports -import time -import logging -from typing import ( - Any, - Dict, - List, - Optional, - Union, -) - -# Third-party imports -from fastapi import ( - APIRouter, - Depends, - Header, - HTTPException, - Query, - Request, - Response, - status, -) -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 = get_logger(__name__) - - -# --- Pydantic Model for Account Context --- -class AccountContext(BaseModel): - account_id: Optional[int] - account_id_random: Optional[str] - - -# --- Dependency Function for Account Context --- -def get_account_context( - x_account_id: Optional[str] = Header(None, min_length=11, max_length=22), - x_no_account_id: Optional[str] = Header(None, min_length=3, max_length=100), # Assuming 'bypass' or similar string - x_no_account_id_token: Optional[str] = Query(None, min_length=11, max_length=22), -) -> AccountContext: - """ - 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. - """ - 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 --- -class PaginationParams(BaseModel): - limit: int = 100 # Default limit - offset: int = 0 - -# --- Dependency Function for Pagination --- -def get_pagination_params( - limit: int = Query(100, ge=0, description="Maximum number of items to return"), - offset: int = Query(0, ge=0, description="Number of items to skip (for pagination)"), -) -> PaginationParams: - return PaginationParams(limit=limit, offset=offset) - - -# --- Pydantic Model for Status Filtering --- -class StatusFilterParams(BaseModel): - enabled: str = 'enabled' # 'enabled', 'disabled', 'all' - hidden: str = 'not_hidden' # 'hidden', 'not_hidden', 'all' - -# --- Dependency Function for Status Filtering --- -def get_status_filter_params( - enabled: str = Query('enabled', description="Filter by object enabled status ('enabled', 'disabled', 'all')"), - hidden: str = Query('not_hidden', description="Filter by object hidden status ('hidden', 'not_hidden', 'all')"), -) -> StatusFilterParams: - allowed_enabled_values = {'enabled', 'disabled', 'all'} - allowed_hidden_values = {'hidden', 'not_hidden', 'all'} - - if enabled not in allowed_enabled_values: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid value for 'enabled'. Must be one of {list(allowed_enabled_values)}." - ) - if hidden not in allowed_hidden_values: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid value for 'hidden'. Must be one of {list(allowed_hidden_values)}." - ) - return StatusFilterParams(enabled=enabled, hidden=hidden) - - -# --- Pydantic Model for Serialization Options --- -class SerializationParams(BaseModel): - by_alias: bool = True - exclude_unset: bool = False - exclude_defaults: bool = False # Added based on common_route_params - exclude_none: bool = False # Added based on common_route_params - -# --- Dependency Function for Serialization Options --- -def get_serialization_params( - by_alias: bool = Query(True, description="Whether to use field aliases for serialization"), - exclude_unset: bool = Query(False, description="Whether to exclude unset fields from the response"), - exclude_defaults: bool = Query(False, description="Whether to exclude fields with their default values from the response"), - exclude_none: bool = Query(False, description="Whether to exclude fields that are None from the response"), -) -> SerializationParams: - return SerializationParams( - by_alias=by_alias, - exclude_unset=exclude_unset, - exclude_defaults=exclude_defaults, - exclude_none=exclude_none, - ) - - -# --- Pydantic Model for Delay --- -class DelayParams(BaseModel): - sleep_time_ms: int = 0 # Raw delay value in ms - sleep_time_s: float = 0.0 # Converted to seconds for time.sleep() - -# --- Dependency Function for Delay --- -def get_delay_params( - x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms', description="Delay response for X milliseconds (header)"), - delay_ms: Optional[int] = Query(0, description="Delay response for X milliseconds (query parameter)"), -) -> DelayParams: - calculated_delay_ms = max(x_delay_ms or 0, delay_ms or 0) - return DelayParams(sleep_time_ms=calculated_delay_ms, sleep_time_s=calculated_delay_ms / 1000.0) \ No newline at end of file diff --git a/app/log.py.snapshot b/app/log.py.snapshot deleted file mode 100644 index c72dd49..0000000 --- a/app/log.py.snapshot +++ /dev/null @@ -1,100 +0,0 @@ -import functools, logging - -from app.config import settings - -# stream options: 'ext://sys.stderr' or 'ext://sys.stdout' - -# NOTE: This log config is confusing and may need work... 2022-10-07 -# 'uvicorn' under 'loggers' creates an output to the 'console' handler -# Do not also add 'console' handler to the 'root' 'handlers' list -# For now just using that to add or remove file logging options. -# logging.config.dictConfig({ -# 'version': 1, -# 'formatters': { -# 'default': {'format': '[%(asctime)s] %(levelname)s @ %(module)s.%(funcName)s()#%(lineno)d: %(message)s'}, -# 'long': {'format': '[%(asctime)s] %(levelname)s @ %(module)s.%(funcName)s()#%(lineno)d: %(message)s', 'datefmt': '%Y-%m-%d %H:%M:%S'}, -# 'short': {'format': '[%(asctime)s] %(levelname)s @ %(module)s.%(funcName)s()#%(lineno)d: %(message)s', 'datefmt': '%H:%M:%S', 'use_colors': True}, -# }, -# #'filename': 'example.log', -# # 'level': logging.ERROR, -# 'handlers': { -# 'console': { -# 'class': 'logging.StreamHandler', -# 'stream': 'ext://sys.stderr', -# 'formatter': 'short', -# }, -# 'log_file_all': { -# 'level': 'NOTSET', -# 'class': 'logging.handlers.RotatingFileHandler', -# 'formatter': 'long', -# 'filename': settings.LOG_PATH['app'], -# 'maxBytes': 10485760, # 5,242,880 = 5 MB; 10,485,760 = 10 MB -# 'backupCount': 9 -# }, -# # 'log_file_warning': { -# # 'level': 'WARNING', -# # 'class': 'logging.handlers.RotatingFileHandler', -# # 'formatter': 'long', -# # 'filename': settings.LOG_PATH['app_warning'], -# # 'maxBytes': 512000, # 524,288 = 512KB -# # 'backupCount': 9 -# # }, -# # 'test_handler': { -# # 'class': 'logging.StreamHandler', -# # 'level': 'INFO', -# # 'formatter': 'short', -# # }, -# # 'test_handler_all_rotate': { -# # 'class': 'logging.handlers.RotatingFileHandler', -# # 'level': 'NOTSET', -# # 'formatter': 'short', -# # 'filename': '/logs/test_rotate.log', -# # 'maxBytes': 100000, # 5120000 = 5 MB -# # 'backupCount': 2, -# # } -# }, -# 'loggers': { -# # 'uvicorn': {'handlers': ['default'], 'level': 'INFO'}, -# 'uvicorn': {'handlers': ['console'], 'level': 'INFO'}, -# # 'uvicorn.error': {'level': 'INFO', 'handlers': ['default'], 'propagate': True}, -# # 'uvicorn.error': {'level': 'INFO', 'handlers': ['console'], 'propagate': True}, -# # 'uvicorn.access': {'handlers': ['access'], 'level': 'INFO', 'propagate': False}, -# # 'gunicorn': {'handlers': ['console'], 'level': 'INFO'}, -# }, -# 'root': { -# 'handlers': ['log_file_all'], #, 'log_file_all', 'log_file_warning'], -# # 'handlers': ['console', 'log_file_all'], #, 'log_file_all', 'log_file_warning'], -# 'level': 'WARNING', # WARNING -# } -# }) - - -# log = logging.getLogger('root') -# # log.setLevel(logging.INFO) # DEBUG > INFO > WARNING > ERROR > CRITICAL -# # logging.basicConfig( -# # format='[%(asctime)s] %(levelname)s @ %(module)s.%(funcName)s()#%(lineno)d: %(message)s' -# # ) - - - -# ### BEGIN ### Log ### logger_reset() ### -# https://realpython.com/primer-on-python-decorators/ -# Updated 2022-02-15 -# def logger_reset(func): -# # log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL -# # log.info(locals()) -# @functools.wraps(func) -# def wrapper(*args, **kwargs): -# if func.__name__ not in ['redis_lookup_id_random', 'sql_enable_part', 'sql_hidden_part']: -# log.info(f'*** Function: "{func.__name__}()"') -# log.debug(f'*** Function Positional Args: {args}\nFunction Key Args: {kwargs}') -# init_log_level = log.level -# returned_result = func(*args, **kwargs) -# log.debug(f'*** Function finished: "{func.__name__}()". Resetting logger level to level: {log.level} ***') -# log.setLevel(init_log_level) -# return returned_result -# return wrapper -# ### END ### Log ### logger_reset() ### - -def get_logger(name: str) -> logging.Logger: - return logging.getLogger(name) diff --git a/app/main.py.snapshot b/app/main.py.snapshot deleted file mode 100644 index 1cdc951..0000000 --- a/app/main.py.snapshot +++ /dev/null @@ -1,625 +0,0 @@ -import datetime, json, os, pytz, random, secrets # , uvicorn - -from enum import Enum -#from datetime import datetime, time, timedelta -from fastapi import Body, Cookie, Depends, FastAPI, File, Form, Header, HTTPException, Path, Query, Request, Response, status, UploadFile -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse -from fastapi.staticfiles import StaticFiles -from functools import lru_cache -from pydantic import BaseModel, EmailStr, Field -from typing import Dict, List, Optional, Set, Union - -from . import config - -from app.log import log, logging - -# Import the routers here first: -from app.routers import ae_obj, aether_cfg, api_crud, api_crud_v2, api_crud_v3, api, importing, sql, account, activity_log, address, archive, archive_content, contact, cont_edu_cert, cont_edu_cert_person, data_store, event, event_abstract, event_badge, event_badge_importing, event_badge_template, event_device, event_exhibit, event_exhibit_tracking, event_file, event_importing, event_location, event_person, event_person_detail, event_person_tracking, event_presentation, event_presenter, event_registration, event_session, flask_cfg, fundraising, grant, hosted_file, journal, journal_entry, log_client_viewing, lookup, membership_cfg, membership_group, membership_person_group, membership_person, membership_person_profile, membership_type, membership_person_type, order, order_v3, order_line, order_cart, organization, page, person, person_user, post, post_comment, product, qr, site, site_domain, user, util_email, websockets_redis, e_confex, e_cvent, c_idaa, e_impexium, e_stripe - -# from app.routers import aether_cfg, sql - -from app.db_sql import sql_select, reset_redis # , sql_connect - - -print('### **** *** ** * The Aether API v4 using FastAPI is loading... * ** *** **** ###') - - - - - -app = FastAPI( - # debug = True, - title = 'Aether API', - description = 'One Sky IT\'s Aether API v4 using FastAPI.', - version = '4.9.0', - operationsSorter = 'method', - ) - - -log.setLevel(logging.INFO) -# log.debug(config.settings) - -if aether_cfg_sql_result := sql_select( - table_name = 'cfg', - record_id = config.settings.AETHER_CFG['id'], - as_list = False, - max_count = 1, - ): - aether_cfg_sql = aether_cfg_sql_result - - config.settings.DB['server'] = aether_cfg_sql.get('db_server') - config.settings.DB['port'] = aether_cfg_sql.get('db_port') - config.settings.DB['name'] = aether_cfg_sql.get('db_name') - config.settings.DB['username'] = aether_cfg_sql.get('db_username') - config.settings.DB['password'] = aether_cfg_sql.get('db_password') - - DB = config.settings.DB - config.settings.SQLALCHEMY_DB_URI = 'mysql://'+DB['username']+':'+DB['password']+'@'+DB['server']+'/'+DB['name'] - # db_result = sql_connect(config.settings.SQLALCHEMY_DB_URI) - log.debug(config.settings.DB) - - config.settings.SMTP['server'] = aether_cfg_sql.get('smtp_server') - config.settings.SMTP['port'] = aether_cfg_sql.get('smtp_port') - config.settings.SMTP['username'] = aether_cfg_sql.get('smtp_username') - config.settings.SMTP['password'] = aether_cfg_sql.get('smtp_password') - - # config.settings.FILES_PATH['hosted_files_root'] = aether_cfg_sql.get('PATH_HOSTED_FILES_ROOT') - # config.settings.FILES_PATH['hosted_tmp_root'] = aether_cfg_sql.get('PATH_HOSTED_TMP_ROOT') - - config.settings.FILES_PATH['hosted_files_root'] = aether_cfg_sql.get('path_hosted_files_root') - config.settings.FILES_PATH['hosted_tmp_root'] = aether_cfg_sql.get('path_hosted_tmp_root') -else: - # aether_cfg_sql_result - pass -log.debug(aether_cfg_sql_result) -log.debug(config.settings) - -# @lru_cache() -# def get_settings(): -# return config.Settings() - - -app.mount('/static', StaticFiles(directory='static'), name='static') - - -# Set up each route once the router has been imported -app.include_router( - ae_obj.router, - prefix='/ae_obj', - tags=['AE Object'], -) -app.include_router( - aether_cfg.router, - tags=['Aether Config'], -) -app.include_router( - api_crud.router, - prefix='/crud', - tags=['CRUD v1.2 (Legacy)'], - #dependencies=[Depends(get_token_header)], - #dependencies=[Depends(get_account_header)], - #responses={404: {'description': 'Not found'}}, -) -app.include_router( - api_crud_v2.router, - prefix='/v2/crud', - tags=['CRUD v2.5'], - #dependencies=[Depends(get_token_header)], - #dependencies=[Depends(get_account_header)], - #responses={404: {'description': 'Not found'}}, -) -app.include_router( - api_crud_v3.router, - prefix='/v3/crud', - tags=['CRUD v3'], -) -app.include_router( - api.router, - prefix='/api', - tags=['API'], -) -app.include_router( - flask_cfg.router, - prefix='/flask_cfg', - tags=['Flask CFG'], -) -app.include_router( - importing.router, - prefix='/importing', - tags=['Importing'], -) -app.include_router( - sql.router, - # prefix='/sql', - tags=['SQL'], -) -# # app.include_router( -# # flask_cfg.router, -# # prefix='/redis', -# # tags=['Redis'], -# # ) - -app.include_router( - account.router, - # prefix='/account', - tags=['Account'], -) -app.include_router( - activity_log.router, - prefix='/activity_log', - tags=['Activity Log'], -) -app.include_router( - address.router, - prefix='/address', - tags=['Address'], -) -app.include_router( - archive.router, - # prefix='/archive', - tags=['Archive'], -) -app.include_router( - archive_content.router, - prefix='/archive/content', - tags=['Archive Content'], -) -app.include_router( - contact.router, - prefix='/contact', - tags=['Contact'], -) -app.include_router( - cont_edu_cert.router, - tags=['Cont Edu Cert'], -) -app.include_router( - cont_edu_cert_person.router, - tags=['Cont Edu Cert Person'], -) -app.include_router( - data_store.router, - # prefix='/data_store', - tags=['Data Store'], -) -app.include_router( - event.router, - # prefix='/event', - tags=['Event'], -) -app.include_router( - event_abstract.router, - tags=['Event Abstract'], -) -app.include_router( - event_badge.router, - tags=['Event Badge'], -) -app.include_router( - event_badge_importing.router, - tags=['Event Badge Importing'], -) -app.include_router( - event_badge_template.router, - # prefix='/event/badge/template', - tags=['Event Badge Template'], -) -app.include_router( - event_device.router, - # prefix='/event/device', - tags=['Event Device'], -) -app.include_router( - event_exhibit.router, - # prefix='/event/exhibit', - tags=['Event Exhibit'], -) -app.include_router( - event_exhibit_tracking.router, - # prefix='/event/exhibit/tracking', - tags=['Event Exhibit Tracking'], -) -app.include_router( - event_file.router, - # prefix='/event/file', - tags=['Event File'], -) -app.include_router( - event_importing.router, - # prefix='/event/importing', - tags=['Event Importing'], -) -app.include_router( - event_location.router, - # prefix='/event/location', - tags=['Event Location'], -) -app.include_router( - event_person.router, - # prefix='/event/person', - tags=['Event Person'], -) -app.include_router( - event_person.router, - prefix='/event/person/detail', - tags=['Event Person Detail'], -) -app.include_router( - event_person_tracking.router, - tags=['Event Person Tracking'], -) -app.include_router( - event_presentation.router, - # prefix='/event/presentation', - tags=['Event Presentation'], -) -app.include_router( - event_presenter.router, - prefix='/event/presenter', - tags=['Event Presenter'], -) -app.include_router( - event_registration.router, - prefix='/event/registration', - tags=['Event Registration'], -) -app.include_router( - event_session.router, - # prefix='/event/session', - tags=['Event Session'], -) -app.include_router( - fundraising.router, - tags=['Fundraising'], -) -app.include_router( - grant.router, - tags=['Grant'], -) -app.include_router( - hosted_file.router, - prefix='/hosted_file', - tags=['Hosted File'], -) -app.include_router( - journal.router, - prefix='/journal', - tags=['Journal'], -) -app.include_router( - journal_entry.router, - # prefix='/journal/entry', - tags=['Journal Entry'], -) -app.include_router( - log_client_viewing.router, - # prefix='/log/client_viewing', - tags=['Log Client Viewing'], -) -app.include_router( - lookup.router, - prefix='/lu', - tags=['Lookup'], -) -app.include_router( - membership_cfg.router, - tags=['Membership Config'], -) -app.include_router( - membership_group.router, - tags=['Membership Group'], -) -app.include_router( - membership_person_group.router, - tags=['Membership Group Person'], -) -app.include_router( - membership_person_profile.router, - tags=['Membership Person Profile'], -) -app.include_router( - membership_person.router, - tags=['Membership Person'], -) -app.include_router( - membership_type.router, - tags=['Membership Type'], -) -app.include_router( - membership_person_type.router, - tags=['Membership Type Person'], -) -app.include_router( - order.router, - # prefix='/order', - tags=['Order'], -) -app.include_router( - order_v3.router, - # prefix='/order', - tags=['Order v3'], -) -app.include_router( - order_line.router, - # prefix='/order', - tags=['Order Line'], -) -app.include_router( - order_cart.router, - prefix='/order/cart', - tags=['Order Cart'], -) -app.include_router( - organization.router, - prefix='/organization', - tags=['Organization'], -) -app.include_router( - page.router, - prefix='/page', - tags=['Page'], -) -app.include_router( - person.router, - tags=['Person'], -) -app.include_router( - person_user.router, - prefix='/person_user', - tags=['Person User'], -) -app.include_router( - post.router, - # prefix='/post', - tags=['Post'], -) -app.include_router( - post_comment.router, - prefix='/post/comment', - tags=['Post Comment'], -) -app.include_router( - product.router, - # prefix='/product', - tags=['Product'], -) -app.include_router( - qr.router, - tags=['QR'], -) -app.include_router( - site.router, - # prefix='/site', - tags=['Site'], -) -app.include_router( - site_domain.router, - # prefix='/site/domain', - tags=['Site Domain'], -) -app.include_router( - user.router, - tags=['User'], -) -app.include_router( - util_email.router, - tags=['Utility: Email'], -) -# app.include_router( -# websockets.router, -# # prefix='/websocket', -# tags=['Websockets'], -# # dependencies=[Depends(get_token_header)], -# # responses={404: {'description': 'Not found'}}, -# ) -app.include_router( - websockets_redis.router, - tags=['Websockets (Redis)'], -) -app.include_router( - e_confex.router, - prefix='/e/confex', - tags=['External Service: Confex'], -) -app.include_router( - e_cvent.router, - prefix='/e/cvent', - tags=['External Service: Cvent'], -) -app.include_router( - e_impexium.router, - prefix='/e/impexium', - tags=['External Service: Impexium'], -) -app.include_router( - e_stripe.router, - prefix='/e/stripe', - tags=['External Service: Stripe'], -) - -app.include_router( - c_idaa.router, - prefix='/c/idaa', - tags=['Client: IDAA'], -) - - -# BEGIN: CORS -# NOTE: Eventually this should query the DB for the specific list based on the cfg table and or site_domain table. That way it is dynamic and only allowing those defined in the DB. No wildcards or regex. -# NOTE: Need to include .localhost for less browser restrictions! Mainly for audio and video. -app.add_middleware( - CORSMiddleware, - # allow_origins = origins, - allow_origins = config.settings.ORIGINS, - allow_origin_regex = config.settings.ORIGINS_REGEX, - # allow_origin_regex = 'https://.*\.oneskyit\.com', - allow_credentials = True, - allow_methods = ['*'], - allow_headers = ['*'], - #expose_headers = [], - #max_age = 600, -) -# END: CORS - - -@app.on_event('startup') -async def startup(): - log.setLevel(logging.INFO) # DEBUG, INFO, WARN, WARNING, ERROR, EXCEPTION, CRITICAL - log.debug(locals()) - - log.info('The Aether FastAPI API is starting up...') - #await database.connect() - - -@app.on_event('shutdown') -async def shutdown(): - log.setLevel(logging.INFO) # DEBUG, INFO, WARN, WARNING, ERROR, EXCEPTION, CRITICAL - log.debug(locals()) - - log.info('The Aether FastAPI API is shutting down...') - #await database.disconnect() - - -#Add the processing time to the response header. -@app.middleware('http') -async def add_process_time_header(request: Request, call_next): - import time - start_time = time.time() - response = await call_next(request) - process_time = time.time() - start_time - response.headers['X-Process-Time'] = str(process_time) - return response - - -# ### BEGIN ### API Main ### fastapi_root() ### -@app.get('/', tags=['Root'], response_class=PlainTextResponse) -async def fastapi_root(response: Response = Response): - log.setLevel(logging.DEBUG) # DEBUG, INFO, WARN, WARNING, ERROR, EXCEPTION, CRITICAL - log.debug(locals()) - - # log.info(config.settings.APP_NAME) - log.info('One Sky IT\'s Aether API root (FastAPI)') - - log.info('***') - log.debug('This is debug') # 10 DEBUG - log.info('This is info') # 20 INFO - log.warning('This is a warning') # 30 WARNING (and WARN) - log.error('This is an error') # 40 ERROR - log.exception('This is an exception') # 40 ERROR - log.critical('This is critical') # 50 CRITICAL - log.info('^^^') - - log.warning('Resetting Redis...') - reset_redis() - log.info('Reset Redis') - - response_data = {} - response_data['message'] = 'This is One Sky IT\'s Aether API root (FastAPI).' - - - current_datetime = datetime.datetime.now() - current_datetime_string = current_datetime.isoformat() - - timezone = pytz.timezone("America/New_York") - current_datetime_tz = timezone.localize(current_datetime) - current_datetime_tz_string = current_datetime_tz.isoformat() - - current_datetime_utc = datetime.datetime.utcnow() - current_datetime_utc_string = current_datetime_utc.isoformat() - - current_datetime_utc_localize = pytz.utc.localize(current_datetime_utc) - current_datetime_utc_localize_string = current_datetime_utc_localize.isoformat() - - current_datetime_utc_localize_pst = current_datetime_utc_localize.astimezone(pytz.timezone("America/Los_Angeles")) - current_datetime_utc_localize_pst_string = current_datetime_utc_localize_pst.isoformat() - - response_data['datetime'] = current_datetime_string - response_data['datetime_tz'] = current_datetime_tz_string - response_data['datetime_utc'] = current_datetime_utc_string - response_data['datetime_utc_localize'] = current_datetime_utc_localize_string - response_data['datetime_utc_localize_pst'] = current_datetime_utc_localize_pst_string - - response_data['url_safe_string_4_bytes_1'] = secrets.token_urlsafe(4) - - response_data['url_safe_string_8_bytes_1'] = secrets.token_urlsafe(8) - response_data['url_safe_string_8_bytes_2'] = secrets.token_urlsafe(8) - response_data['url_safe_string_8_bytes_3'] = secrets.token_urlsafe(8) - response_data['url_safe_string_8_bytes_4'] = secrets.token_urlsafe(8) - response_data['url_safe_string_8_bytes_5'] = secrets.token_urlsafe(8) - - response_data['url_safe_string_16_bytes_1'] = secrets.token_urlsafe(16) - response_data['url_safe_string_16_bytes_2'] = secrets.token_urlsafe(16) - response_data['url_safe_string_16_bytes_3'] = secrets.token_urlsafe(16) - response_data['url_safe_string_16_bytes_4'] = secrets.token_urlsafe(16) - response_data['url_safe_string_16_bytes_5'] = secrets.token_urlsafe(16) - - response_data['hex_string_4_bytes_1'] = secrets.token_hex(4) - response_data['hex_string_8_bytes_1'] = secrets.token_hex(8) - response_data['hex_string_16_bytes_1'] = secrets.token_hex(16) - response_data['hex_string_32_bytes_1'] = secrets.token_hex(32) - - log.debug(json.dumps(response_data, indent=4)) - return json.dumps(response_data, indent=4) # , sort_keys=True -# ### END ### API Main ### fastapi_root() ### - - -# ### BEGIN ### API Main ### generate_id_random() ### -# NOTE: This is just a quick utility function to generate a bunch of random IDs. -# Updated 2022-03-30 -@app.get('/generate_id_random', tags=['Root'], response_class=PlainTextResponse) -async def generate_id_random(response: Response = Response): - log.setLevel(logging.INFO) # DEBUG, INFO, WARN, WARNING, ERROR, EXCEPTION, CRITICAL - log.debug(locals()) - - response_data = {} - - html_list = '' - - return HTMLResponse(content=html_list, status_code=200) -# ### END ### API Main ### generate_id_random() ### - - -# ### BEGIN ### API Main ### sql_test() ### -# ### TEST TEST TEST ### # -@app.get('/sql_test', tags=['Testing'], response_class=PlainTextResponse) -async def sql_test(response: Response = Response): - log.setLevel(logging.DEBUG) # DEBUG, INFO, WARN, WARNING, ERROR, EXCEPTION, CRITICAL - log.debug(locals()) - - return mk_resp(data=False, status_code=501, response=response) - - log.info('Getting all accounts from DB...') - - sql = text( - """ - SELECT id, id_random, name, enable - FROM `account` - """ - ) - try: - result = db.execute(sql) - except Exception as e: - log.error('*** An exception happened. ***') - log.error(repr(e)) - log.error('***') - log.error(str(e)) - log.error('^^^ exception ^^^') - else: - if result.rowcount: - record_li = [dict(record) for record in result.fetchall()] - log.debug(record_li) - else: - log.error('No records found. Something went wrong.') - - log.info('Got the account list') - - response_data = {} - response_data['message'] = 'This is the Aether API using FastAPI.' - response_data['data'] = record_li - - return json.dumps(response_data, indent=4) # , sort_keys=True -# ### END ### API Main ### sql_test() ### diff --git a/app/models/event_badge_models.py b/app/models/event_badge_models.py index 61f6ada..84590a8 100644 --- a/app/models/event_badge_models.py +++ b/app/models/event_badge_models.py @@ -145,7 +145,7 @@ class Event_Badge_Base(BaseModel): cfg_json: Optional[Union[Json, None]] # Store per badge config options like font size; Not currently used 2024-06-11 data_json: Optional[Union[Json, None]] # For key value data. Careful with overwriting existing fields! Not currently used 2024-06-11 - default_qry_string: Optional[str] # Default query string used for searching and filtering badges. Updated using SQL triggers and a SQL function + default_qry_str: Optional[str] # Default query string used for searching and filtering badges. Updated using SQL triggers and a SQL function hide: Optional[bool] priority: Optional[bool] diff --git a/app/models/response_models.py.snapshot b/app/models/response_models.py.snapshot deleted file mode 100644 index 8fea63c..0000000 --- a/app/models/response_models.py.snapshot +++ /dev/null @@ -1,134 +0,0 @@ -from app.lib_general import log, logging, Response, status - -# ### BEGIN ### API Response Model ### Resp_Body_Base() ### -# The pydantic BaseModel to help make consistent REST responses. -# Updated 2021-03-05 -class Resp_Body_Base(BaseModel): - log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL - log.debug(locals()) - - # test_prop: Optional[str] = Field( - # alias = 'test_prop_alias' - # ) - - data: Union[None, list, dict] - meta: Optional[dict] -# ### END ### API Response Model ### Resp_Body_Base() ### - - -# ### BEGIN ### API Response Model ### mk_resp() ### -# The method for making responses for REST. Returns a dict, but currently uses Resp_Body_Base inside the function. -# Update 2021-08-23 -def mk_resp( - data: None|bool|dict|list, - tmp_file_path: None|str = None, - dict_to_json: bool = False, - status_code: int = 200, - status_message: str = '', - status_name: str = '', - success: bool = True, - details: str = '', - include: dict = None, - exclude: dict = None, - by_alias: bool = True, - exclude_unset: bool = False, - response: Response = None - ) -> dict: - log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL - log.debug(locals()) - - if data is None: data_out = { 'result': data } - elif data == False: data_out = { 'result': data } - elif data == True: data_out = { 'result': data } - elif isinstance(data, dict): - log.info('Data type is a dict') - data_out = data - elif isinstance(data, list): - log.info('Data type is a list') - data_out = data - elif isinstance(data, int): - log.info('Data type is an int') - data_out = { 'result': data } - elif isinstance(data, str): - log.info('Data type is a str') - data_out = { 'result': data } - else: # Assuming it is still and object. This should be improved. Example model type: "" - log.info('Data type is other') - data_out = data.dict(include=include, exclude=exclude, by_alias=by_alias, exclude_unset=exclude_unset) - # log.debug(data_out) - - resp_body = {} - resp_body['data'] = data_out - resp_body['meta'] = {} - resp_body['meta']['details'] = details - resp_body['meta']['status_code'] = status_code - if status_message: - resp_body['meta']['status_message'] = status_message - else: - resp_body['meta']['status_message'] = http_status_li[status_code]['message'] - resp_body['meta']['status_name'] = http_status_li[status_code]['name'] - resp_body['meta']['success'] = success - resp_body['meta']['tmp_file_path'] = tmp_file_path - - if isinstance(data, bool): - resp_body['meta']['data_type'] = 'bool' - elif isinstance(data, int): - resp_body['meta']['data_type'] = 'int' - elif isinstance(data, str): - resp_body['meta']['data_type'] = 'str' - elif isinstance(data, dict): - resp_body['meta']['data_type'] = 'dict' - elif isinstance(data, list): - resp_body['meta']['data_type'] = 'list' - resp_body['meta']['data_list_count'] = len(data) - - if response: - log.debug(response) - if status_code == 400: - log.warning('Likely bad request') - response.status_code = status.HTTP_400_BAD_REQUEST - elif status_code == 401: response.status_code = status.HTTP_401_UNAUTHORIZED - # elif status_code == 402: response.status_code = status.HTTP_402_X - elif status_code == 403: response.status_code = status.HTTP_403_FORBIDDEN - elif status_code == 404: - log.info('No results') - response.status_code = status.HTTP_404_NOT_FOUND - elif status_code == 408: response.status_code = status.HTTP_408_REQUEST_TIMEOUT - elif status_code == 429: response.status_code = status.HTTP_429_TOO_MANY_REQUESTS - elif status_code == 500: response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - elif status_code == 501: response.status_code = status.HTTP_501_NOT_IMPLEMENTED - elif status_code == 502: response.status_code = status.HTTP_502_BAD_GATEWAY - elif status_code == 503: response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE - elif status_code == 504: response.status_code = status.HTTP_504_GATEWAY_TIMEOUT - - # log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL - # log.debug(resp_body) - # log.debug(type(resp_body['data'])) - - # import json - # with open('data.txt', 'w') as outfile: - # json.dump(resp_body['data'], outfile) - - resp_body_obj = Resp_Body_Base(**resp_body) - log.debug(resp_body_obj) - resp_body_dict = resp_body_obj.dict(by_alias=by_alias, exclude_unset=exclude_unset) - log.debug(resp_body_dict) - - return resp_body_dict -# ### END ### API Response Model ### mk_resp() ### - - -http_status_li = {} -http_status_li[200] = { 'name': 'OK', 'message': 'The request has succeeded.' } -http_status_li[400] = { 'name': 'Bad Request', 'message': 'The request could not be understood by the server due to malformed syntax. The client SHOULD NOT repeat the request without modifications.' } -http_status_li[401] = { 'name': 'Unauthorized', 'message': 'The server could not verify that you are authorized to access the URL requested. You either supplied the wrong credentials (e.g. a bad password), or your browser does not understand how to supply the credentials required.' } -http_status_li[402] = { 'name': '?Request Failed?', 'message': '??The parameters were valid but the request failed.??' } -http_status_li[403] = { 'name': 'Forbidden', 'message': 'The server understood the request, but is refusing to fulfill it. Authorization will not help and the request SHOULD NOT be repeated. If the request method was not HEAD and the server wishes to make public why the request has not been fulfilled, it SHOULD describe the reason for the refusal in the entity. If the server does not wish to make this information available to the client, the status code 404 (Not Found) can be used instead.' } -http_status_li[404] = { 'name': 'Not Found', 'message': 'The requested resource does not exist.' } -http_status_li[409] = { 'name': 'Conflict', 'message': 'The request conflicts with another request (perhaps due to using the same idempotent key).' } -http_status_li[429] = { 'name': 'Too Many Requests', 'message': 'Too many requests hit the API too quickly. We recommend an exponential backoff of your requests.' } -http_status_li[500] = { 'name': 'Internal Server Error', 'message': 'The server encountered an unexpected condition which prevented it from fulfilling the request.' } -http_status_li[501] = { 'name': 'Not Implemented', 'message': 'The server does not support the functionality required to fulfill the request. This is the appropriate response when the server does not recognize the request method and is not capable of supporting it for any resource.' } -http_status_li[502] = { 'name': 'Bad Gateway', 'message': 'The server, while acting as a gateway or proxy, received an invalid response from the upstream server it accessed in attempting to fulfill the request.' } -http_status_li[503] = { 'name': 'Service Unavailable', 'message': 'The server is currently unable to handle the request due to a temporary overloading or maintenance of the server. The implication is that this is a temporary condition which will be alleviated after some delay. If known, the length of the delay MAY be indicated in a Retry-After header. If no Retry-After is given, the client SHOULD handle the response as it would for a 500 response.' } -http_status_li[504] = { 'name': 'Gateway Timeout', 'message': 'The server, while acting as a gateway or proxy, did not receive a timely response from the upstream server specified by the URI (e.g. HTTP, FTP, LDAP) or some other auxiliary server (e.g. DNS) it needed to access in attempting to complete the request.' } \ No newline at end of file diff --git a/documentation/ARCH__V4_CORE_STANDARDS.md b/documentation/ARCH__V4_CORE_STANDARDS.md new file mode 100644 index 0000000..0f2e65f --- /dev/null +++ b/documentation/ARCH__V4_CORE_STANDARDS.md @@ -0,0 +1,132 @@ +# Aether V4 Architecture Standards + +**Status:** Draft / Strategic Intent (January 2026) +**Scope:** Universal standards for Aether (AE) Core Objects and Database Logic. + +--- + +## 1. Core Identity & Relationships +All Aether V4 objects must implement the following identity fields to support "Vision" string IDs and polymorphic relationships. + +| Field Name | Type | DB Column | Description | +| :--- | :--- | :--- | :--- | +| `id` | `int` | `obj_id` | Internal Primary Key (AI). | +| `id_random` | `str` | `id_random` | Public "Vision" ID (URL-safe string). | +| `code` | `str` | `obj_code` | Human-friendly unique identifier. | +| `account_id` | `int/str` | `obj_account_id` | Multi-tenancy context. | +| `parent_type` | `str` | `obj_parent_type` | Polymorphic parent type (e.g., 'event'). | +| `parent_id` | `int` | `obj_parent_id` | Polymorphic parent ID. | +| `ext_uid` | `str` | `obj_ext_uid` | External system unique ID (GUID/UUID). | +| `ext_id` | `str` | `obj_ext_id` | External system human ID. | + +--- + +## 2. Standardized Lifecycle Fields +V4 expands beyond simple creation/update timestamps to support complex moderation and archival workflows. + +| Field Name | Type | Description | +| :--- | :--- | :--- | +| `status` | `int` | Numerical status code (0=Draft, 1=Active, etc.). | +| `approve` | `bool` | Approval state. | +| `approved_on` | `datetime` | Timestamp of approval. | +| `enable` | `bool` | Global availability toggle. | +| `enable_on` | `datetime` | Scheduled activation time. | +| `archive` | `bool` | Archival state. | +| `archive_on` | `datetime` | Timestamp of archival. | +| `created_on` | `datetime` | Automatic creation timestamp (DB). | +| `updated_on` | `datetime` | Automatic update timestamp (DB). | + +--- + +## 3. Search Optimization Pattern (`default_qry_str`) +To provide high-performance search without complex application-side logic, searchable objects implement a "Search Indexing" pattern. + +### SQL Implementation +1. **Column:** `default_qry_str` (TEXT/VARCHAR, NULL) with a `FULLTEXT` index. +2. **Function:** A deterministic SQL function `{table}_default_qry_str(...)` that returns a `CONCAT_WS` of key fields (ID, Name, Email, Code, etc.). +3. **Trigger:** `BEFORE INSERT` and `BEFORE UPDATE` triggers to call the function and populate the column. + +**Example Function:** +```sql +CREATE OR REPLACE FUNCTION `event_badge_default_qry_str`( + id_random VARCHAR(22), + full_name VARCHAR(200), + email VARCHAR(255) +) RETURNS TEXT CHARSET utf8mb4 DETERMINISTIC +BEGIN + RETURN REPLACE(TRIM(CONCAT_WS(' ', id_random, COALESCE(full_name,''), COALESCE(email,''))), ' ', ' '); +END; +``` + +--- + +## 4. Database Normalization Standards +Aether V4 favors "Database-First" normalization via triggers. This ensures data integrity even when accessed by multiple services. + +### Automatic Logic Patterns: +- **Identity Generation:** Trigger checks if `id_random` is missing and calls `gen_rand_pattern('4C-2N-2N-2N')`. +- **Name Concatenation:** Trigger calls `name_for_full_name(...)` to merge informal/given/family names into a single `full_name` field. +- **Location Stringify:** Trigger calls `make_location_str(...)` to build human-readable locations from city/state/country. + +--- + +## 5. Metadata & JSON Structures +To support real-time schema evolution without migrations, AE objects include four distinct JSON buckets: + +| Field | Purpose | +| :--- | :--- | +| `data_json` | Object-specific business data. | +| `meta_json` | System-level metadata and processing logs. | +| `cfg_json` | UI/Frontend configuration (e.g., display themes). | +--- + +## 6. SQL Orchestration Pattern (The "Triple-Threat") +To maintain high performance and data integrity while keeping the API layer thin, Aether V4 uses a standardized orchestration pattern across all core objects (Badges, Journals, Events, etc.). + +### 6.1 Deterministic Functions +Used to centralize logic that would otherwise be duplicated in the API and UI. +- **Purpose:** Concatenation (Names, Addresses), Search Indexing (`default_qry_str`), and ID Generation. +- **Standard:** Must be `DETERMINISTIC` to allow use in generated columns and triggers. + +### 6.2 Automation Triggers +Enforce data consistency at the point of entry/modification. +- **`BEFORE INSERT`:** Auto-generates IDs, normalizes raw strings into standard fields (e.g., atomic names to `full_name`), and populates search strings. +- **`BEFORE UPDATE`:** Recalculates all derived fields to ensure they never drift from their source components. + +### 6.3 Standardized Views (`v_`) +The API strictly consumes Views rather than raw Tables for `GET` and `SEARCH` operations. +- **Purpose:** Join related data (e.g., joining a Journal with its Owner's name), handle null-coalescing, and provide stable aliases for the API. +- **Standard:** Every table `X` should have a corresponding view `v_X` which serves as the default `tbl_default` in `ae_obj_types_def.py`. + +### 6.4 Journal-Specific Extensions (Reference Implementation) +Journals and Journal Entries represent a high-complexity implementation of the Triple-Threat pattern, incorporating aggregated counts and cross-table aliasing. + +#### View Aggregation (`v_journal`) +The Journal view joins account, person, and user context while calculating entry counts in a subquery to avoid N+1 issues in the API. +- **Stable Aliasing:** `journal.id` is aliased to `journal_id` and `journal.id_random` to `journal_id_random` within the view to ensure API stability across refactors. +- **Context Enrichment:** Joins `person.full_name` and `user.username` so the frontend can display owner info without secondary lookups. + +#### Complex Search (`journal_entry_default_qry_str`) +The Journal Entry search function demonstrates indexing deep into JSON and history fields: +- **Concatenation:** Includes `id_random`, `name`, `summary`, `content`, `history`, `category_code`, `tags`, and even `data_json`. +- **FULLTEXT:** A dedicated `FULLTEXT INDEX` is applied to the resulting `default_qry_str`. + +#### Pattern-Based Identity +Journals use specific random patterns via triggers to distinguish between object types: +- **Journal:** `gen_rand_pattern('4C-2N-2N-2N')` +- **Journal Entry:** `gen_rand_pattern('3CN-2CN-2CN-2CN')` + +--- + +### 6.5 Cleanup & Cascading Logic +To maintain referential integrity without relying solely on DB-level constraints (which can be opaque), Aether V4 uses explicit `AFTER DELETE` triggers. + +#### Cascading Deletes +When a parent object (like a Journal) is deleted, a trigger ensures all children (Journal Entries) are scrubbed. +```sql +CREATE TRIGGER journal_after_delete AFTER DELETE ON `journal` +FOR EACH ROW +BEGIN + DELETE FROM journal_entry WHERE journal_entry.journal_id = OLD.id; +END; +```