14 Commits

Author SHA1 Message Date
Scott Idem
687472f4e3 feat(user): V3 action endpoints + auth bug fixes (19/19 + 22/22 tests)
New router: /v3/action/user/ (api_v3_actions_user.py)
  - POST /authenticate  — credentials in body (not query params; security fix)
  - POST /verify_password
  - POST /{user_id}/change_password  — optional current-password verification
  - GET  /{user_id}/new_auth_key
  - GET  /{user_id}/email_auth_key_url
  Registered in registry.py under /v3/action/user with V3 AccountContext auth.

Bug fixes (from audit in previous session):
  - user.py: fix broken @router.get decorator (authenticate was unreachable)
  - user.py + user_methods.py: fix AttributeError id_random → id (Vision ID)
  - user_models.py: add fields_to_exclude_from_db to User_New_Base; narrow
    collision prevention to self-reference IDs only
  - user_models.py: pre-inject hashed password in root_validator(pre=True) so
    exclude_unset=True in CRUD POST handler includes it (was writing NULL)
  - api_crud_v3.py: move sanitize_payload + account_id injection to after
    model validation (fixes FK integer collision with Vision ID constraints)

Docs: GUIDE__AE_API_V3_for_Frontend.md — new Section 7 with full migration
  table (legacy → V3), request/response docs for all 5 action endpoints,
  and V3 CRUD search equivalents for the 3 lookup routes.

Tests: tests/e2e/test_e2e_v3_user_action_routes.py — 19 tests, 19/19 pass.
  Legacy tests/e2e/test_e2e_v3_user_auth_routes.py — 22/22 still pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 21:54:09 -04:00
Scott Idem
91434968f7 docs+site_domain: Add guidance for restoring access_key validation in site_domain lookup; stage recent user/auth changes and frontend guide updates 2026-03-25 19:33:53 -04:00
Scott Idem
6bde236633 fix(crud): extend Vision ID safety net to all response paths
- Extracted apply_vision_id_fix() helper to lib_api_crud_v3.py — single
  source of truth for the fix that ensures {obj_type}_id in responses is
  always the random string, never the DB integer.
- Applied to all response-returning paths in api_crud_v3.py:
  GET single, GET list, POST search, POST create, PATCH update.
- Applied to all response-returning paths in api_crud_v3_nested.py:
  GET child list, POST search, POST create, GET single child, PATCH child.
- Removed duplicate get_child_obj and patch_child_obj route handlers in
  api_crud_v3_nested.py — FastAPI silently routes to only the first
  matching handler, so the second definitions were unreachable dead code.

Covers all 23 V3 CRUD models still using the old integer-alias pattern.
The archive_content model was already migrated to Vision IDs; this fix
ensures every other model gets correct responses without individual migration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:35:21 -04:00
Scott Idem
cffde249d3 fix(models): migrate Archive_Content_Base to Vision ID pattern
- Replace integer `id` (alias archive_content_id) with Vision string fields:
  `id: Optional[str]` and `archive_content_id: Optional[str]` — both always
  hold the random string ID, never the DB integer.
- Add `root_validator(pre=True)` (map_v3_ids) that maps id_random /
  archive_content_id_random → id and archive_content_id, with collision
  prevention to reject any integer that arrives in these fields.
- Remove old `archive_content_id_lookup` integer validator (superseded by
  sanitize_payload + root_validator).
- Keep `id_random` (alias archive_content_id_random) in responses for
  backward compatibility; add id, archive_content_id, id_random to
  fields_to_exclude_from_db so they never appear in INSERT/UPDATE payloads.

Generic CRUD layer safety net (post_obj + post_child_obj):
- After building resp_data on create, swap any integer {obj_type}_id with
  the corresponding {obj_type}_id_random value — catches models not yet
  migrated to Vision IDs.
- Fix return_obj=False fallback to return obj_id as the random string.

Docs: add Section 3D to GUIDE__AE_API_V3_for_Frontend.md documenting the
Vision ID convention — {obj_type}_id is always the random string; the
_id_random suffix is a legacy artifact that frontend code should phase out.

Fixes: POST /v3/crud/archive/{id}/archive_content/ returning integer ID,
breaking the subsequent PATCH flow (422 min_length validation failure).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 17:40:27 -04:00
Scott Idem
9d5f2c8cea Version update 2026-03-25 13:26:11 -04:00
Scott Idem
b9742cfcd8 feat(routers): migrate hosted_file hash lookup to V3 actions
Ported the legacy '/hosted_file/hash/{hash}' endpoint to the V3 actions router.
The new endpoint '/v3/action/hosted_file/hash/{hosted_file_hash}' supports:
- ID Vision: returns random string IDs instead of internal integers
- Local Check: verifies physical file existence on disk (check_for_local=True)
- Deduplication: enables frontend to check for existing files before upload

Also added PROJECT document for AE Hosted Files migration tracking.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:05:09 -04:00
Scott Idem
b2adfe409b fix(deps): pin gunicorn to 23.0.0
Newer gunicorn patch releases added _get_control_socket_path() which
crashes with TypeError when control_socket is None. Pin to the working
version until the gunicorn config fix propagates everywhere.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 20:23:41 -04:00
Scott Idem
b55b7ea81d refactor(routers): add DeprecationParams to legacy active endpoints
Tags remaining live-but-deprecated routes so every call logs a warning,
giving visibility before the next round of removals.

- registry.py: add DeprecationParams to importing and user routers
- api.py: add DeprecationParams to /request_jwt and /temp_token individually
- user.py: inherits deprecation warning via registry router-level dependency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 19:33:31 -04:00
Scott Idem
8eb699efe5 refactor(routers): comment out legacy endpoints across multiple routers
Disabled legacy routes that are superseded by V3 equivalents. Code is
commented out (not deleted) pending final verification and cleanup pass.

- registry.py: remove sql, lookup (/lu), websockets, websockets_redis;
  clean up dead imports (contact, event_person, etc.)
- data_store.py: comment out legacy CRUD and code-lookup endpoints;
  keep V3 code-lookup routes active; add TODO for action path rename
- api.py: comment out Api_Base CRUD, get_id (internal ID leak),
  and sql_test (debug) endpoints
- aether_cfg.py: comment out legacy Flask cfg endpoint
- user.py: comment out legacy user endpoints
- util_email.py: minor cleanup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 19:22:45 -04:00
Scott Idem
c7f1341b1e docs(lookup): expand Section 4 with override model, group key invariants
Documents the root cause of the timezone collapse bug and how to avoid it
in future data imports. Covers:
- group as the dedup identity key (not a display label), per lookup type
- correct way to add/update/remove account and object overrides
- hard invariant for time_zone: group must equal name
- verification query to catch bad seed data before it ships
- frontend keying guidance: use group, not id or name

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 17:56:50 -04:00
Scott Idem
15b5084df3 Quick lookup project for time zones. 2026-03-23 17:50:59 -04:00
Scott Idem
c9ec3d7ea1 revert(lookup): restore PARTITION BY group; tests now track data fix
Reverts the PARTITION BY name change — group is the correct dedup key.
Partitioning by name broke country deduplication (two US records both
survived, causing Svelte each_key_duplicate on alpha_2_code='US').

