Compare commits
14 Commits
a0767b1c69
...
687472f4e3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
687472f4e3 | ||
|
|
91434968f7 | ||
|
|
6bde236633 | ||
|
|
cffde249d3 | ||
|
|
9d5f2c8cea | ||
|
|
b9742cfcd8 | ||
|
|
b2adfe409b | ||
|
|
b55b7ea81d | ||
|
|
8eb699efe5 | ||
|
|
c7f1341b1e | ||
|
|
15b5084df3 | ||
|
|
c9ec3d7ea1 | ||
|
|
ccf2f30e11 | ||
|
|
f23d27de15 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -141,4 +141,7 @@ logs/
|
||||
myapp/files/
|
||||
myapp/file_distribution/
|
||||
temp/
|
||||
tmp/
|
||||
tmp/
|
||||
|
||||
# Added 2026-03-23
|
||||
gunicorn.ctl
|
||||
|
||||
@@ -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.
|
||||
|
||||
14
app/main.py
14
app/main.py
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() ###
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
299
app/routers/api_v3_actions_user.py
Normal file
299
app/routers/api_v3_actions_user.py
Normal 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.')
|
||||
@@ -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() ###
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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 11–22), 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.
|
||||
|
||||
144
documentation/PROJECT__AE_Lookups_fixes_and_docs_update.md
Normal file
144
documentation/PROJECT__AE_Lookups_fixes_and_docs_update.md
Normal 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
|
||||
124
documentation/PROJECT__AE_hosted_files_uploads_util.md
Normal file
124
documentation/PROJECT__AE_hosted_files_uploads_util.md
Normal 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.
|
||||
@@ -15,7 +15,7 @@ email-validator
|
||||
et-xmlfile
|
||||
fastapi>=0.115.5
|
||||
# greenlet
|
||||
gunicorn
|
||||
gunicorn==23.0.0
|
||||
h11
|
||||
html2text
|
||||
httpcore
|
||||
|
||||
@@ -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")
|
||||
|
||||
498
tests/e2e/test_e2e_v3_user_action_routes.py
Normal file
498
tests/e2e/test_e2e_v3_user_action_routes.py
Normal 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 11–22 (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()
|
||||
511
tests/e2e/test_e2e_v3_user_auth_routes.py
Normal file
511
tests/e2e/test_e2e_v3_user_auth_routes.py
Normal 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)
|
||||
Reference in New Issue
Block a user