reports
This commit is contained in:
parent
d272ae0bd3
commit
ff01efe4bb
17 changed files with 1378 additions and 21 deletions
|
|
@ -22,6 +22,7 @@ class SendAgentMessagePayload(BaseModel):
|
|||
template: str = Field(min_length=2, max_length=64)
|
||||
to: str = Field(min_length=10, max_length=20)
|
||||
customer_name: str = Field(min_length=1, max_length=120)
|
||||
report_group: str = Field(min_length=3, max_length=50)
|
||||
report_id: str | None = Field(default=None, max_length=120)
|
||||
|
||||
|
||||
|
|
@ -72,6 +73,18 @@ def require_subscription(db: Session, user: UserAccount, template: AgentMessageT
|
|||
return subscription
|
||||
|
||||
|
||||
def _validate_report_group(report_group: str) -> str:
|
||||
cleaned = report_group.strip().lower()
|
||||
if not cleaned or len(cleaned) > 50:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Invalid report_group")
|
||||
if not re.fullmatch(r"[a-z][a-z0-9-]{1,48}[a-z0-9]", cleaned) or "--" in cleaned:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="report_group must be 3-50 chars: lowercase letters, numbers, hyphens",
|
||||
)
|
||||
return cleaned
|
||||
|
||||
|
||||
def _validate_report_id(report_id: str) -> str:
|
||||
cleaned = report_id.strip()
|
||||
if not cleaned or len(cleaned) > 120:
|
||||
|
|
@ -84,7 +97,12 @@ def _validate_report_id(report_id: str) -> str:
|
|||
return cleaned
|
||||
|
||||
|
||||
def _build_template_components(template: AgentMessageTemplate, customer_name: str, report_id: str) -> list[dict]:
|
||||
def _build_template_components(
|
||||
template: AgentMessageTemplate,
|
||||
customer_name: str,
|
||||
report_group: str,
|
||||
report_id: str,
|
||||
) -> list[dict]:
|
||||
components: list[dict] = [
|
||||
{
|
||||
"type": "body",
|
||||
|
|
@ -103,7 +121,7 @@ def _build_template_components(template: AgentMessageTemplate, customer_name: st
|
|||
"type": "button",
|
||||
"sub_type": "url",
|
||||
"index": "0",
|
||||
"parameters": [{"type": "text", "text": report_id}],
|
||||
"parameters": [{"type": "text", "text": f"{report_group}/{report_id}"}],
|
||||
}
|
||||
)
|
||||
return components
|
||||
|
|
@ -128,8 +146,9 @@ async def send_agent_template_message(
|
|||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
report_group = _validate_report_group(payload.report_group)
|
||||
report_id = _validate_report_id(payload.report_id or f"run-{secrets.token_hex(3)}")
|
||||
components = _build_template_components(template, payload.customer_name, report_id)
|
||||
components = _build_template_components(template, payload.customer_name, report_group, report_id)
|
||||
|
||||
client = _whatsapp_client(db)
|
||||
try:
|
||||
|
|
@ -149,6 +168,7 @@ async def send_agent_template_message(
|
|||
recipient=recipient,
|
||||
parameters={
|
||||
"customer_name": payload.customer_name.strip(),
|
||||
"report_group": report_group,
|
||||
"report_id": report_id,
|
||||
},
|
||||
wa_message_id=wa_message_id,
|
||||
|
|
@ -159,12 +179,17 @@ async def send_agent_template_message(
|
|||
db.commit()
|
||||
db.refresh(log)
|
||||
|
||||
report_url = f"{template.button_url_base.rstrip('/')}/{report_id}" if template.button_url_base else None
|
||||
report_url = (
|
||||
f"{template.button_url_base.rstrip('/')}/{report_group}/{report_id}"
|
||||
if template.button_url_base
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"template": template.slug,
|
||||
"to": recipient,
|
||||
"customer_name": payload.customer_name.strip(),
|
||||
"report_group": report_group,
|
||||
"report_id": report_id,
|
||||
"report_url": report_url,
|
||||
"wa_message_id": wa_message_id,
|
||||
|
|
@ -201,6 +226,7 @@ async def run_console_template_test(
|
|||
*,
|
||||
to: str,
|
||||
customer_name: str,
|
||||
report_group: str,
|
||||
report_id: str | None = None,
|
||||
) -> dict:
|
||||
require_subscription(db, user, template)
|
||||
|
|
@ -215,6 +241,7 @@ async def run_console_template_test(
|
|||
template=template.slug,
|
||||
to=to,
|
||||
customer_name=customer_name,
|
||||
report_group=report_group,
|
||||
report_id=report_id,
|
||||
)
|
||||
result = await send_agent_template_message(db, user, payload, source="console_test")
|
||||
|
|
@ -250,12 +277,14 @@ def build_agent_message_docs(template: AgentMessageTemplate) -> dict:
|
|||
"template": f"Template slug. Use `{template.slug}`.",
|
||||
"to": "Recipient WhatsApp number with country code, e.g. 919149154337",
|
||||
"customer_name": "Recipient name inserted into the template body",
|
||||
"report_id": "Optional unique report id for the View Report button URL suffix",
|
||||
"report_group": "Urmiport report group slug used in the hosted report URL",
|
||||
"report_id": "Optional report postfix; auto-generated when omitted",
|
||||
},
|
||||
"example": {
|
||||
"template": template.slug,
|
||||
"to": "919149154337",
|
||||
"customer_name": "Suyash",
|
||||
"report_group": "acme-agent-runs",
|
||||
"report_id": "run-abc123",
|
||||
},
|
||||
"response_example": {
|
||||
|
|
@ -263,8 +292,9 @@ def build_agent_message_docs(template: AgentMessageTemplate) -> dict:
|
|||
"template": template.slug,
|
||||
"to": "919149154337",
|
||||
"customer_name": "Suyash",
|
||||
"report_group": "acme-agent-runs",
|
||||
"report_id": "run-abc123",
|
||||
"report_url": f"{template.button_url_base or ''}run-abc123",
|
||||
"report_url": f"{template.button_url_base or ''}acme-agent-runs/run-abc123",
|
||||
"wa_message_id": "wamid.HBg...",
|
||||
"dispatch_id": "00000000-0000-0000-0000-000000000001",
|
||||
},
|
||||
|
|
@ -272,6 +302,7 @@ def build_agent_message_docs(template: AgentMessageTemplate) -> dict:
|
|||
"notes": [
|
||||
"Subscribe to the template in the Urmi console before calling this API.",
|
||||
"Messages are sent from the Urmi AI WhatsApp business number on your behalf.",
|
||||
"If report_id is omitted, Urmi generates a unique id automatically.",
|
||||
"Create the report group in Console → Agent → Urmiport before sending.",
|
||||
"If report_id is omitted, Urmi generates a unique postfix automatically.",
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,18 @@ DEFAULT_AGENT_TEMPLATES = [
|
|||
"description": "Recipient display name shown in the greeting",
|
||||
"example": "Suyash",
|
||||
},
|
||||
{
|
||||
"name": "report_group",
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"description": "Urmiport report group slug used in the hosted report URL",
|
||||
"example": "acme-agent-runs",
|
||||
},
|
||||
{
|
||||
"name": "report_id",
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"description": "Unique report identifier appended to the View Report button URL",
|
||||
"description": "Report postfix appended after the report group in the View Report button URL",
|
||||
"example": "run-abc123",
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ from .routes_voice import router as voice_router
|
|||
from .routes_whatsapp import router as whatsapp_router
|
||||
from .routes_agent_templates import router as agent_templates_router
|
||||
from .routes_agent_messages import router as agent_messages_router
|
||||
from .routes_urmiport import router as urmiport_router
|
||||
from .routes_urmiport_api import router as urmiport_api_router
|
||||
from .agent_templates_seed import seed_agent_message_templates
|
||||
|
||||
|
||||
|
|
@ -95,6 +97,8 @@ app.include_router(voice_router)
|
|||
app.include_router(whatsapp_router)
|
||||
app.include_router(agent_templates_router)
|
||||
app.include_router(agent_messages_router)
|
||||
app.include_router(urmiport_router)
|
||||
app.include_router(urmiport_api_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -109,6 +109,12 @@ class UserAccount(Base):
|
|||
agent_message_dispatches: Mapped[list["AgentMessageDispatchLog"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
urmiport_report_groups: Mapped[list["UrmiportReportGroup"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
urmiport_reports: Mapped[list["UrmiportReport"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class PasswordCredential(Base):
|
||||
|
|
@ -609,6 +615,49 @@ class AgentMessageDispatchLog(Base):
|
|||
template: Mapped[AgentMessageTemplate] = relationship()
|
||||
|
||||
|
||||
class UrmiportReportGroup(Base):
|
||||
__tablename__ = "urmiport_report_groups"
|
||||
__table_args__ = (UniqueConstraint("user_id", "slug", name="uq_urmiport_group_user_slug"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(50), index=True)
|
||||
display_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
default_access: Mapped[str] = mapped_column(String(16), default="public", index=True)
|
||||
password_hash: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
id_mode: Mapped[str] = mapped_column(String(16), default="auto")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
user: Mapped[UserAccount] = relationship(back_populates="urmiport_report_groups")
|
||||
reports: Mapped[list["UrmiportReport"]] = relationship(
|
||||
back_populates="group", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class UrmiportReport(Base):
|
||||
__tablename__ = "urmiport_reports"
|
||||
__table_args__ = (UniqueConstraint("group_id", "report_id", name="uq_urmiport_group_report_id"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("user_accounts.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
group_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("urmiport_report_groups.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
report_id: Mapped[str] = mapped_column(String(120), index=True)
|
||||
html_body: Mapped[str] = mapped_column(Text)
|
||||
access_mode: Mapped[str] = mapped_column(String(16), default="public", index=True)
|
||||
password_hash: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
user: Mapped[UserAccount] = relationship(back_populates="urmiport_reports")
|
||||
group: Mapped[UrmiportReportGroup] = relationship(back_populates="reports")
|
||||
|
||||
|
||||
class PlatformSetting(Base):
|
||||
"""Runtime overrides for platform credentials (admin-editable)."""
|
||||
|
||||
|
|
|
|||
|
|
@ -45,10 +45,16 @@ def _explorer_doc_for_template(template: AgentMessageTemplate) -> dict:
|
|||
"description": "Recipient name shown in the template body",
|
||||
"example": "Suyash",
|
||||
},
|
||||
"report_group": {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"description": "Urmiport report group slug used in the hosted report URL",
|
||||
"example": "acme-agent-runs",
|
||||
},
|
||||
"report_id": {
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"description": "Report id suffix for the dynamic View Report button URL",
|
||||
"description": "Report postfix; auto-generated when omitted",
|
||||
"example": "run-abc123",
|
||||
},
|
||||
},
|
||||
|
|
@ -60,8 +66,9 @@ def _explorer_doc_for_template(template: AgentMessageTemplate) -> dict:
|
|||
"template": template.slug,
|
||||
"to": "919149154337",
|
||||
"customer_name": "Suyash",
|
||||
"report_group": "acme-agent-runs",
|
||||
"report_id": "run-abc123",
|
||||
"report_url": f"{template.button_url_base or ''}run-abc123",
|
||||
"report_url": f"{template.button_url_base or ''}acme-agent-runs/run-abc123",
|
||||
"wa_message_id": "wamid.HBg...",
|
||||
"dispatch_id": "00000000-0000-0000-0000-000000000001",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ router = APIRouter(prefix="/api/v1/agent-templates", tags=["agent-templates"])
|
|||
class AgentTemplateTestRequest(BaseModel):
|
||||
to: str = Field(min_length=10, max_length=20)
|
||||
customer_name: str = Field(min_length=1, max_length=120)
|
||||
report_group: str = Field(min_length=3, max_length=50)
|
||||
report_id: str | None = Field(default=None, max_length=120)
|
||||
|
||||
|
||||
|
|
@ -140,5 +141,6 @@ async def test_agent_template(
|
|||
template,
|
||||
to=payload.to,
|
||||
customer_name=payload.customer_name,
|
||||
report_group=payload.report_group,
|
||||
report_id=payload.report_id,
|
||||
)
|
||||
|
|
|
|||
180
backend/app/routes_urmiport.py
Normal file
180
backend/app/routes_urmiport.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .auth import current_user
|
||||
from .database import get_db
|
||||
from .models import UrmiportReport, UrmiportReportGroup, UserAccount
|
||||
from .urmiport_service import (
|
||||
CreateUrmiportGroupPayload,
|
||||
UpdateUrmiportGroupPayload,
|
||||
build_urmiport_api_docs,
|
||||
get_group_by_slug,
|
||||
get_report,
|
||||
get_user_group,
|
||||
hash_password,
|
||||
normalize_group_slug,
|
||||
serialize_group,
|
||||
serialize_report,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/urmiport", tags=["urmiport"])
|
||||
|
||||
|
||||
class UnlockReportRequest(BaseModel):
|
||||
password: str = Field(min_length=1, max_length=120)
|
||||
|
||||
|
||||
@router.get("/docs")
|
||||
def urmiport_docs() -> dict:
|
||||
return build_urmiport_api_docs()
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
def list_report_groups(
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> list[dict]:
|
||||
groups = db.scalars(
|
||||
select(UrmiportReportGroup)
|
||||
.where(UrmiportReportGroup.user_id == user.id)
|
||||
.order_by(UrmiportReportGroup.created_at.desc())
|
||||
).all()
|
||||
counts = dict(
|
||||
db.execute(
|
||||
select(UrmiportReport.group_id, func.count(UrmiportReport.id))
|
||||
.where(UrmiportReport.user_id == user.id)
|
||||
.group_by(UrmiportReport.group_id)
|
||||
).all()
|
||||
)
|
||||
return [serialize_group(group, counts.get(group.id, 0)) for group in groups]
|
||||
|
||||
|
||||
@router.post("/groups")
|
||||
def create_report_group(
|
||||
payload: CreateUrmiportGroupPayload,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
try:
|
||||
slug = normalize_group_slug(payload.report_group)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
existing = db.scalar(
|
||||
select(UrmiportReportGroup).where(
|
||||
UrmiportReportGroup.user_id == user.id,
|
||||
UrmiportReportGroup.slug == slug,
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Report group already exists")
|
||||
|
||||
if payload.default_access == "password" and not payload.password:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="password is required")
|
||||
|
||||
group = UrmiportReportGroup(
|
||||
user_id=user.id,
|
||||
slug=slug,
|
||||
display_name=payload.display_name or slug,
|
||||
default_access=payload.default_access,
|
||||
password_hash=hash_password(payload.password) if payload.default_access == "password" else None,
|
||||
id_mode=payload.id_mode,
|
||||
)
|
||||
db.add(group)
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
return serialize_group(group, 0)
|
||||
|
||||
|
||||
@router.patch("/groups/{slug}")
|
||||
def update_report_group(
|
||||
slug: str,
|
||||
payload: UpdateUrmiportGroupPayload,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
group = get_user_group(db, user, slug)
|
||||
if payload.display_name is not None:
|
||||
group.display_name = payload.display_name.strip() or group.slug
|
||||
if payload.default_access is not None:
|
||||
group.default_access = payload.default_access
|
||||
if payload.default_access == "password":
|
||||
if payload.password:
|
||||
group.password_hash = hash_password(payload.password)
|
||||
elif not group.password_hash:
|
||||
raise HTTPException(status_code=422, detail="password is required")
|
||||
else:
|
||||
group.password_hash = None
|
||||
elif payload.password:
|
||||
group.password_hash = hash_password(payload.password)
|
||||
if payload.id_mode is not None:
|
||||
group.id_mode = payload.id_mode
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
count = db.scalar(
|
||||
select(func.count(UrmiportReport.id)).where(UrmiportReport.group_id == group.id)
|
||||
) or 0
|
||||
return serialize_group(group, count)
|
||||
|
||||
|
||||
@router.delete("/groups/{slug}")
|
||||
def delete_report_group(
|
||||
slug: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
group = get_user_group(db, user, slug)
|
||||
db.delete(group)
|
||||
db.commit()
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.get("/groups/{slug}/reports")
|
||||
def list_group_reports(
|
||||
slug: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> list[dict]:
|
||||
group = get_user_group(db, user, slug)
|
||||
reports = db.scalars(
|
||||
select(UrmiportReport)
|
||||
.where(UrmiportReport.group_id == group.id)
|
||||
.order_by(UrmiportReport.updated_at.desc())
|
||||
).all()
|
||||
return [serialize_report(report, group) for report in reports]
|
||||
|
||||
|
||||
@router.get("/view/{group_slug}/{report_id}")
|
||||
def view_report_metadata(
|
||||
group_slug: str,
|
||||
report_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
group = get_group_by_slug(db, group_slug)
|
||||
report = get_report(db, group, report_id)
|
||||
return {
|
||||
"report_group": group.slug,
|
||||
"report_id": report.report_id,
|
||||
"access": report.access_mode,
|
||||
"requires_password": report.access_mode == "password",
|
||||
"html": report.html_body if report.access_mode == "public" else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/view/{group_slug}/{report_id}/unlock")
|
||||
def unlock_report(
|
||||
group_slug: str,
|
||||
report_id: str,
|
||||
payload: UnlockReportRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
group = get_group_by_slug(db, group_slug)
|
||||
report = get_report(db, group, report_id)
|
||||
if report.access_mode != "password":
|
||||
return {"html": report.html_body}
|
||||
if not verify_password(payload.password, report.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid password")
|
||||
return {"html": report.html_body}
|
||||
23
backend/app/routes_urmiport_api.py
Normal file
23
backend/app/routes_urmiport_api.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .api_key_auth import current_api_user
|
||||
from .database import get_db
|
||||
from .models import UserAccount
|
||||
from .urmiport_service import UpsertUrmiportReportPayload, build_urmiport_api_docs, upsert_report_for_user
|
||||
|
||||
router = APIRouter(prefix="/api/v1/urmiport", tags=["urmiport-api"])
|
||||
|
||||
|
||||
@router.get("/reports/docs")
|
||||
def urmiport_reports_api_docs() -> dict:
|
||||
return build_urmiport_api_docs()
|
||||
|
||||
|
||||
@router.post("/reports")
|
||||
def publish_report(
|
||||
payload: UpsertUrmiportReportPayload,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_api_user),
|
||||
) -> dict:
|
||||
return upsert_report_for_user(db, user, payload)
|
||||
300
backend/app/urmiport_service.py
Normal file
300
backend/app/urmiport_service.py
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
import re
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from pwdlib import PasswordHash
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import UrmiportReport, UrmiportReportGroup, UserAccount
|
||||
|
||||
password_hasher = PasswordHash.recommended()
|
||||
|
||||
GROUP_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{1,48}[a-z0-9]$")
|
||||
REPORT_ID_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,119}$")
|
||||
|
||||
|
||||
class UpsertUrmiportReportPayload(BaseModel):
|
||||
report_group: str = Field(min_length=3, max_length=50)
|
||||
report_id: str | None = Field(default=None, max_length=120)
|
||||
body: str = Field(min_length=1)
|
||||
access: str = Field(default="public", pattern="^(public|password)$")
|
||||
password: str | None = Field(default=None, max_length=120)
|
||||
|
||||
|
||||
class CreateUrmiportGroupPayload(BaseModel):
|
||||
report_group: str = Field(min_length=3, max_length=50)
|
||||
display_name: str | None = Field(default=None, max_length=120)
|
||||
default_access: str = Field(default="public", pattern="^(public|password)$")
|
||||
password: str | None = Field(default=None, max_length=120)
|
||||
id_mode: str = Field(default="auto", pattern="^(auto|manual)$")
|
||||
|
||||
|
||||
class UpdateUrmiportGroupPayload(BaseModel):
|
||||
display_name: str | None = Field(default=None, max_length=120)
|
||||
default_access: str | None = Field(default=None, pattern="^(public|password)$")
|
||||
password: str | None = Field(default=None, max_length=120)
|
||||
id_mode: str | None = Field(default=None, pattern="^(auto|manual)$")
|
||||
|
||||
|
||||
def normalize_group_slug(value: str) -> str:
|
||||
slug = value.strip().lower()
|
||||
if not GROUP_SLUG_RE.match(slug) or "--" in slug:
|
||||
raise ValueError("Report group must be 3-50 chars: lowercase letters, numbers, hyphens")
|
||||
return slug
|
||||
|
||||
|
||||
def normalize_report_id(value: str) -> str:
|
||||
report_id = value.strip()
|
||||
if not REPORT_ID_RE.match(report_id):
|
||||
raise ValueError("Report id may contain letters, numbers, dots, underscores, and hyphens")
|
||||
return report_id
|
||||
|
||||
|
||||
def hash_password(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
return password_hasher.hash(value)
|
||||
|
||||
|
||||
def verify_password(value: str, hashed: str | None) -> bool:
|
||||
if not hashed:
|
||||
return False
|
||||
return password_hasher.verify(value, hashed)
|
||||
|
||||
|
||||
def report_public_url(group_slug: str, report_id: str) -> str:
|
||||
return f"https://urmiai.com/reports/{group_slug}/{report_id}"
|
||||
|
||||
|
||||
def serialize_group(group: UrmiportReportGroup, report_count: int = 0) -> dict:
|
||||
return {
|
||||
"id": str(group.id),
|
||||
"report_group": group.slug,
|
||||
"display_name": group.display_name,
|
||||
"default_access": group.default_access,
|
||||
"password_protected": group.default_access == "password",
|
||||
"id_mode": group.id_mode,
|
||||
"report_count": report_count,
|
||||
"url_pattern": f"https://urmiai.com/reports/{group.slug}/{{report_id}}",
|
||||
"created_at": group.created_at,
|
||||
}
|
||||
|
||||
|
||||
def serialize_report(report: UrmiportReport, group: UrmiportReportGroup) -> dict:
|
||||
return {
|
||||
"id": str(report.id),
|
||||
"report_group": group.slug,
|
||||
"report_id": report.report_id,
|
||||
"access": report.access_mode,
|
||||
"password_protected": report.access_mode == "password",
|
||||
"url": report_public_url(group.slug, report.report_id),
|
||||
"updated_at": report.updated_at,
|
||||
"created_at": report.created_at,
|
||||
}
|
||||
|
||||
|
||||
def get_user_group(db: Session, user: UserAccount, slug: str) -> UrmiportReportGroup:
|
||||
normalized = normalize_group_slug(slug)
|
||||
group = db.scalar(
|
||||
select(UrmiportReportGroup).where(
|
||||
UrmiportReportGroup.user_id == user.id,
|
||||
UrmiportReportGroup.slug == normalized,
|
||||
)
|
||||
)
|
||||
if not group:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report group not found")
|
||||
return group
|
||||
|
||||
|
||||
def get_group_by_slug(db: Session, slug: str) -> UrmiportReportGroup:
|
||||
try:
|
||||
normalized = normalize_group_slug(slug)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report group not found") from exc
|
||||
group = db.scalar(select(UrmiportReportGroup).where(UrmiportReportGroup.slug == normalized))
|
||||
if not group:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report group not found")
|
||||
return group
|
||||
|
||||
|
||||
def get_report(db: Session, group: UrmiportReportGroup, report_id: str) -> UrmiportReport:
|
||||
try:
|
||||
normalized = normalize_report_id(report_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report not found") from exc
|
||||
report = db.scalar(
|
||||
select(UrmiportReport).where(
|
||||
UrmiportReport.group_id == group.id,
|
||||
UrmiportReport.report_id == normalized,
|
||||
)
|
||||
)
|
||||
if not report:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Report not found")
|
||||
return report
|
||||
|
||||
|
||||
def generate_report_id(db: Session, group: UrmiportReportGroup) -> str:
|
||||
for _ in range(12):
|
||||
candidate = f"run-{secrets.token_hex(3)}"
|
||||
exists = db.scalar(
|
||||
select(UrmiportReport.id).where(
|
||||
UrmiportReport.group_id == group.id,
|
||||
UrmiportReport.report_id == candidate,
|
||||
)
|
||||
)
|
||||
if not exists:
|
||||
return candidate
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Unable to generate report id")
|
||||
|
||||
|
||||
def upsert_report_for_user(
|
||||
db: Session,
|
||||
user: UserAccount,
|
||||
payload: UpsertUrmiportReportPayload,
|
||||
) -> dict:
|
||||
try:
|
||||
group_slug = normalize_group_slug(payload.report_group)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
|
||||
group = db.scalar(
|
||||
select(UrmiportReportGroup).where(
|
||||
UrmiportReportGroup.user_id == user.id,
|
||||
UrmiportReportGroup.slug == group_slug,
|
||||
)
|
||||
)
|
||||
if not group:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Report group not found. Create it in Console → Agent → Urmiport first.",
|
||||
)
|
||||
|
||||
if payload.report_id:
|
||||
try:
|
||||
report_id = normalize_report_id(payload.report_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
elif group.id_mode == "manual":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="report_id is required for this report group",
|
||||
)
|
||||
else:
|
||||
report_id = generate_report_id(db, group)
|
||||
|
||||
access_mode = payload.access
|
||||
password_hash_value = (
|
||||
hash_password(payload.password)
|
||||
if access_mode == "password"
|
||||
else None
|
||||
)
|
||||
if access_mode == "password" and not password_hash_value and not group.password_hash:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="password is required when access is password",
|
||||
)
|
||||
if access_mode == "password" and not password_hash_value:
|
||||
password_hash_value = group.password_hash
|
||||
|
||||
existing = db.scalar(
|
||||
select(UrmiportReport).where(
|
||||
UrmiportReport.group_id == group.id,
|
||||
UrmiportReport.report_id == report_id,
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
existing.html_body = payload.body
|
||||
existing.access_mode = access_mode
|
||||
existing.password_hash = password_hash_value
|
||||
existing.updated_at = datetime.now(timezone.utc)
|
||||
report = existing
|
||||
else:
|
||||
report = UrmiportReport(
|
||||
user_id=user.id,
|
||||
group_id=group.id,
|
||||
report_id=report_id,
|
||||
html_body=payload.body,
|
||||
access_mode=access_mode,
|
||||
password_hash=password_hash_value,
|
||||
)
|
||||
db.add(report)
|
||||
|
||||
db.commit()
|
||||
db.refresh(report)
|
||||
result = serialize_report(report, group)
|
||||
result["success"] = True
|
||||
return result
|
||||
|
||||
|
||||
def build_urmiport_api_docs() -> dict:
|
||||
return {
|
||||
"id": "urmiport-upsert-report",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/urmiport/reports",
|
||||
"title": "Publish Urmiport HTML report",
|
||||
"summary": (
|
||||
"Create or update a hosted HTML report. Reports are served at "
|
||||
"https://urmiai.com/reports/{report_group}/{report_id} without Urmi branding."
|
||||
),
|
||||
"authentication": {
|
||||
"type": "api_key",
|
||||
"header": "Authorization",
|
||||
"format": "Bearer urmi_live_<key>",
|
||||
"alternate_header": "X-Api-Key",
|
||||
},
|
||||
"request_body": {
|
||||
"report_group": {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"description": "Report group slug created in Console → Agent → Urmiport",
|
||||
"example": "acme-agent-runs",
|
||||
},
|
||||
"report_id": {
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"description": "Report postfix. Required when group id_mode is manual; auto-generated when omitted for auto groups",
|
||||
"example": "run-abc123",
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"description": "Full HTML document or HTML fragment to render on the public report URL",
|
||||
"example": "<html><body><h1>Agent run complete</h1><p>All checks passed.</p></body></html>",
|
||||
},
|
||||
"access": {
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"description": "public or password",
|
||||
"example": "public",
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"description": "Required when access is password (unless group default password is set)",
|
||||
"example": "secret-pass",
|
||||
},
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Report created or updated",
|
||||
"example": {
|
||||
"success": True,
|
||||
"report_group": "acme-agent-runs",
|
||||
"report_id": "run-abc123",
|
||||
"url": "https://urmiai.com/reports/acme-agent-runs/run-abc123",
|
||||
"access": "public",
|
||||
},
|
||||
},
|
||||
"401": {"description": "Missing or invalid API key"},
|
||||
"404": {"description": "Report group not found"},
|
||||
"422": {"description": "Validation error"},
|
||||
},
|
||||
"notes": [
|
||||
"Create the report group in the Urmi console before calling this API.",
|
||||
"Upsert semantics: same report_group + report_id updates the existing HTML body.",
|
||||
"The hosted page renders only your HTML — no Urmi chrome or branding.",
|
||||
],
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue