feat: add ReconConfig API and UI for managing configurations

- Added a new API endpoint for managing ReconConfigs at /api/configs, including GET, POST, PUT, and a test URL feature.
- Implemented a new configuration editor UI at /configs for creating and editing ReconConfigs.
- Introduced a new configs list page at /configs to display existing configurations with options to edit.
- Updated base HTML template to include a link to the new configs page.
- Created stub configuration and authentication models for workspace development.
- Added a stub configuration module to handle database configuration without a real database.
This commit is contained in:
2026-05-26 21:58:04 +12:00
parent cf8ec5f094
commit 35d70a7746
10 changed files with 2181 additions and 725 deletions
+222
View File
@@ -0,0 +1,222 @@
"""ReconConfig API proxy / fake endpoints.
When RECON_API_BASE_URL is set in the environment, POST and PUT calls are
forwarded to the real API. GET calls always use fake data in this workspace
so the editor has something to load without a real database.
Also provides a /api/configs/test-url endpoint that inspects file:// URLs and
returns metadata (no file contents are returned).
"""
from __future__ import annotations
import glob
import os
from datetime import datetime
from pathlib import Path
from typing import Any
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
from app.core.refdata import ReconConfigStatus, ReconPatterns
from app.service.fake_configs import get_fake_configs
router = APIRouter(prefix="/api/configs", tags=["ReconConfig"])
_RECON_API_BASE = os.getenv("RECON_API_BASE_URL", "").rstrip("/")
# ── Helpers ────────────────────────────────────────────────────────────────
def _fake_config_full(ref: str, now: datetime) -> dict[str, Any] | None:
"""Build a full ReconConfigRequest-shaped dict from a FakeReconConfig."""
fake = next((c for c in get_fake_configs(now) if c.reference == ref), None)
if fake is None:
return None
return {
"reference": fake.reference,
"name": fake.name,
"business_process": fake.business_process,
"comment": "",
"data_type": "Unknown",
"pattern": ReconPatterns.ONE_TO_ONE.value,
"status": fake.status,
"frequency": fake.frequency,
"start_datetime": fake.start_datetime.isoformat() if fake.start_datetime else None,
"field_mapping": {},
"sources": [
{
"name": "Source System",
"url": "file:///data/input/{{as_at_date.strftime('%Y%m%d')}}.csv",
"comment": "",
"day_offset": 0,
"system_schema": {},
"obfuscate_fields": [],
"pci_redact_fields": [],
"field_widths": [],
"profile_thresholds": [],
"index_fields": [],
"filter": "",
"sql": "",
"csv_spec": {"delimiter": ",", "header": True, "encoding": "utf_8", "trailer_rows": 0, "quoting": True},
"xml_spec": None,
}
],
"destination": {
"name": "Destination System",
"url": "mssql://GROUPDW?db=BIODS_PROCESSING&table=EXAMPLE",
"comment": "",
"day_offset": 0,
"system_schema": {},
"obfuscate_fields": [],
"pci_redact_fields": [],
"field_widths": [],
"profile_thresholds": [],
"index_fields": [],
"filter": "",
"sql": "",
"csv_spec": None,
"xml_spec": None,
},
}
# ── List ────────────────────────────────────────────────────────────────────
@router.get("/")
async def list_configs():
now = datetime.now()
items = []
for c in get_fake_configs(now):
items.append({
"reference": c.reference,
"name": c.name,
"status": c.status,
"frequency": c.frequency,
"business_process": c.business_process,
})
return items
# ── Get one ─────────────────────────────────────────────────────────────────
@router.get("/{reference}")
async def get_config(reference: str):
now = datetime.now()
if _RECON_API_BASE:
async with httpx.AsyncClient(verify=False) as client:
resp = await client.get(f"{_RECON_API_BASE}/api/configs/{reference}")
if resp.status_code == 404:
raise HTTPException(404, f"Config '{reference}' not found")
return resp.json()
data = _fake_config_full(reference, now)
if data is None:
raise HTTPException(404, f"Config '{reference}' not found")
return data
# ── Create ──────────────────────────────────────────────────────────────────
@router.post("/")
async def create_config(request: Request):
body = await request.json()
if _RECON_API_BASE:
async with httpx.AsyncClient(verify=False) as client:
resp = await client.post(f"{_RECON_API_BASE}/api/configs/", json=body)
return JSONResponse(content=resp.json(), status_code=resp.status_code)
# Workspace stub: echo back with a fake revision
return JSONResponse(
content={**body, "revision_number": 1, "user": {"username": "workspace"}},
status_code=201,
)
# ── Update ──────────────────────────────────────────────────────────────────
@router.put("/{reference}")
async def update_config(reference: str, request: Request):
body = await request.json()
if _RECON_API_BASE:
async with httpx.AsyncClient(verify=False) as client:
resp = await client.put(f"{_RECON_API_BASE}/api/configs/{reference}", json=body)
return JSONResponse(content=resp.json(), status_code=resp.status_code)
return JSONResponse(
content={**body, "revision_number": body.get("revision_number", 1) + 1, "user": {"username": "workspace"}},
status_code=200,
)
# ── Test URL ────────────────────────────────────────────────────────────────
@router.post("/test-url")
async def test_url(request: Request):
"""Return metadata for a URL pattern — no file contents ever returned."""
body = await request.json()
url: str = body.get("url", "")
as_at_date: str = body.get("as_at_date", datetime.now().strftime("%Y%m%d"))
if not url.startswith("file://"):
return {"type": "non-file", "message": "Only file:// URLs can be tested here. Database connections are validated at run time."}
# Resolve the path part (strip file://)
raw_path = url[7:]
# Replace simple template variables for preview purposes
from datetime import date as date_type
try:
as_at = datetime.strptime(as_at_date, "%Y%m%d").date()
except ValueError:
as_at = date_type.today()
resolved = (
raw_path
.replace("{{as_at_date.strftime('%Y%m%d')}}", as_at.strftime("%Y%m%d"))
.replace("{{today.strftime('%Y%m%d')}}", date_type.today().strftime("%Y%m%d"))
.replace("{{as_at_date.strftime('%Y-%m-%d')}}", as_at.strftime("%Y-%m-%d"))
.replace("{{today.strftime('%Y-%m-%d')}}", date_type.today().strftime("%Y-%m-%d"))
)
# Treat as glob pattern for anything still containing {
if "*" in resolved or "?" in resolved or "{" in resolved:
matches = glob.glob(resolved)
if not matches:
return {"type": "file", "resolved": resolved, "found": False, "message": "No files matched the pattern."}
file_infos = []
for p in sorted(matches)[:10]:
try:
st = os.stat(p)
file_infos.append({
"path": p,
"size_bytes": st.st_size,
"modified": datetime.fromtimestamp(st.st_mtime).isoformat(timespec="seconds"),
})
except OSError:
file_infos.append({"path": p, "error": "Could not stat file"})
return {"type": "file", "resolved": resolved, "found": True, "matches": len(matches), "files": file_infos}
# Exact path
p = Path(resolved)
if not p.exists():
return {"type": "file", "resolved": resolved, "found": False, "message": f"File not found: {resolved}"}
try:
st = p.stat()
return {
"type": "file",
"resolved": resolved,
"found": True,
"matches": 1,
"files": [{
"path": str(p),
"size_bytes": st.st_size,
"modified": datetime.fromtimestamp(st.st_mtime).isoformat(timespec="seconds"),
}],
}
except OSError as e:
return {"type": "file", "resolved": resolved, "found": False, "message": str(e)}
+2
View File
@@ -6,6 +6,7 @@ from fastapi.staticfiles import StaticFiles
from app.api.reporting import router as reporting_router
from app.api.transactions import router as transactions_router
from app.api.configs import router as configs_router
from app.core.settings import get_settings
from app.views.auth import router as auth_router
from app.views.views import router as dashboard_router
@@ -34,6 +35,7 @@ def create_app() -> FastAPI:
app.include_router(transactions_router)
app.include_router(reporting_router)
app.include_router(configs_router)
app.include_router(auth_router)
app.include_router(dashboard_router)
app.include_router(docs_router)
+29
View File
@@ -0,0 +1,29 @@
"""Stub config module for the workspace.
The production repo has a full config with database URLs and connection maps.
This stub satisfies imports without a real database.
"""
from functools import lru_cache
from os import getenv
db_config_map = {
"NETREVEAL": "netreveal",
"DATAPRODUCT": "dataproduct",
"GROUPDW": "groupdw",
"SNOWFLAKE": "snowflake",
}
class DBConfig:
def __init__(self) -> None:
self.db_url = getenv("DATABASE_URL", "sqlite+aiosqlite:///./recon_ranger.db")
@lru_cache
def get_db_config() -> DBConfig:
return DBConfig()
db_config = get_db_config()
config = db_config
+24 -2
View File
@@ -1,7 +1,7 @@
"""Fake reference-data enums.
The real `app.core.refdata` lives in the production repo. For this workspace
we only need `ReconJobStatus` (consumed by `app.models.recon_job`).
The real `app.core.refdata` lives in the production repo. This workspace
extends it with the enums needed by the config editor.
"""
from enum import Enum
@@ -12,3 +12,25 @@ class ReconJobStatus(str, Enum):
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class ReconConfigStatus(str, Enum):
DRAFT = "draft"
PUBLISHED = "published"
ARCHIVED = "archived"
class ReconPatterns(str, Enum):
ONE_TO_ONE = "one_to_one"
MANY_TO_ONE = "many_to_one"
ONE_TO_MANY = "one_to_many"
POSITIONAL = "positional"
class ProfileFields(str, Enum):
ROW_COUNT = "row_count"
NULL_COUNT = "null_count"
DISTINCT_COUNT = "distinct_count"
SUM = "sum"
MIN = "min"
MAX = "max"
+7
View File
@@ -0,0 +1,7 @@
"""Stub auth model for workspace — production version lives in the real repo."""
from pydantic import BaseModel
class UserResponse(BaseModel):
username: str
email: str = ""
+57
View File
@@ -14,6 +14,7 @@ from fastapi.templating import Jinja2Templates
from app.models.recon_job import ReconJob # noqa: F401 (validates that the real model imports cleanly)
from app.service.fake_jobs import FakeReconJob, get_fake_jobs
from app.service.fake_configs import FakeReconConfig, get_fake_configs
from app.core.refdata import ReconPatterns, ReconConfigStatus
router = APIRouter(prefix="", tags=["User_Interface"])
@@ -505,3 +506,59 @@ async def job_detail_view(request: Request, job_id: int):
},
},
)
# ── Config editor ────────────────────────────────────────────────────────────
_EDITOR_CONTEXT = {
"patterns": [p.value for p in ReconPatterns],
"statuses": [s.value for s in ReconConfigStatus],
"frequencies": ["Ad Hoc", "Intra Day", "Daily", "Weekly", "Monthly", "Quarterly"],
"schema_types": ["str", "int", "float", "date('%Y-%m-%d')", "datetime('%Y-%m-%d %H:%M:%S')", "bool"],
}
@router.get("/configs/new", response_class=HTMLResponse)
async def config_new_view(request: Request):
return templates.TemplateResponse(
request=request,
name="config_editor.html",
context={
"title": "New Config",
"config": None,
"is_new": True,
**_EDITOR_CONTEXT,
},
)
@router.get("/configs/{reference}/edit", response_class=HTMLResponse)
async def config_edit_view(request: Request, reference: str):
now = datetime.now()
configs = get_fake_configs(now)
config = next((c for c in configs if c.reference == reference), None)
return templates.TemplateResponse(
request=request,
name="config_editor.html",
context={
"title": f"Edit — {reference}" if config else "Edit Config",
"config": config,
"reference": reference,
"is_new": False,
**_EDITOR_CONTEXT,
},
)
@router.get("/configs", response_class=HTMLResponse)
async def configs_list_view(request: Request):
now = datetime.now()
configs = get_fake_configs(now)
return templates.TemplateResponse(
request=request,
name="configs_list.html",
context={
"title": "Recon Configs",
"configs": configs,
},
)