Compare commits
3 Commits
c8377a2b22
...
c7335bbc3e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7335bbc3e | ||
|
|
45a5acd45d | ||
|
|
c64c3bc55a |
@@ -139,6 +139,8 @@ class Event_Session_Base(BaseModel):
|
||||
created_on: Optional[datetime.datetime] = None
|
||||
updated_on: Optional[datetime.datetime] = None
|
||||
default_qry_str: Optional[str] # Default query string used for searching and filtering sessions. Updated using SQL triggers and a SQL function
|
||||
event_presentation_li_qry_str: Optional[str] # Concatenated query string of presentation data for this session (from v_event_session_w_file_count)
|
||||
event_presenter_li_qry_str: Optional[str] # Concatenated query string of presenter data for this session (from v_event_session_w_file_count)
|
||||
|
||||
# Including convenience data
|
||||
# This is only for convenience. Probably going to keep unless it causes a problem.
|
||||
|
||||
@@ -42,6 +42,23 @@ class Site_Domain_Base(BaseModel):
|
||||
created_on: Optional[datetime.datetime] = None
|
||||
updated_on: Optional[datetime.datetime] = None
|
||||
|
||||
# Convenience fields from v_site_domain view (joined from account/site)
|
||||
account_code: Optional[str] = None
|
||||
account_name: Optional[str] = None
|
||||
account_enable: Optional[bool] = None
|
||||
account_enable_from: Optional[datetime.datetime] = None
|
||||
account_enable_to: Optional[datetime.datetime] = None
|
||||
|
||||
site_enable_from: Optional[datetime.datetime] = None
|
||||
site_enable_to: Optional[datetime.datetime] = None
|
||||
site_domain_access_key: Optional[str] = None
|
||||
|
||||
logo_path: Optional[str] = None
|
||||
style_href: Optional[str] = None
|
||||
script_src: Optional[str] = None
|
||||
|
||||
google_tracking_id: Optional[str] = None
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
@root_validator(pre=True)
|
||||
@@ -55,18 +72,18 @@ class Site_Domain_Base(BaseModel):
|
||||
if rid := values.get('id_random') or values.get('site_domain_id_random'):
|
||||
values['id'] = rid
|
||||
values['site_domain_id'] = rid
|
||||
|
||||
|
||||
if s_rid := values.get('site_id_random'):
|
||||
values['site_id'] = s_rid
|
||||
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'site_id', 'account_id']:
|
||||
if k in values and not isinstance(values[k], str):
|
||||
del values[k]
|
||||
|
||||
|
||||
return values
|
||||
|
||||
class Config:
|
||||
@@ -98,7 +115,7 @@ class Site_Domain_FQDN_ID_Base(BaseModel):
|
||||
enable: Optional[bool]
|
||||
|
||||
hide: Optional[bool] = None
|
||||
|
||||
|
||||
notes: Optional[str] = None
|
||||
created_on: Optional[datetime.datetime] = None
|
||||
updated_on: Optional[datetime.datetime] = None
|
||||
@@ -133,7 +150,7 @@ class Site_Domain_FQDN_ID_Base(BaseModel):
|
||||
values['site_id'] = s_rid
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
|
||||
|
||||
for k in ['id', 'site_id', 'account_id']:
|
||||
if k in values and not isinstance(values[k], str):
|
||||
del values[k]
|
||||
|
||||
@@ -135,7 +135,8 @@ events_presentation_obj_li = {
|
||||
'poc_person_full_name',
|
||||
'public', 'public_hide', 'hide_event_launcher',
|
||||
'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on',
|
||||
'default_qry_str', 'event_location_name'
|
||||
'default_qry_str', 'event_location_name',
|
||||
'event_presentation_li_qry_str', 'event_presenter_li_qry_str'
|
||||
],
|
||||
},
|
||||
'event_track': {
|
||||
|
||||
@@ -28,6 +28,21 @@ from app.models.response_models import Resp_Body_Base, mk_resp
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _clean_datetime(value) -> str | None:
|
||||
"""Normalize datetime strings from CSV imports (handles \xa0 from Excel, 12-hour format)."""
|
||||
if not value:
|
||||
return None
|
||||
cleaned = str(value).replace('\xa0', ' ').strip()
|
||||
if not cleaned:
|
||||
return None
|
||||
for fmt in ('%m/%d/%Y %I:%M %p', '%m/%d/%Y %H:%M', '%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M'):
|
||||
try:
|
||||
return datetime.datetime.strptime(cleaned, fmt).strftime('%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
continue
|
||||
return cleaned
|
||||
|
||||
|
||||
# No longer needed? 2024-08-15
|
||||
# Based on the program import template the clients are given.
|
||||
# Ideally the import file should only contain records with new External IDs. Old records will be checked and only updated if needed.
|
||||
@@ -332,7 +347,10 @@ router = APIRouter()
|
||||
# ### BEGIN ### Event Importing ### event_importing_program_data() ###
|
||||
# Based on the program import template the clients are given.
|
||||
# Create and update locations, sessions, presentations, and presenters as needed.
|
||||
# Updated 2024-03-25
|
||||
# Careful with how date and time fields are combined
|
||||
# This should work: =TEXT(G2,"M/D/YYYY")&" "&TEXT(H2,"H:MM AM/PM")
|
||||
# Simply adding the fields (=D264+E264) sort of works. This produces non breaking spaces but clean up on import.
|
||||
# Updated 2026-05-15
|
||||
@router.post('/event/{event_id}/importing/program_data', response_model=Resp_Body_Base)
|
||||
async def event_importing_program_data(
|
||||
event_id: str = Path(min_length=11, max_length=22),
|
||||
@@ -656,13 +674,8 @@ async def event_importing_program_data(
|
||||
if record.get('session_description'):
|
||||
event_session_data['description'] = record.get('session_description', '').strip()
|
||||
|
||||
event_session_data['start_datetime'] = record.get('session_start_datetime', '').strip()
|
||||
# event_session_start_datetime = record.get('event_session_start_date', '') + ' ' + record.get('event_session_start_time', '')
|
||||
# event_session_data['start_datetime'] = event_session_start_datetime
|
||||
|
||||
event_session_data['end_datetime'] = record.get('session_end_datetime', '').strip()
|
||||
# event_session_end_datetime = record.get('event_session_end_date', '') + ' ' + record.get('event_session_end_time', '')
|
||||
# event_session_data['end_datetime'] = event_session_end_datetime
|
||||
event_session_data['start_datetime'] = _clean_datetime(record.get('session_start_datetime'))
|
||||
event_session_data['end_datetime'] = _clean_datetime(record.get('session_end_datetime'))
|
||||
|
||||
event_session_data['sort'] = record.get('session_sort')
|
||||
|
||||
@@ -736,19 +749,11 @@ async def event_importing_program_data(
|
||||
if record.get('presentation_description'):
|
||||
event_presentation_data['description'] = record.get('presentation_description', '').strip()
|
||||
|
||||
if record.get('presentation_start_datetime'):
|
||||
event_presentation_data['start_datetime'] = record.get('presentation_start_datetime', '').strip()
|
||||
data['presentation_start_datetime'] = event_presentation_data['start_datetime']
|
||||
else:
|
||||
event_presentation_data['start_datetime'] = None
|
||||
data['presentation_start_datetime'] = None
|
||||
event_presentation_data['start_datetime'] = _clean_datetime(record.get('presentation_start_datetime'))
|
||||
data['presentation_start_datetime'] = event_presentation_data['start_datetime']
|
||||
|
||||
if record.get('presentation_end_datetime'):
|
||||
event_presentation_data['end_datetime'] = record.get('presentation_end_datetime', '').strip()
|
||||
data['presentation_end_datetime'] = event_presentation_data['end_datetime']
|
||||
else:
|
||||
event_presentation_data['end_datetime'] = None
|
||||
data['presentation_end_datetime'] = None
|
||||
event_presentation_data['end_datetime'] = _clean_datetime(record.get('presentation_end_datetime'))
|
||||
data['presentation_end_datetime'] = event_presentation_data['end_datetime']
|
||||
|
||||
if record.get('presentation_abstract_code'):
|
||||
event_presentation_data['abstract_code'] = record.get('presentation_abstract_code', '').strip()
|
||||
|
||||
@@ -45,14 +45,25 @@ When a table or view gains, loses, or renames fields, keep the API contract and
|
||||
3. Update `searchable_fields` in `app/object_definitions/` only for fields that should be searchable.
|
||||
4. Add write-only, virtual, or view-only fields to `fields_to_exclude_from_db` when they must not be persisted.
|
||||
5. Run the schema/search E2E tests that cover the object type before handing the change off.
|
||||
6. **Restart the Docker API containers** (`docker compose restart ae_api`) — Python file changes inside containers are not picked up until restart.
|
||||
|
||||
For `archive_content`, the public field set now includes `external_id` and `code`, and future additions should follow the same order of operations.
|
||||
|
||||
### Response Views (Proposed)
|
||||
- Implement a `view` parameter (e.g., `?view=rich`) to allow clients to request joined data without using legacy `use_alt_tbl` flags.
|
||||
#### Alt-view fields (fields only in `tbl_alt`)
|
||||
Some objects have a richer alternate SQL view (`tbl_alt`) that adds JOINed/computed columns absent from the default view (`tbl_default`). For example, `event_session` uses `v_event_session_w_file_count` as its alt view (triggered by `?view=alt` on search, or `?inc_file_count=true` on GET).
|
||||
|
||||
- Fields from `tbl_alt` **must still be declared in the Pydantic model** and in `searchable_fields` — Pydantic strips undeclared fields, and the search whitelist rejects unknown field names regardless of the view.
|
||||
- When adding such a field, add a comment noting which view provides it (e.g., `# from v_event_session_w_file_count`).
|
||||
- Searching by an alt-view field on the default endpoint returns `400 Unknown column` — this is correct behaviour. Clients must pass `?view=alt` to use those fields in a search.
|
||||
- **Known alt-view fields restored May 2026:** `event_presentation_li_qry_str`, `event_presenter_li_qry_str` (event_session); `account_name`, `account_code`, and related convenience fields (site_domain).
|
||||
|
||||
### Response Views (`?view=` parameter)
|
||||
- The nested search router (`api_crud_v3_nested.py`) already supports `?view=<key>` to switch between registered views. `view=default` uses `tbl_default`; `view=alt` uses `tbl_alt`; additional named views can be added to the object registry as `tbl_<name>` / `mdl_<name>`.
|
||||
- Flat search (`api_crud_v3.py`) does not yet support `?view=` — it always uses `tbl_default`.
|
||||
|
||||
## 4. Stability Rules
|
||||
|
||||
1. **Baby Step Testing**: Restart Docker and verify root health after *every* modular change.
|
||||
2. **Avoid Shadowing**: Never name a module part of the `app.` package the same as a common instance variable (e.g., avoid `app.middleware` package if you use `app = FastAPI()`).
|
||||
3. **Deferred Imports**: Use `from app.db_sql import ...` *inside* functions in library modules to prevent circular dependency traps.
|
||||
4. **Model changes require container restart**: Editing Python files on the host does not hot-reload inside Docker. Always run `docker compose restart ae_api` after model or object-definition changes, then re-run E2E tests.
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
- [x] Whitelist `account_id` in all Event search definitions.
|
||||
- [x] Audit Relational "Low-Priority" Models (Address, Contact, DataStore).
|
||||
- [x] **V3 Uniform Lookup System:** Phase 1 & 2 Complete.
|
||||
- [x] **Restore alt-view convenience fields lost in v1→v3 migration (May 2026):** `site_domain` (`account_name`, `account_code`, `account_enable`, `account_enable_from/to`, `site_enable_from/to`, `site_domain_access_key`, `logo_path`, `style_href`, `script_src`, `google_tracking_id`) and `event_session` (`event_presentation_li_qry_str`, `event_presenter_li_qry_str`). Fields added to Pydantic models and `searchable_fields`. Alt-view fields require `?view=alt` for search.
|
||||
- [ ] Verify SQL Views join in all required `_random` IDs for performance.
|
||||
- [ ] **Step 2:** Coordination (Verify Frontend uses `x-account-id` instead of token).
|
||||
- [ ] **Step 3:** Frontend V3 WebSocket integration test — queued after IDAA-specific work. Backend is ready (auth wired, heartbeat presence refresh confirmed, unit tests passing). Frontend guide updated at `GUIDE__AE_API_V3_for_Frontend_websockets.md`.
|
||||
|
||||
@@ -64,17 +64,72 @@ def test_extra_filters():
|
||||
resp = requests.get(f"{API_BASE}/user/?enabled=all&hidden=all", headers=get_headers())
|
||||
print_result("Bypass Filters (enabled=all)", resp.status_code == 200)
|
||||
|
||||
def test_event_session_qry_str_fields():
|
||||
"""
|
||||
Regression test for event_presentation_li_qry_str and event_presenter_li_qry_str.
|
||||
These fields were lost during the v1/v2 -> v3 migration and restored May 2026.
|
||||
They live in v_event_session_w_file_count (triggered by ?inc_file_count=true).
|
||||
|
||||
Demo session: DOW3h7v6H42 "How To Do Things" under Demo event pjrcghqwert
|
||||
"""
|
||||
print("\n--- Testing event_session qry_str fields (regression: May 2026) ---")
|
||||
|
||||
EVENT_ID = "pjrcghqwert"
|
||||
SESSION_ID = "DOW3h7v6H42"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Aether-API-Key": API_KEY,
|
||||
"x-no-account-id": "bypass"
|
||||
}
|
||||
|
||||
# 1. Verify fields are returned in the GET response when inc_file_count=true
|
||||
url = f"{API_BASE}/event_session/{SESSION_ID}?inc_file_count=true"
|
||||
resp = requests.get(url, headers=headers)
|
||||
ok = resp.status_code == 200
|
||||
print_result("GET event_session with inc_file_count", ok, f"(status={resp.status_code})")
|
||||
if ok:
|
||||
data = resp.json().get("data", {})
|
||||
has_pres = "event_presentation_li_qry_str" in data
|
||||
has_presenter = "event_presenter_li_qry_str" in data
|
||||
print_result("Field present: event_presentation_li_qry_str", has_pres,
|
||||
f"(value={data.get('event_presentation_li_qry_str')!r})")
|
||||
print_result("Field present: event_presenter_li_qry_str", has_presenter,
|
||||
f"(value={data.get('event_presenter_li_qry_str')!r})")
|
||||
|
||||
# 2. Verify searching by event_presentation_li_qry_str via ?view=alt (v_event_session_w_file_count)
|
||||
# These fields only exist in the alt view, so ?view=alt is required.
|
||||
search_url = f"{API_BASE}/event/{EVENT_ID}/event_session/search?view=alt"
|
||||
query = {"and": [{"field": "event_presentation_li_qry_str", "op": "like", "value": "%"}]}
|
||||
resp = requests.post(search_url, headers=headers, json=query)
|
||||
print_result("Search by event_presentation_li_qry_str (?view=alt)", resp.status_code == 200,
|
||||
f"(status={resp.status_code})")
|
||||
|
||||
# 3. Verify searching by event_presenter_li_qry_str via ?view=alt
|
||||
query = {"and": [{"field": "event_presenter_li_qry_str", "op": "like", "value": "%"}]}
|
||||
resp = requests.post(search_url, headers=headers, json=query)
|
||||
print_result("Search by event_presenter_li_qry_str (?view=alt)", resp.status_code == 200,
|
||||
f"(status={resp.status_code})")
|
||||
|
||||
# 4. Confirm search on default view still rejects these fields (expected 400 — not in v_event_session)
|
||||
search_url_default = f"{API_BASE}/event/{EVENT_ID}/event_session/search"
|
||||
query = {"and": [{"field": "event_presentation_li_qry_str", "op": "like", "value": "%"}]}
|
||||
resp = requests.post(search_url_default, headers=headers, json=query)
|
||||
print_result("Search on default view correctly rejects qry_str field (expect 400)", resp.status_code == 400,
|
||||
f"(status={resp.status_code})")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"Starting Consolidated Search Engine E2E Suite")
|
||||
print(f"Target: {API_BASE}")
|
||||
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
test_basic_operators()
|
||||
test_registry_fields()
|
||||
test_nested_search()
|
||||
test_extra_filters()
|
||||
test_event_session_qry_str_fields()
|
||||
except Exception as e:
|
||||
print(f"💥 Suite Error: {e}")
|
||||
|
||||
|
||||
print(f"\nSuite completed in {time.time() - start_time:.2f}s")
|
||||
|
||||
Reference in New Issue
Block a user