Compare commits
184 Commits
release/20
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22e5a3c3fd | ||
|
|
9962176c74 | ||
|
|
35fa5132e7 | ||
|
|
e19fd63d1f | ||
|
|
051b2fd7ac | ||
|
|
221854df90 | ||
|
|
c7335bbc3e | ||
|
|
45a5acd45d | ||
|
|
c64c3bc55a | ||
|
|
c8377a2b22 | ||
|
|
f6ba339276 | ||
|
|
ed66ba4bd4 | ||
|
|
44e4f5c4e6 | ||
|
|
c378040ad4 | ||
|
|
b590bc09a0 | ||
|
|
e71906b59a | ||
|
|
3d89e95c24 | ||
|
|
3db5f7c749 | ||
|
|
55debc8009 | ||
|
|
ace00929f2 | ||
|
|
c7444a8a89 | ||
|
|
8f1fe5d4df | ||
|
|
c0626e061e | ||
|
|
dfb5289188 | ||
|
|
0ecc5a97d5 | ||
|
|
516865b7d8 | ||
|
|
7f9666dc1e | ||
|
|
f9f588ddf2 | ||
|
|
ea25bf78d4 | ||
|
|
c837d465ca | ||
|
|
2659047d24 | ||
|
|
18374f855f | ||
|
|
e5acefe8f6 | ||
|
|
082163b5df | ||
|
|
e35fdb4f67 | ||
|
|
02a2be7275 | ||
|
|
eba3456b7b | ||
|
|
987b552157 | ||
|
|
7ad158883a | ||
|
|
2b608d7a1a | ||
|
|
535fc9f2b5 | ||
|
|
8e9fb88e5a | ||
|
|
42eaa6676e | ||
|
|
b5c50fd116 | ||
|
|
2a1f270db6 | ||
|
|
ebc5db96da | ||
|
|
153c2ce6dd | ||
|
|
9faf22d841 | ||
|
|
293f447a1c | ||
|
|
4629e1ec63 | ||
|
|
1f9cbb0a1f | ||
|
|
7f87f32b70 | ||
|
|
687472f4e3 | ||
|
|
91434968f7 | ||
|
|
6bde236633 | ||
|
|
cffde249d3 | ||
|
|
9d5f2c8cea | ||
|
|
b9742cfcd8 | ||
|
|
b2adfe409b | ||
|
|
b55b7ea81d | ||
|
|
8eb699efe5 | ||
|
|
c7f1341b1e | ||
|
|
15b5084df3 | ||
|
|
c9ec3d7ea1 | ||
|
|
ccf2f30e11 | ||
|
|
f23d27de15 | ||
|
|
a0767b1c69 | ||
|
|
356f4b8efc | ||
|
|
74ad69bc63 | ||
|
|
d7b86cc186 | ||
|
|
9adf5659bc | ||
|
|
308a7f296f | ||
|
|
950e34cabd | ||
|
|
6b25cf9c6d | ||
|
|
29579fd9f1 | ||
|
|
5f3ba1e03e | ||
|
|
eaa18a1d45 | ||
|
|
ee28a4f26e | ||
|
|
e608696ec8 | ||
|
|
c7c14e8047 | ||
|
|
c30631cb7d | ||
|
|
8f0e6c16bc | ||
|
|
49952074aa | ||
|
|
32b519c507 | ||
|
|
8c7263fdbf | ||
|
|
44fa28fab3 | ||
|
|
a20c436013 | ||
|
|
fbbc186af0 | ||
|
|
57195bca30 | ||
|
|
03be0ac062 | ||
|
|
f110c2eecb | ||
|
|
3111ed5f22 | ||
|
|
f1c8958a7a | ||
|
|
42aa318ba0 | ||
|
|
fc3277086f | ||
|
|
32560d2257 | ||
|
|
d35f374a45 | ||
|
|
25de8b9400 | ||
|
|
7a6ccc2520 | ||
|
|
e41a6da575 | ||
|
|
89e12b9f97 | ||
|
|
403b543ed2 | ||
|
|
bc78ac4c2e | ||
|
|
0f4b4d2f51 | ||
|
|
9d89d4c8e4 | ||
|
|
719ca5240b | ||
|
|
f518d7a433 | ||
|
|
48fc97cf46 | ||
|
|
6bfbff309a | ||
|
|
2b2a2bc00f | ||
|
|
6a023a82f5 | ||
|
|
1db71f85a5 | ||
|
|
17a627a981 | ||
|
|
577d784fb8 | ||
|
|
aca15aab91 | ||
|
|
2266f149f7 | ||
|
|
61e17f1efa | ||
|
|
3e6ea108cf | ||
|
|
17ae70992f | ||
|
|
6d5633dc86 | ||
|
|
68e883ba98 | ||
|
|
9715d28bd6 | ||
|
|
e8f9472c5c | ||
|
|
fe368e2f64 | ||
|
|
7084dd3472 | ||
|
|
42bea571e9 | ||
|
|
2a7c27ba80 | ||
|
|
8270f7ff7a | ||
|
|
64d73c4d5c | ||
|
|
d5e685dee8 | ||
|
|
f3662f9462 | ||
|
|
4aadb4ec1c | ||
|
|
b63131e3fa | ||
|
|
b9c00e423c | ||
|
|
b10b5839c7 | ||
|
|
1053d8a81b | ||
|
|
37a43babb9 | ||
|
|
1492b01dad | ||
|
|
a7c82615ab | ||
|
|
78f04bca50 | ||
|
|
03a1569eba | ||
|
|
1cfbf9ebad | ||
|
|
12d725f468 | ||
|
|
907ff9a2f8 | ||
|
|
ac516c4d77 | ||
|
|
2fe783784c | ||
|
|
cc5af1c2e2 | ||
|
|
e29ff23f32 | ||
|
|
69622dbea6 | ||
|
|
37c84de57b | ||
|
|
29f6cf258f | ||
|
|
d43474ea4b | ||
|
|
53db20f627 | ||
|
|
54d6bd8864 | ||
|
|
5eaae99702 | ||
|
|
03154ab95b | ||
|
|
fde729d474 | ||
|
|
d957f5d167 | ||
|
|
9362938ffe | ||
|
|
9e423806df | ||
|
|
6f7fde7b87 | ||
|
|
07609bae9a | ||
|
|
faa6de866d | ||
|
|
bcd466edc7 | ||
|
|
ea117bf268 | ||
|
|
f449e59b55 | ||
|
|
b89264fe19 | ||
|
|
192a5d76b5 | ||
|
|
39391e5949 | ||
|
|
48c3ce76f0 | ||
|
|
a02abbbe4f | ||
|
|
cd19c738f1 | ||
|
|
5d91c05925 | ||
|
|
b862d59e65 | ||
|
|
0de6058639 | ||
|
|
51b24a466a | ||
|
|
470a26f9c3 | ||
|
|
9dd941eb36 | ||
|
|
0606cecb61 | ||
|
|
fdcc859017 | ||
|
|
9c0aae9a6d | ||
|
|
0f8c5dc825 | ||
|
|
860cf80a4e | ||
|
|
3eaf176b05 |
@@ -1,15 +1,15 @@
|
||||
# Aether Project Brief: aether_api_fastapi
|
||||
**Last Updated:** 2026-01-16 17:22:55
|
||||
**Last Updated:** 2026-02-09 19:09:01
|
||||
**Current Agent:** mcp_agent
|
||||
|
||||
## 🛠️ What I Just Did
|
||||
1. Resolved 'Bootstrap Paradox' bug in lib_config_v3.py (hosted file path fix). 2. Developed Aether Field Manager (ae_field_manage.py) with table/view snapshotting and complex view detection. 3. Verified infrastructure and dry-run logic for vertical-slice field management.
|
||||
Hardened Data Store search security (account isolation), ensured ID Vision compliance in Data Store search results, refactored and standardized the Data Store E2E test suite, and updated tests/README.md with suite-wide standards.
|
||||
|
||||
## 🚧 Current Blockers
|
||||
None. Awaiting user verification of the first 'execute' run for the Field Manager.
|
||||
None. V3.1 roadmap is clear.
|
||||
|
||||
## ➡️ Exact Next Steps
|
||||
1. Execute real-world test of ae_field_manage.py with user. 2. Proceed with Journal Management architecture review (Task 155435511). 3. Initiate Pydantic V2 migration impact analysis.
|
||||
Begin [V3.1] ID Vision alignment for Person and Organization modules. Mark legacy V2 routers as [DEPRECATED] to streamline removal for the v3.1 release.
|
||||
|
||||
---
|
||||
*Generated by ae_brief*
|
||||
|
||||
23
.dockerignore
Normal file
23
.dockerignore
Normal file
@@ -0,0 +1,23 @@
|
||||
# Docker ignore file for Aether API
|
||||
environment/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
.venv/
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
documentation/
|
||||
tests/
|
||||
logs/
|
||||
temp/
|
||||
# Don't ignore requirements.txt or Dockerfile!
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -130,6 +130,7 @@ Thumbs.db
|
||||
.vscode
|
||||
flask_config.py
|
||||
config.py
|
||||
!app/config.py
|
||||
# config.cfg
|
||||
# users.cfg
|
||||
|
||||
@@ -140,4 +141,7 @@ logs/
|
||||
myapp/files/
|
||||
myapp/file_distribution/
|
||||
temp/
|
||||
tmp/
|
||||
tmp/
|
||||
|
||||
# Added 2026-03-23
|
||||
gunicorn.ctl
|
||||
|
||||
36
Dockerfile
Normal file
36
Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Aether API - FastAPI + Gunicorn
|
||||
FROM tiangolo/uvicorn-gunicorn-fastapi:python3.11
|
||||
|
||||
LABEL maintainer="Scott Idem <scott.idem@oneskyit.com>"
|
||||
|
||||
# 1. Install OS dependencies FIRST.
|
||||
# These are the slowest to install and change the least.
|
||||
# Doing this before WORKDIR or any COPY ensures maximum caching.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
imagemagick ffmpeg curl poppler-utils && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 2. Set the working directory
|
||||
WORKDIR /srv/aether_api
|
||||
|
||||
# 3. Install Python requirements
|
||||
# We only copy requirements.txt first to keep the pip install layer cached
|
||||
# as long as the dependencies themselves don't change.
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||
|
||||
# 4. Create a reference of actual installed versions
|
||||
RUN pip freeze > /tmp/aether_fastapi_requirements_current.txt
|
||||
|
||||
# NOTE: The application source is mounted as a volume in docker-compose.yml
|
||||
# for real-time development. We don't COPY the source here to keep the
|
||||
# image generic and the build near-instant when code changes.
|
||||
|
||||
# Docker health check — verifies DB + Redis connectivity via the /health route.
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
# CMD curl -f http://localhost/health || exit 1
|
||||
CMD curl -f http://localhost:5005/health || exit 1
|
||||
|
||||
CMD ["gunicorn", "--conf", "/conf/gunicorn_fastapi_conf.py"]
|
||||
134
GEMINI.md
134
GEMINI.md
@@ -1,110 +1,40 @@
|
||||
# Gemini Agent Context: Aether API Orchestrator
|
||||
# Aether Backend Agent Context: Gemini CLI Standard
|
||||
> **Role:** Aether API Orchestrator (Backend & System Architecture)
|
||||
> **Location:** GEMINI.md (Project Root)
|
||||
|
||||
> **Template Version:** 1.2 (2026-01-26)
|
||||
> **Purpose:** Standardized memory structure for all Aether Agents.
|
||||
> **Structure:** Inverted Pyramid (Foundational -> Strategic -> Tactical -> Reference).
|
||||
|
||||
## 1. 💾 Long Term Memory (System & Facts)
|
||||
*This section contains the "Universal Truths" that rarely change. It grounds the agent in the user's reality.*
|
||||
|
||||
### 🤖 Agent Identity & Role
|
||||
- **Agent Name:** Aether API Orchestrator (mcp_agent)
|
||||
- **Primary Role:** Backend Development, System Orchestration, and API Stabilization.
|
||||
- **Scope:** `/home/scott/OSIT_dev/aether_api_fastapi` and Aether Platform backend infrastructure.
|
||||
|
||||
### 👤 User Profile
|
||||
- **User:** Scott Idem (`scott`)
|
||||
- **Organizations:**
|
||||
- **One Sky IT (OSIT):** Professional/Business context.
|
||||
- **Danger Zone (DgrZone):** Personal/Home context.
|
||||
- **Aether Platform (AE):** Scott's (One Sky IT) platform developed for OSIT.
|
||||
- **Preferences:**
|
||||
- **Editor:** `vim` (Terminal), VS Code (GUI).
|
||||
- **Communication:** Direct, concise, professional CLI tone.
|
||||
- **Safety:** "Recycle Bin" (`~/tmp/gemini_trash`) instead of `rm`. Explain destructive actions first.
|
||||
- **Hardware/OS:**
|
||||
- **Host:** Linux (Ubuntu/Arch context)
|
||||
|
||||
### 🏗️ Aether Architecture (V3)
|
||||
- **Concept:** Unified AI-driven platform for business/personal management.
|
||||
- **Backend:** FastAPI (v4.9.0) + Pydantic V1 + SQLAlchemy + MariaDB (Remote).
|
||||
- **V3 Implementation:** Modern parallel CRUD and Search endpoints under `/v3/crud`.
|
||||
- **Core Principle:** "Agent Bridge" - Distributed agents coordinating via file-based messaging (`~/agents_sync`).
|
||||
|
||||
### 📜 Core Protocols
|
||||
- **RAR Protocol:** Request -> Ack -> Result.
|
||||
- **V3 CRUD Paradigm:** JSON metadata via `/v3/crud/`, binary actions (Upload/Download) via `/v3/action/`.
|
||||
- **Fail Fast & Transparently:** API returns `500` on hard errors; avoid silent failures (confirmed in `sql_select`).
|
||||
- **Bite-Sized Data:** Avoid monolithic files (>1MB).
|
||||
- **Source of Truth:** `~/agents_sync` is the shared brain. `~/OSIT_dev` is the local development environment.
|
||||
|
||||
### 🛡️ Security & Secrets Guardrails
|
||||
- **Secrets:** NEVER read/display content from `.env` files unless explicitly debugging configuration logic.
|
||||
- **PII:** Scrub personally identifiable information if sharing logs or data across the bridge.
|
||||
- **Hiding Internal Paths:** `subdirectory_path` is hidden from public-facing API responses via Pydantic `Field(exclude=True)`.
|
||||
|
||||
### 🧠 Key Technical Learnings (Cumulative)
|
||||
- **Circular Dependencies Fixed**: Successfully resolved the fragile startup dependency chain by isolating Auth models and using strictly deferred DB imports in a dedicated `dependencies_v3.py` module.
|
||||
- **Bootstrap Paradox Solved**: Implemented a guest-access exception for `site_domain` search, allowing the frontend to resolve site context without a JWT.
|
||||
- **V3 Searchable Fields**: `searchable_fields` must explicitly include integer ID fields (e.g., `event_id`) to ensure valid numeric filters are not blocked by the V3 search security layer.
|
||||
- **NULL Logic in Filters**: Confirmed that explicit frontend filters like `hide: false` will FAIL to match `NULL` database values. Rely on the API's built-in `hidden=not_hidden` parameter for robust handling.
|
||||
- **Vision ID Safety Net**: Enhanced `lookup_id_random_pop` to resolve random string IDs found in any `*_id` field, ensuring "Vision" style payloads are correctly converted to integers.
|
||||
## 🚨 MANDATORY PROTOCOL
|
||||
You must follow the safety, testing, and coordination standards defined in:
|
||||
`documentation/GUIDE__DEVELOPMENT.md`
|
||||
|
||||
---
|
||||
|
||||
## 2. 🗓️ Near Term Memory (Strategic Context)
|
||||
*This section tracks active projects (1-2 weeks scope). It answers "Why are we doing this?"*
|
||||
## 🏗️ Technical Domain: Aether Backend
|
||||
### Stack & Infrastructure
|
||||
- **API Framework:** FastAPI (v0.95+)
|
||||
- **Validation:** Pydantic V1 (Strict)
|
||||
- **Database:** MariaDB + SQLAlchemy (v1.4+)
|
||||
- **ID Standard:** "ID Vision" - Public String IDs (`id_random`) mapping to hidden Internal Integer IDs.
|
||||
- **Communication:** RAR Protocol (Request -> Ack -> Result).
|
||||
|
||||
### 📩 In-Flight RAR Requests
|
||||
- [ ] **mcp_agent**: Real-world test of `ae_field_manage.py` (ID: 153357623).
|
||||
- [ ] **codebase_investigator**: Review report for Aether extension and journal management (ID: 155435511).
|
||||
### V3 CRUD Architecture
|
||||
- Enforce parallel CRUD and Search under `/v3/crud/`.
|
||||
- **Registry Pattern:** Object definitions in `app/object_definitions/`, logic in `app/methods/`.
|
||||
- **Visibility:** Use `Field(exclude=True)` in Pydantic to hide internal paths from public output.
|
||||
|
||||
### 🎯 Strategic Goals (Current Sprint)
|
||||
- **Primary:** OSIT_dev Environment Optimization & Context Stabilization (Template v1.2 Adoption).
|
||||
- **Secondary:** ID Vision Phase 2 Migration and V3 API Migration (Contacts/Clients).
|
||||
### Specialized Logic
|
||||
- **Universal ID Resolution:** Resolve container IDs (e.g., `event_file`) to physical binaries in actions.
|
||||
- **Relational Joins:** Prefer SQL `INNER JOIN` in Views (`v_`) over duplicating columns in tables.
|
||||
- **Heal-on-Read:** Standardize fallback resolution for relational IDs during GET operations.
|
||||
|
||||
### 🚧 Active Workstreams
|
||||
- **[ID Vision]:** Phase 2 complete. Strictly enforced string-ID standardization for Page, Post, Person, Journal, Contact, and User models. (ID: 161311118 - DONE).
|
||||
- **[Infrastructure]:** Restore AE Events Presentation Launcher (Electron) (ID: 221513945).
|
||||
- **[Infrastructure]:** Pydantic V2 Migration Impact Analysis (Technical Debt).
|
||||
- **[Journals]:** UI: Implementation of Quick Add & Append/Prepend (ID: 185821382).
|
||||
## 🧠 Technical Learnings
|
||||
- **Harden `root_validator`:** Ensure pre-validation logic doesn't delete integer IDs during ID Vision resolution.
|
||||
- **Pydantic worker boot failures:** Watch for `ValueError`, `NameError`, and `KeyError` during startup.
|
||||
- **Inherited Context:** Account context for child objects should be inherited via View joins.
|
||||
- **Lookup Hierarchy:** Implemented `ROW_NUMBER() OVER` logic for tiered overrides (Object > Account > Global).
|
||||
- **Vision Comparison:** Discovered `load_site_obj` returns Random IDs for accounts; comparison in router must use `account_id_random` strings for reliable 403 authorization.
|
||||
|
||||
### 🧠 Recent Decisions
|
||||
- **ID Hardening:** Modified the `map_v3_ids` root validator across core models to explicitly delete aliased integer IDs (e.g., `post_id`, `journal_id`) to prevent Pydantic coercion of legacy integers into strings.
|
||||
- **Search Optimization:** Standardized on `default_qry_str` for optimized fulltext searching. `Event_Badge_Base` is noted as a temporary outlier (`default_qry_string`) awaiting frontend alignment.
|
||||
- **Privacy & Information Hiding:** Centralized `public_read` flag in object definitions and excluded internal file sharding paths from responses.
|
||||
|
||||
---
|
||||
|
||||
## 3. 🧠 Short Term Memory (Session Context)
|
||||
*This section is the "Scratchpad" for the current interaction. It is cleared or summarized often.*
|
||||
|
||||
- **Status:** Online
|
||||
- **Last Action:** Successfully refactored `GEMINI.md` to v1.2 structure.
|
||||
- **Current Blocker:** None.
|
||||
- **Immediate Next Step:** Check for new messages in the inbox or proceed with high-priority tasks.
|
||||
|
||||
---
|
||||
|
||||
## 4. 📂 Reference: Directory & Whitelist
|
||||
*Low-density reference data. Keep at the bottom to avoid cluttering the prompt's "hot zone".*
|
||||
|
||||
### 🛡️ File Whitelist
|
||||
- `~/tmp`
|
||||
- `~/OSIT_dev/aether_api_fastapi`
|
||||
- `~/agents_sync`
|
||||
|
||||
### 🗺️ Standard Directory Map
|
||||
- **`app/methods/`**: Object-specific business logic.
|
||||
- **`app/models/`**: Pydantic schemas.
|
||||
- **`app/object_definitions/`**: V3 Metadata definitions.
|
||||
- **`app/routers/`**: API endpoints.
|
||||
|
||||
### 📋 Aether API Development Protocol
|
||||
0. **Pre-Flight Check:** Verify `git status`. Ensure all previous working changes are committed.
|
||||
1. **Strategic Plan:** Write a concise plan identifying the issue, specific files, and verification steps (curl commands/test scripts).
|
||||
2. **Implementation:** Perform atomic code modifications using `replace` or `write_file`.
|
||||
3. **Syntax Validation:** Run `python3 -m py_compile <modified_file>` immediately.
|
||||
4. **Process Cycle:** Restart the Docker FastAPI service: `docker restart aether_container_env-ae_api-2`.
|
||||
5. **Empirical Testing:** Execute `curl` commands and inspect logs: `tail -n 20 ~/OSIT_dev/aether_container_env/logs/ae_api/aether_api.log`.
|
||||
6. **Finalize:** Commit changes with a descriptive message and sync documentation.
|
||||
## 🤝 Coordination & Continuity
|
||||
- **Handshake:** Use the `message` tool to notify the Frontend Agent of API changes.
|
||||
- **Active Tasks:** Track your progress in `documentation/TODO__Agents.md`.
|
||||
- **Lookup Milestone:** Batch 1 (Country, Subdivision, Timezone) complete. V3.1 goal set for Batch 2 and Novi-Mailman bridge.
|
||||
- **Learning:** Review `ARCH__V3_CORE_STANDARDS.md` for V4 lifecycle field migration planning.
|
||||
|
||||
178
README.md
178
README.md
@@ -1,2 +1,176 @@
|
||||
# Aether API Python FastAPI
|
||||
The Aether API was created and is being developed by Scott Idem using the Python FastAPI framework.
|
||||
|
||||
# Aether API v3.00.20 (FastAPI)
|
||||
|
||||
The **Aether API** is a high-performance, multi-tenant backend for the Aether Platform, built on Python **FastAPI**. It powers both legacy and modern (V3/V4) applications, and is now fully containerized for robust, scalable deployment.
|
||||
|
||||
---
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture Overview
|
||||
|
||||
The API is in transition from legacy (V1/V2) to the modern **V3 CRUD Architecture**. All new development follows V3 standards.
|
||||
|
||||
|
||||
### V3 CRUD (Modern)
|
||||
- **Path:** `/v3/crud/`
|
||||
- **Principles:**
|
||||
- **String IDs:** All public APIs use `id_random` (URL-safe string IDs); internal integer IDs are hidden.
|
||||
- **Nested URLs:** Parent-child relationships enforced in URL structure.
|
||||
- **Advanced Search:** POST-based, recursive, with standardized operators.
|
||||
- **Schema Discovery:** Dynamic model/database introspection at `/v3/crud/{obj_type}/schema`.
|
||||
- **Granular Dependencies:** Specialized FastAPI dependencies for account context, pagination, filtering, serialization.
|
||||
|
||||
|
||||
### V3 Actions
|
||||
- **Path:** `/v3/action/`
|
||||
- Handles complex/atomic business logic and binary operations outside standard CRUD.
|
||||
- **Features:**
|
||||
- **Atomic Event Uploads:** File storage + event relations in one request.
|
||||
- **Content-Addressable Downloads:** SHA256-based file retrieval for high-performance caching.
|
||||
- **Intelligent ID Resolution:** Download endpoints auto-resolve container IDs.
|
||||
|
||||
|
||||
### Legacy API (V1/V2)
|
||||
- **Paths:** `/`, `/api/`, `/crud/`, `/v2/crud/`
|
||||
- Maintained for backward compatibility, but being systematically deprecated. Accessing legacy routes triggers a warning in logs.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 🛠️ Core Technologies
|
||||
- **Framework:** FastAPI (v0.95.1+)
|
||||
- **Database:** MariaDB (Docker, shared) + SQLAlchemy (v1.4.52)
|
||||
- **Caching/ID Resolution:** Redis
|
||||
- **Security:** JWT (JSON Web Tokens), API Key Machine Auth
|
||||
- **Logging:** Structured, module-level, with rotation
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
The Aether API is designed for containerized deployment as part of the unified Aether Docker environment. For full-stack orchestration, see the documentation in the `aether_container_env` project.
|
||||
|
||||
### Prerequisites
|
||||
- Docker & Docker Compose (for containerized use)
|
||||
- Python 3.9+ (for local-only development)
|
||||
|
||||
### Local Development (Optional)
|
||||
You can run the API locally for debugging:
|
||||
```bash
|
||||
virtualenv environment
|
||||
source environment/bin/activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 5005 --reload
|
||||
```
|
||||
See [GUIDE__LOCAL_DEVELOPMENT.md](documentation/GUIDE__LOCAL_DEVELOPMENT.md) for details.
|
||||
|
||||
### Docker Usage
|
||||
The API is run and managed via Docker Compose as part of the full Aether stack. Refer to the `aether_container_env` project for orchestration, environment setup, and advanced deployment instructions.
|
||||
|
||||
### Service Endpoints (Default Ports)
|
||||
- **API Docs:** https://dev-api.oneskyit.com/docs
|
||||
- **Frontend:** http://localhost:8888
|
||||
- **phpMyAdmin:** http://localhost:8081 (if enabled)
|
||||
- **Logs (Dozzle):** http://localhost:8881
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Database & Backups
|
||||
|
||||
All database operations are managed via Docker scripts in `aether_container_env/`:
|
||||
- **Backup:** `./backup_db.sh` (saves to `backups/`)
|
||||
- **Restore:** `./restore_db.sh [backup_file.gz]`
|
||||
- **Export:** `./export_db.sh` (conference-ready backup)
|
||||
- **Automated Import:** Drop file in `backups/import/` and run `./check_and_import.sh`
|
||||
|
||||
See [GUIDE__DEPLOYMENT_MANUAL.md](documentation/GUIDE__DEPLOYMENT_MANUAL.md) for full deployment and backup/restore instructions.
|
||||
|
||||
---
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 📂 Documentation Index
|
||||
|
||||
### **Architecture & Standards**
|
||||
- [V3 Core Architecture](documentation/ARCH__V3_CORE.md): Modular structure and boot sequence.
|
||||
- [V3 Development Standards](documentation/ARCH__V3_DEVELOPMENT_STANDARDS.md): ID Vision, inheritance, and naming rules.
|
||||
- [Unified Agent Arch](documentation/ARCH__UNIFIED_AGENT.md): Vision for cross-stack AI agent awareness.
|
||||
|
||||
### **Integration Guides**
|
||||
- [V3 Frontend API Guide](documentation/GUIDE__AE_API_V3_for_Frontend.md): How to use the V3 CRUD, Search, and Action endpoints.
|
||||
- [V3 Frontend Websockets Guide](documentation/GUIDE__AE_API_V3_for_Frontend_websockets.md): Websocket integration patterns.
|
||||
- [Frontend Code Samples](documentation/FRONTEND_API_SAMPLES.md): TypeScript snippets for common API calls.
|
||||
|
||||
### **Security**
|
||||
- [Project Security Hardening](documentation/PLAN__SECURITY_HARDENING.md): Path towards cryptographic JWT verification.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 🧪 Testing Suite
|
||||
Tests are under `tests/`:
|
||||
- **Unit:** `tests/unit/` (mocked logic)
|
||||
- **Integration:** `tests/integration/` (DB/Redis connectivity)
|
||||
- **E2E:** `tests/e2e/` (API validation)
|
||||
- **Docs:** [tests/README.md](tests/README.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 🚧 Status & Work in Progress
|
||||
|
||||
### Active Workstreams
|
||||
- **API Deprecation:** Pruning orphaned routers/methods
|
||||
- **ID Vision:** String-ID standardization (Phase 2 complete)
|
||||
- **V3 Migration:** Atomic event actions, hash-based file retrieval
|
||||
|
||||
### Known Issues
|
||||
- **Badge Rendering:** Corrupted numeric `id` fields in `event_badge_template` can cause template load failures
|
||||
- **Websockets:** Legacy modules need unification and stability improvements
|
||||
- **Intermittent Timeouts:** Some E2E tests occasionally reproduce 403s/timeouts on nested GET calls
|
||||
|
||||
---
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 📜 Release Snapshot
|
||||
Current Baseline: **`release/2026-01-28-v3_prod-snapshot`** (Stable v3.0.99)
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security & Access
|
||||
- **SSH Required:** All git operations now require SSH (Bitbucket app passwords deprecated June 2026). See your Gitea or Bitbucket account for adding SSH keys.
|
||||
- **Never commit secrets:** `.env` and credentials are git-ignored.
|
||||
- **JWT Key:** Ensure `AE_API_JWT_KEY` is unique and high-entropy in production.
|
||||
- **.env precedence:** API uses `.env` credentials for core infra (SMTP/DB) over DB settings.
|
||||
|
||||
---
|
||||
|
||||
## 🧑💻 Management & Operations
|
||||
- **Restart API:** `docker compose restart ae_api`
|
||||
- **Restart Frontend:** `docker compose restart ae_app`
|
||||
- **Rebuild everything:** `docker compose up -d --build`
|
||||
- **Logs:** http://localhost:8881 (Dozzle)
|
||||
- **phpMyAdmin:** http://localhost:8081 (if enabled)
|
||||
|
||||
---
|
||||
|
||||
## 🏠 Directory Map (Key Mounts)
|
||||
- `conf/` — Nginx/Gunicorn config templates
|
||||
- `logs/` — Centralized logs
|
||||
- `srv/` — Data/source code mounts
|
||||
- `scripts/` — Automation scripts
|
||||
- `backups/` — MariaDB snapshots
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes
|
||||
- For multi-stack setups, ensure unique `AE_NETWORK_NAME` and `CONTAINER_` prefixes in `.env`.
|
||||
- All stacks must connect to `aether_shared_net` for shared DB/Redis.
|
||||
- See Docker env README and CHEATSHEET for advanced orchestration and troubleshooting.
|
||||
@@ -2,6 +2,10 @@
|
||||
This file centralizes the object type definitions for the Aether API.
|
||||
It merges definitions from modular files in app/object_definitions/ to support
|
||||
both V2 (legacy) and V3 CRUD operations.
|
||||
|
||||
🛑 REMINDER: Any field added to 'searchable_fields' in modular definitions
|
||||
MUST also exist in the corresponding Pydantic model (mdl) and SQL View (tbl)
|
||||
to prevent serialization errors during V3 Search operations.
|
||||
"""
|
||||
|
||||
# Restore blanket imports for legacy compatibility (V1 and V2 rely on these)
|
||||
|
||||
112
app/config.py
Normal file
112
app/config.py
Normal file
@@ -0,0 +1,112 @@
|
||||
# Configuration for the Aether FastAPI application.
|
||||
# All settings are read directly from environment variables (injected by Docker via .env).
|
||||
from pydantic import BaseSettings, Field
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
|
||||
# --- Application ---
|
||||
APP_NAME: str = "Aether API (FastAPI)"
|
||||
|
||||
# --- Aether Shared Config (DB-driven bootstrap) ---
|
||||
AE_CFG_ID: int = Field(0, env='AE_CFG_ID')
|
||||
|
||||
# --- JWT ---
|
||||
JWT_KEY: str = Field('EHmSXZFKfMEW65E8kxCKmQ', env='AE_API_JWT_KEY')
|
||||
|
||||
# --- Database ---
|
||||
# These flat fields are mutated by the bootstrap process in main.py (lifespan),
|
||||
# which swaps in production credentials after reading from the cfg table.
|
||||
DB_SERVER: str = Field('mariadb', env='AE_DB_SERVER')
|
||||
DB_PORT: str = Field('3306', env='AE_DB_PORT')
|
||||
DB_NAME: str = Field('aether_dev', env='AE_DB_NAME')
|
||||
DB_USER: str = Field('aether_dev', env='AE_DB_USERNAME')
|
||||
DB_PASS: str = Field('', env='AE_DB_PASSWORD')
|
||||
|
||||
# Connection tuning
|
||||
DB_CONNECT_TIMEOUT: int = Field(20, env='AE_DB_CONNECTION_TIMEOUT')
|
||||
DB_POOL_RECYCLE: int = Field(1800, env='AE_DB_POOL_RECYCLE')
|
||||
DB_POOL_SIZE: int = Field(10, env='AE_DB_POOL_SIZE')
|
||||
DB_POOL_MAX_OVERFLOW: int = Field(20, env='AE_DB_POOL_MAX_OVERFLOW')
|
||||
|
||||
# --- Logging ---
|
||||
LOG_PATH_APP: str = Field('/logs/aether_api.log', env='AE_API_LOG_PATH')
|
||||
|
||||
# --- Redis ---
|
||||
REDIS_SERVER: str = Field('redis', env='AE_REDIS_SERVER')
|
||||
REDIS_PORT: str = Field('6379', env='AE_REDIS_PORT')
|
||||
|
||||
# --- SMTP ---
|
||||
SMTP_SERVER: str = Field('linode.oneskyit.com', env='AE_SMTP_SERVER')
|
||||
SMTP_PORT: str = Field('465', env='AE_SMTP_PORT')
|
||||
SMTP_USERNAME: str = Field('send_mail', env='AE_SMTP_USERNAME')
|
||||
SMTP_PASSWORD: str = Field('set-in-ae-sql-db-cnf-tbl', env='AE_SMTP_PASSWORD')
|
||||
|
||||
# --- File Storage ---
|
||||
FILES_PATH_ROOT: str = Field('/srv/hosted_files', env='AE_FILES_PATH_ROOT')
|
||||
FILES_PATH_TMP: str = Field('/srv/hosted_tmp', env='AE_FILES_PATH_TMP')
|
||||
|
||||
# --- CORS ---
|
||||
ORIGINS_REGEX: str = Field(
|
||||
r'(https://.*\.oneskyit\.com)|(https://.*\.oneskyit\.com:4443)',
|
||||
env='AE_API_ORIGINS_REGEX'
|
||||
)
|
||||
ORIGINS: List[str] = ['https://oneskyit.com']
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Computed properties — maintain backwards-compatible dict interface used
|
||||
# throughout the app (e.g. settings.DB['server'], settings.REDIS['port']).
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def AETHER_CFG(self) -> Dict[str, Any]:
|
||||
return {'id': self.AE_CFG_ID}
|
||||
|
||||
@property
|
||||
def SQLALCHEMY_DB_URI(self) -> str:
|
||||
return f"mysql://{self.DB_USER}:{self.DB_PASS}@{self.DB_SERVER}:{self.DB_PORT}/{self.DB_NAME}"
|
||||
|
||||
@property
|
||||
def DB(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'server': self.DB_SERVER,
|
||||
'port': self.DB_PORT,
|
||||
'name': self.DB_NAME,
|
||||
'username': self.DB_USER,
|
||||
'password': self.DB_PASS,
|
||||
'connect_timeout': self.DB_CONNECT_TIMEOUT,
|
||||
'pool_recycle': self.DB_POOL_RECYCLE,
|
||||
'pool_size': self.DB_POOL_SIZE,
|
||||
'max_overflow': self.DB_POOL_MAX_OVERFLOW,
|
||||
}
|
||||
|
||||
@property
|
||||
def LOG_PATH(self) -> Dict[str, str]:
|
||||
return {'app': self.LOG_PATH_APP}
|
||||
|
||||
@property
|
||||
def REDIS(self) -> Dict[str, str]:
|
||||
return {'server': self.REDIS_SERVER, 'port': self.REDIS_PORT}
|
||||
|
||||
@property
|
||||
def SMTP(self) -> Dict[str, str]:
|
||||
return {
|
||||
'server': self.SMTP_SERVER,
|
||||
'port': self.SMTP_PORT,
|
||||
'username': self.SMTP_USERNAME,
|
||||
'password': self.SMTP_PASSWORD,
|
||||
}
|
||||
|
||||
@property
|
||||
def FILES_PATH(self) -> Dict[str, str]:
|
||||
return {
|
||||
'hosted_files_root': self.FILES_PATH_ROOT,
|
||||
'hosted_tmp_root': self.FILES_PATH_TMP,
|
||||
}
|
||||
|
||||
class Config:
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -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.
|
||||
@@ -43,6 +56,12 @@ def format_db_error(raw_error: str) -> StandardError:
|
||||
recoverable = True
|
||||
elif code in [1054, 1146]: # Unknown column / Table
|
||||
category = "database_schema"
|
||||
elif code == 1364: # Field has no default value — model/schema mismatch
|
||||
category = "database_schema"
|
||||
field_match = re.search(r"Field '([^']+)' doesn't have a default value", message)
|
||||
if field_match:
|
||||
field_name = field_match.group(1)
|
||||
message = f"Schema mismatch: column '{field_name}' is NOT NULL with no default but was not included in the insert. Check the model definition and database schema."
|
||||
else:
|
||||
category = "database"
|
||||
|
||||
@@ -113,7 +132,12 @@ def apply_forced_account_filter(and_qry_dict: Optional[Dict], account: AccountCo
|
||||
except:
|
||||
has_col = False
|
||||
|
||||
if not has_col:
|
||||
return forced
|
||||
|
||||
# CRITICAL: Always apply the filter. If account_id is None, it filters for NULL.
|
||||
forced[target_col] = account.account_id
|
||||
|
||||
return forced
|
||||
|
||||
def filter_order_by(order_by_li: Any, model: Any, table_name: str = None) -> Optional[Dict[str, str]]:
|
||||
@@ -207,6 +231,8 @@ def sanitize_payload(data: dict, model: Any, ignore_extra: bool = False) -> None
|
||||
# Scenario B: Vision naming (e.g., account_id: "abc")
|
||||
# We only resolve if it's a string of the correct length (random ID format)
|
||||
elif k.endswith('_id') and 11 <= len(v) <= 22:
|
||||
if k == 'external_person_id':
|
||||
continue
|
||||
target_id_field = k
|
||||
obj_type_lookup = k.replace('_id', '')
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@ def validate_critical_config(settings: Any):
|
||||
Logs warnings or errors for missing critical infrastructure.
|
||||
"""
|
||||
log.info("Checking critical system configuration...")
|
||||
|
||||
|
||||
# 1. Database Check
|
||||
db = getattr(settings, 'DB', {})
|
||||
if not db.get('server') or db.get('server') == 'mariadb':
|
||||
# 'mariadb' is the default in .env, usually fine, but worth noting
|
||||
log.info(f"Database server: {db.get('server')}")
|
||||
|
||||
|
||||
# 2. SMTP Check
|
||||
smtp = getattr(settings, 'SMTP', {})
|
||||
if not smtp.get('server'):
|
||||
@@ -28,7 +28,7 @@ def validate_critical_config(settings: Any):
|
||||
if not jwt_key or jwt_key == 'fake-super-secret-token':
|
||||
log.error("SECURITY: JWT_KEY is missing or using a known fake token!")
|
||||
|
||||
log.info("Configuration validation complete.")
|
||||
log.info("Aether configuration validation complete.")
|
||||
|
||||
def bootstrap_db_config(settings: Any) -> bool:
|
||||
"""
|
||||
@@ -36,11 +36,13 @@ def bootstrap_db_config(settings: Any) -> bool:
|
||||
Uses deferred import of sql_select to avoid circular dependencies.
|
||||
"""
|
||||
# CRITICAL: Deferred import to prevent boot-time circular dependencies
|
||||
from app.db_sql import sql_select
|
||||
|
||||
cfg_id = settings.AETHER_CFG.get('id', '0')
|
||||
log.info(f"Bootstrapping system configuration from DB (cfg_id={cfg_id})...")
|
||||
|
||||
from app.db_sql import sql_select
|
||||
|
||||
# log.setLevel(logging.DEBUG)
|
||||
|
||||
cfg_id = settings.AETHER_CFG.get('id', 0)
|
||||
log.info(f"Bootstrapping Aether system configuration from DB (cfg_id={cfg_id})...")
|
||||
|
||||
try:
|
||||
# Fetch the config record
|
||||
aether_cfg_sql = sql_select(
|
||||
@@ -49,7 +51,8 @@ def bootstrap_db_config(settings: Any) -> bool:
|
||||
as_list=False,
|
||||
max_count=1,
|
||||
)
|
||||
|
||||
log.debug(f"Raw config record from DB: {aether_cfg_sql}")
|
||||
|
||||
# In some cases sql_select might return a single-item list even with as_list=False
|
||||
if isinstance(aether_cfg_sql, list):
|
||||
if len(aether_cfg_sql) > 0:
|
||||
@@ -62,25 +65,62 @@ def bootstrap_db_config(settings: Any) -> bool:
|
||||
return False
|
||||
|
||||
# --- Update Database settings ---
|
||||
# Safety: Only update if the values are provided in the DB record
|
||||
if aether_cfg_sql.get('db_server'): settings.DB_SERVER = aether_cfg_sql.get('db_server')
|
||||
if aether_cfg_sql.get('db_port'): settings.DB_PORT = str(aether_cfg_sql.get('db_port'))
|
||||
if aether_cfg_sql.get('db_name'): settings.DB_NAME = aether_cfg_sql.get('db_name')
|
||||
if aether_cfg_sql.get('db_username'): settings.DB_USER = aether_cfg_sql.get('db_username')
|
||||
if aether_cfg_sql.get('db_password'): settings.DB_PASS = aether_cfg_sql.get('db_password')
|
||||
|
||||
# ID Vision: Prioritize Environment Variables for core infrastructure.
|
||||
# We only overwrite if the DB value is present AND the environment value is empty OR changed.
|
||||
db_smtp_server = aether_cfg_sql.get('db_server')
|
||||
if db_smtp_server and (not settings.DB_SERVER or settings.DB_SERVER != db_smtp_server):
|
||||
settings.DB_SERVER = db_smtp_server
|
||||
|
||||
db_smtp_port = aether_cfg_sql.get('db_port')
|
||||
if db_smtp_port and (not settings.DB_PORT or settings.DB_PORT != str(db_smtp_port)):
|
||||
settings.DB_PORT = str(db_smtp_port)
|
||||
|
||||
db_smtp_name = aether_cfg_sql.get('db_name')
|
||||
if db_smtp_name and (not settings.DB_NAME or settings.DB_NAME != db_smtp_name):
|
||||
settings.DB_NAME = db_smtp_name
|
||||
|
||||
db_smtp_username = aether_cfg_sql.get('db_username')
|
||||
if db_smtp_username and (not settings.DB_USER or settings.DB_USER != db_smtp_username):
|
||||
settings.DB_USER = db_smtp_username
|
||||
|
||||
db_smtp_password = aether_cfg_sql.get('db_password')
|
||||
if db_smtp_password and (not settings.DB_PASS or settings.DB_PASS != db_smtp_password):
|
||||
settings.DB_PASS = db_smtp_password
|
||||
|
||||
# --- Update SMTP Settings ---
|
||||
if aether_cfg_sql.get('smtp_server'): settings.SMTP['server'] = aether_cfg_sql.get('smtp_server')
|
||||
if aether_cfg_sql.get('smtp_port'): settings.SMTP['port'] = str(aether_cfg_sql.get('smtp_port'))
|
||||
if aether_cfg_sql.get('smtp_username'): settings.SMTP['username'] = aether_cfg_sql.get('smtp_username')
|
||||
if aether_cfg_sql.get('smtp_password'): settings.SMTP['password'] = aether_cfg_sql.get('smtp_password')
|
||||
# ID Vision: Prioritize Environment Variables for core infrastructure.
|
||||
# We overwrite ONLY if:
|
||||
# 1. The environment value is a known placeholder ('set-in-ae-sql-db-cnf-tbl')
|
||||
# 2. OR the database value has explicitly changed (dynamic refresh)
|
||||
placeholder = 'set-in-ae-sql-db-cnf-tbl'
|
||||
|
||||
db_smtp_server = aether_cfg_sql.get('smtp_server')
|
||||
if db_smtp_server and (settings.SMTP.get('server') in [placeholder, '', None] or settings.SMTP.get('server') != db_smtp_server):
|
||||
log.info(f"Updating SMTP server to {db_smtp_server}")
|
||||
settings.SMTP['server'] = db_smtp_server
|
||||
|
||||
db_smtp_port = aether_cfg_sql.get('smtp_port')
|
||||
if db_smtp_port and (settings.SMTP.get('port') in [placeholder, '', None] or settings.SMTP.get('port') != str(db_smtp_port)):
|
||||
settings.SMTP['port'] = str(db_smtp_port)
|
||||
|
||||
db_smtp_username = aether_cfg_sql.get('smtp_username')
|
||||
if db_smtp_username and (settings.SMTP.get('username') in [placeholder, '', None] or settings.SMTP.get('username') != db_smtp_username):
|
||||
settings.SMTP['username'] = db_smtp_username
|
||||
|
||||
db_smtp_password = aether_cfg_sql.get('smtp_password')
|
||||
if db_smtp_password and (settings.SMTP.get('password') in [placeholder, '', None] or settings.SMTP.get('password') != db_smtp_password):
|
||||
log.info("Updating SMTP password from database (dynamic refresh).")
|
||||
settings.SMTP['password'] = db_smtp_password
|
||||
|
||||
# --- Update File Paths ---
|
||||
# DEPRECATED: Filesystem paths should be controlled by the Environment/Docker, not the DB.
|
||||
# if aether_cfg_sql.get('path_hosted_files_root'): settings.FILES_PATH['hosted_files_root'] = aether_cfg_sql.get('path_hosted_files_root')
|
||||
# if aether_cfg_sql.get('path_hosted_tmp_root'): settings.FILES_PATH['hosted_tmp_root'] = aether_cfg_sql.get('path_hosted_tmp_root')
|
||||
|
||||
log.info("System configuration successfully synchronized with DB.")
|
||||
|
||||
# log.setLevel(logging.DEBUG)
|
||||
log.info("Aether API system configuration successfully synchronized with DB.")
|
||||
log.debug(f"Current Database settings after bootstrap: DB_SERVER={settings.DB_SERVER} DB_PORT={settings.DB_PORT} DB_NAME={settings.DB_NAME} DB_USER={settings.DB_USER} DB_PASS={'****' if settings.DB_PASS else ''}")
|
||||
log.debug(f"Current SMTP settings after bootstrap: {settings.SMTP}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
"""
|
||||
This file contains general utility functions and helpers specifically for API v3.
|
||||
It aims to provide a clean slate for new methods and refactor existing ones from lib_general.py
|
||||
that are relevant to the v3 API, while removing unused or outdated functionalities.
|
||||
"""
|
||||
|
||||
# Standard library imports
|
||||
import time
|
||||
import logging
|
||||
from typing import (
|
||||
Any,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Union,
|
||||
)
|
||||
|
||||
# Third-party imports
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
Header,
|
||||
HTTPException,
|
||||
Query,
|
||||
Request,
|
||||
Response,
|
||||
status,
|
||||
)
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Field,
|
||||
ValidationError,
|
||||
computed_field,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
# Internal imports (from this project)
|
||||
from app.config import settings
|
||||
from app.db_sql import redis_lookup_id_random
|
||||
from app.log import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# --- Pydantic Model for Account Context ---
|
||||
class AccountContext(BaseModel):
|
||||
account_id: Optional[int]
|
||||
account_id_random: Optional[str]
|
||||
|
||||
|
||||
# --- Dependency Function for Account Context ---
|
||||
def get_account_context(
|
||||
x_account_id: Optional[str] = Header(None, min_length=11, max_length=22),
|
||||
x_no_account_id: Optional[str] = Header(None, min_length=3, max_length=100), # Assuming 'bypass' or similar string
|
||||
x_no_account_id_token: Optional[str] = Query(None, min_length=11, max_length=22),
|
||||
) -> AccountContext:
|
||||
"""
|
||||
Resolves the account context from headers/query parameters with defined precedence.
|
||||
Precedence: x_account_id (header) > x_no_account_id_token (query) > x_no_account_id (header flag)
|
||||
Raises HTTPException 403 if no valid account is found and no bypass is indicated.
|
||||
"""
|
||||
logger.setLevel(logging.DEBUG) # Adjust as needed
|
||||
logger.debug(locals())
|
||||
|
||||
resolved_account_id = None
|
||||
resolved_account_id_random = None
|
||||
|
||||
if x_account_id:
|
||||
# Primary check: x_account_id header
|
||||
resolved_account_id_random = x_account_id
|
||||
if looked_up_id := redis_lookup_id_random(table_name='account', record_id_random=x_account_id):
|
||||
resolved_account_id = looked_up_id
|
||||
logger.info(f'Found account from x_account_id header: {resolved_account_id}')
|
||||
else:
|
||||
logger.warning(f'Invalid x_account_id header provided: {x_account_id}')
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Invalid X-Account-ID header.')
|
||||
elif x_no_account_id_token:
|
||||
# Secondary check: x_no_account_id_token query parameter
|
||||
resolved_account_id_random = x_no_account_id_token
|
||||
if looked_up_id := redis_lookup_id_random(table_name='account', record_id_random=x_no_account_id_token):
|
||||
resolved_account_id = looked_up_id
|
||||
logger.info(f'Found account from x_no_account_id_token query: {resolved_account_id}')
|
||||
else:
|
||||
logger.warning(f'Invalid x_no_account_id_token query provided: {x_no_account_id_token}')
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Invalid X-No-Account-ID-Token query parameter.')
|
||||
elif x_no_account_id:
|
||||
# Tertiary check: x_no_account_id header for bypass
|
||||
# For now, just presence indicates bypass. Can add a specific value check later if needed.
|
||||
logger.info(f'X-No-Account-ID header found: {x_no_account_id}. Proceeding without specific account context.')
|
||||
resolved_account_id = None # Explicitly None for "no specific account"
|
||||
resolved_account_id_random = '--- NO ACCOUNT ---'
|
||||
else:
|
||||
logger.warning('No valid account context provided via X-Account-ID, X-No-Account-ID-Token, or X-No-Account-ID.')
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Account context required. Please provide X-Account-ID, X-No-Account-ID-Token, or X-No-Account-ID.')
|
||||
|
||||
return AccountContext(account_id=resolved_account_id, account_id_random=resolved_account_id_random)
|
||||
|
||||
|
||||
# --- Pydantic Model for Pagination ---
|
||||
class PaginationParams(BaseModel):
|
||||
limit: int = 100 # Default limit
|
||||
offset: int = 0
|
||||
|
||||
# --- Dependency Function for Pagination ---
|
||||
def get_pagination_params(
|
||||
limit: int = Query(100, ge=0, description="Maximum number of items to return"),
|
||||
offset: int = Query(0, ge=0, description="Number of items to skip (for pagination)"),
|
||||
) -> PaginationParams:
|
||||
return PaginationParams(limit=limit, offset=offset)
|
||||
|
||||
|
||||
# --- Pydantic Model for Status Filtering ---
|
||||
class StatusFilterParams(BaseModel):
|
||||
enabled: str = 'enabled' # 'enabled', 'disabled', 'all'
|
||||
hidden: str = 'not_hidden' # 'hidden', 'not_hidden', 'all'
|
||||
|
||||
# --- Dependency Function for Status Filtering ---
|
||||
def get_status_filter_params(
|
||||
enabled: str = Query('enabled', description="Filter by object enabled status ('enabled', 'disabled', 'all')"),
|
||||
hidden: str = Query('not_hidden', description="Filter by object hidden status ('hidden', 'not_hidden', 'all')"),
|
||||
) -> StatusFilterParams:
|
||||
allowed_enabled_values = {'enabled', 'disabled', 'all'}
|
||||
allowed_hidden_values = {'hidden', 'not_hidden', 'all'}
|
||||
|
||||
if enabled not in allowed_enabled_values:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid value for 'enabled'. Must be one of {list(allowed_enabled_values)}."
|
||||
)
|
||||
if hidden not in allowed_hidden_values:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid value for 'hidden'. Must be one of {list(allowed_hidden_values)}."
|
||||
)
|
||||
return StatusFilterParams(enabled=enabled, hidden=hidden)
|
||||
|
||||
|
||||
# --- Pydantic Model for Serialization Options ---
|
||||
class SerializationParams(BaseModel):
|
||||
by_alias: bool = True
|
||||
exclude_unset: bool = False
|
||||
exclude_defaults: bool = False # Added based on common_route_params
|
||||
exclude_none: bool = False # Added based on common_route_params
|
||||
|
||||
# --- Dependency Function for Serialization Options ---
|
||||
def get_serialization_params(
|
||||
by_alias: bool = Query(True, description="Whether to use field aliases for serialization"),
|
||||
exclude_unset: bool = Query(False, description="Whether to exclude unset fields from the response"),
|
||||
exclude_defaults: bool = Query(False, description="Whether to exclude fields with their default values from the response"),
|
||||
exclude_none: bool = Query(False, description="Whether to exclude fields that are None from the response"),
|
||||
) -> SerializationParams:
|
||||
return SerializationParams(
|
||||
by_alias=by_alias,
|
||||
exclude_unset=exclude_unset,
|
||||
exclude_defaults=exclude_defaults,
|
||||
exclude_none=exclude_none,
|
||||
)
|
||||
|
||||
|
||||
# --- Pydantic Model for Delay ---
|
||||
class DelayParams(BaseModel):
|
||||
sleep_time_ms: int = 0 # Raw delay value in ms
|
||||
sleep_time_s: float = 0.0 # Converted to seconds for time.sleep()
|
||||
|
||||
# --- Dependency Function for Delay ---
|
||||
def get_delay_params(
|
||||
x_delay_ms: Optional[int] = Header(0, alias='X-Delay-ms', description="Delay response for X milliseconds (header)"),
|
||||
delay_ms: Optional[int] = Query(0, description="Delay response for X milliseconds (query parameter)"),
|
||||
) -> DelayParams:
|
||||
calculated_delay_ms = max(x_delay_ms or 0, delay_ms or 0)
|
||||
return DelayParams(sleep_time_ms=calculated_delay_ms, sleep_time_s=calculated_delay_ms / 1000.0)
|
||||
@@ -1,130 +0,0 @@
|
||||
"""
|
||||
Centralized ID random to integer ID resolution.
|
||||
"""
|
||||
import logging
|
||||
import datetime
|
||||
import random
|
||||
import redis
|
||||
from app.config import settings
|
||||
from app.db_connection import db
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def redis_lookup_id_random(
|
||||
record_id_random: int|str,
|
||||
table_name: str,
|
||||
check_int_id: bool = False,
|
||||
log_lvl: int = logging.WARNING,
|
||||
minutes: int = 30,
|
||||
reset_rate: int = 10,
|
||||
) -> str|int|bool|None:
|
||||
"""
|
||||
Looks up a record ID in Redis, falling back to SQL if not found.
|
||||
Resolves 'id_random' (URL-safe string) to internal integer 'id'.
|
||||
"""
|
||||
from app.db_sql import sql_select, get_id_random
|
||||
|
||||
log.setLevel(log_lvl)
|
||||
|
||||
if isinstance(record_id_random, str) and 11 <= len(record_id_random) <= 22:
|
||||
pass
|
||||
elif isinstance(record_id_random, int):
|
||||
if check_int_id:
|
||||
if get_id_random(record_id=record_id_random, table_name=table_name):
|
||||
return record_id_random
|
||||
return False
|
||||
return record_id_random
|
||||
elif record_id_random is None:
|
||||
return None
|
||||
else:
|
||||
log.error(f'Unexpected data type: {type(record_id_random)}. Expected string (11-22 chars) or int.')
|
||||
return False
|
||||
|
||||
if not table_name:
|
||||
log.error(f'Missing table_name for id_random lookup: {record_id_random}')
|
||||
return False
|
||||
|
||||
r = redis.Redis(host=settings.REDIS['server'], port=settings.REDIS['port'], db=7, password=None, decode_responses=True)
|
||||
key_name = f'{table_name}:{record_id_random}'
|
||||
|
||||
record_id = r.get(key_name)
|
||||
|
||||
# Periodic cache refresh
|
||||
if record_id and random.randint(1, reset_rate) == 1:
|
||||
log.warning(f'Redis: Randomly (1/{reset_rate}) refreshing cache for Key="{key_name}"')
|
||||
record_id = None
|
||||
|
||||
if record_id:
|
||||
r.setex(key_name, datetime.timedelta(minutes=minutes), value=record_id)
|
||||
return int(record_id)
|
||||
else:
|
||||
data = { 'id_random': record_id_random }
|
||||
sql = f"SELECT id FROM `{table_name}` WHERE id_random = :id_random;"
|
||||
|
||||
if select_results := sql_select(sql=sql, data=data):
|
||||
if isinstance(select_results, dict):
|
||||
if rid := select_results.get('id'):
|
||||
r.setex(key_name, datetime.timedelta(minutes=minutes), value=rid)
|
||||
return int(rid)
|
||||
log.error('SQL result missing ID field.')
|
||||
return False
|
||||
else:
|
||||
log.error(f'SQL: Duplicate id_random found in "{table_name}". Retrying...')
|
||||
return redis_lookup_id_random(record_id_random=record_id_random, table_name=table_name)
|
||||
else:
|
||||
log.warning(f'SQL: ID Random "{record_id_random}" not found in "{table_name}".')
|
||||
return None
|
||||
|
||||
def lookup_id_random_pop(
|
||||
obj_data: dict,
|
||||
log_lvl: int = logging.WARNING,
|
||||
):
|
||||
"""
|
||||
Resolves any *_id_random fields in a dict to their integer IDs and removes the random keys.
|
||||
"""
|
||||
log.setLevel(log_lvl)
|
||||
|
||||
# List of common prefix patterns to resolve
|
||||
id_patterns = [
|
||||
'account', 'activity_log', 'address', 'archive', 'contact', 'cont_edu_cert',
|
||||
'cont_edu_cert_person', 'event', 'event_abstract', 'event_badge',
|
||||
'event_badge_template', 'event_exhibit', 'event_file', 'event_location',
|
||||
'event_person', 'event_person_profile', 'event_presentation',
|
||||
'event_presenter', 'event_registration', 'event_session', 'event_track',
|
||||
'grant', 'hosted_file', 'journal', 'journal_entry', 'membership_group',
|
||||
'membership_person_group', 'membership_person', 'membership_type',
|
||||
'membership_person_type', 'order', 'order_line', 'order_cart',
|
||||
'order_cart_line', 'organization', 'page', 'person', 'post', 'product',
|
||||
'sponsorship', 'sponsorship_cfg', 'site', 'user'
|
||||
]
|
||||
|
||||
for prefix in id_patterns:
|
||||
key = f'{prefix}_id_random'
|
||||
if key in obj_data:
|
||||
table = prefix
|
||||
if prefix == 'address_location': table = 'address'
|
||||
if prefix in ['contact_1', 'contact_2']: table = 'contact'
|
||||
if prefix == 'event_id_random_only': table = 'event'
|
||||
|
||||
resolved_id = redis_lookup_id_random(record_id_random=obj_data[key], table_name=table)
|
||||
obj_data[f'{prefix}_id'] = resolved_id
|
||||
obj_data.pop(key)
|
||||
|
||||
# Handle polymorphic link fields
|
||||
polymorphic = [
|
||||
('for_type', 'for_id_random', 'for_id'),
|
||||
('link_to_type', 'link_to_id_random', 'link_to_id'),
|
||||
('object_type', 'object_id_random', 'object_id'),
|
||||
('to_object_type', 'to_object_id_random', 'to_object_id'),
|
||||
('from_object_type', 'from_object_id_random', 'from_object_id')
|
||||
]
|
||||
|
||||
for type_key, rand_key, id_key in polymorphic:
|
||||
if type_key in obj_data and rand_key in obj_data:
|
||||
obj_data[id_key] = redis_lookup_id_random(
|
||||
record_id_random=obj_data[rand_key],
|
||||
table_name=obj_data[type_key]
|
||||
)
|
||||
obj_data.pop(rand_key)
|
||||
|
||||
return obj_data
|
||||
@@ -10,13 +10,23 @@ from app.config import settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# --- Global Redis Client ---
|
||||
# Using a single client instance with internal connection pooling is more efficient.
|
||||
redis_client = redis.Redis(
|
||||
host=settings.REDIS['server'],
|
||||
port=settings.REDIS['port'],
|
||||
db=7,
|
||||
password=None,
|
||||
decode_responses=True
|
||||
)
|
||||
|
||||
def redis_lookup_id_random(
|
||||
record_id_random: int|str,
|
||||
table_name: str,
|
||||
check_int_id: bool = False,
|
||||
log_lvl: int = logging.WARNING, # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
minutes: int = 30, # Expire the Redis key after 8 minutes
|
||||
reset_rate: int = 10, # 1 in 10 chance of resetting the Redis key
|
||||
minutes: int = 30, # Expire the Redis key after 30 minutes
|
||||
reset_rate: int = 10, # 1 in 10 chance of resetting the Redis key (DEPRECATED)
|
||||
) -> str|int|bool|None:
|
||||
"""
|
||||
Looks up a record ID in Redis, falling back to SQL if not found.
|
||||
@@ -66,18 +76,15 @@ def redis_lookup_id_random(
|
||||
log.error('Missing table_name and record_id_random')
|
||||
return False
|
||||
|
||||
r = redis.Redis(host=settings.REDIS['server'], port=settings.REDIS['port'], db=7, password=None, decode_responses=True)
|
||||
key_name = f'{table_name}:{record_id_random}'
|
||||
rev_key_prefix = f'rev:{table_name}:'
|
||||
|
||||
record_id = r.get(key_name)
|
||||
|
||||
if record_id and random.randint(1, reset_rate) == 1:
|
||||
log.warning(f'Redis: Randomly (1/{reset_rate}) setting record_id to None. Key="{key_name}" value="{record_id}" TTL={r.ttl(key_name)} seconds')
|
||||
record_id = None
|
||||
# Use the global redis client instead of creating a new one every time
|
||||
record_id = redis_client.get(key_name)
|
||||
|
||||
if record_id:
|
||||
r.setex(key_name, datetime.timedelta(minutes=minutes), value=record_id)
|
||||
log.info(f'Redis: Entry found for: Key="{key_name}" value="{record_id}" TTL={r.ttl(key_name)} seconds')
|
||||
redis_client.setex(key_name, datetime.timedelta(minutes=minutes), value=record_id)
|
||||
log.info(f'Redis: Entry found for: Key="{key_name}" value="{record_id}" TTL={redis_client.ttl(key_name)} seconds')
|
||||
return int(record_id)
|
||||
elif table_name:
|
||||
data = { 'id_random': record_id_random }
|
||||
@@ -88,7 +95,9 @@ def redis_lookup_id_random(
|
||||
if isinstance(select_results, dict):
|
||||
log.info(f"""SQL: Found ID Random for: {str(record_id_random)} = {str(select_results.get('id'))}""")
|
||||
if record_id := select_results.get('id'):
|
||||
r.setex(key_name, datetime.timedelta(minutes=minutes), value=record_id)
|
||||
# Populating BOTH directions in Redis
|
||||
redis_client.setex(key_name, datetime.timedelta(minutes=minutes), value=record_id)
|
||||
redis_client.setex(f'{rev_key_prefix}{record_id}', datetime.timedelta(minutes=minutes), value=record_id_random)
|
||||
return int(record_id)
|
||||
else:
|
||||
log.error('The SQL result was not what was expected. The ID field was not found.')
|
||||
@@ -108,19 +117,45 @@ def get_id_random(
|
||||
record_id: int,
|
||||
table_name: str,
|
||||
log_lvl: int = logging.WARNING, # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
minutes: int = 30, # Expire the Redis key after 30 minutes
|
||||
) -> str|bool|None:
|
||||
"""
|
||||
Looks up the 'id_random' for a given internal integer ID.
|
||||
Uses Redis caching for performance.
|
||||
"""
|
||||
from app.db_sql import sql_select
|
||||
from app.db_sql import sql_select, get_last_sql_error
|
||||
log.setLevel(log_lvl)
|
||||
|
||||
# Hardened check: Skip lookups for tables known to not have random IDs (e.g. lu_ tables)
|
||||
if not table_name or table_name.startswith('lu_') or table_name.startswith('v_lu_'):
|
||||
return None
|
||||
|
||||
# Check Redis cache first (using 'rev:' prefix for integer -> string mappings)
|
||||
key_name = f'rev:{table_name}:{record_id}'
|
||||
if cached_val := redis_client.get(key_name):
|
||||
# Extend TTL on hit
|
||||
redis_client.setex(key_name, datetime.timedelta(minutes=minutes), value=cached_val)
|
||||
return str(cached_val)
|
||||
|
||||
data = { 'id': record_id }
|
||||
sql = f"SELECT id_random FROM `{table_name}` AS `table` WHERE `table`.id = :id;"
|
||||
|
||||
if select_results := sql_select(sql=sql, data=data):
|
||||
select_results = sql_select(sql=sql, data=data)
|
||||
|
||||
# Check for "Unknown column 'id_random'" error if sql_select failed
|
||||
if select_results is False:
|
||||
err = str(get_last_sql_error())
|
||||
if "1054" in err and "id_random" in err:
|
||||
log.info(f"Table '{table_name}' does not have an 'id_random' column. Skipping.")
|
||||
return None
|
||||
return False
|
||||
|
||||
if select_results:
|
||||
if isinstance(select_results, dict):
|
||||
if record_id_random := select_results.get('id_random'):
|
||||
# Populating BOTH directions in Redis
|
||||
redis_client.setex(key_name, datetime.timedelta(minutes=minutes), value=record_id_random)
|
||||
redis_client.setex(f'{table_name}:{record_id_random}', datetime.timedelta(minutes=minutes), value=record_id)
|
||||
return str(record_id_random)
|
||||
else:
|
||||
log.error('The SQL result was not what was expected.')
|
||||
@@ -158,7 +193,7 @@ def lookup_id_random_pop(
|
||||
id_prefixes = [
|
||||
'account', 'activity_log', 'address', 'address_location', 'archive',
|
||||
'contact', 'contact_1', 'contact_2', 'cont_edu_cert', 'cont_edu_cert_person',
|
||||
'event', 'event_id_random_only', 'event_abstract', 'event_badge',
|
||||
'entry', 'event', 'event_id_random_only', 'event_abstract', 'event_badge',
|
||||
'event_badge_template', 'event_exhibit', 'event_file', 'event_location',
|
||||
'event_person', 'event_person_profile', 'event_presentation',
|
||||
'event_presenter', 'event_registration', 'event_session', 'event_track',
|
||||
@@ -178,6 +213,7 @@ def lookup_id_random_pop(
|
||||
table = prefix
|
||||
if prefix == 'address_location': table = 'address'
|
||||
elif prefix in ['contact_1', 'contact_2']: table = 'contact'
|
||||
elif prefix == 'entry': table = 'journal_entry'
|
||||
elif prefix == 'event_id_random_only': table = 'event'
|
||||
elif prefix == 'poc_event_person': table = 'event_person'
|
||||
elif prefix == 'poc_person': table = 'person'
|
||||
|
||||
@@ -43,9 +43,15 @@ def create_ae_engine(uri: str):
|
||||
|
||||
engine = create_ae_engine(db_uri)
|
||||
|
||||
# DEPRECATED: Global shared 'db' connection. Use engine.connect() in context managers instead.
|
||||
# Keeping for legacy compatibility but will phase out usage in crud lib.
|
||||
db = engine.connect()
|
||||
# DEPRECATED: Global shared 'db' connection. Still used by lib_schema_v3.py and lib_api_crud_v3.py.
|
||||
# TODO (P3 full fix): migrate those two call sites to engine.connect() context managers, then remove this.
|
||||
# Bare connect guarded so a Docker startup race (MariaDB not yet ready) doesn't crash the worker.
|
||||
# If this fails, db=None — callers that hit it before reconnect_db() runs will raise AttributeError.
|
||||
try:
|
||||
db = engine.connect()
|
||||
except Exception:
|
||||
log.warning("DB SQL Core: Initial db connection failed at startup (MariaDB not ready?). Will retry via reconnect_db().")
|
||||
db = None
|
||||
|
||||
log.info('DB SQL Core: Initializing engine...')
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError
|
||||
from app.log import log, logger_reset
|
||||
# CRITICAL: Import the core module to access current global state
|
||||
from app import lib_sql_core
|
||||
from app.lib_sql_core import sql_connect, set_last_sql_error
|
||||
from app.lib_sql_core import set_last_sql_error
|
||||
|
||||
log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
# log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
|
||||
# Helper for resolving random IDs
|
||||
from app.lib_redis_helpers import lookup_id_random_pop
|
||||
@@ -63,11 +63,29 @@ def sql_insert(
|
||||
return result_insert.lastrowid
|
||||
return False
|
||||
except IntegrityError as e:
|
||||
# Data constraint violation (duplicate key, FK mismatch, NOT NULL) — do NOT retry;
|
||||
# the same data would fail again. Return None so callers can distinguish from errors.
|
||||
if trans: trans.rollback()
|
||||
log.error('Integrity error (likely duplicate). Returning None')
|
||||
log.debug(e)
|
||||
set_last_sql_error(e)
|
||||
return None
|
||||
except OperationalError:
|
||||
# Transient connection failure. The broken connection rolls back on MariaDB's side,
|
||||
# so retrying with a fresh connection is safe.
|
||||
if trans: trans.rollback()
|
||||
log.warning('Operational error in sql_insert. Retrying once with fresh connection...')
|
||||
try:
|
||||
with lib_sql_core.engine.connect() as conn:
|
||||
trans = conn.begin()
|
||||
result_insert = conn.execute(sql_insert_stmt, data)
|
||||
trans.commit()
|
||||
if result_insert.rowcount == 1 and result_insert.lastrowid > 0:
|
||||
return result_insert.lastrowid
|
||||
return False
|
||||
except Exception as e:
|
||||
set_last_sql_error(e)
|
||||
return False
|
||||
except Exception as e:
|
||||
if trans: trans.rollback()
|
||||
log.error('Unknown exception in sql_insert. Returning False')
|
||||
@@ -111,7 +129,7 @@ def sql_update(
|
||||
if len(sql_set) < 4:
|
||||
return None
|
||||
|
||||
if record_id:
|
||||
if record_id is not None:
|
||||
data['id'] = record_id
|
||||
sql_update_stmt = text(f'UPDATE `{table_name}` SET {sql_set} WHERE id = :id')
|
||||
elif record_id_random:
|
||||
@@ -138,7 +156,6 @@ def sql_update(
|
||||
except OperationalError:
|
||||
if trans: trans.rollback()
|
||||
log.error('Operational error (gone away?). Retrying once...')
|
||||
sql_connect()
|
||||
try:
|
||||
with lib_sql_core.engine.connect() as conn:
|
||||
trans = conn.begin()
|
||||
@@ -199,6 +216,19 @@ def sql_insert_or_update(
|
||||
res = conn.execute(stmt, data)
|
||||
trans.commit()
|
||||
return res.lastrowid if res.lastrowid > 0 else True
|
||||
except OperationalError:
|
||||
# ON DUPLICATE KEY UPDATE is idempotent — safe to retry.
|
||||
if trans: trans.rollback()
|
||||
log.warning('Operational error in sql_insert_or_update. Retrying once...')
|
||||
try:
|
||||
with lib_sql_core.engine.connect() as conn:
|
||||
trans = conn.begin()
|
||||
res = conn.execute(stmt, data)
|
||||
trans.commit()
|
||||
return res.lastrowid if res.lastrowid > 0 else True
|
||||
except Exception as e:
|
||||
set_last_sql_error(e)
|
||||
return False
|
||||
except Exception as e:
|
||||
if trans: trans.rollback()
|
||||
log.exception(e)
|
||||
@@ -250,7 +280,7 @@ def sql_select(
|
||||
order_by_str_li = [f'`{table_name}`.`{k}` {v}' for k, v in order_by_li.items()]
|
||||
sql_order_by = f"ORDER BY {', '.join(order_by_str_li)}"
|
||||
|
||||
if table_name and not (record_id or record_id_random or field_name or field_value or sql or data):
|
||||
if table_name and record_id is None and not (record_id_random or field_name or field_value or sql or data):
|
||||
data = {}
|
||||
s_en, d_en = sql_enable_part(table_name, enabled) if enabled else ('', None)
|
||||
s_hi, d_hi = sql_hidden_part(table_name, hidden) if hidden else ('', None)
|
||||
@@ -264,12 +294,12 @@ def sql_select(
|
||||
|
||||
stmt = text(f"SELECT * FROM `{table_name}` WHERE 1=1 {s_search} {s_en} {s_hi} {sql_order_by} {sql_limit_offset};")
|
||||
|
||||
elif table_name and (record_id or record_id_random) and not (field_name or field_value or sql or data):
|
||||
data = {'rid': record_id} if record_id else {'ridr': record_id_random}
|
||||
where = f"`{table_name}`.id = :rid" if record_id else f"`{table_name}`.id_random = :ridr"
|
||||
elif table_name and (record_id is not None or record_id_random) and not (field_name or field_value or sql or data):
|
||||
data = {'rid': record_id} if record_id is not None else {'ridr': record_id_random}
|
||||
where = f"`{table_name}`.id = :rid" if record_id is not None else f"`{table_name}`.id_random = :ridr"
|
||||
stmt = text(f"SELECT * FROM `{table_name}` WHERE {where} {sql_order_by} {sql_limit_offset};")
|
||||
|
||||
elif table_name and field_name and field_value and not (record_id or record_id_random or sql or data):
|
||||
elif table_name and field_name and field_value and not (record_id is not None or record_id_random or sql or data):
|
||||
data = {field_name: field_value}
|
||||
s_where, d_where = sql_where_qry_part(qry_dict_li) if qry_dict_li else ('', {})
|
||||
s_ft, d_ft = sql_fulltext_qry_part(fulltext_qry_dict) if fulltext_qry_dict else ('', {})
|
||||
@@ -307,8 +337,23 @@ def sql_select(
|
||||
if hasattr(result, 'returns_rows') and not result.returns_rows:
|
||||
log.warning("SQL Result does not return rows (ResourceClosedError prevented).")
|
||||
return [] if as_list else None
|
||||
|
||||
|
||||
rows = result.all()
|
||||
except OperationalError:
|
||||
# Transient connection failure — reads are always safe to retry.
|
||||
log.error('Operational error in sql_select. Retrying once with fresh connection...')
|
||||
try:
|
||||
with lib_sql_core.engine.connect() as conn:
|
||||
result = conn.execute(stmt, data)
|
||||
if not result:
|
||||
return [] if as_list else None
|
||||
if hasattr(result, 'returns_rows') and not result.returns_rows:
|
||||
return [] if as_list else None
|
||||
rows = result.all()
|
||||
except Exception as e:
|
||||
log.error(f"SQL Fetch Error on retry: {e}")
|
||||
set_last_sql_error(e)
|
||||
return False
|
||||
except Exception as e:
|
||||
log.error(f"SQL Fetch Error: {e}")
|
||||
set_last_sql_error(e)
|
||||
@@ -343,7 +388,6 @@ def run_sql_select(
|
||||
return conn.execute(sql, data)
|
||||
except (OperationalError, ProgrammingError) as e:
|
||||
log.error(f'DB Error: {e}. Retrying once...')
|
||||
sql_connect()
|
||||
try:
|
||||
with lib_sql_core.engine.connect() as conn:
|
||||
return conn.execute(sql, data)
|
||||
@@ -370,11 +414,11 @@ def sql_delete(
|
||||
) -> None|bool:
|
||||
log.setLevel(log_lvl)
|
||||
|
||||
if table_name and (record_id or record_id_random) and not (field_name or field_value or sql or data):
|
||||
data = {'rid': record_id} if record_id else {'ridr': record_id_random}
|
||||
where = f"`{table_name}`.id = :rid" if record_id else f"`{table_name}`.id_random = :ridr"
|
||||
if table_name and (record_id is not None or record_id_random) and not (field_name or field_value or sql or data):
|
||||
data = {'rid': record_id} if record_id is not None else {'ridr': record_id_random}
|
||||
where = f"`{table_name}`.id = :rid" if record_id is not None else f"`{table_name}`.id_random = :ridr"
|
||||
stmt = text(f"DELETE FROM `{table_name}` WHERE {where}")
|
||||
elif table_name and field_name and field_value and not (record_id or record_id_random or sql or data):
|
||||
elif table_name and field_name and field_value and not (record_id is not None or record_id_random or sql or data):
|
||||
data = {field_name: field_value}
|
||||
stmt = text(f"DELETE FROM `{table_name}` WHERE `{table_name}`.{field_name} = :{field_name}")
|
||||
elif sql:
|
||||
|
||||
@@ -199,7 +199,11 @@ def sql_search_qry_part(
|
||||
if hasattr(item, 'field'):
|
||||
clause, item_data = process_filter(item)
|
||||
node_clauses.append(clause); data.update(item_data)
|
||||
else: node_clauses.append(f"({process_node(item, current_depth + 1)})")
|
||||
else:
|
||||
# Recurse into nested SearchQuery; only append if non-empty
|
||||
sub_clause = process_node(item, current_depth + 1)
|
||||
if sub_clause:
|
||||
node_clauses.append(f"({sub_clause})")
|
||||
if node_clauses:
|
||||
joiner = ' AND ' if 'and' in filter_attr else ' OR '
|
||||
clauses.append(f"({joiner.join(node_clauses)})")
|
||||
@@ -248,10 +252,30 @@ def sql_search_qry_part(
|
||||
target_field = candidate_field
|
||||
# print(f"Search Trace: Mapping filter field '{f.field}' -> '{target_field}'", flush=True)
|
||||
else:
|
||||
# If random doesn't exist, we must stick to the integer column
|
||||
# but we'll need to resolve the string value to an integer elsewhere
|
||||
# or rely on the user providing an integer for now.
|
||||
pass
|
||||
# Fallback: Resolve ID if random column is missing from view
|
||||
try:
|
||||
from app.lib_redis_helpers import redis_lookup_id_random
|
||||
# Infer table name (e.g., 'event_id' -> 'event')
|
||||
if target_field.endswith('_id') and target_field != 'id':
|
||||
lookup_tbl = target_field[:-3] # remove '_id'
|
||||
resolved_id = redis_lookup_id_random(record_id_random=f.value, table_name=lookup_tbl)
|
||||
if resolved_id:
|
||||
# Update the filter value to use the resolved integer
|
||||
f.value = resolved_id
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to resolve random ID for field {target_field}: {e}")
|
||||
|
||||
# site_domain: 'access_key' is a virtual field.
|
||||
# site_access_key (site-level) takes priority; fall back to site_domain_access_key
|
||||
# when site_access_key is not set (NULL or empty).
|
||||
if target_field == 'access_key' and table_name and 'site_domain' in table_name:
|
||||
sql_op = operator_map.get(f.op.lower())
|
||||
if not sql_op: raise HTTPException(status_code=400, detail=f"Unsupported operator: {f.op}")
|
||||
p1, p2 = get_param_name(), get_param_name()
|
||||
return (
|
||||
f"(site_access_key {sql_op} :{p1} OR "
|
||||
f"((site_access_key IS NULL OR site_access_key = '') AND site_domain_access_key {sql_op} :{p2}))"
|
||||
), {p1: f.value, p2: f.value}
|
||||
|
||||
if searchable_fields is not None and target_field not in searchable_fields:
|
||||
# Fallback check for original field just in case
|
||||
|
||||
119
app/lib_websockets_v3.py
Normal file
119
app/lib_websockets_v3.py
Normal file
@@ -0,0 +1,119 @@
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
import redis.asyncio as redis
|
||||
from app.config import settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# --- Models ---
|
||||
|
||||
class WS_Message_V3(BaseModel):
|
||||
"""
|
||||
Standardized message schema for WebSockets V3.
|
||||
"""
|
||||
version: str = "3"
|
||||
msg_type: str = Field(..., description="'msg', 'cmd', 'heartbeat', 'presence'")
|
||||
target: str = Field(..., description="'direct', 'group', 'broadcast', 'echo'")
|
||||
|
||||
from_id: str = Field(..., description="client_id_random of the sender")
|
||||
to_id: Optional[str] = Field(None, description="target client_id_random (for direct messages)")
|
||||
group_id: Optional[str] = Field(None, description="target group_id_random (for group messages)")
|
||||
|
||||
cmd: Optional[str] = Field(None, description="Specific command string (e.g., 'RELOAD', 'OPEN_FILE')")
|
||||
msg: Optional[str] = Field(None, description="Human-readable message content")
|
||||
|
||||
payload: Dict[str, Any] = Field(default_factory=dict, description="Flexible JSON data payload")
|
||||
sent_at: datetime.datetime = Field(default_factory=lambda: datetime.datetime.now(datetime.timezone.utc))
|
||||
|
||||
class Config:
|
||||
json_encoders = {
|
||||
datetime.datetime: lambda v: v.isoformat()
|
||||
}
|
||||
|
||||
# --- Manager ---
|
||||
|
||||
class WS_Manager_V3:
|
||||
"""
|
||||
Manages Redis Granular Pub/Sub and Presence for WebSockets V3.
|
||||
"""
|
||||
def __init__(self, redis_db: int = 6):
|
||||
self.redis_db = redis_db
|
||||
self.redis_url = f"redis://{settings.REDIS['server']}:{settings.REDIS['port']}"
|
||||
self._redis_conn: Optional[redis.Redis] = None
|
||||
|
||||
async def get_redis(self) -> redis.Redis:
|
||||
"""Lazy-loaded async Redis connection."""
|
||||
if self._redis_conn is None:
|
||||
log.info(f"WS V3: Connecting to Redis DB {self.redis_db}")
|
||||
self._redis_conn = redis.Redis.from_url(
|
||||
self.redis_url,
|
||||
db=self.redis_db,
|
||||
encoding='utf-8',
|
||||
decode_responses=True
|
||||
)
|
||||
return self._redis_conn
|
||||
|
||||
def get_channel_names(self, client_id: str, group_id: Optional[str] = None) -> List[str]:
|
||||
"""
|
||||
Generates the list of Redis channels a client should subscribe to.
|
||||
"""
|
||||
channels = [
|
||||
f"ws:client:{client_id}", # Direct messages
|
||||
"ws:broadcast" # System-wide messages
|
||||
]
|
||||
if group_id:
|
||||
channels.append(f"ws:group:{group_id}") # Group messages
|
||||
return channels
|
||||
|
||||
async def update_presence(self, client_id: str, group_id: str, online: bool = True):
|
||||
"""
|
||||
Tracks which clients are online in which groups using Redis Sets.
|
||||
"""
|
||||
r = await self.get_redis()
|
||||
key = f"ws:presence:{group_id}"
|
||||
if online:
|
||||
await r.sadd(key, client_id)
|
||||
await r.expire(key, 3600) # Auto-expire in 1 hour if not refreshed
|
||||
else:
|
||||
await r.srem(key, client_id)
|
||||
|
||||
async def get_online_clients(self, group_id: str) -> List[str]:
|
||||
"""Returns list of online client IDs in a group."""
|
||||
r = await self.get_redis()
|
||||
return await r.smembers(f"ws:presence:{group_id}")
|
||||
|
||||
async def publish_message(self, message: WS_Message_V3):
|
||||
"""
|
||||
Publishes a structured message to the correct granular Redis channel.
|
||||
"""
|
||||
r = await self.get_redis()
|
||||
channel = ""
|
||||
|
||||
if message.target == "direct":
|
||||
if not message.to_id:
|
||||
log.warning("WS V3: Attempted direct publish without to_id")
|
||||
return
|
||||
channel = f"ws:client:{message.to_id}"
|
||||
|
||||
elif message.target == "group":
|
||||
if not message.group_id:
|
||||
log.warning("WS V3: Attempted group publish without group_id")
|
||||
return
|
||||
channel = f"ws:group:{message.group_id}"
|
||||
|
||||
elif message.target == "broadcast":
|
||||
channel = "ws:broadcast"
|
||||
|
||||
elif message.target == "echo":
|
||||
channel = f"ws:client:{message.from_id}"
|
||||
|
||||
if channel:
|
||||
log.debug(f"WS V3: Publishing to {channel}")
|
||||
await r.publish(channel, message.json())
|
||||
|
||||
# Global instance
|
||||
ws_manager_v3 = WS_Manager_V3()
|
||||
@@ -1,100 +0,0 @@
|
||||
import functools, logging
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# stream options: 'ext://sys.stderr' or 'ext://sys.stdout'
|
||||
|
||||
# NOTE: This log config is confusing and may need work... 2022-10-07
|
||||
# 'uvicorn' under 'loggers' creates an output to the 'console' handler
|
||||
# Do not also add 'console' handler to the 'root' 'handlers' list
|
||||
# For now just using that to add or remove file logging options.
|
||||
# logging.config.dictConfig({
|
||||
# 'version': 1,
|
||||
# 'formatters': {
|
||||
# 'default': {'format': '[%(asctime)s] %(levelname)s @ %(module)s.%(funcName)s()#%(lineno)d: %(message)s'},
|
||||
# 'long': {'format': '[%(asctime)s] %(levelname)s @ %(module)s.%(funcName)s()#%(lineno)d: %(message)s', 'datefmt': '%Y-%m-%d %H:%M:%S'},
|
||||
# 'short': {'format': '[%(asctime)s] %(levelname)s @ %(module)s.%(funcName)s()#%(lineno)d: %(message)s', 'datefmt': '%H:%M:%S', 'use_colors': True},
|
||||
# },
|
||||
# #'filename': 'example.log',
|
||||
# # 'level': logging.ERROR,
|
||||
# 'handlers': {
|
||||
# 'console': {
|
||||
# 'class': 'logging.StreamHandler',
|
||||
# 'stream': 'ext://sys.stderr',
|
||||
# 'formatter': 'short',
|
||||
# },
|
||||
# 'log_file_all': {
|
||||
# 'level': 'NOTSET',
|
||||
# 'class': 'logging.handlers.RotatingFileHandler',
|
||||
# 'formatter': 'long',
|
||||
# 'filename': settings.LOG_PATH['app'],
|
||||
# 'maxBytes': 10485760, # 5,242,880 = 5 MB; 10,485,760 = 10 MB
|
||||
# 'backupCount': 9
|
||||
# },
|
||||
# # 'log_file_warning': {
|
||||
# # 'level': 'WARNING',
|
||||
# # 'class': 'logging.handlers.RotatingFileHandler',
|
||||
# # 'formatter': 'long',
|
||||
# # 'filename': settings.LOG_PATH['app_warning'],
|
||||
# # 'maxBytes': 512000, # 524,288 = 512KB
|
||||
# # 'backupCount': 9
|
||||
# # },
|
||||
# # 'test_handler': {
|
||||
# # 'class': 'logging.StreamHandler',
|
||||
# # 'level': 'INFO',
|
||||
# # 'formatter': 'short',
|
||||
# # },
|
||||
# # 'test_handler_all_rotate': {
|
||||
# # 'class': 'logging.handlers.RotatingFileHandler',
|
||||
# # 'level': 'NOTSET',
|
||||
# # 'formatter': 'short',
|
||||
# # 'filename': '/logs/test_rotate.log',
|
||||
# # 'maxBytes': 100000, # 5120000 = 5 MB
|
||||
# # 'backupCount': 2,
|
||||
# # }
|
||||
# },
|
||||
# 'loggers': {
|
||||
# # 'uvicorn': {'handlers': ['default'], 'level': 'INFO'},
|
||||
# 'uvicorn': {'handlers': ['console'], 'level': 'INFO'},
|
||||
# # 'uvicorn.error': {'level': 'INFO', 'handlers': ['default'], 'propagate': True},
|
||||
# # 'uvicorn.error': {'level': 'INFO', 'handlers': ['console'], 'propagate': True},
|
||||
# # 'uvicorn.access': {'handlers': ['access'], 'level': 'INFO', 'propagate': False},
|
||||
# # 'gunicorn': {'handlers': ['console'], 'level': 'INFO'},
|
||||
# },
|
||||
# 'root': {
|
||||
# 'handlers': ['log_file_all'], #, 'log_file_all', 'log_file_warning'],
|
||||
# # 'handlers': ['console', 'log_file_all'], #, 'log_file_all', 'log_file_warning'],
|
||||
# 'level': 'WARNING', # WARNING
|
||||
# }
|
||||
# })
|
||||
|
||||
|
||||
# log = logging.getLogger('root')
|
||||
# # log.setLevel(logging.INFO) # DEBUG > INFO > WARNING > ERROR > CRITICAL
|
||||
# # logging.basicConfig(
|
||||
# # format='[%(asctime)s] %(levelname)s @ %(module)s.%(funcName)s()#%(lineno)d: %(message)s'
|
||||
# # )
|
||||
|
||||
|
||||
|
||||
# ### BEGIN ### Log ### logger_reset() ###
|
||||
# https://realpython.com/primer-on-python-decorators/
|
||||
# Updated 2022-02-15
|
||||
# def logger_reset(func):
|
||||
# # log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
# # log.info(locals())
|
||||
# @functools.wraps(func)
|
||||
# def wrapper(*args, **kwargs):
|
||||
# if func.__name__ not in ['redis_lookup_id_random', 'sql_enable_part', 'sql_hidden_part']:
|
||||
# log.info(f'*** Function: "{func.__name__}()"')
|
||||
# log.debug(f'*** Function Positional Args: {args}\nFunction Key Args: {kwargs}')
|
||||
# init_log_level = log.level
|
||||
# returned_result = func(*args, **kwargs)
|
||||
# log.debug(f'*** Function finished: "{func.__name__}()". Resetting logger level to level: {log.level} ***')
|
||||
# log.setLevel(init_log_level)
|
||||
# return returned_result
|
||||
# return wrapper
|
||||
# ### END ### Log ### logger_reset() ###
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
return logging.getLogger(name)
|
||||
63
app/main.py
63
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,11 +42,11 @@ 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...")
|
||||
|
||||
|
||||
# Save original settings for fallback
|
||||
orig_db_server = config.settings.DB_SERVER
|
||||
orig_db_user = config.settings.DB_USER
|
||||
@@ -81,22 +81,22 @@ 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 = '4.9.0',
|
||||
description = 'One Sky IT\'s Aether API v3.0 using FastAPI.',
|
||||
version = '3.00.10',
|
||||
operationsSorter = 'method',
|
||||
lifespan = lifespan,
|
||||
)
|
||||
@@ -114,6 +114,7 @@ app.mount('/static', StaticFiles(directory='static'), name='static')
|
||||
setup_routers(app)
|
||||
|
||||
|
||||
# Updated 2026-02-23
|
||||
# BEGIN: CORS
|
||||
# NOTE: Eventually this should query the DB for the specific list based on the cfg table and or site_domain table. That way it is dynamic and only allowing those defined in the DB. No wildcards or regex.
|
||||
# NOTE: Need to include .localhost for less browser restrictions! Mainly for audio and video.
|
||||
@@ -132,6 +133,44 @@ app.add_middleware(
|
||||
# END: CORS
|
||||
|
||||
|
||||
# Updated 2026-02-23
|
||||
# Add middleware to ensure Access-Control-Allow-Private-Network is present
|
||||
# when the response already includes CORS allow-origin (i.e. origin was allowed).
|
||||
@app.middleware("http")
|
||||
async def cors_pna_middleware(request: Request, call_next):
|
||||
"""Add `Access-Control-Allow-Private-Network: true` to responses
|
||||
only when CORS has already allowed the request's origin. This avoids
|
||||
echoing PNA for disallowed origins and leverages the existing
|
||||
CORSMiddleware origin validation.
|
||||
"""
|
||||
response = await call_next(request)
|
||||
# Rely on existing CORS logic (CORSMiddleware) to validate origin.
|
||||
# Only add the PNA header if an Allow-Origin header is present.
|
||||
if response.headers.get('access-control-allow-origin') or response.headers.get('Access-Control-Allow-Origin'):
|
||||
response.headers['Access-Control-Allow-Private-Network'] = 'true'
|
||||
return response
|
||||
|
||||
|
||||
# Updated 2026-02-23
|
||||
# Temporary debug middleware: logs Origin and PNA-related request/response headers
|
||||
# Activate only when an Origin header is present to limit log noise.
|
||||
# @app.middleware("http")
|
||||
# async def debug_pna_logging_middleware(request: Request, call_next):
|
||||
# origin = request.headers.get('origin')
|
||||
# if origin:
|
||||
# acrpn = request.headers.get('access-control-request-private-network')
|
||||
# acrm = request.headers.get('access-control-request-method')
|
||||
# log.debug(f"PNA_DEBUG REQ: method={request.method} path={request.url.path} remote={getattr(request.client, 'host', None)} origin={origin} acr_method={acrm} acr_private_network={acrpn}")
|
||||
|
||||
# response = await call_next(request)
|
||||
|
||||
# if origin:
|
||||
# # collect CORS/PNA-related response headers for visibility
|
||||
# interesting = {k: v for k, v in response.headers.items() if k.lower().startswith('access-control-') or k.lower() == 'vary'}
|
||||
# log.debug(f"PNA_DEBUG RESP: status={response.status_code} headers={interesting}")
|
||||
|
||||
# return response
|
||||
|
||||
# Register utility middleware from external module
|
||||
app.middleware('http')(process_time_middleware)
|
||||
|
||||
|
||||
@@ -1,625 +0,0 @@
|
||||
import datetime, json, os, pytz, random, secrets # , uvicorn
|
||||
|
||||
from enum import Enum
|
||||
#from datetime import datetime, time, timedelta
|
||||
from fastapi import Body, Cookie, Depends, FastAPI, File, Form, Header, HTTPException, Path, Query, Request, Response, status, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from functools import lru_cache
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
|
||||
from . import config
|
||||
|
||||
from app.log import log, logging
|
||||
|
||||
# Import the routers here first:
|
||||
from app.routers import ae_obj, aether_cfg, api_crud, api_crud_v2, api_crud_v3, api, importing, sql, account, activity_log, address, archive, archive_content, contact, cont_edu_cert, cont_edu_cert_person, data_store, event, event_abstract, event_badge, event_badge_importing, event_badge_template, event_device, event_exhibit, event_exhibit_tracking, event_file, event_importing, event_location, event_person, event_person_detail, event_person_tracking, event_presentation, event_presenter, event_registration, event_session, flask_cfg, fundraising, grant, hosted_file, journal, journal_entry, log_client_viewing, lookup, membership_cfg, membership_group, membership_person_group, membership_person, membership_person_profile, membership_type, membership_person_type, order, order_v3, order_line, order_cart, organization, page, person, person_user, post, post_comment, product, qr, site, site_domain, user, util_email, websockets_redis, e_confex, e_cvent, c_idaa, e_impexium, e_stripe
|
||||
|
||||
# from app.routers import aether_cfg, sql
|
||||
|
||||
from app.db_sql import sql_select, reset_redis # , sql_connect
|
||||
|
||||
|
||||
print('### **** *** ** * The Aether API v4 using FastAPI is loading... * ** *** **** ###')
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
# debug = True,
|
||||
title = 'Aether API',
|
||||
description = 'One Sky IT\'s Aether API v4 using FastAPI.',
|
||||
version = '4.9.0',
|
||||
operationsSorter = 'method',
|
||||
)
|
||||
|
||||
|
||||
log.setLevel(logging.INFO)
|
||||
# log.debug(config.settings)
|
||||
|
||||
if aether_cfg_sql_result := sql_select(
|
||||
table_name = 'cfg',
|
||||
record_id = config.settings.AETHER_CFG['id'],
|
||||
as_list = False,
|
||||
max_count = 1,
|
||||
):
|
||||
aether_cfg_sql = aether_cfg_sql_result
|
||||
|
||||
config.settings.DB['server'] = aether_cfg_sql.get('db_server')
|
||||
config.settings.DB['port'] = aether_cfg_sql.get('db_port')
|
||||
config.settings.DB['name'] = aether_cfg_sql.get('db_name')
|
||||
config.settings.DB['username'] = aether_cfg_sql.get('db_username')
|
||||
config.settings.DB['password'] = aether_cfg_sql.get('db_password')
|
||||
|
||||
DB = config.settings.DB
|
||||
config.settings.SQLALCHEMY_DB_URI = 'mysql://'+DB['username']+':'+DB['password']+'@'+DB['server']+'/'+DB['name']
|
||||
# db_result = sql_connect(config.settings.SQLALCHEMY_DB_URI)
|
||||
log.debug(config.settings.DB)
|
||||
|
||||
config.settings.SMTP['server'] = aether_cfg_sql.get('smtp_server')
|
||||
config.settings.SMTP['port'] = aether_cfg_sql.get('smtp_port')
|
||||
config.settings.SMTP['username'] = aether_cfg_sql.get('smtp_username')
|
||||
config.settings.SMTP['password'] = aether_cfg_sql.get('smtp_password')
|
||||
|
||||
# config.settings.FILES_PATH['hosted_files_root'] = aether_cfg_sql.get('PATH_HOSTED_FILES_ROOT')
|
||||
# config.settings.FILES_PATH['hosted_tmp_root'] = aether_cfg_sql.get('PATH_HOSTED_TMP_ROOT')
|
||||
|
||||
config.settings.FILES_PATH['hosted_files_root'] = aether_cfg_sql.get('path_hosted_files_root')
|
||||
config.settings.FILES_PATH['hosted_tmp_root'] = aether_cfg_sql.get('path_hosted_tmp_root')
|
||||
else:
|
||||
# aether_cfg_sql_result
|
||||
pass
|
||||
log.debug(aether_cfg_sql_result)
|
||||
log.debug(config.settings)
|
||||
|
||||
# @lru_cache()
|
||||
# def get_settings():
|
||||
# return config.Settings()
|
||||
|
||||
|
||||
app.mount('/static', StaticFiles(directory='static'), name='static')
|
||||
|
||||
|
||||
# Set up each route once the router has been imported
|
||||
app.include_router(
|
||||
ae_obj.router,
|
||||
prefix='/ae_obj',
|
||||
tags=['AE Object'],
|
||||
)
|
||||
app.include_router(
|
||||
aether_cfg.router,
|
||||
tags=['Aether Config'],
|
||||
)
|
||||
app.include_router(
|
||||
api_crud.router,
|
||||
prefix='/crud',
|
||||
tags=['CRUD v1.2 (Legacy)'],
|
||||
#dependencies=[Depends(get_token_header)],
|
||||
#dependencies=[Depends(get_account_header)],
|
||||
#responses={404: {'description': 'Not found'}},
|
||||
)
|
||||
app.include_router(
|
||||
api_crud_v2.router,
|
||||
prefix='/v2/crud',
|
||||
tags=['CRUD v2.5'],
|
||||
#dependencies=[Depends(get_token_header)],
|
||||
#dependencies=[Depends(get_account_header)],
|
||||
#responses={404: {'description': 'Not found'}},
|
||||
)
|
||||
app.include_router(
|
||||
api_crud_v3.router,
|
||||
prefix='/v3/crud',
|
||||
tags=['CRUD v3'],
|
||||
)
|
||||
app.include_router(
|
||||
api.router,
|
||||
prefix='/api',
|
||||
tags=['API'],
|
||||
)
|
||||
app.include_router(
|
||||
flask_cfg.router,
|
||||
prefix='/flask_cfg',
|
||||
tags=['Flask CFG'],
|
||||
)
|
||||
app.include_router(
|
||||
importing.router,
|
||||
prefix='/importing',
|
||||
tags=['Importing'],
|
||||
)
|
||||
app.include_router(
|
||||
sql.router,
|
||||
# prefix='/sql',
|
||||
tags=['SQL'],
|
||||
)
|
||||
# # app.include_router(
|
||||
# # flask_cfg.router,
|
||||
# # prefix='/redis',
|
||||
# # tags=['Redis'],
|
||||
# # )
|
||||
|
||||
app.include_router(
|
||||
account.router,
|
||||
# prefix='/account',
|
||||
tags=['Account'],
|
||||
)
|
||||
app.include_router(
|
||||
activity_log.router,
|
||||
prefix='/activity_log',
|
||||
tags=['Activity Log'],
|
||||
)
|
||||
app.include_router(
|
||||
address.router,
|
||||
prefix='/address',
|
||||
tags=['Address'],
|
||||
)
|
||||
app.include_router(
|
||||
archive.router,
|
||||
# prefix='/archive',
|
||||
tags=['Archive'],
|
||||
)
|
||||
app.include_router(
|
||||
archive_content.router,
|
||||
prefix='/archive/content',
|
||||
tags=['Archive Content'],
|
||||
)
|
||||
app.include_router(
|
||||
contact.router,
|
||||
prefix='/contact',
|
||||
tags=['Contact'],
|
||||
)
|
||||
app.include_router(
|
||||
cont_edu_cert.router,
|
||||
tags=['Cont Edu Cert'],
|
||||
)
|
||||
app.include_router(
|
||||
cont_edu_cert_person.router,
|
||||
tags=['Cont Edu Cert Person'],
|
||||
)
|
||||
app.include_router(
|
||||
data_store.router,
|
||||
# prefix='/data_store',
|
||||
tags=['Data Store'],
|
||||
)
|
||||
app.include_router(
|
||||
event.router,
|
||||
# prefix='/event',
|
||||
tags=['Event'],
|
||||
)
|
||||
app.include_router(
|
||||
event_abstract.router,
|
||||
tags=['Event Abstract'],
|
||||
)
|
||||
app.include_router(
|
||||
event_badge.router,
|
||||
tags=['Event Badge'],
|
||||
)
|
||||
app.include_router(
|
||||
event_badge_importing.router,
|
||||
tags=['Event Badge Importing'],
|
||||
)
|
||||
app.include_router(
|
||||
event_badge_template.router,
|
||||
# prefix='/event/badge/template',
|
||||
tags=['Event Badge Template'],
|
||||
)
|
||||
app.include_router(
|
||||
event_device.router,
|
||||
# prefix='/event/device',
|
||||
tags=['Event Device'],
|
||||
)
|
||||
app.include_router(
|
||||
event_exhibit.router,
|
||||
# prefix='/event/exhibit',
|
||||
tags=['Event Exhibit'],
|
||||
)
|
||||
app.include_router(
|
||||
event_exhibit_tracking.router,
|
||||
# prefix='/event/exhibit/tracking',
|
||||
tags=['Event Exhibit Tracking'],
|
||||
)
|
||||
app.include_router(
|
||||
event_file.router,
|
||||
# prefix='/event/file',
|
||||
tags=['Event File'],
|
||||
)
|
||||
app.include_router(
|
||||
event_importing.router,
|
||||
# prefix='/event/importing',
|
||||
tags=['Event Importing'],
|
||||
)
|
||||
app.include_router(
|
||||
event_location.router,
|
||||
# prefix='/event/location',
|
||||
tags=['Event Location'],
|
||||
)
|
||||
app.include_router(
|
||||
event_person.router,
|
||||
# prefix='/event/person',
|
||||
tags=['Event Person'],
|
||||
)
|
||||
app.include_router(
|
||||
event_person.router,
|
||||
prefix='/event/person/detail',
|
||||
tags=['Event Person Detail'],
|
||||
)
|
||||
app.include_router(
|
||||
event_person_tracking.router,
|
||||
tags=['Event Person Tracking'],
|
||||
)
|
||||
app.include_router(
|
||||
event_presentation.router,
|
||||
# prefix='/event/presentation',
|
||||
tags=['Event Presentation'],
|
||||
)
|
||||
app.include_router(
|
||||
event_presenter.router,
|
||||
prefix='/event/presenter',
|
||||
tags=['Event Presenter'],
|
||||
)
|
||||
app.include_router(
|
||||
event_registration.router,
|
||||
prefix='/event/registration',
|
||||
tags=['Event Registration'],
|
||||
)
|
||||
app.include_router(
|
||||
event_session.router,
|
||||
# prefix='/event/session',
|
||||
tags=['Event Session'],
|
||||
)
|
||||
app.include_router(
|
||||
fundraising.router,
|
||||
tags=['Fundraising'],
|
||||
)
|
||||
app.include_router(
|
||||
grant.router,
|
||||
tags=['Grant'],
|
||||
)
|
||||
app.include_router(
|
||||
hosted_file.router,
|
||||
prefix='/hosted_file',
|
||||
tags=['Hosted File'],
|
||||
)
|
||||
app.include_router(
|
||||
journal.router,
|
||||
prefix='/journal',
|
||||
tags=['Journal'],
|
||||
)
|
||||
app.include_router(
|
||||
journal_entry.router,
|
||||
# prefix='/journal/entry',
|
||||
tags=['Journal Entry'],
|
||||
)
|
||||
app.include_router(
|
||||
log_client_viewing.router,
|
||||
# prefix='/log/client_viewing',
|
||||
tags=['Log Client Viewing'],
|
||||
)
|
||||
app.include_router(
|
||||
lookup.router,
|
||||
prefix='/lu',
|
||||
tags=['Lookup'],
|
||||
)
|
||||
app.include_router(
|
||||
membership_cfg.router,
|
||||
tags=['Membership Config'],
|
||||
)
|
||||
app.include_router(
|
||||
membership_group.router,
|
||||
tags=['Membership Group'],
|
||||
)
|
||||
app.include_router(
|
||||
membership_person_group.router,
|
||||
tags=['Membership Group Person'],
|
||||
)
|
||||
app.include_router(
|
||||
membership_person_profile.router,
|
||||
tags=['Membership Person Profile'],
|
||||
)
|
||||
app.include_router(
|
||||
membership_person.router,
|
||||
tags=['Membership Person'],
|
||||
)
|
||||
app.include_router(
|
||||
membership_type.router,
|
||||
tags=['Membership Type'],
|
||||
)
|
||||
app.include_router(
|
||||
membership_person_type.router,
|
||||
tags=['Membership Type Person'],
|
||||
)
|
||||
app.include_router(
|
||||
order.router,
|
||||
# prefix='/order',
|
||||
tags=['Order'],
|
||||
)
|
||||
app.include_router(
|
||||
order_v3.router,
|
||||
# prefix='/order',
|
||||
tags=['Order v3'],
|
||||
)
|
||||
app.include_router(
|
||||
order_line.router,
|
||||
# prefix='/order',
|
||||
tags=['Order Line'],
|
||||
)
|
||||
app.include_router(
|
||||
order_cart.router,
|
||||
prefix='/order/cart',
|
||||
tags=['Order Cart'],
|
||||
)
|
||||
app.include_router(
|
||||
organization.router,
|
||||
prefix='/organization',
|
||||
tags=['Organization'],
|
||||
)
|
||||
app.include_router(
|
||||
page.router,
|
||||
prefix='/page',
|
||||
tags=['Page'],
|
||||
)
|
||||
app.include_router(
|
||||
person.router,
|
||||
tags=['Person'],
|
||||
)
|
||||
app.include_router(
|
||||
person_user.router,
|
||||
prefix='/person_user',
|
||||
tags=['Person User'],
|
||||
)
|
||||
app.include_router(
|
||||
post.router,
|
||||
# prefix='/post',
|
||||
tags=['Post'],
|
||||
)
|
||||
app.include_router(
|
||||
post_comment.router,
|
||||
prefix='/post/comment',
|
||||
tags=['Post Comment'],
|
||||
)
|
||||
app.include_router(
|
||||
product.router,
|
||||
# prefix='/product',
|
||||
tags=['Product'],
|
||||
)
|
||||
app.include_router(
|
||||
qr.router,
|
||||
tags=['QR'],
|
||||
)
|
||||
app.include_router(
|
||||
site.router,
|
||||
# prefix='/site',
|
||||
tags=['Site'],
|
||||
)
|
||||
app.include_router(
|
||||
site_domain.router,
|
||||
# prefix='/site/domain',
|
||||
tags=['Site Domain'],
|
||||
)
|
||||
app.include_router(
|
||||
user.router,
|
||||
tags=['User'],
|
||||
)
|
||||
app.include_router(
|
||||
util_email.router,
|
||||
tags=['Utility: Email'],
|
||||
)
|
||||
# app.include_router(
|
||||
# websockets.router,
|
||||
# # prefix='/websocket',
|
||||
# tags=['Websockets'],
|
||||
# # dependencies=[Depends(get_token_header)],
|
||||
# # responses={404: {'description': 'Not found'}},
|
||||
# )
|
||||
app.include_router(
|
||||
websockets_redis.router,
|
||||
tags=['Websockets (Redis)'],
|
||||
)
|
||||
app.include_router(
|
||||
e_confex.router,
|
||||
prefix='/e/confex',
|
||||
tags=['External Service: Confex'],
|
||||
)
|
||||
app.include_router(
|
||||
e_cvent.router,
|
||||
prefix='/e/cvent',
|
||||
tags=['External Service: Cvent'],
|
||||
)
|
||||
app.include_router(
|
||||
e_impexium.router,
|
||||
prefix='/e/impexium',
|
||||
tags=['External Service: Impexium'],
|
||||
)
|
||||
app.include_router(
|
||||
e_stripe.router,
|
||||
prefix='/e/stripe',
|
||||
tags=['External Service: Stripe'],
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
c_idaa.router,
|
||||
prefix='/c/idaa',
|
||||
tags=['Client: IDAA'],
|
||||
)
|
||||
|
||||
|
||||
# BEGIN: CORS
|
||||
# NOTE: Eventually this should query the DB for the specific list based on the cfg table and or site_domain table. That way it is dynamic and only allowing those defined in the DB. No wildcards or regex.
|
||||
# NOTE: Need to include .localhost for less browser restrictions! Mainly for audio and video.
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
# allow_origins = origins,
|
||||
allow_origins = config.settings.ORIGINS,
|
||||
allow_origin_regex = config.settings.ORIGINS_REGEX,
|
||||
# allow_origin_regex = 'https://.*\.oneskyit\.com',
|
||||
allow_credentials = True,
|
||||
allow_methods = ['*'],
|
||||
allow_headers = ['*'],
|
||||
#expose_headers = [],
|
||||
#max_age = 600,
|
||||
)
|
||||
# END: CORS
|
||||
|
||||
|
||||
@app.on_event('startup')
|
||||
async def startup():
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARN, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
log.info('The Aether FastAPI API is starting up...')
|
||||
#await database.connect()
|
||||
|
||||
|
||||
@app.on_event('shutdown')
|
||||
async def shutdown():
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARN, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
log.info('The Aether FastAPI API is shutting down...')
|
||||
#await database.disconnect()
|
||||
|
||||
|
||||
#Add the processing time to the response header.
|
||||
@app.middleware('http')
|
||||
async def add_process_time_header(request: Request, call_next):
|
||||
import time
|
||||
start_time = time.time()
|
||||
response = await call_next(request)
|
||||
process_time = time.time() - start_time
|
||||
response.headers['X-Process-Time'] = str(process_time)
|
||||
return response
|
||||
|
||||
|
||||
# ### BEGIN ### API Main ### fastapi_root() ###
|
||||
@app.get('/', tags=['Root'], response_class=PlainTextResponse)
|
||||
async def fastapi_root(response: Response = Response):
|
||||
log.setLevel(logging.DEBUG) # DEBUG, INFO, WARN, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
# log.info(config.settings.APP_NAME)
|
||||
log.info('One Sky IT\'s Aether API root (FastAPI)')
|
||||
|
||||
log.info('***')
|
||||
log.debug('This is debug') # 10 DEBUG
|
||||
log.info('This is info') # 20 INFO
|
||||
log.warning('This is a warning') # 30 WARNING (and WARN)
|
||||
log.error('This is an error') # 40 ERROR
|
||||
log.exception('This is an exception') # 40 ERROR
|
||||
log.critical('This is critical') # 50 CRITICAL
|
||||
log.info('^^^')
|
||||
|
||||
log.warning('Resetting Redis...')
|
||||
reset_redis()
|
||||
log.info('Reset Redis')
|
||||
|
||||
response_data = {}
|
||||
response_data['message'] = 'This is One Sky IT\'s Aether API root (FastAPI).'
|
||||
|
||||
|
||||
current_datetime = datetime.datetime.now()
|
||||
current_datetime_string = current_datetime.isoformat()
|
||||
|
||||
timezone = pytz.timezone("America/New_York")
|
||||
current_datetime_tz = timezone.localize(current_datetime)
|
||||
current_datetime_tz_string = current_datetime_tz.isoformat()
|
||||
|
||||
current_datetime_utc = datetime.datetime.utcnow()
|
||||
current_datetime_utc_string = current_datetime_utc.isoformat()
|
||||
|
||||
current_datetime_utc_localize = pytz.utc.localize(current_datetime_utc)
|
||||
current_datetime_utc_localize_string = current_datetime_utc_localize.isoformat()
|
||||
|
||||
current_datetime_utc_localize_pst = current_datetime_utc_localize.astimezone(pytz.timezone("America/Los_Angeles"))
|
||||
current_datetime_utc_localize_pst_string = current_datetime_utc_localize_pst.isoformat()
|
||||
|
||||
response_data['datetime'] = current_datetime_string
|
||||
response_data['datetime_tz'] = current_datetime_tz_string
|
||||
response_data['datetime_utc'] = current_datetime_utc_string
|
||||
response_data['datetime_utc_localize'] = current_datetime_utc_localize_string
|
||||
response_data['datetime_utc_localize_pst'] = current_datetime_utc_localize_pst_string
|
||||
|
||||
response_data['url_safe_string_4_bytes_1'] = secrets.token_urlsafe(4)
|
||||
|
||||
response_data['url_safe_string_8_bytes_1'] = secrets.token_urlsafe(8)
|
||||
response_data['url_safe_string_8_bytes_2'] = secrets.token_urlsafe(8)
|
||||
response_data['url_safe_string_8_bytes_3'] = secrets.token_urlsafe(8)
|
||||
response_data['url_safe_string_8_bytes_4'] = secrets.token_urlsafe(8)
|
||||
response_data['url_safe_string_8_bytes_5'] = secrets.token_urlsafe(8)
|
||||
|
||||
response_data['url_safe_string_16_bytes_1'] = secrets.token_urlsafe(16)
|
||||
response_data['url_safe_string_16_bytes_2'] = secrets.token_urlsafe(16)
|
||||
response_data['url_safe_string_16_bytes_3'] = secrets.token_urlsafe(16)
|
||||
response_data['url_safe_string_16_bytes_4'] = secrets.token_urlsafe(16)
|
||||
response_data['url_safe_string_16_bytes_5'] = secrets.token_urlsafe(16)
|
||||
|
||||
response_data['hex_string_4_bytes_1'] = secrets.token_hex(4)
|
||||
response_data['hex_string_8_bytes_1'] = secrets.token_hex(8)
|
||||
response_data['hex_string_16_bytes_1'] = secrets.token_hex(16)
|
||||
response_data['hex_string_32_bytes_1'] = secrets.token_hex(32)
|
||||
|
||||
log.debug(json.dumps(response_data, indent=4))
|
||||
return json.dumps(response_data, indent=4) # , sort_keys=True
|
||||
# ### END ### API Main ### fastapi_root() ###
|
||||
|
||||
|
||||
# ### BEGIN ### API Main ### generate_id_random() ###
|
||||
# NOTE: This is just a quick utility function to generate a bunch of random IDs.
|
||||
# Updated 2022-03-30
|
||||
@app.get('/generate_id_random', tags=['Root'], response_class=PlainTextResponse)
|
||||
async def generate_id_random(response: Response = Response):
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARN, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
response_data = {}
|
||||
|
||||
html_list = '<ul>'
|
||||
for x in range(50):
|
||||
html_list += f'<li>{secrets.token_urlsafe(8)}</li>'
|
||||
html_list += '</ul>'
|
||||
|
||||
return HTMLResponse(content=html_list, status_code=200)
|
||||
# ### END ### API Main ### generate_id_random() ###
|
||||
|
||||
|
||||
# ### BEGIN ### API Main ### sql_test() ###
|
||||
# ### TEST TEST TEST ### #
|
||||
@app.get('/sql_test', tags=['Testing'], response_class=PlainTextResponse)
|
||||
async def sql_test(response: Response = Response):
|
||||
log.setLevel(logging.DEBUG) # DEBUG, INFO, WARN, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
return mk_resp(data=False, status_code=501, response=response)
|
||||
|
||||
log.info('Getting all accounts from DB...')
|
||||
|
||||
sql = text(
|
||||
"""
|
||||
SELECT id, id_random, name, enable
|
||||
FROM `account`
|
||||
"""
|
||||
)
|
||||
try:
|
||||
result = db.execute(sql)
|
||||
except Exception as e:
|
||||
log.error('*** An exception happened. ***')
|
||||
log.error(repr(e))
|
||||
log.error('***')
|
||||
log.error(str(e))
|
||||
log.error('^^^ exception ^^^')
|
||||
else:
|
||||
if result.rowcount:
|
||||
record_li = [dict(record) for record in result.fetchall()]
|
||||
log.debug(record_li)
|
||||
else:
|
||||
log.error('No records found. Something went wrong.')
|
||||
|
||||
log.info('Got the account list')
|
||||
|
||||
response_data = {}
|
||||
response_data['message'] = 'This is the Aether API using FastAPI.'
|
||||
response_data['data'] = record_li
|
||||
|
||||
return json.dumps(response_data, indent=4) # , sort_keys=True
|
||||
# ### END ### API Main ### sql_test() ###
|
||||
110
app/methods/api_crud_methods.py
Normal file
110
app/methods/api_crud_methods.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from fastapi import Query, Response
|
||||
from typing import Optional, Union, List
|
||||
import logging
|
||||
|
||||
from app.db_sql import sql_insert, sql_update, sql_select, sql_delete, redis_lookup_id_random, lookup_id_random_pop
|
||||
from app.models.response_models import mk_resp
|
||||
from app.object_definitions.legacy_v1 import obj_type_li
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def post_obj_template(
|
||||
obj_type: str,
|
||||
data: dict,
|
||||
id_random_length: int = 8,
|
||||
return_obj: bool = True,
|
||||
by_alias: bool = True,
|
||||
exclude_unset: Optional[bool] = True,
|
||||
response: Response = Response,
|
||||
**kwargs
|
||||
):
|
||||
obj_data = lookup_id_random_pop(data)
|
||||
table_name_select = obj_type_li[obj_type]['table_name']
|
||||
base_name = obj_type_li[obj_type]['base_name']
|
||||
|
||||
if sql_insert_result := sql_insert(table_name=obj_type, data=obj_data, id_random_length=id_random_length):
|
||||
obj_id = sql_insert_result
|
||||
else:
|
||||
return mk_resp(data=False, status_code=400, response=response)
|
||||
|
||||
if sql_select_result := sql_select(table_name=table_name_select, record_id=obj_id):
|
||||
resp_data = base_name(**sql_select_result).dict(by_alias=by_alias, exclude_unset=exclude_unset)
|
||||
return mk_resp(data=resp_data, response=response)
|
||||
return mk_resp(data=False, status_code=404, response=response)
|
||||
|
||||
def patch_obj_template(
|
||||
obj_type: str,
|
||||
data: dict,
|
||||
obj_id: str,
|
||||
by_alias: bool=True,
|
||||
exclude_unset: Optional[bool] = True,
|
||||
response: Response = Response,
|
||||
**kwargs
|
||||
):
|
||||
data['id_random'] = obj_id
|
||||
obj_data = lookup_id_random_pop(data)
|
||||
table_name_select = obj_type_li[obj_type]['table_name']
|
||||
base_name = obj_type_li[obj_type]['base_name']
|
||||
|
||||
if sql_update(table_name=obj_type, data=obj_data):
|
||||
obj_id_int = data['id']
|
||||
else:
|
||||
return mk_resp(data=False, status_code=400, response=response)
|
||||
|
||||
if sql_select_result := sql_select(table_name=table_name_select, record_id=obj_id_int):
|
||||
resp_data = base_name(**sql_select_result).dict(by_alias=by_alias, exclude_unset=exclude_unset)
|
||||
return mk_resp(data=resp_data, response=response)
|
||||
return mk_resp(data=False, status_code=404, response=response)
|
||||
|
||||
def get_obj_li_template(
|
||||
obj_type: str,
|
||||
for_obj_type: Optional[str] = None,
|
||||
for_obj_id: Optional[Union[int,str]] = None,
|
||||
by_alias: Optional[bool] = True,
|
||||
exclude_unset: Optional[bool] = True,
|
||||
response: Response = Response,
|
||||
**kwargs
|
||||
):
|
||||
if isinstance(for_obj_id, str):
|
||||
for_obj_id = redis_lookup_id_random(record_id_random=for_obj_id, table_name=for_obj_type)
|
||||
|
||||
table_name_select = obj_type_li[obj_type]['table_name']
|
||||
base_name = obj_type_li[obj_type]['base_name']
|
||||
|
||||
if for_obj_type and for_obj_id:
|
||||
sql_result = sql_select(table_name=table_name_select, field_name=f'{for_obj_type}_id', field_value=for_obj_id)
|
||||
else:
|
||||
sql_result = sql_select(table_name=table_name_select)
|
||||
|
||||
resp_data_li = [base_name(**record).dict(by_alias=by_alias, exclude_unset=exclude_unset) for record in (sql_result or [])]
|
||||
return mk_resp(data=resp_data_li, response=response)
|
||||
|
||||
def get_obj_template(
|
||||
obj_id: Union[int,str],
|
||||
obj_type: str,
|
||||
by_alias: Optional[bool] = True,
|
||||
exclude_unset: Optional[bool] = True,
|
||||
response: Response = Response,
|
||||
**kwargs
|
||||
):
|
||||
if isinstance(obj_id, str):
|
||||
obj_id = redis_lookup_id_random(record_id_random=obj_id, table_name=obj_type)
|
||||
|
||||
table_name_select = obj_type_li[obj_type]['table_name']
|
||||
if not obj_id: return mk_resp(data=False, status_code=404, response=response)
|
||||
|
||||
if sql_result := sql_select(table_name=table_name_select, record_id=obj_id):
|
||||
base_name = obj_type_li[obj_type]['base_name']
|
||||
resp_data = base_name(**sql_result).dict(by_alias=by_alias, exclude_unset=exclude_unset)
|
||||
return mk_resp(data=resp_data, response=response)
|
||||
return mk_resp(data=False, status_code=404, response=response)
|
||||
|
||||
def delete_obj_template(
|
||||
obj_type: str,
|
||||
obj_id: str,
|
||||
response: Response = Response,
|
||||
**kwargs
|
||||
):
|
||||
if sql_delete(table_name=obj_type, record_id_random=obj_id):
|
||||
return mk_resp(data=True, response=response)
|
||||
return mk_resp(data=False, status_code=404, response=response)
|
||||
@@ -55,7 +55,7 @@ def load_data_store_obj(
|
||||
def load_data_store_obj_w_code(
|
||||
account_id: int,
|
||||
code: str,
|
||||
for_type: int = None,
|
||||
for_type: str = None,
|
||||
for_id: int = None,
|
||||
enabled: str = 'enabled', # enabled, disabled, all
|
||||
limit: int = 1,
|
||||
@@ -80,16 +80,9 @@ def load_data_store_obj_w_code(
|
||||
# if for_id := redis_lookup_id_random(record_id_random=for_id, table_name=for_type): pass
|
||||
# else: return False
|
||||
|
||||
if for_type and for_id:
|
||||
sql_for_type_id = 'AND `data_store`.for_type = :for_type AND `data_store`.for_id = :for_id'
|
||||
else:
|
||||
sql_for_type_id = 'AND `data_store`.for_type IS NULL AND `data_store`.for_id IS NULL'
|
||||
|
||||
sql_enabled, data['enable'] = sql_enable_part(table_name='data_store', enabled=enabled) # Reasonably safe return str and bool
|
||||
sql_limit = sql_limit_offset_part(limit=limit, offset=offset) # Reasonably safe return str
|
||||
|
||||
log.debug(data)
|
||||
|
||||
sql = f"""
|
||||
SELECT *
|
||||
FROM `v_data_store` AS `data_store`
|
||||
@@ -97,11 +90,11 @@ def load_data_store_obj_w_code(
|
||||
(
|
||||
`data_store`.account_id = :account_id
|
||||
OR `data_store`.account_id IS NULL
|
||||
OR (`data_store`.for_type = :for_type AND `data_store`.for_id = :for_id)
|
||||
)
|
||||
AND `data_store`.code = :code
|
||||
{sql_for_type_id}
|
||||
{sql_enabled}
|
||||
ORDER BY `data_store`.account_id DESC, `data_store`.created_on DESC, `data_store`.updated_on DESC
|
||||
ORDER BY `data_store`.for_id DESC, `data_store`.account_id DESC, `data_store`.created_on DESC, `data_store`.updated_on DESC
|
||||
{sql_limit};
|
||||
"""
|
||||
# log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
@@ -123,11 +116,11 @@ def load_data_store_obj_w_code(
|
||||
try:
|
||||
data_store_obj = Data_Store_Base(**data_store_rec)
|
||||
data_store_obj_li.append(data_store_obj)
|
||||
log.debug(data_store_obj)
|
||||
except ValidationError as e:
|
||||
log.error(e.json())
|
||||
data_store_obj_li.append(None)
|
||||
# return False
|
||||
log.debug(data_store_obj)
|
||||
else: pass
|
||||
|
||||
log.info(f'Found {len(data_store_obj_li)} Data Store records with code: {code} for Account ID: {account_id} and For Type: {for_type} and For ID: {for_id}')
|
||||
|
||||
448
app/methods/e_novi_mailman_methods.py
Normal file
448
app/methods/e_novi_mailman_methods.py
Normal file
@@ -0,0 +1,448 @@
|
||||
import json, requests
|
||||
from typing import Dict, List, Optional
|
||||
from app.db_sql import sql_select
|
||||
from app.lib_general import log, logging, logger_reset
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Novi-Mailman Bridge — IDAA
|
||||
#
|
||||
# Credentials live in site.cfg_json for the IDAA site (id_random='58_gJESdlUh').
|
||||
# Novi keys already present:
|
||||
# novi_api_root_url — e.g. "https://www.idaa.org/api"
|
||||
# novi_idaa_api_key — Base64 API key (Basic auth)
|
||||
#
|
||||
# Keys that must be added to cfg_json before Mailman or webhooks can work:
|
||||
# mailman_base_url — e.g. "http://lists.idaa.org:8001"
|
||||
# mailman_username — Mailman REST admin user (usually "restadmin")
|
||||
# mailman_password — Mailman REST admin password
|
||||
# mailman_list_id — Target list, e.g. "members@idaa.org"
|
||||
# novi_webhook_secret — Shared secret for HMAC-SHA256 webhook validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
IDAA_SITE_ID_RANDOM = '58_gJESdlUh'
|
||||
|
||||
|
||||
# ── Config Helper ─────────────────────────────────────────────────────────
|
||||
|
||||
@logger_reset
|
||||
def _load_idaa_cfg() -> Optional[Dict]:
|
||||
"""
|
||||
Load IDAA site cfg_json. Returns the parsed dict, or None on failure.
|
||||
"""
|
||||
from app.methods.site_methods import load_site_obj
|
||||
site = load_site_obj(site_id=IDAA_SITE_ID_RANDOM, model_as_dict=True)
|
||||
if not site:
|
||||
log.error("Could not load IDAA site record (id_random='%s').", IDAA_SITE_ID_RANDOM)
|
||||
return None
|
||||
cfg = site.get('cfg_json')
|
||||
if isinstance(cfg, str):
|
||||
try:
|
||||
cfg = json.loads(cfg)
|
||||
except Exception as e:
|
||||
log.error("Failed to parse IDAA cfg_json: %s", e)
|
||||
return None
|
||||
if not isinstance(cfg, dict):
|
||||
log.error("IDAA cfg_json is not a dict after parsing.")
|
||||
return None
|
||||
return cfg
|
||||
|
||||
|
||||
# ── Novi AMS Methods ──────────────────────────────────────────────────────
|
||||
|
||||
@logger_reset
|
||||
def test_novi_connection() -> Dict:
|
||||
"""
|
||||
Verify Novi AMS API credentials from IDAA site cfg_json.
|
||||
Uses the first group GUID in novi_idaa_group_guid_li as a lightweight auth probe.
|
||||
Returns {'ok': True, 'member_count': N} on success.
|
||||
"""
|
||||
cfg = _load_idaa_cfg()
|
||||
if not cfg:
|
||||
return {"ok": False, "error": "Could not load IDAA site config."}
|
||||
|
||||
base_url = cfg.get('novi_api_root_url', '').rstrip('/')
|
||||
api_key = cfg.get('novi_idaa_api_key', '')
|
||||
|
||||
if not base_url or not api_key:
|
||||
return {"ok": False, "error": "novi_api_root_url or novi_idaa_api_key missing from cfg_json."}
|
||||
|
||||
group_guid_li = cfg.get('novi_idaa_group_guid_li') or []
|
||||
if not group_guid_li:
|
||||
return {"ok": False, "error": "novi_idaa_group_guid_li missing from cfg_json."}
|
||||
|
||||
headers = {"Authorization": f"Basic {api_key}", "Accept": "application/json"}
|
||||
try:
|
||||
# Use first group as a lightweight auth probe (pageSize=1)
|
||||
guid = group_guid_li[0]
|
||||
resp = requests.get(f"{base_url}/groups/{guid}/members",
|
||||
headers=headers, params={"pageSize": 1}, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
return {"ok": True, "probe_group": guid}
|
||||
return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
|
||||
except Exception as e:
|
||||
log.exception("Novi connection test failed: %s", e)
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
@logger_reset
|
||||
def get_novi_members(status_filter: Optional[str] = None, page_size: int = 500, offset: int = 0) -> Optional[List[Dict]]:
|
||||
"""
|
||||
Fetch member records from Novi AMS.
|
||||
|
||||
Novi has no flat member-list endpoint. Members are fetched per group from
|
||||
novi_idaa_group_guid_li, deduped by UniqueID, then each member's full record
|
||||
(including Email) is fetched via GET /customers/{uuid}.
|
||||
|
||||
status_filter and pagination (page_size/offset) are not supported at the
|
||||
Novi API level for this approach — all group members are returned.
|
||||
"""
|
||||
cfg = _load_idaa_cfg()
|
||||
if not cfg:
|
||||
return None
|
||||
|
||||
base_url = cfg.get('novi_api_root_url', '').rstrip('/')
|
||||
api_key = cfg.get('novi_idaa_api_key', '')
|
||||
group_guid_li = cfg.get('novi_idaa_group_guid_li') or []
|
||||
headers = {"Authorization": f"Basic {api_key}", "Accept": "application/json"}
|
||||
|
||||
if not group_guid_li:
|
||||
log.error("novi_idaa_group_guid_li missing from cfg_json.")
|
||||
return None
|
||||
|
||||
# Step 1: collect unique member UUIDs across all configured groups
|
||||
seen_uuids: set = set()
|
||||
uuid_list: List[str] = []
|
||||
for guid in group_guid_li:
|
||||
try:
|
||||
resp = requests.get(f"{base_url}/groups/{guid}/members",
|
||||
headers=headers, params={"pageSize": page_size}, timeout=30)
|
||||
if resp.status_code != 200:
|
||||
log.error("Novi group %s fetch error: %s", guid, resp.status_code)
|
||||
continue
|
||||
for entry in resp.json():
|
||||
uid = entry.get('UniqueID')
|
||||
if uid and uid not in seen_uuids:
|
||||
seen_uuids.add(uid)
|
||||
uuid_list.append(uid)
|
||||
except Exception as e:
|
||||
log.exception("Failed to fetch Novi group %s: %s", guid, e)
|
||||
|
||||
log.info("Novi: %d unique members across %d group(s).", len(uuid_list), len(group_guid_li))
|
||||
|
||||
# Step 2: fetch full customer record (including Email) for each UUID
|
||||
members: List[Dict] = []
|
||||
for uid in uuid_list:
|
||||
try:
|
||||
resp = requests.get(f"{base_url}/customers/{uid}", headers=headers, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
members.append(resp.json())
|
||||
else:
|
||||
log.warning("Novi customer %s fetch error: %s", uid, resp.status_code)
|
||||
except Exception as e:
|
||||
log.exception("Failed to fetch Novi customer %s: %s", uid, e)
|
||||
|
||||
return members
|
||||
|
||||
|
||||
# ── Mailman 3 Methods ─────────────────────────────────────────────────────
|
||||
|
||||
@logger_reset
|
||||
def test_mailman_connection() -> Dict:
|
||||
"""
|
||||
Verify Mailman 3 REST API credentials from IDAA site cfg_json.
|
||||
Returns {'ok': True, 'version': '...'} on success.
|
||||
"""
|
||||
cfg = _load_idaa_cfg()
|
||||
if not cfg:
|
||||
return {"ok": False, "error": "Could not load IDAA site config."}
|
||||
|
||||
base_url = cfg.get('mailman_base_url', '').rstrip('/')
|
||||
username = cfg.get('mailman_username', 'restadmin')
|
||||
password = cfg.get('mailman_password', '')
|
||||
|
||||
if not base_url or not password:
|
||||
return {"ok": False, "error": "mailman_base_url or mailman_password missing from cfg_json."}
|
||||
|
||||
try:
|
||||
resp = requests.get(f"{base_url}/3.1/system/versions", auth=(username, password), timeout=10)
|
||||
if resp.status_code == 200:
|
||||
return {"ok": True, "version": resp.json().get('mailman_version')}
|
||||
return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
|
||||
except Exception as e:
|
||||
log.exception("Mailman connection test failed: %s", e)
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
@logger_reset
|
||||
def get_mailman_list_members(list_id: str, count: int = 100, page: int = 1) -> Optional[List[Dict]]:
|
||||
"""
|
||||
Return members of a specific Mailman 3 list.
|
||||
|
||||
Args:
|
||||
list_id: fqdn_listname e.g. 'mm3@idaa.org' or dot-notation 'mm3.idaa.org'
|
||||
count: page size (Mailman default 20, max typically 100)
|
||||
page: 1-based page number
|
||||
"""
|
||||
cfg = _load_idaa_cfg()
|
||||
if not cfg:
|
||||
return None
|
||||
|
||||
base_url = cfg.get('mailman_base_url', '').rstrip('/')
|
||||
auth = (cfg.get('mailman_username', 'restadmin'), cfg.get('mailman_password', ''))
|
||||
list_id_dot = list_id.replace('@', '.')
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{base_url}/3.1/lists/{list_id_dot}/roster/member",
|
||||
auth=auth,
|
||||
params={"count": count, "page": page},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
log.error("Mailman member list fetch failed for %s: %s", list_id, resp.status_code)
|
||||
return None
|
||||
data = resp.json()
|
||||
return data.get('entries', [])
|
||||
except Exception as e:
|
||||
log.exception("Failed to fetch members for list %s: %s", list_id, e)
|
||||
return None
|
||||
|
||||
|
||||
@logger_reset
|
||||
def get_mailman_lists() -> Optional[List[Dict]]:
|
||||
"""Return all mailing lists from this Mailman 3 instance."""
|
||||
cfg = _load_idaa_cfg()
|
||||
if not cfg:
|
||||
return None
|
||||
|
||||
base_url = cfg.get('mailman_base_url', '').rstrip('/')
|
||||
auth = (cfg.get('mailman_username', 'restadmin'), cfg.get('mailman_password', ''))
|
||||
|
||||
try:
|
||||
resp = requests.get(f"{base_url}/3.1/lists", auth=auth, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
log.error("Mailman list fetch failed: %s", resp.status_code)
|
||||
return None
|
||||
return resp.json().get('entries', [])
|
||||
except Exception as e:
|
||||
log.exception("Failed to fetch Mailman lists: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
@logger_reset
|
||||
def subscribe_member_to_list(list_id: str, email: str, display_name: str = '') -> bool:
|
||||
"""
|
||||
Subscribe an email address to a Mailman 3 list (pre-confirmed, no welcome email).
|
||||
Returns True on success or already-subscribed, False on error.
|
||||
"""
|
||||
cfg = _load_idaa_cfg()
|
||||
if not cfg:
|
||||
return False
|
||||
|
||||
base_url = cfg.get('mailman_base_url', '').rstrip('/')
|
||||
auth = (cfg.get('mailman_username', 'restadmin'), cfg.get('mailman_password', ''))
|
||||
|
||||
payload = {
|
||||
"list_id": list_id.replace('@', '.'),
|
||||
"subscriber": email,
|
||||
"display_name": display_name,
|
||||
"pre_verified": True,
|
||||
"pre_confirmed": True,
|
||||
"pre_approved": True,
|
||||
"send_welcome_message": False,
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(f"{base_url}/3.1/members", auth=auth, json=payload, timeout=10)
|
||||
if resp.status_code in (200, 201):
|
||||
log.info("Subscribed %s to %s", email, list_id)
|
||||
return True
|
||||
if resp.status_code == 409:
|
||||
log.debug("%s already subscribed to %s — skipping.", email, list_id)
|
||||
return True
|
||||
log.error("Subscribe failed for %s: %s - %s", email, resp.status_code, resp.text[:200])
|
||||
return False
|
||||
except Exception as e:
|
||||
log.exception("Error subscribing %s to %s: %s", email, list_id, e)
|
||||
return False
|
||||
|
||||
|
||||
@logger_reset
|
||||
def unsubscribe_member_from_list(list_id: str, email: str) -> bool:
|
||||
"""
|
||||
Unsubscribe an email address from a Mailman 3 list.
|
||||
Returns True on success or not-found, False on error.
|
||||
"""
|
||||
cfg = _load_idaa_cfg()
|
||||
if not cfg:
|
||||
return False
|
||||
|
||||
base_url = cfg.get('mailman_base_url', '').rstrip('/')
|
||||
auth = (cfg.get('mailman_username', 'restadmin'), cfg.get('mailman_password', ''))
|
||||
list_id_dot = list_id.replace('@', '.')
|
||||
|
||||
try:
|
||||
resp = requests.delete(
|
||||
f"{base_url}/3.1/lists/{list_id_dot}/member/{email}",
|
||||
auth=auth,
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code in (200, 204):
|
||||
log.info("Unsubscribed %s from %s", email, list_id)
|
||||
return True
|
||||
if resp.status_code == 404:
|
||||
log.debug("%s not found in %s — skipping.", email, list_id)
|
||||
return True
|
||||
log.error("Unsubscribe failed for %s: %s - %s", email, resp.status_code, resp.text[:200])
|
||||
return False
|
||||
except Exception as e:
|
||||
log.exception("Error unsubscribing %s from %s: %s", email, list_id, e)
|
||||
return False
|
||||
|
||||
|
||||
# ── Mirror Sync Engine ────────────────────────────────────────────────────
|
||||
|
||||
@logger_reset
|
||||
def mirror_novi_group_to_mailman_list(novi_group_guid: str, mailman_list_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Mirror a single Novi group to a Mailman 3 list.
|
||||
|
||||
- Fetches all members of `novi_group_guid` from Novi.
|
||||
- For each member, fetches their customer record to get Email, name, and
|
||||
membership flags. Members with `Active=False` or `UnsubscribeFromEmails=True`
|
||||
are excluded from the target set.
|
||||
- Fetches current members of `mailman_list_id`.
|
||||
- Subscribes addresses in Novi but not in Mailman.
|
||||
- Unsubscribes addresses in Mailman but not in Novi (mirror / full reconcile).
|
||||
- Returns a result dict with counts.
|
||||
"""
|
||||
cfg = _load_idaa_cfg()
|
||||
if not cfg:
|
||||
return None
|
||||
|
||||
base_url = cfg.get('novi_api_root_url', '').rstrip('/')
|
||||
api_key = cfg.get('novi_idaa_api_key', '')
|
||||
headers = {"Authorization": f"Basic {api_key}", "Accept": "application/json"}
|
||||
|
||||
log.info("Mirror sync: Novi group %s → Mailman list %s", novi_group_guid, mailman_list_id)
|
||||
|
||||
# ── Step 1: Novi group → UUIDs ────────────────────────────────────────
|
||||
try:
|
||||
resp = requests.get(f"{base_url}/groups/{novi_group_guid}/members",
|
||||
headers=headers, params={"pageSize": 500}, timeout=30)
|
||||
if resp.status_code != 200:
|
||||
log.error("Novi group fetch failed (%s): %s", novi_group_guid, resp.status_code)
|
||||
return None
|
||||
uuid_list = [m['UniqueID'] for m in resp.json() if m.get('UniqueID')]
|
||||
except Exception as e:
|
||||
log.exception("Failed to fetch Novi group %s: %s", novi_group_guid, e)
|
||||
return None
|
||||
|
||||
log.info("Novi group %s has %d member(s).", novi_group_guid, len(uuid_list))
|
||||
|
||||
# ── Step 2: UUID → customer record → email ────────────────────────────
|
||||
# email.lower() → display_name
|
||||
novi_members: Dict[str, str] = {}
|
||||
skipped_inactive = 0
|
||||
skipped_unsub = 0
|
||||
skipped_no_email = 0
|
||||
|
||||
for uid in uuid_list:
|
||||
try:
|
||||
r = requests.get(f"{base_url}/customers/{uid}", headers=headers, timeout=10)
|
||||
if r.status_code != 200:
|
||||
log.warning("Novi customer %s fetch failed: %s", uid, r.status_code)
|
||||
continue
|
||||
c = r.json()
|
||||
if not c.get('Active', False):
|
||||
skipped_inactive += 1
|
||||
continue
|
||||
if c.get('UnsubscribeFromEmails', False):
|
||||
skipped_unsub += 1
|
||||
continue
|
||||
email = (c.get('Email') or '').strip()
|
||||
if not email:
|
||||
skipped_no_email += 1
|
||||
continue
|
||||
display = f"{c.get('FirstName', '')} {c.get('LastName', '')}".strip()
|
||||
novi_members[email.lower()] = display
|
||||
except Exception as e:
|
||||
log.exception("Failed to fetch Novi customer %s: %s", uid, e)
|
||||
|
||||
log.info("Novi active/subscribed members with email: %d (skipped: inactive=%d unsub=%d no_email=%d)",
|
||||
len(novi_members), skipped_inactive, skipped_unsub, skipped_no_email)
|
||||
|
||||
# ── Step 3: Current Mailman members ───────────────────────────────────
|
||||
mailman_entries = get_mailman_list_members(mailman_list_id)
|
||||
if mailman_entries is None:
|
||||
log.error("Could not fetch current Mailman members for %s — aborting.", mailman_list_id)
|
||||
return None
|
||||
mailman_emails = {m['email'].lower() for m in mailman_entries}
|
||||
|
||||
# ── Step 4: Diff ──────────────────────────────────────────────────────
|
||||
novi_email_set = set(novi_members.keys())
|
||||
to_subscribe = novi_email_set - mailman_emails
|
||||
to_unsubscribe = mailman_emails - novi_email_set
|
||||
|
||||
log.info("Diff — to subscribe: %d, to unsubscribe: %d", len(to_subscribe), len(to_unsubscribe))
|
||||
|
||||
results = {
|
||||
"novi_group_guid": novi_group_guid,
|
||||
"mailman_list_id": mailman_list_id,
|
||||
"novi_count": len(novi_email_set),
|
||||
"mailman_count_before": len(mailman_emails),
|
||||
"subscribed": 0,
|
||||
"unsubscribed": 0,
|
||||
"errors": 0,
|
||||
"skipped_inactive": skipped_inactive,
|
||||
"skipped_unsub": skipped_unsub,
|
||||
"skipped_no_email": skipped_no_email,
|
||||
}
|
||||
|
||||
# ── Step 5: Apply ─────────────────────────────────────────────────────
|
||||
for email in to_subscribe:
|
||||
ok = subscribe_member_to_list(mailman_list_id, email, novi_members[email])
|
||||
if ok: results['subscribed'] += 1
|
||||
else: results['errors'] += 1
|
||||
|
||||
for email in to_unsubscribe:
|
||||
ok = unsubscribe_member_from_list(mailman_list_id, email)
|
||||
if ok: results['unsubscribed'] += 1
|
||||
else: results['errors'] += 1
|
||||
|
||||
log.info("Mirror sync complete: %s", results)
|
||||
return results
|
||||
|
||||
|
||||
@logger_reset
|
||||
def mirror_all_configured_mappings() -> Optional[List[Dict]]:
|
||||
"""
|
||||
Run mirror_novi_group_to_mailman_list for every entry in
|
||||
cfg_json['novi_mailman_sync'].
|
||||
|
||||
Expected cfg_json shape:
|
||||
"novi_mailman_sync": [
|
||||
{"novi_group_guid": "...", "mailman_list_id": "members@idaa.org"},
|
||||
...
|
||||
]
|
||||
"""
|
||||
cfg = _load_idaa_cfg()
|
||||
if not cfg:
|
||||
return None
|
||||
|
||||
sync_map = cfg.get('novi_mailman_sync') or []
|
||||
if not sync_map:
|
||||
log.warning("novi_mailman_sync not configured in IDAA cfg_json.")
|
||||
return []
|
||||
|
||||
results = []
|
||||
for mapping in sync_map:
|
||||
guid = mapping.get('novi_group_guid', '').strip()
|
||||
list_id = mapping.get('mailman_list_id', '').strip()
|
||||
if not guid or not list_id:
|
||||
log.warning("Skipping incomplete novi_mailman_sync entry: %s", mapping)
|
||||
continue
|
||||
result = mirror_novi_group_to_mailman_list(guid, list_id)
|
||||
results.append(result or {"novi_group_guid": guid, "mailman_list_id": list_id, "error": "sync failed"})
|
||||
|
||||
return results
|
||||
226
app/methods/e_zoom_methods.py
Normal file
226
app/methods/e_zoom_methods.py
Normal file
@@ -0,0 +1,226 @@
|
||||
import datetime, json, requests, time
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from app.db_sql import sql_select, sql_update, sql_insert
|
||||
from app.lib_general import log, logging, logger_reset
|
||||
|
||||
# Zoom API Configuration (Defaults)
|
||||
ZOOM_OAUTH_URL = "https://zoom.us/oauth/token"
|
||||
ZOOM_API_BASE = "https://api.zoom.us/v2"
|
||||
|
||||
# In-memory token cache (Standard Aether pattern)
|
||||
zoom_api_cache = {
|
||||
"access_token": None,
|
||||
"expire_on": None,
|
||||
"headers": {}
|
||||
}
|
||||
|
||||
@logger_reset
|
||||
def get_zoom_access_token(force_refresh: bool = False):
|
||||
"""
|
||||
Retrieves a Zoom Access Token using Server-to-Server OAuth.
|
||||
Credentials should be stored in the 'data_store' table with code 'zoom_api_config'.
|
||||
"""
|
||||
log.setLevel(logging.INFO)
|
||||
|
||||
# 1. Check Cache
|
||||
if not force_refresh and zoom_api_cache["access_token"] and zoom_api_cache["expire_on"]:
|
||||
if datetime.datetime.now() < zoom_api_cache["expire_on"]:
|
||||
log.debug("Using cached Zoom access token.")
|
||||
return zoom_api_cache
|
||||
|
||||
# 2. Load Credentials from Data Store
|
||||
# Logic: Look for 'zoom_api_config' in data_store
|
||||
config_rec = sql_select(
|
||||
table_name='data_store',
|
||||
field_name='code',
|
||||
field_value='zoom_api_config'
|
||||
)
|
||||
|
||||
if not config_rec:
|
||||
log.error("Zoom API credentials not found in data_store (code='zoom_api_config').")
|
||||
return False
|
||||
|
||||
try:
|
||||
config_data = json.loads(config_rec['text'])
|
||||
client_id = config_data['client_id']
|
||||
client_secret = config_data['client_secret']
|
||||
account_id = config_data['account_id']
|
||||
except Exception as e:
|
||||
log.error(f"Failed to parse Zoom credentials from data_store: {e}")
|
||||
return False
|
||||
|
||||
# 3. Request Token
|
||||
log.info("Requesting new Zoom Access Token...")
|
||||
params = {
|
||||
"grant_type": "account_credentials",
|
||||
"account_id": account_id
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
ZOOM_OAUTH_URL,
|
||||
params=params,
|
||||
auth=(client_id, client_secret),
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
log.error(f"Zoom OAuth failure: {resp.status_code} - {resp.text}")
|
||||
return False
|
||||
|
||||
data = resp.json()
|
||||
zoom_api_cache["access_token"] = data["access_token"]
|
||||
# Set expiry with a 60s safety buffer
|
||||
zoom_api_cache["expire_on"] = datetime.datetime.now() + datetime.timedelta(seconds=data["expires_in"] - 60)
|
||||
zoom_api_cache["headers"] = {
|
||||
"Authorization": f"Bearer {data['access_token']}",
|
||||
"Accept": "application/json"
|
||||
}
|
||||
|
||||
log.info("Successfully obtained Zoom access token.")
|
||||
return zoom_api_cache
|
||||
|
||||
except Exception as e:
|
||||
log.exception(f"Unexpected error during Zoom OAuth: {e}")
|
||||
return False
|
||||
|
||||
@logger_reset
|
||||
def get_zoom_tickets(event_id: str, page_size: int = 300, next_page_token: str = None):
|
||||
"""
|
||||
Retrieves 'Tickets' (Attendees) for a specific Zoom Event.
|
||||
Endpoint: GET /zoom_events/events/{eventId}/tickets
|
||||
"""
|
||||
auth = get_zoom_access_token()
|
||||
if not auth: return False
|
||||
|
||||
url = f"{ZOOM_API_BASE}/zoom_events/events/{event_id}/tickets"
|
||||
params = {"page_size": page_size}
|
||||
if next_page_token: params["next_page_token"] = next_page_token
|
||||
|
||||
try:
|
||||
resp = requests.get(url, headers=auth["headers"], params=params, timeout=15)
|
||||
if resp.status_code != 200:
|
||||
log.error(f"Zoom API Error: {resp.status_code} - {resp.text}")
|
||||
return False
|
||||
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to fetch tickets from Zoom: {e}")
|
||||
return False
|
||||
|
||||
@logger_reset
|
||||
def sync_zoom_attendees_to_event(event_id_random: str, zoom_event_id: str):
|
||||
"""
|
||||
Atomic sync action: Pulls Zoom tickets and upserts Aether event_person records.
|
||||
Uses Zoom ticket_id as the primary external identifier.
|
||||
"""
|
||||
from app.methods.event_person_methods import create_update_event_person_obj_v4
|
||||
|
||||
log.info(f"Starting Zoom sync for event {event_id_random} (Zoom ID: {zoom_event_id})")
|
||||
|
||||
# 1. Fetch tickets from Zoom
|
||||
zoom_data = get_zoom_tickets(zoom_event_id)
|
||||
if not zoom_data:
|
||||
log.error("Failed to retrieve tickets from Zoom API.")
|
||||
return False
|
||||
|
||||
tickets = zoom_data.get("tickets", [])
|
||||
log.info(f"Found {len(tickets)} tickets in Zoom.")
|
||||
|
||||
# 2. Resolve Local Context
|
||||
if event_id_int := redis_lookup_id_random(record_id_random=event_id_random, table_name='event'):
|
||||
pass
|
||||
else:
|
||||
log.error(f"Aether Event ID {event_id_random} not found.")
|
||||
return False
|
||||
|
||||
# Get account_id for this event
|
||||
res = sql_select(sql="SELECT account_id FROM event WHERE id = :id", data={'id': event_id_int})
|
||||
account_id_int = res.get('account_id') if res else None
|
||||
if not account_id_int:
|
||||
log.error("Could not resolve account_id for event.")
|
||||
return False
|
||||
|
||||
sync_results = {
|
||||
"total": len(tickets),
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"failed": 0
|
||||
}
|
||||
|
||||
# 3. Iterate and Upsert
|
||||
for ticket in tickets:
|
||||
try:
|
||||
ticket_id = ticket.get('ticket_id')
|
||||
email = ticket.get('email')
|
||||
first_name = ticket.get('first_name', '').strip()
|
||||
last_name = ticket.get('last_name', '').strip()
|
||||
|
||||
# Standard External ID Pattern for Zoom: {zoom_event_id}:{ticket_id}
|
||||
external_id = f"{zoom_event_id}:{ticket_id}"
|
||||
|
||||
# Prepare Aether Data structure (event_person_methods v4 expects this nested shape)
|
||||
event_person_data = {
|
||||
"enable": True,
|
||||
"external_id": external_id,
|
||||
"external_person_id": ticket.get('registrant_id'), # Zoom User ID
|
||||
"external_registration_id": ticket_id,
|
||||
"allow_tracking": True, # Lead retrieval demo default
|
||||
|
||||
# Nested Person Profile (Person Table)
|
||||
"event_person_profile": {
|
||||
"given_name": first_name,
|
||||
"family_name": last_name,
|
||||
"full_name": f"{first_name} {last_name}".strip(),
|
||||
"email": email,
|
||||
"enable": True
|
||||
},
|
||||
|
||||
# Nested Badge Data (Event Badge Table)
|
||||
"event_badge": {
|
||||
"given_name": first_name,
|
||||
"family_name": last_name,
|
||||
"full_name": f"{first_name} {last_name}".strip(),
|
||||
"email": email,
|
||||
"badge_type": ticket.get('ticket_type_name', 'Attendee'),
|
||||
"badge_type_code": ticket.get('ticket_type_id'),
|
||||
"external_id": external_id,
|
||||
"enable": True
|
||||
}
|
||||
}
|
||||
|
||||
# 4. Check for existing Event Person to determine create vs update
|
||||
# (Matches Impexium pattern of looking up by external_id)
|
||||
existing = sql_select(
|
||||
sql="SELECT id, event_badge_id, event_person_profile_id FROM event_person WHERE event_id = :eid AND external_id = :ext",
|
||||
data={'eid': event_id_int, 'ext': external_id}
|
||||
)
|
||||
|
||||
if existing:
|
||||
log.info(f"Updating existing record for {email} ({external_id})")
|
||||
res = create_update_event_person_obj_v4(
|
||||
event_person_dict_obj = event_person_data,
|
||||
event_person_id = existing['id'],
|
||||
account_id = account_id_int,
|
||||
event_id = event_id_int,
|
||||
event_badge_id = existing['event_badge_id'],
|
||||
event_person_profile_id = existing['event_person_profile_id']
|
||||
)
|
||||
if res: sync_results["updated"] += 1
|
||||
else: sync_results["failed"] += 1
|
||||
else:
|
||||
log.info(f"Creating new record for {email} ({external_id})")
|
||||
res = create_update_event_person_obj_v4(
|
||||
event_person_dict_obj = event_person_data,
|
||||
account_id = account_id_int,
|
||||
event_id = event_id_int
|
||||
)
|
||||
if res: sync_results["created"] += 1
|
||||
else: sync_results["failed"] += 1
|
||||
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to process ticket {ticket.get('ticket_id')}: {e}")
|
||||
sync_results["failed"] += 1
|
||||
|
||||
log.info(f"Zoom sync complete: {sync_results}")
|
||||
return sync_results
|
||||
@@ -322,10 +322,9 @@ def create_update_event_badge_obj_v4(
|
||||
elif event_person_id := event_badge_obj.event_person_id: pass
|
||||
|
||||
if event_badge_id:
|
||||
if event_badge_dict_up_result := sql_update(data=event_badge_dict, table_name='event_badge', rm_id_random=True): pass
|
||||
else:
|
||||
log.warning(f'Event Badge not updated. Event Badge ID: {event_badge_id}')
|
||||
log.debug(event_badge_dict_up_result)
|
||||
event_badge_dict_up_result = sql_update(data=event_badge_dict, table_name='event_badge', record_id=event_badge_id, rm_id_random=True)
|
||||
if event_badge_dict_up_result is False:
|
||||
log.warning(f'Event Badge update failed (DB error). Event Badge ID: {event_badge_id}')
|
||||
return False
|
||||
log.debug(event_badge_dict_up_result)
|
||||
else:
|
||||
|
||||
@@ -83,7 +83,15 @@ def load_event_file_obj(
|
||||
enabled = enabled,
|
||||
):
|
||||
event_file_obj.hosted_file = hosted_file_obj
|
||||
# event_file_obj.hosted_file = hosted_file_obj.dict(by_alias=by_alias, exclude_unset=exclude_unset)
|
||||
# Explicitly populate convenience fields from hosted_file_obj
|
||||
if hosted_file_obj.hash_sha256:
|
||||
event_file_obj.hosted_file_hash_sha256 = hosted_file_obj.hash_sha256
|
||||
if hosted_file_obj.subdirectory_path:
|
||||
event_file_obj.hosted_file_subdirectory_path = hosted_file_obj.subdirectory_path
|
||||
if hosted_file_obj.content_type:
|
||||
event_file_obj.hosted_file_content_type = hosted_file_obj.content_type
|
||||
if hosted_file_obj.size:
|
||||
event_file_obj.hosted_file_size = str(hosted_file_obj.size) # Ensure it's a string as per model definition
|
||||
else:
|
||||
event_file_obj.hosted_file = {}
|
||||
else:
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
from __future__ import annotations
|
||||
import datetime
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, PrivateAttr, ValidationError, validator
|
||||
|
||||
from app.db_sql import redis_lookup_id_random, sql_insert, sql_select, sql_update
|
||||
from app.lib_general import log, logging
|
||||
|
||||
from app.methods.event_methods import load_event_obj
|
||||
|
||||
|
||||
# ### BEGIN ### API Event Methods ### load_event_obj_list() ###
|
||||
def load_event_obj_list(
|
||||
account_id: int|str,
|
||||
limit: int = 1000,
|
||||
model_as_dict: bool = False,
|
||||
enabled: str = 'enabled', # enabled, disabled, all
|
||||
inc_contact_1: bool = False,
|
||||
inc_contact_2: bool = False,
|
||||
inc_contact_3: bool = False,
|
||||
inc_event_abstract_list: bool = False,
|
||||
inc_event_badge_list: bool = False,
|
||||
inc_event_cfg: bool = False,
|
||||
inc_event_device_list: bool = False,
|
||||
inc_event_exhibit_list: bool = False,
|
||||
inc_event_file_list: bool = False,
|
||||
inc_event_location: bool = False, # For event_session child object
|
||||
inc_event_location_list: bool = False,
|
||||
inc_event_person_list: bool = False,
|
||||
inc_event_presentation_list: bool = False,
|
||||
inc_event_presenter_cat: bool = False, # For event_session child object
|
||||
inc_event_presenter_list: bool = False,
|
||||
inc_event_registration_cfg: bool = False,
|
||||
inc_event_registration_list: bool = False,
|
||||
inc_event_session_list: bool = False,
|
||||
inc_event_track: bool = False, # For event_session child object
|
||||
inc_event_track_list: bool = False,
|
||||
inc_location_address: bool = False,
|
||||
inc_poc_event_person: bool = False,
|
||||
inc_person: bool = False,
|
||||
inc_user: bool = False,
|
||||
) -> list|bool:
|
||||
log.setLevel(logging.WARNING) # 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 False
|
||||
|
||||
data = {}
|
||||
data['account_id'] = account_id
|
||||
|
||||
if enabled in ['enabled', 'disabled', 'all']:
|
||||
if enabled == 'enabled':
|
||||
data['enable'] = True
|
||||
sql_enabled = f'AND `tbl`.enable = :enable'
|
||||
elif enabled == 'disabled':
|
||||
data['enable'] = False
|
||||
sql_enabled = f'AND `tbl`.enable = :enable'
|
||||
elif enabled == 'all':
|
||||
sql_enabled = ''
|
||||
# else: tbl_obj['account'] = None
|
||||
|
||||
if limit:
|
||||
data['limit'] = limit
|
||||
sql_limit = f'LIMIT :limit'
|
||||
else:
|
||||
sql_limit = ''
|
||||
|
||||
sql = f"""
|
||||
SELECT `tbl`.id AS 'event_id', `tbl`.id_random AS 'event_id_random'
|
||||
FROM `event` AS `tbl`
|
||||
WHERE `tbl`.account_id = :account_id
|
||||
{sql_enabled}
|
||||
ORDER BY `tbl`.created_on DESC, `tbl`.updated_on DESC
|
||||
{sql_limit};
|
||||
"""
|
||||
|
||||
if event_rec_li_result := sql_select(data=data, sql=sql, as_list=True):
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(event_rec_li_result)
|
||||
event_result_li = []
|
||||
for event_rec in event_rec_li_result:
|
||||
event_id = event_rec.get('event_id', None)
|
||||
if event_result := load_event_obj(
|
||||
event_id = event_id,
|
||||
limit = limit,
|
||||
model_as_dict = model_as_dict,
|
||||
enabled = enabled,
|
||||
# inc_location_address = inc_address,
|
||||
# inc_contact_1 = inc_contact,
|
||||
# inc_contact_2 = inc_contact,
|
||||
# inc_contact_3 = inc_contact,
|
||||
# inc_event_abstract_list = inc_event_abstract_list,
|
||||
# inc_event_badge_list = inc_event_badge_list,
|
||||
# inc_event_device_list = inc_event_device_list,
|
||||
inc_event_exhibit_list = inc_event_exhibit_list,
|
||||
inc_event_file_list = inc_event_file_list,
|
||||
inc_event_location_list = inc_event_location_list,
|
||||
inc_event_person_list = inc_event_person_list,
|
||||
inc_event_presentation_list = inc_event_presentation_list,
|
||||
inc_event_presenter_list = inc_event_presenter_list,
|
||||
inc_event_registration_list = inc_event_registration_list,
|
||||
inc_event_session_list = inc_event_session_list,
|
||||
inc_event_track_list = inc_event_track_list,
|
||||
# inc_person = inc_person,
|
||||
# inc_user = inc_user,
|
||||
):
|
||||
log.debug(event_result)
|
||||
event_result_li.append(event_result)
|
||||
else:
|
||||
log.debug(event_result)
|
||||
event_result_li.append(None)
|
||||
log.debug(event_result_li)
|
||||
else:
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(event_rec_li_result)
|
||||
event_result_li = []
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
|
||||
return event_result_li
|
||||
# ### END ### API Event Methods ### load_event_obj_list() ###
|
||||
@@ -3,7 +3,7 @@ import datetime
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, PrivateAttr, ValidationError, validator
|
||||
|
||||
from app.db_sql import get_account_id_w_for_type_id, redis_lookup_id_random, sql_insert, sql_select, sql_update
|
||||
from app.db_sql import get_account_id_w_for_type_id, redis_lookup_id_random, sql_insert, sql_select, sql_update, get_id_random
|
||||
from app.lib_general import log, logging, logger_reset
|
||||
|
||||
# from app.methods.event_abstract_methods import load_event_abstract_obj
|
||||
@@ -355,7 +355,7 @@ def create_update_event_person_obj_v4(
|
||||
fail_any: bool = False, # Fail if any thing goes wrong for sub objects
|
||||
return_outline: bool = False,
|
||||
) -> int|bool:
|
||||
log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
# ### SECTION ### Secondary data validation
|
||||
@@ -420,7 +420,19 @@ def create_update_event_person_obj_v4(
|
||||
if account_id:
|
||||
event_person_dict['account_id'] = account_id
|
||||
if event_id:
|
||||
event_person_dict['event_id'] = event_id
|
||||
# The model expects random-string IDs (eg. id_random). If we have an
|
||||
# integer internal ID, convert it to the random string form so the
|
||||
# Pydantic root_validator preserves it. This ensures `event_id` is
|
||||
# present when inserting a new `event_person` record.
|
||||
if isinstance(event_id, int):
|
||||
if idr := get_id_random(record_id=event_id, table_name='event'):
|
||||
event_person_dict['event_id_random'] = idr
|
||||
else:
|
||||
# Fallback: set the integer (will likely be removed by the model),
|
||||
# but allow downstream logic to attempt insertion.
|
||||
event_person_dict['event_id'] = event_id
|
||||
else:
|
||||
event_person_dict['event_id'] = event_id
|
||||
try:
|
||||
event_person_obj = Event_Person_Base(**event_person_dict)
|
||||
except ValidationError as e:
|
||||
@@ -434,7 +446,16 @@ def create_update_event_person_obj_v4(
|
||||
if account_id:
|
||||
event_person_obj.account_id = account_id
|
||||
if event_id:
|
||||
event_person_obj.event_id = event_id
|
||||
# If an integer internal ID was provided, convert to the random ID
|
||||
# string form for the Pydantic object so it is preserved when
|
||||
# serializing to the DB insert/update payload.
|
||||
if isinstance(event_id, int):
|
||||
if idr := get_id_random(record_id=event_id, table_name='event'):
|
||||
event_person_obj.event_id = idr
|
||||
else:
|
||||
event_person_obj.event_id = event_id
|
||||
else:
|
||||
event_person_obj.event_id = event_id
|
||||
log.debug(event_person_obj)
|
||||
|
||||
event_person_dict = event_person_obj.dict(by_alias=False, exclude_defaults=False, exclude_unset=True, exclude={'event_badge', 'event_person_profile', 'event_registration', 'created_on', 'updated_on', 'external_id_old'})
|
||||
@@ -453,11 +474,11 @@ def create_update_event_person_obj_v4(
|
||||
event_person_profile_id = event_person_obj.event_person_profile_id
|
||||
|
||||
if event_person_id:
|
||||
if event_person_dict_up_result := sql_update(data=event_person_dict, table_name='event_person', rm_id_random=True): pass
|
||||
else:
|
||||
log.warning(f'Event Person not updated. Event Person ID: {event_person_id}')
|
||||
log.debug(event_person_dict_up_result)
|
||||
event_person_dict_up_result = sql_update(data=event_person_dict, table_name='event_person', record_id=event_person_id, rm_id_random=True)
|
||||
if event_person_dict_up_result is False:
|
||||
log.warning(f'Event Person update failed (DB error). Event Person ID: {event_person_id}')
|
||||
return False
|
||||
# None means 0 rows affected (record unchanged) — not an error, continue to sub-objects
|
||||
log.debug(event_person_dict_up_result)
|
||||
else:
|
||||
if event_person_dict_in_result := sql_insert(data=event_person_dict, table_name='event_person', rm_id_random=True, id_random_length=None): pass
|
||||
|
||||
@@ -154,11 +154,12 @@ def create_update_event_person_profile_obj_v4(
|
||||
contact_id = event_person_profile_obj.contact_id
|
||||
|
||||
if event_person_profile_id:
|
||||
if event_person_profile_dict_up_result := sql_update(data=event_person_profile_dict, table_name='event_person_profile', rm_id_random=True): pass
|
||||
else:
|
||||
log.warning(f'Event Person Profile not updated. Event Person Profile ID: {event_person_profile_id}')
|
||||
event_person_profile_dict_up_result = sql_update(data=event_person_profile_dict, table_name='event_person_profile', record_id=event_person_profile_id, rm_id_random=True)
|
||||
if event_person_profile_dict_up_result is False:
|
||||
log.warning(f'Event Person Profile update failed (DB error). Event Person Profile ID: {event_person_profile_id}')
|
||||
log.debug(event_person_profile_dict_up_result)
|
||||
return False
|
||||
# None means 0 rows affected (record unchanged) — not an error
|
||||
log.debug(event_person_profile_dict_up_result)
|
||||
else:
|
||||
if event_person_profile_dict_in_result := sql_insert(data=event_person_profile_dict, table_name='event_person_profile', rm_id_random=True, id_random_length=8): pass
|
||||
|
||||
@@ -429,9 +429,9 @@ def create_update_event_presentation_obj_v4(
|
||||
event_presentation_dict = event_presentation_obj.dict(by_alias=False, exclude_defaults=False, exclude_unset=True, exclude={'event_presenter', 'event_presenter_list', 'created_on', 'updated_on'})
|
||||
|
||||
if event_presentation_id:
|
||||
if event_presentation_dict_up_result := sql_update(data=event_presentation_dict, table_name='event_presentation', rm_id_random=True): pass
|
||||
else:
|
||||
log.warning(f'Event Presentation not updated. Event Presentation ID: {event_presentation_id}')
|
||||
event_presentation_dict_up_result = sql_update(data=event_presentation_dict, table_name='event_presentation', record_id=event_presentation_id, rm_id_random=True)
|
||||
if event_presentation_dict_up_result is False:
|
||||
log.warning(f'Event Presentation update failed (DB error). Event Presentation ID: {event_presentation_id}')
|
||||
log.debug(event_presentation_dict_up_result)
|
||||
return False
|
||||
log.debug(event_presentation_dict_up_result)
|
||||
|
||||
@@ -404,9 +404,9 @@ def create_update_event_presenter_obj_v4(
|
||||
event_presenter_dict = event_presenter_obj.dict(by_alias=False, exclude_defaults=False, exclude_unset=True, exclude={'created_on', 'updated_on'})
|
||||
|
||||
if event_presenter_id:
|
||||
if event_presenter_dict_up_result := sql_update(data=event_presenter_dict, table_name='event_presenter', rm_id_random=True): pass
|
||||
else:
|
||||
log.warning(f'Event Presenter not updated. Event Presenter ID: {event_presenter_id}')
|
||||
event_presenter_dict_up_result = sql_update(data=event_presenter_dict, table_name='event_presenter', record_id=event_presenter_id, rm_id_random=True)
|
||||
if event_presenter_dict_up_result is False:
|
||||
log.warning(f'Event Presenter update failed (DB error). Event Presenter ID: {event_presenter_id}')
|
||||
log.debug(event_presenter_dict_up_result)
|
||||
return False
|
||||
log.debug(event_presenter_dict_up_result)
|
||||
|
||||
@@ -5,20 +5,67 @@ from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, PrivateAttr, ValidationError, validator
|
||||
|
||||
from app.config import settings
|
||||
from app.db_sql import redis_lookup_id_random, sql_delete, sql_enable_part, sql_insert, sql_limit_offset_part, sql_select, sql_update
|
||||
from app.db_sql import redis_lookup_id_random, sql_delete, sql_enable_part, sql_insert, sql_limit_offset_part, sql_select, sql_update, get_id_random
|
||||
from app.lib_general import log, logging, logger_reset
|
||||
|
||||
from app.models.hosted_file_models import Hosted_File_Base
|
||||
|
||||
|
||||
# ### BEGIN ### API Hosted File Methods ### directory_check_method() ###
|
||||
# Extracted 2026-02-03
|
||||
def directory_check_method(rm_orphan: bool = False):
|
||||
"""
|
||||
Logic for scanning the hosted_files root and migrating legacy files to 2-char subdirectories.
|
||||
Returns a list of processed files.
|
||||
"""
|
||||
hosted_files_path = settings.FILES_PATH['hosted_files_root']
|
||||
if not os.path.isdir(hosted_files_path):
|
||||
return False
|
||||
|
||||
directory_list = os.listdir(hosted_files_path)
|
||||
result_list = []
|
||||
count = 0
|
||||
|
||||
for item in directory_list:
|
||||
if count >= 100: break # Rate limited per call
|
||||
|
||||
file_path = os.path.join(hosted_files_path, item)
|
||||
if os.path.isfile(file_path):
|
||||
if '.file' not in item: continue
|
||||
|
||||
log.info(f'Migrating legacy file to subdirectory: {item}')
|
||||
result_list.append(file_path)
|
||||
|
||||
# Create a subdirectory with the first 2 characters of the hash
|
||||
full_subdirectory_path = os.path.join(hosted_files_path, item[:2])
|
||||
os.makedirs(full_subdirectory_path, exist_ok=True)
|
||||
|
||||
# Move the file
|
||||
shutil.move(file_path, os.path.join(full_subdirectory_path, item))
|
||||
count += 1
|
||||
|
||||
return result_list
|
||||
# ### END ### API Hosted File Methods ### directory_check_method() ###
|
||||
|
||||
|
||||
# ### BEGIN ### API Hosted File Methods ### create_hosted_file_obj() ###
|
||||
@logger_reset
|
||||
def create_hosted_file_obj(hosted_file_obj_new:Hosted_File_Base):
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
# hosted_file_obj_data = hosted_file_obj_new.dict(by_alias=False, exclude_defaults=False, exclude_unset=True, exclude={'created_on', 'updated_on'})
|
||||
hosted_file_obj_data = hosted_file_obj_new.dict(by_alias=False, exclude_defaults=False, exclude_unset=True, exclude={'saved', 'already_exists', 'copy_timer', 'created_on', 'updated_on'})
|
||||
# We need to explicitly include subdirectory_path because it has Field(exclude=True) in the model
|
||||
# which prevents it from showing in the public API, but also strips it from .dict() by default.
|
||||
hosted_file_obj_data = hosted_file_obj_new.dict(
|
||||
by_alias=False,
|
||||
exclude_defaults=False,
|
||||
exclude_unset=True,
|
||||
exclude={'saved', 'already_exists', 'copy_timer', 'created_on', 'updated_on'}
|
||||
)
|
||||
|
||||
# Force inclusion of subdirectory_path if present in the object
|
||||
if hasattr(hosted_file_obj_new, 'subdirectory_path') and hosted_file_obj_new.subdirectory_path:
|
||||
hosted_file_obj_data['subdirectory_path'] = hosted_file_obj_new.subdirectory_path
|
||||
|
||||
if hosted_file_obj_in_result := sql_insert(data=hosted_file_obj_data, table_name='hosted_file', rm_id_random=True, id_random_length=8): pass
|
||||
else:
|
||||
@@ -195,59 +242,38 @@ async def save_file(
|
||||
log.debug(locals())
|
||||
|
||||
hosted_files_path = settings.FILES_PATH['hosted_files_root']
|
||||
# hosted_files_path = '/home/scott/tmp/hosted_files_dev/'
|
||||
log.info(f'Hosted Files Path: {hosted_files_path}')
|
||||
log.debug(shutil.disk_usage(hosted_files_path))
|
||||
|
||||
log.debug(dir(file))
|
||||
log.debug(f'{file.filename}')
|
||||
|
||||
if file.filename.endswith('.docwin'):
|
||||
log.warning('Fixing win extension')
|
||||
file.filename = file.filename.replace('.docwin', '.doc')
|
||||
if file.filename.endswith('.docxwin'):
|
||||
log.warning('Fixing win extension')
|
||||
file.filename = file.filename.replace('.docxwin', '.docx')
|
||||
|
||||
if file.filename.endswith('.odpmac'):
|
||||
log.warning('Fixing mac extension')
|
||||
file.filename = file.filename.replace('.odpmac', '.odp')
|
||||
|
||||
if file.filename.endswith('.odpwin'):
|
||||
log.warning('Fixing win extension')
|
||||
file.filename = file.filename.replace('.odpwin', '.odp')
|
||||
|
||||
if file.filename.endswith('.pdfmac'):
|
||||
log.warning('Fixing mac extension')
|
||||
file.filename = file.filename.replace('.pdfmac', '.pdf')
|
||||
|
||||
if file.filename.endswith('.pdfwin'):
|
||||
log.warning('Fixing win extension')
|
||||
file.filename = file.filename.replace('.pdfwin', '.pdf')
|
||||
|
||||
if file.filename.endswith('.pptmac'):
|
||||
log.warning('Fixing mac extension')
|
||||
file.filename = file.filename.replace('.pptmac', '.ppt')
|
||||
if file.filename.endswith('.pptxmac'):
|
||||
log.warning('Fixing mac extension')
|
||||
file.filename = file.filename.replace('.pptxmac', '.pptx')
|
||||
|
||||
if file.filename.endswith('.pptwin'):
|
||||
log.warning('Fixing win extension')
|
||||
file.filename = file.filename.replace('.pptwin', '.ppt')
|
||||
if file.filename.endswith('.pptxwin'):
|
||||
log.warning('Fixing win extension')
|
||||
file.filename = file.filename.replace('.pptxwin', '.pptx')
|
||||
|
||||
if file.filename.endswith('.xlswin'):
|
||||
log.warning('Fixing win extension')
|
||||
file.filename = file.filename.replace('.xlswin', '.xls')
|
||||
if file.filename.endswith('.xlsxwin'):
|
||||
log.warning('Fixing win extension')
|
||||
file.filename = file.filename.replace('.xlsxwin', '.xlsx')
|
||||
|
||||
file_info: dict = {}
|
||||
file_info['saved'] = None
|
||||
file_info['account_id'] = account_id
|
||||
file_info['account_id_random'] = account_id_random
|
||||
file_info['link_to_type'] = link_to_type
|
||||
file_info['link_to_id'] = link_to_id
|
||||
file_info['link_to_id_random'] = link_to_id_random
|
||||
@@ -264,136 +290,48 @@ async def save_file(
|
||||
else:
|
||||
file_info['extension_allowed'] = None
|
||||
|
||||
# There is a difference between Content-Type and MIME type.
|
||||
# https://stackoverflow.com/questions/3452381/whats-the-difference-of-contenttype-and-mimetype
|
||||
file_info['content_type'] = file.content_type # might also include charset or other parameters
|
||||
# file_info['mimetype'] = file.mimetype # This may need to be filled in a different way?
|
||||
file_info['content_type'] = file.content_type
|
||||
|
||||
file.file.seek(0, os.SEEK_END)
|
||||
file_size = file.file.tell()
|
||||
file.file.seek(0) # The file will not properly save if seek is not reset to 0.
|
||||
log.debug(file_size)
|
||||
file.file.seek(0)
|
||||
file_info['size'] = file_size
|
||||
|
||||
file_hash = await get_file_object_hash(file.file)
|
||||
log.debug(file_hash)
|
||||
file_info['hash_sha256'] = file_hash
|
||||
|
||||
# 16384 bytes is the default
|
||||
# 4096 8192 16384 32768 65536 131072 262144 524288 1048576 bytes
|
||||
buffer_size = 524288
|
||||
|
||||
#f_src = open(file_src, 'rb')
|
||||
f_src = file.file # Don't need to do open(file_src, 'rb') since it is already "open"
|
||||
f_src = file.file
|
||||
|
||||
file_hash_subdirectory = file_hash[0:2]
|
||||
subdirectory_dest = os.path.join(hosted_files_path, file_hash_subdirectory)
|
||||
log.debug(subdirectory_dest)
|
||||
log.info(f"Subdirectory Dest: {subdirectory_dest}")
|
||||
pathlib.Path(subdirectory_dest).mkdir(parents=True, exist_ok=True)
|
||||
file_info['subdirectory_path'] = file_hash_subdirectory
|
||||
|
||||
#file_dest = f'{hosted_files_path}{file.filename}'
|
||||
# file_dest = f'{hosted_files_path}{file_hash}.file'
|
||||
|
||||
file_dest = os.path.join(hosted_files_path, f'{file_hash}.file')
|
||||
file_dest_w_subdir = os.path.join(subdirectory_dest, f'{file_hash}.file')
|
||||
|
||||
existing_file_check = pathlib.Path(file_dest)
|
||||
existing_file_check_subdir = pathlib.Path(file_dest_w_subdir)
|
||||
|
||||
|
||||
if existing_file_check.exists():
|
||||
log.warning('This file already exists at the destination without the subdirectory. Not re-saving. Going to move the current file and update the database later.')
|
||||
file_info['already_exists'] = True
|
||||
file_info['already_exists_subdir'] = False
|
||||
try:
|
||||
log.info('Moving file to sub directory destination...')
|
||||
timer_start = time.process_time()
|
||||
shutil.move(existing_file_check, existing_file_check_subdir)
|
||||
timer_end = time.process_time()
|
||||
elapsed_time = timer_end - timer_start
|
||||
log.debug(f'Elapsed time: {elapsed_time}')
|
||||
file_info['copy_timer'] = elapsed_time
|
||||
file_info['saved'] = True
|
||||
|
||||
log.info(f'File moved to: {hosted_files_path}')
|
||||
except Exception as e:
|
||||
log.exception('*** An exception happened. ***')
|
||||
log.exception(repr(e))
|
||||
log.exception('***')
|
||||
log.exception(str(e))
|
||||
log.exception('^^^ exception ^^^')
|
||||
|
||||
file_info['copy_timer'] = 0
|
||||
file_info['saved'] = False
|
||||
elif existing_file_check_subdir.exists():
|
||||
log.warning('This file already exists at the destination with the subdirectory. Not re-saving.')
|
||||
if existing_file_check_subdir.exists():
|
||||
file_info['already_exists'] = True
|
||||
file_info['already_exists_subdir'] = True
|
||||
file_info['copy_timer'] = 0
|
||||
file_info['saved'] = True
|
||||
else:
|
||||
# log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.warning('This file does not already exist at the destination with or without the subdirectory.')
|
||||
file_info['already_exists'] = False
|
||||
file_info['already_exists_subdir'] = False
|
||||
try:
|
||||
log.info('Saving file to destination...')
|
||||
f_dest = open(file_dest_w_subdir, 'wb')
|
||||
timer_start = time.process_time()
|
||||
shutil.copyfileobj(f_src, f_dest, buffer_size)
|
||||
timer_end = time.process_time()
|
||||
elapsed_time = timer_end - timer_start
|
||||
log.debug(f'Elapsed time: {elapsed_time}')
|
||||
file_info['copy_timer'] = elapsed_time
|
||||
file_info['copy_timer'] = timer_end - timer_start
|
||||
file_info['saved'] = True
|
||||
|
||||
log.info(f'File saved to: {hosted_files_path}')
|
||||
except Exception as e:
|
||||
log.exception('*** An exception happened. ***')
|
||||
log.exception(repr(e))
|
||||
log.exception('***')
|
||||
log.exception(str(e))
|
||||
log.exception('^^^ exception ^^^')
|
||||
|
||||
log.exception(f'Error saving file: {e}')
|
||||
file_info['copy_timer'] = 0
|
||||
file_info['saved'] = False
|
||||
return False
|
||||
log.info(f'Disk usage: {shutil.disk_usage(hosted_files_path)}')
|
||||
log.info(f"Filename: {file_info['filename']}")
|
||||
log.info(f"Subdirectory Path: {file_info['subdirectory_path']}")
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(file_info)
|
||||
|
||||
|
||||
# if existing_file_check.exists():
|
||||
# file_info['already_exists'] = True
|
||||
# file_info['copy_timer'] = 0
|
||||
# file_info['saved'] = True
|
||||
# else:
|
||||
# file_info['already_exists'] = False
|
||||
# try:
|
||||
# f_dest = open(file_dest, 'wb')
|
||||
# timer_start = time.process_time()
|
||||
# shutil.copyfileobj(f_src, f_dest, buffer_size)
|
||||
# timer_end = time.process_time()
|
||||
# elapsed_time = timer_end - timer_start
|
||||
# log.debug(f'Elapsed time: {elapsed_time}')
|
||||
# file_info['copy_timer'] = elapsed_time
|
||||
# file_info['saved'] = True
|
||||
# except Exception as e:
|
||||
# log.exception('*** An exception happened. ***')
|
||||
# log.exception(repr(e))
|
||||
# log.exception('***')
|
||||
# log.exception(str(e))
|
||||
# log.exception('^^^ exception ^^^')
|
||||
|
||||
# file_info['copy_timer'] = 0
|
||||
# file_info['saved'] = False
|
||||
|
||||
|
||||
log.debug(shutil.disk_usage(hosted_files_path))
|
||||
|
||||
return file_info
|
||||
# ### END ### API Hosted File Methods ### save_file() ###
|
||||
|
||||
@@ -408,118 +346,65 @@ async def save_file_to_hosted_file(
|
||||
account_id: int,
|
||||
link_to_type: str,
|
||||
link_to_id: int,
|
||||
account_id_random: str = None,
|
||||
link_to_id_random: str = None,
|
||||
):
|
||||
log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
hosted_files_path = settings.FILES_PATH['hosted_files_root']
|
||||
log.info(f'Hosted Files Path: {hosted_files_path}')
|
||||
log.debug(shutil.disk_usage(hosted_files_path))
|
||||
|
||||
log.debug(file_path)
|
||||
log.debug(f'Filename: {filename} Extension: {extension}')
|
||||
|
||||
file_obj = open(file_path, 'rb')
|
||||
|
||||
|
||||
file_info: dict = {}
|
||||
file_info['saved'] = None
|
||||
file_info['account_id'] = account_id
|
||||
file_info['account_id_random'] = account_id_random
|
||||
file_info['link_to_type'] = link_to_type
|
||||
file_info['link_to_id'] = link_to_id
|
||||
file_info['link_to_id_random'] = link_to_id_random
|
||||
file_info['filename'] = filename
|
||||
file_info['extension'] = extension # guess_file_extension(filename=filename)
|
||||
|
||||
# if check_allowed_extension:
|
||||
# if allowed_file_extension(extension=file_info['extension'], extension_list=['jpg','png','webp']):
|
||||
# file_info['extension_allowed'] = True
|
||||
# else:
|
||||
# file_info['extension_allowed'] = False
|
||||
# file_info['saved'] = False
|
||||
# return file_info
|
||||
# else:
|
||||
# file_info['extension_allowed'] = None
|
||||
|
||||
# There is a difference between Content-Type and MIME type.
|
||||
# https://stackoverflow.com/questions/3452381/whats-the-difference-of-contenttype-and-mimetype
|
||||
file_info['extension'] = extension
|
||||
file_info['content_type'] = mimetypes.guess_type(filename)[0]
|
||||
|
||||
file_obj.seek(0, os.SEEK_END)
|
||||
file_size = file_obj.tell()
|
||||
file_obj.seek(0) # The file will not properly save if seek is not reset to 0.
|
||||
log.debug(file_size)
|
||||
file_obj.seek(0)
|
||||
file_info['size'] = file_size
|
||||
|
||||
file_hash = await get_file_object_hash(file_obj)
|
||||
log.debug(file_hash)
|
||||
file_info['hash_sha256'] = file_hash
|
||||
|
||||
# 16384 bytes is the default
|
||||
# 4096 8192 16384 32768 65536 131072 262144 524288 1048576 bytes
|
||||
buffer_size = 524288
|
||||
|
||||
#f_src = open(file_src, 'rb')
|
||||
f_src = file_obj # Don't need to do open(file_src, 'rb') since it is already "open"
|
||||
f_src = file_obj
|
||||
|
||||
file_hash_subdirectory = file_hash[0:2]
|
||||
subdirectory_dest = os.path.join(hosted_files_path, file_hash_subdirectory)
|
||||
log.debug(subdirectory_dest)
|
||||
pathlib.Path(subdirectory_dest).mkdir(parents=True, exist_ok=True)
|
||||
file_info['subdirectory_path'] = file_hash_subdirectory
|
||||
|
||||
#file_dest = f'{hosted_files_path}{file.filename}'
|
||||
# file_dest = f'{hosted_files_path}{file_hash}.file'
|
||||
|
||||
file_dest = os.path.join(hosted_files_path, f'{file_hash}.file')
|
||||
file_dest_w_subdir = os.path.join(subdirectory_dest, f'{file_hash}.file')
|
||||
|
||||
existing_file_check = pathlib.Path(file_dest)
|
||||
existing_file_check_subdir = pathlib.Path(file_dest_w_subdir)
|
||||
|
||||
log.debug(existing_file_check_subdir)
|
||||
# return file_info
|
||||
|
||||
|
||||
if existing_file_check_subdir.exists():
|
||||
log.warning('This file already exists at the destination with the subdirectory. Not re-saving.')
|
||||
file_info['already_exists'] = True
|
||||
file_info['already_exists_subdir'] = True
|
||||
file_info['copy_timer'] = 0
|
||||
file_info['saved'] = True
|
||||
else:
|
||||
# log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.warning('This file does not already exist at the destination subdirectory.')
|
||||
file_info['already_exists'] = False
|
||||
file_info['already_exists_subdir'] = False
|
||||
try:
|
||||
log.info('Saving file to destination...')
|
||||
f_dest = open(file_dest_w_subdir, 'wb')
|
||||
timer_start = time.process_time()
|
||||
shutil.copyfileobj(f_src, f_dest, buffer_size)
|
||||
timer_end = time.process_time()
|
||||
elapsed_time = timer_end - timer_start
|
||||
log.debug(f'Elapsed time: {elapsed_time}')
|
||||
file_info['copy_timer'] = elapsed_time
|
||||
file_info['copy_timer'] = timer_end - timer_start
|
||||
file_info['saved'] = True
|
||||
|
||||
log.info(f'File saved to: {hosted_files_path}')
|
||||
except Exception as e:
|
||||
log.exception('*** An exception happened. ***')
|
||||
log.exception(repr(e))
|
||||
log.exception('***')
|
||||
log.exception(str(e))
|
||||
log.exception('^^^ exception ^^^')
|
||||
|
||||
log.exception(f'Error saving to hosted storage: {e}')
|
||||
file_info['copy_timer'] = 0
|
||||
file_info['saved'] = False
|
||||
return False
|
||||
log.info(f'Disk usage: {shutil.disk_usage(hosted_files_path)}')
|
||||
log.info(f"Filename: {file_info['filename']}")
|
||||
log.info(f"Subdirectory Path: {file_info['subdirectory_path']}")
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(file_info)
|
||||
|
||||
log.debug(shutil.disk_usage(hosted_files_path))
|
||||
|
||||
return file_info
|
||||
# ### END ### API Hosted File Methods ### save_file_to_hosted_file() ###
|
||||
|
||||
@@ -546,235 +431,116 @@ def create_hosted_file_link(
|
||||
hosted_file_link_data: dict = {}
|
||||
hosted_file_link_data['account_id'] = account_id
|
||||
hosted_file_link_data['hosted_file_id'] = hosted_file_id
|
||||
hosted_file_link_data['link_to_type'] = link_to_type # Should this be renamed to "link_to_type" for clarity?
|
||||
hosted_file_link_data['link_to_id'] = link_to_id # Should this be renamed to "link_to_id" for clarity?
|
||||
hosted_file_link_data['link_to_type'] = link_to_type
|
||||
hosted_file_link_data['link_to_id'] = link_to_id
|
||||
|
||||
# hosted_file_link_data['test'] = 'test'
|
||||
|
||||
# NOTE: Currently sql_insert does not handle all successful inserts correctly. If there is not an autonum ID then it will return 0 as the ID.
|
||||
if hosted_file_link_data_in_result := sql_insert(data=hosted_file_link_data, table_name='hosted_file_link', id_random_length=0):
|
||||
log.info('The hosted_file_link was created.')
|
||||
pass # This should be improved
|
||||
elif hosted_file_link_data_in_result is None:
|
||||
log.info('The hosted_file_link probably already exists.')
|
||||
return None
|
||||
else:
|
||||
# This should be improved
|
||||
log.warning('Because the hosted_file_link table does not have a primary autonum this check is incorrect even when successful.')
|
||||
log.warning('Something may have gone wrong while trying to create the hosted_file_link record.')
|
||||
log.warning('The hosted_file_link was probably created fine though.')
|
||||
return False
|
||||
|
||||
log.debug(hosted_file_link_data_in_result)
|
||||
return True
|
||||
# ### END ### API Hosted File Methods ### create_hosted_file_link() ###
|
||||
|
||||
|
||||
# ### BEGIN ### API Hosted File Methods ### handle_delete_hosted_file() ###
|
||||
# Updated 2022-08-09
|
||||
# Updated 2026-02-03
|
||||
@logger_reset
|
||||
def handle_delete_hosted_file(
|
||||
account_id: int|str,
|
||||
hosted_file_id: int|str,
|
||||
|
||||
link_to_type: str = None,
|
||||
link_to_id: int|str = None,
|
||||
|
||||
rm_all_links: bool = False,
|
||||
rm_orphan: bool = False,
|
||||
):
|
||||
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 False
|
||||
# Resolve account_id if it's a string (Vision ID or 'bypass')
|
||||
if isinstance(account_id, str):
|
||||
if res_acc := redis_lookup_id_random(record_id_random=account_id, table_name='account'):
|
||||
account_id_int = res_acc
|
||||
else:
|
||||
# If bypass or not found, we still proceed but log it.
|
||||
# In many maintenance cases, we don't want to block the deletion.
|
||||
log.warning(f"Could not resolve account_id '{account_id}'. Proceeding without account restriction.")
|
||||
account_id_int = None
|
||||
else:
|
||||
account_id_int = account_id
|
||||
|
||||
if hosted_file_id := redis_lookup_id_random(record_id_random=hosted_file_id, table_name='hosted_file'): pass
|
||||
if hosted_file_id_int := redis_lookup_id_random(record_id_random=hosted_file_id, table_name='hosted_file'): pass
|
||||
else: return False
|
||||
|
||||
# ### SECTION ### Handle links NOTE NOTE NOTE NOTE NOTE NOTE
|
||||
# NOTE: If link_to_type and link_to_id passed then try and remove that link record first.
|
||||
|
||||
if link_to_type and link_to_id:
|
||||
if hosted_file_link_result := delete_hosted_file_link(
|
||||
account_id = account_id,
|
||||
hosted_file_id = hosted_file_id,
|
||||
|
||||
account_id = account_id_int,
|
||||
hosted_file_id = hosted_file_id_int,
|
||||
link_to_type = link_to_type,
|
||||
link_to_id = link_to_id,
|
||||
|
||||
# rm_orphan = rm_orphan,
|
||||
):
|
||||
log.info('The hosted file link record was deleted.')
|
||||
elif hosted_file_link_result is None:
|
||||
log.warning('The hosted file link record was not found and may have already been deleted. Odd, but this can happen. event_file has a trigger to delete hosted_file_link when being deleted.')
|
||||
# return None
|
||||
log.warning('The hosted file link record was not found.')
|
||||
else:
|
||||
log.error('Something went wrong while trying to delete the hosted file link record.')
|
||||
return False
|
||||
|
||||
# ### SECTION ### Handle orphan check and deletion of hosted_file record and file on server NOTE NOTE NOTE NOTE NOTE NOTE
|
||||
# NOTE: If not rm_orphan then do nothing else.
|
||||
# NOTE: If rm_orphan then get list of links for file.
|
||||
# NOTE: If 0 links result then delete the hosted_file record and file on the server.
|
||||
# NOTE: If >0 links result then do nothing else.
|
||||
if not rm_orphan: return True
|
||||
|
||||
# NOTE: Don't check or remove orphan
|
||||
if not rm_orphan:
|
||||
log.info('Removed hosted file link. No orphan check.')
|
||||
if hosted_file_obj := load_hosted_file_obj(hosted_file_id = hosted_file_id_int, inc_hosted_file_link_list = True): pass
|
||||
else: return False
|
||||
|
||||
if hosted_file_link_rec_list_result := get_hosted_file_link_rec_list(hosted_file_id=hosted_file_id_int):
|
||||
log.info('Still not an orphan file.')
|
||||
return True
|
||||
|
||||
if hosted_file_obj := load_hosted_file_obj(
|
||||
hosted_file_id = hosted_file_id,
|
||||
# inc_hosted_file = True,
|
||||
inc_hosted_file_link_list = True, # if rm_orphan (True) then need to include hosted_file_link_list (True)
|
||||
):
|
||||
log.info('Hosted File object loaded.')
|
||||
pass
|
||||
elif hosted_file_obj is None:
|
||||
log.warning('Hosted File object not found. Can not attempt to delete file from the server if there is one.')
|
||||
# pass
|
||||
return None
|
||||
else:
|
||||
log.error('Something went wrong while trying to load the Hosted File object.')
|
||||
return False
|
||||
log.debug(hosted_file_obj)
|
||||
|
||||
# NOTE: Check and remove orphan
|
||||
if hosted_file_link_rec_list_result := get_hosted_file_link_rec_list(hosted_file_id=hosted_file_id):
|
||||
log.info('This hosted file has linked records to it.')
|
||||
hosted_file_link_result_list = []
|
||||
for hosted_file_link_rec in hosted_file_link_rec_list_result:
|
||||
hosted_file_link_result_list.append(hosted_file_link_rec)
|
||||
# log.debug( )
|
||||
hosted_file_list = hosted_file_link_result_list
|
||||
# NOT safe to delete the hosted_file record and file from server!!!
|
||||
# STOP!
|
||||
log.info('Removed hosted file link (above). Still not an orphan file.')
|
||||
return True
|
||||
elif isinstance(hosted_file_link_rec_list_result, list) or hosted_file_link_rec_list_result is None:
|
||||
log.info('This hosted file has no link records to it.')
|
||||
hosted_file_list = []
|
||||
# Safe to delete the hosted_file record and file from server???
|
||||
# CONTINUE
|
||||
else:
|
||||
hosted_file_list = False
|
||||
# Safe to delete the hosted_file record and file from server???
|
||||
# CONTINUE???
|
||||
log.error('Something went wrong while trying to get a list of the hosted file link records.')
|
||||
return False
|
||||
|
||||
# ### Orphan file: ### Delete file from server
|
||||
hosted_files_path = settings.FILES_PATH['hosted_files_root']
|
||||
# hosted_files_path = '/home/scott/tmp/hosted_files_dev/'
|
||||
log.info(f'Hosted Files Path: {hosted_files_path}')
|
||||
|
||||
# dir_path = hosted_file_obj.directory_path
|
||||
|
||||
# Orphan: Delete physical file
|
||||
subdir_path = hosted_file_obj.subdirectory_path
|
||||
hash_sha256 = hosted_file_obj.hash_sha256
|
||||
hash_filename = hash_sha256+'.file'
|
||||
file_path = os.path.join(settings.FILES_PATH['hosted_files_root'], subdir_path or '', f'{hash_sha256}.file')
|
||||
|
||||
if subdir_path:
|
||||
full_subdirectory_path = os.path.join(hosted_files_path, subdir_path)
|
||||
else:
|
||||
full_subdirectory_path = hosted_files_path
|
||||
log.debug(full_subdirectory_path)
|
||||
file_path_w_subdir = os.path.join(full_subdirectory_path, hash_filename)
|
||||
log.info(f'Full file path with subdirectory: {file_path_w_subdir}')
|
||||
|
||||
if os.path.exists(file_path_w_subdir):
|
||||
log.info('File exists!')
|
||||
log.info('Going remove the file if it is an orphan...')
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
pathlib.Path(file_path_w_subdir).unlink()
|
||||
pathlib.Path(file_path).unlink()
|
||||
log.info(f"Unlinked physical file: {file_path}")
|
||||
except OSError as e:
|
||||
log.error("Error: %s : %s" % (file_path, e.strerror))
|
||||
log.error(f"Error unlinking: {e}")
|
||||
return False
|
||||
pass
|
||||
# return True
|
||||
else:
|
||||
log.warning(f'The hosted file was not found on the server. Hash: {hash_sha256}')
|
||||
pass
|
||||
# return None
|
||||
|
||||
# ### Orphan file: ### Delete hosted_file record
|
||||
sql = f"""
|
||||
DELETE FROM hosted_file
|
||||
WHERE hosted_file.id = :hosted_file_id
|
||||
"""
|
||||
log.debug(sql)
|
||||
|
||||
hosted_file_data = {}
|
||||
hosted_file_data['hosted_file_id'] = hosted_file_id
|
||||
log.debug(hosted_file_data)
|
||||
|
||||
if hosted_file_delete_result := sql_delete(sql=sql, data=hosted_file_data):
|
||||
log.info(f'Deleted Hosted File record. Hosted File ID: {hosted_file_id}')
|
||||
# Delete record
|
||||
sql = "DELETE FROM hosted_file WHERE id = :hosted_file_id"
|
||||
if sql_delete(sql=sql, data={'hosted_file_id': hosted_file_id_int}):
|
||||
log.info(f"Deleted record for hosted_file {hosted_file_id_int}")
|
||||
return True
|
||||
elif hosted_file_delete_result is None:
|
||||
log.warning(f'Hosted File record was not found and may have already been removed. Hosted File ID: {hosted_file_id}')
|
||||
return None
|
||||
# pass
|
||||
else:
|
||||
log.error('Something went wrong while trying to delete the hosted file record.')
|
||||
return False
|
||||
return False
|
||||
# ### END ### API Hosted File Methods ### handle_delete_hosted_file() ###
|
||||
|
||||
|
||||
|
||||
# ### BEGIN ### API Hosted File Methods ### delete_hosted_file_link() ###
|
||||
# Updated 2022-08-09
|
||||
@logger_reset
|
||||
def delete_hosted_file_link(
|
||||
account_id: int|str,
|
||||
hosted_file_id: int|str,
|
||||
|
||||
link_to_type: str,
|
||||
link_to_id: int|str,
|
||||
|
||||
# rm_orphan: bool = False,
|
||||
):
|
||||
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 False
|
||||
|
||||
if hosted_file_id := redis_lookup_id_random(record_id_random=hosted_file_id, table_name='hosted_file'): pass
|
||||
else: return False
|
||||
|
||||
if link_to_id := redis_lookup_id_random(record_id_random=link_to_id, table_name=link_to_type): pass
|
||||
else: return False
|
||||
|
||||
sql = f"""
|
||||
DELETE FROM hosted_file_link
|
||||
WHERE hosted_file_id = :hosted_file_id
|
||||
AND link_to_type = :link_to_type
|
||||
AND link_to_id = :link_to_id
|
||||
"""
|
||||
log.debug(sql)
|
||||
|
||||
hosted_file_link_data = {}
|
||||
hosted_file_link_data['hosted_file_id'] = hosted_file_id
|
||||
hosted_file_link_data['link_to_type'] = link_to_type
|
||||
hosted_file_link_data['link_to_id'] = link_to_id
|
||||
log.debug(hosted_file_link_data)
|
||||
|
||||
if hosted_file_delete_result := sql_delete(sql=sql, data=hosted_file_link_data):
|
||||
log.info(f'Deleted Hosted File Link. Hosted File ID: {hosted_file_id}, Link To Type: {link_to_type}, Link To ID: {link_to_id}')
|
||||
elif hosted_file_delete_result is None:
|
||||
return None
|
||||
else:
|
||||
return False
|
||||
|
||||
return True
|
||||
sql = "DELETE FROM hosted_file_link WHERE hosted_file_id = :hosted_file_id AND link_to_type = :link_to_type AND link_to_id = :link_to_id"
|
||||
if sql_delete(sql=sql, data={'hosted_file_id': hosted_file_id, 'link_to_type': link_to_type, 'link_to_id': link_to_id}):
|
||||
return True
|
||||
return False
|
||||
# ### END ### API Hosted File Methods ### delete_hosted_file_link() ###
|
||||
|
||||
|
||||
# ### BEGIN ### API Hosted File Methods ### get_hosted_file_rec_list() ###
|
||||
# This needs to be improved. Currently it does not really do anything.
|
||||
# Need to allow for list by account? Probably have the same actual hosted file have two hosted_file entries if it was uploaded for two separate accounts.
|
||||
# Updated 2022-09-22
|
||||
@logger_reset
|
||||
def get_hosted_file_rec_list(
|
||||
for_obj_type: str,
|
||||
@@ -782,92 +548,34 @@ def get_hosted_file_rec_list(
|
||||
limit: int = 1000,
|
||||
enabled: str = 'enabled', # enabled, disabled, all
|
||||
) -> list|bool:
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
if for_obj_id := redis_lookup_id_random(record_id_random=for_obj_id, table_name=for_obj_type): pass
|
||||
else: return False
|
||||
data = {}
|
||||
data[f'{for_obj_type}_id'] = for_obj_id
|
||||
# data['for_obj_type'] = for_obj_type
|
||||
sql_obj_type_id = f'`tbl`.{for_obj_type}_id = :{for_obj_type}_id'
|
||||
|
||||
if enabled in ['enabled', 'disabled', 'all']:
|
||||
if enabled == 'enabled':
|
||||
data['enable'] = True
|
||||
sql_enabled = f'AND `tbl`.enable = :enable'
|
||||
elif enabled == 'disabled':
|
||||
data['enable'] = False
|
||||
sql_enabled = f'AND `tbl`.enable = :enable'
|
||||
elif enabled == 'all':
|
||||
sql_enabled = ''
|
||||
|
||||
if limit:
|
||||
data['limit'] = limit
|
||||
sql_limit = f'LIMIT :limit'
|
||||
else:
|
||||
sql_limit = ''
|
||||
|
||||
data = {f'{for_obj_type}_id': for_obj_id, 'limit': limit}
|
||||
sql_enabled = "AND enable = :enable" if enabled == 'enabled' else ("AND enable = :enable" if enabled == 'disabled' else "")
|
||||
if enabled != 'all': data['enable'] = (enabled == 'enabled')
|
||||
|
||||
sql = f"""
|
||||
SELECT `hosted_file`.id AS 'hosted_file_id', `hosted_file`.id_random AS 'hosted_file_id_random'
|
||||
FROM `hosted_file` AS `hosted_file`
|
||||
WHERE
|
||||
{sql_obj_type_id}
|
||||
{sql_enabled}
|
||||
ORDER BY `hosted_file`.created_on DESC, `hosted_file`.updated_on DESC, `hosted_file`.filename ASC, `hosted_file`.extension ASC
|
||||
{sql_limit};
|
||||
SELECT id AS 'hosted_file_id', id_random AS 'hosted_file_id_random'
|
||||
FROM hosted_file
|
||||
WHERE {for_obj_type}_id = :{for_obj_type}_id {sql_enabled}
|
||||
ORDER BY created_on DESC, updated_on DESC, filename ASC
|
||||
LIMIT :limit;
|
||||
"""
|
||||
|
||||
# NOTE: Use the ORDER BY below if priority and sort fields are added to the hosted_file table.
|
||||
# /* ORDER BY `hosted_file`.priority DESC, -`hosted_file`.sort DESC, `hosted_file`.created_on DESC, `hosted_file`.updated_on DESC, `hosted_file`.filename ASC, `hosted_file`.extension ASC */
|
||||
|
||||
if hosted_file_rec_li_result := sql_select(data=data, sql=sql, as_list=True):
|
||||
hosted_file_rec_li = hosted_file_rec_li_result
|
||||
else:
|
||||
hosted_file_rec_li = []
|
||||
log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(hosted_file_rec_li_result)
|
||||
|
||||
return hosted_file_rec_li
|
||||
if res := sql_select(data=data, sql=sql, as_list=True): return res
|
||||
return []
|
||||
# ### END ### API Hosted File Methods ### get_hosted_file_rec_list() ###
|
||||
|
||||
|
||||
# ### BEGIN ### API Hosted File Methods ### get_hosted_file_link_rec_list() ###
|
||||
# Updated 2022-08-09
|
||||
@logger_reset
|
||||
def get_hosted_file_link_rec_list(
|
||||
hosted_file_id: int|str,
|
||||
|
||||
link_to_type: str = None,
|
||||
link_to_id: int|str = None,
|
||||
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
enabled: str = 'enabled', # enabled, disabled, all
|
||||
) -> list|bool:
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
data = {'hosted_file_id': hosted_file_id}
|
||||
|
||||
# sql_enabled, data['enable'] = sql_enable_part(table_name='hosted_file', enabled=enabled) # Reasonably safe return str and bool
|
||||
sql_limit = sql_limit_offset_part(limit=limit, offset=offset) # Reasonably safe return str
|
||||
|
||||
sql = f"""
|
||||
SELECT *
|
||||
FROM `hosted_file_link` AS `hosted_file_link`
|
||||
WHERE
|
||||
`hosted_file_link`.hosted_file_id = :hosted_file_id
|
||||
ORDER BY `hosted_file_link`.created_on DESC, `hosted_file_link`.updated_on DESC
|
||||
{sql_limit};
|
||||
"""
|
||||
|
||||
if hosted_file_link_rec_li_result := sql_select(data=data, sql=sql, as_list=True):
|
||||
hosted_file_link_rec_li = hosted_file_link_rec_li_result
|
||||
else:
|
||||
hosted_file_link_rec_li = []
|
||||
log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(hosted_file_link_rec_li_result)
|
||||
|
||||
return hosted_file_link_rec_li
|
||||
# ### END ### API Hosted File Methods ### get_hosted_file_link_rec_list() ###
|
||||
data = {'hosted_file_id': hosted_file_id, 'limit': limit, 'offset': offset}
|
||||
sql = "SELECT * FROM hosted_file_link WHERE hosted_file_id = :hosted_file_id ORDER BY created_on DESC LIMIT :limit OFFSET :offset"
|
||||
if res := sql_select(data=data, sql=sql, as_list=True): return res
|
||||
return []
|
||||
# ### END ### API Hosted File Methods ### get_hosted_file_link_rec_list() ###
|
||||
154
app/methods/idaa_novi_verify_methods.py
Normal file
154
app/methods/idaa_novi_verify_methods.py
Normal file
@@ -0,0 +1,154 @@
|
||||
import datetime
|
||||
import json
|
||||
import requests
|
||||
from typing import Dict, Optional
|
||||
|
||||
from app.lib_general import log, logger_reset
|
||||
|
||||
IDAA_SITE_ID_RANDOM = '58_gJESdlUh'
|
||||
_CACHE_TTL = datetime.timedelta(hours=4)
|
||||
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────
|
||||
|
||||
@logger_reset
|
||||
def _load_idaa_cfg() -> Optional[Dict]:
|
||||
"""Load IDAA site cfg_json. Returns parsed dict or None on failure."""
|
||||
from app.methods.site_methods import load_site_obj
|
||||
site = load_site_obj(site_id=IDAA_SITE_ID_RANDOM, model_as_dict=True)
|
||||
if not site:
|
||||
log.error("Could not load IDAA site record (id_random='%s').", IDAA_SITE_ID_RANDOM)
|
||||
return None
|
||||
cfg = site.get('cfg_json')
|
||||
if isinstance(cfg, str):
|
||||
try:
|
||||
cfg = json.loads(cfg)
|
||||
except Exception as e:
|
||||
log.error("Failed to parse IDAA cfg_json: %s", e)
|
||||
return None
|
||||
if not isinstance(cfg, dict):
|
||||
log.error("IDAA cfg_json is not a dict after parsing.")
|
||||
return None
|
||||
return cfg
|
||||
|
||||
|
||||
def _cache_key(uuid: str) -> str:
|
||||
return f'idaa:novi_member:{uuid}'
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
@logger_reset
|
||||
def verify_novi_member(uuid: str) -> Dict:
|
||||
"""
|
||||
Proxy GET /customers/{uuid} to Novi AMS and return normalized member data.
|
||||
|
||||
Returns a dict with one of:
|
||||
{'status': 200, 'verified': True, 'full_name': '...', 'email': '...'}
|
||||
{'status': 404, 'reason': '...'}
|
||||
{'status': 429, 'reason': '...'}
|
||||
{'status': 503, 'reason': '...'}
|
||||
|
||||
Redis cache key: idaa:novi_member:{uuid}, TTL 4 hours.
|
||||
Only 200 (verified) results are cached — 404 is never cached.
|
||||
"""
|
||||
from app.lib_redis_helpers import redis_client
|
||||
|
||||
cache_key = _cache_key(uuid)
|
||||
|
||||
# ── Cache hit ─────────────────────────────────────────────────────────
|
||||
cached_raw = redis_client.get(cache_key)
|
||||
if cached_raw:
|
||||
try:
|
||||
cached = json.loads(cached_raw)
|
||||
log.info("Novi verify cache hit: %s", uuid)
|
||||
return cached
|
||||
except Exception:
|
||||
pass # corrupt cache entry — fall through to Novi
|
||||
|
||||
# ── Load credentials ──────────────────────────────────────────────────
|
||||
cfg = _load_idaa_cfg()
|
||||
if not cfg:
|
||||
return {'status': 503, 'reason': 'IDAA site configuration unavailable.'}
|
||||
|
||||
base_url = cfg.get('novi_api_root_url', '').rstrip('/')
|
||||
api_key = cfg.get('novi_idaa_api_key', '')
|
||||
|
||||
if not base_url or not api_key:
|
||||
log.error("novi_api_root_url or novi_idaa_api_key missing from IDAA cfg_json.")
|
||||
return {'status': 503, 'reason': 'Novi credentials not configured.'}
|
||||
|
||||
headers = {'Authorization': f'Basic {api_key}', 'Accept': 'application/json'}
|
||||
|
||||
# ── Call Novi ─────────────────────────────────────────────────────────
|
||||
try:
|
||||
resp = requests.get(f'{base_url}/customers/{uuid}', headers=headers, timeout=10)
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
log.error("Novi unreachable: %s", e)
|
||||
return {'status': 503, 'reason': 'Novi API unreachable.'}
|
||||
except requests.exceptions.Timeout:
|
||||
log.error("Novi request timed out for UUID %s", uuid)
|
||||
return {'status': 503, 'reason': 'Novi API timed out.'}
|
||||
except Exception as e:
|
||||
log.exception("Unexpected error calling Novi for UUID %s: %s", uuid, e)
|
||||
return {'status': 503, 'reason': 'Unexpected error contacting Novi.'}
|
||||
|
||||
if resp.status_code == 429:
|
||||
log.warning("Novi rate limit hit for UUID %s", uuid)
|
||||
return {'status': 429, 'reason': 'Novi rate limit exceeded. Try again shortly.'}
|
||||
|
||||
if resp.status_code >= 500:
|
||||
log.error("Novi server error %s for UUID %s", resp.status_code, uuid)
|
||||
return {'status': 503, 'reason': f'Novi server error ({resp.status_code}).'}
|
||||
|
||||
if resp.status_code == 404:
|
||||
log.info("Novi returned 404 for UUID %s", uuid)
|
||||
return {'status': 404, 'reason': 'Member not found in Novi.'}
|
||||
|
||||
if resp.status_code != 200:
|
||||
log.error("Unexpected Novi status %s for UUID %s: %s", resp.status_code, uuid, resp.text[:200])
|
||||
return {'status': 503, 'reason': f'Unexpected Novi response ({resp.status_code}).'}
|
||||
|
||||
# ── Parse response ────────────────────────────────────────────────────
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
log.error("Novi returned non-JSON for UUID %s", uuid)
|
||||
return {'status': 503, 'reason': 'Novi returned an unparseable response.'}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
log.warning("Novi returned non-dict body for UUID %s", uuid)
|
||||
return {'status': 404, 'reason': 'Member not found in Novi (empty response).'}
|
||||
|
||||
# Empty-member anti-pattern: Novi 200 with no identity data
|
||||
email_raw = (data.get('Email') or '').strip()
|
||||
if not email_raw:
|
||||
log.info("Novi 200 with no Email for UUID %s — empty-member anti-pattern", uuid)
|
||||
return {'status': 404, 'reason': 'Member not found in Novi (no identity data).'}
|
||||
|
||||
email = email_raw.replace(' ', '+')
|
||||
|
||||
# Build display name: "FirstName LastName[0]." — fall back to Name field
|
||||
first = (data.get('FirstName') or '').strip()
|
||||
last = (data.get('LastName') or '').strip()
|
||||
if first and last:
|
||||
full_name = f'{first} {last[0]}.'
|
||||
elif first:
|
||||
full_name = first
|
||||
else:
|
||||
full_name = (data.get('Name') or '').strip() or 'Member'
|
||||
|
||||
result = {
|
||||
'status': 200,
|
||||
'verified': True,
|
||||
'full_name': full_name,
|
||||
'email': email,
|
||||
}
|
||||
|
||||
# ── Cache verified result ─────────────────────────────────────────────
|
||||
try:
|
||||
redis_client.setex(cache_key, _CACHE_TTL, json.dumps(result))
|
||||
except Exception as e:
|
||||
log.warning("Failed to cache Novi verify result for %s: %s", uuid, e)
|
||||
|
||||
return result
|
||||
169
app/methods/lib_media.py
Normal file
169
app/methods/lib_media.py
Normal file
@@ -0,0 +1,169 @@
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import time
|
||||
import tempfile
|
||||
import subprocess
|
||||
import shlex
|
||||
import logging
|
||||
import mimetypes
|
||||
|
||||
from app.config import settings
|
||||
from app.lib_general import log, logging
|
||||
from app.db_sql import sql_select, sql_update, sql_insert, get_id_random
|
||||
from app.methods.hosted_file_methods import (
|
||||
load_hosted_file_obj, create_hosted_file_obj, save_file_to_hosted_file
|
||||
)
|
||||
from app.models.hosted_file_models import Hosted_File_Base
|
||||
|
||||
# ### BEGIN ### API Hosted File Methods ### clip_video_method() ###
|
||||
async def clip_video_method(
|
||||
hosted_file_id: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
account_id: int,
|
||||
link_to_type: str,
|
||||
link_to_id: int,
|
||||
account_id_random: str = None,
|
||||
filename_no_ext: str = 'automated_hosted_file_clip_video',
|
||||
to_type: str = 'mp4',
|
||||
reencode: bool = False,
|
||||
scale_down: bool = False,
|
||||
):
|
||||
"""
|
||||
Business logic for clipping a video using ffmpeg and saving as a new hosted_file.
|
||||
Returns the new hosted_file dict or False.
|
||||
"""
|
||||
# NOTE: This function is invoked by the hosted_file router at
|
||||
# `/hosted_file/{hosted_file_id}/clip_video` and returns the created
|
||||
# hosted_file metadata (or False) so the router can build the standard
|
||||
# response body consumed by frontends.
|
||||
hosted_file_obj = load_hosted_file_obj(hosted_file_id=hosted_file_id)
|
||||
if not hosted_file_obj: return False
|
||||
|
||||
file_hash = hosted_file_obj.hash_sha256
|
||||
hosted_files_path = settings.FILES_PATH['hosted_files_root']
|
||||
full_file_path = os.path.join(hosted_files_path, file_hash[0:2], f'{file_hash}.file')
|
||||
|
||||
if not os.path.exists(full_file_path): return False
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.mp4') as tmp_video_file_clip:
|
||||
tmp_video_file_clip_path = tmp_video_file_clip.name
|
||||
|
||||
try:
|
||||
if scale_down:
|
||||
new_filename = f'{filename_no_ext}_[clip_scaled].{to_type}'
|
||||
cmd = f'ffmpeg -hide_banner -loglevel error -nostats -y -i {full_file_path} -ss {start_time} -to {end_time} -vf "scale=w=1920:h=1080:force_original_aspect_ratio=decrease" -c:v libx264 -crf 23 -maxrate 2M -bufsize 2M -c:a copy -movflags +faststart {tmp_video_file_clip_path}'
|
||||
elif reencode:
|
||||
new_filename = f'{filename_no_ext}_[clip_reencode].{to_type}'
|
||||
cmd = f"ffmpeg -hide_banner -loglevel error -nostats -y -i {full_file_path} -ss {start_time} -to {end_time} -c:v libx264 -crf 23 -maxrate 2M -bufsize 2M -c:a copy -movflags +faststart {tmp_video_file_clip_path}"
|
||||
else:
|
||||
new_filename = f'{filename_no_ext}_[clip].{to_type}'
|
||||
cmd = f"ffmpeg -hide_banner -loglevel error -nostats -y -i {full_file_path} -ss {start_time} -to {end_time} -c:v copy -c:a copy -movflags +faststart {tmp_video_file_clip_path}"
|
||||
|
||||
args = shlex.split(cmd)
|
||||
try:
|
||||
subprocess.run(args, check=True, capture_output=True, text=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
log.exception(f'ffmpeg failed: returncode={e.returncode}; stdout={e.stdout}; stderr={e.stderr}')
|
||||
return False
|
||||
|
||||
file_info = await save_file_to_hosted_file(
|
||||
file_path = tmp_video_file_clip_path,
|
||||
filename = new_filename,
|
||||
extension = to_type,
|
||||
account_id = account_id,
|
||||
account_id_random = account_id_random,
|
||||
link_to_type = link_to_type,
|
||||
link_to_id = link_to_id,
|
||||
)
|
||||
|
||||
if file_info.get('saved'):
|
||||
if sel := sql_select(table_name='hosted_file', field_name='hash_sha256', field_value=file_info['hash_sha256']):
|
||||
return load_hosted_file_obj(hosted_file_id=sel.get('id'), model_as_dict=True)
|
||||
else:
|
||||
new_obj = Hosted_File_Base(**file_info)
|
||||
if res_id := create_hosted_file_obj(hosted_file_obj_new=new_obj):
|
||||
return load_hosted_file_obj(hosted_file_id=res_id, model_as_dict=True)
|
||||
finally:
|
||||
try:
|
||||
if os.path.exists(tmp_video_file_clip_path):
|
||||
os.unlink(tmp_video_file_clip_path)
|
||||
except Exception:
|
||||
log.exception('Failed to remove temporary video clip file')
|
||||
return False
|
||||
# ### END ### API Hosted File Methods ### clip_video_method() ###
|
||||
|
||||
|
||||
# ### BEGIN ### API Hosted File Methods ### convert_file_method() ###
|
||||
async def convert_file_method(
|
||||
hosted_file_id: str,
|
||||
link_to_type: str,
|
||||
link_to_id: int,
|
||||
account_id: int,
|
||||
account_id_random: str = None,
|
||||
filename_no_ext: str = 'automated_hosted_file_conversion',
|
||||
to_type: str = 'webp',
|
||||
):
|
||||
from pdf2image import convert_from_path
|
||||
|
||||
# NOTE: Invoked by the hosted_file router at
|
||||
# `/hosted_file/{hosted_file_id}/convert_file`. This helper currently
|
||||
# converts the first page of a PDF to an image (webp/png) and saves a
|
||||
# new hosted_file record; it returns that record or False on failure.
|
||||
|
||||
hosted_file_obj = load_hosted_file_obj(hosted_file_id=hosted_file_id)
|
||||
if not hosted_file_obj: return False
|
||||
|
||||
# Ensure input is a PDF (pdf2image is designed for PDFs)
|
||||
if (getattr(hosted_file_obj, 'extension', None) or '').lower() != 'pdf' and (getattr(hosted_file_obj, 'content_type', None) or '') != 'application/pdf':
|
||||
log.warning('convert_file_method called on non-PDF file')
|
||||
return False
|
||||
|
||||
full_file_path = os.path.join(settings.FILES_PATH['hosted_files_root'], hosted_file_obj.hash_sha256[0:2], f'{hosted_file_obj.hash_sha256}.file')
|
||||
if not os.path.exists(full_file_path): return False
|
||||
if not os.path.exists(full_file_path): return False
|
||||
|
||||
save_path = os.path.join(settings.FILES_PATH['hosted_tmp_root'], 'convert_file', f'conv_{int(time.time())}.{to_type}')
|
||||
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||
|
||||
try:
|
||||
images = convert_from_path(full_file_path, size=(3840, None))
|
||||
image = images[0]
|
||||
|
||||
if to_type == 'webp':
|
||||
image.save(save_path, lossless=False, quality=90)
|
||||
elif to_type == 'png':
|
||||
image.save(save_path, compress_level=9)
|
||||
else:
|
||||
log.warning(f'Unsupported target type for convert_file_method: {to_type}')
|
||||
return False
|
||||
except Exception:
|
||||
log.exception('Error converting file to image')
|
||||
return False
|
||||
|
||||
file_info = await save_file_to_hosted_file(
|
||||
file_path = save_path,
|
||||
filename = f'{filename_no_ext}.{to_type}',
|
||||
extension = to_type,
|
||||
account_id = account_id,
|
||||
account_id_random = account_id_random,
|
||||
link_to_type = link_to_type,
|
||||
link_to_id = link_to_id,
|
||||
)
|
||||
|
||||
if file_info.get('saved'):
|
||||
if sel := sql_select(table_name='hosted_file', field_name='hash_sha256', field_value=file_info['hash_sha256']):
|
||||
return load_hosted_file_obj(hosted_file_id=sel.get('id'), model_as_dict=True)
|
||||
else:
|
||||
new_obj = Hosted_File_Base(**file_info)
|
||||
if res_id := create_hosted_file_obj(hosted_file_obj_new=new_obj):
|
||||
# cleanup tmp file
|
||||
try:
|
||||
if os.path.exists(save_path):
|
||||
os.unlink(save_path)
|
||||
except Exception:
|
||||
log.exception('Failed to remove temporary converted file')
|
||||
return load_hosted_file_obj(hosted_file_id=res_id, model_as_dict=True)
|
||||
return False
|
||||
# ### END ### API Hosted File Methods ### convert_file_method() ###
|
||||
96
app/methods/lookup_methods.py
Normal file
96
app/methods/lookup_methods.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import text
|
||||
from app.lib_sql_core import engine
|
||||
from app.lib_general_v3 import AccountContext
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def get_lookup_list_v3(
|
||||
lu_type: str,
|
||||
account_ctx: AccountContext,
|
||||
for_type: Optional[str] = None,
|
||||
for_id: Optional[int] = None,
|
||||
include_disabled: bool = False,
|
||||
whitelist: Optional[List[str]] = None,
|
||||
only_priority: bool = False
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Retrieves a ranked, deduplicated list of lookup records.
|
||||
Priority: Object Override > Account Override > Global Default.
|
||||
Supports an optional whitelist and priority filtering.
|
||||
"""
|
||||
table_name = f"v_lu_v3_{lu_type}"
|
||||
|
||||
# We use ROW_NUMBER() to handle the hierarchy
|
||||
|
||||
sql = f"""
|
||||
SELECT * FROM (
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY `group`
|
||||
ORDER BY
|
||||
(for_type = :for_type AND for_id = :for_id) DESC,
|
||||
(account_id = :account_id) DESC,
|
||||
created_on DESC
|
||||
) as rank_priority
|
||||
FROM `{table_name}`
|
||||
WHERE ((for_type = :for_type AND for_id = :for_id)
|
||||
OR account_id = :account_id
|
||||
OR account_id IS NULL)
|
||||
"""
|
||||
|
||||
if whitelist:
|
||||
sql += " AND `group` IN :whitelist"
|
||||
|
||||
sql += f"""
|
||||
) AS ranked
|
||||
WHERE rank_priority = 1
|
||||
"""
|
||||
|
||||
if not include_disabled:
|
||||
sql += " AND enable = 1"
|
||||
|
||||
if only_priority:
|
||||
sql += " AND priority = 1"
|
||||
|
||||
sql += " ORDER BY COALESCE(priority, 0) DESC, COALESCE(sort, 0) DESC, name ASC"
|
||||
|
||||
params = {
|
||||
"account_id": account_ctx.account_id,
|
||||
"for_type": for_type,
|
||||
"for_id": for_id,
|
||||
"whitelist": tuple(whitelist) if whitelist else None
|
||||
}
|
||||
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
result = conn.execute(text(sql), params)
|
||||
return [dict(row._mapping) for row in result]
|
||||
except Exception as e:
|
||||
log.error(f"Error in get_lookup_list_v3: {e}")
|
||||
return []
|
||||
|
||||
def resolve_lookup_v3(
|
||||
lu_type: str,
|
||||
query: str,
|
||||
account_ctx: AccountContext,
|
||||
identity_fields: List[str]
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Resolves a query string to a single lookup record by scanning multiple identity fields.
|
||||
Returns the highest-priority match.
|
||||
"""
|
||||
# Simple implementation: get the full ranked list and find first match in identity fields
|
||||
# For performance with large tables (like timezones), we might want a specific SQL query
|
||||
full_list = get_lookup_list_v3(lu_type, account_ctx)
|
||||
|
||||
query_clean = query.strip().lower()
|
||||
|
||||
for item in full_list:
|
||||
for field in identity_fields:
|
||||
val = item.get(field)
|
||||
if val and str(val).lower() == query_clean:
|
||||
return item
|
||||
|
||||
return None
|
||||
@@ -147,6 +147,9 @@ def get_site_domain_rec_list(
|
||||
# ### BEGIN ### API Site Domain Methods ### lookup_site_domain_fqdn() ###
|
||||
def lookup_site_domain_fqdn(
|
||||
fqdn: str,
|
||||
# Accept access_key as an argument for validation (str|None)
|
||||
access_key: Optional[str] = None,
|
||||
referrer: Optional[str] = None,
|
||||
enabled: str = 'enabled', # enabled, disabled, all
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
@@ -156,15 +159,37 @@ def lookup_site_domain_fqdn(
|
||||
|
||||
data = {}
|
||||
data['fqdn'] = fqdn
|
||||
# If access_key is provided, add it to the data dict for SQL parameterization
|
||||
data['domain_access_key'] = access_key
|
||||
if referrer:
|
||||
data['required_referrer'] = referrer
|
||||
|
||||
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
|
||||
|
||||
# Build access key / referrer SQL similar to router.lookup_fqdn behavior
|
||||
if access_key and referrer:
|
||||
sql_access_key_referrer = """
|
||||
AND site_domain.domain_access_key = :domain_access_key
|
||||
AND site_domain.required_referrer = :required_referrer
|
||||
"""
|
||||
elif access_key:
|
||||
sql_access_key_referrer = """
|
||||
AND site_domain.domain_access_key = :domain_access_key
|
||||
AND (site_domain.required_referrer IS NULL OR site_domain.required_referrer = '')
|
||||
"""
|
||||
else:
|
||||
sql_access_key_referrer = """
|
||||
AND (site_domain.domain_access_key IS NULL OR site_domain.domain_access_key = '')
|
||||
AND (site_domain.required_referrer IS NULL OR site_domain.required_referrer = '')
|
||||
"""
|
||||
|
||||
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
|
||||
{sql_access_key_referrer}
|
||||
{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 +201,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
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
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
|
||||
@@ -22,16 +22,12 @@ class Account_Base(BaseModel):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
**base_fields['account_id_random'],
|
||||
alias = 'account_id_random',
|
||||
# default_factory = lambda:secrets.token_urlsafe(default_num_bytes),
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'account_id'
|
||||
)
|
||||
# account_id: Optional[int] = Field(
|
||||
# )
|
||||
# --- Standardized Vision IDs (Strings) ---
|
||||
id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='account_id_random', exclude=True)
|
||||
|
||||
code: Optional[str]
|
||||
name: Optional[str]
|
||||
@@ -77,28 +73,29 @@ class Account_Base(BaseModel):
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
@validator('id', always=True)
|
||||
def account_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['id_random']:
|
||||
log.debug(values['id_random'])
|
||||
return redis_lookup_id_random(record_id_random=values['id_random'], table_name='account')
|
||||
return None
|
||||
|
||||
# @validator('account_id', always=True)
|
||||
# def account_id_duplicate(cls, v, values, **kwargs):
|
||||
# log.setLevel(logging.DEBUG)
|
||||
# log.debug(locals())
|
||||
|
||||
# if values['id']:
|
||||
# log.debug(values['id'])
|
||||
# return values['id']
|
||||
# return None
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
if rid := values.get('id_random') or values.get('account_id_random'):
|
||||
values['id'] = rid
|
||||
values['account_id'] = rid
|
||||
|
||||
# 2. Final Vision Enforcement: Strip internal integers from public fields
|
||||
for k in ['id', 'account_id']:
|
||||
val = values.get(k)
|
||||
if val is not None:
|
||||
# If it's not a valid random string ID
|
||||
if not isinstance(val, str) or len(val) < 11:
|
||||
values[k] = None
|
||||
|
||||
return values
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = False
|
||||
fields = base_fields
|
||||
allow_population_by_field_name = True
|
||||
# ### END ### API Account Models ### Account_Base() ###
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator, root_validator
|
||||
|
||||
from app.db_sql import get_id_random, redis_lookup_id_random
|
||||
@@ -14,15 +14,21 @@ class Address_Base(BaseModel):
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
# --- Standardized Vision IDs (Strings) ---
|
||||
# Standardized Vision IDs (Strings) ---
|
||||
id: Optional[str] = Field(None, **base_fields['address_id_random'])
|
||||
address_id: Optional[str] = Field(None, **base_fields['address_id_random'])
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
contact_id: Optional[str] = Field(None, **base_fields['contact_id_random'])
|
||||
|
||||
# Standardized Polymorphic Target
|
||||
for_type: Optional[str]
|
||||
for_id_random: Optional[str]
|
||||
for_id: Optional[int] = Field(None, exclude=True)
|
||||
for_id: Optional[str] = Field(**base_fields['obj_id_random'])
|
||||
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='address_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
contact_id_random: Optional[str] = Field(None, exclude=True)
|
||||
for_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
#organization: Optional[Organization_Base] = Organization_Base()
|
||||
|
||||
@@ -42,7 +48,7 @@ class Address_Base(BaseModel):
|
||||
country_name: Optional[str] # From country lookup table
|
||||
country: Optional[str] # Avoid using
|
||||
|
||||
lu_time_zone_id: Optional[str]
|
||||
lu_time_zone_id: Optional[str] = Field(None, exclude=True)
|
||||
timezone: Optional[str]
|
||||
|
||||
latitude: Optional[str]
|
||||
@@ -70,23 +76,55 @@ class Address_Base(BaseModel):
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
# 1. Map Primary Object ID
|
||||
if rid := values.get('id_random') or values.get('address_id_random'):
|
||||
values['id'] = rid
|
||||
values['address_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
if c_rid := values.get('contact_id_random'):
|
||||
values['contact_id'] = c_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'account_id', 'contact_id']:
|
||||
if k in values and not isinstance(values[k], str):
|
||||
del values[k]
|
||||
# 2. Map & Resolve Relational IDs
|
||||
id_map = [
|
||||
('account_id', 'account'),
|
||||
('contact_id', 'contact'),
|
||||
]
|
||||
|
||||
for field, table in id_map:
|
||||
r_val = values.get(f'{field}_random')
|
||||
if r_val and isinstance(r_val, str):
|
||||
values[field] = r_val
|
||||
elif values.get(field) and isinstance(values[field], (int, str)):
|
||||
is_random = isinstance(values[field], str) and len(values[field]) >= 11
|
||||
if not is_random:
|
||||
resolved_rid = get_id_random(values[field], table)
|
||||
if resolved_rid:
|
||||
values[field] = resolved_rid
|
||||
values[f'{field}_random'] = resolved_rid
|
||||
|
||||
# 3. Handle Polymorphic for_id
|
||||
if f_rid := values.get('for_id_random'):
|
||||
values['for_id'] = f_rid
|
||||
elif values.get('for_id') and values.get('for_type'):
|
||||
# Resolve based on the for_type
|
||||
is_random = isinstance(values['for_id'], str) and len(values['for_id']) >= 11
|
||||
if not is_random:
|
||||
resolved_for_rid = get_id_random(values['for_id'], values['for_type'])
|
||||
if resolved_for_rid:
|
||||
values['for_id'] = resolved_for_rid
|
||||
values['for_id_random'] = resolved_for_rid
|
||||
|
||||
# 4. Final Vision Enforcement
|
||||
for k in ['id', 'address_id', 'account_id', 'contact_id', 'for_id']:
|
||||
val = values.get(k)
|
||||
if val is not None:
|
||||
if not isinstance(val, str) or len(val) < 11:
|
||||
values[k] = None
|
||||
|
||||
return values
|
||||
|
||||
# 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] = [
|
||||
'country_subdivision_name', 'country_name'
|
||||
]
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = False
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
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]
|
||||
|
||||
@@ -37,6 +36,9 @@ class Archive_Content_Base(BaseModel):
|
||||
lu_media_type_id: Optional[int]
|
||||
lu_media_type: Optional[str]
|
||||
|
||||
external_id: Optional[str]
|
||||
code: Optional[str]
|
||||
|
||||
name: Optional[str]
|
||||
description: Optional[str]
|
||||
|
||||
@@ -92,14 +94,31 @@ class Archive_Content_Base(BaseModel):
|
||||
hosted_file_content_type: Optional[str]
|
||||
hosted_file_size: Optional[str]
|
||||
|
||||
# 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'
|
||||
]
|
||||
|
||||
_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,7 +1,7 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
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
|
||||
@@ -14,15 +14,39 @@ class Archive_Base(BaseModel):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
**base_fields['archive_id_random'],
|
||||
alias = 'archive_id_random',
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'archive_id'
|
||||
)
|
||||
account_id_random: Optional[str]
|
||||
account_id: Optional[int]
|
||||
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
|
||||
id: Optional[Union[int, str]] = Field(None, **base_fields['archive_id_random'])
|
||||
archive_id: Optional[Union[int, str]] = Field(None, **base_fields['archive_id_random'])
|
||||
account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_id_random'])
|
||||
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='archive_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers during READ operations.
|
||||
During CREATE (POST) operations, we ensure resolved integers are preserved.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
rid = values.get('id_random') or values.get('archive_id_random')
|
||||
if rid and isinstance(rid, str):
|
||||
values['id'] = rid
|
||||
values['archive_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||
|
||||
# 2. Prevent leakage of integers during API responses (Vision Standard)
|
||||
for k in ['id', 'archive_id', 'account_id']:
|
||||
val = values.get(k)
|
||||
if val is not None and not isinstance(val, str):
|
||||
if values.get(f'{k}_random') or (k=='id' and values.get('id_random')):
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
archive_type_id: Optional[int]
|
||||
archive_type: Optional[str]
|
||||
|
||||
@@ -70,22 +94,8 @@ class Archive_Base(BaseModel):
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
@validator('id', always=True)
|
||||
def archive_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')
|
||||
return None
|
||||
|
||||
@validator('account_id', always=True)
|
||||
def account_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('account_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='account')
|
||||
return None
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = True
|
||||
allow_population_by_field_name = False
|
||||
fields = base_fields
|
||||
# ### END ### API Archive Models ### Archive_Base() ###
|
||||
|
||||
@@ -12,3 +12,4 @@ class AccountContext(BaseModel):
|
||||
super: bool = False
|
||||
auth_method: str = 'legacy_header'
|
||||
token_payload: Optional[dict] = None
|
||||
auth_error: Optional[str] = None
|
||||
|
||||
@@ -49,6 +49,7 @@ base_fields['event_person_tracking_id_random'] = xxx_id_random_field_schema
|
||||
base_fields['event_presentation_id_random'] = xxx_id_random_field_schema
|
||||
base_fields['event_presenter_id_random'] = xxx_id_random_field_schema
|
||||
base_fields['event_registration_id_random'] = xxx_id_random_field_schema
|
||||
base_fields['event_registration_cfg_id_random'] = xxx_id_random_field_schema
|
||||
base_fields['event_session_id_random'] = xxx_id_random_field_schema
|
||||
base_fields['event_track_id_random'] = xxx_id_random_field_schema
|
||||
base_fields['flask_cfg_id_random'] = xxx_id_random_field_schema
|
||||
@@ -80,6 +81,7 @@ base_fields['post_comment_id_random'] = xxx_id_random_field_schema
|
||||
base_fields['product_id_random'] = xxx_id_random_field_schema
|
||||
base_fields['site_id_random'] = xxx_id_random_field_schema
|
||||
base_fields['site_domain_id_random'] = xxx_id_random_field_schema
|
||||
base_fields['status_id_random'] = xxx_id_random_field_schema
|
||||
base_fields['sponsorship_cfg_id_random'] = xxx_id_random_field_schema
|
||||
base_fields['sponsorship_id_random'] = xxx_id_random_field_schema
|
||||
base_fields['user_id_random'] = xxx_id_random_field_schema
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator, root_validator
|
||||
|
||||
from app.db_sql import get_id_random, redis_lookup_id_random
|
||||
@@ -26,9 +26,16 @@ class Contact_Base(BaseModel):
|
||||
# NOTE: Linked Address ID is actually the old contact.address_id (Legacy?)
|
||||
linked_address_id: Optional[str] = Field(None, **base_fields['address_id_random'])
|
||||
|
||||
# Standardized Polymorphic Target
|
||||
for_type: Optional[str]
|
||||
for_id: Optional[int]
|
||||
for_id_random: Optional[Union[str,None]] = None # lambda:get_id_random(values.get('for_id'), table_name=values.get('for_type')),
|
||||
for_id: Optional[str] = Field(**base_fields['obj_id_random'])
|
||||
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='contact_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
address_id_random: Optional[str] = Field(None, exclude=True)
|
||||
linked_address_id_random: Optional[str] = Field(None, exclude=True)
|
||||
for_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
name: Optional[str]
|
||||
title: Optional[str]
|
||||
@@ -87,64 +94,55 @@ class Contact_Base(BaseModel):
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
# 1. Map Primary Object ID
|
||||
if rid := values.get('id_random') or values.get('contact_id_random'):
|
||||
values['id'] = rid
|
||||
values['contact_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
if ad_rid := values.get('address_id_random'):
|
||||
values['address_id'] = ad_rid
|
||||
if lad_rid := values.get('linked_address_id_random'):
|
||||
values['linked_address_id'] = lad_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'contact_id', 'account_id', 'address_id', 'linked_address_id']:
|
||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||
del values[k]
|
||||
# 2. Map & Resolve Relational IDs
|
||||
id_map = [
|
||||
('account_id', 'account'),
|
||||
('address_id', 'address'),
|
||||
('linked_address_id', 'address'),
|
||||
]
|
||||
|
||||
for field, table in id_map:
|
||||
r_val = values.get(f'{field}_random')
|
||||
if r_val and isinstance(r_val, str):
|
||||
values[field] = r_val
|
||||
elif values.get(field) and isinstance(values[field], (int, str)):
|
||||
is_random = isinstance(values[field], str) and len(values[field]) >= 11
|
||||
if not is_random:
|
||||
resolved_rid = get_id_random(values[field], table)
|
||||
if resolved_rid:
|
||||
values[field] = resolved_rid
|
||||
values[f'{field}_random'] = resolved_rid
|
||||
|
||||
# 3. Handle Polymorphic for_id
|
||||
if f_rid := values.get('for_id_random'):
|
||||
values['for_id'] = f_rid
|
||||
elif values.get('for_id') and values.get('for_type'):
|
||||
# Resolve based on the for_type
|
||||
is_random = isinstance(values['for_id'], str) and len(values['for_id']) >= 11
|
||||
if not is_random:
|
||||
resolved_for_rid = get_id_random(values['for_id'], values['for_type'])
|
||||
if resolved_for_rid:
|
||||
values['for_id'] = resolved_for_rid
|
||||
values['for_id_random'] = resolved_for_rid
|
||||
|
||||
# 4. Final Vision Enforcement
|
||||
for k in ['id', 'contact_id', 'account_id', 'address_id', 'linked_address_id', 'for_id']:
|
||||
val = values.get(k)
|
||||
if val is not None:
|
||||
if not isinstance(val, str) or len(val) < 11:
|
||||
values[k] = None
|
||||
|
||||
return values
|
||||
|
||||
@validator('for_id', pre=True, always=True)
|
||||
def for_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.DEBUG)
|
||||
log.debug(locals())
|
||||
|
||||
for_type = values.get('for_type')
|
||||
for_id = v # values.get('for_id')
|
||||
for_id_random = values.get('for_id_random')
|
||||
|
||||
if for_id and for_type:
|
||||
log.info(f'Got For ID: {for_id}; For Type: {for_type}')
|
||||
for_id_random = get_id_random(for_id, table_name=for_type)
|
||||
values['for_id_random'] = for_id_random
|
||||
return for_id
|
||||
elif values.get('for_id_random') and values.get('for_type'):
|
||||
log.info(f'Got For ID Random: {for_id_random}; For Type: {for_type}')
|
||||
return redis_lookup_id_random(record_id_random=values['for_id_random'], table_name=values['for_type'])
|
||||
log.info(f'Got nothing? For ID: {for_id}; For ID Random: {for_id_random}; For Type: {for_type}')
|
||||
return None
|
||||
|
||||
@validator('for_id_random', always=True)
|
||||
def for_id_random_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.DEBUG)
|
||||
log.debug(locals())
|
||||
|
||||
for_type = values.get('for_type')
|
||||
for_id = values.get('for_id')
|
||||
for_id_random = v
|
||||
|
||||
if for_id_random:
|
||||
log.info(f'Got For ID Random: {for_id_random}')
|
||||
return for_id_random
|
||||
elif for_id and for_type:
|
||||
log.info(f'Got For ID: {for_id}; For Type: {for_type}')
|
||||
for_id_random = get_id_random(for_id, table_name=for_type)
|
||||
log.info(f'Got ID Random: {for_id_random}')
|
||||
return for_id_random
|
||||
log.info(f'Got nothing? For ID: {for_id}; For ID Random: {for_id_random}; For Type: {for_type}')
|
||||
return None
|
||||
# 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] = [
|
||||
'linked_address_id', 'timezone_name', 'address'
|
||||
]
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator, root_validator
|
||||
|
||||
from app.db_sql import redis_lookup_id_random
|
||||
from app.db_sql import get_id_random, redis_lookup_id_random
|
||||
from app.lib_general import log, logging
|
||||
|
||||
from app.models.common_field_schema import base_fields
|
||||
@@ -11,39 +11,35 @@ from app.models.common_field_schema import base_fields
|
||||
|
||||
# ### BEGIN ### API Data Store Models ### Data_Store_Base() ###
|
||||
class Data_Store_Base(BaseModel):
|
||||
# log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
**base_fields['data_store_id_random'],
|
||||
alias = 'data_store_id_random',
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'data_store_id'
|
||||
)
|
||||
|
||||
account_id_random: Optional[str]
|
||||
account_id: Optional[int]
|
||||
# --- Standardized Vision IDs (Strings) ---
|
||||
id: Optional[str] = Field(None, **base_fields['data_store_id_random'])
|
||||
data_store_id: Optional[str] = Field(None, **base_fields['data_store_id_random'])
|
||||
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
person_id: Optional[str] = Field(None, **base_fields['person_id_random'])
|
||||
user_id: Optional[str] = Field(None, **base_fields['user_id_random'])
|
||||
|
||||
# Standardized Polymorphic Target
|
||||
for_type: Optional[str]
|
||||
for_id_random: Optional[str]
|
||||
for_id: Optional[int]
|
||||
for_id: Optional[str] = Field(**base_fields['obj_id_random'])
|
||||
|
||||
person_id_random: Optional[str]
|
||||
person_id: Optional[int]
|
||||
|
||||
user_id_random: Optional[str]
|
||||
user_id: Optional[int]
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='data_store_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
person_id_random: Optional[str] = Field(None, exclude=True)
|
||||
user_id_random: Optional[str] = Field(None, exclude=True)
|
||||
for_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
code: Optional[str]
|
||||
|
||||
name: Optional[str]
|
||||
description: Optional[str]
|
||||
|
||||
type: Optional[str] # html, json, md, text
|
||||
|
||||
# The JSON fields are case sensitive
|
||||
# json: Optional[str] # "json" is reserved; need to change field name? json_str?
|
||||
json_str: Optional[Union[Json, None]] = Field(
|
||||
alias = 'json',
|
||||
)
|
||||
@@ -71,58 +67,68 @@ class Data_Store_Base(BaseModel):
|
||||
created_on: Optional[datetime.datetime] = None
|
||||
updated_on: Optional[datetime.datetime] = None
|
||||
|
||||
# Including convenience data
|
||||
# This is only for convenience. Probably going to keep unless it causes a problem.
|
||||
|
||||
# Including JSON data
|
||||
# other_json: Optional[Json]
|
||||
# meta_json: Optional[Json]
|
||||
|
||||
# Including other related objects
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
@validator('id', always=True)
|
||||
def data_store_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='data_store')
|
||||
return None
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
"""
|
||||
# 0. Scrub stringified NULLs from database
|
||||
for k, v in list(values.items()):
|
||||
if isinstance(v, str) and v.upper() == 'NULL':
|
||||
values[k] = None
|
||||
|
||||
@validator('account_id', always=True)
|
||||
def account_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('account_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='account')
|
||||
return None
|
||||
# 1. Map Primary Object ID
|
||||
if rid := values.get('id_random') or values.get('data_store_id_random'):
|
||||
values['id'] = rid
|
||||
values['data_store_id'] = rid
|
||||
|
||||
# 2. Map & Resolve Relational IDs
|
||||
id_map = [
|
||||
('account_id', 'account'),
|
||||
('person_id', 'person'),
|
||||
('user_id', 'user'),
|
||||
]
|
||||
|
||||
@validator('for_id', always=True)
|
||||
def for_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif values.get('for_id_random') and values.get('for_type'):
|
||||
for_id_random = values.get('for_id_random')
|
||||
for_type = values.get('for_type')
|
||||
return redis_lookup_id_random(record_id_random=for_id_random, table_name=for_type)
|
||||
return None
|
||||
for field, table in id_map:
|
||||
r_val = values.get(f'{field}_random')
|
||||
if r_val and isinstance(r_val, str):
|
||||
values[field] = r_val
|
||||
elif values.get(field) and isinstance(values[field], (int, str)):
|
||||
# If it's a string but doesn't look like a random ID (e.g. integer string), resolve it
|
||||
is_random = isinstance(values[field], str) and len(values[field]) >= 11
|
||||
if not is_random:
|
||||
resolved_rid = get_id_random(values[field], table)
|
||||
if resolved_rid:
|
||||
values[field] = resolved_rid
|
||||
values[f'{field}_random'] = resolved_rid
|
||||
|
||||
@validator('person_id', always=True)
|
||||
def person_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('person_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='person')
|
||||
return None
|
||||
# 3. Handle Polymorphic for_id
|
||||
if f_rid := values.get('for_id_random'):
|
||||
values['for_id'] = f_rid
|
||||
elif values.get('for_id') and values.get('for_type'):
|
||||
# Resolve based on the for_type
|
||||
is_random = isinstance(values['for_id'], str) and len(values['for_id']) >= 11
|
||||
if not is_random:
|
||||
resolved_for_rid = get_id_random(values['for_id'], values['for_type'])
|
||||
if resolved_for_rid:
|
||||
values['for_id'] = resolved_for_rid
|
||||
values['for_id_random'] = resolved_for_rid
|
||||
|
||||
@validator('user_id', always=True)
|
||||
def user_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('user_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='user')
|
||||
return None
|
||||
# 4. Final Vision Enforcement: Strip internal integers from public fields
|
||||
for k in ['id', 'data_store_id', 'account_id', 'person_id', 'user_id', 'for_id']:
|
||||
val = values.get(k)
|
||||
# If value is present but not a valid random string ID
|
||||
if val is not None:
|
||||
if not isinstance(val, str) or len(val) < 11:
|
||||
values[k] = None
|
||||
|
||||
return values
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = True
|
||||
allow_population_by_field_name = False
|
||||
fields = base_fields
|
||||
# ### END ### API Data Store Models ### Data_Store_Base() ###
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator, root_validator
|
||||
|
||||
from app.db_sql import redis_lookup_id_random
|
||||
@@ -27,6 +27,7 @@ class Event_Abstract_Base(BaseModel):
|
||||
id: Optional[str] = Field(None, **base_fields['event_abstract_id_random'])
|
||||
event_abstract_id: Optional[str] = Field(None, **base_fields['event_abstract_id_random'])
|
||||
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
event_id: Optional[str] = Field(None, **base_fields['event_id_random'])
|
||||
event_person_id: Optional[str] = Field(None, **base_fields['event_person_id_random'])
|
||||
event_presentation_id: Optional[str] = Field(None, **base_fields['event_presentation_id_random'])
|
||||
@@ -96,6 +97,8 @@ class Event_Abstract_Base(BaseModel):
|
||||
values['id'] = rid
|
||||
values['event_abstract_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
if e_rid := values.get('event_id_random'):
|
||||
values['event_id'] = e_rid
|
||||
if ep_rid := values.get('event_person_id_random'):
|
||||
@@ -110,12 +113,18 @@ class Event_Abstract_Base(BaseModel):
|
||||
values['grant_id'] = g_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'event_abstract_id', 'event_id', 'event_person_id', 'event_presentation_id', 'event_presenter_id', 'event_session_id', 'grant_id']:
|
||||
for k in ['id', 'event_abstract_id', 'account_id', 'event_id', 'event_person_id', 'event_presentation_id', 'event_presenter_id', 'event_session_id', 'grant_id']:
|
||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
# 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] = [
|
||||
'account_id', 'event_session_code', 'event_session_name',
|
||||
'event_file_list', 'event_person', 'event_presenter_list'
|
||||
]
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = False
|
||||
@@ -136,6 +145,7 @@ class Event_Abstract_Base_New(Core_Std_Obj_Base):
|
||||
id: Optional[str] = Field(None, **base_fields['event_abstract_id_random'])
|
||||
event_abstract_id: Optional[str] = Field(None, **base_fields['event_abstract_id_random'])
|
||||
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
event_id: Optional[str] = Field(None, **base_fields['event_id_random'])
|
||||
event_person_id: Optional[str] = Field(None, **base_fields['event_person_id_random'])
|
||||
event_presentation_id: Optional[str] = Field(None, **base_fields['event_presentation_id_random'])
|
||||
@@ -181,6 +191,8 @@ class Event_Abstract_Base_New(Core_Std_Obj_Base):
|
||||
values['id'] = rid
|
||||
values['event_abstract_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
if e_rid := values.get('event_id_random'):
|
||||
values['event_id'] = e_rid
|
||||
if ep_rid := values.get('event_person_id_random'):
|
||||
@@ -195,12 +207,17 @@ class Event_Abstract_Base_New(Core_Std_Obj_Base):
|
||||
values['grant_id'] = g_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'event_abstract_id', 'event_id', 'event_person_id', 'event_presentation_id', 'event_presenter_id', 'event_session_id', 'grant_id']:
|
||||
for k in ['id', 'event_abstract_id', 'account_id', 'event_id', 'event_person_id', 'event_presentation_id', 'event_presenter_id', 'event_session_id', 'grant_id']:
|
||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
# 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] = [
|
||||
'account_id'
|
||||
]
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = False
|
||||
@@ -246,4 +263,9 @@ class Event_Abstract_Ext(Event_Abstract_Base_New):
|
||||
# event_track: Optional[Event_Track_Base]
|
||||
# poc_event_person: Optional[Event_Person_Base] # Maybe change this to primary_event_person?
|
||||
# poc_person: Optional[Person_Base] # Maybe change this to primary_person?
|
||||
|
||||
# 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] = Event_Abstract_Base_New.fields_to_exclude_from_db + [
|
||||
'event_session_code', 'event_session_name', 'event_cfg', 'event_file_list', 'event_person'
|
||||
]
|
||||
# ### END ### API Event Abstract Models ### Event_Abstract_Ext() ###
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
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
|
||||
@@ -11,33 +11,60 @@ from app.models.event_badge_template_models import Event_Badge_Template_Base
|
||||
from app.models.order_models import Order_Base
|
||||
|
||||
|
||||
# ### BEGIN ### API Event Badge Models ### Event_Badge_Base() ###
|
||||
class Event_Badge_Base(BaseModel):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
# **base_fields['event_badge_id_random'],
|
||||
alias = 'event_badge_id_random',
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'event_badge_id'
|
||||
)
|
||||
|
||||
event_id_random: Optional[str]
|
||||
event_id: Optional[int]
|
||||
|
||||
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
|
||||
id: Optional[Union[int, str]] = Field(**base_fields['event_badge_id_random'])
|
||||
event_badge_id: Optional[Union[int, str]] = Field(**base_fields['event_badge_id_random'])
|
||||
account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_id_random'])
|
||||
event_id: Optional[Union[int, str]] = Field(**base_fields['event_id_random'])
|
||||
|
||||
# NOTE: This should only be used when the event_person record can not be created. And records before 2022.
|
||||
event_id_random_only: Optional[str]
|
||||
event_id_only: Optional[int]
|
||||
event_id_only: Optional[Union[int, str]] = Field(**base_fields['event_id_random'])
|
||||
|
||||
event_badge_template_id_random: Optional[str]
|
||||
event_badge_template_id: Optional[int]
|
||||
event_badge_template_id: Optional[Union[int, str]] = Field(**base_fields['event_badge_template_id_random'])
|
||||
event_person_id: Optional[Union[int, str]] = Field(**base_fields['event_person_id_random'])
|
||||
person_id: Optional[Union[int, str]] = Field(**base_fields['person_id_random'])
|
||||
|
||||
event_person_id_random: Optional[str]
|
||||
event_person_id: Optional[int]
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='event_badge_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_id_random_only: Optional[str] = Field(None, exclude=True)
|
||||
event_badge_template_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_person_id_random: Optional[str] = Field(None, exclude=True)
|
||||
person_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
person_id_random: Optional[str]
|
||||
person_id: Optional[int]
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers during READ operations.
|
||||
During CREATE (POST) operations, we ensure resolved integers are preserved.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
rid = values.get('id_random') or values.get('event_badge_id_random')
|
||||
if rid and isinstance(rid, str):
|
||||
values['id'] = rid
|
||||
values['event_badge_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||
if e_rid := values.get('event_id_random'): values['event_id'] = e_rid
|
||||
if eo_rid := values.get('event_id_random_only'): values['event_id_only'] = eo_rid
|
||||
if et_rid := values.get('event_badge_template_id_random'): values['event_badge_template_id'] = et_rid
|
||||
if ep_rid := values.get('event_person_id_random'): values['event_person_id'] = ep_rid
|
||||
if p_rid := values.get('person_id_random'): values['person_id'] = p_rid
|
||||
|
||||
# 2. Prevent leakage of integers during API responses (Vision Standard)
|
||||
for k in ['id', 'event_badge_id', 'account_id', 'event_id', 'event_id_only', 'event_badge_template_id', 'event_person_id', 'person_id']:
|
||||
val = values.get(k)
|
||||
if val is not None and not isinstance(val, str):
|
||||
values[k] = None
|
||||
|
||||
return values
|
||||
|
||||
external_id: Optional[str] # Generated internally or externally. Needs to be stable. It should not change.
|
||||
external_event_id: Optional[str] # Event ID generated by external system. Needs to be stable. It should not change.
|
||||
@@ -145,7 +172,7 @@ class Event_Badge_Base(BaseModel):
|
||||
cfg_json: Optional[Union[Json, None]] # Store per badge config options like font size; Not currently used 2024-06-11
|
||||
data_json: Optional[Union[Json, None]] # For key value data. Careful with overwriting existing fields! Not currently used 2024-06-11
|
||||
|
||||
default_qry_string: Optional[str] # Default query string used for searching and filtering badges. Updated using SQL triggers and a SQL function
|
||||
default_qry_str: Optional[str] # Default query string used for searching and filtering badges. Updated using SQL triggers and a SQL function
|
||||
|
||||
hide: Optional[bool]
|
||||
priority: Optional[bool]
|
||||
@@ -164,47 +191,10 @@ class Event_Badge_Base(BaseModel):
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
@validator('id', always=True)
|
||||
def event_badge_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='event_badge')
|
||||
return None
|
||||
|
||||
@validator('event_id', always=True)
|
||||
def event_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event')
|
||||
return None
|
||||
|
||||
@validator('event_id_only', always=True)
|
||||
def event_id_only_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_id_random_only'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event')
|
||||
return None
|
||||
|
||||
@validator('event_badge_template_id', always=True)
|
||||
def event_badge_template_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_badge_template_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_badge_template')
|
||||
return None
|
||||
|
||||
@validator('event_person_id', always=True)
|
||||
def event_person_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_person_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_person')
|
||||
return None
|
||||
|
||||
@validator('person_id', always=True)
|
||||
def person_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('person_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='person')
|
||||
return None
|
||||
# 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] = [
|
||||
'account_id', 'order', 'ticket_list', 'event_badge_template'
|
||||
]
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
@@ -216,18 +206,44 @@ class Event_Badge_Basic_Base(BaseModel):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
**base_fields['event_badge_id_random'],
|
||||
alias = 'event_badge_id_random',
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'event_badge_id'
|
||||
)
|
||||
event_badge_template_id_random: Optional[str]
|
||||
# event_badge_template_id: Optional[int]
|
||||
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
|
||||
id: Optional[Union[int, str]] = Field(None, **base_fields['event_badge_id_random'])
|
||||
event_badge_id: Optional[Union[int, str]] = Field(None, **base_fields['event_badge_id_random'])
|
||||
account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_id_random'])
|
||||
event_badge_template_id: Optional[Union[int, str]] = Field(None, **base_fields['event_badge_template_id_random'])
|
||||
event_person_id: Optional[Union[int, str]] = Field(None, **base_fields['event_person_id_random'])
|
||||
|
||||
event_person_id_random: Optional[str]
|
||||
# event_person_id: Optional[int]
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='event_badge_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_badge_template_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_person_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers during READ operations.
|
||||
During CREATE (POST) operations, we ensure resolved integers are preserved.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
rid = values.get('id_random') or values.get('event_badge_id_random')
|
||||
if rid and isinstance(rid, str):
|
||||
values['id'] = rid
|
||||
values['event_badge_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||
if et_rid := values.get('event_badge_template_id_random'): values['event_badge_template_id'] = et_rid
|
||||
if ep_rid := values.get('event_person_id_random'): values['event_person_id'] = ep_rid
|
||||
|
||||
# 2. Prevent "Collision Population" or leakage of integers during API responses
|
||||
for k in ['id', 'event_badge_id', 'account_id', 'event_badge_template_id', 'event_person_id']:
|
||||
val = values.get(k)
|
||||
if val is not None and not isinstance(val, str):
|
||||
if values.get(f'{k}_random') or (k=='id' and values.get('id_random')):
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
external_id: Optional[str] # Generated internally or externally. Needs to be stable. It should not change.
|
||||
# external_sys_id: Optional[str] # Person ID generated by external system (should be stable and not change)
|
||||
@@ -318,14 +334,13 @@ class Event_Badge_Basic_Base(BaseModel):
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
@validator('id', always=True)
|
||||
def event_badge_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='event_badge')
|
||||
return None
|
||||
# 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] = [
|
||||
'account_id', 'event_badge_template'
|
||||
]
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = True
|
||||
fields = base_fields
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
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
|
||||
@@ -15,19 +15,16 @@ class Event_Badge_Template_Base(BaseModel):
|
||||
|
||||
# log.info('Using base template')
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
**base_fields['event_badge_template_id_random'],
|
||||
alias = 'event_badge_template_id_random',
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'event_badge_template_id'
|
||||
)
|
||||
# --- Standardized Vision IDs (Strings) ---
|
||||
id: Optional[str] = Field(None, **base_fields['event_badge_template_id_random'])
|
||||
event_badge_template_id: Optional[str] = Field(None, **base_fields['event_badge_template_id_random'])
|
||||
event_id: Optional[str] = Field(None, **base_fields['event_id_random'])
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
|
||||
# account_id_random: Optional[str]
|
||||
# account_id: Optional[int]
|
||||
|
||||
event_id_random: Optional[str]
|
||||
event_id: Optional[int]
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='event_badge_template_id_random', exclude=True)
|
||||
event_id_random: Optional[str] = Field(None, exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
name: Optional[str]
|
||||
description: Optional[str]
|
||||
@@ -40,6 +37,7 @@ class Event_Badge_Template_Base(BaseModel):
|
||||
header_background: Optional[str]
|
||||
|
||||
secondary_header_path: Optional[str] # Path to image file for back of badge and other sections
|
||||
background_image_path: Optional[str]
|
||||
|
||||
footer_path: Optional[str] # Path to image file
|
||||
footer_title: Optional[str]
|
||||
@@ -49,6 +47,8 @@ class Event_Badge_Template_Base(BaseModel):
|
||||
|
||||
badge_type_list: Optional[Json]
|
||||
|
||||
duplex: Optional[bool]
|
||||
|
||||
ticket_list: Optional[Json]
|
||||
ticket_1_text: Optional[str]
|
||||
ticket_2_text: Optional[str]
|
||||
@@ -74,32 +74,47 @@ class Event_Badge_Template_Base(BaseModel):
|
||||
script_src: Optional[str]
|
||||
passcode: Optional[str]
|
||||
|
||||
other_json: Optional[str]
|
||||
other_json: Optional[Json]
|
||||
cfg_json: Optional[Json]
|
||||
|
||||
enable: Optional[bool]
|
||||
hide: Optional[bool]
|
||||
priority: Optional[int]
|
||||
sort: Optional[int]
|
||||
group: Optional[str]
|
||||
notes: Optional[str]
|
||||
created_on: Optional[datetime.datetime] = None
|
||||
updated_on: Optional[datetime.datetime] = None
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
@validator('id', always=True)
|
||||
def event_badge_template_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='event_badge_template')
|
||||
return None
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
if rid := values.get('id_random') or values.get('event_badge_template_id_random'):
|
||||
values['id'] = rid
|
||||
values['event_badge_template_id'] = rid
|
||||
|
||||
@validator('event_id', always=True)
|
||||
def event_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event')
|
||||
return None
|
||||
if e_rid := values.get('event_id_random'):
|
||||
values['event_id'] = e_rid
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
|
||||
# 2. Prevent "Collision Population" (ensure no integers leak into the clean string fields)
|
||||
for k in ['id', 'event_badge_template_id', 'event_id', 'account_id']:
|
||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = True
|
||||
allow_population_by_field_name = False
|
||||
fields = base_fields
|
||||
|
||||
|
||||
@@ -113,22 +128,11 @@ class Event_Badge_Template_Base_In(Event_Badge_Template_Base):
|
||||
|
||||
|
||||
class Event_Badge_Template_Base_Out(Event_Badge_Template_Base):
|
||||
|
||||
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
|
||||
|
||||
log.debug(locals())
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# log.info('Using Out template')
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# badge_type_list: Optional[Json]
|
||||
event_name: Optional[str]
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
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
|
||||
@@ -15,16 +15,40 @@ class Event_Cfg_Base(BaseModel):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
# **base_fields['event_cfg_id_random'],
|
||||
alias = 'event_cfg_id_random',
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'event_cfg_id'
|
||||
)
|
||||
# --- Standardized Vision IDs (Strings) ---
|
||||
id: Optional[str] = Field(None, **base_fields['event_cfg_id_random'])
|
||||
event_cfg_id: Optional[str] = Field(None, **base_fields['event_cfg_id_random'])
|
||||
|
||||
event_id_random: Optional[str]
|
||||
event_id: Optional[int]
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
event_id: Optional[str] = Field(None, **base_fields['event_id_random'])
|
||||
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='event_cfg_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
if rid := values.get('id_random') or values.get('event_cfg_id_random'):
|
||||
values['id'] = rid
|
||||
values['event_cfg_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
if e_rid := values.get('event_id_random'):
|
||||
values['event_id'] = e_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'event_cfg_id', 'account_id', 'event_id']:
|
||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
enable: Optional[bool]
|
||||
enable_from: Optional[datetime.datetime]
|
||||
@@ -105,6 +129,11 @@ class Event_Cfg_Base(BaseModel):
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
# 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] = [
|
||||
'account_id', 'event_id', 'event_registration_cfg'
|
||||
]
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = True
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
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
|
||||
@@ -16,22 +16,13 @@ class Event_Device_Base(BaseModel):
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
# **base_fields['event_device_id_random'],
|
||||
alias = 'event_device_id_random',
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'event_device_id'
|
||||
)
|
||||
# --- Standardized Vision IDs (Strings) ---
|
||||
id: Optional[str] = Field(None, **base_fields['event_device_id_random'])
|
||||
event_device_id: Optional[str] = Field(None, **base_fields['event_device_id_random'])
|
||||
|
||||
account_id_random: Optional[str]
|
||||
account_id: Optional[int]
|
||||
|
||||
event_id_random: Optional[str]
|
||||
event_id: Optional[int]
|
||||
|
||||
event_location_id_random: Optional[str]
|
||||
event_location_id: Optional[int]
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
event_id: Optional[str] = Field(None, **base_fields['event_id_random'])
|
||||
event_location_id: Optional[str] = Field(None, **base_fields['event_location_id_random'])
|
||||
|
||||
code: Optional[str]
|
||||
|
||||
@@ -131,45 +122,36 @@ class Event_Device_Base(BaseModel):
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
#@validator('event_device_id_random', always=True)
|
||||
# def event_device_id_random_copy(cls, v, values, **kwargs):
|
||||
# log.setLevel(logging.WARNING)
|
||||
# log.debug(locals())
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
if rid := values.get('id_random') or values.get('event_device_id_random'):
|
||||
values['id'] = rid
|
||||
values['event_device_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
if e_rid := values.get('event_id_random'):
|
||||
values['event_id'] = e_rid
|
||||
if el_rid := values.get('event_location_id_random'):
|
||||
values['event_location_id'] = el_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'event_device_id', 'account_id', 'event_id', 'event_location_id']:
|
||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
# if values['id_random']:
|
||||
# return values['id_random']
|
||||
# return None
|
||||
|
||||
@validator('id', always=True)
|
||||
def event_device_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='event_device')
|
||||
return None
|
||||
|
||||
@validator('account_id', always=True)
|
||||
def account_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('account_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='account')
|
||||
return None
|
||||
|
||||
@validator('event_id', always=True)
|
||||
def event_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event')
|
||||
return None
|
||||
|
||||
@validator('event_location_id', always=True)
|
||||
def event_location_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_location_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_location')
|
||||
return None
|
||||
# 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] = ['account_id', 'event_cfg', 'event_location']
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = True
|
||||
allow_population_by_field_name = False
|
||||
fields = base_fields
|
||||
# ### END ### API Event Device Models ### Event_Device_Base() ###
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
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
|
||||
@@ -15,32 +15,78 @@ class Event_Exhibit_Base(BaseModel):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
**base_fields['event_exhibit_id_random'],
|
||||
alias = 'event_exhibit_id_random',
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'event_exhibit_id'
|
||||
)
|
||||
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
|
||||
id: Optional[Union[int, str]] = Field(**base_fields['event_exhibit_id_random'])
|
||||
event_exhibit_id: Optional[Union[int, str]] = Field(**base_fields['event_exhibit_id_random'])
|
||||
account_id: Optional[Union[int, str]] = Field(**base_fields['account_id_random'])
|
||||
event_id: Optional[Union[int, str]] = Field(**base_fields['event_id_random'])
|
||||
organization_id: Optional[Union[int, str]] = Field(**base_fields['organization_id_random'])
|
||||
contact_id: Optional[Union[int, str]] = Field(**base_fields['contact_id_random'])
|
||||
person_id: Optional[Union[int, str]] = Field(**base_fields['person_id_random'])
|
||||
status_id: Optional[Union[int, str]] = Field(**base_fields['status_id_random'])
|
||||
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='event_exhibit_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_id_random: Optional[str] = Field(None, exclude=True)
|
||||
organization_id_random: Optional[str] = Field(None, exclude=True)
|
||||
contact_id_random: Optional[str] = Field(None, exclude=True)
|
||||
person_id_random: Optional[str] = Field(None, exclude=True)
|
||||
status_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers during READ operations.
|
||||
Falls back to Redis/DB lookups if random string IDs are missing from the view.
|
||||
"""
|
||||
from app.db_sql import get_id_random
|
||||
|
||||
# 1. Map Primary Object ID
|
||||
rid = values.get('id_random') or values.get('event_exhibit_id_random')
|
||||
if rid and isinstance(rid, str):
|
||||
values['id'] = rid
|
||||
values['event_exhibit_id'] = rid
|
||||
elif values.get('id') and isinstance(values.get('id'), int):
|
||||
# Fallback for primary ID
|
||||
resolved_rid = get_id_random(values['id'], 'event_exhibit')
|
||||
if resolved_rid:
|
||||
values['id'] = resolved_rid
|
||||
values['event_exhibit_id'] = resolved_rid
|
||||
values['id_random'] = resolved_rid
|
||||
|
||||
# 2. Map & Resolve Relational IDs
|
||||
id_map = [
|
||||
('account_id', 'account'),
|
||||
('event_id', 'event'),
|
||||
('organization_id', 'organization'),
|
||||
('contact_id', 'contact'),
|
||||
('person_id', 'person'),
|
||||
('status_id', 'status'),
|
||||
]
|
||||
|
||||
for field, table in id_map:
|
||||
r_val = values.get(f'{field}_random')
|
||||
if r_val and isinstance(r_val, str):
|
||||
values[field] = r_val
|
||||
elif values.get(field) and isinstance(values[field], int):
|
||||
# Fallback: Resolve from Redis/DB if missing from view result
|
||||
resolved_rid = get_id_random(values[field], table)
|
||||
if resolved_rid:
|
||||
values[field] = resolved_rid
|
||||
values[f'{field}_random'] = resolved_rid
|
||||
|
||||
# 3. Final Vision Enforcement: Strip internal integers
|
||||
for k in ['id', 'event_exhibit_id', 'account_id', 'event_id', 'organization_id', 'contact_id', 'person_id', 'status_id']:
|
||||
val = values.get(k)
|
||||
if val is not None and not isinstance(val, str):
|
||||
values[k] = None
|
||||
|
||||
return values
|
||||
|
||||
code: Optional[str] # The assigned booth number or ID
|
||||
|
||||
account_id_random: Optional[str]
|
||||
account_id: Optional[int]
|
||||
event_id_random: Optional[str]
|
||||
event_id: Optional[int]
|
||||
organization_id_random: Optional[str]
|
||||
organization_id: Optional[int]
|
||||
contact_id_random: Optional[str]
|
||||
contact_id: Optional[int]
|
||||
|
||||
# Point of Contact person ID
|
||||
person_id_random: Optional[str]
|
||||
person_id: Optional[int]
|
||||
|
||||
status_id_random: Optional[str]
|
||||
status_id: Optional[int]
|
||||
|
||||
staff_passcode: Optional[str]
|
||||
|
||||
name: Optional[str]
|
||||
@@ -97,78 +143,10 @@ class Event_Exhibit_Base(BaseModel):
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
#@validator('event_exhibit_id_random', always=True)
|
||||
def event_exhibit_id_random_copy(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['id_random']:
|
||||
return values['id_random']
|
||||
return None
|
||||
|
||||
@validator('id', always=True)
|
||||
def event_exhibit_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['id_random']:
|
||||
log.debug(values['id_random'])
|
||||
return redis_lookup_id_random(record_id_random=values['id_random'], table_name='event_exhibit')
|
||||
return None
|
||||
|
||||
@validator('account_id', always=True)
|
||||
def account_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['account_id_random']:
|
||||
return redis_lookup_id_random(record_id_random=values['account_id_random'], table_name='account')
|
||||
return None
|
||||
|
||||
@validator('event_id', always=True)
|
||||
def event_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['event_id_random']:
|
||||
return redis_lookup_id_random(record_id_random=values['event_id_random'], table_name='event')
|
||||
return None
|
||||
|
||||
@validator('organization_id', always=True)
|
||||
def organization_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['organization_id_random']:
|
||||
return redis_lookup_id_random(record_id_random=values['organization_id_random'], table_name='organization')
|
||||
return None
|
||||
|
||||
@validator('contact_id', always=True)
|
||||
def contact_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['contact_id_random']:
|
||||
return redis_lookup_id_random(record_id_random=values['contact_id_random'], table_name='contact')
|
||||
return None
|
||||
|
||||
@validator('person_id', always=True)
|
||||
def person_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['person_id_random']:
|
||||
return redis_lookup_id_random(record_id_random=values['person_id_random'], table_name='person')
|
||||
return None
|
||||
|
||||
# @validator('leads_custom_questions_json', always=True)
|
||||
# def leads_custom_questions_json_fix(cls, v, values, **kwargs):
|
||||
# if isinstance(v, str):
|
||||
# return json.loads(v)
|
||||
# # return v.dict()
|
||||
# else:
|
||||
# return v
|
||||
# return None
|
||||
# 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] = [
|
||||
'event_exhibit_tracking_list'
|
||||
]
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
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
|
||||
@@ -17,25 +17,50 @@ class Event_Exhibit_Tracking_Base(BaseModel):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
**base_fields['event_exhibit_tracking_id_random'],
|
||||
alias = 'event_exhibit_tracking_id_random',
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'event_exhibit_tracking_id'
|
||||
)
|
||||
# --- Standardized Vision IDs (Strings for API, Integers/Strings for DB) ---
|
||||
id: Optional[str] = Field(None, **base_fields['event_exhibit_tracking_id_random'])
|
||||
event_exhibit_tracking_id: Optional[str] = Field(None, **base_fields['event_exhibit_tracking_id_random'])
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
|
||||
event_id_random: Optional[str]
|
||||
event_id: Optional[int]
|
||||
event_id: Optional[Union[int, str]] = Field(None, **base_fields['event_id_random'])
|
||||
event_exhibit_id: Optional[Union[int, str]] = Field(None, **base_fields['event_exhibit_id_random'])
|
||||
event_person_id: Optional[Union[int, str]] = Field(None, **base_fields['event_person_id_random'])
|
||||
event_badge_id: Optional[Union[int, str]] = Field(None, **base_fields['event_badge_id_random'])
|
||||
|
||||
event_exhibit_id_random: Optional[str]
|
||||
event_exhibit_id: Optional[int]
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='event_exhibit_tracking_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_exhibit_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_person_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_badge_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
event_person_id_random: Optional[str]
|
||||
event_person_id: Optional[int]
|
||||
|
||||
event_badge_id_random: Optional[str]
|
||||
event_badge_id: Optional[int]
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
if rid := values.get('id_random') or values.get('event_exhibit_tracking_id_random'):
|
||||
values['id'] = rid
|
||||
values['event_exhibit_tracking_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||
if e_rid := values.get('event_id_random'): values['event_id'] = e_rid
|
||||
if ee_rid := values.get('event_exhibit_id_random'): values['event_exhibit_id'] = ee_rid
|
||||
if ep_rid := values.get('event_person_id_random'): values['event_person_id'] = ep_rid
|
||||
if eb_rid := values.get('event_badge_id_random'): values['event_badge_id'] = eb_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
# We only strip integers for the primary IDs and account_id to prevent leak in READ views.
|
||||
# Relational IDs (event_id, exhibit_id, etc.) are allowed to remain as integers during
|
||||
# POST/PUT operations so they reach the database correctly.
|
||||
for k in ['id', 'event_exhibit_tracking_id', 'account_id']:
|
||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
external_person_id: Optional[str] # This is probably an email address
|
||||
|
||||
@@ -168,40 +193,27 @@ class Event_Exhibit_Tracking_Base(BaseModel):
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
@validator('id', always=True)
|
||||
def event_exhibit_tracking_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='event_exhibit_tracking')
|
||||
return None
|
||||
|
||||
@validator('event_id', always=True)
|
||||
def event_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event')
|
||||
return None
|
||||
|
||||
@validator('event_exhibit_id', always=True)
|
||||
def event_exhibit_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_exhibit_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_exhibit')
|
||||
return None
|
||||
|
||||
@validator('event_person_id', always=True)
|
||||
def event_person_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_person_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_person')
|
||||
return None
|
||||
|
||||
@validator('event_badge_id', always=True)
|
||||
def event_badge_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_badge_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_badge')
|
||||
return None
|
||||
# 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] = [
|
||||
'account_id',
|
||||
'event_badge_pronouns', 'event_badge_pronouns_override', 'event_badge_informal_name',
|
||||
'event_badge_title_names', 'event_badge_given_name', 'event_badge_middle_name',
|
||||
'event_badge_family_name', 'event_badge_designations',
|
||||
'event_badge_professional_title', 'event_badge_professional_title_override',
|
||||
'event_badge_full_name', 'event_badge_full_name_override',
|
||||
'event_badge_affiliations', 'event_badge_affiliations_override',
|
||||
'event_badge_email', 'event_badge_email_override',
|
||||
'event_badge_phone', 'event_badge_phone_override',
|
||||
'event_badge_address_line_1', 'event_badge_address_line_2', 'event_badge_address_line_3',
|
||||
'event_badge_city', 'event_badge_county', 'event_badge_country_subdivision_code',
|
||||
'event_badge_state_province', 'event_badge_state_province_abb', 'event_badge_postal_code',
|
||||
'event_badge_country_alpha_2_code', 'event_badge_country',
|
||||
'event_badge_location', 'event_badge_location_override',
|
||||
'event_person_informal_name', 'event_person_given_name', 'event_person_family_name',
|
||||
'event_person_full_name', 'event_person_full_name_override',
|
||||
'event_person_affiliations', 'event_person_email', 'event_exhibit_name',
|
||||
'event_person'
|
||||
]
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator, root_validator
|
||||
|
||||
from app.db_sql import get_id_random, redis_lookup_id_random
|
||||
# from app.lib_general import log, logging
|
||||
@@ -16,38 +16,110 @@ class Event_File_Base(BaseModel):
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
# **base_fields['event_file_id_random'],
|
||||
alias = 'event_file_id_random',
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'event_file_id'
|
||||
)
|
||||
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
|
||||
id: Optional[Union[int, str]] = Field(**base_fields['event_file_id_random'])
|
||||
event_file_id: Optional[Union[int, str]] = Field(**base_fields['event_file_id_random'])
|
||||
account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_id_random'])
|
||||
hosted_file_id: Optional[Union[int, str]] = Field(**base_fields['hosted_file_id_random'])
|
||||
|
||||
# Generic Relational target
|
||||
for_id: Optional[Union[int, str]] = Field(**base_fields['obj_id_random'])
|
||||
|
||||
event_id: Optional[Union[int, str]] = Field(**base_fields['event_id_random'])
|
||||
event_exhibit_id: Optional[Union[int, str]] = Field(**base_fields['event_exhibit_id_random'])
|
||||
event_location_id: Optional[Union[int, str]] = Field(**base_fields['event_location_id_random'])
|
||||
event_presentation_id: Optional[Union[int, str]] = Field(**base_fields['event_presentation_id_random'])
|
||||
event_presenter_id: Optional[Union[int, str]] = Field(**base_fields['event_presenter_id_random'])
|
||||
event_session_id: Optional[Union[int, str]] = Field(**base_fields['event_session_id_random'])
|
||||
event_track_id: Optional[Union[int, str]] = Field(**base_fields['event_track_id_random'])
|
||||
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='event_file_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
hosted_file_id_random: Optional[str] = Field(None, exclude=True)
|
||||
for_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_exhibit_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_location_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_presentation_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_presenter_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_session_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_track_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
# Internal flag to signal the model to load nested hosted_file
|
||||
inc_hosted_file: Optional[bool] = Field(False, exclude=True)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers during READ operations.
|
||||
Falls back to Redis/DB lookups if random string IDs are missing from the view.
|
||||
"""
|
||||
# 1. Map Primary Object ID
|
||||
rid = values.get('id_random') or values.get('event_file_id_random')
|
||||
if rid and isinstance(rid, str):
|
||||
values['id'] = rid
|
||||
values['event_file_id'] = rid
|
||||
|
||||
# 2. Map & Resolve Relational IDs
|
||||
# (Field Name, Table Name)
|
||||
id_map = [
|
||||
('account_id', 'account'),
|
||||
('hosted_file_id', 'hosted_file'),
|
||||
('event_id', 'event'),
|
||||
('event_exhibit_id', 'event_exhibit'),
|
||||
('event_location_id', 'event_location'),
|
||||
('event_presentation_id', 'event_presentation'),
|
||||
('event_presenter_id', 'event_presenter'),
|
||||
('event_session_id', 'event_session'),
|
||||
('event_track_id', 'event_track'),
|
||||
]
|
||||
|
||||
# 2a. Handle specific relational fields
|
||||
for field, table in id_map:
|
||||
# Check for existing random string version
|
||||
r_val = values.get(f'{field}_random')
|
||||
if r_val and isinstance(r_val, str):
|
||||
values[field] = r_val
|
||||
elif values.get(field) and isinstance(values[field], int):
|
||||
# Fallback: Resolve from Redis/DB if missing from view result
|
||||
resolved_rid = get_id_random(values[field], table)
|
||||
if resolved_rid:
|
||||
values[field] = resolved_rid
|
||||
values[f'{field}_random'] = resolved_rid
|
||||
|
||||
# 2b. Handle Polymorphic for_id
|
||||
if f_rid := values.get('for_id_random'):
|
||||
values['for_id'] = f_rid
|
||||
elif values.get('for_id') and isinstance(values.get('for_id'), int) and values.get('for_type'):
|
||||
# Resolve based on the for_type
|
||||
resolved_for_rid = get_id_random(values['for_id'], values['for_type'])
|
||||
if resolved_for_rid:
|
||||
values['for_id'] = resolved_for_rid
|
||||
values['for_id_random'] = resolved_for_rid
|
||||
|
||||
# 3. Final Vision Enforcement: Strip internal integers
|
||||
id_fields = [f for f, t in id_map] + ['id', 'event_file_id', 'for_id']
|
||||
for k in id_fields:
|
||||
val = values.get(k)
|
||||
if val is not None and not isinstance(val, str):
|
||||
values[k] = None
|
||||
|
||||
# 4. Conditionally load nested 'hosted_file' object
|
||||
if values.get('inc_hosted_file') and values.get('hosted_file_id'):
|
||||
from app.methods.hosted_file_methods import load_hosted_file_obj
|
||||
if hosted_file_obj := load_hosted_file_obj(hosted_file_id=values['hosted_file_id']):
|
||||
values['hosted_file'] = hosted_file_obj
|
||||
|
||||
# Clean up internal inc_hosted_file flag after processing
|
||||
if 'inc_hosted_file' in values:
|
||||
del values['inc_hosted_file']
|
||||
|
||||
return values
|
||||
|
||||
hosted_file_id_random: Optional[str]
|
||||
hosted_file_id: Optional[int]
|
||||
|
||||
# NOTE: Handling this outside of the Pydantic model and model validation. See below as well. -STI 2021-09-10
|
||||
for_type: Optional[str]
|
||||
for_id: Optional[int] # NOTE: This is reversed with for_id_random
|
||||
for_id_random: Optional[str] # NOTE: This is reversed with for_id
|
||||
# for_id_random: Optional[str] = None # Need to override value from common_field_schema.py
|
||||
# for_id: Optional[int]
|
||||
|
||||
event_id_random: Optional[str]
|
||||
event_id: Optional[int]
|
||||
event_exhibit_id_random: Optional[str]
|
||||
event_exhibit_id: Optional[int]
|
||||
event_location_id_random: Optional[str]
|
||||
event_location_id: Optional[int]
|
||||
event_presentation_id_random: Optional[str]
|
||||
event_presentation_id: Optional[int]
|
||||
event_presenter_id_random: Optional[str]
|
||||
event_presenter_id: Optional[int]
|
||||
event_session_id_random: Optional[str]
|
||||
event_session_id: Optional[int]
|
||||
event_track_id_random: Optional[str]
|
||||
event_track_id: Optional[int]
|
||||
|
||||
filename: Optional[str]
|
||||
filename_no_ext: Optional[str] # Currently created with a view
|
||||
@@ -56,7 +128,7 @@ class Event_File_Base(BaseModel):
|
||||
title: Optional[str]
|
||||
description: Optional[str]
|
||||
|
||||
lu_file_purpose_id: Optional[int]
|
||||
lu_file_purpose_id: Optional[int] = Field(None, exclude=True)
|
||||
file_purpose: Optional[str]
|
||||
|
||||
# New internal use fields to help with logistics and planning 2022-09-15
|
||||
@@ -85,21 +157,21 @@ class Event_File_Base(BaseModel):
|
||||
created_on: Optional[datetime.datetime] = None
|
||||
updated_on: Optional[datetime.datetime] = None
|
||||
|
||||
# Including convenience data
|
||||
# This is only for convenience. Probably going to keep unless it causes a problem.
|
||||
hosted_file_hash_sha256: Optional[str] = Field(
|
||||
alias = 'hash_sha256'
|
||||
)
|
||||
hosted_file_subdirectory_path: Optional[str] = Field( # NOTE: This will frequently only contain numbers, but it still needs to be a string
|
||||
alias = 'subdirectory_path',
|
||||
exclude = True
|
||||
)
|
||||
hosted_file_content_type: Optional[str] = Field(
|
||||
alias = 'content_type'
|
||||
)
|
||||
hosted_file_size: Optional[str] = Field(
|
||||
alias = 'file_size'
|
||||
)
|
||||
# Including convenience data for Hosted Files (top-level properties)
|
||||
# These fields provide direct access to frequently needed properties from the associated
|
||||
# hosted file, effectively flattening some aspects of the nested 'hosted_file' object.
|
||||
#
|
||||
# IMPORTANT: These fields are designed to be populated directly from the SQL View
|
||||
# (e.g., `v_event_file_simple`) via JOINs. They should **NOT** have Pydantic `alias`
|
||||
# definitions here if the view provides them with matching names (e.g., `hosted_file_hash_sha256`).
|
||||
# Pydantic's default mapping will handle them directly from the incoming data dictionary
|
||||
# (the `sql_result` in `api_crud_v3.py`).
|
||||
# The `root_validator` does **NOT** populate these top-level fields; its role is
|
||||
# solely to conditionally load the *nested* `hosted_file` object.
|
||||
hosted_file_hash_sha256: Optional[str]
|
||||
hosted_file_subdirectory_path: Optional[str]
|
||||
hosted_file_content_type: Optional[str]
|
||||
hosted_file_size: Optional[str]
|
||||
|
||||
lu_event_file_purpose_name: Optional[str] = Field(
|
||||
alias = 'file_purpose_name'
|
||||
@@ -134,98 +206,24 @@ class Event_File_Base(BaseModel):
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
#@validator('event_file_id_random', always=True)
|
||||
def event_file_id_random_copy(cls, v, values, **kwargs):
|
||||
if values['id_random']:
|
||||
return values['id_random']
|
||||
return None
|
||||
|
||||
@validator('id', always=True)
|
||||
def event_file_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='event_file')
|
||||
return None
|
||||
|
||||
@validator('hosted_file_id', always=True)
|
||||
def hosted_file_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('hosted_file_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='hosted_file')
|
||||
return None
|
||||
|
||||
@validator('event_id', always=True)
|
||||
def event_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event')
|
||||
return None
|
||||
|
||||
@validator('event_exhibit_id', always=True)
|
||||
def event_exhibit_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_exhibit_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_exhibit')
|
||||
return None
|
||||
|
||||
@validator('event_location_id', always=True)
|
||||
def event_location_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_location_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_location')
|
||||
return None
|
||||
|
||||
@validator('event_presentation_id', always=True)
|
||||
def event_presentation_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_presentation_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_presentation')
|
||||
return None
|
||||
|
||||
@validator('event_presenter_id', always=True)
|
||||
def event_presenter_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_presenter_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_presenter')
|
||||
return None
|
||||
|
||||
@validator('event_session_id', always=True)
|
||||
def event_session_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_session_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_session')
|
||||
return None
|
||||
|
||||
@validator('event_track_id', always=True)
|
||||
def event_track_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_track_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_track')
|
||||
return None
|
||||
|
||||
# NOTE: I kind of give up on this. Handling this outside of Pydantic and before the data is even attempted to be loaded into the Event_File_Base model. -STI 2021-09-10
|
||||
# NOTE: This validator will try to find and "set" the for_id_random value. However, The value is not really "set" in Pydantic. To get this value, exclude_unset=True when returning a dict from the model.
|
||||
@validator('for_id_random', always=True)
|
||||
def for_id_random_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
if isinstance(v, str): return v
|
||||
elif values.get('for_id') and values['for_type']:
|
||||
return get_id_random(record_id=values['for_id'], table_name=values['for_type'])
|
||||
return None
|
||||
|
||||
# @validator('for_id', always=True)
|
||||
# def for_id_lookup(cls, v, values, **kwargs):
|
||||
# log.setLevel(logging.DEBUG)
|
||||
# log.debug(locals())
|
||||
|
||||
# if values.get('for_id_random', None) and values['for_type']:
|
||||
# return redis_lookup_id_random(record_id_random=values['for_id_random'], table_name=values['for_type'])
|
||||
# # return None
|
||||
# else: return v
|
||||
# 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] = [
|
||||
'account_id', 'filename_no_ext', 'filename_w_ext',
|
||||
'hosted_file_hash_sha256', 'hosted_file_subdirectory_path',
|
||||
'hosted_file_content_type', 'hosted_file_size',
|
||||
'event_name', 'event_code', 'event_start_datetime', 'event_end_datetime',
|
||||
'event_location_code', 'event_location_name', 'event_presentation_code',
|
||||
'event_presentation_type_code', 'event_presentation_name',
|
||||
'event_presentation_start_datetime', 'event_presentation_end_datetime',
|
||||
'event_presenter_code', 'event_presenter_given_name', 'event_presenter_family_name',
|
||||
'event_presenter_full_name', 'event_presenter_email', 'event_session_code',
|
||||
'event_session_type_code', 'event_session_name', 'event_session_start_datetime',
|
||||
'event_session_end_datetime', 'event_track_code', 'event_track_name',
|
||||
'hosted_file'
|
||||
]
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = True
|
||||
allow_population_by_field_name = False
|
||||
fields = base_fields
|
||||
# ### END ### API Event File Models ### Event_File_Base() ###
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator, root_validator
|
||||
|
||||
from app.db_sql import redis_lookup_id_random
|
||||
@@ -19,9 +19,16 @@ class Event_Location_Base(BaseModel):
|
||||
id: Optional[str] = Field(None, **base_fields['event_location_id_random'])
|
||||
event_location_id: Optional[str] = Field(None, **base_fields['event_location_id_random'])
|
||||
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
event_id: Optional[str] = Field(None, **base_fields['event_id_random'])
|
||||
event_track_id: Optional[str] = Field(None, **base_fields['event_track_id_random'])
|
||||
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='event_location_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_track_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
code: Optional[str] = Field(
|
||||
# alias = 'event_location_code'
|
||||
)
|
||||
@@ -108,18 +115,30 @@ class Event_Location_Base(BaseModel):
|
||||
values['id'] = rid
|
||||
values['event_location_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
if e_rid := values.get('event_id_random'):
|
||||
values['event_id'] = e_rid
|
||||
if et_rid := values.get('event_track_id_random'):
|
||||
values['event_track_id'] = et_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'event_location_id', 'event_id', 'event_track_id']:
|
||||
for k in ['id', 'event_location_id', 'account_id', 'event_id', 'event_track_id']:
|
||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
# 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] = [
|
||||
'account_id',
|
||||
'location_type', 'file_count', 'internal_use_count', 'event_file_id_li_json', 'file_count_all',
|
||||
'event_name', 'event_start_datetime', 'event_end_datetime',
|
||||
'event_abstract_list', 'event_device_list', 'event_file_list',
|
||||
'event_file_internal_use_list', 'event_presentation_list',
|
||||
'event_presenter_list', 'event_session_list', 'event_track_list'
|
||||
]
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = False
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
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
|
||||
@@ -22,33 +22,71 @@ class Event_Base(BaseModel):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
**base_fields['event_id_random'],
|
||||
alias = 'event_id_random',
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'event_id'
|
||||
)
|
||||
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
|
||||
# We use Union[int, str] to allow both public string IDs and resolved DB integers to pass validation.
|
||||
id: Optional[Union[int, str]] = Field(None, **base_fields['event_id_random'])
|
||||
event_id: Optional[Union[int, str]] = Field(None, **base_fields['event_id_random'])
|
||||
account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_id_random'])
|
||||
|
||||
poc_event_person_id: Optional[Union[int, str]] = Field(None, **base_fields['event_person_id_random'])
|
||||
poc_person_id: Optional[Union[int, str]] = Field(None, **base_fields['person_id_random'])
|
||||
user_id: Optional[Union[int, str]] = Field(None, **base_fields['user_id_random'])
|
||||
address_location_id: Optional[Union[int, str]] = Field(None, **base_fields['address_id_random'])
|
||||
|
||||
contact_1_id: Optional[Union[int, str]] = Field(None, **base_fields['contact_id_random'])
|
||||
contact_2_id: Optional[Union[int, str]] = Field(None, **base_fields['contact_id_random'])
|
||||
contact_3_id: Optional[Union[int, str]] = Field(None, **base_fields['contact_id_random'])
|
||||
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='event_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
poc_event_person_id_random: Optional[str] = Field(None, exclude=True)
|
||||
poc_person_id_random: Optional[str] = Field(None, exclude=True)
|
||||
user_id_random: Optional[str] = Field(None, exclude=True)
|
||||
address_location_id_random: Optional[str] = Field(None, exclude=True)
|
||||
contact_1_id_random: Optional[str] = Field(None, exclude=True)
|
||||
contact_2_id_random: Optional[str] = Field(None, exclude=True)
|
||||
contact_3_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers during READ operations.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
rid = values.get('id_random') or values.get('event_id_random')
|
||||
if rid and isinstance(rid, str):
|
||||
values['id'] = rid
|
||||
values['event_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||
if pep_rid := values.get('poc_event_person_id_random'): values['poc_event_person_id'] = pep_rid
|
||||
if pp_rid := values.get('poc_person_id_random'): values['poc_person_id'] = pp_rid
|
||||
if u_rid := values.get('user_id_random'): values['user_id'] = u_rid
|
||||
if al_rid := values.get('address_location_id_random'): values['address_location_id'] = al_rid
|
||||
if c1_rid := values.get('contact_1_id_random'): values['contact_1_id'] = c1_rid
|
||||
if c2_rid := values.get('contact_2_id_random'): values['contact_2_id'] = c2_rid
|
||||
if c3_rid := values.get('contact_3_id_random'): values['contact_3_id'] = c3_rid
|
||||
|
||||
# 2. Prevent "Collision Population" or leakage of integers during API responses
|
||||
# WE MUST NOT DELETE these if they are already integers during a POST operation
|
||||
# as they have been resolved by sanitize_payload.
|
||||
for k in ['id', 'event_id', 'account_id', 'poc_event_person_id', 'poc_person_id', 'user_id', 'address_location_id', 'contact_1_id', 'contact_2_id', 'contact_3_id']:
|
||||
val = values.get(k)
|
||||
if val is not None and not isinstance(val, str):
|
||||
if values.get(f'{k}_random') or (k=='id' and values.get('id_random')):
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
code: Optional[str] = Field(
|
||||
alias = 'event_code'
|
||||
)
|
||||
|
||||
account_id_random: Optional[str]
|
||||
account_id: Optional[int]
|
||||
|
||||
poc_event_person_id_random: Optional[str]
|
||||
poc_event_person_id: Optional[int]
|
||||
|
||||
poc_person_id_random: Optional[str]
|
||||
poc_person_id: Optional[int]
|
||||
|
||||
external_person_id: Optional[str] # Person ID generated by external system (should be stable and not change)
|
||||
|
||||
user_id_random: Optional[str]
|
||||
user_id: Optional[int]
|
||||
|
||||
lu_event_type_id: Optional[int]
|
||||
lu_event_type_id: Optional[int] = Field(None, exclude=True)
|
||||
#lu_event_type: Optional[str] # Needs to be reviewed
|
||||
|
||||
conference: Optional[bool] # Also in Event_Cfg_Base model
|
||||
@@ -82,8 +120,6 @@ class Event_Base(BaseModel):
|
||||
weekday_friday: Optional[bool]
|
||||
weekday_saturday: Optional[bool]
|
||||
|
||||
address_location_id_random: Optional[str]
|
||||
address_location_id: Optional[int]
|
||||
location_address_json: Optional[Union[Json, None]]
|
||||
location_text: Optional[str]
|
||||
|
||||
@@ -101,12 +137,6 @@ class Event_Base(BaseModel):
|
||||
physical: Optional[bool] # physical in person event
|
||||
virtual: Optional[bool] # virtual remote access event
|
||||
|
||||
contact_1_id_random: Optional[str]
|
||||
contact_1_id: Optional[int]
|
||||
contact_2_id_random: Optional[str]
|
||||
contact_2_id: Optional[int]
|
||||
contact_3_id_random: Optional[str]
|
||||
contact_3_id: Optional[int]
|
||||
contact_li_json: Optional[Union[Json, None]] # list of dicts (custom for client); this is SQL FULLTEXT() indexed
|
||||
|
||||
attend_url: Optional[str]
|
||||
@@ -141,6 +171,7 @@ class Event_Base(BaseModel):
|
||||
|
||||
cfg_json: Optional[Union[Json, None]] # Store per event config options; Not currently used 2024-06-11
|
||||
data_json: Optional[Union[Json, None]] # For key value data. Careful with overwriting existing fields! Not currently used 2024-06-11
|
||||
default_qry_str: Optional[str] # Default query string used for searching and filtering events. Updated using SQL triggers and a SQL function
|
||||
|
||||
enable: Optional[bool] # Also in Event_Cfg_Base model
|
||||
enable_from: Optional[datetime.datetime] = None
|
||||
@@ -180,95 +211,6 @@ class Event_Base(BaseModel):
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
#@validator('event_id_random', always=True)
|
||||
def event_id_random_copy(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['id_random']:
|
||||
return values['id_random']
|
||||
return None
|
||||
|
||||
@validator('id', always=True)
|
||||
def event_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['id_random']:
|
||||
log.debug(values['id_random'])
|
||||
return redis_lookup_id_random(record_id_random=values['id_random'], table_name='event')
|
||||
return None
|
||||
|
||||
@validator('account_id', always=True)
|
||||
def account_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('account_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='account')
|
||||
return None
|
||||
|
||||
@validator('poc_event_person_id', always=True)
|
||||
def poc_event_person_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['poc_event_person_id_random']:
|
||||
return redis_lookup_id_random(record_id_random=values['poc_event_person_id_random'], table_name='event_person')
|
||||
return None
|
||||
|
||||
@validator('poc_person_id', always=True)
|
||||
def poc_person_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['poc_person_id_random']:
|
||||
return redis_lookup_id_random(record_id_random=values['poc_person_id_random'], table_name='person')
|
||||
return None
|
||||
|
||||
@validator('user_id', always=True)
|
||||
def user_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['user_id_random']:
|
||||
return redis_lookup_id_random(record_id_random=values['user_id_random'], table_name='user')
|
||||
return None
|
||||
|
||||
@validator('address_location_id', always=True)
|
||||
def address_location_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['address_location_id_random']:
|
||||
return redis_lookup_id_random(record_id_random=values['address_location_id_random'], table_name='address')
|
||||
return None
|
||||
|
||||
@validator('contact_1_id', always=True)
|
||||
def contact_1_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['contact_1_id_random']:
|
||||
return redis_lookup_id_random(record_id_random=values['contact_1_id_random'], table_name='contact')
|
||||
return None
|
||||
|
||||
@validator('contact_2_id', always=True)
|
||||
def contact_2_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['contact_2_id_random']:
|
||||
return redis_lookup_id_random(record_id_random=values['contact_2_id_random'], table_name='contact')
|
||||
return None
|
||||
|
||||
@validator('contact_3_id', always=True)
|
||||
def contact_3_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['contact_3_id_random']:
|
||||
return redis_lookup_id_random(record_id_random=values['contact_3_id_random'], table_name='contact')
|
||||
return None
|
||||
|
||||
@validator('created_on', always=True)
|
||||
def created_on_utc(cls, v, values, **kwargs):
|
||||
if isinstance(v, datetime.datetime):
|
||||
@@ -287,6 +229,17 @@ class Event_Base(BaseModel):
|
||||
return str(v)
|
||||
return v
|
||||
|
||||
# Fields that are part of the model (for reading) but should not be saved to the DB table.
|
||||
# These convenience fields and related objects are joined in the view.
|
||||
fields_to_exclude_from_db: ClassVar[list] = [
|
||||
'file_count', 'internal_use_count', 'event_file_id_li_json', 'file_count_all',
|
||||
'address_location', 'contact_1', 'contact_2', 'contact_3',
|
||||
'event_abstract_list', 'event_cfg', 'event_device_list', 'event_exhibit_list',
|
||||
'event_file_list', 'event_location_list', 'event_person_list',
|
||||
'event_presentation_list', 'event_presenter_list', 'event_session_list',
|
||||
'event_track_list', 'poc_person'
|
||||
]
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = True
|
||||
@@ -300,31 +253,68 @@ class Event_Meeting_Flat_Base(BaseModel):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
**base_fields['event_id_random'],
|
||||
alias = 'event_id_random',
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'event_id'
|
||||
)
|
||||
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
|
||||
id: Optional[Union[int, str]] = Field(None, **base_fields['event_id_random'])
|
||||
event_id: Optional[Union[int, str]] = Field(None, **base_fields['event_id_random'])
|
||||
account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_id_random'])
|
||||
|
||||
poc_event_person_id: Optional[Union[int, str]] = Field(None, **base_fields['event_person_id_random'])
|
||||
poc_person_id: Optional[Union[int, str]] = Field(None, **base_fields['person_id_random'])
|
||||
user_id: Optional[Union[int, str]] = Field(None, **base_fields['user_id_random'])
|
||||
address_location_id: Optional[Union[int, str]] = Field(None, **base_fields['address_id_random'])
|
||||
|
||||
contact_1_id: Optional[Union[int, str]] = Field(None, **base_fields['contact_id_random'])
|
||||
contact_2_id: Optional[Union[int, str]] = Field(None, **base_fields['contact_id_random'])
|
||||
contact_3_id: Optional[Union[int, str]] = Field(None, **base_fields['contact_id_random'])
|
||||
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='event_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
poc_event_person_id_random: Optional[str] = Field(None, exclude=True)
|
||||
poc_person_id_random: Optional[str] = Field(None, exclude=True)
|
||||
user_id_random: Optional[str] = Field(None, exclude=True)
|
||||
address_location_id_random: Optional[str] = Field(None, exclude=True)
|
||||
contact_1_id_random: Optional[str] = Field(None, exclude=True)
|
||||
contact_2_id_random: Optional[str] = Field(None, exclude=True)
|
||||
contact_3_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers during READ operations.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
rid = values.get('id_random') or values.get('event_id_random')
|
||||
if rid and isinstance(rid, str):
|
||||
values['id'] = rid
|
||||
values['event_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||
if pep_rid := values.get('poc_event_person_id_random'): values['poc_event_person_id'] = pep_rid
|
||||
if pp_rid := values.get('poc_person_id_random'): values['poc_person_id'] = pp_rid
|
||||
if u_rid := values.get('user_id_random'): values['user_id'] = u_rid
|
||||
if al_rid := values.get('address_location_id_random'): values['address_location_id'] = al_rid
|
||||
if c1_rid := values.get('contact_1_id_random'): values['contact_1_id'] = c1_rid
|
||||
if c2_rid := values.get('contact_2_id_random'): values['contact_2_id'] = c2_rid
|
||||
if c3_rid := values.get('contact_3_id_random'): values['contact_3_id'] = c3_rid
|
||||
|
||||
# 2. Prevent "Collision Population" or leakage of integers during API responses
|
||||
for k in ['id', 'event_id', 'account_id', 'poc_event_person_id', 'poc_person_id', 'user_id', 'address_location_id', 'contact_1_id', 'contact_2_id', 'contact_3_id']:
|
||||
val = values.get(k)
|
||||
if val is not None and not isinstance(val, str):
|
||||
if values.get(f'{k}_random') or (k=='id' and values.get('id_random')):
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
code: Optional[str] = Field(
|
||||
alias = 'event_code'
|
||||
)
|
||||
|
||||
account_id_random: Optional[str]
|
||||
account_id: Optional[int]
|
||||
external_person_id: Optional[str] # Person ID generated by external system (should be stable and not change)
|
||||
|
||||
# poc_event_person_id_random: Optional[str]
|
||||
# poc_event_person_id: Optional[int]
|
||||
|
||||
# poc_person_id_random: Optional[str]
|
||||
# poc_person_id: Optional[int]
|
||||
|
||||
# user_id_random: Optional[str]
|
||||
# user_id: Optional[int]
|
||||
|
||||
lu_event_type_id: Optional[int]
|
||||
lu_event_type_id: Optional[int] = Field(None, exclude=True)
|
||||
#lu_event_type: Optional[str] # Needs to be reviewed
|
||||
|
||||
conference: Optional[bool] # Also in Event_Cfg_Base model
|
||||
@@ -358,8 +348,6 @@ class Event_Meeting_Flat_Base(BaseModel):
|
||||
weekday_friday: Optional[bool]
|
||||
weekday_saturday: Optional[bool]
|
||||
|
||||
address_location_id_random: Optional[str]
|
||||
address_location_id: Optional[int]
|
||||
location_address_json: Optional[Union[Json, None]]
|
||||
location_text: Optional[str]
|
||||
|
||||
@@ -377,14 +365,6 @@ class Event_Meeting_Flat_Base(BaseModel):
|
||||
physical: Optional[bool] # physical in person event
|
||||
virtual: Optional[bool] # virtual remote access event
|
||||
|
||||
external_person_id: Optional[str] # Person ID generated by external system (should be stable and not change)
|
||||
|
||||
contact_1_id_random: Optional[str]
|
||||
contact_1_id: Optional[int]
|
||||
contact_2_id_random: Optional[str]
|
||||
contact_2_id: Optional[int]
|
||||
contact_3_id_random: Optional[str]
|
||||
contact_3_id: Optional[int]
|
||||
contact_li_json: Optional[Union[Json, None]] # list of dicts (custom for client); this is SQL FULLTEXT() indexed
|
||||
|
||||
attend_url: Optional[str]
|
||||
@@ -417,6 +397,7 @@ class Event_Meeting_Flat_Base(BaseModel):
|
||||
|
||||
cfg_json: Optional[Union[Json, None]] # Store per event config options; Not currently used 2024-06-11
|
||||
data_json: Optional[Union[Json, None]] # For key value data. Careful with overwriting existing fields! Not currently used 2024-06-11
|
||||
default_qry_str: Optional[str] # Default query string used for searching and filtering events. Updated using SQL triggers and a SQL function
|
||||
|
||||
enable: Optional[bool] # Also in Event_Cfg_Base model
|
||||
enable_from: Optional[datetime.datetime] = None
|
||||
@@ -431,9 +412,11 @@ class Event_Meeting_Flat_Base(BaseModel):
|
||||
created_on: Optional[datetime.datetime] = None
|
||||
updated_on: Optional[datetime.datetime] = None
|
||||
|
||||
# Including convenience data
|
||||
address_id_random: Optional[str]
|
||||
address_id: Optional[int]
|
||||
# --- IDAA Recovery Meetings: Convenience Data (Flat) ---
|
||||
# These fields are primarily for the flat "Meeting" view used by the IDAA mobile/web apps.
|
||||
# Note: We prioritize string IDs (id_random) for all external API consumers.
|
||||
|
||||
address_id_random: Optional[str] = Field(None, **base_fields['address_id_random'])
|
||||
address_name: Optional[str]
|
||||
address_line_1: Optional[str]
|
||||
address_line_2: Optional[str]
|
||||
@@ -445,8 +428,6 @@ class Event_Meeting_Flat_Base(BaseModel):
|
||||
address_country_alpha_2_code: Optional[str]
|
||||
address_country_name: Optional[str]
|
||||
|
||||
contact_1_id_random: Optional[str]
|
||||
contact_1_id: Optional[int]
|
||||
contact_1_name: Optional[str] # Avoid using or use as something different?
|
||||
contact_1_full_name: Optional[str] # Yes... it is the same as "name"
|
||||
contact_1_email: Optional[str]
|
||||
@@ -458,8 +439,6 @@ class Event_Meeting_Flat_Base(BaseModel):
|
||||
contact_1_phone_other: Optional[str]
|
||||
contact_1_other_text: Optional[str]
|
||||
|
||||
contact_2_id_random: Optional[str]
|
||||
contact_2_id: Optional[int]
|
||||
contact_2_name: Optional[str] # Avoid using or use as something different?
|
||||
contact_2_full_name: Optional[str] # Yes... it is the same as "name"
|
||||
contact_2_email: Optional[str]
|
||||
@@ -473,70 +452,6 @@ class Event_Meeting_Flat_Base(BaseModel):
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
#@validator('event_id_random', always=True)
|
||||
def event_id_random_copy(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['id_random']:
|
||||
return values['id_random']
|
||||
return None
|
||||
|
||||
@validator('id', always=True)
|
||||
def event_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['id_random']:
|
||||
log.debug(values['id_random'])
|
||||
return redis_lookup_id_random(record_id_random=values['id_random'], table_name='event')
|
||||
return None
|
||||
|
||||
# @validator('account_id', always=True)
|
||||
# def account_id_lookup(cls, v, values, **kwargs):
|
||||
# log.setLevel(logging.WARNING)
|
||||
# log.debug(locals())
|
||||
|
||||
# if values['account_id_random']:
|
||||
# return redis_lookup_id_random(record_id_random=values['account_id_random'], table_name='account')
|
||||
# return None
|
||||
|
||||
# @validator('address_id', always=True)
|
||||
# def address_id_lookup(cls, v, values, **kwargs):
|
||||
# log.setLevel(logging.WARNING)
|
||||
# log.debug(locals())
|
||||
|
||||
# if values['address_id_random']:
|
||||
# return redis_lookup_id_random(record_id_random=values['address_id_random'], table_name='address')
|
||||
# return None
|
||||
|
||||
# @validator('contact_1_id', always=True)
|
||||
# def contact_1_id_lookup(cls, v, values, **kwargs):
|
||||
# log.setLevel(logging.WARNING)
|
||||
# log.debug(locals())
|
||||
|
||||
# if values['contact_1_id_random']:
|
||||
# return redis_lookup_id_random(record_id_random=values['contact_1_id_random'], table_name='contact')
|
||||
# return None
|
||||
|
||||
# @validator('contact_2_id', always=True)
|
||||
# def contact_2_id_lookup(cls, v, values, **kwargs):
|
||||
# log.setLevel(logging.WARNING)
|
||||
# log.debug(locals())
|
||||
|
||||
# if values['contact_2_id_random']:
|
||||
# return redis_lookup_id_random(record_id_random=values['contact_2_id_random'], table_name='contact')
|
||||
# return None
|
||||
|
||||
# @validator('contact_3_id', always=True)
|
||||
# def contact_3_id_lookup(cls, v, values, **kwargs):
|
||||
# log.setLevel(logging.WARNING)
|
||||
# log.debug(locals())
|
||||
|
||||
# if values['contact_3_id_random']:
|
||||
# return redis_lookup_id_random(record_id_random=values['contact_3_id_random'], table_name='contact')
|
||||
# return None
|
||||
|
||||
@validator('created_on', always=True)
|
||||
def created_on_utc(cls, v, values, **kwargs):
|
||||
if isinstance(v, datetime.datetime):
|
||||
@@ -549,8 +464,22 @@ class Event_Meeting_Flat_Base(BaseModel):
|
||||
return v.astimezone(pytz.UTC).isoformat()
|
||||
else: return v
|
||||
|
||||
# Fields that are part of the model (for reading) but should not be saved to the DB table.
|
||||
# These convenience fields are joined in the view.
|
||||
fields_to_exclude_from_db: ClassVar[list] = [
|
||||
'address_name', 'address_line_1', 'address_line_2', 'address_line_3', 'address_city',
|
||||
'address_country_subdivision_code', 'address_country_subdivision_name', 'address_postal_code',
|
||||
'address_country_alpha_2_code', 'address_country_name',
|
||||
'contact_1_name', 'contact_1_full_name', 'contact_1_email', 'contact_1_phone_mobile',
|
||||
'contact_1_phone_home', 'contact_1_phone_office', 'contact_1_phone_land', 'contact_1_phone_fax',
|
||||
'contact_1_phone_other', 'contact_1_other_text',
|
||||
'contact_2_name', 'contact_2_full_name', 'contact_2_email', 'contact_2_phone_mobile',
|
||||
'contact_2_phone_home', 'contact_2_phone_office', 'contact_2_phone_land', 'contact_2_phone_fax',
|
||||
'contact_2_phone_other', 'contact_2_other_text'
|
||||
]
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = True
|
||||
fields = base_fields
|
||||
# ### END ### API Event Models ### Event_Meeting_Flat_Base() ###
|
||||
# ### END ### API Event Models ### Event_Meeting_Flat_Base() ###
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
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
|
||||
@@ -22,40 +22,33 @@ class Event_Person_Base(BaseModel):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
# **base_fields['event_person_id_random'],
|
||||
alias = 'event_person_id_random',
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'event_person_id'
|
||||
)
|
||||
# --- Standardized Vision IDs (Strings) ---
|
||||
id: Optional[str] = Field(None, **base_fields['event_person_id_random'])
|
||||
event_person_id: Optional[str] = Field(None, **base_fields['event_person_id_random'])
|
||||
|
||||
account_id_random: Optional[str]
|
||||
account_id: Optional[int]
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
event_id: Optional[str] = Field(None, **base_fields['event_id_random'])
|
||||
|
||||
event_id_random: Optional[str]
|
||||
event_id: Optional[int]
|
||||
event_badge_id: Optional[str] = Field(None, **base_fields['event_badge_id_random'])
|
||||
event_badge_vendor_id: Optional[str] = Field(None, **base_fields['event_badge_id_random'])
|
||||
event_badge_vip_id: Optional[str] = Field(None, **base_fields['event_badge_id_random'])
|
||||
|
||||
event_badge_id_random: Optional[str] # Default attendee badge
|
||||
event_badge_id: Optional[int]
|
||||
event_person_profile_id: Optional[str] = Field(None, **base_fields['event_person_profile_id_random'])
|
||||
event_registration_id: Optional[str] = Field(None, **base_fields['event_registration_id_random'])
|
||||
person_id: Optional[str] = Field(None, **base_fields['person_id_random'])
|
||||
user_id: Optional[str] = Field(None, **base_fields['user_id_random'])
|
||||
|
||||
event_badge_vendor_id_random: Optional[str] # Additional vendor badge
|
||||
event_badge_vendor_id: Optional[int]
|
||||
|
||||
event_badge_vip_id_random: Optional[str] # Additional VIP badge
|
||||
event_badge_vip_id: Optional[int]
|
||||
|
||||
event_person_profile_id_random: Optional[str]
|
||||
event_person_profile_id: Optional[int]
|
||||
|
||||
event_registration_id_random: Optional[str]
|
||||
event_registration_id: Optional[int]
|
||||
|
||||
person_id_random: Optional[str]
|
||||
person_id: Optional[int]
|
||||
|
||||
user_id_random: Optional[str]
|
||||
user_id: Optional[int]
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='event_person_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_badge_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_badge_vendor_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_badge_vip_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_person_profile_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_registration_id_random: Optional[str] = Field(None, exclude=True)
|
||||
person_id_random: Optional[str] = Field(None, exclude=True)
|
||||
user_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
external_id: Optional[str] # Generated internally or externally. Needs to be stable. It should not change.
|
||||
external_event_id: Optional[str] # Event ID generated by external system. Needs to be stable. It should not change.
|
||||
@@ -159,81 +152,54 @@ class Event_Person_Base(BaseModel):
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
#@validator('event_person_id_random', always=True)
|
||||
def event_person_id_random_copy(cls, v, values, **kwargs):
|
||||
if values['id_random']:
|
||||
return values['id_random']
|
||||
return None
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
if rid := values.get('id_random') or values.get('event_person_id_random'):
|
||||
values['id'] = rid
|
||||
values['event_person_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||
if e_rid := values.get('event_id_random'): values['event_id'] = e_rid
|
||||
if b_rid := values.get('event_badge_id_random'): values['event_badge_id'] = b_rid
|
||||
if bv_rid := values.get('event_badge_vendor_id_random'): values['event_badge_vendor_id'] = bv_rid
|
||||
if bvip_rid := values.get('event_badge_vip_id_random'): values['event_badge_vip_id'] = bvip_rid
|
||||
if ep_rid := values.get('event_person_profile_id_random'): values['event_person_profile_id'] = ep_rid
|
||||
if er_rid := values.get('event_registration_id_random'): values['event_registration_id'] = er_rid
|
||||
if p_rid := values.get('person_id_random'): values['person_id'] = p_rid
|
||||
if u_rid := values.get('user_id_random'): values['user_id'] = u_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'event_person_id', 'account_id', 'event_id', 'event_badge_id', 'event_badge_vendor_id', 'event_badge_vip_id', 'event_person_profile_id', 'event_registration_id', 'person_id', 'user_id']:
|
||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
@validator('id', always=True)
|
||||
def event_person_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='event_person')
|
||||
return None
|
||||
|
||||
@validator('account_id', always=True)
|
||||
def account_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('account_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='account')
|
||||
return None
|
||||
|
||||
@validator('event_id', always=True)
|
||||
def event_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event')
|
||||
return None
|
||||
|
||||
@validator('event_badge_id', always=True)
|
||||
def event_badge_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_badge_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_badge')
|
||||
return None
|
||||
|
||||
@validator('event_badge_vendor_id', always=True)
|
||||
def event_badge_vendor_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_badge_vendor_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_badge')
|
||||
return None
|
||||
|
||||
@validator('event_badge_vip_id', always=True)
|
||||
def event_badge_vip_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_badge_vip_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_badge')
|
||||
return None
|
||||
|
||||
@validator('event_person_profile_id', always=True)
|
||||
def event_person_profile_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_person_profile_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_person_profile')
|
||||
return None
|
||||
|
||||
@validator('event_registration_id', always=True)
|
||||
def event_registration_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_registration_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_registration')
|
||||
return None
|
||||
|
||||
@validator('person_id', always=True)
|
||||
def person_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('person_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='person')
|
||||
return None
|
||||
|
||||
@validator('user_id', always=True)
|
||||
def user_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('user_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='user')
|
||||
return None
|
||||
# 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] = [
|
||||
'file_count',
|
||||
'informal_name', 'given_name', 'middle_name', 'family_name', 'full_name_override', 'full_name',
|
||||
'affiliations', 'email', 'website_url',
|
||||
'event_badge_informal_name', 'event_badge_given_name', 'event_badge_middle_name',
|
||||
'event_badge_family_name', 'event_badge_full_name', 'event_badge_full_name_override',
|
||||
'event_badge_affiliations', 'event_badge_email', 'event_badge_city',
|
||||
'event_badge_state_province', 'event_badge_country_alpha_2_code', 'event_badge_country',
|
||||
'event_person_informal_name', 'event_person_given_name', 'event_person_middle_name',
|
||||
'event_person_family_name', 'event_person_name_override', 'event_person_full_name',
|
||||
'event_person_affiliations', 'event_person_email', 'event_person_extended_json',
|
||||
'person_informal_name', 'person_given_name', 'person_middle_name', 'person_family_name',
|
||||
'person_full_name', 'person_full_name_override', 'person_affiliations', 'person_email',
|
||||
'user_email', 'user_name', 'user_username',
|
||||
'event_badge', 'event_badge_vendor', 'event_badge_vip', 'event_exhibit_list',
|
||||
'event_file_list', 'event_location_list', 'event_person_profile',
|
||||
'event_presentation_list', 'event_presenter_list', 'event_registration',
|
||||
'event_session', 'event_track', 'person', 'user'
|
||||
]
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
@@ -247,17 +213,17 @@ class Event_Person_New_Base(BaseModel):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
**base_fields['event_person_id_random'],
|
||||
alias = 'event_person_id_random',
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'event_person_id'
|
||||
)
|
||||
account_id_random: Optional[str]
|
||||
account_id: Optional[int]
|
||||
event_id_random: Optional[str]
|
||||
event_id: Optional[int]
|
||||
# --- Standardized Vision IDs (Strings) ---
|
||||
id: Optional[str] = Field(None, **base_fields['event_person_id_random'])
|
||||
event_person_id: Optional[str] = Field(None, **base_fields['event_person_id_random'])
|
||||
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
event_id: Optional[str] = Field(None, **base_fields['event_id_random'])
|
||||
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='event_person_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
extended_json: Optional[Union[Json, None]]
|
||||
|
||||
@@ -319,32 +285,43 @@ class Event_Person_New_Base(BaseModel):
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
#@validator('event_person_id_random', always=True)
|
||||
def event_person_id_random_copy(cls, v, values, **kwargs):
|
||||
if values['id_random']:
|
||||
return values['id_random']
|
||||
return None
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
if rid := values.get('id_random') or values.get('event_person_id_random'):
|
||||
values['id'] = rid
|
||||
values['event_person_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
if e_rid := values.get('event_id_random'):
|
||||
values['event_id'] = e_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'event_person_id', 'account_id', 'event_id']:
|
||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
@validator('id', always=True)
|
||||
def event_person_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='event_person')
|
||||
return None
|
||||
|
||||
@validator('account_id', always=True)
|
||||
def account_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('account_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='account')
|
||||
return None
|
||||
|
||||
@validator('event_id', always=True)
|
||||
def event_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event')
|
||||
return None
|
||||
# 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] = [
|
||||
'informal_name', 'given_name', 'middle_name', 'family_name', 'full_name',
|
||||
'full_name_override', 'affiliations', 'email', 'website_url', 'state_province_name',
|
||||
'event_badge_informal_name', 'event_badge_given_name', 'event_badge_middle_name',
|
||||
'event_badge_family_name', 'event_badge_full_name', 'event_badge_full_name_override',
|
||||
'event_badge_affiliations', 'event_badge_email', 'event_badge_city',
|
||||
'event_badge_state_province', 'event_badge_country_alpha_2_code', 'event_badge_country',
|
||||
'event_person_informal_name', 'event_person_given_name', 'event_person_middle_name',
|
||||
'event_person_family_name', 'event_person_name_override', 'event_person_full_name',
|
||||
'event_person_affiliations', 'event_person_email',
|
||||
'person_given_name', 'person_middle_name', 'person_family_name',
|
||||
'person_full_name', 'person_full_name_override', 'new_password'
|
||||
]
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
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
|
||||
@@ -17,28 +17,23 @@ class Event_Person_Profile_Base(BaseModel):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
**base_fields['event_person_profile_id_random'],
|
||||
alias = 'event_person_profile_id_random',
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'event_person_profile_id'
|
||||
)
|
||||
# --- Standardized Vision IDs (Strings) ---
|
||||
id: Optional[str] = Field(None, **base_fields['event_person_profile_id_random'])
|
||||
event_person_profile_id: Optional[str] = Field(None, **base_fields['event_person_profile_id_random'])
|
||||
|
||||
account_id_random: Optional[str]
|
||||
account_id: Optional[int]
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
contact_id: Optional[str] = Field(None, **base_fields['contact_id_random'])
|
||||
event_id: Optional[str] = Field(None, **base_fields['event_id_random'])
|
||||
event_person_id: Optional[str] = Field(None, **base_fields['event_person_id_random'])
|
||||
organization_id: Optional[str] = Field(None, **base_fields['organization_id_random'])
|
||||
|
||||
contact_id_random: Optional[str]
|
||||
contact_id: Optional[int]
|
||||
|
||||
event_id_random: Optional[str] # Only in view
|
||||
event_id: Optional[int] # Only in view
|
||||
|
||||
event_person_id_random: Optional[str] # Only in view
|
||||
event_person_id: Optional[int] # Only in view
|
||||
|
||||
organization_id_random: Optional[str]
|
||||
organization_id: Optional[int]
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='event_person_profile_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
contact_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_person_id_random: Optional[str] = Field(None, exclude=True)
|
||||
organization_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
pronouns: Optional[str] # Preferred pronouns
|
||||
informal_name: Optional[str]
|
||||
@@ -65,21 +60,18 @@ class Event_Person_Profile_Base(BaseModel):
|
||||
email: Optional[str]
|
||||
website_url: Optional[str]
|
||||
|
||||
thumbnail_hosted_file_id: Optional[int]
|
||||
thumbnail_hosted_file_id_random: Optional[str]
|
||||
thumbnail_hosted_file_id: Optional[str] = Field(None, **base_fields['hosted_file_id_random'])
|
||||
thumbnail_path: Optional[str]
|
||||
thumbnail_bg_color: Optional[str]
|
||||
|
||||
# photo_path: Optional[str]
|
||||
# photo_bg_color: Optional[str]
|
||||
|
||||
picture_hosted_file_id: Optional[int]
|
||||
picture_hosted_file_id_random: Optional[str]
|
||||
picture_hosted_file_id: Optional[str] = Field(None, **base_fields['hosted_file_id_random'])
|
||||
picture_path: Optional[str]
|
||||
picture_bg_color: Optional[str]
|
||||
|
||||
about_hosted_file_id: Optional[int]
|
||||
about_hosted_file_id_random: Optional[str]
|
||||
about_hosted_file_id: Optional[str] = Field(None, **base_fields['hosted_file_id_random'])
|
||||
about_path: Optional[str]
|
||||
|
||||
email_allowed: Optional[bool]
|
||||
@@ -110,66 +102,37 @@ class Event_Person_Profile_Base(BaseModel):
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
#@validator('event_person_profile_id_random', always=True)
|
||||
def event_person_profile_id_random_copy(cls, v, values, **kwargs):
|
||||
if values['id_random']:
|
||||
return values['id_random']
|
||||
return None
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
if rid := values.get('id_random') or values.get('event_person_profile_id_random'):
|
||||
values['id'] = rid
|
||||
values['event_person_profile_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||
if c_rid := values.get('contact_id_random'): values['contact_id'] = c_rid
|
||||
if e_rid := values.get('event_id_random'): values['event_id'] = e_rid
|
||||
if ep_rid := values.get('event_person_id_random'): values['event_person_id'] = ep_rid
|
||||
if o_rid := values.get('organization_id_random'): values['organization_id'] = o_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'event_person_profile_id', 'account_id', 'contact_id', 'event_id', 'event_person_id', 'organization_id']:
|
||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
@validator('id', always=True)
|
||||
def event_person_profile_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='event_person_profile')
|
||||
return None
|
||||
|
||||
@validator('account_id', always=True)
|
||||
def account_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('account_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='account')
|
||||
return None
|
||||
|
||||
@validator('contact_id', always=True)
|
||||
def contact_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('contact_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='contact')
|
||||
return None
|
||||
|
||||
@validator('organization_id', always=True)
|
||||
def organization_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('organization_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='organization')
|
||||
return None
|
||||
|
||||
@validator('thumbnail_hosted_file_id', always=True)
|
||||
def thumbnail_hosted_file_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values.get('thumbnail_hosted_file_id_random', None):
|
||||
return redis_lookup_id_random(record_id_random=values['thumbnail_hosted_file_id_random'], table_name='thumbnail_hosted_file')
|
||||
return None
|
||||
|
||||
@validator('picture_hosted_file_id', always=True)
|
||||
def picture_hosted_file_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values.get('picture_hosted_file_id_random', None):
|
||||
return redis_lookup_id_random(record_id_random=values['picture_hosted_file_id_random'], table_name='picture_hosted_file')
|
||||
return None
|
||||
|
||||
@validator('about_hosted_file_id', always=True)
|
||||
def about_hosted_file_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values.get('about_hosted_file_id_random', None):
|
||||
return redis_lookup_id_random(record_id_random=values['about_hosted_file_id_random'], table_name='about_hosted_file')
|
||||
return None
|
||||
# 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] = [
|
||||
'account_id', 'event_id', 'event_person_id',
|
||||
'full_name', 'full_name_override', 'display_name',
|
||||
'thumbnail_path', 'picture_path', 'about_path',
|
||||
'contact', 'event_cfg', 'organization'
|
||||
]
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
import datetime, hashlib, logging, os, pytz, redis, secrets
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
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
|
||||
@@ -14,26 +14,69 @@ class Event_Person_Tracking_Base(BaseModel):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
**base_fields['event_person_tracking_id_random'],
|
||||
alias = 'event_person_tracking_id_random',
|
||||
default_factory = lambda:secrets.token_urlsafe(default_num_bytes),
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'event_person_tracking_id'
|
||||
)
|
||||
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
|
||||
id: Optional[Union[int, str]] = Field(**base_fields['event_person_tracking_id_random'])
|
||||
event_person_tracking_id: Optional[Union[int, str]] = Field(**base_fields['event_person_tracking_id_random'])
|
||||
account_id: Optional[Union[int, str]] = Field(**base_fields['account_id_random'])
|
||||
event_id: Optional[Union[int, str]] = Field(**base_fields['event_id_random'])
|
||||
event_session_id: Optional[Union[int, str]] = Field(**base_fields['event_session_id_random'])
|
||||
event_person_id: Optional[Union[int, str]] = Field(**base_fields['event_person_id_random'])
|
||||
|
||||
# account_id_random: Optional[str]
|
||||
# account_id: Optional[int]
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='event_person_tracking_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_session_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_person_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
event_id_random: Optional[str]
|
||||
event_id: Optional[int]
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers during READ operations.
|
||||
Falls back to Redis/DB lookups if random string IDs are missing from the view.
|
||||
"""
|
||||
from app.db_sql import get_id_random
|
||||
|
||||
event_session_id_random: Optional[str]
|
||||
event_session_id: Optional[int]
|
||||
# 1. Map Primary Object ID
|
||||
rid = values.get('id_random') or values.get('event_person_tracking_id_random')
|
||||
if rid and isinstance(rid, str):
|
||||
values['id'] = rid
|
||||
values['event_person_tracking_id'] = rid
|
||||
elif values.get('id') and isinstance(values.get('id'), int):
|
||||
# Fallback for primary ID
|
||||
resolved_rid = get_id_random(values['id'], 'event_person_tracking')
|
||||
if resolved_rid:
|
||||
values['id'] = resolved_rid
|
||||
values['event_person_tracking_id'] = resolved_rid
|
||||
values['id_random'] = resolved_rid
|
||||
|
||||
event_person_id_random: Optional[str]
|
||||
event_person_id: Optional[int]
|
||||
# 2. Map & Resolve Relational IDs
|
||||
id_map = [
|
||||
('account_id', 'account'),
|
||||
('event_id', 'event'),
|
||||
('event_session_id', 'event_session'),
|
||||
('event_person_id', 'event_person'),
|
||||
]
|
||||
|
||||
for field, table in id_map:
|
||||
r_val = values.get(f'{field}_random')
|
||||
if r_val and isinstance(r_val, str):
|
||||
values[field] = r_val
|
||||
elif values.get(field) and isinstance(values[field], int):
|
||||
# Fallback: Resolve from Redis/DB if missing from view result
|
||||
resolved_rid = get_id_random(values[field], table)
|
||||
if resolved_rid:
|
||||
values[field] = resolved_rid
|
||||
values[f'{field}_random'] = resolved_rid
|
||||
|
||||
# 3. Final Vision Enforcement: Strip internal integers
|
||||
for k in ['id', 'event_person_tracking_id', 'account_id', 'event_id', 'event_session_id', 'event_person_id']:
|
||||
val = values.get(k)
|
||||
if val is not None and not isinstance(val, str):
|
||||
values[k] = None
|
||||
|
||||
return values
|
||||
|
||||
check_in_out: Optional[bool]
|
||||
break_in_out: Optional[bool]
|
||||
@@ -43,15 +86,11 @@ class Event_Person_Tracking_Base(BaseModel):
|
||||
in_datetime: Optional[datetime.datetime] # This should generally default to the created datetime and be overridden as needed
|
||||
out_datetime: Optional[datetime.datetime] # This should generally default to the updated datetime and be overridden as needed
|
||||
|
||||
# Maybe add minutes or hours?
|
||||
# Maybe add timezone?
|
||||
|
||||
|
||||
check_in: Optional[bool] # Does this make sense to use instead?
|
||||
break_out: Optional[bool] # Does this make sense to use instead?
|
||||
break_in: Optional[bool] # Does this make sense to use instead?
|
||||
check_out: Optional[bool] # Does this make sense to use instead?
|
||||
datetime: Optional[datetime.datetime] # This should generally default to the created datetime and be overridden as needed
|
||||
check_in: Optional[bool]
|
||||
break_out: Optional[bool]
|
||||
break_in: Optional[bool]
|
||||
check_out: Optional[bool]
|
||||
datetime: Optional[datetime.datetime]
|
||||
|
||||
enable: Optional[bool]
|
||||
|
||||
@@ -60,14 +99,6 @@ class Event_Person_Tracking_Base(BaseModel):
|
||||
updated_on: Optional[datetime.datetime] = None
|
||||
|
||||
# Including convenience data
|
||||
# This is only for convenience. Probably going to keep unless it causes a problem.
|
||||
# full_name: Optional[str] = Field(
|
||||
# alias = 'event_person_full_name'
|
||||
# )
|
||||
# display_name: Optional[str] = Field(
|
||||
# alias = 'event_person_display_name'
|
||||
# )
|
||||
|
||||
event_person_informal_name: Optional[str]
|
||||
event_person_given_name: Optional[str]
|
||||
event_person_family_name: Optional[str]
|
||||
@@ -83,57 +114,10 @@ class Event_Person_Tracking_Base(BaseModel):
|
||||
track_name: Optional[str] = Field(
|
||||
alias = 'event_track_name'
|
||||
)
|
||||
# Maybe add timezone in the future?
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
#@validator('event_person_tracking_id_random', always=True)
|
||||
def event_person_tracking_id_random_copy(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['id_random']:
|
||||
return values['id_random']
|
||||
return None
|
||||
|
||||
@validator('id', always=True)
|
||||
def event_person_tracking_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values.get('id_random', None):
|
||||
log.debug(values['id_random'])
|
||||
return redis_lookup_id_random(record_id_random=values['id_random'], table_name='event_person_tracking')
|
||||
return None
|
||||
|
||||
@validator('event_id', always=True)
|
||||
def event_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values.get('event_id_random', None):
|
||||
return redis_lookup_id_random(record_id_random=values['event_id_random'], table_name='event')
|
||||
return None
|
||||
|
||||
@validator('event_session_id', always=True)
|
||||
def event_session_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values.get('event_session_id_random', None):
|
||||
return redis_lookup_id_random(record_id_random=values['event_session_id_random'], table_name='event_session')
|
||||
return None
|
||||
|
||||
@validator('event_person_id', always=True)
|
||||
def event_person_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values.get('event_person_id_random', None):
|
||||
return redis_lookup_id_random(record_id_random=values['event_person_id_random'], table_name='event_person')
|
||||
return None
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = True
|
||||
fields = base_fields
|
||||
fields = base_fields
|
||||
@@ -1,6 +1,6 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator, root_validator
|
||||
|
||||
from app.db_sql import redis_lookup_id_random
|
||||
@@ -22,12 +22,22 @@ class Event_Presentation_Base(BaseModel):
|
||||
id: Optional[str] = Field(None, **base_fields['event_presentation_id_random'])
|
||||
event_presentation_id: Optional[str] = Field(None, **base_fields['event_presentation_id_random'])
|
||||
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
event_id: Optional[str] = Field(None, **base_fields['event_id_random'])
|
||||
event_abstract_id: Optional[str] = Field(None, **base_fields['event_abstract_id_random'])
|
||||
event_location_id: Optional[str] = Field(None, **base_fields['event_location_id_random'])
|
||||
event_session_id: Optional[str] = Field(None, **base_fields['event_session_id_random'])
|
||||
event_track_id: Optional[str] = Field(None, **base_fields['event_track_id_random'])
|
||||
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='event_presentation_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_abstract_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_location_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_session_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_track_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
external_id: Optional[str] = Field(
|
||||
# alias = 'event_presentation_external_id'
|
||||
)
|
||||
@@ -109,6 +119,8 @@ class Event_Presentation_Base(BaseModel):
|
||||
values['id'] = rid
|
||||
values['event_presentation_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
if e_rid := values.get('event_id_random'):
|
||||
values['event_id'] = e_rid
|
||||
if ea_rid := values.get('event_abstract_id_random'):
|
||||
@@ -121,12 +133,22 @@ class Event_Presentation_Base(BaseModel):
|
||||
values['event_track_id'] = et_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'event_presentation_id', 'event_id', 'event_abstract_id', 'event_location_id', 'event_session_id', 'event_track_id']:
|
||||
for k in ['id', 'event_presentation_id', 'account_id', 'event_id', 'event_abstract_id', 'event_location_id', 'event_session_id', 'event_track_id']:
|
||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
# 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] = [
|
||||
'account_id', 'file_count',
|
||||
'event_name', 'event_start_datetime', 'event_end_datetime', 'event_location_name',
|
||||
'event_session_type_code', 'event_session_name', 'event_session_start_datetime',
|
||||
'event_session_end_datetime', 'event_track_name',
|
||||
'poc_event_person', 'poc_person', 'event_abstract_list', 'event_file_list',
|
||||
'event_presenter_list', 'event_session'
|
||||
]
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = False
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
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
|
||||
@@ -19,42 +19,32 @@ class Event_Presenter_Base(BaseModel):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
# **base_fields['event_presenter_id_random'],
|
||||
alias = 'event_presenter_id_random',
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'event_presenter_id'
|
||||
)
|
||||
# --- Standardized Vision IDs (Strings) ---
|
||||
id: Optional[str] = Field(None, **base_fields['event_presenter_id_random'])
|
||||
event_presenter_id: Optional[str] = Field(None, **base_fields['event_presenter_id_random'])
|
||||
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
event_id: Optional[str] = Field(None, **base_fields['event_id_random'])
|
||||
event_person_id: Optional[str] = Field(None, **base_fields['event_person_id_random'])
|
||||
event_presentation_id: Optional[str] = Field(None, **base_fields['event_presentation_id_random'])
|
||||
event_session_id: Optional[str] = Field(None, **base_fields['event_session_id_random'])
|
||||
event_track_id: Optional[str] = Field(None, **base_fields['event_track_id_random'])
|
||||
person_id: Optional[str] = Field(None, **base_fields['person_id_random'])
|
||||
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='event_presenter_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_person_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_presentation_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_session_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_track_id_random: Optional[str] = Field(None, exclude=True)
|
||||
person_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
external_id: Optional[str]
|
||||
|
||||
code: Optional[str]
|
||||
|
||||
event_id_random: Optional[str]
|
||||
event_id: Optional[int]
|
||||
|
||||
# event_abstract_id_random: Optional[str]
|
||||
# event_abstract_id: Optional[int]
|
||||
|
||||
event_location_id_random: Optional[str]
|
||||
event_location_id: Optional[int]
|
||||
|
||||
event_person_id_random: Optional[str]
|
||||
event_person_id: Optional[int]
|
||||
|
||||
event_presentation_id_random: Optional[str]
|
||||
event_presentation_id: Optional[int]
|
||||
|
||||
event_session_id_random: Optional[str]
|
||||
event_session_id: Optional[int]
|
||||
|
||||
event_track_id_random: Optional[str]
|
||||
event_track_id: Optional[int]
|
||||
|
||||
person_id_random: Optional[str]
|
||||
person_id: Optional[int]
|
||||
|
||||
for_type: Optional[str]
|
||||
for_id: Optional[int]
|
||||
|
||||
@@ -135,6 +125,7 @@ class Event_Presenter_Base(BaseModel):
|
||||
notes: Optional[str]
|
||||
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 presenters. Updated using SQL triggers and a SQL function
|
||||
|
||||
# Including convenience data
|
||||
# This is only for convenience. Probably going to keep unless it causes a problem.
|
||||
@@ -190,51 +181,52 @@ class Event_Presenter_Base(BaseModel):
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
@validator('id', always=True)
|
||||
def event_presenter_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='event_presenter')
|
||||
return None
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
if rid := values.get('id_random') or values.get('event_presenter_id_random'):
|
||||
values['id'] = rid
|
||||
values['event_presenter_id'] = rid
|
||||
|
||||
@validator('event_id', always=True)
|
||||
def event_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event')
|
||||
return None
|
||||
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||
if e_rid := values.get('event_id_random'): values['event_id'] = e_rid
|
||||
if ep_rid := values.get('event_person_id_random'): values['event_person_id'] = ep_rid
|
||||
if epr_rid := values.get('event_presentation_id_random'): values['event_presentation_id'] = epr_rid
|
||||
if es_rid := values.get('event_session_id_random'): values['event_session_id'] = es_rid
|
||||
if et_rid := values.get('event_track_id_random'): values['event_track_id'] = et_rid
|
||||
if p_rid := values.get('person_id_random'): values['person_id'] = p_rid
|
||||
|
||||
@validator('event_person_id', always=True)
|
||||
def event_person_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_person_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_person')
|
||||
return None
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'event_presenter_id', 'account_id', 'event_id', 'event_person_id', 'event_presentation_id', 'event_session_id', 'event_track_id', 'person_id']:
|
||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||
del values[k]
|
||||
|
||||
@validator('event_presentation_id', always=True)
|
||||
def event_presentation_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_presentation_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_presentation')
|
||||
return None
|
||||
return values
|
||||
|
||||
@validator('event_session_id', always=True)
|
||||
def event_session_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_session_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_session')
|
||||
return None
|
||||
|
||||
@validator('person_id', always=True)
|
||||
def person_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('person_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='person')
|
||||
return None
|
||||
# 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] = [
|
||||
'account_id', 'file_count', 'event_file_id_li_json',
|
||||
'event_name', 'event_start_datetime', 'event_end_datetime',
|
||||
'event_location_code', 'event_location_name',
|
||||
'event_presentation_code', 'event_presentation_type_code', 'event_presentation_name',
|
||||
'event_presentation_start_datetime', 'event_presentation_end_datetime',
|
||||
'event_session_code', 'event_session_type_code', 'event_session_name',
|
||||
'event_session_start_datetime', 'event_session_end_datetime',
|
||||
'event_track_code', 'event_track_name',
|
||||
'person_external_id', 'person_external_sys_id', 'person_given_name',
|
||||
'person_family_name', 'person_professional_title', 'person_full_name',
|
||||
'person_affiliations', 'person_primary_email', 'person_passcode',
|
||||
'event_abstract', 'event_abstract_list', 'event_cfg', 'event_file_list',
|
||||
'event_person', 'event_presentation', 'event_session'
|
||||
]
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = True
|
||||
allow_population_by_field_name = False
|
||||
fields = base_fields
|
||||
# ### END ### API Event Presenter Models ### Event_Presenter_Base() ###
|
||||
|
||||
@@ -245,45 +237,28 @@ class Event_Presenter_Out_Base(BaseModel):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
**base_fields['event_presenter_id_random'],
|
||||
alias = 'event_presenter_id_random',
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'event_presenter_id'
|
||||
)
|
||||
# --- Standardized Vision IDs (Strings) ---
|
||||
id: Optional[str] = Field(None, **base_fields['event_presenter_id_random'])
|
||||
event_presenter_id: Optional[str] = Field(None, **base_fields['event_presenter_id_random'])
|
||||
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
event_id: Optional[str] = Field(None, **base_fields['event_id_random'])
|
||||
person_id: Optional[str] = Field(None, **base_fields['person_id_random'])
|
||||
event_presentation_id: Optional[str] = Field(None, **base_fields['event_presentation_id_random'])
|
||||
event_session_id: Optional[str] = Field(None, **base_fields['event_session_id_random'])
|
||||
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='event_presenter_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_id_random: Optional[str] = Field(None, exclude=True)
|
||||
person_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_presentation_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_session_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
external_id: Optional[str]
|
||||
|
||||
code: Optional[str]
|
||||
|
||||
event_id_random: Optional[str]
|
||||
event_id: Optional[int]
|
||||
|
||||
# event_abstract_id_random: Optional[str]
|
||||
# event_abstract_id: Optional[int]
|
||||
|
||||
# event_location_id_random: Optional[str]
|
||||
# event_location_id: Optional[int]
|
||||
|
||||
# event_person_id_random: Optional[str]
|
||||
# event_person_id: Optional[int]
|
||||
|
||||
event_presentation_id_random: Optional[str]
|
||||
event_presentation_id: Optional[int]
|
||||
|
||||
event_session_id_random: Optional[str]
|
||||
event_session_id: Optional[int]
|
||||
|
||||
# event_track_id_random: Optional[str]
|
||||
# event_track_id: Optional[int]
|
||||
|
||||
person_id_random: Optional[str]
|
||||
person_id: Optional[int]
|
||||
|
||||
# for_type: Optional[str]
|
||||
# for_id: Optional[int]
|
||||
|
||||
pronouns: Optional[str] # Preferred pronouns
|
||||
informal_name: Optional[str] # Informal or nick name they commonly go by
|
||||
|
||||
@@ -298,43 +273,29 @@ class Event_Presenter_Out_Base(BaseModel):
|
||||
professional_title: Optional[str] # Professional title
|
||||
# title: Optional[str] # NOTE: Phasing out! Use *professional_title* instead.
|
||||
|
||||
# display_name: Optional[str] # NOTE: This will be changed to full_name_override to match event_badge, event_person_profile, and person
|
||||
|
||||
# BEGIN # Auto created name variations
|
||||
full_name: Optional[str] # title_names given_name middle_name family_name designations
|
||||
full_name_override: Optional[str] # Override full_name; Actual name shown for presenter
|
||||
|
||||
# degree: Optional[str] # NOTE: Phasing out! Use *designations* instead.
|
||||
# degrees: Optional[str] # NOTE: Phasing out! Use *designations* instead.
|
||||
# credentials: Optional[str] # NOTE: Phasing out! Use *designations* instead.
|
||||
|
||||
affiliations: Optional[str] # One or more affiliations with organizations, companies, and other groups
|
||||
# affiliation: Optional[str] # NOTE: Phasing out! Use *affiliations* instead.
|
||||
|
||||
email: Optional[str]
|
||||
website_url: Optional[str]
|
||||
|
||||
# phone_li_json: Optional[Union[Json, None]]
|
||||
|
||||
# For social media in a JSON object format. The Aether standard field names should be used. Examples: url, url_text, icon, etc.
|
||||
social_li_json: Optional[Union[Json, None]]
|
||||
|
||||
tagline: Optional[str]
|
||||
biography: Optional[str]
|
||||
|
||||
# picture_path: Optional[str] # Start using image_li_json instead
|
||||
# picture_bg_color: Optional[str]
|
||||
|
||||
# For image files only in a JSON object format. The Aether standard field names should be used. Examples: url, url_text, alt_text, width, height, size (in bytes), etc.
|
||||
image_li_json: Optional[Union[Json, None]] # "headshot" is probably the most common
|
||||
# media_li_json: Optional[Union[Json, None]]
|
||||
|
||||
# role: Optional[str]
|
||||
|
||||
data_json: Optional[Union[Json, None]] # For key value data. Careful with overwriting existing fields!
|
||||
cfg_json: Optional[Union[Json, None]] # Store per presenter config options like theme, language, etc
|
||||
|
||||
# file_count: Optional[int]
|
||||
file_count: Optional[int]
|
||||
|
||||
# General catchall for agreement or consent
|
||||
agree: Optional[bool]
|
||||
@@ -343,13 +304,8 @@ class Event_Presenter_Out_Base(BaseModel):
|
||||
comments: Optional[str]
|
||||
|
||||
enable: Optional[bool]
|
||||
# enable_from: Optional[datetime.datetime] = None
|
||||
# enable_to: Optional[datetime.datetime] = None
|
||||
|
||||
hide: Optional[bool]
|
||||
# public: Optional[bool]
|
||||
# public_hide: Optional[bool]
|
||||
# hide_event_launcher: Optional[bool]
|
||||
|
||||
priority: Optional[bool]
|
||||
sort: Optional[int] # The presenter number if given
|
||||
@@ -358,24 +314,7 @@ class Event_Presenter_Out_Base(BaseModel):
|
||||
notes: Optional[str]
|
||||
created_on: Optional[datetime.datetime] = None
|
||||
updated_on: Optional[datetime.datetime] = None
|
||||
|
||||
# Including convenience data
|
||||
# This is only for convenience. Probably going to keep unless it causes a problem.
|
||||
# event_name: Optional[str]
|
||||
# event_start_datetime: Optional[datetime.datetime]
|
||||
# event_end_datetime: Optional[datetime.datetime]
|
||||
# event_location_code: Optional[str]
|
||||
# event_location_name: Optional[str]
|
||||
# event_presentation_code: Optional[str]
|
||||
# event_presentation_type_code: Optional[str]
|
||||
# event_presentation_name: Optional[str]
|
||||
# event_presentation_start_datetime: Optional[datetime.datetime]
|
||||
# event_presentation_end_datetime: Optional[datetime.datetime]
|
||||
# event_session_code: Optional[str]
|
||||
# event_session_type_code: Optional[str]
|
||||
# event_session_name: Optional[str]
|
||||
# event_session_start_datetime: Optional[datetime.datetime]
|
||||
# event_session_end_datetime: Optional[datetime.datetime]
|
||||
default_qry_str: Optional[str] # Default query string used for searching and filtering presenters. Updated using SQL triggers and a SQL function
|
||||
|
||||
person_external_id: Optional[str]
|
||||
person_external_sys_id: Optional[str]
|
||||
@@ -391,50 +330,32 @@ class Event_Presenter_Out_Base(BaseModel):
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
@validator('id', always=True)
|
||||
def event_presenter_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='event_presenter')
|
||||
return None
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
if rid := values.get('id_random') or values.get('event_presenter_id_random'):
|
||||
values['id'] = rid
|
||||
values['event_presenter_id'] = rid
|
||||
|
||||
@validator('event_id', always=True)
|
||||
def event_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event')
|
||||
return None
|
||||
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||
if e_rid := values.get('event_id_random'): values['event_id'] = e_rid
|
||||
if epr_rid := values.get('event_presentation_id_random'): values['event_presentation_id'] = epr_rid
|
||||
if es_rid := values.get('event_session_id_random'): values['event_session_id'] = es_rid
|
||||
if p_rid := values.get('person_id_random'): values['person_id'] = p_rid
|
||||
|
||||
# @validator('event_person_id', always=True)
|
||||
# def event_person_id_lookup(cls, v, values, **kwargs):
|
||||
# if isinstance(v, int) and v > 0: return v
|
||||
# elif id_random := values.get('event_person_id_random'):
|
||||
# return redis_lookup_id_random(record_id_random=id_random, table_name='event_person')
|
||||
# return None
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'event_presenter_id', 'account_id', 'event_id', 'event_presentation_id', 'event_session_id', 'person_id']:
|
||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||
del values[k]
|
||||
|
||||
@validator('event_presentation_id', always=True)
|
||||
def event_presentation_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_presentation_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_presentation')
|
||||
return None
|
||||
|
||||
@validator('event_session_id', always=True)
|
||||
def event_session_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_session_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_session')
|
||||
return None
|
||||
|
||||
@validator('person_id', always=True)
|
||||
def person_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('person_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='person')
|
||||
return None
|
||||
return values
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = True
|
||||
allow_population_by_field_name = False
|
||||
fields = base_fields
|
||||
# ### END ### API Event Presenter Models ### Event_Presenter_Base() ###
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
import datetime, hashlib, logging, os, pytz, redis, secrets
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
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
|
||||
@@ -14,6 +14,41 @@ class Event_Registration_Cfg_Base(BaseModel):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
# --- Standardized Vision IDs (Strings) ---
|
||||
id: Optional[str] = Field(None, **base_fields['event_registration_cfg_id_random'])
|
||||
event_registration_cfg_id: Optional[str] = Field(None, **base_fields['event_registration_cfg_id_random'])
|
||||
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
event_id: Optional[str] = Field(None, **base_fields['event_id_random'])
|
||||
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='event_registration_cfg_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
if rid := values.get('id_random') or values.get('event_registration_cfg_id_random'):
|
||||
values['id'] = rid
|
||||
values['event_registration_cfg_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
if e_rid := values.get('event_id_random'):
|
||||
values['event_id'] = e_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'event_registration_cfg_id', 'account_id', 'event_id']:
|
||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
start_on: Optional[datetime.datetime]
|
||||
end_on: Optional[datetime.datetime]
|
||||
|
||||
@@ -50,6 +85,9 @@ class Event_Registration_Cfg_Base(BaseModel):
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
# 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] = []
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = True
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
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 *
|
||||
@@ -15,24 +15,23 @@ class Event_Registration_Base(BaseModel):
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
**base_fields['event_registration_id_random'],
|
||||
alias = 'event_registration_id_random',
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'event_registration_id'
|
||||
)
|
||||
# --- Standardized Vision IDs (Strings) ---
|
||||
id: Optional[str] = Field(None, **base_fields['event_registration_id_random'])
|
||||
event_registration_id: Optional[str] = Field(None, **base_fields['event_registration_id_random'])
|
||||
|
||||
account_id_random: Optional[str]
|
||||
account_id: Optional[int]
|
||||
event_id_random: Optional[str]
|
||||
event_id: Optional[int]
|
||||
organization_id_random: Optional[str]
|
||||
organization_id: Optional[int]
|
||||
contact_id_random: Optional[str]
|
||||
contact_id: Optional[int]
|
||||
person_id_random: Optional[str]
|
||||
person_id: Optional[int]
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
event_id: Optional[str] = Field(None, **base_fields['event_id_random'])
|
||||
organization_id: Optional[str] = Field(None, **base_fields['organization_id_random'])
|
||||
contact_id: Optional[str] = Field(None, **base_fields['contact_id_random'])
|
||||
person_id: Optional[str] = Field(None, **base_fields['person_id_random'])
|
||||
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='event_registration_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_id_random: Optional[str] = Field(None, exclude=True)
|
||||
organization_id_random: Optional[str] = Field(None, exclude=True)
|
||||
contact_id_random: Optional[str] = Field(None, exclude=True)
|
||||
person_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
priority: Optional[bool]
|
||||
sort: Optional[int]
|
||||
@@ -48,69 +47,32 @@ class Event_Registration_Base(BaseModel):
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
#@validator('event_registration_id_random', always=True)
|
||||
def event_registration_id_random_copy(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
if rid := values.get('id_random') or values.get('event_registration_id_random'):
|
||||
values['id'] = rid
|
||||
values['event_registration_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||
if e_rid := values.get('event_id_random'): values['event_id'] = e_rid
|
||||
if o_rid := values.get('organization_id_random'): values['organization_id'] = o_rid
|
||||
if c_rid := values.get('contact_id_random'): values['contact_id'] = c_rid
|
||||
if p_rid := values.get('person_id_random'): values['person_id'] = p_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'event_registration_id', 'account_id', 'event_id', 'organization_id', 'contact_id', 'person_id']:
|
||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
if values['id_random']:
|
||||
return values['id_random']
|
||||
return None
|
||||
|
||||
@validator('id', always=True)
|
||||
def event_registration_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['id_random']:
|
||||
log.debug(values['id_random'])
|
||||
return redis_lookup_id_random(record_id_random=values['id_random'], table_name='event_registration')
|
||||
return None
|
||||
|
||||
@validator('account_id', always=True)
|
||||
def account_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['account_id_random']:
|
||||
return redis_lookup_id_random(record_id_random=values['account_id_random'], table_name='account')
|
||||
return None
|
||||
|
||||
@validator('event_id', always=True)
|
||||
def event_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['event_id_random']:
|
||||
return redis_lookup_id_random(record_id_random=values['event_id_random'], table_name='event')
|
||||
return None
|
||||
|
||||
@validator('organization_id', always=True)
|
||||
def organization_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['organization_id_random']:
|
||||
return redis_lookup_id_random(record_id_random=values['organization_id_random'], table_name='organization')
|
||||
return None
|
||||
|
||||
@validator('contact_id', always=True)
|
||||
def contact_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['contact_id_random']:
|
||||
return redis_lookup_id_random(record_id_random=values['contact_id_random'], table_name='contact')
|
||||
return None
|
||||
|
||||
@validator('person_id', always=True)
|
||||
def person_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['person_id_random']:
|
||||
return redis_lookup_id_random(record_id_random=values['person_id_random'], table_name='person')
|
||||
return None
|
||||
# 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] = ['cfg', 'event_person_list']
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
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
|
||||
@@ -19,13 +19,25 @@ class Event_Session_Base(BaseModel):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
# **base_fields['event_session_id_random'],
|
||||
alias = 'event_session_id_random',
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'event_session_id'
|
||||
)
|
||||
# --- Standardized Vision IDs (Strings) ---
|
||||
id: Optional[str] = Field(None, **base_fields['event_session_id_random'])
|
||||
event_session_id: Optional[str] = Field(None, **base_fields['event_session_id_random'])
|
||||
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
event_id: Optional[str] = Field(None, **base_fields['event_id_random'])
|
||||
event_location_id: Optional[str] = Field(None, **base_fields['event_location_id_random'])
|
||||
event_track_id: Optional[str] = Field(None, **base_fields['event_track_id_random'])
|
||||
poc_event_person_id: Optional[str] = Field(None, **base_fields['event_person_id_random'])
|
||||
poc_person_id: Optional[str] = Field(None, **base_fields['person_id_random'])
|
||||
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='event_session_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_location_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_track_id_random: Optional[str] = Field(None, exclude=True)
|
||||
poc_event_person_id_random: Optional[str] = Field(None, exclude=True)
|
||||
poc_person_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
external_id: Optional[str] = Field(
|
||||
# alias = 'event_session_external_id'
|
||||
@@ -35,21 +47,6 @@ class Event_Session_Base(BaseModel):
|
||||
# alias = 'event_session_code'
|
||||
)
|
||||
|
||||
event_id_random: Optional[str]
|
||||
event_id: Optional[int]
|
||||
|
||||
event_location_id_random: Optional[str]
|
||||
event_location_id: Optional[int]
|
||||
|
||||
event_track_id_random: Optional[str]
|
||||
event_track_id: Optional[int]
|
||||
|
||||
poc_event_person_id_random: Optional[str]
|
||||
poc_event_person_id: Optional[int]
|
||||
|
||||
poc_person_id_random: Optional[str]
|
||||
poc_person_id: Optional[int]
|
||||
|
||||
# General catchall for agreement or consent
|
||||
poc_agree: Optional[bool]
|
||||
|
||||
@@ -141,6 +138,9 @@ class Event_Session_Base(BaseModel):
|
||||
notes: Optional[str]
|
||||
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.
|
||||
@@ -186,43 +186,54 @@ class Event_Session_Base(BaseModel):
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
@validator('id', always=True)
|
||||
def event_session_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='event_session')
|
||||
return None
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
if rid := values.get('id_random') or values.get('event_session_id_random'):
|
||||
values['id'] = rid
|
||||
values['event_session_id'] = rid
|
||||
|
||||
@validator('event_id', always=True)
|
||||
def event_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event')
|
||||
return None
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
if e_rid := values.get('event_id_random'):
|
||||
values['event_id'] = e_rid
|
||||
if el_rid := values.get('event_location_id_random'):
|
||||
values['event_location_id'] = el_rid
|
||||
if et_rid := values.get('event_track_id_random'):
|
||||
values['event_track_id'] = et_rid
|
||||
if pep_rid := values.get('poc_event_person_id_random'):
|
||||
values['poc_event_person_id'] = pep_rid
|
||||
if pp_rid := values.get('poc_person_id_random'):
|
||||
values['poc_person_id'] = pp_rid
|
||||
|
||||
@validator('event_location_id', always=True)
|
||||
def event_location_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_location_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_location')
|
||||
return None
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'event_session_id', 'account_id', 'event_id', 'event_location_id', 'event_track_id', 'poc_event_person_id', 'poc_person_id']:
|
||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||
del values[k]
|
||||
|
||||
@validator('event_track_id', always=True)
|
||||
def event_track_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_track_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_track')
|
||||
return None
|
||||
return values
|
||||
|
||||
@validator('poc_person_id', always=True)
|
||||
def poc_person_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('poc_person_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='person')
|
||||
return None
|
||||
# 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] = [
|
||||
'account_id',
|
||||
'file_count', 'internal_use_count', 'event_file_id_li_json', 'file_count_all',
|
||||
'event_name', 'event_start_datetime', 'event_end_datetime',
|
||||
'event_location_name', 'event_track_name',
|
||||
'event_abstract_list', 'event_badge_list', 'event_device_list',
|
||||
'event_file_list', 'event_file_internal_use_list', 'event_location',
|
||||
'event_location_list', 'event_person_list', 'event_presenter_cat',
|
||||
'event_presentation_list', 'event_presenter_list', 'event_track',
|
||||
'poc_event_person', 'poc_person',
|
||||
'poc_person_external_id', 'poc_person_given_name', 'poc_person_family_name',
|
||||
'poc_person_full_name', 'poc_person_primary_email', 'poc_person_passcode'
|
||||
]
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = True
|
||||
allow_population_by_field_name = False
|
||||
fields = base_fields
|
||||
# ### END ### API Event Session Models ### Event_Session_Base() ###
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
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
|
||||
@@ -15,17 +15,19 @@ class Event_Track_Base(BaseModel):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
**base_fields['event_track_id_random'],
|
||||
alias = 'event_track_id_random',
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'event_track_id'
|
||||
)
|
||||
event_id_random: Optional[str]
|
||||
event_id: Optional[int]
|
||||
event_location_id_random: Optional[str] # Can a location be assigned to one track?
|
||||
event_location_id: Optional[int] # Can a location be assigned to one track?
|
||||
# --- Standardized Vision IDs (Strings) ---
|
||||
id: Optional[str] = Field(None, **base_fields['event_track_id_random'])
|
||||
event_track_id: Optional[str] = Field(None, **base_fields['event_track_id_random'])
|
||||
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
event_id: Optional[str] = Field(None, **base_fields['event_id_random'])
|
||||
event_location_id: Optional[str] = Field(None, **base_fields['event_location_id_random'])
|
||||
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='event_track_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_id_random: Optional[str] = Field(None, exclude=True)
|
||||
event_location_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
lu_track_type_id: Optional[int]
|
||||
track_type_code: Optional[str]
|
||||
@@ -54,6 +56,11 @@ class Event_Track_Base(BaseModel):
|
||||
created_on: Optional[datetime.datetime] = None
|
||||
updated_on: Optional[datetime.datetime] = None
|
||||
|
||||
# Including convenience data
|
||||
event_name: Optional[str]
|
||||
event_start_datetime: Optional[datetime.datetime]
|
||||
event_end_datetime: Optional[datetime.datetime]
|
||||
|
||||
# Including other related objects
|
||||
#event: Optional[Event_Base]
|
||||
event_abstract_list: Optional[list] # Optional[Event_Abstract_Base]
|
||||
@@ -66,45 +73,41 @@ class Event_Track_Base(BaseModel):
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
#@validator('event_track_id_random', always=True)
|
||||
def event_track_id_random_copy(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
if rid := values.get('id_random') or values.get('event_track_id_random'):
|
||||
values['id'] = rid
|
||||
values['event_track_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
if e_rid := values.get('event_id_random'):
|
||||
values['event_id'] = e_rid
|
||||
if el_rid := values.get('event_location_id_random'):
|
||||
values['event_location_id'] = el_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'event_track_id', 'account_id', 'event_id', 'event_location_id']:
|
||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
if values['id_random']:
|
||||
return values['id_random']
|
||||
return None
|
||||
|
||||
@validator('id', always=True)
|
||||
def event_track_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['id_random']:
|
||||
log.debug(values['id_random'])
|
||||
return redis_lookup_id_random(record_id_random=values['id_random'], table_name='event_track')
|
||||
return None
|
||||
|
||||
@validator('event_id', always=True)
|
||||
def event_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['event_id_random']:
|
||||
return redis_lookup_id_random(record_id_random=values['event_id_random'], table_name='event')
|
||||
return None
|
||||
|
||||
@validator('event_location_id', always=True)
|
||||
def event_location_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['event_location_id_random']:
|
||||
return redis_lookup_id_random(record_id_random=values['event_location_id_random'], table_name='event_location')
|
||||
return None
|
||||
# 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] = [
|
||||
'account_id', 'track_type',
|
||||
'event_name', 'event_start_datetime', 'event_end_datetime',
|
||||
'event_abstract_list', 'event_device_list', 'event_file_list',
|
||||
'event_presentation_list', 'event_presenter_list', 'event_session_list', 'event_track_list'
|
||||
]
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = True
|
||||
allow_population_by_field_name = False
|
||||
fields = base_fields
|
||||
# ### END ### API Event Track Models ### Event_Track_Base() ###
|
||||
|
||||
@@ -27,6 +27,8 @@ class Hosted_File_Base(BaseModel):
|
||||
|
||||
subdirectory_path: Optional[str] = Field(None, exclude=True) # NOTE: This will frequently only contain numbers, but it still needs to be a string
|
||||
filename: Optional[str]
|
||||
filename_no_ext: Optional[str]
|
||||
filename_w_ext: Optional[str]
|
||||
extension: Optional[str]
|
||||
content_type: Optional[str]
|
||||
mimetype: Optional[str]
|
||||
@@ -65,21 +67,24 @@ class Hosted_File_Base(BaseModel):
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
Map DB keys to clean API keys and strip internal integers during READ operations.
|
||||
During CREATE (POST) operations, we ensure resolved integers are preserved.
|
||||
"""
|
||||
# 1. Capture the random ID string
|
||||
# 1. Map Random Strings to Clean Names
|
||||
rid = values.get('id_random') or values.get('hosted_file_id_random')
|
||||
|
||||
# 2. Map Random Strings to Clean Names for the Frontend
|
||||
# We always want the string version in 'id' and 'hosted_file_id' for the API response
|
||||
if rid:
|
||||
if rid and isinstance(rid, str):
|
||||
values['id'] = rid
|
||||
values['hosted_file_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
# If we have a random account ID string, use it for the Vision API
|
||||
values['account_id'] = a_rid
|
||||
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||
|
||||
# 2. Prevent "Collision Population" or leakage of integers during API responses
|
||||
for k in ['id', 'hosted_file_id', 'account_id']:
|
||||
val = values.get(k)
|
||||
if val is not None and not isinstance(val, str):
|
||||
if values.get(f'{k}_random') or (k=='id' and values.get('id_random')):
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -73,7 +73,7 @@ class Journal_Entry_Base(BaseModel):
|
||||
|
||||
parent_id: Optional[str] = Field(None, **base_fields['journal_entry_id_random'])
|
||||
# parent_id_random: Optional[str]
|
||||
|
||||
|
||||
related_entry_id_random: Optional[List[str]]
|
||||
related_entry_id_li: Optional[List[int]] = Field(None, exclude=True)
|
||||
|
||||
@@ -102,6 +102,7 @@ class Journal_Entry_Base(BaseModel):
|
||||
notes: Optional[str]
|
||||
created_on: Optional[datetime.datetime] = None
|
||||
updated_on: Optional[datetime.datetime] = None
|
||||
default_qry_str: Optional[str] = None # Default query string used for searching and filtering journal entries
|
||||
|
||||
# Including other related objects
|
||||
# This is only for convenience. Probably going to keep unless it causes a problem.
|
||||
@@ -119,21 +120,21 @@ class Journal_Entry_Base(BaseModel):
|
||||
if rid := values.get('id_random') or values.get('journal_entry_id_random'):
|
||||
values['id'] = rid
|
||||
values['journal_entry_id'] = rid
|
||||
|
||||
|
||||
if j_rid := values.get('journal_id_random'):
|
||||
values['journal_id'] = j_rid
|
||||
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
|
||||
|
||||
if p_rid := values.get('parent_id_random'):
|
||||
values['parent_id'] = p_rid
|
||||
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'journal_entry_id', 'journal_id', 'account_id', 'parent_id']:
|
||||
if k in values and not isinstance(values[k], str):
|
||||
del values[k]
|
||||
|
||||
|
||||
return values
|
||||
|
||||
# Fields that are part of the model (for reading) but should not be saved to the DB table
|
||||
|
||||
@@ -16,12 +16,12 @@ class Journal_Base(BaseModel):
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
# --- Standardized Vision IDs (Strings) ---
|
||||
id: Optional[str] = Field(None, **base_fields['journal_id_random'])
|
||||
journal_id: Optional[str] = Field(None, **base_fields['journal_id_random'])
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
person_id: Optional[str] = Field(None, **base_fields['person_id_random'])
|
||||
user_id: Optional[str] = Field(None, **base_fields['user_id_random'])
|
||||
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
|
||||
id: Optional[Union[int, str]] = Field(None, **base_fields['journal_id_random'])
|
||||
journal_id: Optional[Union[int, str]] = Field(None, **base_fields['journal_id_random'])
|
||||
account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_id_random'])
|
||||
person_id: Optional[Union[int, str]] = Field(None, **base_fields['person_id_random'])
|
||||
user_id: Optional[Union[int, str]] = Field(None, **base_fields['user_id_random'])
|
||||
|
||||
external_id: Optional[str] # ID generated by or for external systems (should be stable and not change)
|
||||
import_id: Optional[str] # Used for import purposes to track the source of the data
|
||||
@@ -38,7 +38,7 @@ class Journal_Base(BaseModel):
|
||||
|
||||
description: Optional[str]
|
||||
description_html: Optional[str]
|
||||
description_json: Optional[str]
|
||||
description_json: Optional[Union[Json, None]]
|
||||
|
||||
type_code: Optional[str] # 'log', 'tracking', 'personal', 'professional', etc
|
||||
tags: Optional[str]
|
||||
@@ -132,24 +132,25 @@ class Journal_Base(BaseModel):
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
Map DB keys to clean API keys and strip internal integers during READ operations.
|
||||
During CREATE (POST) operations, we ensure resolved integers are preserved.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
if rid := values.get('id_random') or values.get('journal_id_random'):
|
||||
rid = values.get('id_random') or values.get('journal_id_random')
|
||||
if rid and isinstance(rid, str):
|
||||
values['id'] = rid
|
||||
values['journal_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
if p_rid := values.get('person_id_random'):
|
||||
values['person_id'] = p_rid
|
||||
if u_rid := values.get('user_id_random'):
|
||||
values['user_id'] = u_rid
|
||||
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||
if p_rid := values.get('person_id_random'): values['person_id'] = p_rid
|
||||
if u_rid := values.get('user_id_random'): values['user_id'] = u_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
# 2. Prevent "Collision Population" or leakage of integers during API responses
|
||||
for k in ['id', 'journal_id', 'account_id', 'person_id', 'user_id']:
|
||||
if k in values and not isinstance(values[k], str):
|
||||
del values[k]
|
||||
val = values.get(k)
|
||||
if val is not None and not isinstance(val, str):
|
||||
if values.get(f'{k}_random') or (k=='id' and values.get('id_random')):
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
|
||||
38
app/models/lookup_models.py
Normal file
38
app/models/lookup_models.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from typing import Optional
|
||||
from pydantic import Field
|
||||
from .core_object_models import Core_Std_Obj_Base
|
||||
|
||||
class Lookup_Base(Core_Std_Obj_Base):
|
||||
"""
|
||||
Standardized Baseline for Aether V3 Lookups.
|
||||
Follows the Hierarchical, Identity-Agnostic System.
|
||||
"""
|
||||
id_random: Optional[str] = Field(None, description="Public String ID (ID Vision)")
|
||||
account_id: Optional[int] = Field(None, description="Internal Account ID (NULL = Global)")
|
||||
account_id_random: Optional[str] = Field(None, description="Public Account ID")
|
||||
|
||||
for_type: Optional[str] = Field(None, description="Polymorphic Context Type")
|
||||
for_id: Optional[int] = Field(None, description="Polymorphic Context Internal ID")
|
||||
for_id_random: Optional[str] = Field(None, description="Polymorphic Context Public ID")
|
||||
|
||||
group: Optional[str] = Field(None, description="Primary Business Key / Cluster Key")
|
||||
name: Optional[str] = Field(None, description="Primary Display Label")
|
||||
description: Optional[str] = Field(None, description="Detailed Explanation")
|
||||
|
||||
enable: Optional[bool] = Field(True, description="Active status (Shadowing/Negative Overrides)")
|
||||
hide: Optional[bool] = Field(False, description="UI Visibility flag")
|
||||
sort: Optional[int] = Field(0, description="Ordering priority")
|
||||
|
||||
class Lu_Country_V3_Base(Lookup_Base):
|
||||
alpha_2_code: Optional[str] = None
|
||||
alpha_3_code: Optional[str] = None
|
||||
numeric_code: Optional[str] = None
|
||||
english_short_name: Optional[str] = None
|
||||
|
||||
class Lu_Country_Subdivision_V3_Base(Lookup_Base):
|
||||
country_alpha_2_code: Optional[str] = None
|
||||
code: Optional[str] = None
|
||||
|
||||
class Lu_Time_Zone_V3_Base(Lookup_Base):
|
||||
timezone: Optional[str] = None
|
||||
offset: Optional[str] = None
|
||||
@@ -1,6 +1,6 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator, root_validator
|
||||
|
||||
from app.db_sql import redis_lookup_id_random
|
||||
@@ -86,6 +86,11 @@ class Organization_Base(BaseModel):
|
||||
|
||||
return values
|
||||
|
||||
# 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] = [
|
||||
'contact'
|
||||
]
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = False
|
||||
|
||||
@@ -14,19 +14,11 @@ class Page_Base(BaseModel):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
# --- Standardized Vision IDs (Strings) ---
|
||||
id: Optional[str] = Field(None, **base_fields['page_id_random'])
|
||||
page_id: Optional[str] = Field(None, **base_fields['page_id_random'])
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
site_id: Optional[str] = Field(None, **base_fields['site_id_random'])
|
||||
|
||||
# page_id_random: Optional[str] = Field(
|
||||
# **base_fields['page_id_random'],
|
||||
# alias = 'page_id_random',
|
||||
# )
|
||||
# id: Optional[int] = Field(
|
||||
# alias = 'page_id'
|
||||
# )
|
||||
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
|
||||
id: Optional[Union[int, str]] = Field(None, **base_fields['page_id_random'])
|
||||
page_id: Optional[Union[int, str]] = Field(None, **base_fields['page_id_random'])
|
||||
account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_id_random'])
|
||||
site_id: Optional[Union[int, str]] = Field(None, **base_fields['site_id_random'])
|
||||
|
||||
code: Optional[str]
|
||||
name: Optional[str]
|
||||
@@ -69,22 +61,24 @@ class Page_Base(BaseModel):
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
Map DB keys to clean API keys and strip internal integers during READ operations.
|
||||
During CREATE (POST) operations, we ensure resolved integers are preserved.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
if rid := values.get('id_random') or values.get('page_id_random'):
|
||||
rid = values.get('id_random') or values.get('page_id_random')
|
||||
if rid and isinstance(rid, str):
|
||||
values['id'] = rid
|
||||
values['page_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_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
|
||||
if s_rid := values.get('site_id_random'): values['site_id'] = s_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
# 2. Prevent "Collision Population" or leakage of integers during API responses
|
||||
for k in ['id', 'account_id', 'site_id']:
|
||||
if k in values and not isinstance(values[k], str):
|
||||
del values[k]
|
||||
val = values.get(k)
|
||||
if val is not None and not isinstance(val, str):
|
||||
if values.get(f'{k}_random') or (k=='id' and values.get('id_random')):
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator, root_validator
|
||||
|
||||
from app.db_sql import redis_lookup_id_random
|
||||
@@ -184,6 +184,19 @@ class Person_Base(BaseModel):
|
||||
return True
|
||||
return v
|
||||
|
||||
# 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] = [
|
||||
'membership_person_id', 'first_last_name', 'first_middle_last_name',
|
||||
'last_first_name', 'last_first_middle_name', 'informal_full_name',
|
||||
'professional_full_name', 'lu_gender_name', 'email', 'cc_email',
|
||||
'username', 'user_name', 'user_email', 'user_allow_auth_key',
|
||||
'user_super', 'user_manager', 'user_administrator', 'user_public',
|
||||
'event_list', 'hosted_file_list', 'journal_list', 'contact',
|
||||
'membership_person', 'membership_group_list', 'membership_type_list',
|
||||
'orders_info', 'order_list', 'order_cart', 'order_cart_v3',
|
||||
'organization', 'post_list', 'user'
|
||||
]
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = False
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator, root_validator
|
||||
|
||||
from app.db_sql import redis_lookup_id_random
|
||||
@@ -17,12 +17,17 @@ class Post_Comment_Base(BaseModel):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
# --- Standardized Vision IDs (Strings) ---
|
||||
id: Optional[str] = Field(None, **base_fields['post_comment_id_random'])
|
||||
post_comment_id: Optional[str] = Field(None, **base_fields['post_comment_id_random'])
|
||||
post_id: Optional[str] = Field(None, **base_fields['post_id_random'])
|
||||
person_id: Optional[str] = Field(None, **base_fields['person_id_random'])
|
||||
user_id: Optional[str] = Field(None, **base_fields['user_id_random'])
|
||||
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
|
||||
# We use Union[int, str] to allow both public string IDs and resolved DB integers to pass validation.
|
||||
id: Optional[Union[int, str]] = Field(None, **base_fields['post_comment_id_random'])
|
||||
post_comment_id: Optional[Union[int, str]] = Field(None, **base_fields['post_comment_id_random'])
|
||||
post_id: Optional[Union[int, str]] = Field(None, **base_fields['post_id_random'])
|
||||
account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_id_random'])
|
||||
person_id: Optional[Union[int, str]] = Field(None, **base_fields['person_id_random'])
|
||||
user_id: Optional[Union[int, str]] = Field(None, **base_fields['user_id_random'])
|
||||
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='post_comment_id_random', exclude=True)
|
||||
|
||||
external_person_id: Optional[str] # Person ID generated by external system (should be stable and not change)
|
||||
|
||||
@@ -59,27 +64,40 @@ class Post_Comment_Base(BaseModel):
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
Map DB keys to clean API keys and strip internal integers during READ operations.
|
||||
During CREATE (POST) operations, we ensure resolved integers are preserved.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
if rid := values.get('id_random') or values.get('post_comment_id_random'):
|
||||
rid = values.get('id_random') or values.get('post_comment_id_random')
|
||||
if rid and isinstance(rid, str):
|
||||
values['id'] = rid
|
||||
values['post_comment_id'] = rid
|
||||
|
||||
if p_rid := values.get('post_id_random'):
|
||||
values['post_id'] = p_rid
|
||||
if per_rid := values.get('person_id_random'):
|
||||
values['person_id'] = per_rid
|
||||
if u_rid := values.get('user_id_random'):
|
||||
values['user_id'] = u_rid
|
||||
if p_rid := values.get('post_id_random'): values['post_id'] = p_rid
|
||||
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||
if per_rid := values.get('person_id_random'): values['person_id'] = per_rid
|
||||
if u_rid := values.get('user_id_random'): values['user_id'] = u_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'post_comment_id', 'post_id', 'person_id', 'user_id']:
|
||||
if k in values and not isinstance(values[k], str):
|
||||
del values[k]
|
||||
# 2. Prevent "Collision Population" or leakage of integers during API responses
|
||||
# WE MUST NOT DELETE these if they are already integers during a POST operation
|
||||
# as they have been resolved by sanitize_payload.
|
||||
# We only delete the integer if a string version was successfully mapped above.
|
||||
for k in ['id', 'post_comment_id', 'post_id', 'account_id', 'person_id', 'user_id']:
|
||||
val = values.get(k)
|
||||
if val is not None and not isinstance(val, str):
|
||||
# If we have a random ID counterpart in the source dict, we are in "Read Mode".
|
||||
# In Read Mode, we prioritize the string ID for the client.
|
||||
# In "Create Mode", the random ID field won't be in 'values' after sanitize_payload
|
||||
# but the integer will be in 'k'.
|
||||
if values.get(f'{k}_random') or (k=='id' and values.get('id_random')):
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
# Fields that are part of the model (for reading) but should not be saved to the DB table.
|
||||
# account_id is joined from the 'post' table in the v_post_comment view.
|
||||
fields_to_exclude_from_db: ClassVar[list] = ['account_id']
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = False
|
||||
|
||||
@@ -16,12 +16,12 @@ class Post_Base(BaseModel):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
# --- Standardized Vision IDs (Strings) ---
|
||||
id: Optional[str] = Field(None, **base_fields['post_id_random'])
|
||||
post_id: Optional[str] = Field(None, **base_fields['post_id_random'])
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
person_id: Optional[str] = Field(None, **base_fields['person_id_random'])
|
||||
user_id: Optional[str] = Field(None, **base_fields['user_id_random'])
|
||||
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
|
||||
id: Optional[Union[int, str]] = Field(None, **base_fields['post_id_random'])
|
||||
post_id: Optional[Union[int, str]] = Field(None, **base_fields['post_id_random'])
|
||||
account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_id_random'])
|
||||
person_id: Optional[Union[int, str]] = Field(None, **base_fields['person_id_random'])
|
||||
user_id: Optional[Union[int, str]] = Field(None, **base_fields['user_id_random'])
|
||||
|
||||
external_person_id: Optional[str] # Person ID generated by external system (should be stable and not change)
|
||||
|
||||
@@ -32,7 +32,7 @@ class Post_Base(BaseModel):
|
||||
# type_id: Optional[int]
|
||||
|
||||
# topic_id_random: Optional[str]
|
||||
# topic_id: Optional[int]
|
||||
topic_id: Optional[int]
|
||||
|
||||
type: Optional[str]
|
||||
|
||||
@@ -93,25 +93,26 @@ class Post_Base(BaseModel):
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
Map DB keys to clean API keys and strip internal integers during READ operations.
|
||||
During CREATE (POST) operations, we ensure resolved integers are preserved.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
if rid := values.get('id_random') or values.get('post_id_random'):
|
||||
rid = values.get('id_random') or values.get('post_id_random')
|
||||
if rid and isinstance(rid, str):
|
||||
values['id'] = rid
|
||||
values['post_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
if p_rid := values.get('person_id_random'):
|
||||
values['person_id'] = p_rid
|
||||
if u_rid := values.get('user_id_random'):
|
||||
values['user_id'] = u_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
|
||||
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||
if p_rid := values.get('person_id_random'): values['person_id'] = p_rid
|
||||
if u_rid := values.get('user_id_random'): values['user_id'] = u_rid
|
||||
|
||||
# 2. Prevent "Collision Population" or leakage of integers during API responses
|
||||
for k in ['id', 'post_id', 'account_id', 'person_id', 'user_id']:
|
||||
if k in values and not isinstance(values[k], str):
|
||||
del values[k]
|
||||
|
||||
val = values.get(k)
|
||||
if val is not None and not isinstance(val, str):
|
||||
if values.get(f'{k}_random') or (k=='id' and values.get('id_random')):
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
from app.lib_general import log, logging, Response, status
|
||||
|
||||
# ### BEGIN ### API Response Model ### Resp_Body_Base() ###
|
||||
# The pydantic BaseModel to help make consistent REST responses.
|
||||
# Updated 2021-03-05
|
||||
class Resp_Body_Base(BaseModel):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
# test_prop: Optional[str] = Field(
|
||||
# alias = 'test_prop_alias'
|
||||
# )
|
||||
|
||||
data: Union[None, list, dict]
|
||||
meta: Optional[dict]
|
||||
# ### END ### API Response Model ### Resp_Body_Base() ###
|
||||
|
||||
|
||||
# ### BEGIN ### API Response Model ### mk_resp() ###
|
||||
# The method for making responses for REST. Returns a dict, but currently uses Resp_Body_Base inside the function.
|
||||
# Update 2021-08-23
|
||||
def mk_resp(
|
||||
data: None|bool|dict|list,
|
||||
tmp_file_path: None|str = None,
|
||||
dict_to_json: bool = False,
|
||||
status_code: int = 200,
|
||||
status_message: str = '',
|
||||
status_name: str = '',
|
||||
success: bool = True,
|
||||
details: str = '',
|
||||
include: dict = None,
|
||||
exclude: dict = None,
|
||||
by_alias: bool = True,
|
||||
exclude_unset: bool = False,
|
||||
response: Response = None
|
||||
) -> dict:
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
if data is None: data_out = { 'result': data }
|
||||
elif data == False: data_out = { 'result': data }
|
||||
elif data == True: data_out = { 'result': data }
|
||||
elif isinstance(data, dict):
|
||||
log.info('Data type is a dict')
|
||||
data_out = data
|
||||
elif isinstance(data, list):
|
||||
log.info('Data type is a list')
|
||||
data_out = data
|
||||
elif isinstance(data, int):
|
||||
log.info('Data type is an int')
|
||||
data_out = { 'result': data }
|
||||
elif isinstance(data, str):
|
||||
log.info('Data type is a str')
|
||||
data_out = { 'result': data }
|
||||
else: # Assuming it is still and object. This should be improved. Example model type: "<class 'app.models.account_models.Account_Base'>"
|
||||
log.info('Data type is other')
|
||||
data_out = data.dict(include=include, exclude=exclude, by_alias=by_alias, exclude_unset=exclude_unset)
|
||||
# log.debug(data_out)
|
||||
|
||||
resp_body = {}
|
||||
resp_body['data'] = data_out
|
||||
resp_body['meta'] = {}
|
||||
resp_body['meta']['details'] = details
|
||||
resp_body['meta']['status_code'] = status_code
|
||||
if status_message:
|
||||
resp_body['meta']['status_message'] = status_message
|
||||
else:
|
||||
resp_body['meta']['status_message'] = http_status_li[status_code]['message']
|
||||
resp_body['meta']['status_name'] = http_status_li[status_code]['name']
|
||||
resp_body['meta']['success'] = success
|
||||
resp_body['meta']['tmp_file_path'] = tmp_file_path
|
||||
|
||||
if isinstance(data, bool):
|
||||
resp_body['meta']['data_type'] = 'bool'
|
||||
elif isinstance(data, int):
|
||||
resp_body['meta']['data_type'] = 'int'
|
||||
elif isinstance(data, str):
|
||||
resp_body['meta']['data_type'] = 'str'
|
||||
elif isinstance(data, dict):
|
||||
resp_body['meta']['data_type'] = 'dict'
|
||||
elif isinstance(data, list):
|
||||
resp_body['meta']['data_type'] = 'list'
|
||||
resp_body['meta']['data_list_count'] = len(data)
|
||||
|
||||
if response:
|
||||
log.debug(response)
|
||||
if status_code == 400:
|
||||
log.warning('Likely bad request')
|
||||
response.status_code = status.HTTP_400_BAD_REQUEST
|
||||
elif status_code == 401: response.status_code = status.HTTP_401_UNAUTHORIZED
|
||||
# elif status_code == 402: response.status_code = status.HTTP_402_X
|
||||
elif status_code == 403: response.status_code = status.HTTP_403_FORBIDDEN
|
||||
elif status_code == 404:
|
||||
log.info('No results')
|
||||
response.status_code = status.HTTP_404_NOT_FOUND
|
||||
elif status_code == 408: response.status_code = status.HTTP_408_REQUEST_TIMEOUT
|
||||
elif status_code == 429: response.status_code = status.HTTP_429_TOO_MANY_REQUESTS
|
||||
elif status_code == 500: response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
elif status_code == 501: response.status_code = status.HTTP_501_NOT_IMPLEMENTED
|
||||
elif status_code == 502: response.status_code = status.HTTP_502_BAD_GATEWAY
|
||||
elif status_code == 503: response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
elif status_code == 504: response.status_code = status.HTTP_504_GATEWAY_TIMEOUT
|
||||
|
||||
# log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
# log.debug(resp_body)
|
||||
# log.debug(type(resp_body['data']))
|
||||
|
||||
# import json
|
||||
# with open('data.txt', 'w') as outfile:
|
||||
# json.dump(resp_body['data'], outfile)
|
||||
|
||||
resp_body_obj = Resp_Body_Base(**resp_body)
|
||||
log.debug(resp_body_obj)
|
||||
resp_body_dict = resp_body_obj.dict(by_alias=by_alias, exclude_unset=exclude_unset)
|
||||
log.debug(resp_body_dict)
|
||||
|
||||
return resp_body_dict
|
||||
# ### END ### API Response Model ### mk_resp() ###
|
||||
|
||||
|
||||
http_status_li = {}
|
||||
http_status_li[200] = { 'name': 'OK', 'message': 'The request has succeeded.' }
|
||||
http_status_li[400] = { 'name': 'Bad Request', 'message': 'The request could not be understood by the server due to malformed syntax. The client SHOULD NOT repeat the request without modifications.' }
|
||||
http_status_li[401] = { 'name': 'Unauthorized', 'message': 'The server could not verify that you are authorized to access the URL requested. You either supplied the wrong credentials (e.g. a bad password), or your browser does not understand how to supply the credentials required.' }
|
||||
http_status_li[402] = { 'name': '?Request Failed?', 'message': '??The parameters were valid but the request failed.??' }
|
||||
http_status_li[403] = { 'name': 'Forbidden', 'message': 'The server understood the request, but is refusing to fulfill it. Authorization will not help and the request SHOULD NOT be repeated. If the request method was not HEAD and the server wishes to make public why the request has not been fulfilled, it SHOULD describe the reason for the refusal in the entity. If the server does not wish to make this information available to the client, the status code 404 (Not Found) can be used instead.' }
|
||||
http_status_li[404] = { 'name': 'Not Found', 'message': 'The requested resource does not exist.' }
|
||||
http_status_li[409] = { 'name': 'Conflict', 'message': 'The request conflicts with another request (perhaps due to using the same idempotent key).' }
|
||||
http_status_li[429] = { 'name': 'Too Many Requests', 'message': 'Too many requests hit the API too quickly. We recommend an exponential backoff of your requests.' }
|
||||
http_status_li[500] = { 'name': 'Internal Server Error', 'message': 'The server encountered an unexpected condition which prevented it from fulfilling the request.' }
|
||||
http_status_li[501] = { 'name': 'Not Implemented', 'message': 'The server does not support the functionality required to fulfill the request. This is the appropriate response when the server does not recognize the request method and is not capable of supporting it for any resource.' }
|
||||
http_status_li[502] = { 'name': 'Bad Gateway', 'message': 'The server, while acting as a gateway or proxy, received an invalid response from the upstream server it accessed in attempting to fulfill the request.' }
|
||||
http_status_li[503] = { 'name': 'Service Unavailable', 'message': 'The server is currently unable to handle the request due to a temporary overloading or maintenance of the server. The implication is that this is a temporary condition which will be alleviated after some delay. If known, the length of the delay MAY be indicated in a Retry-After header. If no Retry-After is given, the client SHOULD handle the response as it would for a 500 response.' }
|
||||
http_status_li[504] = { 'name': 'Gateway Timeout', 'message': 'The server, while acting as a gateway or proxy, did not receive a timely response from the upstream server specified by the URI (e.g. HTTP, FTP, LDAP) or some other auxiliary server (e.g. DNS) it needed to access in attempting to complete the request.' }
|
||||
@@ -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]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
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
|
||||
@@ -15,16 +15,38 @@ class Site_Base(BaseModel):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
**base_fields['site_id_random'],
|
||||
alias = 'site_id_random',
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'site_id'
|
||||
)
|
||||
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
|
||||
id: Optional[Union[int, str]] = Field(None, **base_fields['site_id_random'])
|
||||
site_id: Optional[Union[int, str]] = Field(None, **base_fields['site_id_random'])
|
||||
account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_id_random'])
|
||||
|
||||
account_id_random: Optional[str]
|
||||
account_id: Optional[int]
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='site_id_random', exclude=True)
|
||||
account_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers during READ operations.
|
||||
During CREATE (POST) operations, we ensure resolved integers are preserved.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
rid = values.get('id_random') or values.get('site_id_random')
|
||||
if rid and isinstance(rid, str):
|
||||
values['id'] = rid
|
||||
values['site_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||
|
||||
# 2. Prevent leakage of integers during API responses (Vision Standard)
|
||||
for k in ['id', 'site_id', 'account_id']:
|
||||
val = values.get(k)
|
||||
if val is not None and not isinstance(val, str):
|
||||
if values.get(f'{k}_random') or (k=='id' and values.get('id_random')):
|
||||
del values[k]
|
||||
|
||||
return values
|
||||
|
||||
code: Optional[str]
|
||||
|
||||
@@ -101,35 +123,8 @@ class Site_Base(BaseModel):
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
@validator('id', always=True)
|
||||
def site_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['id_random']:
|
||||
log.debug(values['id_random'])
|
||||
return redis_lookup_id_random(record_id_random=values['id_random'], table_name='site')
|
||||
return None
|
||||
|
||||
@validator('account_id', always=True)
|
||||
def account_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('account_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='account')
|
||||
return None
|
||||
|
||||
@validator('account_id_random', always=True)
|
||||
def account_id_random_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, str) and len(v) >= 11: return v
|
||||
elif account_id := values.get('account_id'):
|
||||
return get_id_random(record_id=account_id, table_name='account')
|
||||
return None
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = True
|
||||
allow_population_by_field_name = False
|
||||
fields = base_fields
|
||||
# ### END ### API Site Models ### Site_Base() ###
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -26,66 +26,65 @@ cms_obj_li = {
|
||||
'created_on', 'updated_on'
|
||||
],
|
||||
},
|
||||
'post': {
|
||||
'tbl': 'post',
|
||||
'tbl_default': 'v_post',
|
||||
'tbl_alt': 'v_post_detail',
|
||||
'tbl_update': 'post',
|
||||
'mdl': Post_Base,
|
||||
'mdl_default': Post_Base,
|
||||
'mdl_in': Post_Base,
|
||||
'mdl_out': Post_Base,
|
||||
# Legacy V2 keys:
|
||||
'table_name': 'v_post',
|
||||
'table_name_alt': 'v_post_detail',
|
||||
'tbl_name_update': 'post',
|
||||
'base_name': Post_Base,
|
||||
'public_read': True,
|
||||
'exp_default': [
|
||||
'post_id_random',
|
||||
'account_id_random',
|
||||
'title', 'content',
|
||||
'enable', 'hide', 'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on',
|
||||
'post': {
|
||||
'tbl': 'post',
|
||||
'tbl_default': 'v_post',
|
||||
'tbl_alt': 'v_post_detail',
|
||||
'tbl_update': 'post',
|
||||
'mdl': Post_Base,
|
||||
'mdl_default': Post_Base,
|
||||
'mdl_in': Post_Base,
|
||||
'mdl_out': Post_Base,
|
||||
# Legacy V2 keys:
|
||||
'table_name': 'v_post',
|
||||
'table_name_alt': 'v_post_detail',
|
||||
'tbl_name_update': 'post',
|
||||
'base_name': Post_Base,
|
||||
'exp_default': [
|
||||
'post_id_random',
|
||||
'account_id_random',
|
||||
'title', 'content',
|
||||
'enable', 'hide', 'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on',
|
||||
],
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'id', 'account_id', 'person_id', 'user_id', 'external_person_id',
|
||||
'post_id_random', 'account_id_random', 'organization_id_random',
|
||||
'person_id_random', 'user_id_random', 'external_person_id', 'title', 'content',
|
||||
'type_code', 'topic_code', 'category_code', 'tags', 'location',
|
||||
'enable', 'hide', 'priority', 'sort', 'group', 'notes',
|
||||
'archive_on', 'created_on', 'updated_on'
|
||||
],
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'id', 'account_id', 'person_id', 'user_id',
|
||||
'post_id_random', 'account_id_random', 'organization_id_random',
|
||||
'person_id_random', 'user_id_random', 'external_person_id', 'title', 'content',
|
||||
'type_code', 'topic_code', 'category_code', 'tags', 'location',
|
||||
'enable', 'hide', 'priority', 'sort', 'group', 'notes',
|
||||
'archive_on', 'created_on', 'updated_on'
|
||||
],
|
||||
},
|
||||
'post_comment': {
|
||||
'tbl': 'post_comment',
|
||||
'tbl_default': 'v_post_comment',
|
||||
'tbl_alt': 'v_post_comment_detail',
|
||||
'tbl_update': 'post_comment',
|
||||
'mdl': Post_Comment_Base,
|
||||
'mdl_default': Post_Comment_Base,
|
||||
'mdl_in': Post_Comment_Base,
|
||||
'mdl_out': Post_Comment_Base,
|
||||
# Legacy V2 keys:
|
||||
'table_name': 'v_post_comment',
|
||||
'table_name_alt': 'v_post_comment_detail',
|
||||
'tbl_name_update': 'post_comment',
|
||||
'base_name': Post_Comment_Base,
|
||||
'public_read': True,
|
||||
'exp_default': [
|
||||
'post_comment_id_random',
|
||||
'account_id_random', 'post_id_random',
|
||||
'content',
|
||||
'enable', 'hide', 'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on',
|
||||
},
|
||||
'post_comment': {
|
||||
'tbl': 'post_comment',
|
||||
'tbl_default': 'v_post_comment',
|
||||
'tbl_alt': 'v_post_comment_detail',
|
||||
'tbl_update': 'post_comment',
|
||||
'mdl': Post_Comment_Base,
|
||||
'mdl_default': Post_Comment_Base,
|
||||
'mdl_in': Post_Comment_Base,
|
||||
'mdl_out': Post_Comment_Base,
|
||||
# Legacy V2 keys:
|
||||
'table_name': 'v_post_comment',
|
||||
'table_name_alt': 'v_post_comment_detail',
|
||||
'tbl_name_update': 'post_comment',
|
||||
'base_name': Post_Comment_Base,
|
||||
'exp_default': [
|
||||
'post_comment_id_random',
|
||||
'account_id_random', 'post_id_random',
|
||||
'content',
|
||||
'enable', 'hide', 'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on',
|
||||
],
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'id', 'post_id', 'account_id', 'person_id', 'user_id', 'external_person_id',
|
||||
'post_comment_id_random', 'account_id_random', 'post_id_random',
|
||||
'person_id_random', 'user_id_random', 'content', 'enable', 'hide',
|
||||
'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on'
|
||||
],
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'id', 'post_id', 'person_id', 'user_id', 'account_id',
|
||||
'post_comment_id_random', 'account_id_random', 'post_id_random',
|
||||
'person_id_random', 'user_id_random', 'content', 'enable', 'hide',
|
||||
'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on'
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
'site': {
|
||||
'tbl': 'site',
|
||||
'tbl_default': 'site',
|
||||
@@ -125,7 +124,8 @@ cms_obj_li = {
|
||||
'searchable_fields': [
|
||||
'id', 'account_id', 'site_id',
|
||||
'id_random', 'account_id_random', 'site_id_random',
|
||||
'fqdn', 'enable', 'created_on', 'updated_on'
|
||||
'fqdn', 'access_key', 'site_access_key', 'site_domain_access_key',
|
||||
'enable', 'created_on', 'updated_on'
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ core_obj_li = {
|
||||
'base_name': Activity_Log_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'activity_log_id_random', 'account_id_random', 'person_id_random',
|
||||
'id', 'account_id', 'person_id', 'user_id',
|
||||
'id_random', 'activity_log_id_random', 'account_id_random', 'person_id_random',
|
||||
'user_id_random', 'external_client_id', 'name', 'description',
|
||||
'source', 'url_root', 'url_full_path', 'object_type',
|
||||
'object_id_random', 'action', 'action_with', 'action_on_type',
|
||||
@@ -66,7 +67,7 @@ core_obj_li = {
|
||||
'base_name': Account_Cfg_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'id', 'account_id', 'account_cfg_id_random', 'account_id_random', 'account_code',
|
||||
'id', 'account_id', 'id_random', 'account_cfg_id_random', 'account_id_random', 'account_code',
|
||||
'account_name', 'account_short_name', 'default_no_reply_email',
|
||||
'default_no_reply_name', 'confirm_email', 'help_event_email',
|
||||
'help_general_email', 'help_tech_email', 'stripe_account_id',
|
||||
@@ -87,7 +88,7 @@ core_obj_li = {
|
||||
'base_name': Address_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'id', 'account_id', 'contact_id', 'address_id_random', 'account_id_random',
|
||||
'id', 'account_id', 'contact_id', 'id_random', 'address_id_random', 'account_id_random',
|
||||
'for_type', 'for_id_random', 'contact_id_random', 'name', 'attention_to',
|
||||
'organization_name', 'line_1', 'line_2', 'line_3', 'city', 'country_subdivision_code',
|
||||
'country_subdivision_name', 'state_province', 'postal_code',
|
||||
@@ -109,7 +110,7 @@ core_obj_li = {
|
||||
'base_name': Contact_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'id', 'account_id', 'contact_id_random', 'account_id_random', 'for_type', 'for_id_random',
|
||||
'id', 'account_id', 'id_random', 'contact_id_random', 'account_id_random', 'for_type', 'for_id_random',
|
||||
'name', 'title', 'tagline', 'description', 'timezone_name',
|
||||
'email', 'email_status', 'phone_mobile', 'phone_office',
|
||||
'website_url', 'website_name', 'enable', 'hide', 'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on'
|
||||
@@ -130,7 +131,7 @@ core_obj_li = {
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'id', 'account_id', 'person_id', 'user_id',
|
||||
'data_store_id_random', 'account_id_random', 'for_type', 'for_id_random',
|
||||
'id_random', 'data_store_id_random', 'account_id_random', 'for_type', 'for_id_random',
|
||||
'person_id_random', 'user_id_random', 'code', 'name', 'description',
|
||||
'type', 'text', 'meta_text', 'access', 'enable', 'hide', 'priority',
|
||||
'sort', 'group', 'notes', 'created_on', 'updated_on'
|
||||
@@ -151,7 +152,7 @@ core_obj_li = {
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'id', 'account_id', 'contact_id', 'person_id', 'user_id',
|
||||
'organization_id_random', 'account_id_random', 'contact_id_random',
|
||||
'id_random', 'organization_id_random', 'account_id_random', 'contact_id_random',
|
||||
'person_id_random', 'user_id_random', 'name', 'tagline', 'description',
|
||||
'company', 'nonprofit', 'enable', 'hide', 'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on'
|
||||
],
|
||||
@@ -178,7 +179,7 @@ core_obj_li = {
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'id', 'account_id', 'contact_id', 'organization_id', 'user_id', 'membership_person_id',
|
||||
'person_id_random', 'account_id_random', 'contact_id_random',
|
||||
'id_random', 'person_id_random', 'account_id_random', 'contact_id_random',
|
||||
'organization_id_random', 'user_id_random', 'membership_person_id_random',
|
||||
'title_names', 'given_name', 'middle_name',
|
||||
'family_name', 'designations', 'professional_title', 'full_name',
|
||||
|
||||
@@ -16,8 +16,9 @@ events_exhibits_obj_li = {
|
||||
'base_name': Event_Exhibit_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'event_exhibit_id', 'event_exhibit_id_random', 'account_id', 'account_id_random', 'event_id_random',
|
||||
'organization_id_random', 'contact_id_random', 'person_id_random',
|
||||
'id', 'event_exhibit_id', 'account_id', 'event_id', 'organization_id', 'contact_id', 'person_id', 'status_id',
|
||||
'id_random', 'event_exhibit_id_random', 'account_id_random', 'event_id_random',
|
||||
'organization_id_random', 'contact_id_random', 'person_id_random', 'status_id_random',
|
||||
'code', 'name', 'tagline', 'description', 'enable', 'hide',
|
||||
'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on'
|
||||
],
|
||||
@@ -28,15 +29,20 @@ events_exhibits_obj_li = {
|
||||
'tbl_update': 'event_exhibit_tracking',
|
||||
'mdl': Event_Exhibit_Tracking_Base,
|
||||
'mdl_default': Event_Exhibit_Tracking_Base,
|
||||
'mdl_in': Event_Exhibit_Tracking_Base,
|
||||
'mdl_out': Event_Exhibit_Tracking_Base,
|
||||
# Legacy V2 keys:
|
||||
'table_name': 'v_event_exhibit_tracking',
|
||||
'tbl_name_update': 'event_exhibit_tracking',
|
||||
'base_name': Event_Exhibit_Tracking_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'event_exhibit_tracking_id_random', 'event_id_random',
|
||||
'id', 'event_exhibit_tracking_id', 'account_id', 'event_id', 'event_exhibit_id', 'event_person_id', 'event_badge_id',
|
||||
'id_random', 'event_exhibit_tracking_id_random', 'account_id_random', 'event_id_random',
|
||||
'event_exhibit_id_random', 'event_person_id_random',
|
||||
'event_badge_id_random', 'external_person_id', 'enable', 'hide',
|
||||
'event_badge_id_random', 'external_person_id',
|
||||
'event_badge_full_name', 'event_badge_affiliations', 'event_badge_email', 'event_badge_location',
|
||||
'enable', 'hide',
|
||||
'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on'
|
||||
],
|
||||
},
|
||||
|
||||
@@ -20,7 +20,6 @@ events_general_obj_li = {
|
||||
'tbl_name_update': 'event',
|
||||
'base_name': Event_Base,
|
||||
'base_name_alt': Event_Meeting_Flat_Base,
|
||||
'public_read': True,
|
||||
'exp_default': [
|
||||
'event_id_random',
|
||||
'conference', 'type',
|
||||
@@ -47,7 +46,8 @@ events_general_obj_li = {
|
||||
'account_id', 'event_id',
|
||||
'event_id_random', 'account_id_random', 'event_code', 'conference',
|
||||
'type', 'name', 'summary', 'description', 'format', 'timezone',
|
||||
'location_text', 'status', 'enable', 'hide', 'priority', 'sort',
|
||||
'location_text', 'physical', 'virtual', 'external_person_id',
|
||||
'status', 'enable', 'hide', 'priority', 'sort',
|
||||
'group', 'notes', 'created_on', 'updated_on', 'default_qry_str'
|
||||
],
|
||||
},
|
||||
@@ -65,15 +65,17 @@ events_general_obj_li = {
|
||||
'table_name_alt': 'v_event_file',
|
||||
'tbl_name_update': 'event_file',
|
||||
'base_name': Event_File_Base,
|
||||
'public_read': True,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'account_id', 'account_id_random',
|
||||
'event_id', 'event_file_id', 'hosted_file_id',
|
||||
'event_file_id_random', 'hosted_file_id_random', 'event_id_random',
|
||||
'event_exhibit_id_random', 'event_location_id_random',
|
||||
'event_presentation_id_random', 'event_presenter_id_random',
|
||||
'event_session_id_random', 'event_track_id_random', 'filename',
|
||||
'extension', 'title', 'description', 'file_purpose', 'enable', 'hide',
|
||||
'extension', 'title', 'description', 'file_purpose', 'hosted_file_size',
|
||||
'event_session_start_datetime', 'event_session_name',
|
||||
'enable', 'hide',
|
||||
'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on'
|
||||
],
|
||||
},
|
||||
@@ -113,6 +115,7 @@ events_general_obj_li = {
|
||||
'base_name': Event_Cfg_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'account_id', 'account_id_random',
|
||||
'event_cfg_id_random', 'event_id_random',
|
||||
'status', 'notes', 'updated_on'
|
||||
],
|
||||
|
||||
@@ -20,6 +20,7 @@ events_presentation_obj_li = {
|
||||
'base_name': Event_Abstract_In,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'account_id', 'account_id_random',
|
||||
'event_abstract_id_random', 'event_id_random', 'event_person_id_random',
|
||||
'code', 'external_id', 'name', 'description', 'abstract', 'enable',
|
||||
'hide', 'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on'
|
||||
@@ -41,6 +42,7 @@ events_presentation_obj_li = {
|
||||
'base_name': Event_Location_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'account_id', 'account_id_random',
|
||||
'event_location_id_random', 'event_id_random', 'code', 'name',
|
||||
'description', 'location_type', 'internal_use', 'enable', 'hide',
|
||||
'public', 'public_hide', 'hide_event_launcher', 'priority', 'sort',
|
||||
@@ -61,9 +63,9 @@ events_presentation_obj_li = {
|
||||
'table_name_alt': 'v_event_presentation_w_file_count',
|
||||
'tbl_name_update': 'event_presentation',
|
||||
'base_name': Event_Presentation_Base,
|
||||
'public_read': True,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'account_id', 'account_id_random',
|
||||
'event_presentation_id_random', 'event_id_random',
|
||||
'event_abstract_id_random', 'event_location_id_random',
|
||||
'event_session_id_random', 'event_track_id_random', 'code', 'name',
|
||||
@@ -86,7 +88,6 @@ events_presentation_obj_li = {
|
||||
'table_name_alt': 'v_event_presenter_w_file_count',
|
||||
'tbl_name_update': 'event_presenter',
|
||||
'base_name': Event_Presenter_Base,
|
||||
'public_read': True,
|
||||
'exp_default': [
|
||||
'event_presenter_id_random',
|
||||
'title_names', 'given_name', 'middle_name', 'family_name', 'designations',
|
||||
@@ -99,10 +100,13 @@ events_presentation_obj_li = {
|
||||
],
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'account_id', 'account_id_random',
|
||||
'event_presenter_id_random', 'event_id_random',
|
||||
'event_person_id_random', 'event_presentation_id_random',
|
||||
'event_session_id_random', 'person_id_random', 'code', 'informal_name',
|
||||
'given_name', 'family_name', 'full_name', 'email', 'role', 'enable',
|
||||
'given_name', 'family_name', 'full_name', 'email', 'role', 'biography', 'agree',
|
||||
'event_presentation_start_datetime',
|
||||
'enable',
|
||||
'hide', 'public', 'public_hide', 'hide_event_launcher', 'priority',
|
||||
'sort', 'group', 'notes', 'created_on', 'updated_on', 'default_qry_str'
|
||||
],
|
||||
@@ -121,15 +125,18 @@ events_presentation_obj_li = {
|
||||
'table_name': 'v_event_session',
|
||||
'tbl_name_update': 'event_session',
|
||||
'base_name': Event_Session_Base,
|
||||
'public_read': True,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'account_id', 'account_id_random',
|
||||
'event_session_id_random', 'event_id_random',
|
||||
'event_location_id_random', 'event_track_id_random', 'code', 'name',
|
||||
'description', 'type_code', 'start_datetime', 'end_datetime',
|
||||
'enable', 'hide', 'public', 'public_hide', 'hide_event_launcher',
|
||||
'enable', 'hide', 'poc_agree', 'file_count', 'file_count_all',
|
||||
'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': {
|
||||
@@ -144,9 +151,10 @@ events_presentation_obj_li = {
|
||||
'base_name': Event_Track_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'account_id', 'account_id_random',
|
||||
'event_track_id_random', 'event_id_random',
|
||||
'event_location_id_random', 'name', 'description', 'track_type',
|
||||
'enable', 'hide', 'public', 'public_hide', 'hide_event_launcher',
|
||||
'enable', 'hide', 'poc_agree', 'file_count', 'file_count_all', 'public', 'public_hide', 'hide_event_launcher',
|
||||
'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on'
|
||||
],
|
||||
},
|
||||
|
||||
@@ -12,7 +12,9 @@ events_registration_obj_li = {
|
||||
'tbl_alt': 'v_event_badge_only',
|
||||
'tbl_update': 'event_badge',
|
||||
'mdl': Event_Badge_Base,
|
||||
'mdl_default': Event_Badge_Basic_Base,
|
||||
'mdl_default': Event_Badge_Base,
|
||||
'mdl_in': Event_Badge_Base,
|
||||
'mdl_out': Event_Badge_Base,
|
||||
# Legacy V2 keys:
|
||||
'table_name': 'v_event_badge',
|
||||
'table_name_alt': 'v_event_badge_only',
|
||||
@@ -20,11 +22,9 @@ events_registration_obj_li = {
|
||||
'base_name': Event_Badge_Basic_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'account_id', 'event_badge_id', 'event_badge_template_id',
|
||||
'event_id',
|
||||
'account_id_random', 'event_badge_id_random', 'event_badge_template_id_random',
|
||||
'event_id_random',
|
||||
'event_person_id_random', 'external_id', 'pronouns', 'informal_name',
|
||||
'id', 'event_badge_id', 'account_id', 'event_id', 'event_id_only', 'event_badge_template_id', 'event_person_id', 'person_id',
|
||||
'id_random', 'event_badge_id_random', 'account_id_random', 'event_id_random', 'event_id_random_only', 'event_badge_template_id_random', 'event_person_id_random', 'person_id_random',
|
||||
'external_id', 'pronouns', 'informal_name',
|
||||
'title_names', 'given_name', 'middle_name', 'family_name', 'designations',
|
||||
'professional_title', 'full_name', 'affiliations', 'email', 'phone',
|
||||
'location', 'allow_tracking', 'print_count', 'print_first_datetime',
|
||||
@@ -33,6 +33,12 @@ events_registration_obj_li = {
|
||||
'member_status', 'registration_type_code',
|
||||
'notes', 'created_on', 'updated_on', 'default_qry_str'
|
||||
],
|
||||
# Allow nested operations under both `event` and `event_person` parents.
|
||||
# `event_badge` is directly linked to `event_person` (FK: event_person_id),
|
||||
# but views expose it under `event` as well. Explicitly register both
|
||||
# so nested CRUD routes like POST /v3/crud/event_person/{id}/event_badge/
|
||||
# will be accepted by the generic nested router.
|
||||
'parent_types': ['event', 'event_person'],
|
||||
},
|
||||
'event_badge_template': {
|
||||
'tbl': 'event_badge_template',
|
||||
@@ -48,9 +54,9 @@ events_registration_obj_li = {
|
||||
'base_name': Event_Badge_Template_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'event_badge_template_id', 'event_id',
|
||||
'event_badge_template_id_random', 'event_id_random', 'name',
|
||||
'description', 'layout', 'notes', 'enable',
|
||||
'id', 'event_badge_template_id', 'event_id', 'account_id',
|
||||
'id_random', 'event_badge_template_id_random', 'event_id_random', 'account_id_random',
|
||||
'name', 'description', 'layout', 'notes', 'enable',
|
||||
'created_on', 'updated_on'
|
||||
],
|
||||
},
|
||||
@@ -68,7 +74,8 @@ events_registration_obj_li = {
|
||||
'base_name': Event_Person_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'event_person_id_random', 'account_id_random', 'event_id_random',
|
||||
'id', 'event_person_id', 'account_id', 'event_id', 'event_badge_id', 'person_id', 'user_id',
|
||||
'id_random', 'event_person_id_random', 'account_id_random', 'event_id_random',
|
||||
'event_badge_id_random', 'person_id_random', 'user_id_random',
|
||||
'external_id', 'external_person_id', 'informal_name', 'given_name',
|
||||
'family_name', 'full_name', 'email', 'enable', 'hide', 'priority', 'sort', 'group',
|
||||
@@ -89,7 +96,8 @@ events_registration_obj_li = {
|
||||
'base_name': Event_Person_Profile_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'event_person_profile_id_random', 'account_id_random',
|
||||
'id', 'event_person_profile_id', 'account_id', 'contact_id', 'event_id', 'event_person_id', 'organization_id',
|
||||
'id_random', 'event_person_profile_id_random', 'account_id_random',
|
||||
'contact_id_random', 'event_id_random', 'event_person_id_random',
|
||||
'organization_id_random', 'pronouns', 'informal_name', 'given_name',
|
||||
'family_name', 'professional_title', 'full_name', 'affiliations',
|
||||
@@ -103,13 +111,16 @@ events_registration_obj_li = {
|
||||
'tbl_update': 'event_person_tracking',
|
||||
'mdl': Event_Person_Tracking_Base,
|
||||
'mdl_default': Event_Person_Tracking_Base,
|
||||
'mdl_in': Event_Person_Tracking_Base,
|
||||
'mdl_out': Event_Person_Tracking_Base,
|
||||
# Legacy V2 keys:
|
||||
'table_name': 'v_event_person_tracking',
|
||||
'tbl_name_update': 'event_person_tracking',
|
||||
'base_name': Event_Person_Tracking_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'event_person_tracking_id_random', 'event_id_random',
|
||||
'id', 'event_person_tracking_id', 'account_id', 'event_id', 'event_session_id', 'event_person_id',
|
||||
'id_random', 'event_person_tracking_id_random', 'account_id_random', 'event_id_random',
|
||||
'event_session_id_random', 'event_person_id_random',
|
||||
'check_in_out', 'in_datetime', 'out_datetime', 'enable', 'notes',
|
||||
'created_on', 'updated_on'
|
||||
@@ -129,7 +140,8 @@ events_registration_obj_li = {
|
||||
'base_name': Event_Registration_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'event_registration_id_random', 'account_id_random',
|
||||
'id', 'event_registration_id', 'account_id', 'event_id', 'organization_id', 'contact_id', 'person_id',
|
||||
'id_random', 'event_registration_id_random', 'account_id_random',
|
||||
'event_id_random', 'organization_id_random', 'contact_id_random',
|
||||
'person_id_random', 'notes', 'created_on',
|
||||
'updated_on'
|
||||
|
||||
@@ -54,3 +54,6 @@ journal_obj_li = {
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# Aliases for shorter/cleaner URLs
|
||||
journal_obj_li['entry'] = journal_obj_li['journal_entry']
|
||||
|
||||
115
app/object_definitions/legacy_v1.py
Normal file
115
app/object_definitions/legacy_v1.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from app.models.account_models import *
|
||||
from app.models.account_cfg_models import *
|
||||
from app.models.activity_log_models import *
|
||||
from app.models.address_models import *
|
||||
from app.models.archive_models import *
|
||||
from app.models.archive_content_models import *
|
||||
from app.models.contact_models import *
|
||||
from app.models.cont_edu_cert_models import *
|
||||
from app.models.cont_edu_cert_person_models import *
|
||||
from app.models.data_store_models import *
|
||||
from app.models.event_models import *
|
||||
from app.models.event_abstract_models import *
|
||||
from app.models.event_badge_models import *
|
||||
from app.models.event_device_models import *
|
||||
from app.models.event_exhibit_models import *
|
||||
from app.models.event_exhibit_tracking_models import *
|
||||
from app.models.event_file_models import *
|
||||
from app.models.event_location_models import *
|
||||
from app.models.event_person_models import *
|
||||
from app.models.event_person_tracking_models import *
|
||||
from app.models.event_presentation_models import *
|
||||
from app.models.event_presenter_models import *
|
||||
from app.models.event_registration_models import *
|
||||
from app.models.event_session_models import *
|
||||
from app.models.event_track_models import *
|
||||
from app.models.grant_models import *
|
||||
from app.models.hosted_file_models import *
|
||||
from app.models.journal_models import *
|
||||
from app.models.journal_entry_models import *
|
||||
from app.models.log_client_viewing_models import Log_Client_Viewing_Base
|
||||
from app.models.membership_cfg_models import *
|
||||
from app.models.membership_group_models import *
|
||||
from app.models.membership_person_group_models import *
|
||||
from app.models.membership_person_models import *
|
||||
from app.models.membership_person_profile_models import *
|
||||
from app.models.membership_type_models import *
|
||||
from app.models.membership_person_type_models import *
|
||||
from app.models.order_models import *
|
||||
from app.models.order_cart_models import *
|
||||
from app.models.organization_models import *
|
||||
from app.models.page_models import *
|
||||
from app.models.person_models import *
|
||||
from app.models.product_models import *
|
||||
from app.models.post_models import *
|
||||
from app.models.post_comment_models import *
|
||||
from app.models.site_models import *
|
||||
from app.models.site_domain_models import *
|
||||
from app.models.sponsorship_cfg_models import *
|
||||
from app.models.sponsorship_models import *
|
||||
from app.models.user_models import *
|
||||
from app.models.user_role_models import *
|
||||
from app.models.e_stripe_models import *
|
||||
|
||||
# Registry for V1 CRUD Templates
|
||||
obj_type_li = {}
|
||||
|
||||
obj_type_li['account'] = {'table_name': 'account', 'tbl_name_update': 'account', 'base_name': Account_Base}
|
||||
obj_type_li['account_cfg'] = {'table_name': 'v_account_cfg', 'tbl_name_update': 'account_cfg', 'base_name': Account_Cfg_Base}
|
||||
obj_type_li['activity_log'] = {'table_name': 'activity_log', 'tbl_name_update': 'activity_log', 'base_name': Activity_Log_Base}
|
||||
obj_type_li['address'] = {'table_name': 'v_address', 'tbl_name_update': 'address', 'base_name': Address_Base}
|
||||
obj_type_li['contact'] = {'table_name': 'v_contact', 'tbl_name_update': 'contact', 'base_name': Contact_Base}
|
||||
obj_type_li['data_store'] = {'table_name': 'v_data_store', 'tbl_name_update': 'data_store', 'base_name': Data_Store_Base}
|
||||
obj_type_li['hosted_file'] = {'table_name': 'v_hosted_file', 'tbl_name_update': 'hosted_file', 'base_name': Hosted_File_Base}
|
||||
obj_type_li['log_client_viewing'] = {'table_name': 'log_client_viewing', 'tbl_name_update': 'log_client_viewing', 'base_name': Log_Client_Viewing_Base}
|
||||
obj_type_li['order'] = {'table_name': 'v_order', 'tbl_name_update': 'order', 'base_name': Order_Base}
|
||||
obj_type_li['order_cart'] = {'table_name': 'v_order_cart', 'tbl_name_update': 'order_cart', 'base_name': Order_Cart_Base}
|
||||
obj_type_li['order_cart_line'] = {'table_name': 'v_order_cart_line', 'tbl_name_update': 'order_cart_line', 'base_name': Order_Cart_Line_Base}
|
||||
obj_type_li['order_line'] = {'table_name': 'v_order_line', 'tbl_name_update': 'order_line', 'base_name': Order_Line_Base}
|
||||
obj_type_li['organization'] = {'table_name': 'v_organization', 'tbl_name_update': 'organization', 'base_name': Organization_Base}
|
||||
obj_type_li['page'] = {'table_name': 'page', 'tbl_name_update': 'page', 'base_name': Page_Base}
|
||||
obj_type_li['person'] = {'table_name': 'v_person', 'tbl_name_update': 'person', 'base_name': Person_Base}
|
||||
obj_type_li['site'] = {'table_name': 'site', 'tbl_name_update': 'site', 'base_name': Site_Base}
|
||||
obj_type_li['site_domain'] = {'table_name': 'v_site_domain', 'table_name_alt': 'v_site_domain_fqdn_id', 'tbl_name_update': 'site_domain', 'base_name': Site_Domain_Base, 'base_name_alt': Site_Domain_FQDN_ID_Base}
|
||||
obj_type_li['user'] = {'table_name': 'v_user', 'tbl_name_update': 'user', 'base_name': User_Base}
|
||||
obj_type_li['user_role'] = {'table_name': 'v_user_role', 'tbl_name_update': 'user_role', 'base_name': User_Role_Base}
|
||||
|
||||
obj_type_li['lu_country'] = {'table_name': 'lu_country', 'tbl_name_update': 'lu_country', 'base_name': None}
|
||||
obj_type_li['lu_country_subdivision'] = {'table_name': 'lu_country_subdivision', 'tbl_name_update': 'lu_country_subdivision', 'base_name': None}
|
||||
obj_type_li['lu_time_zone'] = {'table_name': 'v_lu_time_zone', 'tbl_name_update': 'lu_time_zone', 'base_name': None}
|
||||
|
||||
obj_type_li['archive'] = {'table_name': 'v_archive', 'table_name_alt': 'v_archive', 'tbl_name_update': 'archive', 'base_name': Archive_Base}
|
||||
obj_type_li['archive_content'] = {'table_name': 'v_archive_content', 'table_name_alt': 'v_archive_content', 'tbl_name_update': 'archive_content', 'base_name': Archive_Content_Base}
|
||||
obj_type_li['cont_edu_cert'] = {'table_name': 'v_cont_edu_cert', 'tbl_name_update': 'cont_edu_cert', 'base_name': Cont_Edu_Cert_Base}
|
||||
obj_type_li['cont_edu_cert_person'] = {'table_name': 'v_cont_edu_cert_person', 'tbl_name_update': 'cont_edu_cert_person', 'base_name': Cont_Edu_Cert_Person_Base}
|
||||
obj_type_li['event'] = {'table_name': 'v_event', 'table_name_alt': 'v_event_w_file_count', 'tbl_name_update': 'event', 'base_name': Event_Base, 'base_name_alt': Event_Meeting_Flat_Base}
|
||||
obj_type_li['event_abstract'] = {'table_name': 'v_event_abstract', 'tbl_name_update': 'event_abstract', 'base_name': Event_Abstract_In}
|
||||
obj_type_li['event_badge'] = {'table_name': 'v_event_badge', 'table_name_alt': 'v_event_badge_only', 'tbl_name_update': 'event_badge', 'base_name': Event_Badge_Base, 'base_name_alt': Event_Badge_Basic_Base}
|
||||
obj_type_li['event_device'] = {'table_name': 'event_device', 'table_name_alt': 'v_event_device', 'tbl_name_update': 'event_device', 'base_name': Event_Device_Base}
|
||||
obj_type_li['event_exhibit'] = {'table_name': 'v_event_exhibit', 'tbl_name_update': 'event_exhibit', 'base_name': Event_Exhibit_Base}
|
||||
obj_type_li['event_exhibit_tracking'] = {'table_name': 'v_event_exhibit_tracking', 'tbl_name_update': 'event_exhibit_tracking', 'base_name': Event_Exhibit_Tracking_Base}
|
||||
obj_type_li['event_file'] = {'table_name': 'v_event_file_simple', 'table_name_alt': 'v_event_file', 'tbl_name_update': 'event_file', 'base_name': Event_File_Base}
|
||||
obj_type_li['event_location'] = {'table_name': 'v_event_location', 'table_name_alt': 'v_event_location_w_file_count', 'tbl_name_update': 'event_location', 'base_name': Event_Location_Base}
|
||||
obj_type_li['event_person'] = {'table_name': 'v_event_person', 'tbl_name_update': 'event_person', 'base_name': Event_Person_Base}
|
||||
obj_type_li['event_person_tracking'] = {'table_name': 'v_event_person_tracking', 'tbl_name_update': 'event_person_tracking', 'base_name': Event_Person_Tracking_Base}
|
||||
obj_type_li['event_presentation'] = {'table_name': 'v_event_presentation', 'table_name_alt': 'v_event_presentation_w_file_count', 'tbl_name_update': 'event_presentation', 'base_name': Event_Presentation_Base}
|
||||
obj_type_li['event_presenter'] = {'table_name': 'v_event_presenter', 'table_name_alt': 'v_event_presenter_w_file_count', 'tbl_name_update': 'event_presenter', 'base_name': Event_Presenter_Base}
|
||||
obj_type_li['event_registration'] = {'table_name': 'v_event_registration', 'tbl_name_update': 'event_registration', 'base_name': Event_Registration_Base}
|
||||
obj_type_li['event_session'] = {'table_name': 'v_event_session', 'table_name_alt': 'v_event_session_w_file_count', 'tbl_name_update': 'event_session', 'base_name': Event_Session_Base}
|
||||
obj_type_li['event_track'] = {'table_name': 'v_event_track', 'tbl_name_update': 'event_track', 'base_name': Event_Track_Base}
|
||||
obj_type_li['grant'] = {'table_name': 'v_grant', 'tbl_name_update': 'grant', 'base_name': Grant_Base}
|
||||
obj_type_li['journal'] = {'table_name': 'v_journal', 'table_name_alt': 'v_journal', 'tbl_name_update': 'journal', 'base_name': Journal_Base}
|
||||
obj_type_li['journal_entry'] = {'table_name': 'v_journal_entry', 'table_name_alt': 'v_journal_entry', 'tbl_name_update': 'journal_entry', 'base_name': Journal_Entry_Base}
|
||||
obj_type_li['membership_cfg'] = {'table_name': 'v_membership_cfg', 'tbl_name_update': 'membership_cfg', 'base_name': Membership_Cfg_Base}
|
||||
obj_type_li['membership_group'] = {'table_name': 'v_membership_group', 'tbl_name_update': 'membership_group', 'base_name': Membership_Group_Base}
|
||||
obj_type_li['membership_person_group'] = {'table_name': 'v_membership_person_group', 'tbl_name_update': 'membership_person_group', 'base_name': Membership_Person_Group_Base}
|
||||
obj_type_li['membership_person'] = {'table_name': 'v_membership_person', 'tbl_name_update': 'membership_person', 'base_name': Membership_Person_Base}
|
||||
obj_type_li['membership_person_profile'] = {'table_name': 'v_membership_person_profile', 'tbl_name_update': 'membership_person_profile', 'base_name': Membership_Person_Profile_Base}
|
||||
obj_type_li['membership_type'] = {'table_name': 'v_membership_type', 'tbl_name_update': 'membership_type', 'base_name': Membership_Type_Base}
|
||||
obj_type_li['membership_person_type'] = {'table_name': 'v_membership_person_type', 'tbl_name_update': 'membership_person_type', 'base_name': Membership_Person_Type_Base}
|
||||
obj_type_li['post'] = {'table_name': 'v_post', 'table_name_alt': 'v_post', 'tbl_name_update': 'post', 'base_name': Post_Base}
|
||||
obj_type_li['post_comment'] = {'table_name': 'v_post_comment', 'table_name_alt': 'v_post_comment', 'tbl_name_update': 'post_comment', 'base_name': Post_Comment_Base}
|
||||
obj_type_li['product'] = {'table_name': 'v_product', 'tbl_name_update': 'product', 'base_name': Product_Base}
|
||||
obj_type_li['sponsorship'] = {'table_name': 'v_sponsorship', 'tbl_name_update': 'sponsorship', 'base_name': Sponsorship_Base}
|
||||
obj_type_li['sponsorship_cfg'] = {'table_name': 'v_sponsorship_cfg', 'tbl_name_update': 'sponsorship_cfg', 'base_name': Sponsorship_Cfg_Base}
|
||||
obj_type_li['stripe_log'] = {'table_name': 'stripe_log', 'tbl_name_update': 'stripe_log', 'base_name': Stripe_Log_Base_In}
|
||||
@@ -1,3 +1,5 @@
|
||||
from app.models.lookup_models import Lu_Country_V3_Base, Lu_Country_Subdivision_V3_Base, Lu_Time_Zone_V3_Base
|
||||
|
||||
lu_obj_li = {
|
||||
'lu_country': {
|
||||
'tbl': 'lu_country',
|
||||
@@ -53,4 +55,31 @@ lu_obj_li = {
|
||||
'id', 'timezone', 'offset', 'name'
|
||||
],
|
||||
},
|
||||
'lu_v3_country': {
|
||||
'tbl': 'lu_v3_country',
|
||||
'tbl_default': 'v_lu_v3_country',
|
||||
'tbl_update': 'lu_v3_country',
|
||||
'mdl_default': Lu_Country_V3_Base,
|
||||
'searchable_fields': [
|
||||
'id_random', 'group', 'name', 'alpha_2_code', 'alpha_3_code', 'numeric_code', 'english_short_name'
|
||||
],
|
||||
},
|
||||
'lu_v3_country_subdivision': {
|
||||
'tbl': 'lu_v3_country_subdivision',
|
||||
'tbl_default': 'v_lu_v3_country_subdivision',
|
||||
'tbl_update': 'lu_v3_country_subdivision',
|
||||
'mdl_default': Lu_Country_Subdivision_V3_Base,
|
||||
'searchable_fields': [
|
||||
'id_random', 'group', 'name', 'country_alpha_2_code', 'code'
|
||||
],
|
||||
},
|
||||
'lu_v3_time_zone': {
|
||||
'tbl': 'lu_v3_time_zone',
|
||||
'tbl_default': 'v_lu_v3_time_zone',
|
||||
'tbl_update': 'lu_v3_time_zone',
|
||||
'mdl_default': Lu_Time_Zone_V3_Base,
|
||||
'searchable_fields': [
|
||||
'id_random', 'group', 'name', 'timezone'
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -33,9 +33,9 @@ other_obj_li = {
|
||||
],
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'sponsorship_id_random', 'account_id_random', 'name', 'description',
|
||||
'website_url', 'level_str', 'enable', 'hide', 'priority', 'group',
|
||||
'created_on', 'updated_on'
|
||||
'id', 'account_id', 'id_random', 'sponsorship_id_random', 'account_id_random',
|
||||
'name', 'description', 'website_url', 'level_str', 'enable', 'hide',
|
||||
'priority', 'group', 'created_on', 'updated_on'
|
||||
],
|
||||
},
|
||||
'sponsorship_cfg': {
|
||||
@@ -50,8 +50,9 @@ other_obj_li = {
|
||||
'base_name': Sponsorship_Cfg_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'sponsorship_cfg_id_random', 'account_id_random', 'name',
|
||||
'description', 'enable', 'hide', 'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on'
|
||||
'id', 'account_id', 'id_random', 'sponsorship_cfg_id_random', 'account_id_random',
|
||||
'name', 'description', 'enable', 'hide', 'priority', 'sort', 'group',
|
||||
'notes', 'created_on', 'updated_on'
|
||||
],
|
||||
},
|
||||
'archive': {
|
||||
@@ -85,9 +86,10 @@ other_obj_li = {
|
||||
],
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'archive_id_random', 'account_id_random', 'archive_type_id_random',
|
||||
'archive_type', 'name', 'description', 'filename', 'original_location', 'enable',
|
||||
'hide', 'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on'
|
||||
'id', 'account_id', 'id_random', 'archive_id_random', 'account_id_random',
|
||||
'archive_type_id_random', 'archive_type', 'name', 'description',
|
||||
'filename', 'original_location', 'enable', 'hide', 'priority',
|
||||
'sort', 'group', 'notes', 'created_on', 'updated_on'
|
||||
],
|
||||
},
|
||||
'archive_content': {
|
||||
@@ -102,11 +104,11 @@ other_obj_li = {
|
||||
'table_name': 'v_archive_content',
|
||||
'tbl_name_update': 'archive_content',
|
||||
'base_name': Archive_Content_Base,
|
||||
'public_read': True,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'archive_content_id_random', 'account_id_random', 'archive_id_random',
|
||||
'archive_content_type', 'lu_media_type', 'name', 'description',
|
||||
'id', 'account_id', 'archive_id', 'hosted_file_id',
|
||||
'id_random', 'archive_content_id_random', 'account_id_random', 'archive_id_random',
|
||||
'archive_content_type', 'lu_media_type', 'external_id', 'code', 'name', 'description',
|
||||
'filename', 'file_extension', 'original_location', 'original_url',
|
||||
'enable', 'hide', 'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on'
|
||||
],
|
||||
@@ -123,7 +125,6 @@ other_obj_li = {
|
||||
'table_name': 'v_hosted_file',
|
||||
'tbl_name_update': 'hosted_file',
|
||||
'base_name': Hosted_File_Base,
|
||||
'public_read': True,
|
||||
'exp_default': [
|
||||
'hosted_file_id_random',
|
||||
'hash_sha256',
|
||||
@@ -136,9 +137,9 @@ other_obj_li = {
|
||||
],
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'id', 'account_id', 'hosted_file_id_random', 'account_id_random',
|
||||
'hash_sha256', 'title', 'description', 'filename', 'extension',
|
||||
'content_type', 'enable', 'hide', 'priority', 'sort', 'group',
|
||||
'id', 'account_id', 'id_random', 'hosted_file_id_random', 'account_id_random',
|
||||
'hash_sha256', 'title', 'description', 'filename', 'extension',
|
||||
'content_type', 'enable', 'hide', 'priority', 'sort', 'group',
|
||||
'notes', 'created_on', 'updated_on'
|
||||
],
|
||||
},
|
||||
@@ -156,8 +157,8 @@ other_obj_li = {
|
||||
'base_name': Hosted_File_Link_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'id', 'account_id', 'hosted_file_id', 'account_id_random',
|
||||
'hosted_file_id_random', 'link_to_type', 'link_to_id_random',
|
||||
'id', 'account_id', 'hosted_file_id', 'id_random', 'account_id_random',
|
||||
'hosted_file_id_random', 'link_to_type', 'link_to_id_random',
|
||||
'created_on', 'updated_on'
|
||||
],
|
||||
},
|
||||
@@ -171,7 +172,8 @@ other_obj_li = {
|
||||
'base_name': Stripe_Log_Base_In,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'stripe_log_id_random', 'account_id_random', 'person_id_random',
|
||||
'id', 'account_id', 'person_id', 'user_id', 'event_id', 'order_id',
|
||||
'id_random', 'stripe_log_id_random', 'account_id_random', 'person_id_random',
|
||||
'user_id_random', 'event_id_random', 'order_id_random', 'type',
|
||||
'status', 'created_on', 'updated_on'
|
||||
],
|
||||
@@ -188,7 +190,8 @@ other_obj_li = {
|
||||
'base_name': Cont_Edu_Cert_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'cont_edu_cert_id_random', 'account_id_random', 'event_id_random',
|
||||
'id', 'account_id', 'event_id',
|
||||
'id_random', 'cont_edu_cert_id_random', 'account_id_random', 'event_id_random',
|
||||
'name', 'description', 'code', 'enable', 'hide', 'priority', 'sort',
|
||||
'group', 'notes', 'created_on', 'updated_on'
|
||||
],
|
||||
@@ -205,7 +208,8 @@ other_obj_li = {
|
||||
'base_name': Cont_Edu_Cert_Person_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'cont_edu_cert_person_id_random', 'cont_edu_cert_id_random',
|
||||
'id', 'cont_edu_cert_id', 'person_id',
|
||||
'id_random', 'cont_edu_cert_person_id_random', 'cont_edu_cert_id_random',
|
||||
'person_id_random', 'enable', 'hide', 'priority', 'sort', 'group', 'notes',
|
||||
'created_on', 'updated_on'
|
||||
],
|
||||
@@ -222,9 +226,9 @@ other_obj_li = {
|
||||
'base_name': Grant_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'grant_id_random', 'account_id_random', 'code', 'name',
|
||||
'description', 'enable', 'hide', 'priority', 'sort', 'group', 'notes',
|
||||
'created_on', 'updated_on'
|
||||
'id', 'account_id', 'id_random', 'grant_id_random', 'account_id_random',
|
||||
'code', 'name', 'description', 'enable', 'hide', 'priority', 'sort',
|
||||
'group', 'notes', 'created_on', 'updated_on'
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,15 +4,16 @@ from typing import Dict, List, Optional, Set, Union
|
||||
from sqlalchemy import text
|
||||
import json
|
||||
import time
|
||||
import secrets
|
||||
# import secrets
|
||||
import jwt as pyjwt # Avoid conflict with app.lib_jwt
|
||||
|
||||
from app.db_connection import db
|
||||
# from app.db_connection import db
|
||||
from app.lib_general import sign_jwt, decode_jwt, log, logging
|
||||
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.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
|
||||
|
||||
@@ -20,10 +21,21 @@ router = APIRouter()
|
||||
|
||||
# --- Passcode Authentication ---
|
||||
|
||||
ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']
|
||||
|
||||
ROLE_TTL = {
|
||||
'super': 8 * 3600, # 8 hours
|
||||
'manager': 24 * 3600, # 24 hours
|
||||
'administrator': 48 * 3600, # 48 hours
|
||||
'trusted': 48 * 3600, # 48 hours
|
||||
'public': 24 * 3600, # 24 hours
|
||||
'authenticated': 12 * 3600, # 12 hours
|
||||
}
|
||||
|
||||
class PasscodeAuthRequest(BaseModel):
|
||||
"""Request model for site-based passcode authentication."""
|
||||
site_id: str = Field(..., description="The random string ID of the site")
|
||||
passcode: str = Field(..., description="The passcode to verify")
|
||||
passcode: str = Field(..., min_length=5, description="The passcode to verify")
|
||||
|
||||
@router.post('/authenticate_passcode', response_model=Resp_Body_Base)
|
||||
async def authenticate_passcode(
|
||||
@@ -53,41 +65,45 @@ async def authenticate_passcode(
|
||||
except Exception as e:
|
||||
log.error(f"Failed to parse access_code_kv_json for site {site_id}: {e}")
|
||||
|
||||
# 3. Verify Passcode and Resolve Role
|
||||
# 3. Verify passcode in explicit priority order (highest privilege wins)
|
||||
matched_role = None
|
||||
for role, code in access_codes.items():
|
||||
if str(code) == str(passcode):
|
||||
for role in ROLE_PRIORITY:
|
||||
code = access_codes.get(role)
|
||||
if code and str(code) == str(passcode):
|
||||
matched_role = role
|
||||
break
|
||||
|
||||
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
|
||||
|
||||
# 5. Mint JWT with complete role flags and per-role TTL
|
||||
payload = {
|
||||
'account_id': account_id_random,
|
||||
'account_id': account_id_random,
|
||||
'super': (matched_role == 'super'),
|
||||
'manager': (matched_role == 'manager'),
|
||||
'administrator': (matched_role == 'administrator'),
|
||||
'manager': (matched_role == 'manager'),
|
||||
'super': (matched_role == 'super'),
|
||||
'trusted': (matched_role == 'trusted'),
|
||||
'public': (matched_role == 'public'),
|
||||
'authenticated': (matched_role == 'authenticated'),
|
||||
'json_str': json.dumps({
|
||||
'auth_type': 'passcode',
|
||||
'site_id': site_id,
|
||||
'role': matched_role
|
||||
'site_id': site_id,
|
||||
'role': matched_role
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
token = sign_jwt(
|
||||
secret_key=settings.JWT_KEY,
|
||||
ttl=3600 * 24, # 24 hour session
|
||||
ttl=ROLE_TTL[matched_role],
|
||||
**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 +114,9 @@ async def authenticate_passcode(
|
||||
|
||||
# --- JWT Request ---
|
||||
|
||||
@router.get('/request_jwt', response_model=Resp_Body_Base)
|
||||
# DEPRECATED — no V3 replacement needed; passcode→JWT is the V3 auth pattern (/api/authenticate_passcode).
|
||||
# No frontend references found. Safe to remove after confirming no live traffic. TODO: remove.
|
||||
@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 +169,8 @@ 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)
|
||||
# DEPRECATED — no active use identified. TODO: remove after confirming no live traffic.
|
||||
@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 +186,10 @@ async def get_api_temp_token(
|
||||
|
||||
# --- Jitsi Token ---
|
||||
|
||||
# NOTE: Actively used by IDAA for video conferences on self-hosted Jitsi (jitsi.dgrzone.com).
|
||||
# JWT_APP_ID and JWT_APP_SECRET must match the values in the Jitsi server .env file.
|
||||
# TODO: Rotate JWT_APP_SECRET — update it here AND in /mnt/nfs_dgr_storage/env/dgr_zone_jitsi/.env (JWT_APP_SECRET) then restart prosody + jicofo.
|
||||
|
||||
JWT_APP_ID = "my_jitsi_app_id"
|
||||
JWT_APP_SECRET = "my_jitsi_app_secret-9876543210"
|
||||
JITSI_DOMAIN = "jitsi.dgrzone.com"
|
||||
@@ -184,14 +207,12 @@ class JitsiTokenRequest(BaseModel):
|
||||
@router.post("/jitsi_token")
|
||||
async def create_jitsi_jwt(request_data: JitsiTokenRequest = Body(...)):
|
||||
log.setLevel(logging.INFO)
|
||||
if not request_data.is_moderator:
|
||||
raise HTTPException(status_code=403, detail="JWT generation is only permitted for moderators.")
|
||||
|
||||
try:
|
||||
payload = {
|
||||
"aud": JWT_APP_ID, "iss": JWT_APP_ID, "sub": JITSI_DOMAIN,
|
||||
"room": request_data.room,
|
||||
"exp": int(time.time()) + 3600,
|
||||
"exp": int(time.time()) + 7200, # 2 hour expiry
|
||||
"config": request_data.config or {},
|
||||
"context": {
|
||||
"user": {
|
||||
@@ -211,40 +232,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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1323,6 +1323,20 @@ def post_obj_template(
|
||||
table_name_select = obj_type_kv_li[obj_type]['table_name']
|
||||
base_name = obj_type_kv_li[obj_type]['base_name']
|
||||
|
||||
# # Prune any keys that are not actual columns on the target table to avoid
|
||||
# # SQL errors when clients include convenience fields (e.g., account_id)
|
||||
# try:
|
||||
# from app import lib_sql_core
|
||||
# from sqlalchemy import text
|
||||
# with lib_sql_core.engine.connect() as conn:
|
||||
# cols_res = conn.execute(text(f"DESCRIBE `{table_name_insert}`;"))
|
||||
# cols = [r[0] for r in cols_res.fetchall()]
|
||||
# # keep only keys that match real columns (always allow id_random)
|
||||
# obj_data = {k: v for k, v in obj_data.items() if k in cols or k == 'id_random'}
|
||||
# except Exception as _:
|
||||
# # If DESCRIBE fails for any reason, fall back to original obj_data
|
||||
# log.debug(f"Could not inspect table columns for {table_name_insert}; proceeding without pruning.")
|
||||
|
||||
if sql_insert_result := sql_insert(table_name=table_name_insert, data=obj_data, id_random_length=id_random_length):
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(sql_insert_result)
|
||||
|
||||
@@ -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
|
||||
@@ -60,16 +61,16 @@ async def get_obj_schema(
|
||||
):
|
||||
"""
|
||||
Dynamic Schema Introspection.
|
||||
|
||||
|
||||
Allows the frontend (e.g., Svelte/React apps) to retrieve the structure of an object type on the fly.
|
||||
Returns:
|
||||
- Database column definitions (types, defaults, nullability).
|
||||
- Pydantic model field definitions (validation rules, aliases).
|
||||
|
||||
|
||||
This enables dynamic form generation without hardcoding schemas in the frontend.
|
||||
"""
|
||||
schema_info = get_object_schema_info(obj_type, view, variant)
|
||||
|
||||
|
||||
if "error" in schema_info:
|
||||
status_code = 400 if "not found" in schema_info["error"] else 500
|
||||
return mk_resp(data=False, status_code=status_code, response=response, status_message=schema_info["error"])
|
||||
@@ -86,7 +87,7 @@ async def validate_obj_payload(
|
||||
):
|
||||
"""
|
||||
Dry-Run Payload Validation.
|
||||
|
||||
|
||||
Verifies that a payload is valid according to the Pydantic model
|
||||
without performing any database operations.
|
||||
"""
|
||||
@@ -110,13 +111,14 @@ async def get_obj(
|
||||
obj_type_l1: str = Path(min_length=2, max_length=50),
|
||||
obj_id: str = Path(min_length=11, max_length=22),
|
||||
view: str = Query('default'),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
inc_hosted_file: Optional[bool] = Query(False), # Added inc_hosted_file parameter
|
||||
account: AccountContext = Depends(get_account_context_optional),
|
||||
serialization: SerializationParams = Depends(),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""
|
||||
Retrieve a Single Object.
|
||||
|
||||
|
||||
1. Resolves the public `id_random` (string) to the internal `id` (integer).
|
||||
2. Performs a SQL SELECT.
|
||||
3. Enforces Multi-Tenant access checks.
|
||||
@@ -143,9 +145,20 @@ async def get_obj(
|
||||
|
||||
if sql_result := sql_select(table_name=table_name, record_id=record_id):
|
||||
if not obj_cfg.get('public_read', False):
|
||||
# Strict context check for non-public objects
|
||||
if account.auth_method == 'guest' or (account.account_id is None and not account.super):
|
||||
reason = account.auth_error or "Account context required."
|
||||
return mk_resp(data=False, status_code=403, response=response, status_message=reason)
|
||||
|
||||
if not check_account_access(sql_result, account, obj_name):
|
||||
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied.")
|
||||
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied. Record belongs to another account.")
|
||||
|
||||
# Pass inc_hosted_file to the Pydantic model if applicable
|
||||
if obj_name == 'event_file' and inc_hosted_file:
|
||||
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.")
|
||||
@@ -168,7 +181,7 @@ async def get_obj_li(
|
||||
):
|
||||
"""
|
||||
List Objects (Pagination & Filtering).
|
||||
|
||||
|
||||
Supports:
|
||||
- Standard filtering (enabled/hidden).
|
||||
- Advanced filtering via JSON Payload (`jp`) param (Search, Fulltext, AND/OR queries).
|
||||
@@ -186,7 +199,7 @@ async def get_obj_li(
|
||||
and_like_dict_obj = None
|
||||
or_like_dict_obj = None
|
||||
and_in_dict_li_obj = None
|
||||
|
||||
|
||||
jp_obj = safe_json_loads(urllib.parse.unquote(jp)) if jp else None
|
||||
if jp_obj:
|
||||
if jp_obj.get('qry'): qry_dict_li = jp_obj['qry']
|
||||
@@ -200,7 +213,7 @@ async def get_obj_li(
|
||||
obj_name = obj_type_l1
|
||||
if obj_name not in obj_type_kv_li:
|
||||
return mk_resp(data=False, status_code=400, response=response, status_message=f"Object type '{obj_name}' not found.")
|
||||
|
||||
|
||||
obj_cfg = obj_type_kv_li[obj_name]
|
||||
|
||||
if obj_name == 'site' and not (for_obj_type == 'account' and for_obj_id):
|
||||
@@ -219,7 +232,7 @@ async def get_obj_li(
|
||||
|
||||
order_by_li = filter_order_by(order_by_li, base_name, table_name)
|
||||
status_filter = get_supported_filters(base_name, status_filter)
|
||||
|
||||
|
||||
if not obj_cfg.get('public_read', False):
|
||||
and_qry_dict_obj = apply_forced_account_filter(and_qry_dict_obj, account, base_name, obj_name, table_name=table_name)
|
||||
|
||||
@@ -262,8 +275,17 @@ async def get_obj_li(
|
||||
as_list=True,
|
||||
)
|
||||
|
||||
if sql_result is False:
|
||||
# Standardized rich error bubbling
|
||||
db_err = format_db_error(get_last_sql_error())
|
||||
|
||||
# If it's a schema error (like Unknown Column), it's a 400 Bad Request
|
||||
status_code = 400 if db_err.category == "database_schema" else 500
|
||||
|
||||
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)
|
||||
@@ -286,7 +308,7 @@ async def search_obj_li(
|
||||
):
|
||||
"""
|
||||
Search Objects (POST).
|
||||
|
||||
|
||||
Advanced search endpoint using `SearchQuery` body.
|
||||
- Security: Guests can access specific objects (e.g., site_domain) if permitted.
|
||||
- Filtering: Supports dynamic AND/OR filters built from the frontend.
|
||||
@@ -321,11 +343,36 @@ async def search_obj_li(
|
||||
status_filter = get_supported_filters(base_name, status_filter)
|
||||
searchable_fields = obj_cfg.get('searchable_fields')
|
||||
|
||||
# site_domain access-key enforcement:
|
||||
# - site_access_key (site-level) takes priority; site_domain_access_key used as fallback.
|
||||
# - A domain is public only if site_domain_access_key is NULL/empty (and site_access_key is also unset).
|
||||
# - Falsy access_key values (empty string, None) are stripped — treated as "no key".
|
||||
# - When a key IS provided, lib_sql_search handles the SQL expansion (see process_filter).
|
||||
if obj_name == 'site_domain':
|
||||
# Sanity check: drop access_key filters with falsy values
|
||||
if search_query.and_filters:
|
||||
search_query.and_filters = [
|
||||
f for f in search_query.and_filters
|
||||
if not (isinstance(f, SearchFilter) and f.field == 'access_key' and not f.value)
|
||||
]
|
||||
key_fields = {'access_key', 'site_access_key', 'site_domain_access_key'}
|
||||
has_key_filter = any(
|
||||
isinstance(f, SearchFilter) and f.field in key_fields
|
||||
for f in (search_query.and_filters or [])
|
||||
)
|
||||
if not has_key_filter:
|
||||
if search_query.and_filters is None:
|
||||
search_query.and_filters = []
|
||||
for col in ('site_access_key', 'site_domain_access_key'):
|
||||
search_query.and_filters.append(SearchQuery.parse_obj({
|
||||
'or': [{'field': col, 'op': 'is_null'}, {'field': col, 'op': 'eq', 'value': ''}]
|
||||
}))
|
||||
|
||||
if for_obj_type == 'account' and for_obj_id:
|
||||
if not account.super and for_obj_id != account.account_id_random:
|
||||
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied to requested account.")
|
||||
|
||||
if not account.super and account.auth_method != 'bypass' and account.account_id:
|
||||
if not is_public_read and not account.super and account.auth_method != 'bypass':
|
||||
if search_query.and_filters is None: search_query.and_filters = []
|
||||
if obj_name == 'account':
|
||||
search_query.and_filters.append(SearchFilter(field='id', op='eq', value=account.account_id))
|
||||
@@ -366,10 +413,14 @@ async def search_obj_li(
|
||||
if sql_result is False:
|
||||
# Standardized rich error bubbling
|
||||
db_err = format_db_error(get_last_sql_error())
|
||||
return mk_resp(data=False, status_code=500, response=response, status_message="Search failed due to database error.", details=db_err.dict())
|
||||
|
||||
# If it's a schema error (like Unknown Column), it's a 400 Bad Request
|
||||
status_code = 400 if db_err.category == "database_schema" else 500
|
||||
|
||||
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)
|
||||
@@ -388,7 +439,7 @@ async def post_obj(
|
||||
):
|
||||
"""
|
||||
Create Object.
|
||||
|
||||
|
||||
1. Injects `account_id` for ownership.
|
||||
2. **Sanitizes Payload**: Resolves `*_id_random` -> `*_id`, removes virtual fields, and view-only fields.
|
||||
- If `x-ae-ignore-extra-fields: true` header is provided, unknown fields are stripped.
|
||||
@@ -414,14 +465,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:
|
||||
@@ -433,6 +479,22 @@ 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.
|
||||
# Guard: skip if the model explicitly excludes account_id from DB writes (e.g. event_badge,
|
||||
# event_device — the column does not exist in those tables).
|
||||
if not account.super and account.auth_method != 'bypass' and account.account_id:
|
||||
if 'account_id' in input_model.__fields__:
|
||||
excluded = getattr(input_model, 'fields_to_exclude_from_db', [])
|
||||
if 'account_id' not in excluded:
|
||||
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)
|
||||
@@ -440,8 +502,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())
|
||||
@@ -462,7 +525,7 @@ async def patch_obj(
|
||||
):
|
||||
"""
|
||||
Update Object (Partial).
|
||||
|
||||
|
||||
1. Resolves ID and checks access permissions.
|
||||
2. **Sanitizes Payload**: Resolves `*_id_random` -> `*_id`, removes virtual fields, and view-only fields.
|
||||
- If `x-ae-ignore-extra-fields: true` header is provided, unknown fields are stripped.
|
||||
@@ -503,6 +566,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:
|
||||
@@ -522,7 +586,7 @@ async def delete_obj(
|
||||
):
|
||||
"""
|
||||
Delete Object.
|
||||
|
||||
|
||||
Supports:
|
||||
- Soft Delete: `method='hide'` or `method='disable'`.
|
||||
- Hard Delete: `method='delete'`.
|
||||
|
||||
@@ -13,10 +13,12 @@ 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 *
|
||||
from app.models.api_crud_models import SearchFilter, SearchQuery
|
||||
from app.ae_obj_types_def import obj_type_kv_li
|
||||
|
||||
"""
|
||||
@@ -34,6 +36,7 @@ async def get_child_obj_li(
|
||||
parent_obj_type: str,
|
||||
parent_obj_id: str,
|
||||
child_obj_type: str,
|
||||
view: str = Query('default'),
|
||||
order_by_li: Optional[str] = None,
|
||||
jp: Optional[Union[str, None]] = None,
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
@@ -44,7 +47,7 @@ async def get_child_obj_li(
|
||||
):
|
||||
"""
|
||||
List Child Objects (One-to-Many).
|
||||
|
||||
|
||||
Retrieves a list of child objects associated with a specific parent.
|
||||
1. Verifies parent existence and user access to the parent.
|
||||
2. Filters children where `{parent_obj_type}_id` matches the parent's ID.
|
||||
@@ -60,7 +63,7 @@ async def get_child_obj_li(
|
||||
and_like_dict_obj = None
|
||||
or_like_dict_obj = None
|
||||
and_in_dict_li_obj = None
|
||||
|
||||
|
||||
jp_obj = safe_json_loads(urllib.parse.unquote(jp)) if jp else None
|
||||
if jp_obj:
|
||||
if jp_obj.get('qry'): qry_dict_li = jp_obj['qry']
|
||||
@@ -72,13 +75,18 @@ async def get_child_obj_li(
|
||||
|
||||
order_by_li = safe_json_loads(order_by_li)
|
||||
obj_name = child_obj_type
|
||||
|
||||
|
||||
if obj_name not in obj_type_kv_li or parent_obj_type not in obj_type_kv_li:
|
||||
return mk_resp(data=False, status_code=400, response=response, status_message=f"Invalid object type(s).")
|
||||
|
||||
# ID Vision: Resolve physical table names from registry to support aliases
|
||||
parent_table = obj_type_kv_li[parent_obj_type].get('tbl')
|
||||
obj_cfg = obj_type_kv_li[obj_name]
|
||||
table_name = obj_cfg.get('tbl_default', obj_cfg.get('tbl'))
|
||||
base_name = obj_cfg.get('mdl_default', obj_cfg.get('mdl'))
|
||||
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')))
|
||||
|
||||
# Log parent/child resolution details (use INFO so logs appear in production)
|
||||
log.info("nested.list start parent=%s parent_table=%s parent_id_random=%s child=%s table=%s allowed_parents=%s", parent_obj_type, parent_table, parent_obj_id, obj_name, table_name, obj_cfg.get('parent_types'))
|
||||
|
||||
if not table_name or not base_name:
|
||||
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.")
|
||||
@@ -86,16 +94,27 @@ async def get_child_obj_li(
|
||||
order_by_li = filter_order_by(order_by_li, base_name, table_name)
|
||||
status_filter = get_supported_filters(base_name, status_filter)
|
||||
|
||||
resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_obj_type)
|
||||
resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_table)
|
||||
log.info("nested.list resolved_parent_id=%s (random=%s) for parent_table=%s", resolved_parent_id, parent_obj_id, parent_table)
|
||||
if not resolved_parent_id:
|
||||
log.info("nested.list parent resolution failed for random id=%s table=%s", parent_obj_id, parent_table)
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message=f"Parent not found.")
|
||||
|
||||
# Enforce allowed parent types when configured on the child object
|
||||
allowed_parents = obj_cfg.get('parent_types')
|
||||
if allowed_parents and parent_obj_type not in allowed_parents:
|
||||
log.info("nested.list invalid parent type: parent=%s allowed=%s", parent_obj_type, allowed_parents)
|
||||
return mk_resp(data=False, status_code=400, response=response, status_message=f"Invalid parent type for this child.")
|
||||
|
||||
parent_cfg = obj_type_kv_li[parent_obj_type]
|
||||
parent_table = parent_cfg.get('tbl_default', parent_cfg.get('tbl'))
|
||||
if parent_sql_res := sql_select(table_name=parent_table, record_id=resolved_parent_id):
|
||||
parent_table_select = parent_cfg.get('tbl_default', parent_cfg.get('tbl'))
|
||||
if parent_sql_res := sql_select(table_name=parent_table_select, record_id=resolved_parent_id):
|
||||
log.info("nested.list parent_sql_res found for id=%s table=%s", resolved_parent_id, parent_table_select)
|
||||
if not check_account_access(parent_sql_res, account, parent_obj_type):
|
||||
log.info("nested.list access denied to parent id=%s for account=%s", resolved_parent_id, getattr(account, 'account_id', None))
|
||||
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied to parent.")
|
||||
else:
|
||||
log.info("nested.list parent sql_select returned no row for id=%s table=%s", resolved_parent_id, parent_table_select)
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message="Parent not found.")
|
||||
|
||||
and_qry_dict_obj = apply_forced_account_filter(and_qry_dict_obj, account, base_name, obj_name, table_name=table_name)
|
||||
@@ -118,8 +137,114 @@ async def get_child_obj_li(
|
||||
as_list=True,
|
||||
)
|
||||
|
||||
if sql_result is False:
|
||||
# Standardized rich error bubbling
|
||||
db_err = format_db_error(get_last_sql_error())
|
||||
|
||||
# If it's a schema error (like Unknown Column), it's a 400 Bad Request
|
||||
status_code = 400 if db_err.category == "database_schema" else 500
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@router.post('/{parent_obj_type}/{parent_obj_id}/{child_obj_type}/search', response_model=Resp_Body_Base, tags=['CRUD v3 Search (Dev)'])
|
||||
async def search_child_obj_li(
|
||||
response: Response,
|
||||
parent_obj_type: str,
|
||||
parent_obj_id: str,
|
||||
child_obj_type: str,
|
||||
search_query: SearchQuery,
|
||||
view: str = Query('default'),
|
||||
order_by_li: Optional[str] = Query(None),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
pagination: PaginationParams = Depends(),
|
||||
status_filter: StatusFilterParams = Depends(),
|
||||
serialization: SerializationParams = Depends(),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""
|
||||
Search Child Objects (POST).
|
||||
|
||||
Advanced search endpoint for nested objects.
|
||||
"""
|
||||
from app.db_sql import redis_lookup_id_random, sql_select
|
||||
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
|
||||
order_by_li = safe_json_loads(order_by_li)
|
||||
obj_name = child_obj_type
|
||||
if obj_name not in obj_type_kv_li or parent_obj_type not in obj_type_kv_li:
|
||||
return mk_resp(data=False, status_code=400, response=response, status_message="Invalid object type(s).")
|
||||
|
||||
# ID Vision: Resolve physical table names from registry to support aliases
|
||||
parent_table = obj_type_kv_li[parent_obj_type].get('tbl')
|
||||
obj_cfg = obj_type_kv_li[obj_name]
|
||||
table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl')))
|
||||
base_name = obj_cfg.get(f'mdl_{view}', obj_cfg.get('mdl_default', obj_cfg.get('mdl')))
|
||||
|
||||
if not table_name or not base_name:
|
||||
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.")
|
||||
|
||||
order_by_li = filter_order_by(order_by_li, base_name, table_name)
|
||||
status_filter = get_supported_filters(base_name, status_filter)
|
||||
searchable_fields = obj_cfg.get('searchable_fields')
|
||||
|
||||
resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_table)
|
||||
log.info("nested.search resolved_parent_id=%s (random=%s) for parent_table=%s", resolved_parent_id, parent_obj_id, parent_table)
|
||||
if not resolved_parent_id:
|
||||
log.info("nested.search parent resolution failed for random id=%s table=%s", parent_obj_id, parent_table)
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message="Parent not found.")
|
||||
|
||||
# Enforce allowed parent types when configured on the child object
|
||||
allowed_parents = obj_cfg.get('parent_types')
|
||||
if allowed_parents and parent_obj_type not in allowed_parents:
|
||||
log.info("nested.search invalid parent type: parent=%s allowed=%s", parent_obj_type, allowed_parents)
|
||||
return mk_resp(data=False, status_code=400, response=response, status_message="Invalid parent type for this child.")
|
||||
|
||||
parent_cfg = obj_type_kv_li[parent_obj_type]
|
||||
parent_table_select = parent_cfg.get('tbl_default', parent_cfg.get('tbl'))
|
||||
if parent_sql_res := sql_select(table_name=parent_table_select, record_id=resolved_parent_id):
|
||||
log.info("nested.search parent_sql_res found for id=%s table=%s", resolved_parent_id, parent_table_select)
|
||||
if not check_account_access(parent_sql_res, account, parent_obj_type):
|
||||
log.info("nested.search access denied to parent id=%s for account=%s", resolved_parent_id, getattr(account, 'account_id', None))
|
||||
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied to parent.")
|
||||
else:
|
||||
log.info("nested.search parent sql_select returned no row for id=%s table=%s", resolved_parent_id, parent_table_select)
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message="Parent not found.")
|
||||
|
||||
# Enforce account isolation on the search query
|
||||
if not account.super and account.auth_method != 'bypass' and account.account_id:
|
||||
if search_query.and_filters is None: search_query.and_filters = []
|
||||
if 'account_id' in base_name.__fields__:
|
||||
search_query.and_filters.append(SearchFilter(field='account_id', op='eq', value=account.account_id))
|
||||
|
||||
sql_result = sql_select(
|
||||
table_name=table_name,
|
||||
field_name=f'{parent_obj_type}_id',
|
||||
field_value=resolved_parent_id,
|
||||
enabled=status_filter.enabled,
|
||||
hidden=status_filter.hidden,
|
||||
search_query=search_query,
|
||||
searchable_fields=searchable_fields,
|
||||
order_by_li=order_by_li,
|
||||
limit=pagination.limit,
|
||||
offset=pagination.offset,
|
||||
as_list=True,
|
||||
)
|
||||
|
||||
if sql_result is False:
|
||||
db_err = format_db_error(get_last_sql_error())
|
||||
status_code = 400 if db_err.category == "database_schema" else 500
|
||||
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 = [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)
|
||||
@@ -140,7 +265,7 @@ async def post_child_obj(
|
||||
):
|
||||
"""
|
||||
Create Child Object.
|
||||
|
||||
|
||||
1. Verifies Parent existence and access.
|
||||
2. Automatically links the new child to the parent (`{parent_obj_type}_id` = parent_id).
|
||||
3. Performs standard creation logic (validation, injection, sanitization).
|
||||
@@ -154,19 +279,32 @@ async def post_child_obj(
|
||||
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.")
|
||||
|
||||
resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_obj_type)
|
||||
# ID Vision: Resolve physical table names from registry to support aliases
|
||||
parent_table = obj_type_kv_li[parent_obj_type].get('tbl')
|
||||
resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_table)
|
||||
log.info("nested.post parent=%s parent_table=%s parent_id_random=%s", parent_obj_type, parent_table, parent_obj_id)
|
||||
log.info("nested.post resolved_parent_id=%s for random=%s table=%s", resolved_parent_id, parent_obj_id, parent_table)
|
||||
if not resolved_parent_id:
|
||||
log.info("nested.post parent resolution failed for random id=%s table=%s", parent_obj_id, parent_table)
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message="Parent not found.")
|
||||
|
||||
parent_cfg = obj_type_kv_li[parent_obj_type]
|
||||
parent_table = parent_cfg.get('tbl_default', parent_cfg.get('tbl'))
|
||||
if parent_sql_res := sql_select(table_name=parent_table, record_id=resolved_parent_id):
|
||||
parent_table_select = parent_cfg.get('tbl_default', parent_cfg.get('tbl'))
|
||||
if parent_sql_res := sql_select(table_name=parent_table_select, record_id=resolved_parent_id):
|
||||
log.info("nested.post parent_sql_res found for id=%s table=%s", resolved_parent_id, parent_table_select)
|
||||
if not check_account_access(parent_sql_res, account, parent_obj_type):
|
||||
log.info("nested.post access denied to parent id=%s for account=%s", resolved_parent_id, getattr(account, 'account_id', None))
|
||||
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied to parent.")
|
||||
else:
|
||||
log.info("nested.post parent sql_select returned no row for id=%s table=%s", resolved_parent_id, parent_table_select)
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message="Parent not found.")
|
||||
|
||||
obj_cfg = obj_type_kv_li[child_obj_type]
|
||||
# Enforce allowed parent types when configured on the child object
|
||||
allowed_parents = obj_cfg.get('parent_types')
|
||||
if allowed_parents and parent_obj_type not in allowed_parents:
|
||||
log.info("nested.post invalid parent type: parent=%s allowed=%s", parent_obj_type, allowed_parents)
|
||||
return mk_resp(data=False, status_code=400, response=response, status_message="Invalid parent type for this child.")
|
||||
table_name_insert = obj_cfg.get('tbl_update', obj_cfg.get('tbl'))
|
||||
table_name_select = obj_cfg.get('tbl_default', obj_cfg.get('tbl'))
|
||||
input_model = obj_cfg.get('mdl_in', obj_cfg.get('mdl'))
|
||||
@@ -175,15 +313,6 @@ async def post_child_obj(
|
||||
if not table_name_insert or not input_model or not table_name_select or not output_model:
|
||||
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration 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
|
||||
|
||||
obj_data[f'{parent_obj_type}_id'] = resolved_parent_id
|
||||
|
||||
# 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:
|
||||
@@ -194,6 +323,20 @@ async def post_child_obj(
|
||||
|
||||
data_to_insert = validated_obj.dict(exclude_unset=True)
|
||||
|
||||
# Sanitize AFTER serialization so that:
|
||||
# 1. The model receives raw Vision ID strings (passes field-length constraints).
|
||||
# 2. ID resolution (string → integer) happens on the dict going to the DB,
|
||||
# avoiding the root_validator's integer-stripping anti-leakage guard.
|
||||
# (Matches the flat V3 POST pattern in api_crud_v3.py.)
|
||||
sanitize_payload(data_to_insert, input_model, ignore_extra=x_ae_ignore_extra_fields)
|
||||
|
||||
# Re-inject parent FK last — overrides anything sanitize_payload or the model may have
|
||||
# set — ensuring the child is always linked to the correct parent.
|
||||
# Note: account_id is intentionally NOT injected here. Child objects in the nested
|
||||
# endpoint inherit account context from their parent via the FK relationship; they do
|
||||
# not carry their own account_id column (e.g. event_badge, journal_entry).
|
||||
data_to_insert[f'{parent_obj_type}_id'] = resolved_parent_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=child_obj_type)
|
||||
@@ -201,8 +344,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())
|
||||
@@ -216,34 +360,47 @@ async def get_child_obj(
|
||||
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)
|
||||
|
||||
resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_obj_type)
|
||||
resolved_child_id = redis_lookup_id_random(record_id_random=child_obj_id, table_name=child_obj_type)
|
||||
# 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('tbl_default', obj_cfg.get('tbl'))
|
||||
base_name = obj_cfg.get('mdl_default', obj_cfg.get('mdl'))
|
||||
# Enforce allowed parent types when configured on the child object
|
||||
allowed_parents = obj_cfg.get('parent_types')
|
||||
if allowed_parents and parent_obj_type not in allowed_parents:
|
||||
return mk_resp(data=False, status_code=400, response=response, status_message="Invalid parent type for this child.")
|
||||
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)
|
||||
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.")
|
||||
|
||||
@@ -264,7 +421,7 @@ async def patch_child_obj(
|
||||
):
|
||||
"""
|
||||
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
|
||||
@@ -272,8 +429,16 @@ async def patch_child_obj(
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
|
||||
obj_data = await request.json()
|
||||
resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_obj_type)
|
||||
resolved_child_id = redis_lookup_id_random(record_id_random=child_obj_id, table_name=child_obj_type)
|
||||
|
||||
# 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.")
|
||||
@@ -297,11 +462,13 @@ 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:
|
||||
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)
|
||||
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)
|
||||
@@ -317,15 +484,22 @@ async def delete_child_obj(
|
||||
):
|
||||
"""
|
||||
Delete Child Object.
|
||||
|
||||
|
||||
Verifies that the child belongs to the specified parent before deleting.
|
||||
"""
|
||||
from app.db_sql import redis_lookup_id_random, sql_select, sql_update, sql_delete
|
||||
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
|
||||
resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_obj_type)
|
||||
resolved_child_id = redis_lookup_id_random(record_id_random=child_obj_id, table_name=child_obj_type)
|
||||
# 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.")
|
||||
@@ -349,4 +523,4 @@ async def delete_child_obj(
|
||||
|
||||
if success:
|
||||
return mk_resp(data=True, response=response, status_message=f"Deleted successfully via {method}.")
|
||||
return mk_resp(data=False, status_code=400, response=response, status_message="Deletion failed.")
|
||||
return mk_resp(data=False, status_code=400, response=response, status_message="Deletion failed.")
|
||||
164
app/routers/api_v3_actions_e_novi_mailman.py
Normal file
164
app/routers/api_v3_actions_e_novi_mailman.py
Normal file
@@ -0,0 +1,164 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from typing import Optional
|
||||
|
||||
from app.lib_general_v3 import AccountContext, get_account_context, DelayParams
|
||||
from app.models.response_models import Resp_Body_Base, mk_resp
|
||||
from app.methods.e_novi_mailman_methods import (
|
||||
test_novi_connection,
|
||||
test_mailman_connection,
|
||||
get_mailman_lists,
|
||||
get_mailman_list_members,
|
||||
subscribe_member_to_list,
|
||||
unsubscribe_member_from_list,
|
||||
get_novi_members,
|
||||
mirror_novi_group_to_mailman_list,
|
||||
mirror_all_configured_mappings,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Connection Tests ──────────────────────────────────────────────────────
|
||||
|
||||
@router.get('/test_connection/novi', response_model=Resp_Body_Base)
|
||||
async def test_novi(
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""Verify Novi AMS API credentials from IDAA site cfg_json."""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
result = test_novi_connection()
|
||||
if result.get('ok'):
|
||||
return mk_resp(data=result)
|
||||
return mk_resp(data=result, status_code=401, status_message="Novi connection failed.")
|
||||
|
||||
|
||||
@router.get('/test_connection/mailman', response_model=Resp_Body_Base)
|
||||
async def test_mailman(
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""Verify Mailman 3 REST API credentials from IDAA site cfg_json."""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
result = test_mailman_connection()
|
||||
if result.get('ok'):
|
||||
return mk_resp(data=result)
|
||||
return mk_resp(data=result, status_code=401, status_message="Mailman connection failed.")
|
||||
|
||||
|
||||
# ── Inspection / Preview ──────────────────────────────────────────────────
|
||||
|
||||
@router.get('/mailman/lists/{list_id}/members', response_model=Resp_Body_Base)
|
||||
async def list_mailman_list_members(
|
||||
list_id: str,
|
||||
count: int = Query(100, ge=1, le=500),
|
||||
page: int = Query(1, ge=1),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""Return members of a specific Mailman 3 list."""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
data = get_mailman_list_members(list_id=list_id, count=count, page=page)
|
||||
if data is not None:
|
||||
return mk_resp(data={"count": len(data), "members": data})
|
||||
return mk_resp(data=False, status_code=500, status_message=f"Failed to fetch members for list '{list_id}'.")
|
||||
|
||||
|
||||
@router.post('/mailman/lists/{list_id}/subscribe', response_model=Resp_Body_Base)
|
||||
async def mailman_subscribe(
|
||||
list_id: str,
|
||||
email: str = Query(..., description="Email address to subscribe"),
|
||||
display_name: str = Query('', description="Optional display name"),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""Subscribe an email address to a Mailman 3 list."""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
ok = subscribe_member_to_list(list_id=list_id, email=email, display_name=display_name)
|
||||
if ok:
|
||||
return mk_resp(data={"list_id": list_id, "email": email}, status_message="Subscribed successfully.")
|
||||
return mk_resp(data=False, status_code=500, status_message=f"Failed to subscribe {email} to {list_id}.")
|
||||
|
||||
|
||||
@router.delete('/mailman/lists/{list_id}/subscribe', response_model=Resp_Body_Base)
|
||||
async def mailman_unsubscribe(
|
||||
list_id: str,
|
||||
email: str = Query(..., description="Email address to unsubscribe"),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""Unsubscribe an email address from a Mailman 3 list."""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
ok = unsubscribe_member_from_list(list_id=list_id, email=email)
|
||||
if ok:
|
||||
return mk_resp(data={"list_id": list_id, "email": email}, status_message="Unsubscribed successfully.")
|
||||
return mk_resp(data=False, status_code=500, status_message=f"Failed to unsubscribe {email} from {list_id}.")
|
||||
|
||||
|
||||
@router.get('/mailman/lists', response_model=Resp_Body_Base)
|
||||
async def list_mailman_lists(
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""Return all mailing lists from this Mailman 3 instance."""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
data = get_mailman_lists()
|
||||
if data is not None:
|
||||
return mk_resp(data=data)
|
||||
return mk_resp(data=False, status_code=500, status_message="Failed to fetch Mailman lists.")
|
||||
|
||||
|
||||
@router.get('/novi/members', response_model=Resp_Body_Base)
|
||||
async def list_novi_members(
|
||||
status_filter: Optional[str] = Query(None, description="Novi membership status filter (e.g. 'Active', 'Lapsed')"),
|
||||
page_size: int = Query(100, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""Fetch a page of Novi AMS members."""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
data = get_novi_members(status_filter=status_filter, page_size=page_size, offset=offset)
|
||||
if data is not None:
|
||||
return mk_resp(data={"count": len(data), "members": data})
|
||||
return mk_resp(data=False, status_code=500, status_message="Failed to fetch members from Novi.")
|
||||
|
||||
|
||||
# ── Sync ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post('/sync', response_model=Resp_Body_Base)
|
||||
async def sync_all_mappings(
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""
|
||||
Run all Novi → Mailman mirror syncs configured in novi_mailman_sync (IDAA cfg_json).
|
||||
This is the cron target — call on a schedule to keep all lists in sync.
|
||||
"""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
results = mirror_all_configured_mappings()
|
||||
if results is not None:
|
||||
return mk_resp(data=results, status_message=f"Mirror sync complete. {len(results)} mapping(s) processed.")
|
||||
return mk_resp(data=False, status_code=500, status_message="Mirror sync failed.")
|
||||
|
||||
|
||||
@router.post('/sync/group/{novi_group_guid}', response_model=Resp_Body_Base)
|
||||
async def sync_single_group(
|
||||
novi_group_guid: str,
|
||||
mailman_list_id: str = Query(..., description="Target Mailman list, e.g. 'mm3@idaa.org'"),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""
|
||||
Mirror a single Novi group to a specific Mailman list.
|
||||
Useful for testing or forcing a refresh of one mapping.
|
||||
"""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
result = mirror_novi_group_to_mailman_list(novi_group_guid, mailman_list_id)
|
||||
if result:
|
||||
return mk_resp(data=result, status_message="Mirror sync complete.")
|
||||
return mk_resp(data=False, status_code=500, status_message="Mirror sync failed.")
|
||||
|
||||
|
||||
62
app/routers/api_v3_actions_e_zoom.py
Normal file
62
app/routers/api_v3_actions_e_zoom.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
|
||||
from app.lib_general import log, logging
|
||||
from app.lib_general_v3 import AccountContext, get_account_context, DelayParams
|
||||
from app.models.response_models import Resp_Body_Base, mk_resp
|
||||
from app.methods.e_zoom_methods import get_zoom_access_token, get_zoom_tickets, sync_zoom_attendees_to_event
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get('/test_connection', response_model=Resp_Body_Base)
|
||||
async def test_zoom_connection(
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""
|
||||
Verifies that the Zoom API credentials in data_store are valid.
|
||||
"""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
|
||||
auth = get_zoom_access_token()
|
||||
if auth:
|
||||
return mk_resp(data={"status": "connected", "expires_on": str(auth["expire_on"])})
|
||||
else:
|
||||
return mk_resp(data=False, status_code=401, status_message="Zoom authentication failed. Check data_store credentials.")
|
||||
|
||||
@router.get('/events/{zoom_event_id}/tickets', response_model=Resp_Body_Base)
|
||||
async def get_zoom_event_tickets(
|
||||
zoom_event_id: str,
|
||||
page_size: int = Query(300, ge=1, le=300),
|
||||
next_page_token: Optional[str] = None,
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""
|
||||
Proxy route to fetch raw ticket data from Zoom.
|
||||
"""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
|
||||
data = get_zoom_tickets(zoom_event_id, page_size, next_page_token)
|
||||
if data:
|
||||
return mk_resp(data=data)
|
||||
return mk_resp(data=False, status_code=500, status_message="Failed to fetch data from Zoom API.")
|
||||
|
||||
@router.post('/sync/event/{event_id_random}', response_model=Resp_Body_Base)
|
||||
async def sync_zoom_to_aether(
|
||||
event_id_random: str,
|
||||
zoom_event_id: str = Query(...),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""
|
||||
Atomic sync action: Pulls Zoom tickets and upserts Aether event_person records.
|
||||
Returns counts of created and updated records.
|
||||
"""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
|
||||
result = sync_zoom_attendees_to_event(event_id_random, zoom_event_id)
|
||||
if result:
|
||||
return mk_resp(data=result, status_message="Zoom sync process completed.")
|
||||
return mk_resp(data=False, status_code=500, status_message="Sync process failed or returned no data.")
|
||||
78
app/routers/api_v3_actions_email.py
Normal file
78
app/routers/api_v3_actions_email.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Aether API V3 - Email Action Router
|
||||
-------------------------------------
|
||||
Handles transactional email sending.
|
||||
|
||||
Routes:
|
||||
POST /send — send a transactional email
|
||||
|
||||
Replaces: POST /util/email/send (legacy — see util_email.py)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.lib_email import send_email
|
||||
from app.lib_general_v3 import AccountContext, get_account_context
|
||||
from app.models.response_models import Resp_Body_Base, mk_resp
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class EmailSendRequest(BaseModel):
|
||||
from_email: str = Field(..., description="Sender email address")
|
||||
from_name: Optional[str] = None
|
||||
to_email: str = Field(..., description="Recipient email address")
|
||||
to_name: Optional[str] = None
|
||||
cc_email: Optional[str] = None
|
||||
cc_name: Optional[str] = None
|
||||
bcc_email: Optional[str] = None
|
||||
bcc_name: Optional[str] = None
|
||||
subject: str = Field(..., description="Email subject line")
|
||||
body_html: str = Field(..., description="HTML email body")
|
||||
body_text: Optional[str] = None
|
||||
|
||||
|
||||
@router.post('/send', response_model=Resp_Body_Base)
|
||||
async def action_email_send(
|
||||
req: EmailSendRequest,
|
||||
test: bool = Query(False, description="Simulate send without delivering"),
|
||||
account_ctx: AccountContext = Depends(get_account_context),
|
||||
response: Response = Response,
|
||||
):
|
||||
log.setLevel(logging.INFO)
|
||||
|
||||
success = send_email(
|
||||
from_email=req.from_email,
|
||||
from_name=req.from_name,
|
||||
to_email=req.to_email,
|
||||
to_name=req.to_name,
|
||||
cc_email=req.cc_email or '',
|
||||
cc_name=req.cc_name or '',
|
||||
bcc_email=req.bcc_email or '',
|
||||
bcc_name=req.bcc_name or '',
|
||||
subject=req.subject,
|
||||
body_text=req.body_text,
|
||||
body_html=req.body_html,
|
||||
test=test,
|
||||
)
|
||||
|
||||
if success:
|
||||
status_code = 200
|
||||
status_message = f'Email sent to <{req.to_email}>.'
|
||||
else:
|
||||
status_code = 400
|
||||
status_message = f'Email failed to send to <{req.to_email}>.'
|
||||
|
||||
log.info(status_message)
|
||||
resp_data = {
|
||||
'from_email': req.from_email,
|
||||
'to_email': req.to_email,
|
||||
'subject': req.subject[:40],
|
||||
}
|
||||
return mk_resp(data=resp_data, status_code=status_code, response=response, status_message=status_message)
|
||||
232
app/routers/api_v3_actions_event_exhibit.py
Normal file
232
app/routers/api_v3_actions_event_exhibit.py
Normal file
@@ -0,0 +1,232 @@
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
import pandas
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
from app.config import settings
|
||||
from app.db_sql import redis_lookup_id_random, sql_select
|
||||
from app.lib_api_crud_v3 import check_account_access
|
||||
from app.lib_export import create_export_file, return_full_tmp_path
|
||||
from app.lib_general_v3 import AccountContext, get_account_context
|
||||
from app.methods.event_exhibit_tracking_methods import (
|
||||
get_event_exhibit_tracking_rec_list,
|
||||
load_event_exhibit_tracking_obj,
|
||||
)
|
||||
from app.models.response_models import mk_resp
|
||||
|
||||
"""
|
||||
Aether API V3 - Event Exhibit Action Router
|
||||
---------------------------------------------
|
||||
Handles specialized actions for the Event Exhibit module, such as
|
||||
exporting tracking (lead) data for exhibitors.
|
||||
"""
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
_HTML_TAG_RE = re.compile(r'<[^>]+>')
|
||||
|
||||
|
||||
def _strip_html(text: Optional[str]) -> Optional[str]:
|
||||
if not text:
|
||||
return text
|
||||
return _HTML_TAG_RE.sub('', text)
|
||||
|
||||
|
||||
def _flatten_responses(responses_json: Optional[dict]) -> dict:
|
||||
"""
|
||||
Flatten responses_json into key→value pairs for CSV/Excel export.
|
||||
New format: { question_code: { response: <value> } } → value = inner['response']
|
||||
Legacy format: { label: <scalar> } → value = scalar
|
||||
"""
|
||||
if not responses_json:
|
||||
return {}
|
||||
flat = {}
|
||||
for key, value in responses_json.items():
|
||||
if isinstance(value, dict):
|
||||
flat[key] = value.get('response')
|
||||
else:
|
||||
flat[key] = value
|
||||
return flat
|
||||
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@router.get('/{exhibit_id}/tracking_export')
|
||||
async def export_exhibit_tracking(
|
||||
exhibit_id: str = Path(..., min_length=11, max_length=22),
|
||||
file_type: str = Query('CSV', regex=r'^(CSV|XLSX)$'),
|
||||
return_file: bool = Query(True),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
):
|
||||
"""
|
||||
V3 Action: Export all tracking (lead capture) records for an exhibit.
|
||||
|
||||
Auth: Requires `leads_api_access == True` on the exhibit OR manager-level account access.
|
||||
Returns a CSV or XLSX file attachment.
|
||||
"""
|
||||
# 1. Resolve random ID → internal integer
|
||||
exhibit_int_id = redis_lookup_id_random(record_id_random=exhibit_id, table_name='event_exhibit')
|
||||
if not exhibit_int_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Exhibit not found.')
|
||||
|
||||
# 2. Load exhibit record for ownership + permission checks
|
||||
exhibit_rec = sql_select(table_name='v_event_exhibit', record_id=exhibit_int_id)
|
||||
if not exhibit_rec:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Exhibit not found.')
|
||||
|
||||
# 3. Multi-tenant ownership check
|
||||
if not check_account_access(exhibit_rec, account, 'event_exhibit'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Access denied: this exhibit belongs to a different account.',
|
||||
)
|
||||
|
||||
# 4. Permission: leads_api_access flag OR manager-level access
|
||||
if not exhibit_rec.get('leads_api_access') and not account.manager:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Access denied: leads API access is not enabled for this exhibit.',
|
||||
)
|
||||
|
||||
# 5. Fetch all tracking records — no hidden/enabled filter, full export
|
||||
tracking_rec_list = get_event_exhibit_tracking_rec_list(
|
||||
event_exhibit_id=exhibit_int_id,
|
||||
hidden='all',
|
||||
enabled='all',
|
||||
limit=1500,
|
||||
)
|
||||
if tracking_rec_list is False:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve tracking records.',
|
||||
)
|
||||
|
||||
# 6. Build export rows
|
||||
data_rows = []
|
||||
response_keys: list = [] # ordered unique custom-question column names
|
||||
|
||||
for rec in (tracking_rec_list or []):
|
||||
tracking_obj = load_event_exhibit_tracking_obj(
|
||||
event_exhibit_tracking_id=rec.get('event_exhibit_tracking_id'),
|
||||
)
|
||||
if not tracking_obj:
|
||||
continue
|
||||
|
||||
# Apply exhibitor-side field overrides (override values replace badge defaults)
|
||||
if tracking_obj.event_badge_full_name_override:
|
||||
tracking_obj.event_badge_full_name = tracking_obj.event_badge_full_name_override
|
||||
if tracking_obj.event_badge_professional_title_override:
|
||||
tracking_obj.event_badge_professional_title = tracking_obj.event_badge_professional_title_override
|
||||
if tracking_obj.event_badge_affiliations_override:
|
||||
tracking_obj.event_badge_affiliations = tracking_obj.event_badge_affiliations_override
|
||||
if tracking_obj.event_badge_email_override:
|
||||
tracking_obj.event_badge_email = tracking_obj.event_badge_email_override
|
||||
if tracking_obj.event_badge_location_override:
|
||||
tracking_obj.event_badge_location = tracking_obj.event_badge_location_override
|
||||
|
||||
# Flatten custom Q&A responses and collect column keys (order-preserving dedup)
|
||||
responses = _flatten_responses(tracking_obj.responses_json)
|
||||
for key in responses:
|
||||
if key not in response_keys:
|
||||
response_keys.append(key)
|
||||
|
||||
row = {
|
||||
'event_exhibit_tracking_id': tracking_obj.event_exhibit_tracking_id,
|
||||
'created_on': tracking_obj.created_on,
|
||||
'updated_on': tracking_obj.updated_on,
|
||||
'event_exhibit_name': tracking_obj.event_exhibit_name,
|
||||
'event_badge_full_name': tracking_obj.event_badge_full_name,
|
||||
'event_badge_email': tracking_obj.event_badge_email,
|
||||
'event_badge_professional_title': tracking_obj.event_badge_professional_title,
|
||||
'event_badge_affiliations': tracking_obj.event_badge_affiliations,
|
||||
'event_badge_location': tracking_obj.event_badge_location,
|
||||
'event_badge_country': tracking_obj.event_badge_country,
|
||||
'external_person_id': tracking_obj.external_person_id,
|
||||
'exhibitor_notes': _strip_html(tracking_obj.exhibitor_notes),
|
||||
'priority': tracking_obj.priority,
|
||||
'enable': tracking_obj.enable,
|
||||
'hide': tracking_obj.hide,
|
||||
**responses,
|
||||
}
|
||||
data_rows.append(row)
|
||||
|
||||
# 7. Determine file format
|
||||
export_type = 'Excel' if file_type == 'XLSX' else 'CSV'
|
||||
content_type = (
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
if file_type == 'XLSX' else 'text/csv'
|
||||
)
|
||||
datetime_str = datetime.datetime.utcnow().strftime('%Y-%m-%d_%H%M')
|
||||
filename = f'leads_export_{datetime_str}'
|
||||
ext = '.xlsx' if export_type == 'Excel' else '.csv'
|
||||
filename_w_ext = filename + ext
|
||||
|
||||
fixed_columns = [
|
||||
'event_exhibit_tracking_id',
|
||||
'created_on',
|
||||
'updated_on',
|
||||
'event_exhibit_name',
|
||||
'event_badge_full_name',
|
||||
'event_badge_email',
|
||||
'event_badge_professional_title',
|
||||
'event_badge_affiliations',
|
||||
'event_badge_location',
|
||||
'event_badge_country',
|
||||
'external_person_id',
|
||||
'exhibitor_notes',
|
||||
'priority',
|
||||
'enable',
|
||||
'hide',
|
||||
]
|
||||
column_name_li = fixed_columns + response_keys
|
||||
|
||||
# 8. Handle empty result — write headers-only file
|
||||
if not data_rows:
|
||||
hosted_tmp_path = settings.FILES_PATH['hosted_tmp_root']
|
||||
subdir = os.path.join(hosted_tmp_path, 'event_exhibit')
|
||||
pathlib.Path(subdir).mkdir(parents=True, exist_ok=True)
|
||||
full_path = os.path.join(subdir, filename_w_ext)
|
||||
df = pandas.DataFrame(columns=fixed_columns)
|
||||
if export_type == 'CSV':
|
||||
df.to_csv(full_path, index=False)
|
||||
else:
|
||||
df.to_excel(full_path, index=False)
|
||||
if return_file:
|
||||
return FileResponse(path=full_path, filename=filename_w_ext, media_type=content_type)
|
||||
return mk_resp(data=[], tmp_file_path=filename_w_ext)
|
||||
|
||||
# 9. Generate the export file
|
||||
tmp_file_path = create_export_file(
|
||||
data_dict_list=data_rows,
|
||||
column_name_li=column_name_li,
|
||||
subdir_path='event_exhibit',
|
||||
filename=filename,
|
||||
rm_id=False,
|
||||
export_type=export_type,
|
||||
)
|
||||
if not tmp_file_path:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to generate export file.',
|
||||
)
|
||||
|
||||
if return_file:
|
||||
full_path = return_full_tmp_path(full_tmp_path=tmp_file_path)
|
||||
if not full_path:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Export file not found after creation.',
|
||||
)
|
||||
return FileResponse(path=full_path, filename=filename_w_ext, media_type=content_type)
|
||||
|
||||
return mk_resp(data=data_rows, tmp_file_path=tmp_file_path)
|
||||
302
app/routers/api_v3_actions_event_file.py
Normal file
302
app/routers/api_v3_actions_event_file.py
Normal file
@@ -0,0 +1,302 @@
|
||||
from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Path, Query, Response, status, UploadFile
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
import aiofiles
|
||||
import mimetypes
|
||||
import os
|
||||
import pathlib
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
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
|
||||
)
|
||||
from app.methods.event_file_methods import create_event_file_obj, load_event_file_obj
|
||||
from app.lib_general_v3 import (
|
||||
AccountContext, get_account_context, get_account_context_optional,
|
||||
SerializationParams, DelayParams
|
||||
)
|
||||
from app.models.hosted_file_models import Hosted_File_Base
|
||||
from app.models.event_file_models import Event_File_Base
|
||||
from app.models.response_models import Resp_Body_Base, mk_resp
|
||||
|
||||
"""
|
||||
Aether API V3 - Event File Action Router
|
||||
------------------------------------------
|
||||
Handles high-level atomic operations for the Event module, specifically
|
||||
marrying physical hosted_files with the relational event_file table.
|
||||
"""
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
def validate_file_extension(filename: str, allowed_extensions: List[str]):
|
||||
if not allowed_extensions:
|
||||
return True
|
||||
ext = filename.rsplit('.', 1)[-1].lower()
|
||||
if ext not in [e.lower().strip('.') for e in allowed_extensions]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File extension '.{ext}' is not allowed. Allowed: {', '.join(allowed_extensions)}"
|
||||
)
|
||||
return True
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@router.post('/upload', response_model=Resp_Body_Base)
|
||||
async def upload_event_file_action(
|
||||
file_list: List[UploadFile] = File(...),
|
||||
account_id: str = Form(..., min_length=11, max_length=22),
|
||||
for_type: str = Form(...),
|
||||
for_id: str = Form(..., min_length=11, max_length=22),
|
||||
|
||||
# Event Specific Metadata
|
||||
event_id: Optional[str] = Form(None),
|
||||
event_session_id: Optional[str] = Form(None),
|
||||
event_presentation_id: Optional[str] = Form(None),
|
||||
event_presenter_id: Optional[str] = Form(None),
|
||||
event_location_id: Optional[str] = Form(None),
|
||||
event_track_id: Optional[str] = Form(None),
|
||||
|
||||
# Display/Logic Metadata
|
||||
title: Optional[str] = Form(None),
|
||||
description: Optional[str] = Form(None),
|
||||
internal_use: Optional[bool] = Form(False),
|
||||
open_in_os: Optional[str] = Form(None),
|
||||
|
||||
allowed_extensions: Optional[List[str]] = Query(None),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""
|
||||
High-level Event File Upload.
|
||||
- Saves physical file (hosted_file).
|
||||
- Links to generic storage (hosted_file_link).
|
||||
- Creates context-aware association (event_file).
|
||||
- Returns full event_file objects.
|
||||
"""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
|
||||
# 1. Resolve Core Parent IDs
|
||||
account_id_int = redis_lookup_id_random(record_id_random=account_id, table_name='account')
|
||||
if not account_id_int:
|
||||
raise HTTPException(status_code=400, detail="Invalid account_id.")
|
||||
|
||||
# Generic link target (usually same as for_type/for_id but explicitly passed)
|
||||
link_to_id_int = redis_lookup_id_random(record_id_random=for_id, table_name=for_type)
|
||||
if not link_to_id_int:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid link target ID for type {for_type}.")
|
||||
|
||||
event_file_results = []
|
||||
|
||||
for file_obj in file_list:
|
||||
# 2. Extension Validation
|
||||
validate_file_extension(file_obj.filename, allowed_extensions)
|
||||
|
||||
# 3. Physical Save & Hosted File Sync (Deduplication)
|
||||
file_info = await save_file(
|
||||
file = file_obj,
|
||||
account_id = account_id_int,
|
||||
account_id_random = account_id,
|
||||
link_to_type = for_type,
|
||||
link_to_id = link_to_id_int,
|
||||
link_to_id_random = for_id,
|
||||
check_allowed_extension = False,
|
||||
)
|
||||
|
||||
if not file_info.get('saved'):
|
||||
log.error(f"Failed to save physical file: {file_obj.filename}")
|
||||
continue
|
||||
|
||||
hosted_file_id_int = None
|
||||
|
||||
# Deduplication lookup
|
||||
if existing_rec := sql_select(table_name='hosted_file', field_name='hash_sha256', field_value=file_info['hash_sha256']):
|
||||
hosted_file_id_int = existing_rec.get('id')
|
||||
if not existing_rec.get('subdirectory_path') and file_info.get('subdirectory_path'):
|
||||
sql_update(table_name='hosted_file', data={'id': hosted_file_id_int, 'subdirectory_path': file_info['subdirectory_path']})
|
||||
else:
|
||||
file_info['account_id'] = account_id_int
|
||||
file_info['account_id_random'] = account_id
|
||||
new_hf = Hosted_File_Base(**file_info)
|
||||
hosted_file_id_int = create_hosted_file_obj(hosted_file_obj_new=new_hf)
|
||||
|
||||
if not hosted_file_id_int:
|
||||
log.error("Database failure creating hosted_file record.")
|
||||
continue
|
||||
|
||||
# 4. Standard Generic Linking
|
||||
create_hosted_file_link(
|
||||
account_id = account_id_int,
|
||||
hosted_file_id = hosted_file_id_int,
|
||||
link_to_type = for_type,
|
||||
link_to_id = link_to_id_int
|
||||
)
|
||||
|
||||
# 5. Specialized Event File Linking
|
||||
# Prepare the event_file record
|
||||
ef_data = {
|
||||
"hosted_file_id": hosted_file_id_int,
|
||||
"for_type": for_type,
|
||||
"for_id": link_to_id_int, # Explicitly pass the resolved int ID
|
||||
"for_id_random": for_id,
|
||||
"event_id_random": event_id,
|
||||
"event_session_id_random": event_session_id,
|
||||
"event_presentation_id_random": event_presentation_id,
|
||||
"event_presenter_id_random": event_presenter_id,
|
||||
"event_location_id_random": event_location_id,
|
||||
"event_track_id_random": event_track_id,
|
||||
"filename": file_obj.filename,
|
||||
"extension": file_info['extension'],
|
||||
"title": title,
|
||||
"description": description,
|
||||
"internal_use": internal_use,
|
||||
"open_in_os": open_in_os,
|
||||
"enable": True
|
||||
}
|
||||
|
||||
# Instantiate model to trigger ID resolution validators
|
||||
new_ef_obj = Event_File_Base(**ef_data)
|
||||
|
||||
res_ef_id = create_event_file_obj(event_file_obj_new=new_ef_obj)
|
||||
|
||||
if res_ef_id is True:
|
||||
# An update happened instead of an insert. Resolve the ID via unique keys.
|
||||
# unique index: hosted_file_id_for (hosted_file_id, for_type, for_id)
|
||||
lookup_res = sql_select(
|
||||
table_name='event_file',
|
||||
data={
|
||||
'hosted_file_id': hosted_file_id_int,
|
||||
'for_type': for_type,
|
||||
'for_id': link_to_id_int
|
||||
}
|
||||
)
|
||||
if lookup_res:
|
||||
res_ef_id = lookup_res.get('id')
|
||||
|
||||
if isinstance(res_ef_id, int):
|
||||
# Load the newly created/updated object (enriched view)
|
||||
if enriched_ef := load_event_file_obj(event_file_id=res_ef_id, inc_hosted_file=True, model_as_dict=True):
|
||||
# Vision Transformer: Ensure ID is the random string for the frontend
|
||||
if not isinstance(enriched_ef.get('id'), str):
|
||||
rid = get_id_random(res_ef_id, table_name='event_file')
|
||||
enriched_ef['id'] = rid
|
||||
enriched_ef['event_file_id'] = rid
|
||||
|
||||
event_file_results.append(enriched_ef)
|
||||
else:
|
||||
log.error(f"Created/Updated event_file {res_ef_id} but failed to reload.")
|
||||
else:
|
||||
log.error(f"Failed to create/update event_file record. Result: {res_ef_id}")
|
||||
|
||||
return mk_resp(data=event_file_results, status_message=f"Successfully processed {len(event_file_results)} event files.")
|
||||
|
||||
|
||||
@router.post('/from_hosted_file/{hosted_file_id}', response_model=Resp_Body_Base)
|
||||
async def create_event_file_from_hosted_file_action(
|
||||
event_file_obj: Event_File_Base,
|
||||
hosted_file_id: str = Path(..., min_length=11, max_length=22),
|
||||
inc_hosted_file: bool = Query(False),
|
||||
return_obj: bool = Query(True),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""
|
||||
Specialized Action: Create Event File from Existing Hosted File.
|
||||
|
||||
This endpoint allows the frontend to associate an ALREADY UPLOADED hosted_file
|
||||
with an event-specific context (e.g., event_session, exhibit).
|
||||
|
||||
Matches V3 Vision ID Standard:
|
||||
- Accepts string ID in path.
|
||||
- Resolves relational IDs in body via Event_File_Base root_validator.
|
||||
"""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
|
||||
# 1. Verify physical file exists
|
||||
hf_rec = load_hosted_file_obj(hosted_file_id=hosted_file_id)
|
||||
if not hf_rec:
|
||||
raise HTTPException(status_code=404, detail=f"Hosted file '{hosted_file_id}' not found.")
|
||||
|
||||
# 2. Prepare event_file data
|
||||
# The Event_File_Base model now standardizes all IDs to Union[int, str]
|
||||
# and its root_validator handles the string->int resolution during instantiation.
|
||||
ef_data = event_file_obj.dict(exclude_unset=True)
|
||||
ef_data['hosted_file_id'] = hosted_file_id # Inject the path ID
|
||||
|
||||
# Re-instantiate to trigger hardened Vision resolution
|
||||
validated_ef = Event_File_Base(**ef_data)
|
||||
|
||||
# 3. Standard Generic Linking (Ensures hosted_file_link exists)
|
||||
# We need the integers for the method call
|
||||
hf_id_int = redis_lookup_id_random(record_id_random=hosted_file_id, table_name='hosted_file')
|
||||
link_to_id_int = redis_lookup_id_random(record_id_random=validated_ef.for_id, table_name=validated_ef.for_type)
|
||||
|
||||
create_hosted_file_link(
|
||||
account_id = account.account_id,
|
||||
hosted_file_id = hf_id_int,
|
||||
link_to_type = validated_ef.for_type,
|
||||
link_to_id = link_to_id_int
|
||||
)
|
||||
|
||||
# 4. Create Event File record
|
||||
res_ef_id = create_event_file_obj(event_file_obj_new=validated_ef)
|
||||
|
||||
if res_ef_id is True:
|
||||
# Update instead of insert - find the ID
|
||||
lookup_res = sql_select(
|
||||
table_name='event_file',
|
||||
data={
|
||||
'hosted_file_id': hf_id_int,
|
||||
'for_type': validated_ef.for_type,
|
||||
'for_id': link_to_id_int
|
||||
}
|
||||
)
|
||||
if lookup_res: res_ef_id = lookup_res.get('id')
|
||||
|
||||
if not isinstance(res_ef_id, int):
|
||||
raise HTTPException(status_code=400, detail="Failed to create event_file record.")
|
||||
|
||||
# 5. Return result
|
||||
if return_obj:
|
||||
enriched_ef = load_event_file_obj(event_file_id=res_ef_id, inc_hosted_file=inc_hosted_file, model_as_dict=True)
|
||||
# Vision Transformer: Ensure clean ID for frontend
|
||||
if enriched_ef and not isinstance(enriched_ef.get('id'), str):
|
||||
rid = get_id_random(res_ef_id, table_name='event_file')
|
||||
enriched_ef['id'] = rid
|
||||
enriched_ef['event_file_id'] = rid
|
||||
return mk_resp(data=enriched_ef)
|
||||
|
||||
return mk_resp(data={"event_file_id": get_id_random(res_ef_id, 'event_file')})
|
||||
|
||||
|
||||
@router.get('/{event_file_id}/download')
|
||||
async def download_event_file_action(
|
||||
response: Response,
|
||||
event_file_id: str = Path(min_length=11, max_length=22),
|
||||
filename: Optional[str] = Query(None, min_length=4, max_length=255),
|
||||
site_key: Optional[str] = Query(None),
|
||||
range: Optional[str] = Header(None),
|
||||
account: AccountContext = Depends(get_account_context_optional),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""
|
||||
Semantic alias for hosted_file download with Event-specific context.
|
||||
"""
|
||||
# Simply delegate to the universal hosted_file download logic
|
||||
from app.routers.api_v3_actions_hosted_file import download_file_action
|
||||
return await download_file_action(
|
||||
response=response,
|
||||
hosted_file_id=event_file_id, # The universal downloader now resolves this!
|
||||
filename=filename,
|
||||
site_key=site_key,
|
||||
range=range,
|
||||
account=account,
|
||||
delay=delay
|
||||
)
|
||||
@@ -7,15 +7,19 @@ import pathlib
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
import asyncio
|
||||
import logging
|
||||
from urllib.parse import quote
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
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_obj, load_hosted_file_obj, save_file,
|
||||
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
|
||||
from app.lib_general_v3 import (
|
||||
AccountContext, get_account_context, get_account_context_optional,
|
||||
SerializationParams, DelayParams
|
||||
@@ -40,7 +44,7 @@ def validate_file_extension(filename: str, allowed_extensions: List[str]):
|
||||
"""
|
||||
if not allowed_extensions:
|
||||
return True
|
||||
|
||||
|
||||
ext = filename.rsplit('.', 1)[-1].lower()
|
||||
if ext not in [e.lower().strip('.') for e in allowed_extensions]:
|
||||
raise HTTPException(
|
||||
@@ -83,7 +87,7 @@ async def upload_files_action(
|
||||
- Returns clean Vision IDs.
|
||||
"""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
|
||||
|
||||
# 1. Resolve Parent IDs
|
||||
account_id_random = account_id
|
||||
if res_account_id := redis_lookup_id_random(record_id_random=account_id, table_name='account'):
|
||||
@@ -130,21 +134,23 @@ async def upload_files_action(
|
||||
):
|
||||
# Use existing record
|
||||
hosted_file_id_int = existing_rec.get('id')
|
||||
|
||||
# Migration check: Update subdirectory if missing
|
||||
if not existing_rec.get('subdirectory_path') and file_info.get('subdirectory_path'):
|
||||
|
||||
# Migration check: Update subdirectory if missing or mismatched
|
||||
if file_info.get('subdirectory_path') and existing_rec.get('subdirectory_path') != file_info['subdirectory_path']:
|
||||
log.info(f"Updating subdirectory_path for existing record {hosted_file_id_int} to {file_info['subdirectory_path']}")
|
||||
sql_update(
|
||||
table_name = 'hosted_file',
|
||||
data = {'id': hosted_file_id_int, 'subdirectory_path': file_info['subdirectory_path']}
|
||||
)
|
||||
|
||||
|
||||
# Reload to get the latest DB state (including updated path)
|
||||
hosted_file_dict = load_hosted_file_obj(hosted_file_id=hosted_file_id_int, model_as_dict=True)
|
||||
else:
|
||||
# Create new record
|
||||
file_info['account_id'] = account_id_int
|
||||
file_info['account_id_random'] = account_id_random
|
||||
new_hosted_file_obj = Hosted_File_Base(**file_info)
|
||||
|
||||
|
||||
if res_new_id := create_hosted_file_obj(hosted_file_obj_new=new_hosted_file_obj):
|
||||
hosted_file_id_int = res_new_id
|
||||
hosted_file_dict = load_hosted_file_obj(hosted_file_id=hosted_file_id_int, model_as_dict=True)
|
||||
@@ -183,6 +189,7 @@ async def download_file_action(
|
||||
response: Response,
|
||||
hosted_file_id: str = Path(min_length=11, max_length=22),
|
||||
filename: Optional[str] = Query(None, min_length=4, max_length=255),
|
||||
key: Optional[str] = Query(None), # Simplified unauthenticated access (Account ID)
|
||||
site_key: Optional[str] = Query(None), # Bypass API Key/JWT if valid site key provided
|
||||
range: Optional[str] = Header(None),
|
||||
account: AccountContext = Depends(get_account_context_optional),
|
||||
@@ -190,26 +197,58 @@ async def download_file_action(
|
||||
):
|
||||
"""
|
||||
Enhanced download/streaming logic.
|
||||
Supports byte-range seeking, delay simulation, and site_key bypass.
|
||||
Supports byte-range seeking, delay simulation, and site_key/key bypass.
|
||||
"""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
|
||||
# 1. Auth Bypass Logic (site_key)
|
||||
# 1. Auth Bypass Logic (site_key and simplified key)
|
||||
is_authorized = False
|
||||
|
||||
# Priority A: Standard Auth (JWT or API Key)
|
||||
if account.auth_method != 'guest':
|
||||
is_authorized = True
|
||||
|
||||
# Priority B: Simplified Access Pattern (?key=ANY_VALID_ACCOUNT_ID)
|
||||
elif key:
|
||||
# For now, to unblock the frontend, any valid account_id_random is sufficient.
|
||||
# Ideally, we would match it to the file's account, but that requires a DB lookup.
|
||||
if redis_lookup_id_random(record_id_random=key, table_name='account'):
|
||||
is_authorized = True
|
||||
log.info(f"Auth Bypass: Download authorized via simplified account key.")
|
||||
|
||||
# Priority C: Site Key (?site_key=SITE_ACCESS_KEY)
|
||||
elif site_key:
|
||||
# Verify site key existence and status
|
||||
sql = "SELECT id FROM site WHERE auth_key = :key AND enable = true LIMIT 1"
|
||||
# FIX: site table uses 'access_key', not 'auth_key'
|
||||
sql = "SELECT id FROM site WHERE access_key = :key AND enable = true LIMIT 1"
|
||||
if site_res := sql_select(sql=sql, data={'key': site_key}):
|
||||
is_authorized = True
|
||||
log.info(f"Auth Bypass: Download authorized via site_key.")
|
||||
|
||||
|
||||
if not is_authorized:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Authentication required or invalid site_key.")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Authentication required or invalid access key.")
|
||||
|
||||
# 2. Resolve File Record
|
||||
# ID Vision: Attempt to resolve the ID.
|
||||
# 🛑 REMINDER: If adding a new specialized 'container' object (like event_person_profile),
|
||||
# ensure the lookup logic is mirrored here to allow direct downloads via container ID.
|
||||
# If not found in hosted_file, check if it's an event_file or archive_content ID that we can resolve.
|
||||
resolved_id = redis_lookup_id_random(record_id_random=hosted_file_id, table_name='hosted_file')
|
||||
|
||||
if not resolved_id:
|
||||
log.info(f"ID {hosted_file_id} not found in hosted_file. Checking container tables...")
|
||||
# A. Check event_file
|
||||
if ef_id := redis_lookup_id_random(record_id_random=hosted_file_id, table_name='event_file'):
|
||||
if ef_rec := sql_select(sql="SELECT hosted_file_id FROM event_file WHERE id = :id", data={'id': ef_id}):
|
||||
resolved_id = ef_rec.get('hosted_file_id')
|
||||
log.info(f"Resolved event_file {hosted_file_id} to hosted_file {resolved_id}")
|
||||
|
||||
# B. Check archive_content
|
||||
if not resolved_id:
|
||||
if ac_id := redis_lookup_id_random(record_id_random=hosted_file_id, table_name='archive_content'):
|
||||
if ac_rec := sql_select(sql="SELECT hosted_file_id FROM archive_content WHERE id = :id", data={'id': ac_id}):
|
||||
resolved_id = ac_rec.get('hosted_file_id')
|
||||
log.info(f"Resolved archive_content {hosted_file_id} to hosted_file {resolved_id}")
|
||||
|
||||
if not resolved_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hosted file record not found.")
|
||||
|
||||
@@ -251,6 +290,12 @@ async def download_file_action(
|
||||
end = min(end, file_size - 1)
|
||||
content_length = end - start + 1
|
||||
|
||||
# ID Vision: Properly encode filename for headers to avoid UnicodeEncodeError (latin-1)
|
||||
# 1. Standard filename (Sanitized for legacy clients - latin-1 safe)
|
||||
safe_filename = target_filename.encode('ascii', errors='ignore').decode('ascii')
|
||||
# 2. filename* (UTF-8 encoded for modern clients)
|
||||
encoded_filename = quote(target_filename)
|
||||
|
||||
return StreamingResponse(
|
||||
file_streamer(full_file_path, start, end + 1),
|
||||
media_type = media_type,
|
||||
@@ -259,13 +304,89 @@ async def download_file_action(
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Range': f'bytes {start}-{end}/{file_size}',
|
||||
'Content-Length': str(content_length),
|
||||
'Content-Disposition': f'attachment; filename="{target_filename}"'
|
||||
'Content-Disposition': f'attachment; filename="{safe_filename}"; filename*=utf-8\'\'{encoded_filename}'
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
return FileResponse(full_file_path, filename=target_filename, media_type=media_type)
|
||||
|
||||
|
||||
@router.get('/hash/{sha256}/download')
|
||||
async def download_file_by_hash_action(
|
||||
response: Response,
|
||||
sha256: str = Path(min_length=64, max_length=64, regex='^[a-f0-9]{64}$'),
|
||||
filename: Optional[str] = Query(None, min_length=4, max_length=255),
|
||||
account: AccountContext = Depends(get_account_context_optional),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""
|
||||
Direct hash-based download (Content-Addressable).
|
||||
- Skips DB lookup for path resolution.
|
||||
- Requires a valid API Key (via header or ?api_key=).
|
||||
- Ideal for local caching systems like Events Launcher.
|
||||
"""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
|
||||
# 1. Mandatory Auth Check
|
||||
# For now, we strictly require a valid machine API key (auth_method will not be 'guest')
|
||||
if account.auth_method == 'guest':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Valid API Key required for hash-based downloads."
|
||||
)
|
||||
|
||||
# 2. Path Resolution (Deterministic)
|
||||
hosted_files_path = settings.FILES_PATH['hosted_files_root']
|
||||
subdir = sha256[0:2]
|
||||
hash_filename = f"{sha256}.file"
|
||||
full_file_path = os.path.join(hosted_files_path, subdir, hash_filename)
|
||||
|
||||
if not os.path.exists(full_file_path):
|
||||
# Fallback to root (legacy structure)
|
||||
full_file_path = os.path.join(hosted_files_path, hash_filename)
|
||||
if not os.path.exists(full_file_path):
|
||||
log.error(f"Hash-based file not found: {sha256}")
|
||||
raise HTTPException(status_code=404, detail="File not found on server.")
|
||||
|
||||
# 3. Serve File
|
||||
target_filename = filename or f"file_{sha256[:8]}.bin"
|
||||
media_type = mimetypes.guess_type(target_filename)[0] or 'application/octet-stream'
|
||||
|
||||
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),
|
||||
@@ -340,13 +461,17 @@ async def delete_file_action(
|
||||
|
||||
if rm_orphan and is_orphan:
|
||||
log.info(f"File {file_id_int} is an orphan. Cleaning up...")
|
||||
|
||||
|
||||
# Method Handling
|
||||
if method == 'delete':
|
||||
# Hard delete: Record + Disk
|
||||
if file_exists_on_disk:
|
||||
pathlib.Path(file_path).unlink()
|
||||
physical_removed = True
|
||||
try:
|
||||
pathlib.Path(file_path).unlink()
|
||||
physical_removed = True
|
||||
except OSError as e:
|
||||
log.error(f"Error unlinking file {file_path}: {e}")
|
||||
physical_removed = False
|
||||
sql_delete(table_name='hosted_file', record_id=file_id_int)
|
||||
record_removed = True
|
||||
elif method == 'hide':
|
||||
@@ -361,3 +486,88 @@ async def delete_file_action(
|
||||
"record_removed": record_removed,
|
||||
"method": method
|
||||
}, status_message="Deletion process complete.")
|
||||
|
||||
|
||||
# ### BEGIN ### API V3 Hosted File Action ### convert_file() ###
|
||||
@router.get('/{hosted_file_id}/convert_file', response_model=Resp_Body_Base)
|
||||
async def convert_file(
|
||||
hosted_file_id: str = Path(min_length=11, max_length=22),
|
||||
link_to_type: str = Query(...),
|
||||
link_to_id: str = Query(...),
|
||||
filename_no_ext: str = Query('automated_hosted_file_conversion'),
|
||||
to_type: str = Query('webp'),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
):
|
||||
"""
|
||||
Convert a hosted file to another format (e.g. PDF → webp image).
|
||||
Runs pdf2image server-side and saves the result as a new hosted_file record
|
||||
linked to the same parent object via link_to_type / link_to_id.
|
||||
"""
|
||||
lid_int = redis_lookup_id_random(record_id_random=link_to_id, table_name=link_to_type)
|
||||
if not lid_int:
|
||||
raise HTTPException(status_code=404, detail=f"Linked object not found: {link_to_type}:{link_to_id}")
|
||||
result = await convert_file_method(
|
||||
hosted_file_id=hosted_file_id,
|
||||
link_to_type=link_to_type,
|
||||
link_to_id=lid_int,
|
||||
account_id=account.account_id,
|
||||
account_id_random=account.account_id_random,
|
||||
filename_no_ext=filename_no_ext,
|
||||
to_type=to_type
|
||||
)
|
||||
if result:
|
||||
return mk_resp(data=result)
|
||||
return mk_resp(data=None, status_code=400, status_message="Conversion failed.")
|
||||
# ### END ### API V3 Hosted File Action ### convert_file() ###
|
||||
|
||||
|
||||
@router.get('/{hosted_file_id}/clip_video', response_model=Resp_Body_Base)
|
||||
async def clip_video(
|
||||
hosted_file_id: str = Path(min_length=11, max_length=22),
|
||||
link_to_type: str = Query(...),
|
||||
link_to_id: str = Query(...),
|
||||
start_time: str = Query(..., min_length=8, max_length=8),
|
||||
end_time: str = Query(..., min_length=8, max_length=8),
|
||||
filename_no_ext: str = Query('automated_hosted_file_clip_video'),
|
||||
reencode: bool = Query(False),
|
||||
scale_down: bool = Query(False),
|
||||
background: bool = Query(False),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
):
|
||||
"""
|
||||
Clip a segment from a hosted video and save as a new hosted_file record.
|
||||
Supports optional background scheduling returning `202 Accepted` when `background=true`.
|
||||
"""
|
||||
lid_int = redis_lookup_id_random(record_id_random=link_to_id, table_name=link_to_type)
|
||||
if not lid_int:
|
||||
raise HTTPException(status_code=404, detail=f"Linked object not found: {link_to_type}:{link_to_id}")
|
||||
|
||||
async def _run_clip():
|
||||
try:
|
||||
return await clip_video_method(
|
||||
hosted_file_id=hosted_file_id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
account_id=account.account_id,
|
||||
account_id_random=account.account_id_random,
|
||||
link_to_type=link_to_type,
|
||||
link_to_id=lid_int,
|
||||
filename_no_ext=filename_no_ext,
|
||||
reencode=reencode,
|
||||
scale_down=scale_down,
|
||||
)
|
||||
except Exception:
|
||||
log.exception('Background clip task failed')
|
||||
return None
|
||||
|
||||
if background:
|
||||
# Schedule and return 202 Accepted
|
||||
asyncio.create_task(_run_clip())
|
||||
return mk_resp(data={'task': 'scheduled'}, status_code=202, status_message='Clip scheduled (background)')
|
||||
|
||||
result = await _run_clip()
|
||||
if result:
|
||||
return mk_resp(data=result)
|
||||
return mk_resp(data=None, status_code=400, status_message="Clip failed.")
|
||||
# ### END ### API V3 Hosted File Action ### clip_video() ###
|
||||
# ### END ### API V3 Hosted File Action ### convert_file() ###
|
||||
|
||||
41
app/routers/api_v3_actions_idaa.py
Normal file
41
app/routers/api_v3_actions_idaa.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.lib_general_v3 import AccountContext, get_account_context, DelayParams
|
||||
from app.models.response_models import Resp_Body_Base, mk_resp
|
||||
from app.methods.idaa_novi_verify_methods import verify_novi_member
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/novi_member/{uuid}', response_model=Resp_Body_Base)
|
||||
async def get_novi_member_verification(
|
||||
uuid: str,
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""
|
||||
Proxy Novi AMS member lookup server-to-server.
|
||||
Returns verified member identity or an appropriate error code.
|
||||
"""
|
||||
if delay.sleep_time_s > 0:
|
||||
await asyncio.sleep(delay.sleep_time_s)
|
||||
|
||||
result = verify_novi_member(uuid)
|
||||
status = result.get('status', 503)
|
||||
|
||||
if status == 200:
|
||||
return mk_resp(data={
|
||||
'verified': result['verified'],
|
||||
'full_name': result['full_name'],
|
||||
'email': result['email'],
|
||||
})
|
||||
|
||||
if status == 404:
|
||||
return mk_resp(data=False, status_code=404, status_message=result.get('reason', 'Member not found.'))
|
||||
|
||||
if status == 429:
|
||||
return mk_resp(data=False, status_code=429, status_message=result.get('reason', 'Novi rate limit exceeded.'))
|
||||
|
||||
return mk_resp(data=False, status_code=503, status_message=result.get('reason', 'Novi API unavailable.'))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user