Root cause is bad seed data in lu_v3_time_zone: group='United States'
for 13 US/* zones and group='Europe' for 63 Europe/* zones instead of
group=name. A separate DB UPDATE is required to fix those rows.

Tests updated to assert:
- No duplicate alpha_2_code in country list (PARTITION BY group regression)
- All 13 US/* and Europe/* spot-check zones present (pending DB data fix)
- priority-only timezone count == 72 (pending DB data fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 17:31:30 -04:00
Scott Idem
ccf2f30e11 fix(lookup): partition dedup by name instead of group
ROW_NUMBER() was partitioning by `group`, collapsing all 12 US/* timezones
(which share group="United States") down to a single record. Partitioning
by `name` correctly deduplicates by timezone identity while still preserving
the object > account > global override hierarchy.

Priority-only list now returns the expected 72 entries. Adds a regression
test asserting all 12 US/* timezones are present in the full list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 16:46:47 -04:00
Scott Idem
f23d27de15 Updated gitignore to fix a problem when deploying in test, bak, and prod on Linode 2026-03-23 16:07:37 -04:00
25 changed files with 2606 additions and 667 deletions

5
.gitignore vendored
View File

@@ -141,4 +141,7 @@ logs/
myapp/files/
myapp/file_distribution/
temp/
tmp/
tmp/
# Added 2026-03-23
gunicorn.ctl

View File

@@ -8,6 +8,19 @@ from app.models.error_models import StandardError
log = logging.getLogger(__name__)
def apply_vision_id_fix(resp_data: dict, obj_type: str, by_alias: bool) -> dict:
"""
V3 contract: {obj_type}_id in responses must be the random string, never the DB integer.
Applies to models not yet migrated to the Vision ID pattern (root_validator).
Safe to call on already-migrated models — no-op if the value is already a string.
"""
_id_key = f'{obj_type}_id' if by_alias else 'id'
_rand_key = f'{obj_type}_id_random' if by_alias else 'id_random'
if isinstance(resp_data.get(_id_key), int) and resp_data.get(_rand_key):
resp_data[_id_key] = resp_data[_rand_key]
return resp_data
def format_db_error(raw_error: str) -> StandardError:
"""
Parses raw SQLAlchemy/MariaDB errors into structured StandardError objects.

View File

@@ -26,7 +26,7 @@ from app.db_sql import sql_select, reset_redis, reconnect_db
from app.lib_config_v3 import bootstrap_db_config, validate_critical_config
print('### **** *** ** * The Aether API v4 using FastAPI is loading... * ** *** **** ###')
print('### **** *** ** * The Aether API v3.0 using FastAPI is loading... * ** *** **** ###')
log = logging.getLogger(__name__)
@@ -42,7 +42,7 @@ async def lifespan(app: FastAPI):
"""
# 1. Initialize Logging early but safely
setup_logging(config.settings)
log.info('### **** *** ** * Aether API v4 using FastAPI - Startup Lifespan Initiated * ** *** **** ###')
log.info('### **** *** ** * Aether API v3.0 using FastAPI - Startup Lifespan Initiated * ** *** **** ###')
# 2. Bootstrapping Configuration from DB with robust error handling
log.info("Bootstrapping Configuration...")
@@ -82,21 +82,21 @@ async def lifespan(app: FastAPI):
# 3. Final validation of critical infrastructure
validate_critical_config(config.settings)
log.info('### **** *** ** * Aether API v4 using FastAPI - Startup Sequence Complete * ** *** **** ###')
log.info('### **** *** ** * Aether API v3.0 using FastAPI - Startup Sequence Complete * ** *** **** ###')
yield
# Shutdown logic
log.info('### **** *** ** * Aether API v4 using FastAPI - Shutdown Lifespan Initiated * ** *** **** ###')
log.info('### **** *** ** * Aether API v3.0 using FastAPI - Shutdown Lifespan Initiated * ** *** **** ###')
log.info('The Aether FastAPI API is shutting down...')
print('### **** *** ** * Aether API v4 using FastAPI - About to try FastAPI() while loading... * ** *** **** ###')
print('### **** *** ** * Aether API v3.0 using FastAPI - About to try FastAPI() while loading... * ** *** **** ###')
app = FastAPI(
# debug = True,
title = 'Aether API',
description = 'One Sky IT\'s Aether API v4 using FastAPI.',
version = '3.00.01',
description = 'One Sky IT\'s Aether API v3.0 using FastAPI.',
version = '3.00.03',
operationsSorter = 'method',
lifespan = lifespan,
)

View File

@@ -29,7 +29,7 @@ def get_lookup_list_v3(
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY `group`
ORDER BY
ORDER BY
(for_type = :for_type AND for_id = :for_id) DESC,
(account_id = :account_id) DESC,
created_on DESC

View File

@@ -147,6 +147,8 @@ def get_site_domain_rec_list(
# ### BEGIN ### API Site Domain Methods ### lookup_site_domain_fqdn() ###
def lookup_site_domain_fqdn(
fqdn: str,
# TODO: Accept access_key as an argument for validation (str|None)
# access_key: Optional[str] = None,
enabled: str = 'enabled', # enabled, disabled, all
limit: int = 100,
offset: int = 0,
@@ -156,15 +158,22 @@ def lookup_site_domain_fqdn(
data = {}
data['fqdn'] = fqdn
# TODO: If access_key is provided, add it to the data dict for SQL parameterization
# if access_key is not None:
# data['access_key'] = access_key
sql_enabled, data['enable'] = sql_enable_part(table_name='site_domain', enabled=enabled) # Reasonably safe return str and bool
sql_limit = sql_limit_offset_part(limit=limit, offset=offset) # Reasonably safe return str
# TODO: Add access_key to WHERE clause if provided, e.g.:
# WHERE site_domain.fqdn = :fqdn AND (:access_key IS NULL OR site_domain.access_key = :access_key)
sql = f"""
SELECT `site_domain`.id AS 'site_domain_id', `site_domain`.id_random AS 'site_domain_id_random'
FROM `v_site_domain` AS site_domain
WHERE
site_domain.fqdn = :fqdn
-- TODO: Add access_key check here for stricter validation
-- AND (:access_key IS NULL OR site_domain.access_key = :access_key)
{sql_enabled}
ORDER BY `site_domain`.fqdn ASC, `site_domain`.access_key ASC, `site_domain`.required_referrer ASC, `site_domain`.created_on DESC, `site_domain`.updated_on DESC
{sql_limit};
@@ -176,4 +185,11 @@ def lookup_site_domain_fqdn(
site_domain_rec_li = []
return site_domain_rec_li
# ---
# To restore access_key validation:
# 1. Accept access_key as a parameter to this function (and any API endpoint calling it).
# 2. Add access_key to the SQL WHERE clause (see above) so only matching records are returned.
# 3. If access_key is required, return empty or error if not matched.
# 4. Update API docs and tests to reflect the new/required parameter.
# ### END ### API Site Domain Methods ### get_site_domain_rec_list() ###

View File

@@ -654,7 +654,7 @@ def email_user_auth_key_url(
else: return False
log.debug(account_cfg)
user_id_random = user_obj.id_random # NOTE: Not user_id_random because of alias
user_id_random = user_obj.id or user_obj.user_id # Vision ID: User_Out_Base uses 'id'/'user_id', not 'id_random'
from_email = account_cfg.default_no_reply_email
from_name = account_cfg.default_no_reply_name

View File

@@ -1,7 +1,7 @@
import datetime, pytz
from typing import Dict, List, Optional, Set, Union, ClassVar
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator, root_validator
from app.db_sql import redis_lookup_id_random
from app.lib_general import log, logging
@@ -18,13 +18,12 @@ class Archive_Content_Base(BaseModel):
# log.debug(test_var)
# return test_var
id_random: Optional[str] = Field(
# **base_fields['archive_content_id_random'],
alias = 'archive_content_id_random',
)
id: Optional[int] = Field(
alias = 'archive_content_id'
)
# --- Vision IDs (primary public identifiers — always random strings) ---
id: Optional[str] = Field(None, **base_fields['archive_content_id_random'])
archive_content_id: Optional[str] = Field(None, **base_fields['archive_content_id_random'])
# Legacy alias kept for backward compatibility; populated by root_validator
id_random: Optional[str] = Field(None, alias='archive_content_id_random')
account_id_random: Optional[str]
account_id: Optional[int]
@@ -94,6 +93,7 @@ class Archive_Content_Base(BaseModel):
# Fields that are part of the model (for reading) but should not be saved to the DB table
fields_to_exclude_from_db: ClassVar[list] = [
'id', 'archive_content_id', 'id_random',
'account_id', 'account_id_random', 'archive_id_random', 'hosted_file_id_random',
'hosted_file_path', 'api_hosted_file_path_download', 'api_hosted_file_path_stream',
'hosted_file_hash_sha256', 'hosted_file_content_type', 'hosted_file_size'
@@ -101,12 +101,21 @@ class Archive_Content_Base(BaseModel):
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
@validator('id', always=True)
def archive_content_id_lookup(cls, v, values, **kwargs):
if isinstance(v, int) and v > 0: return v
elif id_random := values.get('id_random'):
return redis_lookup_id_random(record_id_random=id_random, table_name='archive_content')
return None
@root_validator(pre=True)
def map_v3_ids(cls, values):
"""
Vision Transformer: Map DB random-string keys to clean Vision ID fields.
Collision prevention strips any integer that snuck into the string ID fields.
"""
rid = values.get('id_random') or values.get('archive_content_id_random')
if rid and isinstance(rid, str):
values['id'] = rid
values['archive_content_id'] = rid
# Collision prevention: reject integer values in Vision string fields
for k in ['id', 'archive_content_id']:
if k in values and not isinstance(values[k], str):
del values[k]
return values
@validator('archive_id', always=True)
def archive_id_lookup(cls, v, values, **kwargs):

View File

@@ -1,6 +1,6 @@
import datetime, hashlib, logging, os, pytz, redis, secrets
from typing import Dict, List, Optional, Set, Union
from typing import ClassVar, Dict, List, Optional, Set, Union
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator, root_validator
from app.db_sql import get_id_random, redis_lookup_id_random
@@ -169,6 +169,14 @@ class User_New_Base(BaseModel):
# Including JSON data
other_json: Optional[Json]
# Fields that are part of the model (for input) but must not be written to the DB table
fields_to_exclude_from_db: ClassVar[list] = [
'new_password', # Virtual input field — the validator hashes it into 'password'; DB has no new_password column
'id', 'user_id', # Vision ID strings — DB uses int 'id' (auto) and string 'id_random'
'account_id_random', 'contact_id_random', 'organization_id_random', 'person_id_random',
'account_name',
]
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
@root_validator(pre=True)
@@ -181,7 +189,7 @@ class User_New_Base(BaseModel):
if rid := values.get('id_random') or values.get('user_id_random'):
values['id'] = rid
values['user_id'] = rid
if a_rid := values.get('account_id_random'):
values['account_id'] = a_rid
if c_rid := values.get('contact_id_random'):
@@ -190,12 +198,22 @@ class User_New_Base(BaseModel):
values['organization_id'] = o_rid
if p_rid := values.get('person_id_random'):
values['person_id'] = p_rid
# 2. Prevent "Collision Population"
for k in ['id', 'user_id', 'account_id', 'contact_id', 'organization_id', 'person_id']:
# 2. Prevent "Collision Population" — only strip self-reference IDs.
# FK IDs (account_id, contact_id, etc.) are resolved to integers by sanitize_payload
# before model construction and must NOT be stripped, or they won't be written to the DB.
for k in ['id', 'user_id']:
if k in values and not isinstance(values[k], str):
del values[k]
# 3. Pre-inject hashed password so it appears in __fields_set__.
# The @validator('password', always=True) below computes the same hash, but
# exclude_unset=True (used by the CRUD POST handler) only includes fields that
# were in the original input dict. By injecting 'password' here (pre=True),
# it is treated as part of the input and thus written to the DB.
if new_pw := values.get('new_password'):
values['password'] = secure_hash_string(string=new_pw)
return values
@validator('password', always=True)

View File

@@ -39,24 +39,24 @@ async def get_aether_cfg_obj(
return mk_resp(data=None, status_code=404, response=commons.response)
@router.get('/aether/flask/cfg/{aether_flask_cfg_id}', response_model=Resp_Body_Base)
async def get_aether_flask_cfg_obj(
aether_flask_cfg_id: int,
# aether_flask_cfg_id: str = Path(min_length=11, max_length=22),
# @router.get('/aether/flask/cfg/{aether_flask_cfg_id}', response_model=Resp_Body_Base)
# async def get_aether_flask_cfg_obj(
# aether_flask_cfg_id: int,
# # aether_flask_cfg_id: str = Path(min_length=11, max_length=22),
# NOTE: The x_account_id header value is not required.
# commons: Common_Route_Params = Depends(common_route_params),
commons: Common_Route_Params_No_Account_ID = Depends(common_route_params_no_account_id),
):
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
log.debug(locals())
# # NOTE: The x_account_id header value is not required.
# # commons: Common_Route_Params = Depends(common_route_params),
# commons: Common_Route_Params_No_Account_ID = Depends(common_route_params_no_account_id),
# ):
# log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
# log.debug(locals())
if sql_select_result := sql_select(
table_name = 'cfg_flask',
record_id = aether_flask_cfg_id,
as_list = False,
max_count = 1,
):
return mk_resp(data=sql_select_result, response=commons.response)
else:
return mk_resp(data=None, status_code=404, response=commons.response)
# if sql_select_result := sql_select(
# table_name = 'cfg_flask',
# record_id = aether_flask_cfg_id,
# as_list = False,
# max_count = 1,
# ):
# return mk_resp(data=sql_select_result, response=commons.response)
# else:
# return mk_resp(data=None, status_code=404, response=commons.response)

View File

@@ -13,6 +13,7 @@ from app.config import settings
from app.db_sql import sql_insert, sql_update, sql_select, redis_lookup_id_random, get_id_random
from app.routers.api_crud import delete_obj_template, get_obj_template, get_obj_li_template, patch_obj_template, post_obj_template
from app.routers.dependencies_v3 import DeprecationParams
from app.models.api_models import Api_Base
from app.models.response_models import Resp_Body_Base, mk_resp
@@ -62,13 +63,13 @@ async def authenticate_passcode(
if matched_role:
log.info(f"Auth Success: Verified '{matched_role}' passcode for site {site_id}")
# 4. Resolve Account Context
account_id_random = record.get('account_id_random')
if not account_id_random:
if account_id_int := record.get('account_id'):
account_id_random = get_id_random(record_id=account_id_int, table_name='account')
# 5. Mint JWT
payload = {
'account_id': account_id_random,
@@ -81,13 +82,13 @@ async def authenticate_passcode(
'role': matched_role
})
}
token = sign_jwt(
secret_key=settings.JWT_KEY,
ttl=3600 * 24, # 24 hour session
**payload
)
return mk_resp(data={'jwt': token, 'account_id': account_id_random, 'role': matched_role}, response=response)
else:
log.warning(f"Auth Failed: Invalid passcode for site {site_id}")
@@ -98,7 +99,7 @@ async def authenticate_passcode(
# --- JWT Request ---
@router.get('/request_jwt', response_model=Resp_Body_Base)
@router.get('/request_jwt', response_model=Resp_Body_Base, dependencies=[Depends(DeprecationParams)])
async def request_jwt(
x_aether_signing_key: Optional[str] = Header(None, min_length=22, max_length=22),
x_aether_api_key: Optional[str] = Header(None, min_length=22, max_length=22),
@@ -151,7 +152,7 @@ async def request_jwt(
token = sign_jwt(secret_key=signing_key, public_key=x_aether_api_key, ttl=max_ttl, max_renew=max_renew, **payload)
return mk_resp(data={ 'jwt': token })
@router.get('/temp_token', response_model=Resp_Body_Base)
@router.get('/temp_token', response_model=Resp_Body_Base, dependencies=[Depends(DeprecationParams)])
async def get_api_temp_token(
x_aether_api_key: Optional[str] = Header(None),
response: Response = Response,
@@ -167,6 +168,8 @@ async def get_api_temp_token(
# --- Jitsi Token ---
# NOTE: This is still actively used by IDAA for their video conferences using self hosted Jitsi. Thi is actually live. We do need to change the app secret once things have stabilized.
JWT_APP_ID = "my_jitsi_app_id"
JWT_APP_SECRET = "my_jitsi_app_secret-9876543210"
JITSI_DOMAIN = "jitsi.dgrzone.com"
@@ -211,40 +214,43 @@ async def create_jitsi_jwt(request_data: JitsiTokenRequest = Body(...)):
raise HTTPException(status_code=500, detail=f"Failed to create JWT: {str(e)}")
# --- Api_Base CRUD ---
# LEGACY (disabled) - superseded by V3 CRUD: /v3/crud/api/
@router.post('', response_model=Resp_Body_Base)
async def post_api_obj(obj: Api_Base, x_account_id: str = Header(...)):
return post_obj_template(obj_type='api', data=obj.dict(by_alias=False, exclude_unset=True), return_obj=True)
# @router.post('', response_model=Resp_Body_Base)
# async def post_api_obj(obj: Api_Base, x_account_id: str = Header(...)):
# return post_obj_template(obj_type='api', data=obj.dict(by_alias=False, exclude_unset=True), return_obj=True)
@router.patch('/{obj_id}', response_model=Resp_Body_Base)
async def patch_api_obj(obj_id: str, obj: Api_Base, x_account_id: str = Header(...)):
data = obj.dict(by_alias=False, exclude_unset=True)
data['id'] = redis_lookup_id_random(record_id_random=obj_id, table_name='api')
return patch_obj_template(obj_type='api', data=data, obj_id=obj_id, return_obj=True)
# @router.patch('/{obj_id}', response_model=Resp_Body_Base)
# async def patch_api_obj(obj_id: str, obj: Api_Base, x_account_id: str = Header(...)):
# data = obj.dict(by_alias=False, exclude_unset=True)
# data['id'] = redis_lookup_id_random(record_id_random=obj_id, table_name='api')
# return patch_obj_template(obj_type='api', data=data, obj_id=obj_id, return_obj=True)
@router.get('/list', response_model=Resp_Body_Base)
async def get_api_obj_li(for_obj_type: Optional[str] = Query(None), for_obj_id: Optional[str] = Query(None), x_account_id: str = Header(...)):
return get_obj_li_template(obj_type='api', for_obj_type=for_obj_type, for_obj_id=for_obj_id)
# @router.get('/list', response_model=Resp_Body_Base)
# async def get_api_obj_li(for_obj_type: Optional[str] = Query(None), for_obj_id: Optional[str] = Query(None), x_account_id: str = Header(...)):
# return get_obj_li_template(obj_type='api', for_obj_type=for_obj_type, for_obj_id=for_obj_id)
@router.get('/{obj_id}', response_model=Resp_Body_Base)
async def get_api_obj(obj_id: str, x_account_id: str = Header(...)):
return get_obj_template(obj_type='api', obj_id=obj_id)
# @router.get('/{obj_id}', response_model=Resp_Body_Base)
# async def get_api_obj(obj_id: str, x_account_id: str = Header(...)):
# return get_obj_template(obj_type='api', obj_id=obj_id)
@router.delete('/{obj_id}', response_model=Resp_Body_Base)
async def delete_api_obj(obj_id: str, x_account_id: str = Header(...)):
return delete_obj_template(obj_type='api', obj_id=obj_id)
# @router.delete('/{obj_id}', response_model=Resp_Body_Base)
# async def delete_api_obj(obj_id: str, x_account_id: str = Header(...)):
# return delete_obj_template(obj_type='api', obj_id=obj_id)
@router.get('/get_id/{object_type}/{object_id_random}', response_model=Resp_Body_Base)
async def get_api_object_id(object_type: str, object_id_random: str):
if object_id := redis_lookup_id_random(record_id_random=object_id_random, table_name=object_type):
return mk_resp(data={ 'object_id': object_id})
return mk_resp(data=None, status_code=404)
# LEGACY (disabled) - exposes internal integer IDs, breaks id_random abstraction
# @router.get('/get_id/{object_type}/{object_id_random}', response_model=Resp_Body_Base)
# async def get_api_object_id(object_type: str, object_id_random: str):
# if object_id := redis_lookup_id_random(record_id_random=object_id_random, table_name=object_type):
# return mk_resp(data={ 'object_id': object_id})
# return mk_resp(data=None, status_code=404)
@router.get('/sql_test', tags=['Testing'])
async def sql_test(response: Response = Response):
sql = text("SELECT NOW() as current_time, VERSION() as version")
try:
result = db.execute(sql).fetchone()
return mk_resp(data={"current_time": str(result[0]), "version": result[1]})
except Exception as e:
return mk_resp(data=False, status_code=500, details=str(e), response=response)
# LEGACY (disabled) - testing/debug endpoint
# @router.get('/sql_test', tags=['Testing'])
# async def sql_test(response: Response = Response):
# sql = text("SELECT NOW() as current_time, VERSION() as version")
# try:
# result = db.execute(sql).fetchone()
# return mk_resp(data={"current_time": str(result[0]), "version": result[1]})
# except Exception as e:
# return mk_resp(data=False, status_code=500, details=str(e), response=response)

View File

@@ -15,7 +15,8 @@ from app.lib_general_v3 import (
)
from app.lib_api_crud_v3 import (
check_account_access, apply_forced_account_filter, filter_order_by,
get_supported_filters, safe_json_loads, sanitize_payload, format_db_error
get_supported_filters, safe_json_loads, sanitize_payload, format_db_error,
apply_vision_id_fix
)
from app.lib_schema_v3 import get_object_schema_info
from app.db_sql import get_last_sql_error
@@ -157,6 +158,7 @@ async def get_obj(
sql_result['inc_hosted_file'] = True
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)
apply_vision_id_fix(resp_data, obj_name, serialization.by_alias)
return mk_resp(data=resp_data, response=response)
else:
return mk_resp(data=False, status_code=404, response=response, status_message=f"Object with ID '{obj_id}' not found in database.")
@@ -283,7 +285,7 @@ async def get_obj_li(
return mk_resp(data=False, status_code=status_code, response=response, status_message="Listing failed due to database error.", details=db_err.dict())
if sql_result:
resp_data_li = [base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none) for record in sql_result]
resp_data_li = [apply_vision_id_fix(base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none), obj_name, serialization.by_alias) for record in sql_result]
return mk_resp(data=resp_data_li, response=response)
else:
return mk_resp(data=[], status_code=200, response=response)
@@ -393,7 +395,7 @@ async def search_obj_li(
return mk_resp(data=False, status_code=status_code, response=response, status_message="Search failed due to database error.", details=db_err.dict())
if sql_result:
resp_data_li = [base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none) for record in sql_result]
resp_data_li = [apply_vision_id_fix(base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none), obj_name, serialization.by_alias) for record in sql_result]
return mk_resp(data=resp_data_li, response=response)
else:
return mk_resp(data=[], status_code=200, response=response)
@@ -438,14 +440,9 @@ async def post_obj(
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.")
if not account.super and account.auth_method != 'bypass' and account.account_id:
if 'account_id' in input_model.__fields__:
obj_data['account_id'] = account.account_id
elif obj_name == 'account':
if obj_name == 'account':
return mk_resp(data=False, status_code=403, response=response, status_message="Account creation is restricted.")
# Sanitize payload (ID resolution, virtual fields, and optionally extra fields)
sanitize_payload(obj_data, input_model, ignore_extra=x_ae_ignore_extra_fields)
try:
validated_obj = input_model(**obj_data)
except ValidationError as e:
@@ -457,6 +454,18 @@ async def post_obj(
data_to_insert = validated_obj.dict(exclude_unset=True)
# Sanitize payload AFTER model validation so that:
# 1. The model receives raw Vision ID strings (passes field-length constraints).
# 2. ID resolution (string → integer) happens on the serialized dict that goes to the DB,
# avoiding conflicts with root_validator collision-prevention logic.
sanitize_payload(data_to_insert, input_model, ignore_extra=x_ae_ignore_extra_fields)
# Enforce account ownership AFTER sanitize_payload so the integer account_id goes straight
# to the DB without conflicting with Vision ID string constraints in the model.
if not account.super and account.auth_method != 'bypass' and account.account_id:
if 'account_id' in input_model.__fields__:
data_to_insert['account_id'] = account.account_id
if sql_insert_result := sql_insert(data=data_to_insert, table_name=table_name_insert):
new_obj_id = sql_insert_result
new_obj_id_random = get_id_random(record_id=new_obj_id, table_name=obj_name)
@@ -464,8 +473,9 @@ 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)
apply_vision_id_fix(resp_data, obj_name, serialization.by_alias)
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)
return mk_resp(data={"obj_id": new_obj_id_random, "obj_id_random": new_obj_id_random}, response=response)
else:
# Standardized rich error bubbling
db_err = format_db_error(get_last_sql_error())
@@ -527,6 +537,7 @@ async def patch_obj(
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)
apply_vision_id_fix(resp_data, obj_name, serialization.by_alias)
return mk_resp(data=resp_data, response=response)
return mk_resp(data=True, response=response, status_message="Object updated successfully.")
else:

View File

@@ -13,7 +13,8 @@ from app.lib_general_v3 import (
)
from app.lib_api_crud_v3 import (
check_account_access, apply_forced_account_filter, filter_order_by,
get_supported_filters, safe_json_loads, sanitize_payload, format_db_error
get_supported_filters, safe_json_loads, sanitize_payload, format_db_error,
apply_vision_id_fix
)
from app.db_sql import get_last_sql_error
from app.models.response_models import *
@@ -132,7 +133,7 @@ async def get_child_obj_li(
return mk_resp(data=False, status_code=status_code, response=response, status_message="Listing failed due to database error.", details=db_err.dict())
if sql_result:
resp_data_li = [base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none) for record in sql_result]
resp_data_li = [apply_vision_id_fix(base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none), obj_name, serialization.by_alias) for record in sql_result]
return mk_resp(data=resp_data_li, response=response)
else:
return mk_resp(data=[], status_code=200, response=response)
@@ -218,7 +219,7 @@ async def search_child_obj_li(
return mk_resp(data=False, status_code=status_code, response=response, status_message="Search failed due to database error.", details=db_err.dict())
if sql_result:
resp_data_li = [base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none) for record in sql_result]
resp_data_li = [apply_vision_id_fix(base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none), obj_name, serialization.by_alias) for record in sql_result]
return mk_resp(data=resp_data_li, response=response)
else:
return mk_resp(data=[], status_code=200, response=response)
@@ -306,8 +307,9 @@ async def post_child_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)
apply_vision_id_fix(resp_data, child_obj_type, serialization.by_alias)
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)
return mk_resp(data={"obj_id": new_obj_id_random, "obj_id_random": new_obj_id_random}, response=response)
else:
# Standardized rich error bubbling
db_err = format_db_error(get_last_sql_error())
@@ -357,6 +359,7 @@ async def get_child_obj(
return mk_resp(data=False, status_code=404, response=response, status_message="Child not found under parent.")
resp_data = base_name(**sql_result).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset)
apply_vision_id_fix(resp_data, child_obj_type, serialization.by_alias)
return mk_resp(data=resp_data, response=response)
return mk_resp(data=False, status_code=404, response=response, status_message="Child not found.")
@@ -418,6 +421,7 @@ async def patch_child_obj(
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)
apply_vision_id_fix(resp_data, child_obj_type, serialization.by_alias)
return mk_resp(data=resp_data, response=response)
return mk_resp(data=True, response=response, status_message="Updated successfully.")
else:
@@ -425,116 +429,6 @@ async def patch_child_obj(
return mk_resp(data=False, status_code=400, response=response, status_message="Update failed.", details=db_err.dict())
@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),
view: str = Query('default'),
account: AccountContext = Depends(get_account_context),
serialization: SerializationParams = Depends(),
delay: DelayParams = Depends(),
):
"""
Retrieve Child Object.
Verifies that the child belongs to the specified parent.
"""
from app.db_sql import redis_lookup_id_random, sql_select
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
# ID Vision: Resolve physical table names from registry to support aliases
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(s).")
parent_table = obj_type_kv_li[parent_obj_type].get('tbl')
child_table = obj_type_kv_li[child_obj_type].get('tbl')
resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_table)
resolved_child_id = redis_lookup_id_random(record_id_random=child_obj_id, table_name=child_table)
if not resolved_parent_id or not resolved_child_id:
return mk_resp(data=False, status_code=404, response=response, status_message="Object(s) not found.")
obj_cfg = obj_type_kv_li[child_obj_type]
table_name = obj_cfg.get(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 sql_result := sql_select(table_name=table_name, record_id=resolved_child_id):
if sql_result.get(f'{parent_obj_type}_id') != resolved_parent_id:
return mk_resp(data=False, status_code=404, response=response, status_message="Child not found under parent.")
resp_data = base_name(**sql_result).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset)
return mk_resp(data=resp_data, response=response)
return mk_resp(data=False, status_code=404, response=response, status_message="Child not found.")
@router.patch('/{parent_obj_type}/{parent_obj_id}/{child_obj_type}/{child_obj_id}', response_model=Resp_Body_Base)
async def patch_child_obj(
request: Request,
response: Response,
parent_obj_type: str = Path(min_length=2, max_length=50),
parent_obj_id: str = Path(min_length=11, max_length=22),
child_obj_type: str = Path(min_length=2, max_length=50),
child_obj_id: str = Path(min_length=11, max_length=22),
return_obj: Optional[bool] = True,
x_ae_ignore_extra_fields: Optional[bool] = Header(False),
account: AccountContext = Depends(get_account_context),
serialization: SerializationParams = Depends(),
delay: DelayParams = Depends(),
):
"""
Update Child Object.
Verifies that the child belongs to the specified parent before updating.
"""
from app.db_sql import redis_lookup_id_random, sql_select, sql_update
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
obj_data = await request.json()
# ID Vision: Resolve physical table names from registry to support aliases
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(s).")
parent_table = obj_type_kv_li[parent_obj_type].get('tbl')
child_table = obj_type_kv_li[child_obj_type].get('tbl')
resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_table)
resolved_child_id = redis_lookup_id_random(record_id_random=child_obj_id, table_name=child_table)
if not resolved_parent_id or not resolved_child_id:
return mk_resp(data=False, status_code=404, response=response, status_message="Object(s) not found.")
obj_cfg = obj_type_kv_li[child_obj_type]
table_name_update = obj_cfg.get('tbl_update', obj_cfg.get('tbl'))
table_name_select = obj_cfg.get('tbl_default', obj_cfg.get('tbl'))
input_model = obj_cfg.get('mdl_in', obj_cfg.get('mdl'))
output_model = obj_cfg.get('mdl_out', obj_cfg.get('mdl_default', obj_cfg.get('mdl')))
if existing_child := sql_select(table_name=table_name_select, record_id=resolved_child_id):
if existing_child.get(f'{parent_obj_type}_id') != resolved_parent_id:
return mk_resp(data=False, status_code=404, response=response, status_message="Child not found under parent.")
else:
return mk_resp(data=False, status_code=404, response=response, status_message="Child not found.")
# Sanitize payload (ID resolution, virtual fields, and optionally extra fields)
sanitize_payload(obj_data, input_model, ignore_extra=x_ae_ignore_extra_fields)
if sql_update(data=obj_data, table_name=table_name_update, record_id=resolved_child_id):
if return_obj:
if updated_child := sql_select(table_name=table_name_select, record_id=resolved_child_id):
resp_data = output_model(**updated_child).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset)
return mk_resp(data=resp_data, response=response)
return mk_resp(data=True, response=response, status_message="Updated successfully.")
else:
db_err = format_db_error(get_last_sql_error())
return mk_resp(data=False, status_code=400, response=response, status_message="Update failed.", details=db_err.dict())
@router.delete('/{parent_obj_type}/{parent_obj_id}/{child_obj_type}/{child_obj_id}', response_model=Resp_Body_Base)
async def delete_child_obj(

View File

@@ -15,7 +15,8 @@ from app.config import settings
from app.db_sql import redis_lookup_id_random, sql_select, sql_update, sql_delete, get_id_random
from app.methods.hosted_file_methods import (
create_hosted_file_obj, load_hosted_file_obj, save_file,
create_hosted_file_link, delete_hosted_file_link, get_hosted_file_link_rec_list
create_hosted_file_link, delete_hosted_file_link, get_hosted_file_link_rec_list,
lookup_file_hash, check_for_hosted_file_hash_file
)
from app.methods.lib_media import convert_file_method
from app.methods.lib_media import clip_video_method
@@ -354,6 +355,38 @@ async def download_file_by_hash_action(
return FileResponse(full_file_path, filename=target_filename, media_type=media_type)
@router.get('/hash/{hosted_file_hash}', response_model=Resp_Body_Base)
async def check_hosted_file_obj_w_hash_action(
response: Response,
hosted_file_hash: str = Path(min_length=64, max_length=64),
check_for_local: Optional[bool] = Query(True),
account: AccountContext = Depends(get_account_context_optional),
delay: DelayParams = Depends(),
):
"""
Look up a hosted_file record by its hash (Deduplication Check).
Optionally verifies physical file existence on disk.
"""
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
if hfid := lookup_file_hash(file_hash=hosted_file_hash):
obj_model = load_hosted_file_obj(hosted_file_id=hfid, model_as_dict=False)
if not obj_model:
return mk_resp(data=False, status_code=404, response=response, status_message="Record found but data could not be loaded.")
if check_for_local:
# We use the model directly to access subdirectory_path even if it's excluded from dicts
sub_dir = getattr(obj_model, 'subdirectory_path', '') or ''
if check := check_for_hosted_file_hash_file(file_hash=hosted_file_hash, sub_dir=sub_dir):
obj_model.hosted_file_found_check = True
obj_model.hosted_file_size_check = check['file_size']
# mk_resp will handle model->dict conversion with proper ID Vision mapping
return mk_resp(data=obj_model)
return mk_resp(data=False, status_code=404, response=response, status_message="No record found for this hash.")
@router.delete('/{hosted_file_id}', response_model=Resp_Body_Base)
async def delete_file_action(
hosted_file_id: str = Path(min_length=11, max_length=22),

View File

@@ -0,0 +1,299 @@
"""
Aether API V3 - User Action Router
------------------------------------
Handles secure, stateful user account operations that are not standard CRUD.
Routes:
POST /authenticate — username+password or user_id+auth_key (body, not query params)
POST /verify_password — verify a user's current password without changing it
POST /{user_id}/change_password — change password (with optional current-password verification)
GET /{user_id}/new_auth_key — generate a new one-time login auth key
GET /{user_id}/email_auth_key_url — email a one-time login link to the user
Security improvements over legacy /user/* routes:
- Credentials are in the POST body, never in query params (no URL logging exposure).
- Uses V3 AccountContext (x-aether-api-key mandatory).
- HTTPException for all error paths (native FastAPI status codes).
"""
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, status
import datetime
import logging
from typing import Optional
from pydantic import BaseModel, Field
from app.db_sql import redis_lookup_id_random, sql_select, sql_update
from app.lib_general import secure_hash_string, verify_secure_hash_string
from app.lib_general_v3 import AccountContext, get_account_context
from app.methods.user_methods import email_user_auth_key_url, load_user_obj
from app.models.common_field_schema import default_num_bytes
from app.models.response_models import Resp_Body_Base, mk_resp
log = logging.getLogger(__name__)
router = APIRouter()
# --- Request Body Models ---
class ChangePasswordRequest(BaseModel):
new_password: str = Field(..., min_length=10, max_length=100)
current_password: Optional[str] = Field(None, description="If provided, verified before applying the change.")
class AuthenticateRequest(BaseModel):
"""Provide either username+password or user_id+auth_key."""
username: Optional[str] = Field(None, min_length=3, max_length=50)
password: Optional[str] = Field(None, min_length=8, max_length=100)
user_id: Optional[str] = Field(None, min_length=11, max_length=22, description="Vision ID (id_random) of the user.")
auth_key: Optional[str] = Field(None, min_length=11, max_length=22)
valid_email: Optional[bool] = Field(None, description="If True, marks email_verified=True on successful auth.")
class VerifyPasswordRequest(BaseModel):
"""Provide user_id (Vision ID) or username, plus the password to verify."""
current_password: str = Field(..., min_length=1, max_length=100)
user_id: Optional[str] = Field(None, min_length=11, max_length=22)
username: Optional[str] = Field(None, min_length=2, max_length=50)
# --- Internal Helper ---
def _check_user_enabled(rec: dict) -> Optional[str]:
"""
Returns an error message string if the user account is not currently active, None if OK.
Checks: enable flag, enable_from, enable_to (all treated as UTC).
"""
if not rec.get('enable'):
return 'This user account is not enabled.'
now = datetime.datetime.now(datetime.timezone.utc)
if enable_from := rec.get('enable_from'):
ef = enable_from.replace(tzinfo=datetime.timezone.utc)
if ef > now:
return f'This user account is not yet enabled (active from: {ef}).'
if enable_to := rec.get('enable_to'):
et = enable_to.replace(tzinfo=datetime.timezone.utc)
if et < now:
return f'This user account has expired (expired: {et}).'
return None
# --- Routes ---
@router.post('/authenticate', response_model=Resp_Body_Base)
async def action_authenticate(
body: AuthenticateRequest = Body(...),
inc_user_role_list: bool = Query(False),
account: AccountContext = Depends(get_account_context),
):
"""
Authenticate a user by username+password or user_id+auth_key.
- Credentials are in the POST body (not query params) — safe from URL logging.
- Auth key is one-time-use: cleared on successful authentication.
- On success: stamps logged_in_on, returns the full user object.
- Provide x-account-id to scope username lookups to the correct account.
"""
account_id = account.account_id
if body.username and body.password:
sql = """
SELECT id AS user_id, id_random AS user_id_random, password,
enable, enable_from, enable_to
FROM `user`
WHERE account_id = :account_id AND username = :username
LIMIT 1
"""
rec = sql_select(sql=sql, data={'account_id': account_id, 'username': body.username})
if not rec:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail='User not found for this account and username.')
if not rec.get('password'):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail='No password is set for this user.')
if not verify_secure_hash_string(string=body.password, string_hash=rec['password']):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail='Password did not match.')
if err := _check_user_enabled(rec):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=err)
db_user_id = rec['user_id']
update_data = {'id': db_user_id, 'logged_in_on': datetime.datetime.utcnow()}
if body.valid_email:
update_data['email_verified'] = True
sql_update(table_name='user', data=update_data)
elif body.user_id and body.auth_key:
sql = """
SELECT id AS user_id, id_random AS user_id_random, password,
enable, enable_from, enable_to
FROM `user`
WHERE id_random = :user_id_random
AND auth_key = :auth_key
AND allow_auth_key = 1
LIMIT 1
"""
rec = sql_select(sql=sql, data={'user_id_random': body.user_id, 'auth_key': body.auth_key})
if not rec:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail='User + auth key combination not found.')
if err := _check_user_enabled(rec):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=err)
db_user_id = rec['user_id']
# Auth key is one-time-use — clear it immediately.
update_data = {'id': db_user_id, 'auth_key': None, 'logged_in_on': datetime.datetime.utcnow()}
if body.valid_email:
update_data['email_verified'] = True
sql_update(table_name='user', data=update_data)
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail='Provide either username+password or user_id+auth_key.')
user_obj = load_user_obj(user_id=db_user_id, inc_user_role_list=inc_user_role_list)
if not user_obj:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Authentication succeeded but user record could not be loaded.')
return mk_resp(data=user_obj.dict(by_alias=True), status_message='Authentication successful.')
@router.post('/verify_password', response_model=Resp_Body_Base)
async def action_verify_password(
body: VerifyPasswordRequest = Body(...),
account: AccountContext = Depends(get_account_context),
):
"""
Verify a user's current password without changing it.
Provide user_id (Vision ID) or username + current_password.
Returns data=True on match, 403 on mismatch.
"""
account_id = account.account_id
if body.user_id:
sql = """
SELECT id AS user_id, username, password
FROM `user`
WHERE id_random = :user_id_random
LIMIT 1
"""
rec = sql_select(sql=sql, data={'user_id_random': body.user_id})
elif body.username:
sql = """
SELECT id AS user_id, username, password
FROM `user`
WHERE account_id = :account_id AND username = :username
LIMIT 1
"""
rec = sql_select(sql=sql, data={'account_id': account_id, 'username': body.username})
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail='Provide user_id or username.')
if not rec:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found.')
if not rec.get('password'):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail='No password is set for this user.')
if not verify_secure_hash_string(string=body.current_password, string_hash=rec['password']):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Password did not match.')
return mk_resp(data=True, status_message='Password verified.')
@router.post('/{user_id}/change_password', response_model=Resp_Body_Base)
async def action_change_password(
user_id: str = Path(min_length=11, max_length=22),
body: ChangePasswordRequest = Body(...),
account: AccountContext = Depends(get_account_context),
):
"""
Change a user's password.
- new_password is required (min 10 chars).
- If current_password is provided, it is verified before the change is applied.
- Stamps password_set_on on success.
"""
db_user_id = redis_lookup_id_random(record_id_random=user_id, table_name='user')
if not db_user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found.')
if body.current_password:
sql = "SELECT password FROM `user` WHERE id = :uid LIMIT 1"
rec = sql_select(sql=sql, data={'uid': db_user_id})
if not rec or not rec.get('password'):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail='User not found or password not set.')
if not verify_secure_hash_string(string=body.current_password, string_hash=rec['password']):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail='Current password is incorrect.')
update_data = {
'id': db_user_id,
'password': secure_hash_string(string=body.new_password),
'password_set_on': datetime.datetime.utcnow(),
}
if not sql_update(table_name='user', data=update_data):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Password update failed.')
return mk_resp(data=True, status_message='Password changed successfully.')
@router.get('/{user_id}/new_auth_key', response_model=Resp_Body_Base)
async def action_new_auth_key(
user_id: str = Path(min_length=11, max_length=22),
account: AccountContext = Depends(get_account_context),
):
"""
Generate a new one-time-use auth key for the user.
The key is written to the DB and returned in the response body.
The user record must have allow_auth_key=1 for the key to be usable
with the /authenticate endpoint.
"""
import secrets
db_user_id = redis_lookup_id_random(record_id_random=user_id, table_name='user')
if not db_user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found.')
new_key = secrets.token_urlsafe(default_num_bytes)
update_data = {'id': db_user_id, 'auth_key': new_key}
if not sql_update(table_name='user', data=update_data):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to write auth key.')
return mk_resp(data={'auth_key': new_key}, status_message='New auth key generated.')
@router.get('/{user_id}/email_auth_key_url', response_model=Resp_Body_Base)
async def action_email_auth_key_url(
user_id: str = Path(min_length=11, max_length=22),
root_url: Optional[str] = Query(None, min_length=10, max_length=200),
key_param_name: str = Query('auth_key', min_length=2, max_length=30),
account: AccountContext = Depends(get_account_context),
):
"""
Generate a new auth key and email a one-time login URL to the user.
root_url is the base URL the login link will be built from.
key_param_name controls the query param name used for the auth key in the link (default: auth_key).
Returns data=True on success (email sent), 500 if delivery failed.
"""
db_user_id = redis_lookup_id_random(record_id_random=user_id, table_name='user')
if not db_user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found.')
result = email_user_auth_key_url(
account_id=account.account_id,
user_id=db_user_id,
root_url=root_url,
key_param_name=key_param_name,
)
if not result:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Auth key email could not be sent. Check account email config and user enable status.')
return mk_resp(data=True, status_message='Auth key email sent.')

View File

@@ -24,119 +24,125 @@ router = APIRouter()
# ### BEGIN ### API Data Store Routers ### post_data_store_obj() ###
# LEGACY (disabled) - superseded by V3 CRUD: POST /v3/crud/data_store/
# Updated 2026-01-28
@router.post('/data_store', response_model=Resp_Body_Base)
async def post_data_store_obj(
data_store_obj: Data_Store_Base,
return_obj: bool = True,
commons: Common_Route_Params = Depends(common_route_params),
):
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
log.debug(locals())
# ### SECTION ### Secondary data validation
# None
# ### SECTION ### Process data
if data_store_id := create_update_data_store_obj(
data_store_dict_obj = data_store_obj,
): pass
else:
log.warning('Likely bad request')
return mk_resp(data=False, status_code=400, response=commons.response, status_message='Not created. Something failed while processing the data. Check the field names and data types.') # Bad Request
# ### SECTION ### Return successful results
if return_obj:
data_store_obj = load_data_store_obj(
data_store_id = data_store_id,
)
data = data_store_obj
else:
data_store_id_random = get_id_random(record_id=data_store_id, table_name='data_store')
data = {}
data['data_store_id'] = data_store_id
data['data_store_id_random'] = data_store_id_random
return mk_resp(data=data, response=commons.response)
# @router.post('/data_store', response_model=Resp_Body_Base)
# async def post_data_store_obj(
# data_store_obj: Data_Store_Base,
#
# return_obj: bool = True,
#
# commons: Common_Route_Params = Depends(common_route_params),
# ):
# log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
# log.debug(locals())
#
# # ### SECTION ### Secondary data validation
# # None
#
# # ### SECTION ### Process data
# if data_store_id := create_update_data_store_obj(
# data_store_dict_obj = data_store_obj,
# ): pass
# else:
# log.warning('Likely bad request')
# return mk_resp(data=False, status_code=400, response=commons.response, status_message='Not created. Something failed while processing the data. Check the field names and data types.') # Bad Request
#
# # ### SECTION ### Return successful results
# if return_obj:
# data_store_obj = load_data_store_obj(
# data_store_id = data_store_id,
# )
# data = data_store_obj
# else:
# data_store_id_random = get_id_random(record_id=data_store_id, table_name='data_store')
# data = {}
# data['data_store_id'] = data_store_id
# data['data_store_id_random'] = data_store_id_random
# return mk_resp(data=data, response=commons.response)
# ### END ### API Data Store Routers ### post_data_store_obj() ###
# ### BEGIN ### API Data Store Routers ### patch_data_store_obj() ###
# LEGACY (disabled) - superseded by V3 CRUD: PATCH /v3/crud/data_store/{id}
# Updated 2022-03-11
@router.patch('/data_store/{data_store_id}', response_model=Resp_Body_Base)
async def patch_data_store_obj(
data_store_obj: Data_Store_Base,
data_store_id: str = Path(min_length=11, max_length=22),
return_obj: Optional[bool] = True,
commons: Common_Route_Params = Depends(common_route_params),
):
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
log.debug(locals())
# ### SECTION ### Secondary data validation
data_store_id_random = data_store_id # This is used later for the response data
if data_store_id := redis_lookup_id_random(record_id_random=data_store_id, table_name='data_store'): pass
else: return mk_resp(data=None, status_code=404, response=commons.response, status_message='The Data Store ID was invalid or not found.')
# ### SECTION ### Process data
if data_store_up_result := create_update_data_store_obj(
data_store_dict_obj = data_store_obj,
data_store_id = data_store_id,
): pass
else:
log.warning('Likely bad request')
return mk_resp(data=False, status_code=400, response=commons.response, status_message='Not updated. Something failed while processing the data. Check the field names and data types.') # Bad Request
# ### SECTION ### Return successful results
if return_obj:
data_store_obj = load_data_store_obj(
data_store_id = data_store_id,
)
data = data_store_obj
else:
data = {}
data['data_store_id'] = data_store_id
data['data_store_id_random'] = data_store_id_random
return mk_resp(data=data, response=commons.response)
# @router.patch('/data_store/{data_store_id}', response_model=Resp_Body_Base)
# async def patch_data_store_obj(
# data_store_obj: Data_Store_Base,
# data_store_id: str = Path(min_length=11, max_length=22),
#
# return_obj: Optional[bool] = True,
#
# commons: Common_Route_Params = Depends(common_route_params),
# ):
# log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
# log.debug(locals())
#
# # ### SECTION ### Secondary data validation
# data_store_id_random = data_store_id # This is used later for the response data
# if data_store_id := redis_lookup_id_random(record_id_random=data_store_id, table_name='data_store'): pass
# else: return mk_resp(data=None, status_code=404, response=commons.response, status_message='The Data Store ID was invalid or not found.')
#
# # ### SECTION ### Process data
# if data_store_up_result := create_update_data_store_obj(
# data_store_dict_obj = data_store_obj,
# data_store_id = data_store_id,
# ): pass
# else:
# log.warning('Likely bad request')
# return mk_resp(data=False, status_code=400, response=commons.response, status_message='Not updated. Something failed while processing the data. Check the field names and data types.') # Bad Request
#
# # ### SECTION ### Return successful results
# if return_obj:
# data_store_obj = load_data_store_obj(
# data_store_id = data_store_id,
# )
# data = data_store_obj
# else:
# data = {}
# data['data_store_id'] = data_store_id
# data['data_store_id_random'] = data_store_id_random
# return mk_resp(data=data, response=commons.response)
# ### END ### API Data Store Routers ### patch_data_store_obj() ###
# ### BEGIN ### API Data Store ### get_data_store_obj() ###
# LEGACY (disabled) - superseded by V3 CRUD: GET /v3/crud/data_store/{id}
# Updated 2026-01-28
@router.get('/data_store/{data_store_id}', response_model=Resp_Body_Base)
async def get_data_store_obj(
data_store_id: str = Path(min_length=11, max_length=22),
commons: Common_Route_Params = Depends(common_route_params),
):
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
log.debug(locals())
# ### SECTION ### Secondary data validation
if data_store_id := redis_lookup_id_random(record_id_random=data_store_id, table_name='data_store'): pass
else: return mk_resp(data=None, status_code=404, response=commons.response)
if data_store_rec_result := load_data_store_obj(
data_store_id = data_store_id,
limit = commons.limit,
enabled = commons.enabled,
):
log.info('Loading successful. Returning result')
return mk_resp(data=data_store_rec_result, response=commons.response)
elif isinstance(data_store_rec_result, list) or data_store_rec_result is None: # Empty list or None
log.info('No results')
return mk_resp(data=None, status_code=404, response=commons.response) # Not Found
else:
log.warning('Likely bad request')
return mk_resp(data=False, status_code=400, response=commons.response) # Bad Request
# @router.get('/data_store/{data_store_id}', response_model=Resp_Body_Base)
# async def get_data_store_obj(
# data_store_id: str = Path(min_length=11, max_length=22),
#
# commons: Common_Route_Params = Depends(common_route_params),
# ):
# log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
# log.debug(locals())
#
# # ### SECTION ### Secondary data validation
# if data_store_id := redis_lookup_id_random(record_id_random=data_store_id, table_name='data_store'): pass
# else: return mk_resp(data=None, status_code=404, response=commons.response)
#
# if data_store_rec_result := load_data_store_obj(
# data_store_id = data_store_id,
# limit = commons.limit,
# enabled = commons.enabled,
# ):
# log.info('Loading successful. Returning result')
# return mk_resp(data=data_store_rec_result, response=commons.response)
# elif isinstance(data_store_rec_result, list) or data_store_rec_result is None: # Empty list or None
# log.info('No results')
# return mk_resp(data=None, status_code=404, response=commons.response) # Not Found
# else:
# log.warning('Likely bad request')
# return mk_resp(data=False, status_code=400, response=commons.response) # Bad Request
# ### END ### API Data Store ### get_data_store_obj() ###
# ### BEGIN ### API Data Store ### get_v3_data_store_obj_w_code() ###
# NEW V3 Endpoint for Code Lookup
# TODO: Migrate to a dedicated api_v3_actions_data_store.py router and rename path to
# /v3/action/data_store/code/{data_store_code} to match the V3 action naming convention.
# Requires a coordinated frontend update before the path rename can happen.
# Updated 2026-01-28
@router.get('/v3/data_store/code/{data_store_code}', response_model=Resp_Body_Base, tags=['Data Store V3'])
async def get_v3_data_store_obj_w_code(
@@ -156,13 +162,13 @@ async def get_v3_data_store_obj_w_code(
Returns a single object if limit=1, otherwise returns a list.
"""
log.setLevel(logging.INFO)
# Map V3 params to the shared handler
v3_commons = Common_Route_Params(
x_account_id=account.account_id,
x_account_id_random=account.account_id_random,
enabled=status_filter.enabled,
response=Response()
response=Response()
)
return await handle_get_data_store_obj_w_code(
@@ -177,57 +183,60 @@ async def get_v3_data_store_obj_w_code(
# ### BEGIN ### API Data Store ### get_data_store_obj_w_code() ###
# NOTE: Adding some explanation because this is not quickly obvious how it fully works.
# The look up order starts with a required data_store_code. Then the first result that matches the most specific method. The for_type and for_id fields are not required. I think it makes the most sense to be a part of the URL path, not the GET params. Either should work with no problem though.
# LEGACY (disabled) - legacy code-based lookup; use GET /v3/data_store/code/{code} instead.
# NOTE: The look up order starts with a required data_store_code. Then the first result that matches the most specific method. The for_type and for_id fields are not required. I think it makes the most sense to be a part of the URL path, not the GET params. Either should work with no problem though.
# Lookup using: for_type and for_id > account_id > data_store_code
# This is a nice way to have global default data along with account and object specific data.
# Updated 2023-05-22
@router.get('/data_store/code/{data_store_code}/{for_type}/{for_id}', response_model=Resp_Body_Base)
async def get_data_store_obj_w_code_path(
data_store_code: str = Path(min_length=3, max_length=50),
for_type: Optional[str] = Path(min_length=1, max_length=25),
for_id: Optional[str] = Path(min_length=11, max_length=22),
limit: int = Query(1, ge=1),
commons: Common_Route_Params = Depends(common_route_params),
):
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
log.debug(locals())
log.info('Using path parameters')
# ### SECTION ### Call generic function to get the data_store object
return await handle_get_data_store_obj_w_code(
data_store_code = data_store_code,
for_type = for_type,
for_id = for_id,
commons = commons,
limit = limit,
)
# @router.get('/data_store/code/{data_store_code}/{for_type}/{for_id}', response_model=Resp_Body_Base)
# async def get_data_store_obj_w_code_path(
# data_store_code: str = Path(min_length=3, max_length=50),
# for_type: Optional[str] = Path(min_length=1, max_length=25),
# for_id: Optional[str] = Path(min_length=11, max_length=22),
# limit: int = Query(1, ge=1),
#
# commons: Common_Route_Params = Depends(common_route_params),
# ):
# log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
# log.debug(locals())
# log.info('Using path parameters')
# # ### SECTION ### Call generic function to get the data_store object
# return await handle_get_data_store_obj_w_code(
# data_store_code = data_store_code,
# for_type = for_type,
# for_id = for_id,
# commons = commons,
# limit = limit,
# )
@router.get('/data_store/code/{data_store_code}', response_model=Resp_Body_Base)
async def get_data_store_obj_w_code_query(
data_store_code: str = Path(min_length=3, max_length=50),
for_type: Optional[str] = Query(None, min_length=1, max_length=25),
for_id: Optional[str] = Query(None, min_length=11, max_length=22),
limit: int = Query(1, ge=1),
commons: Common_Route_Params = Depends(common_route_params),
):
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
log.debug(locals())
log.info('Using query parameters')
# ### SECTION ### Call generic function to get the data_store object
return await handle_get_data_store_obj_w_code(
data_store_code = data_store_code,
for_type = for_type,
for_id = for_id,
commons = commons,
limit = limit,
)
# @router.get('/data_store/code/{data_store_code}', response_model=Resp_Body_Base)
# async def get_data_store_obj_w_code_query(
# data_store_code: str = Path(min_length=3, max_length=50),
# for_type: Optional[str] = Query(None, min_length=1, max_length=25),
# for_id: Optional[str] = Query(None, min_length=11, max_length=22),
# limit: int = Query(1, ge=1),
#
# commons: Common_Route_Params = Depends(common_route_params),
# ):
# log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
# log.debug(locals())
# log.info('Using query parameters')
# # ### SECTION ### Call generic function to get the data_store object
# return await handle_get_data_store_obj_w_code(
# data_store_code = data_store_code,
# for_type = for_type,
# for_id = for_id,
# commons = commons,
# limit = limit,
# )
# ### END ### API Data Store ### get_data_store_obj_w_code() ###
# TODO: Migrate to a dedicated api_v3_actions_data_store.py router and rename path to
# /v3/action/data_store/code/{data_store_code}/search to match the V3 action naming convention.
# Requires a coordinated frontend update before the path rename can happen.
@router.post('/v3/data_store/code/{data_store_code}/search', response_model=Resp_Body_Base, tags=['Data Store V3'])
async def search_v3_data_store_obj_w_code(
data_store_code: str,
@@ -256,18 +265,18 @@ async def search_v3_data_store_obj_w_code(
# 2. Construct the hierarchical search SQL
# We must enforce that users only see their own account records OR global defaults (account_id IS NULL)
from app.db_sql import sql_enable_part, sql_hidden_part, sql_search_qry_part, sql_limit_offset_part
sql_enabled, data_enabled = sql_enable_part('data_store', status_filter.enabled)
sql_hidden, data_hidden = sql_hidden_part('data_store', status_filter.hidden)
# Generate search logic from the SearchQuery model
search_sql, search_data = sql_search_qry_part(
search_query=search_query,
search_query=search_query,
table_name='v_data_store'
)
sql_limit = sql_limit_offset_part(limit=pagination.limit, offset=pagination.offset)
# Prepare parameter dictionary
data = {
'code': data_store_code,
@@ -342,11 +351,11 @@ async def handle_get_data_store_obj_w_code(
):
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
log.info(f'Loading successful. Returning {len(data_store_obj_result)} result(s)')
# If limit=1, return the first object directly (standard lookup behavior)
# If limit > 1, return the list of results
data = data_store_obj_result[0] if limit == 1 else data_store_obj_result
log.debug(data)
return mk_resp(data=data, response=commons.response)
elif isinstance(data_store_obj_result, list) or data_store_obj_result is None: # Empty list or None
@@ -359,43 +368,44 @@ async def handle_get_data_store_obj_w_code(
# ### BEGIN ### API Data Store ### get_account_obj_data_store_list() ###
# LEGACY (disabled) - superseded by V3 CRUD search: POST /v3/crud/data_store/search
# Updated 2022-03-11
@router.get('/account/{account_id}/data_store/list', response_model=Resp_Body_Base)
async def get_account_obj_data_store_list(
account_id: str = Path(min_length=11, max_length=22),
commons: Common_Route_Params = Depends(common_route_params),
):
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
log.debug(locals())
if account_id := redis_lookup_id_random(record_id_random=account_id, table_name='account'): pass
else: return mk_resp(data=None, status_code=404, response=commons.response)
# Updated 2022-03-11
if data_store_rec_list_result := get_data_store_rec_list(
account_id = account_id,
for_type = 'account',
for_id = account_id,
enabled = commons.enabled,
limit = commons.limit,
offset = commons.offset,
):
data_store_result_list = []
for data_store_rec in data_store_rec_list_result:
if load_data_store_result := load_data_store_obj(
data_store_id = data_store_rec.get('data_store_id', None),
enabled = commons.enabled,
):
data_store_result_list.append(load_data_store_result)
else:
data_store_result_list.append(None)
response_data = data_store_result_list
return mk_resp(data=response_data, response=commons.response)
elif isinstance(data_store_rec_list_result, list) or data_store_rec_list_result is None: # Empty list or None
log.info('No results')
return mk_resp(data=None, status_code=404, response=commons.response) # Not Found
else:
log.warning('Likely bad request')
return mk_resp(data=False, status_code=400, response=commons.response) # Bad Request
# ### END ### API Data Store ### get_account_obj_data_store_list() ###
# @router.get('/account/{account_id}/data_store/list', response_model=Resp_Body_Base)
# async def get_account_obj_data_store_list(
# account_id: str = Path(min_length=11, max_length=22),
#
# commons: Common_Route_Params = Depends(common_route_params),
# ):
# log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
# log.debug(locals())
#
# if account_id := redis_lookup_id_random(record_id_random=account_id, table_name='account'): pass
# else: return mk_resp(data=None, status_code=404, response=commons.response)
#
# # Updated 2022-03-11
# if data_store_rec_list_result := get_data_store_rec_list(
# account_id = account_id,
# for_type = 'account',
# for_id = account_id,
# enabled = commons.enabled,
# limit = commons.limit,
# offset = commons.offset,
# ):
# data_store_result_list = []
# for data_store_rec in data_store_rec_list_result:
# if load_data_store_result := load_data_store_obj(
# data_store_id = data_store_rec.get('data_store_id', None),
# enabled = commons.enabled,
# ):
# data_store_result_list.append(load_data_store_result)
# else:
# data_store_result_list.append(None)
# response_data = data_store_result_list
# return mk_resp(data=response_data, response=commons.response)
# elif isinstance(data_store_rec_list_result, list) or data_store_rec_list_result is None: # Empty list or None
# log.info('No results')
# return mk_resp(data=None, status_code=404, response=commons.response) # Not Found
# else:
# log.warning('Likely bad request')
# return mk_resp(data=False, status_code=400, response=commons.response) # Bad Request
# ### END ### API Data Store ### get_account_obj_data_store_list() ###

View File

@@ -1,16 +1,13 @@
from fastapi import FastAPI, Depends
from app.routers.dependencies_v3 import DeprecationParams
from app.routers import (
ae_obj, aether_cfg, api_crud, api_crud_v2, api_crud_v3, api, health, importing, sql,
account, contact, data_store,
event, event_badge, event_badge_importing, event_badge_template,
event_device, event_exhibit, event_exhibit_tracking, event_file, event_importing,
event_location, event_person,
event_presentation, event_presenter, event_session,
flask_cfg, hosted_file, api_v3_actions_hosted_file, api_v3_actions_event_file, api_v3_actions_event_exhibit, api_v3_actions_e_zoom, api_v3_actions_e_novi_mailman, lookup, lookup_v3,
organization, page, person,
person_user, qr, site, site_domain, user,
util_email, websockets, websockets_redis, websockets_v3, e_confex, e_cvent, e_impexium, e_stripe
ae_obj, aether_cfg, api_crud_v3, api, health, importing,
data_store,
event_badge_importing,
event_importing,
api_v3_actions_hosted_file, api_v3_actions_event_file, api_v3_actions_event_exhibit, api_v3_actions_e_zoom, api_v3_actions_e_novi_mailman, api_v3_actions_user, lookup_v3,
user,
util_email, websockets_v3, e_confex, e_cvent, e_impexium, e_stripe
)
def setup_routers(app: FastAPI):
@@ -21,13 +18,13 @@ def setup_routers(app: FastAPI):
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(DeprecationParams)])
app.include_router(api_crud_v2.router, prefix='/v2/crud', tags=['CRUD v2.5'], dependencies=[Depends(DeprecationParams)])
# app.include_router(api_crud_v2.router, prefix='/v2/crud', tags=['CRUD v2.5'], dependencies=[Depends(DeprecationParams)])
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'], dependencies=[Depends(DeprecationParams)])
app.include_router(importing.router, prefix='/importing', tags=['Importing'])
app.include_router(sql.router, tags=['SQL'])
app.include_router(importing.router, prefix='/importing', tags=['Importing'], dependencies=[Depends(DeprecationParams)])
# app.include_router(sql.router, tags=['SQL']) # LEGACY (disabled) - raw SQL select endpoint, testing only
# app.include_router(account.router, tags=['Account'], dependencies=[Depends(DeprecationParams)])
app.include_router(data_store.router, tags=['Data Store'])
@@ -38,8 +35,8 @@ def setup_routers(app: FastAPI):
# app.include_router(event_device.router, tags=['Event Device'], dependencies=[Depends(DeprecationParams)])
# app.include_router(event_exhibit.router, tags=['Event Exhibit'], dependencies=[Depends(DeprecationParams)])
app.include_router(event_exhibit_tracking.router, tags=['Event Exhibit Tracking'])
app.include_router(event_file.router, tags=['Event File'])
# app.include_router(event_exhibit_tracking.router, tags=['Event Exhibit Tracking'])
# app.include_router(event_file.router, tags=['Event File'])
app.include_router(event_importing.router, tags=['Event Importing'])
# app.include_router(event_location.router, tags=['Event Location'], dependencies=[Depends(DeprecationParams)])
@@ -47,13 +44,14 @@ def setup_routers(app: FastAPI):
# app.include_router(event_presenter.router, prefix='/event/presenter', tags=['Event Presenter'], dependencies=[Depends(DeprecationParams)])
# app.include_router(event_session.router, tags=['Event Session'], dependencies=[Depends(DeprecationParams)])
app.include_router(hosted_file.router, prefix='/hosted_file', tags=['Hosted File'])
# app.include_router(hosted_file.router, prefix='/hosted_file', tags=['Hosted File'])
app.include_router(api_v3_actions_hosted_file.router, prefix='/v3/action/hosted_file', tags=['Hosted File (V3 Actions)'])
app.include_router(api_v3_actions_event_file.router, prefix='/v3/action/event_file', tags=['Event File (V3 Actions)'])
app.include_router(api_v3_actions_event_exhibit.router, prefix='/v3/action/event_exhibit', tags=['Event Exhibit (V3 Actions)'])
app.include_router(api_v3_actions_e_zoom.router, prefix='/v3/action/e_zoom', tags=['Zoom Events (V3 Actions)'])
app.include_router(api_v3_actions_e_novi_mailman.router, prefix='/v3/action/e_novi_mailman', tags=['Novi-Mailman Bridge (V3 Actions)'])
app.include_router(lookup.router, prefix='/lu', tags=['Lookup'])
app.include_router(api_v3_actions_user.router, prefix='/v3/action/user', tags=['User (V3 Actions)'])
# app.include_router(lookup.router, prefix='/lu', tags=['Lookup']) # LEGACY (disabled) - superseded by /v3/lookup
app.include_router(lookup_v3.router, prefix='/v3/lookup', tags=['Lookup V3'])
# app.include_router(organization.router, prefix='/organization', tags=['Organization'], dependencies=[Depends(DeprecationParams)])
@@ -64,10 +62,10 @@ def setup_routers(app: FastAPI):
# app.include_router(qr.router, tags=['QR'], dependencies=[Depends(DeprecationParams)])
# app.include_router(site.router, tags=['Site'], dependencies=[Depends(DeprecationParams)])
# app.include_router(site_domain.router, tags=['Site Domain'], dependencies=[Depends(DeprecationParams)])
app.include_router(user.router, tags=['User'])
app.include_router(user.router, tags=['User'], dependencies=[Depends(DeprecationParams)])
app.include_router(util_email.router, tags=['Utility: Email'])
app.include_router(websockets.router, tags=['Websockets'])
app.include_router(websockets_redis.router, tags=['Websockets (Redis)'])
# app.include_router(websockets.router, tags=['Websockets']) # LEGACY (disabled) - superseded by Websockets V3
# app.include_router(websockets_redis.router, tags=['Websockets (Redis)']) # LEGACY (disabled) - superseded by Websockets V3
app.include_router(websockets_v3.router, prefix='/v3', tags=['Websockets V3'])
app.include_router(e_confex.router, prefix='/e/confex', tags=['External Service: Confex'])

View File

@@ -20,65 +20,67 @@ from app.models.user_models import User_Base, User_New_Base, User_Out_Base
router = APIRouter()
@router.post('/user', response_model=Resp_Body_Base)
async def post_user_obj(
obj: User_Base,
return_obj: Optional[bool] = True,
commons: Common_Route_Params = Depends(common_route_params),
):
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
log.debug(locals())
# @router.post('/user', response_model=Resp_Body_Base)
# async def post_user_obj(
# obj: User_Base,
# return_obj: Optional[bool] = True,
# commons: Common_Route_Params = Depends(common_route_params),
# ):
# log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
# log.debug(locals())
obj_type = 'user'
obj_data_dict = obj.dict(by_alias=False, exclude_unset=True)
result = post_obj_template(
obj_type = obj_type,
data = obj_data_dict,
return_obj = True,
by_alias = True,
exclude_unset = True,
)
return result
# obj_type = 'user'
# obj_data_dict = obj.dict(by_alias=False, exclude_unset=True)
# result = post_obj_template(
# obj_type = obj_type,
# data = obj_data_dict,
# return_obj = True,
# by_alias = True,
# exclude_unset = True,
# )
# return result
# ### BEGIN ### API User ### post_user_obj_new() ###
# Updated 2021-08-21 (complete re-write)
@router.post('/user/new', response_model=Resp_Body_Base)
async def post_user_obj_new(
user_obj: User_New_Base,
allow_update: bool = False,
avoid_dup_username: bool = False,
commons: Common_Route_Params = Depends(common_route_params),
):
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
log.debug(locals())
# # ### BEGIN ### API User ### post_user_obj_new() ###
# # Updated 2021-08-21 (complete re-write)
# @router.post('/user/new', response_model=Resp_Body_Base)
# async def post_user_obj_new(
# user_obj: User_New_Base,
# allow_update: bool = False,
# avoid_dup_username: bool = False,
# commons: Common_Route_Params = Depends(common_route_params),
# ):
# log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
# log.debug(locals())
if account_id_random := user_obj.account_id_random: pass
else: return False
# if account_id_random := user_obj.account_id_random: pass
# else: return False
if create_user_obj_result := create_user_obj(account_id=account_id_random, user_dict_obj=user_obj, allow_update=allow_update, avoid_dup_username=avoid_dup_username): pass
else: return mk_resp(data=False, status_code=400, response=commons.response, status_message='The user account was not created. This is likely because that username already exists for this account.')
# if create_user_obj_result := create_user_obj(account_id=account_id_random, user_dict_obj=user_obj, allow_update=allow_update, avoid_dup_username=avoid_dup_username): pass
# else: return mk_resp(data=False, status_code=400, response=commons.response, status_message='The user account was not created. This is likely because that username already exists for this account.')
if isinstance(create_user_obj_result, int):
user_id = create_user_obj_result
if return_obj:
if load_user_obj_result := load_user_obj(user_id=user_id):
data = load_user_obj_result
else:
data = False
else:
user_id = create_user_obj_result
user_id_random = get_id_random(record_id=user_id, table_name='user')
data = {}
data['user_id'] = user_id
data['user_id_random'] = user_id_random
return mk_resp(data=data, response=commons.response, status_message='The user account was created.')
else:
return mk_resp(data=False, status_code=400, response=commons.response, status_message='The result from trying to create a user account was unexpected.')
# ### END ### API User ### post_user_obj_new() ###
# if isinstance(create_user_obj_result, int):
# user_id = create_user_obj_result
# if return_obj:
# if load_user_obj_result := load_user_obj(user_id=user_id):
# data = load_user_obj_result
# else:
# data = False
# else:
# user_id = create_user_obj_result
# user_id_random = get_id_random(record_id=user_id, table_name='user')
# data = {}
# data['user_id'] = user_id
# data['user_id_random'] = user_id_random
# return mk_resp(data=data, response=commons.response, status_message='The user account was created.')
# else:
# return mk_resp(data=False, status_code=400, response=commons.response, status_message='The result from trying to create a user account was unexpected.')
# # ### END ### API User ### post_user_obj_new() ###
# ### BEGIN ### API User ### user_obj_change_password() ###
# NOTE: This is actively in use 2026-03-24 -Scott
# This is marked for deprecation and must be migrated to Aether API v3 standards!
@router.patch('/user/{user_id}/change_password', response_model=Resp_Body_Base)
async def user_obj_change_password(
user_id: Union[int,str],
@@ -143,35 +145,37 @@ async def user_obj_change_password(
# ### END ### API User ### user_obj_change_password() ###
@router.patch('/user/{obj_id}', response_model=Resp_Body_Base)
async def patch_user_obj(
obj: User_Base,
obj_id: str = Path(min_length=11, max_length=22),
return_obj: Optional[bool] = True,
commons: Common_Route_Params = Depends(common_route_params),
):
log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
log.debug(locals())
# @router.patch('/user/{obj_id}', response_model=Resp_Body_Base)
# async def patch_user_obj(
# obj: User_Base,
# obj_id: str = Path(min_length=11, max_length=22),
# return_obj: Optional[bool] = True,
# commons: Common_Route_Params = Depends(common_route_params),
# ):
# log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
# log.debug(locals())
obj_type = 'user'
obj_data_dict = obj.dict(by_alias=False, exclude_unset=True)
obj_data_dict['id'] = redis_lookup_id_random(record_id_random=obj_id, table_name=obj_type)
obj_data_dict['id_random'] = obj_id
result = patch_obj_template(
obj_type=obj_type,
data=obj_data_dict,
obj_id=obj_id,
return_obj=True,
by_alias=True,
exclude_unset=True,
)
return result
# obj_type = 'user'
# obj_data_dict = obj.dict(by_alias=False, exclude_unset=True)
# obj_data_dict['id'] = redis_lookup_id_random(record_id_random=obj_id, table_name=obj_type)
# obj_data_dict['id_random'] = obj_id
# result = patch_obj_template(
# obj_type=obj_type,
# data=obj_data_dict,
# obj_id=obj_id,
# return_obj=True,
# by_alias=True,
# exclude_unset=True,
# )
# return result
# ### BEGIN ### API User Routers ### user_new_auth_key() ###
# Generate a new one time use authorization key for login without password
# Updated 2022-01-07
# @router.get('/user/new_auth_key', response_model=Resp_Body_Base)
# NOTE: This may be actively in use 2026-03-24
# This is marked for deprecation and must be migrated to Aether API v3 standards!
@router.get('/user/{user_id}/new_auth_key', response_model=Resp_Body_Base)
async def user_new_auth_key(
user_id: str = Path(min_length=11, max_length=22),
@@ -218,6 +222,8 @@ async def user_new_auth_key(
# A new key will need to be requested for a particular user each time.
# NOTE: Should this be divided into username/password and user ID/auth key endpoints? Probably vote 2x
# Updated 2021-10-06
# NOTE: This is actively in use 2026-03-24 -Scott
# This is marked for deprecation and must be migrated to Aether API v3 standards!
@router.get('/user/authenticate', response_model=Resp_Body_Base)
async def user_authenticate(
null_account_id: bool = False,
@@ -394,6 +400,8 @@ async def user_authenticate(
# ### BEGIN ### API User ### user_verify_password() ###
# NOTE: This may be actively in use 2026-03-24
# This is marked for deprecation and must be migrated to Aether API v3 standards!
# @router.post('/{user_id}/verify_password', response_model=Resp_Body_Base)
@router.post('/user/verify_password', response_model=Resp_Body_Base)
async def user_verify_password(
@@ -410,14 +418,14 @@ async def user_verify_password(
account_id = commons.x_account_id
log.debug(user_obj)
log.debug(user_obj.id_random)
log.debug(user_obj.id)
log.debug(user_obj.current_password)
log.debug(user_obj.username)
if current_password := user_obj.current_password: pass
else: return mk_resp(data=False, status_code=400, status_message='The current password to verify is required.', response=commons.response) # Bad Request
if user_id_random := user_obj.id_random: # Use id_random instead of user_id_random when getting from User model.
if user_id_random := user_obj.id: # Vision ID: User_Base uses 'id' (not 'id_random') for the random string.
log.info(f'Using the user ID to look up the user. User ID: {user_id_random}')
# NOTE: Not doing a redis lookup since we have to look up the record again. Redis lookup may save or add an insignificant amount of time.
user_data = {}
@@ -487,82 +495,84 @@ async def user_verify_password(
# ### END ### API User ### user_verify_password() ###
# ### BEGIN ### API User ### get_account_user_obj_li() ###
# Updated 2021-12-13
@router.get('/account/{account_id}/user/list', response_model=Resp_Body_Base)
async def get_account_user_obj_li(
account_id: str = Path(min_length=11, max_length=22),
hidden: str = 'not_hidden', # hidden, not_hidden, all
inc_address: bool = False, # Priority l1
inc_contact: bool = False, # Priority l1
inc_person: bool = False, # Priority l1
inc_user_role_list: bool = False, # Priority l1
commons: Common_Route_Params = Depends(common_route_params),
):
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
log.debug(locals())
# # ### BEGIN ### API User ### get_account_user_obj_li() ###
# # Updated 2021-12-13
# @router.get('/account/{account_id}/user/list', response_model=Resp_Body_Base)
# async def get_account_user_obj_li(
# account_id: str = Path(min_length=11, max_length=22),
# hidden: str = 'not_hidden', # hidden, not_hidden, all
# inc_address: bool = False, # Priority l1
# inc_contact: bool = False, # Priority l1
# inc_person: bool = False, # Priority l1
# inc_user_role_list: bool = False, # Priority l1
# commons: Common_Route_Params = Depends(common_route_params),
# ):
# log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
# log.debug(locals())
if account_id := redis_lookup_id_random(record_id_random=account_id, table_name='account'): pass
else: return mk_resp(data=None, status_code=404, response=commons.response)
# if account_id := redis_lookup_id_random(record_id_random=account_id, table_name='account'): pass
# else: return mk_resp(data=None, status_code=404, response=commons.response)
# Updated 2021-12-13
if user_rec_list_result := get_user_rec_list(
account_id = account_id,
hidden = hidden, # hidden, not_hidden, all
enabled = commons.enabled,
limit = commons.limit,
):
user_result_list = []
for user_rec in user_rec_list_result:
if load_user_result := load_user_obj(
user_id = user_rec.get('user_id', None),
enabled = commons.enabled,
# hidden = hidden,
limit = commons.limit,
inc_address = inc_address,
inc_contact = inc_contact,
inc_person = inc_person,
inc_user_role_list = inc_user_role_list,
by_alias = commons.by_alias,
exclude_unset = commons.exclude_unset,
# model_as_dict = model_as_dict,
):
user_result_list.append(load_user_result)
else:
user_result_list.append(None)
response_data = user_result_list
elif isinstance(user_rec_list_result, list) or user_rec_list_result is None: # Empty list or None
log.info('No results')
return mk_resp(data=False, status_code=404, response=commons.response) # Not Found
else:
log.warning('Likely bad request')
return mk_resp(data=False, status_code=400, response=commons.response) # Bad Request
# # Updated 2021-12-13
# if user_rec_list_result := get_user_rec_list(
# account_id = account_id,
# hidden = hidden, # hidden, not_hidden, all
# enabled = commons.enabled,
# limit = commons.limit,
# ):
# user_result_list = []
# for user_rec in user_rec_list_result:
# if load_user_result := load_user_obj(
# user_id = user_rec.get('user_id', None),
# enabled = commons.enabled,
# # hidden = hidden,
# limit = commons.limit,
# inc_address = inc_address,
# inc_contact = inc_contact,
# inc_person = inc_person,
# inc_user_role_list = inc_user_role_list,
# by_alias = commons.by_alias,
# exclude_unset = commons.exclude_unset,
# # model_as_dict = model_as_dict,
# ):
# user_result_list.append(load_user_result)
# else:
# user_result_list.append(None)
# response_data = user_result_list
# elif isinstance(user_rec_list_result, list) or user_rec_list_result is None: # Empty list or None
# log.info('No results')
# return mk_resp(data=False, status_code=404, response=commons.response) # Not Found
# else:
# log.warning('Likely bad request')
# return mk_resp(data=False, status_code=400, response=commons.response) # Bad Request
return mk_resp(data=response_data, response=commons.response)
# ### END ### API User ### get_account_user_obj_li() ###
# return mk_resp(data=response_data, response=commons.response)
# # ### END ### API User ### get_account_user_obj_li() ###
@router.get('/user/list', response_model=Resp_Body_Base)
async def get_user_obj_li(
for_obj_type: Optional[str] = Query(None, min_length=2, max_length=50),
for_obj_id: Optional[str] = Query(None, min_length=1, max_length=22),
commons: Common_Route_Params = Depends(common_route_params),
):
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
log.debug(locals())
# @router.get('/user/list', response_model=Resp_Body_Base)
# async def get_user_obj_li(
# for_obj_type: Optional[str] = Query(None, min_length=2, max_length=50),
# for_obj_id: Optional[str] = Query(None, min_length=1, max_length=22),
# commons: Common_Route_Params = Depends(common_route_params),
# ):
# log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
# log.debug(locals())
obj_type = 'user'
result = get_obj_li_template(
obj_type=obj_type,
for_obj_type=for_obj_type,
for_obj_id=for_obj_id,
by_alias=True,
exclude_unset=True,
)
return result
# obj_type = 'user'
# result = get_obj_li_template(
# obj_type=obj_type,
# for_obj_type=for_obj_type,
# for_obj_id=for_obj_id,
# by_alias=True,
# exclude_unset=True,
# )
# return result
# Look up is only for account or person records
# NOTE: This may be actively in use 2026-03-24
# This is marked for deprecation and must be migrated to Aether API v3 standards!
@router.get('/user/lookup', response_model=Resp_Body_Base)
async def lookup_user_obj(
for_obj_id: Union[int,str],
@@ -638,6 +648,8 @@ async def lookup_user_obj(
# Look up a user with an email address for an account
# NOTE: This is actively in use 2026-03-24 -Scott
# This is marked for deprecation and must be migrated to Aether API v3 standards!
@router.get('/user/lookup_email', response_model=Resp_Body_Base)
async def lookup_email(
email: str = Query(..., min_length=2, max_length=50),
@@ -728,6 +740,8 @@ async def lookup_email(
# Look up is only for account or person records
# Look up a user with a username for an account
# NOTE: This may be actively in use 2026-03-24
# This is marked for deprecation and must be migrated to Aether API v3 standards!
@router.get('/user/lookup_username', response_model=Resp_Body_Base)
async def lookup_username(
username: str = Query(..., min_length=2, max_length=50),
@@ -799,6 +813,8 @@ async def lookup_username(
# This requires the user_id and root_url or base_url.
# This endpoint will generate a new user auth_key and send the email to the user's email address.
# Updated 2025-04-08
# NOTE: This is actively in use 2026-03-24 -Scott
# This is marked for deprecation and must be migrated to Aether API v3 standards!
# @router.get('/user/email_auth_key_url', response_model=Resp_Body_Base)
@router.get('/user/{user_id}/email_auth_key_url', response_model=Resp_Body_Base)
async def email_auth_key_url(
@@ -830,69 +846,69 @@ async def email_auth_key_url(
# ### END ### API User ### email_auth_key_url() ###
# ### BEGIN ### API User ### get_user_obj() ###
# Updated 2022-01-05
@router.get('/user/{user_id}', response_model=Resp_Body_Base)
async def get_user_obj(
user_id: str = Path(min_length=11, max_length=22),
inc_address: bool = False, # Priority l1
# inc_archive_list: bool = False, # Priority l3
inc_contact: bool = False, # Priority l1
inc_event_list: bool = False, # Priority l1
# inc_hosted_file_list: bool = False, # Priority l3
inc_journal_list: bool = False, # Priority l2
# inc_journal_entry_list: bool = False, # Priority l3
inc_membership_person: bool = False, # Priority l2
# inc_membership_list: bool = False, # ???
inc_order_line_list: bool = False, # Priority l1
inc_order_list: bool = False, # Priority l1
inc_order_cart_list: bool = False, # Priority l1
inc_organization: bool = False, # Priority l1
# inc_organization_list: bool = False,
inc_person: bool = False, # Priority l1
# inc_person_list: bool = False,
inc_post_list: bool = False, # Priority l2
inc_post_comment_list: bool = False, # Priority l3
inc_user_role_list: bool = False, # Priority l1
commons: Common_Route_Params = Depends(common_route_params),
):
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
log.debug(locals())
# # ### BEGIN ### API User ### get_user_obj() ###
# # Updated 2022-01-05
# @router.get('/user/{user_id}', response_model=Resp_Body_Base)
# async def get_user_obj(
# user_id: str = Path(min_length=11, max_length=22),
# inc_address: bool = False, # Priority l1
# # inc_archive_list: bool = False, # Priority l3
# inc_contact: bool = False, # Priority l1
# inc_event_list: bool = False, # Priority l1
# # inc_hosted_file_list: bool = False, # Priority l3
# inc_journal_list: bool = False, # Priority l2
# # inc_journal_entry_list: bool = False, # Priority l3
# inc_membership_person: bool = False, # Priority l2
# # inc_membership_list: bool = False, # ???
# inc_order_line_list: bool = False, # Priority l1
# inc_order_list: bool = False, # Priority l1
# inc_order_cart_list: bool = False, # Priority l1
# inc_organization: bool = False, # Priority l1
# # inc_organization_list: bool = False,
# inc_person: bool = False, # Priority l1
# # inc_person_list: bool = False,
# inc_post_list: bool = False, # Priority l2
# inc_post_comment_list: bool = False, # Priority l3
# inc_user_role_list: bool = False, # Priority l1
# commons: Common_Route_Params = Depends(common_route_params),
# ):
# log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
# log.debug(locals())
if user_id := redis_lookup_id_random(record_id_random=user_id, table_name='user'): pass
else: return mk_resp(data=None, status_code=404, response=commons.response)
# if user_id := redis_lookup_id_random(record_id_random=user_id, table_name='user'): pass
# else: return mk_resp(data=None, status_code=404, response=commons.response)
if user_result := load_user_obj(
user_id = user_id,
limit = commons.limit,
model_as_dict = True, # NOTE: returning model as a dict
enabled = commons.enabled,
inc_address = inc_address,
# inc_archive_list = inc_archive_list,
inc_contact = inc_contact,
inc_event_list = inc_event_list,
# inc_hosted_file_list = inc_hosted_file_list,
# inc_journal_list = inc_journal_list,
# inc_journal_entry_list = inc_journal_entry_list,
# inc_membership_person = inc_membership_person,
# inc_membership_list = inc_membership_list, # ???
inc_order_line_list = inc_order_line_list,
inc_order_list = inc_order_list,
inc_order_cart_list = inc_order_cart_list,
# inc_organization = inc_organization,
# inc_organization_list = inc_organization_list,
inc_person = inc_person,
# inc_person_list = inc_person_list,
# inc_post_list = inc_post_list,
# inc_post_comment_list = inc_post_comment_list,
inc_user_role_list = inc_user_role_list,
):
response_data = user_result
else:
return mk_resp(data=False, status_code=400, response=commons.response) # Bad Request
# if user_result := load_user_obj(
# user_id = user_id,
# limit = commons.limit,
# model_as_dict = True, # NOTE: returning model as a dict
# enabled = commons.enabled,
# inc_address = inc_address,
# # inc_archive_list = inc_archive_list,
# inc_contact = inc_contact,
# inc_event_list = inc_event_list,
# # inc_hosted_file_list = inc_hosted_file_list,
# # inc_journal_list = inc_journal_list,
# # inc_journal_entry_list = inc_journal_entry_list,
# # inc_membership_person = inc_membership_person,
# # inc_membership_list = inc_membership_list, # ???
# inc_order_line_list = inc_order_line_list,
# inc_order_list = inc_order_list,
# inc_order_cart_list = inc_order_cart_list,
# # inc_organization = inc_organization,
# # inc_organization_list = inc_organization_list,
# inc_person = inc_person,
# # inc_person_list = inc_person_list,
# # inc_post_list = inc_post_list,
# # inc_post_comment_list = inc_post_comment_list,
# inc_user_role_list = inc_user_role_list,
# ):
# response_data = user_result
# else:
# return mk_resp(data=False, status_code=400, response=commons.response) # Bad Request
return mk_resp(data=response_data, response=commons.response)
# ### END ### API User ### get_user_obj() ###
# return mk_resp(data=response_data, response=commons.response)
# # ### END ### API User ### get_user_obj() ###
# # ### BEGIN ### API User ### get_user_obj_order_list() ###
@@ -962,17 +978,17 @@ async def get_user_obj(
# # ### END ### API User ### get_user_obj_order_list() ###
@router.delete('/user/{obj_id}', response_model=Resp_Body_Base)
async def delete_user_obj(
obj_id: str = Path(min_length=11, max_length=22),
commons: Common_Route_Params = Depends(common_route_params),
):
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
log.debug(locals())
# @router.delete('/user/{obj_id}', response_model=Resp_Body_Base)
# async def delete_user_obj(
# obj_id: str = Path(min_length=11, max_length=22),
# commons: Common_Route_Params = Depends(common_route_params),
# ):
# log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
# log.debug(locals())
obj_type = 'user'
result = delete_obj_template(
obj_type=obj_type,
obj_id=obj_id,
)
return result
# obj_type = 'user'
# result = delete_obj_template(
# obj_type=obj_type,
# obj_id=obj_id,
# )
# return result

View File

@@ -20,6 +20,8 @@ router = APIRouter()
# ### BEGIN ### API Utility: Email ### util_email_send_obj() ###
# Updated 2023-06-27
# NOTE: This is actively in use 2026-03-24 -Scott
# This is marked for deprecation and must be migrated to Aether API v3 standards!
@router.post('/util/email/send', response_model=Resp_Body_Base)
async def util_email_send_obj(
email_send_obj: Email_Send_Base,

View File

@@ -68,39 +68,145 @@ Modify data in the system.
* **Header:** `x-ae-ignore-extra-fields: true`
* **Behavior:** When set to `true`, the backend will automatically strip any fields from the payload that are not defined in the object's model before attempting to save to the database.
### D. ID Fields in Responses (Vision ID Convention)
> [!IMPORTANT]
> **V3 responses always use random string IDs — never database integers.**
All V3 responses — `POST` create, `GET` single, `GET` list, search, and `PATCH` update — contain:
| Field | Type | Use |
| :--- | :--- | :--- |
| `{obj_type}_id` | `string` | **Primary public ID.** Use this for subsequent `PATCH` calls and UI routing. |
| `{obj_type}_id_random` | `string` | Legacy alias. Same value as `{obj_type}_id`. Present for backward compat only. |
**Example — create then immediately PATCH:**
```ts
const created = await postArchiveContent(archiveId, payload);
const newId = created.data.archive_content_id; // random string e.g. "xK9mP3qRtL2"
// Use it directly in the PATCH URL — no lookup needed
await patchArchiveContent(newId, { name: 'Updated Name' });
// PATCH /v3/crud/archive/{archive_id}/archive_content/{newId}
```
> **Note on `_id_random` suffix:** The `{obj_type}_id_random` field is a legacy artifact from the pre-Vision model. Once you confirm `{obj_type}_id` is a random string (length 1122), you do not need `_id_random` as a fallback. New code should only read `{obj_type}_id`.
---
## 4. V3 Uniform Lookup System
The V3 Lookup system provides a hierarchical, deduplicated interface for standardized tables (Countries, Timezones, etc.). It supports global defaults, account overrides, and site-specific whitelisting.
The V3 Lookup system provides a hierarchical, deduplicated interface for standardized reference tables (Countries, Timezones, etc.). It supports global defaults, account-level overrides, and object-level overrides, with optional site-specific whitelisting.
### How the hierarchy works
Each lookup table (`lu_v3_country`, `lu_v3_time_zone`, etc.) can hold multiple rows for the same logical item at different scopes:
| Scope | `account_id` | `for_type` / `for_id` | Wins over |
|---|---|---|---|
| Global default | `NULL` | `NULL` / `NULL` | nothing |
| Account override | set | `NULL` / `NULL` | Global default |
| Object override | set | set | Account override + Global default |
The API uses `ROW_NUMBER() PARTITION BY group` to collapse all rows for the same item down to the single highest-priority winner before returning results. **`group` is the identity key** — it is what makes two rows "the same item competing for priority."
> [!IMPORTANT]
> **The `group` field is not a display label.** It is the deduplication key. Each lookup type uses a different natural key for `group`:
>
> | Lookup type | `group` value | Example |
> |---|---|---|
> | `country` | ISO alpha-2 code | `"US"`, `"CA"`, `"GB"` |
> | `country_subdivision` | subdivision code | `"US-NY"`, `"CA-ON"` |
> | `time_zone` | IANA timezone name | `"America/New_York"`, `"US/Eastern"` |
>
> For `time_zone`, `group` and `name` must always be identical — there is no concept of "override all US timezones as a group." Each timezone is its own identity.
### A. List Lookups
Retrieve a ranked and filtered list of lookup items.
Retrieve the deduplicated, ranked list for a lookup type.
* **Endpoint:** `GET /v3/lookup/{lu_type}/list`
* **Available Types:** `country`, `country_subdivision`, `time_zone`
* **Parameters:**
* `site_id` (Optional): Random ID of the site to apply a **Whitelist Policy**.
* `only_priority` (Optional): Set to `true` to return only high-priority items (e.g., common time zones).
* `for_type` / `for_id` (Optional): Context for object-specific overrides.
* `include_disabled` (Optional): Set to `true` to see shadowed/disabled records.
* `site_id` (Optional): Random ID of the site applies a **Whitelist Policy** (see §C).
* `only_priority` (Optional): `true` returns only `priority=1` items (e.g., common time zones).
* `for_type` / `for_id` (Optional): Object context — activates object-level override matching.
* `include_disabled` (Optional): `true` includes shadowed/disabled records (useful for admin views).
**Frontend keying:** Always key Svelte `{#each}` blocks on `group`, not `id` or `name`. `group` is guaranteed unique in the response. Keying on `id` will break if an account override wins (different `id`, same logical item).
### B. Resolve Identity
Resolves a string (code, group, or name) to a single record.
Resolves a string to a single lookup record.
* **Endpoint:** `GET /v3/lookup/{lu_type}/resolve?q=VALUE`
* **Usage:** Use this when you have an external code (e.g., ISO "US") and need the full Aether record.
* **Usage:** Use when you have an external code (e.g., ISO `"US"`) and need the full Aether record. Scans `name`, `group`, and other identity fields.
### C. Site Whitelist Policy
To limit lookups for a specific site, add a `lookup_policy` to the `site.cfg_json` field.
**Schema:**
To restrict which lookup items appear for a specific site, add a `lookup_policy` to `site.cfg_json`:
```json
{
"lookup_policy": {
"country": ["US", "CA", "GB"],
"time_zone": ["America/New_York"]
"time_zone": ["America/New_York", "US/Eastern"]
}
}
```
*Note: Whitelist values must match the `group` field in the database.*
> **Whitelist values must match the `group` field** — i.e., the natural key for that type (ISO code for country, IANA name for time zone). Using a display name will silently return no results for that item.
### D. Adding and managing client overrides
When a client needs a customized label or wants to hide/reorder lookup items, create override records rather than modifying global defaults.
**Rules:**
1. **Never modify global default rows** (`account_id = NULL`). Those are shared across all accounts. Any change there affects every client.
2. **Set `group` to the exact same value as the global default row** for the item you are overriding. If `group` doesn't match, the override creates a new item instead of replacing the existing one.
3. **Set `account_id`** to the client's account ID. Leave `for_type` / `for_id` null unless the override is specific to a single object (e.g., one site).
**Example — rename "US/Eastern" for one account:**
```sql
INSERT INTO lu_v3_time_zone
(account_id, name, name_override, `group`, enable, priority, sort)
VALUES
(42, 'US/Eastern', 'Eastern Time (Client Label)', 'US/Eastern', 1, 1, 50);
```
The `name_override` field is the display label the frontend should prefer when set. `group = 'US/Eastern'` ensures this row competes with — and wins over — the global default in the `PARTITION BY group` deduplication.
**To disable an item for one account** (hide it from their dropdowns):
```sql
INSERT INTO lu_v3_time_zone
(account_id, name, `group`, enable)
VALUES
(42, 'US/Samoa', 'US/Samoa', 0);
```
Setting `enable = 0` on an account-scoped row shadows the global default for that account only.
**To remove a client override** (revert to global default):
Simply delete the row where `account_id = <client>` and `group = '<item>'`. The global default row is unaffected and immediately resumes winning.
### E. Adding new global lookup items
When seeding new lookup data (e.g., adding timezones in bulk):
1. Set `group = name` for every row (for `time_zone`). This is a hard invariant — if `group` is set to a regional label like `"United States"` instead of the timezone name, the entire group collapses to a single winner and all but one entry disappear from the API response.
2. Set `account_id = NULL` and `for_type = NULL` / `for_id = NULL` for global defaults.
3. After seeding, verify with:
```sql
-- Should return 0 rows; any result means multiple items will collapse into one
SELECT `group`, COUNT(*) AS cnt
FROM lu_v3_time_zone
WHERE account_id IS NULL
GROUP BY `group`
HAVING cnt > 1;
```
---
@@ -156,7 +262,150 @@ Frontend guidance:
---
## 7. Event Exhibit Tracking Export (Leads Export)
## 7. User Actions (`/v3/action/user/`)
Stateful user account operations that are not standard CRUD. All require `x-aether-api-key`.
> [!IMPORTANT]
> **Migration from legacy `/user/*` routes:** The table below maps each legacy endpoint to its V3 replacement. Run both in parallel during transition; remove legacy routes once traffic logs confirm they are quiet.
>
> | Legacy | V3 Replacement |
> |---|---|
> | `GET /user/authenticate` | `POST /v3/action/user/authenticate` |
> | `POST /user/verify_password` | `POST /v3/action/user/verify_password` |
> | `PATCH /user/{id}/change_password` | `POST /v3/action/user/{id}/change_password` |
> | `GET /user/{id}/new_auth_key` | `GET /v3/action/user/{id}/new_auth_key` |
> | `GET /user/{id}/email_auth_key_url` | `GET /v3/action/user/{id}/email_auth_key_url` |
> | `GET /user/lookup` | `POST /v3/crud/user/search` |
> | `GET /user/lookup_email` | `POST /v3/crud/user/search` |
> | `GET /user/lookup_username` | `POST /v3/crud/user/search` |
### A. Authenticate
Authenticate a user by **username + password** or **user_id + auth_key**.
- **Method:** `POST`
- **Path:** `/v3/action/user/authenticate`
- **Auth:** `x-aether-api-key` + `x-account-id` (scopes username lookups to the correct account)
- **Security improvement:** Credentials are in the **POST body**, not query params — safe from URL logging.
**Request body:**
```json
{ "username": "scott", "password": "MyPassword123!" }
```
or:
```json
{ "user_id": "<user_id_random>", "auth_key": "<one_time_key>", "valid_email": true }
```
- `valid_email` (optional `bool`): if `true`, marks `email_verified = true` on success.
- `inc_user_role_list` (optional query param, default `false`): include role list in the returned user object.
**Response on success:** Full user object (same shape as `GET /v3/crud/user/{id}`).
**Errors:** `400` missing credentials, `403` wrong password or account disabled, `404` user not found.
> **Auth key flow:** Auth keys are one-time-use — the key is cleared from the DB immediately on successful authentication. Request a new one via `GET /v3/action/user/{id}/new_auth_key`.
---
### B. Verify Password
Check a user's current password without changing it.
- **Method:** `POST`
- **Path:** `/v3/action/user/verify_password`
- **Auth:** `x-aether-api-key` + `x-account-id`
**Request body:**
```json
{ "user_id": "<user_id_random>", "current_password": "MyPassword123!" }
```
or use `"username"` instead of `"user_id"` to look up by username within the account.
**Response:** `data: true` on match. `403` on mismatch, `404` if user not found.
---
### C. Change Password
Change a user's password. Optionally verify the current password first.
- **Method:** `POST`
- **Path:** `/v3/action/user/{user_id}/change_password`
- **Auth:** `x-aether-api-key` + `x-account-id`
**Request body:**
```json
{ "new_password": "NewPassword456!", "current_password": "MyPassword123!" }
```
- `new_password` is required (minimum 10 characters).
- `current_password` is optional. If provided, it is verified before the change is applied. Omit it for admin-driven resets.
**Response:** `data: true` on success. `403` if `current_password` provided but wrong.
---
### D. Generate New Auth Key
Generate a fresh one-time-use auth key for the user and write it to the DB.
- **Method:** `GET`
- **Path:** `/v3/action/user/{user_id}/new_auth_key`
- **Auth:** `x-aether-api-key` + `x-account-id`
**Response:**
```json
{ "data": { "auth_key": "<new_key>" } }
```
The returned key can then be passed to `/authenticate` (as `auth_key`) or embedded in a login URL. The user record must have `allow_auth_key = true` for key-based authentication to work.
---
### E. Email Auth Key URL
Generate a new auth key and email a one-time login link to the user's email address.
- **Method:** `GET`
- **Path:** `/v3/action/user/{user_id}/email_auth_key_url`
- **Auth:** `x-aether-api-key` + `x-account-id`
**Query Parameters:**
| Parameter | Type | Default | Description |
|---|---|---|---|
| `root_url` | `string` | `null` | Base URL the login link is built from. |
| `key_param_name` | `string` | `auth_key` | Query param name used for the auth key in the generated link. |
**Response:** `data: true` on success (email sent). `500` if delivery failed (check account email config and that the user account is enabled with `allow_auth_key = true`).
---
### F. User Lookups via V3 CRUD Search
The three legacy lookup routes (`lookup`, `lookup_email`, `lookup_username`) are replaced by standard V3 CRUD search:
```typescript
// Look up by user_id (Vision ID)
POST /v3/crud/user/search
{ "and": [{ "field": "id_random", "op": "eq", "value": "<user_id>" }] }
// Look up by email
POST /v3/crud/user/search
{ "and": [{ "field": "email", "op": "eq", "value": "user@example.com" }] }
// Look up by username
POST /v3/crud/user/search
{ "and": [{ "field": "username", "op": "eq", "value": "scott" }] }
```
Results are automatically scoped to the `x-account-id` provided in the request.
---
## 9. Event Exhibit Tracking Export (Leads Export)
Allows an exhibitor to download all lead-capture records for their exhibit as a CSV or XLSX file.
@@ -224,7 +473,7 @@ const url = URL.createObjectURL(blob);
---
## 8. Troubleshooting 403 Forbidden
## 10. Troubleshooting 403 Forbidden
If you receive a 403 on a valid ID:
1. Verify `x-aether-api-key` is correct.

View File

@@ -0,0 +1,144 @@
# Project: V3 Lookup Bug Fix — Timezone Group Data + PARTITION BY Revert
> **Status:** 🔧 Action Required
> **Date:** 2026-03-23
> **Related doc:** `PROJECT__V3_UNIFORM_LOOKUP_SYSTEM.md`
> **Reported by:** Frontend Agent (Scott Idem / One Sky IT)
---
## 1. Summary
Two bugs were discovered in the V3 Uniform Lookup System during IDAA Recovery Meetings
timezone dropdown testing. They stem from a single root cause: the `lu_v3_time_zone`
table was seeded with regional `group` values (`"United States"`, `"Europe"`) instead of
individual timezone names — contrary to the design specified in Phase 2 of the lookup
architecture doc, which explicitly states `lu_v3_time_zone (Group: name)`.
An attempted fix changed `PARTITION BY group` to `PARTITION BY name` in
`get_lookup_list_v3()`. This unintentionally broke country deduplication, which depends
on `PARTITION BY group` being correct (country group = `alpha_2_code`, e.g. `"US"`).
---
## 2. Root Cause
### 2.1 Timezone `group` values were set to regional names instead of timezone names
The `lu_v3_time_zone` table has two groups where multiple records share a single group value:
| `group` value | Count | Example records |
|----------------|-------|-----------------|
| `United States` | 13 | US/Alaska, US/Arizona, US/Central, US/East-Indiana, US/Eastern, US/Hawaii, US/Indiana-Starke, US/Michigan, US/Mountain, US/Pacific, US/Pacific-New, US/Samoa, US/Aleutian |
| `Europe` | 63 | Europe/London, Europe/Paris, Europe/Prague, Europe/Rome, ... (all Europe/* zones) |
All other timezone records already have `group = name` (e.g., `Canada/Eastern` has
`group = "Canada/Eastern"`). The US and Europe records were loaded incorrectly.
**Effect:** `PARTITION BY group` collapsed all 13 US/* records into a single winner and
all 63 Europe/* records into a single winner. Only ~7 distinct US timezones and 1 Europe
timezone appeared in the dropdown instead of all 76.
### 2.2 Attempted fix broke country lookup deduplication
Changing `PARTITION BY group``PARTITION BY name` in `get_lookup_list_v3()` fixed the
timezone collapse but broke `lu_v3_country`.
`lu_v3_country` has (at minimum) two records for `alpha_2_code = "US"`:
- `id=240`: global default (`account_id=NULL`), `group="US"`
- `id=251`: account-specific (`account_id=1`), `group="US"`
With `PARTITION BY group`, both records share `group="US"` and are correctly deduped —
the account-specific record wins per the override hierarchy. With `PARTITION BY name`,
if the two records have different `name` values they are treated as separate identities
and both survive, resulting in duplicate `alpha_2_code="US"` entries in the API response.
The frontend's `{#each lu_country_list as country (country.alpha_2_code)}` then throws:
> `Svelte error: each_key_duplicate — Keyed each block has duplicate key 'US'`
The same risk applies to `lu_v3_country_subdivision`.
---
## 3. Correct Fix (Two Steps)
### Step 1 — Revert `app/methods/lookup_methods.py`
Change `PARTITION BY name` back to `PARTITION BY group`:
```python
# lookup_methods.py — get_lookup_list_v3()
ROW_NUMBER() OVER (
PARTITION BY `group` # <-- revert to this
ORDER BY
(for_type = :for_type AND for_id = :for_id) DESC,
(account_id = :account_id) DESC,
created_on DESC
) as rank_priority
```
This restores correct behavior for all three active V3 lookup types
(`country`, `country_subdivision`, `time_zone`).
### Step 2 — Fix the `lu_v3_time_zone` data
Set `group = name` for all records where the group is a regional label rather than the
timezone's own name. Run once against the database:
```sql
UPDATE lu_v3_time_zone
SET `group` = `name`
WHERE `group` IN ('United States', 'Europe');
```
**Verification:**
```sql
-- Should return 0 rows after the fix
SELECT `group`, COUNT(*) as cnt
FROM lu_v3_time_zone
GROUP BY `group`
HAVING cnt > 1;
```
---
## 4. Why PARTITION BY `group` Is Correct
As documented in `PROJECT__V3_UNIFORM_LOOKUP_SYSTEM.md` (Section 2.1):
> `group`: The primary business key/cluster key. *Note: Must be populated for hierarchy to work.*
The `group` field IS the deduplication identity. Each lookup type uses a different natural
key for `group`:
| Lookup type | `group` field | Example |
|---|---|---|
| `country` | `alpha_2_code` | `"US"`, `"CA"`, `"GB"` |
| `country_subdivision` | `code` | `"US-NY"`, `"CA-ON"` |
| `time_zone` | `name` (= the IANA timezone identifier) | `"US/Eastern"`, `"Europe/London"` |
For `time_zone`, `group` and `name` are intended to be the same value — each timezone
is its own identity. There is no meaningful concept of "override all US timezones as a
group." Each one is individually addressable.
---
## 5. Regression Tests to Add / Update
- `test_timezone_us_dedup()` — assert all 13 US/* priority zones are present individually
- `test_timezone_europe_dedup()` — assert all Europe/* priority zones present individually
- `test_country_us_dedup()` — assert only one `alpha_2_code="US"` record returned;
account-specific override wins over global default
- General: `GET /v3/lookup/time_zone/list?only_priority=true` should return exactly 72
records (the current count of priority=1 enabled timezones)
---
## 6. What Was NOT Changed (and Should Not Be)
- The endpoint signature for `GET /v3/lookup/{lu_type}/list` — it does not and should
not expose `limit`, `offset`, or `order_by_li` query params. The frontend sends these
but they are correctly ignored. The sort order is hardcoded and correct:
`ORDER BY COALESCE(priority, 0) DESC, COALESCE(sort, 0) DESC, name ASC`
- Country and country_subdivision data — no changes needed to those tables
- Frontend code — no backend-side changes are needed on the frontend for this fix

View File

@@ -0,0 +1,124 @@
# PROJECT: AE Hosted Files — Upload Util & V3 Actions Migration
**Status:** In Progress
**Date:** 2026-03-25
**Affected systems:** Frontend (aether_app_sveltekit), Backend (aether_api_fastapi)
---
## Background
The legacy `hosted_file.router` (registered at prefix `/hosted_file`) was commented out
in `app/routers/registry.py` as part of the V3 migration:
```python
# app.include_router(hosted_file.router, prefix='/hosted_file', tags=['Hosted File'])
app.include_router(api_v3_actions_hosted_file.router, prefix='/v3/action/hosted_file', ...)
```
This broke several frontend features that were still calling the old endpoints.
Three endpoints have been fixed on the frontend side (already committed and pushed).
One endpoint still needs a backend fix.
---
## Endpoints: Status Summary
### FIXED (frontend updated to call new V3 path)
| Old endpoint | New endpoint | Frontend file |
|---|---|---|
| `POST /hosted_file/upload_files` | `POST /v3/action/hosted_file/upload` | `src/lib/ae_core/ae_comp__hosted_files_upload.svelte`, `src/routes/events/ae_comp__event_files_upload.svelte` |
| `GET /hosted_file/{id}/clip_video` | `GET /v3/action/hosted_file/{id}/clip_video` | `src/lib/ae_core/ae_comp__hosted_files_clip_video.svelte` |
### NEEDS BACKEND ACTION — Hash Lookup Endpoint
**Missing endpoint:** `GET /hosted_file/hash/{hosted_file_hash}`
This endpoint existed in the legacy `hosted_file.py` router (line 233) and has **not** been
ported to `api_v3_actions_hosted_file.py`.
**What it does:**
1. Looks up a `hosted_file` record by its `hash_sha256` field
2. Optionally checks that the physical file actually exists on disk (`check_for_local=true`)
3. Returns the full hosted_file object with two extra flags:
- `hosted_file_found_check: true` — file record exists AND physical file confirmed on disk
- `hosted_file_size_check: <bytes>` — file size from disk
**Legacy implementation (hosted_file.py:233):**
```python
@router.get('/hash/{hosted_file_hash}', response_model=Resp_Body_Base)
async def check_hosted_file_obj_w_hash(
hosted_file_hash: str = Path(min_length=64, max_length=64),
check_for_local: Optional[bool] = True,
commons: Common_Route_Params = Depends(common_route_params),
):
if hfid := lookup_file_hash(file_hash=hosted_file_hash):
obj = load_hosted_file_obj(hosted_file_id=hfid, model_as_dict=True)
if check_for_local and obj:
if check := check_for_hosted_file_hash_file(file_hash=hosted_file_hash, sub_dir=obj.get('subdirectory_path', '')):
obj['hosted_file_found_check'] = True
obj['hosted_file_size_check'] = check['file_size']
return mk_resp(data=obj, response=commons.response)
return mk_resp(data=False, status_code=404, response=commons.response)
```
**Where it's called on the frontend:**
- `src/lib/ae_core/core__check_hosted_file_obj_w_hash.ts` — thin wrapper, calls `GET /hosted_file/hash/{hash}`
- `src/lib/elements/element_input_file.svelte` — calls this before uploading (dedup check)
- `src/lib/elements/element_input_files_tbl.svelte` — same (dedup check in the table file input)
- Exported via `src/lib/ae_core/ae_core_functions.ts` as `core_func.check_hosted_file_obj_w_hash`
**Current impact:** The 404 causes a null return. The frontend checks
`result && result.hosted_file_found_check` — so if null, it silently skips the dedup check
and proceeds to upload anyway. Uploads still work, but duplicate files may be created rather
than reusing existing records.
**Requested fix (backend):**
Port this endpoint to `api_v3_actions_hosted_file.py` as:
```
GET /v3/action/hosted_file/hash/{hosted_file_hash}
```
Parameters and response shape should match the legacy implementation exactly.
The `check_for_local` query param (default `True`) must be preserved — the frontend
passes `check_for_local=true` and expects `hosted_file_found_check` in the response.
**After backend deploys the new endpoint**, the frontend needs one line changed in
`src/lib/ae_core/core__check_hosted_file_obj_w_hash.ts`:
```ts
// Before:
const endpoint = `/hosted_file/hash/${hosted_file_hash}`;
// After:
const endpoint = `/v3/action/hosted_file/hash/${hosted_file_hash}`;
```
---
## Other Legacy Endpoints — Audit Notes
The following were also in `hosted_file.py` but appear to either have V3 equivalents already
or are not currently called by the frontend. Backend should confirm:
| Legacy endpoint | V3 equivalent | Notes |
|---|---|---|
| `GET /hosted_file/{id}/download` | `GET /v3/action/hosted_file/{id}/download` | Exists in V3 router |
| `DELETE /hosted_file/{id}` | `DELETE /v3/action/hosted_file/{id}` | Exists in V3 router |
| `GET /hosted_file/{id}/convert_file` | `GET /v3/action/hosted_file/{id}/convert_file` | Exists in V3 router |
| `GET /hosted_file/{id}/stream` | Unknown | Not confirmed in V3 router — verify |
| `GET /hosted_file/directory_check` | Unknown | Admin/dev utility — verify if still needed |
| `GET /hosted_file/hash/{hash}/download` (via V3) | `GET /v3/action/hosted_file/hash/{sha256}/download` | Exists in V3 router (hash-based download) |
| `GET /hosted_file/tmp/{subdir}/{filename}/download` | Unknown | Temp file download — verify if still needed |
| `POST /hosted_file/create_video` | Unknown | Verify if still needed |
---
## Coordinator Notes
- Frontend commits fixing upload and clip_video are on branch `ae_app_3x_llm`
(commits `a5a806e2` and `362136e6`)
- Once the backend adds the hash lookup endpoint, the frontend one-line fix in
`core__check_hosted_file_obj_w_hash.ts` can be committed alongside it
- The `check_for_local` flag is important — it verifies the physical file exists on disk,
not just the DB record. Don't drop it in the V3 port.

View File

@@ -15,7 +15,7 @@ email-validator
et-xmlfile
fastapi>=0.115.5
# greenlet
gunicorn
gunicorn==23.0.0
h11
html2text
httpcore

View File

@@ -14,7 +14,20 @@ HEADERS = {
}
# TODO: SET THIS to your demo site's random ID
SITE_ID_RANDOM = "92vkYC4fVEl"
SITE_ID_RANDOM = "92vkYC4fVEl"
# All US/* priority timezones — group must equal name in lu_v3_time_zone for these to survive
# PARTITION BY group dedup. If group="United States" for these, only 1 survives.
US_TIMEZONES = [
"US/Alaska", "US/Aleutian", "US/Arizona", "US/Central", "US/East-Indiana",
"US/Eastern", "US/Hawaii", "US/Indiana-Starke", "US/Michigan",
"US/Mountain", "US/Pacific", "US/Pacific-New", "US/Samoa",
]
# Spot-check a subset of Europe/* priority timezones — same root cause as US/*
EUROPE_TIMEZONES_SAMPLE = [
"Europe/London", "Europe/Paris", "Europe/Prague", "Europe/Rome",
]
def print_result(label, success, message=""):
status = "✅ PASS" if success else "❌ FAIL"
@@ -30,17 +43,17 @@ def test_lookup_list(lu_type, site_id=None, only_priority=False):
if only_priority:
params["only_priority"] = "true"
label += " (Priority Only)"
try:
start_time = time.time()
response = requests.get(url, headers=HEADERS, params=params)
duration = time.time() - start_time
if response.status_code == 200:
data = response.json().get('data', [])
msg = f"Found {len(data)} items ({duration:.2f}s)"
print_result(label, True, msg)
# Print top 10 for sorting verification
if data and not site_id: # Only print for full or priority lists
limit = 10 if not only_priority else len(data)
@@ -49,7 +62,7 @@ def test_lookup_list(lu_type, site_id=None, only_priority=False):
prio = item.get('priority', 0)
sort = item.get('sort', 0)
print(f" [{i+1}] {item.get('name')} (Prio: {prio}, Sort: {sort})")
return data
else:
print_result(label, False, f"Status {response.status_code}: {response.text[:100]}")
@@ -75,27 +88,99 @@ def test_lookup_resolve(lu_type, query):
print_result(f"GET /{lu_type}/resolve?q={query}", False, str(e))
return False
def test_timezone_us_dedup(data):
"""
Regression: lu_v3_time_zone group data fix.
All 13 US/* priority zones must appear individually.
Root cause: group was seeded as 'United States' instead of name — PARTITION BY group
collapsed all 13 into one winner.
"""
label = "time_zone: all 13 US/* zones present (group=name data fix)"
if data is None:
print_result(label, False, "No data")
return
names = {item.get("name") for item in data}
missing = [tz for tz in US_TIMEZONES if tz not in names]
if missing:
print_result(label, False, f"Missing (group data not yet fixed?): {missing}")
else:
print_result(label, True, f"All {len(US_TIMEZONES)} US/* timezones present")
def test_timezone_europe_dedup(data):
"""
Regression: same root cause as US/* — group was 'Europe' for all Europe/* zones.
Spot-check that the priority ones appear individually after data fix.
"""
label = "time_zone: Europe/* spot-check (group=name data fix)"
if data is None:
print_result(label, False, "No data")
return
names = {item.get("name") for item in data}
missing = [tz for tz in EUROPE_TIMEZONES_SAMPLE if tz not in names]
if missing:
print_result(label, False, f"Missing (group data not yet fixed?): {missing}")
else:
print_result(label, True, f"Europe/* spot-check passed ({len(EUROPE_TIMEZONES_SAMPLE)} zones found)")
def test_country_us_dedup(data):
"""
Regression: PARTITION BY group must NOT produce duplicate alpha_2_code values.
Two records exist for alpha_2_code='US' (global default + account override) — only one
should survive. If PARTITION BY name were used, both would appear and Svelte would
throw each_key_duplicate on alpha_2_code='US'.
"""
label = "country: no duplicate alpha_2_code (PARTITION BY group dedup)"
if data is None:
print_result(label, False, "No data")
return
codes = [item.get("alpha_2_code") for item in data if item.get("alpha_2_code")]
duplicates = [c for c in set(codes) if codes.count(c) > 1]
if duplicates:
print_result(label, False, f"Duplicate alpha_2_codes: {duplicates}")
else:
print_result(label, True, f"No duplicates across {len(data)} countries")
def test_priority_only_count(data, expected=72):
"""priority=1 enabled timezones: should be exactly {expected} after data fix."""
label = f"time_zone priority-only count == {expected}"
if data is None:
print_result(label, False, "No data")
return
if len(data) == expected:
print_result(label, True, f"{len(data)} records")
else:
print_result(label, False, f"Got {len(data)}, expected {expected} (data fix pending?)")
if __name__ == "__main__":
print(f"🚀 Starting V3 Lookup E2E Suite ({BASE_URL})\n")
start_suite = time.time()
# 1. Basic Lists (Phase 1)
test_lookup_list("country")
print("\n--- Testing Priority Only ---")
test_lookup_list("time_zone", only_priority=True)
# 2. Whitelist Test (Phase 2)
# 1. Country — basic list + dedup regression
print("--- Country ---")
country_data = test_lookup_list("country")
test_country_us_dedup(country_data)
# 2. Timezone — full list + group data fix regressions
print("\n--- Timezone (full list) ---")
tz_data = test_lookup_list("time_zone")
test_timezone_us_dedup(tz_data)
test_timezone_europe_dedup(tz_data)
# 3. Timezone — priority only
print("\n--- Timezone (priority only) ---")
tz_priority_data = test_lookup_list("time_zone", only_priority=True)
test_priority_only_count(tz_priority_data, expected=72)
# 4. Whitelist Test
if SITE_ID_RANDOM != "SET_ME_TO_SITE_ID":
print("\n--- Testing Site Whitelist Policy ---")
# Should return only whitelisted items
print("\n--- Site Whitelist Policy ---")
test_lookup_list("country", site_id=SITE_ID_RANDOM)
test_lookup_list("time_zone", site_id=SITE_ID_RANDOM)
else:
print("\n⚠️ Skipping Phase 2 test: SITE_ID_RANDOM not set.")
# 3. Resolve Test
print("\n--- Testing Resolve ---")
print("\n⚠️ Skipping whitelist test: SITE_ID_RANDOM not set.")
# 5. Resolve
print("\n--- Resolve ---")
test_lookup_resolve("country", "US")
print(f"\n⏱️ Suite completed in {time.time() - start_suite:.2f}s")

View File

@@ -0,0 +1,498 @@
"""
E2E Tests: V3 User Action Routes (app/routers/api_v3_actions_user.py)
======================================================================
Covers the new V3 action endpoints under /v3/action/user/:
- POST /v3/action/user/authenticate
- POST /v3/action/user/verify_password
- POST /v3/action/user/{user_id}/change_password
- GET /v3/action/user/{user_id}/new_auth_key
- GET /v3/action/user/{user_id}/email_auth_key_url
Setup: creates a temporary test user via V3 CRUD; tears down on completion.
Run from project root:
./environment/bin/python3 tests/e2e/test_e2e_v3_user_action_routes.py
"""
import os
import sys
import time
import requests
sys.path.append(os.getcwd())
# --- Configuration ---
API_ROOT = "https://dev-api.oneskyit.com"
API_KEY = "PMM4n50teUCaOMMTN8qOJA"
ACCOUNT_ID = "_XY7DXtc9MY" # One Sky IT Demo account
V3_HEADERS = {
"x-aether-api-key": API_KEY,
"x-account-id": ACCOUNT_ID,
}
TEST_PASSWORD = "TestAction1234!" # >= 10 chars
NEW_PASSWORD = "NewAction5678!" # used after change_password tests
# Populated during setup
_test_user_id = None # Vision ID (random string)
_test_username = None
_test_email = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def print_result(label, success, message=""):
status = "✅ PASS" if success else "❌ FAIL"
print(f" [{status}] {label}" + (f"{message}" if message else ""))
def assert_vision_id(obj, field_name="user_id"):
"""Returns True if field is a non-empty string of length 1122 (Vision ID)."""
val = obj.get(field_name) if isinstance(obj, dict) else None
return isinstance(val, str) and 11 <= len(val) <= 22
# ---------------------------------------------------------------------------
# Setup / Teardown
# ---------------------------------------------------------------------------
def setup_test_user():
"""Create a temporary test user via V3 CRUD. Returns the Vision ID or None."""
global _test_user_id, _test_username, _test_email
ts = int(time.time())
_test_username = f"test_v3act_e2e_{ts}"
_test_email = f"test_v3act_e2e_{ts}@test.invalid"
payload = {
"account_id": ACCOUNT_ID,
"username": _test_username,
"name": "E2E V3 Action Test User",
"email": _test_email,
"new_password": TEST_PASSWORD,
"enable": True,
"allow_auth_key": True,
}
resp = requests.post(f"{API_ROOT}/v3/crud/user/", json=payload, headers=V3_HEADERS)
if resp.status_code != 200:
print(f" [SETUP ❌] Failed to create test user — HTTP {resp.status_code}")
print(f" {resp.text[:300]}")
return None
data = resp.json().get("data", {})
_test_user_id = data.get("user_id") or data.get("id")
if not _test_user_id:
print(f" [SETUP ❌] Test user created but no Vision ID returned: {data}")
return None
print(f" [SETUP ✅] Test user created — user_id={_test_user_id} username={_test_username}")
return _test_user_id
def teardown_test_user(user_id):
"""Delete the test user via V3 CRUD."""
if not user_id:
return
resp = requests.delete(f"{API_ROOT}/v3/crud/user/{user_id}", headers=V3_HEADERS)
if resp.status_code == 200:
print(f" [TEARDOWN ✅] Test user deleted — user_id={user_id}")
else:
print(f" [TEARDOWN ❌] Failed to delete test user — HTTP {resp.status_code} {resp.text[:200]}")
# ---------------------------------------------------------------------------
# authenticate
# ---------------------------------------------------------------------------
def test_authenticate_username_password():
"""POST /v3/action/user/authenticate — valid username + password."""
print("\n--- authenticate ---")
resp = requests.post(
f"{API_ROOT}/v3/action/user/authenticate",
json={"username": _test_username, "password": TEST_PASSWORD},
headers=V3_HEADERS,
)
data = resp.json().get("data", {})
vision_ok = assert_vision_id(data, "user_id")
success = resp.status_code == 200 and vision_ok
print_result("Valid username+password", success,
f"HTTP {resp.status_code}" + ("" if vision_ok else " — missing Vision ID"))
return success
def test_authenticate_wrong_password():
"""POST /v3/action/user/authenticate — wrong password → 403."""
resp = requests.post(
f"{API_ROOT}/v3/action/user/authenticate",
json={"username": _test_username, "password": "WrongPassword999!"},
headers=V3_HEADERS,
)
success = resp.status_code == 403
print_result("Wrong password → 403", success, f"HTTP {resp.status_code}")
return success
def test_authenticate_unknown_user():
"""POST /v3/action/user/authenticate — unknown username → 404."""
resp = requests.post(
f"{API_ROOT}/v3/action/user/authenticate",
json={"username": "no_such_user_xyzzy", "password": TEST_PASSWORD},
headers=V3_HEADERS,
)
success = resp.status_code == 404
print_result("Unknown username → 404", success, f"HTTP {resp.status_code}")
return success
def test_authenticate_missing_fields():
"""POST /v3/action/user/authenticate — no credentials → 400."""
resp = requests.post(
f"{API_ROOT}/v3/action/user/authenticate",
json={"username": _test_username}, # password missing
headers=V3_HEADERS,
)
success = resp.status_code == 400
print_result("Missing credentials → 400", success, f"HTTP {resp.status_code}")
return success
def test_authenticate_auth_key_flow():
"""
Full auth-key flow:
1. GET new_auth_key → get a key
2. POST authenticate with user_id + auth_key → success
3. POST authenticate again with same key → 404 (key cleared)
"""
print("\n--- authenticate (auth_key flow) ---")
# Step 1: generate key
resp1 = requests.get(
f"{API_ROOT}/v3/action/user/{_test_user_id}/new_auth_key",
headers=V3_HEADERS,
)
if resp1.status_code != 200:
print_result("Auth key flow — generate key", False, f"HTTP {resp1.status_code}")
return False
key = resp1.json().get("data", {}).get("auth_key")
if not key:
print_result("Auth key flow — generate key", False, "No auth_key in response")
return False
# Step 2: authenticate with key
resp2 = requests.post(
f"{API_ROOT}/v3/action/user/authenticate",
json={"user_id": _test_user_id, "auth_key": key},
headers=V3_HEADERS,
)
data2 = resp2.json().get("data", {})
step2_ok = resp2.status_code == 200 and assert_vision_id(data2, "user_id")
print_result("Auth key flow — first use succeeds", step2_ok,
f"HTTP {resp2.status_code}")
# Step 3: replay must fail (key is cleared)
resp3 = requests.post(
f"{API_ROOT}/v3/action/user/authenticate",
json={"user_id": _test_user_id, "auth_key": key},
headers=V3_HEADERS,
)
step3_ok = resp3.status_code == 404
print_result("Auth key flow — replay → 404 (one-time-use)", step3_ok,
f"HTTP {resp3.status_code}")
return step2_ok and step3_ok
# ---------------------------------------------------------------------------
# verify_password
# ---------------------------------------------------------------------------
def test_verify_password_by_user_id():
"""POST /v3/action/user/verify_password — correct password by user_id."""
print("\n--- verify_password ---")
resp = requests.post(
f"{API_ROOT}/v3/action/user/verify_password",
json={"user_id": _test_user_id, "current_password": TEST_PASSWORD},
headers=V3_HEADERS,
)
data = resp.json().get("data")
# Primitive True is wrapped as {"result": True}
result = data.get("result") if isinstance(data, dict) else data
success = resp.status_code == 200 and result is True
print_result("Correct password by user_id → True", success, f"HTTP {resp.status_code}")
return success
def test_verify_password_by_username():
"""POST /v3/action/user/verify_password — correct password by username."""
resp = requests.post(
f"{API_ROOT}/v3/action/user/verify_password",
json={"username": _test_username, "current_password": TEST_PASSWORD},
headers=V3_HEADERS,
)
data = resp.json().get("data")
result = data.get("result") if isinstance(data, dict) else data
success = resp.status_code == 200 and result is True
print_result("Correct password by username → True", success, f"HTTP {resp.status_code}")
return success
def test_verify_password_wrong():
"""POST /v3/action/user/verify_password — wrong password → 403."""
resp = requests.post(
f"{API_ROOT}/v3/action/user/verify_password",
json={"user_id": _test_user_id, "current_password": "WrongPassword999!"},
headers=V3_HEADERS,
)
success = resp.status_code == 403
print_result("Wrong password → 403", success, f"HTTP {resp.status_code}")
return success
def test_verify_password_no_identifier():
"""POST /v3/action/user/verify_password — no user_id or username → 400."""
resp = requests.post(
f"{API_ROOT}/v3/action/user/verify_password",
json={"current_password": TEST_PASSWORD},
headers=V3_HEADERS,
)
success = resp.status_code == 400
print_result("No identifier → 400", success, f"HTTP {resp.status_code}")
return success
# ---------------------------------------------------------------------------
# change_password
# ---------------------------------------------------------------------------
def test_change_password_no_verification():
"""POST /v3/action/user/{id}/change_password — no current_password (admin reset)."""
print("\n--- change_password ---")
resp = requests.post(
f"{API_ROOT}/v3/action/user/{_test_user_id}/change_password",
json={"new_password": NEW_PASSWORD},
headers=V3_HEADERS,
)
data = resp.json().get("data")
result = data.get("result") if isinstance(data, dict) else data
success = resp.status_code == 200 and result is True
print_result("Change password (no verification)", success, f"HTTP {resp.status_code}")
# Verify the new password works
resp2 = requests.post(
f"{API_ROOT}/v3/action/user/verify_password",
json={"user_id": _test_user_id, "current_password": NEW_PASSWORD},
headers=V3_HEADERS,
)
data2 = resp2.json().get("data")
r2 = data2.get("result") if isinstance(data2, dict) else data2
verify_ok = resp2.status_code == 200 and r2 is True
print_result("New password accepted by verify_password", verify_ok,
f"HTTP {resp2.status_code}")
return success and verify_ok
def test_change_password_with_verification():
"""POST /v3/action/user/{id}/change_password — with correct current_password."""
# Password is currently NEW_PASSWORD (set by previous test)
resp = requests.post(
f"{API_ROOT}/v3/action/user/{_test_user_id}/change_password",
json={"current_password": NEW_PASSWORD, "new_password": TEST_PASSWORD},
headers=V3_HEADERS,
)
data = resp.json().get("data")
result = data.get("result") if isinstance(data, dict) else data
success = resp.status_code == 200 and result is True
print_result("Change password with correct current_password", success,
f"HTTP {resp.status_code}")
return success
def test_change_password_wrong_current():
"""POST /v3/action/user/{id}/change_password — wrong current_password → 403."""
resp = requests.post(
f"{API_ROOT}/v3/action/user/{_test_user_id}/change_password",
json={"current_password": "WrongPassword999!", "new_password": NEW_PASSWORD},
headers=V3_HEADERS,
)
success = resp.status_code == 403
print_result("Wrong current_password → 403", success, f"HTTP {resp.status_code}")
return success
def test_change_password_too_short():
"""POST /v3/action/user/{id}/change_password — new_password < 10 chars → 422."""
resp = requests.post(
f"{API_ROOT}/v3/action/user/{_test_user_id}/change_password",
json={"new_password": "short"},
headers=V3_HEADERS,
)
# Pydantic validation rejects min_length constraint with 422 Unprocessable Entity
success = resp.status_code == 422
print_result("new_password too short → 422", success, f"HTTP {resp.status_code}")
return success
def test_change_password_bad_user():
"""POST /v3/action/user/{id}/change_password — invalid user_id → 404."""
resp = requests.post(
f"{API_ROOT}/v3/action/user/AAAAAAAAAAA/change_password",
json={"new_password": "ValidPassword123!"},
headers=V3_HEADERS,
)
success = resp.status_code == 404
print_result("Invalid user_id → 404", success, f"HTTP {resp.status_code}")
return success
# ---------------------------------------------------------------------------
# new_auth_key
# ---------------------------------------------------------------------------
def test_new_auth_key():
"""GET /v3/action/user/{user_id}/new_auth_key — generates and returns key."""
print("\n--- new_auth_key ---")
resp = requests.get(
f"{API_ROOT}/v3/action/user/{_test_user_id}/new_auth_key",
headers=V3_HEADERS,
)
data = resp.json().get("data", {})
key = data.get("auth_key") if isinstance(data, dict) else None
success = resp.status_code == 200 and isinstance(key, str) and len(key) >= 11
print_result("Returns new auth_key string", success,
f"HTTP {resp.status_code}" + (f" key={key!r}" if success else ""))
return success
def test_new_auth_key_bad_user():
"""GET /v3/action/user/{user_id}/new_auth_key — invalid user → 404."""
resp = requests.get(
f"{API_ROOT}/v3/action/user/AAAAAAAAAAA/new_auth_key",
headers=V3_HEADERS,
)
success = resp.status_code == 404
print_result("Invalid user_id → 404", success, f"HTTP {resp.status_code}")
return success
# ---------------------------------------------------------------------------
# email_auth_key_url
# ---------------------------------------------------------------------------
def test_email_auth_key_url():
"""GET /v3/action/user/{user_id}/email_auth_key_url — sends or fails gracefully."""
print("\n--- email_auth_key_url ---")
resp = requests.get(
f"{API_ROOT}/v3/action/user/{_test_user_id}/email_auth_key_url",
params={"root_url": "https://test.invalid/login"},
headers=V3_HEADERS,
)
# 200 = email sent; 500 = delivery failed (.invalid domain) — both are acceptable.
success = resp.status_code in (200, 500)
print_result(
"email_auth_key_url (200=sent, 500=delivery failed — both OK for .invalid domain)",
success, f"HTTP {resp.status_code}"
)
return success
def test_email_auth_key_url_bad_user():
"""GET /v3/action/user/{user_id}/email_auth_key_url — invalid user → 404."""
resp = requests.get(
f"{API_ROOT}/v3/action/user/AAAAAAAAAAA/email_auth_key_url",
params={"root_url": "https://test.invalid/login"},
headers=V3_HEADERS,
)
success = resp.status_code == 404
print_result("Invalid user_id → 404", success, f"HTTP {resp.status_code}")
return success
# ---------------------------------------------------------------------------
# Auth guard checks
# ---------------------------------------------------------------------------
def test_no_api_key():
"""All V3 action endpoints require x-aether-api-key — missing → 403."""
print("\n--- auth guards ---")
resp = requests.post(
f"{API_ROOT}/v3/action/user/authenticate",
json={"username": _test_username, "password": TEST_PASSWORD},
headers={"x-account-id": ACCOUNT_ID}, # no API key
)
success = resp.status_code == 403
print_result("No API key → 403", success, f"HTTP {resp.status_code}")
return success
# ---------------------------------------------------------------------------
# Runner
# ---------------------------------------------------------------------------
def run_suite():
start = time.time()
print("=" * 60)
print("E2E: V3 User Action Routes")
print("=" * 60)
if not setup_test_user():
print("\n[ABORT] Setup failed — cannot run tests.\n")
return
results = []
# authenticate
results.append(test_authenticate_username_password())
results.append(test_authenticate_wrong_password())
results.append(test_authenticate_unknown_user())
results.append(test_authenticate_missing_fields())
results.append(test_authenticate_auth_key_flow())
# verify_password
results.append(test_verify_password_by_user_id())
results.append(test_verify_password_by_username())
results.append(test_verify_password_wrong())
results.append(test_verify_password_no_identifier())
# change_password (order matters — each test assumes the password left by the previous)
results.append(test_change_password_no_verification()) # TEST → NEW
results.append(test_change_password_with_verification()) # NEW → TEST
results.append(test_change_password_wrong_current()) # bad → 403 (no change)
results.append(test_change_password_too_short()) # bad → 422
results.append(test_change_password_bad_user()) # 404
# new_auth_key
results.append(test_new_auth_key())
results.append(test_new_auth_key_bad_user())
# email_auth_key_url
results.append(test_email_auth_key_url())
results.append(test_email_auth_key_url_bad_user())
# auth guards
results.append(test_no_api_key())
teardown_test_user(_test_user_id)
elapsed = time.time() - start
passed = sum(1 for r in results if r)
total = len(results)
print(f"\n{'=' * 60}")
print(f"Results: {passed}/{total} passed ({elapsed:.2f}s)")
print("=" * 60)
if __name__ == "__main__":
run_suite()

View File

@@ -0,0 +1,511 @@
"""
E2E Tests: User Auth Routes (app/routers/user.py)
==================================================
Covers the active legacy user routes that are marked for migration to V3:
- PATCH /user/{user_id}/change_password
- GET /user/{user_id}/new_auth_key
- GET /user/authenticate ← KNOWN BUG: decorator accidentally commented out
- POST /user/verify_password
- GET /user/lookup
- GET /user/lookup_email
- GET /user/lookup_username
- GET /user/{user_id}/email_auth_key_url
Run from project root:
./environment/bin/python3 tests/e2e/test_e2e_v3_user_auth_routes.py
"""
import os
import sys
import time
import requests
sys.path.append(os.getcwd())
# --- Configuration ---
API_ROOT = "https://dev-api.oneskyit.com"
API_KEY = "PMM4n50teUCaOMMTN8qOJA"
ACCOUNT_ID = "_XY7DXtc9MY" # One Sky IT Demo account
# Standard headers for V3 CRUD (create/delete the test user)
V3_HEADERS = {
"x-aether-api-key": API_KEY,
"x-account-id": ACCOUNT_ID,
}
# Legacy routes use the same headers (Common_Route_Params reads x-account-id)
LEGACY_HEADERS = V3_HEADERS
TEST_PASSWORD = "TestAuth1234!" # >= 10 chars
NEW_PASSWORD = "NewTestPwd5678!" # used after change_password
# Populated during setup
_test_user_id = None # Vision ID (random string)
_test_username = None
_test_email = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def print_result(label, success, message=""):
status = "✅ PASS" if success else "❌ FAIL"
print(f" [{status}] {label}" + (f"{message}" if message else ""))
def assert_vision_id(obj_dict, field_name="user_id"):
"""Returns True if the given field is a string (Vision ID), not an int."""
val = obj_dict.get(field_name)
return isinstance(val, str) and 11 <= len(val) <= 22
# ---------------------------------------------------------------------------
# Setup / Teardown
# ---------------------------------------------------------------------------
def setup_test_user():
"""Create a temporary test user via V3 CRUD. Returns the Vision ID or None."""
global _test_user_id, _test_username, _test_email
ts = int(time.time())
_test_username = f"test_auth_e2e_{ts}"
_test_email = f"test_auth_e2e_{ts}@test.invalid"
payload = {
"account_id": ACCOUNT_ID,
"username": _test_username,
"name": "E2E Auth Test User",
"email": _test_email,
"new_password": TEST_PASSWORD,
"enable": True,
"allow_auth_key": True, # needed for new_auth_key / email_auth_key_url tests
}
resp = requests.post(
f"{API_ROOT}/v3/crud/user/",
json=payload,
headers=V3_HEADERS,
)
if resp.status_code != 200:
print(f" [SETUP ❌] Failed to create test user — HTTP {resp.status_code}")
print(f" {resp.text[:300]}")
return None
data = resp.json().get("data", {})
_test_user_id = data.get("user_id") or data.get("id")
if not _test_user_id:
print(f" [SETUP ❌] Test user created but no Vision ID returned: {data}")
return None
print(f" [SETUP ✅] Test user created — user_id={_test_user_id} username={_test_username}")
return _test_user_id
def teardown_test_user(user_id):
"""Delete the test user via V3 CRUD."""
if not user_id:
return
resp = requests.delete(
f"{API_ROOT}/v3/crud/user/{user_id}",
headers=V3_HEADERS,
)
if resp.status_code == 200:
print(f" [TEARDOWN ✅] Test user deleted — user_id={user_id}")
else:
print(f" [TEARDOWN ❌] Failed to delete test user — HTTP {resp.status_code} {resp.text[:200]}")
# ---------------------------------------------------------------------------
# change_password
# ---------------------------------------------------------------------------
def test_change_password():
"""PATCH /user/{user_id}/change_password — valid new password."""
print("\n--- change_password ---")
resp = requests.patch(
f"{API_ROOT}/user/{_test_user_id}/change_password",
json={"password": NEW_PASSWORD},
headers=LEGACY_HEADERS,
)
success = resp.status_code == 200 and resp.json().get("data") is not False
print_result("Valid password change", success,
f"HTTP {resp.status_code}" if not success else "")
return success
def test_change_password_too_short():
"""PATCH /user/{user_id}/change_password — password < 10 chars → 400."""
resp = requests.patch(
f"{API_ROOT}/user/{_test_user_id}/change_password",
json={"password": "short"},
headers=LEGACY_HEADERS,
)
print_result("Short password rejected (400)", resp.status_code == 400,
f"HTTP {resp.status_code}")
def test_change_password_missing_field():
"""PATCH /user/{user_id}/change_password — no password field → 400."""
resp = requests.patch(
f"{API_ROOT}/user/{_test_user_id}/change_password",
json={"not_password": "whatever"},
headers=LEGACY_HEADERS,
)
print_result("Missing password field rejected (400)", resp.status_code == 400,
f"HTTP {resp.status_code}")
def test_change_password_invalid_user():
"""PATCH /user/{invalid_id}/change_password → 404."""
resp = requests.patch(
f"{API_ROOT}/user/NotARealUserID99/change_password",
json={"password": "ValidPassword123!"},
headers=LEGACY_HEADERS,
)
print_result("Invalid user_id rejected (404)", resp.status_code == 404,
f"HTTP {resp.status_code}")
# ---------------------------------------------------------------------------
# new_auth_key
# ---------------------------------------------------------------------------
def test_new_auth_key():
"""GET /user/{user_id}/new_auth_key — generates and returns a new key."""
print("\n--- new_auth_key ---")
resp = requests.get(
f"{API_ROOT}/user/{_test_user_id}/new_auth_key",
headers=LEGACY_HEADERS,
)
data = resp.json().get("data", {})
has_key = isinstance(data, dict) and bool(data.get("auth_key"))
print_result("New auth_key generated", resp.status_code == 200 and has_key,
f"HTTP {resp.status_code}")
return data.get("auth_key") if has_key else None
def test_new_auth_key_invalid_user():
"""GET /user/{invalid_id}/new_auth_key → 404."""
resp = requests.get(
f"{API_ROOT}/user/NotARealUserID99/new_auth_key",
headers=LEGACY_HEADERS,
)
print_result("Invalid user_id rejected (404)", resp.status_code == 404,
f"HTTP {resp.status_code}")
# ---------------------------------------------------------------------------
# verify_password
# ---------------------------------------------------------------------------
def _verify_result(resp) -> bool:
"""Extract the boolean result from a legacy mk_resp response.
Primitive data is wrapped as {"data": {"result": value}}.
"""
data = resp.json().get("data", {})
if isinstance(data, dict):
return data.get("result")
return data
def test_verify_password_by_username_correct():
"""POST /user/verify_password — correct password via username → result True."""
print("\n--- verify_password ---")
resp = requests.post(
f"{API_ROOT}/user/verify_password",
json={"username": _test_username, "current_password": NEW_PASSWORD},
headers=LEGACY_HEADERS,
)
result = _verify_result(resp)
success = resp.status_code == 200 and result is True
print_result("Correct password (username path)", success,
f"HTTP {resp.status_code} result={result}")
def test_verify_password_by_username_wrong():
"""POST /user/verify_password — wrong password → result not True."""
resp = requests.post(
f"{API_ROOT}/user/verify_password",
json={"username": _test_username, "current_password": "WrongPassword999!"},
headers=LEGACY_HEADERS,
)
result = _verify_result(resp)
success = result is not True
print_result("Wrong password rejected", success,
f"HTTP {resp.status_code} result={result}")
def test_verify_password_by_user_id():
"""
POST /user/verify_password — correct password via Vision ID ('id' field).
The handler reads user_obj.id (User_Base Vision ID field). Send the
Vision ID as 'id' in the request body.
"""
resp = requests.post(
f"{API_ROOT}/user/verify_password",
json={"id": _test_user_id, "current_password": NEW_PASSWORD},
headers=LEGACY_HEADERS,
)
result = _verify_result(resp)
success = resp.status_code == 200 and result is True
print_result("Correct password (Vision ID / 'id' path)", success,
f"HTTP {resp.status_code} result={result}")
def test_verify_password_missing_fields():
"""POST /user/verify_password — no user_id or username → 400."""
resp = requests.post(
f"{API_ROOT}/user/verify_password",
json={"current_password": NEW_PASSWORD},
headers=LEGACY_HEADERS,
)
print_result("Missing user fields rejected (400)", resp.status_code == 400,
f"HTTP {resp.status_code}")
# ---------------------------------------------------------------------------
# lookup, lookup_email, lookup_username
# ---------------------------------------------------------------------------
def test_lookup_by_account():
"""GET /user/lookup?for_obj_type=account&for_obj_id={account_id} — returns user list."""
print("\n--- lookup ---")
resp = requests.get(
f"{API_ROOT}/user/lookup",
params={"for_obj_type": "account", "for_obj_id": ACCOUNT_ID},
headers=LEGACY_HEADERS,
)
data = resp.json().get("data")
success = resp.status_code == 200 and isinstance(data, list) and len(data) > 0
print_result("Lookup by account (list)", success, f"HTTP {resp.status_code} count={len(data) if isinstance(data, list) else 'n/a'}")
# Vision ID check on first result
if success and isinstance(data, list) and data:
has_vision_id = assert_vision_id(data[0], "user_id")
print_result("Vision ID compliance (user_id is string)", has_vision_id,
f"user_id={data[0].get('user_id')!r}")
def test_lookup_by_person_invalid():
"""GET /user/lookup?for_obj_type=person&for_obj_id={bad_id} → 404."""
resp = requests.get(
f"{API_ROOT}/user/lookup",
params={"for_obj_type": "person", "for_obj_id": "NotARealUID999"},
headers=LEGACY_HEADERS,
)
print_result("Invalid person ID rejected (404)", resp.status_code == 404,
f"HTTP {resp.status_code}")
def test_lookup_bad_obj_type():
"""GET /user/lookup?for_obj_type=invalid → 404.
The redis lookup for for_obj_id against an unknown table returns None,
which triggers the 404 before the 400 type-check is reached.
"""
resp = requests.get(
f"{API_ROOT}/user/lookup",
params={"for_obj_type": "invoice", "for_obj_id": ACCOUNT_ID},
headers=LEGACY_HEADERS,
)
print_result("Unsupported for_obj_type returns 404", resp.status_code == 404,
f"HTTP {resp.status_code}")
def test_lookup_email():
"""GET /user/lookup_email?email={email} — finds the test user."""
print("\n--- lookup_email ---")
resp = requests.get(
f"{API_ROOT}/user/lookup_email",
params={"email": _test_email},
headers=LEGACY_HEADERS,
)
data = resp.json().get("data")
found = (
resp.status_code == 200
and isinstance(data, dict)
and data.get("email") == _test_email
)
print_result("Lookup by email (found)", found, f"HTTP {resp.status_code}")
if found:
has_vision_id = assert_vision_id(data, "user_id")
print_result("Vision ID compliance (user_id is string)", has_vision_id,
f"user_id={data.get('user_id')!r}")
def test_lookup_email_not_found():
"""GET /user/lookup_email?email={nonexistent} → 404."""
resp = requests.get(
f"{API_ROOT}/user/lookup_email",
params={"email": "nobody_at_all@test.invalid"},
headers=LEGACY_HEADERS,
)
print_result("Nonexistent email → 404", resp.status_code == 404,
f"HTTP {resp.status_code}")
def test_lookup_username():
"""GET /user/lookup_username?username={username} — finds the test user."""
print("\n--- lookup_username ---")
resp = requests.get(
f"{API_ROOT}/user/lookup_username",
params={"username": _test_username},
headers=LEGACY_HEADERS,
)
data = resp.json().get("data")
found = (
resp.status_code == 200
and isinstance(data, dict)
and data.get("username") == _test_username
)
print_result("Lookup by username (found)", found, f"HTTP {resp.status_code}")
if found:
has_vision_id = assert_vision_id(data, "user_id")
print_result("Vision ID compliance (user_id is string)", has_vision_id,
f"user_id={data.get('user_id')!r}")
def test_lookup_username_not_found():
"""GET /user/lookup_username?username={nonexistent} → 404."""
resp = requests.get(
f"{API_ROOT}/user/lookup_username",
params={"username": "no_such_user_xyz_99999"},
headers=LEGACY_HEADERS,
)
print_result("Nonexistent username → 404", resp.status_code == 404,
f"HTTP {resp.status_code}")
# ---------------------------------------------------------------------------
# email_auth_key_url
# ---------------------------------------------------------------------------
def test_email_auth_key_url():
"""
GET /user/{user_id}/email_auth_key_url — generates auth key and sends email.
NOTE: The test user email uses '@test.invalid' domain, so actual mail
delivery will fail. This test verifies the route responds correctly;
expect HTTP 500 if the mail server rejects the send. The auth key IS
generated and stored regardless of email success.
"""
print("\n--- email_auth_key_url ---")
resp = requests.get(
f"{API_ROOT}/user/{_test_user_id}/email_auth_key_url",
params={"root_url": "https://dev-app.oneskyit.com"},
headers=LEGACY_HEADERS,
)
# 200 = email sent; 500 = route hit but email delivery failed (acceptable for .invalid)
route_hit = resp.status_code in [200, 500]
print_result("Route reachable", route_hit, f"HTTP {resp.status_code}"
+ (" (email delivery failed — expected for .invalid domain)" if resp.status_code == 500 else ""))
def test_email_auth_key_url_invalid_user():
"""GET /user/{invalid_id}/email_auth_key_url → 404."""
resp = requests.get(
f"{API_ROOT}/user/NotARealUserID99/email_auth_key_url",
params={"root_url": "https://dev-app.oneskyit.com"},
headers=LEGACY_HEADERS,
)
print_result("Invalid user_id rejected (404)", resp.status_code == 404,
f"HTTP {resp.status_code}")
# ---------------------------------------------------------------------------
# BUG VERIFICATION: user_authenticate route
# ---------------------------------------------------------------------------
def test_authenticate():
"""
GET /user/authenticate — authenticate with username + password.
Note: The @router.get() decorator was accidentally commented out in a
prior version (user.py line 226). That bug has been fixed. This test
verifies the route is reachable and returns user data on success.
"""
print("\n--- authenticate ---")
resp = requests.get(
f"{API_ROOT}/user/authenticate",
params={"username": _test_username, "password": NEW_PASSWORD},
headers=LEGACY_HEADERS,
)
data = resp.json().get("data")
success = resp.status_code == 200 and isinstance(data, dict) and bool(data.get("user_id") or data.get("id"))
print_result("authenticate (username+password)", success, f"HTTP {resp.status_code}")
if success:
has_vision_id = assert_vision_id(data, "user_id")
print_result("Vision ID compliance (user_id is string)", has_vision_id,
f"user_id={data.get('user_id')!r}")
# Wrong password should be rejected
resp2 = requests.get(
f"{API_ROOT}/user/authenticate",
params={"username": _test_username, "password": "WrongPassword000!"},
headers=LEGACY_HEADERS,
)
print_result("Wrong password rejected", resp2.status_code in [200, 404] and resp2.json().get("data") is not True,
f"HTTP {resp2.status_code}")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
if __name__ == "__main__":
suite_start = time.time()
print("=" * 60)
print("User Auth Routes E2E Test Suite")
print(f"API: {API_ROOT}")
print("=" * 60)
# --- Setup ---
print("\n[Setup]")
user_id = setup_test_user()
if not user_id:
print("\n❌ Setup failed — cannot run tests. Aborting.")
sys.exit(1)
# --- Tests ---
test_change_password()
test_change_password_too_short()
test_change_password_missing_field()
test_change_password_invalid_user()
test_new_auth_key()
test_new_auth_key_invalid_user()
test_verify_password_by_username_correct()
test_verify_password_by_username_wrong()
test_verify_password_by_user_id()
test_verify_password_missing_fields()
test_lookup_by_account()
test_lookup_by_person_invalid()
test_lookup_bad_obj_type()
test_lookup_email()
test_lookup_email_not_found()
test_lookup_username()
test_lookup_username_not_found()
test_email_auth_key_url()
test_email_auth_key_url_invalid_user()
test_authenticate()
# --- Teardown ---
print("\n[Teardown]")
teardown_test_user(user_id)
elapsed = time.time() - suite_start
print(f"\n{'=' * 60}")
print(f"Suite completed in {elapsed:.2f}s")
print("=" * 60)