Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Add elog_last_entry table for follow-up parent pre-fill.

Revision ID: 95c2670ff066
Revises: 6c1c0c2f8a1b
Create Date: 2026-04-29 00:00:00.000000
"""
from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "95c2670ff066"
down_revision: str | None = "6c1c0c2f8a1b"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.create_table(
"elog_last_entry",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("api_key_id", sa.UUID(), nullable=False),
sa.Column("logbooks_key", sa.String(length=1024), nullable=False),
sa.Column("entry_id", sa.String(length=255), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["api_key_id"], ["api_key.id"], ondelete="CASCADE"),
sa.UniqueConstraint("api_key_id", "logbooks_key", name="uq_elog_last_entry_scope"),
)
op.create_index("ix_elog_last_entry_api_key_id", "elog_last_entry", ["api_key_id"])


def downgrade() -> None:
op.drop_index("ix_elog_last_entry_api_key_id", table_name="elog_last_entry")
op.drop_table("elog_last_entry")
220 changes: 220 additions & 0 deletions app/api/v1/elog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"""HTTP routes for posting snapshots (and arbitrary entries) to an e-log.

Returns 503 when no e-log provider is configured so the frontend can hide the
"Post to elog" affordance.
"""
import logging
from typing import Annotated
from datetime import datetime

import httpx
from fastapi import Query, Depends, Security, APIRouter, HTTPException
from pydantic import Field, BaseModel

from app.config import Settings, get_settings
from app.dependencies import DataBaseDep, get_api_key, require_read_access
from app.services.elog import (
ElogTag,
ElogUser,
ElogAdapter,
ElogLogbook,
ElogEntryResult,
ElogEntryRequest,
get_elog_service,
)
from app.schemas.api_key import ApiKeyDTO
from app.services.elog.last_entry import get_last_entry_id, upsert_last_entry

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/elog", tags=["Elog"])


# ---------------------------------------------------------------------------
# Request/Response models specific to the HTTP surface
# ---------------------------------------------------------------------------


class ElogConfigDTO(BaseModel):
"""Exposes whether e-log posting is available to the frontend."""

enabled: bool
provider: str = ""
defaultLogbooks: list[str] = []


class CreateEntryRequestDTO(BaseModel):
"""Posted by the frontend to create an e-log entry.

``author`` is *not* trusted from the client — we stamp it from the API key.
"""

logbooks: list[str] = Field(..., min_length=1)
title: str = Field(..., min_length=1, max_length=255)
bodyMarkdown: str
tags: list[str] = Field(default_factory=list)
snapshotId: str | None = None
followsUpEntryId: str | None = Field(
default=None,
description="If set, post as a follow-up of this entry id instead of a fresh entry.",
)
additionalAuthors: list[str] = Field(default_factory=list)
important: bool = False
eventAt: datetime | None = None


class LastEntryResponseDTO(BaseModel):
"""The most recent entry id this api key posted/followed-up for a logbook set."""

entryId: str | None = None


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _require_adapter(adapter: ElogAdapter | None) -> ElogAdapter:
if adapter is None:
raise HTTPException(status_code=503, detail="E-log integration is not configured")
return adapter


def _get_elog_adapter() -> ElogAdapter | None:
"""FastAPI dependency wrapper; lets tests override via ``app.dependency_overrides``."""
return get_elog_service()


async def _proxy_upstream(coro):
"""Translate upstream HTTP errors into 502/504 responses."""
try:
return await coro
except httpx.HTTPStatusError as exc:
body_excerpt = (exc.response.text or "").strip()[:500]
logger.warning("E-log upstream error: %s %s", exc.response.status_code, body_excerpt)
detail = f"E-log upstream returned {exc.response.status_code}"
if body_excerpt:
detail = f"{detail}: {body_excerpt}"
raise HTTPException(status_code=502, detail=detail) from exc
except httpx.TimeoutException as exc:
logger.warning("E-log upstream timeout: %s", exc)
raise HTTPException(status_code=504, detail="E-log upstream timed out") from exc
except httpx.HTTPError as exc:
logger.warning("E-log upstream HTTP error: %s", exc)
raise HTTPException(status_code=502, detail="E-log upstream unreachable") from exc


# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------


@router.get("/config")
async def get_elog_config(
settings: Annotated[Settings, Depends(get_settings)],
adapter: Annotated[ElogAdapter | None, Depends(_get_elog_adapter)],
) -> ElogConfigDTO:
"""Feature-flag endpoint. Always returns 200 — no auth required."""
provider = (settings.elog_provider or "").strip()
return ElogConfigDTO(
enabled=adapter is not None,
provider=provider if adapter is not None else "",
defaultLogbooks=list(settings.elog_default_logbooks) if adapter is not None else [],
)


@router.get(
"/logbooks",
dependencies=[Security(require_read_access)],
)
async def list_logbooks(
adapter: Annotated[ElogAdapter | None, Depends(_get_elog_adapter)],
) -> list[ElogLogbook]:
return await _proxy_upstream(_require_adapter(adapter).list_logbooks())


@router.get(
"/users",
dependencies=[Security(require_read_access)],
)
async def search_users(
adapter: Annotated[ElogAdapter | None, Depends(_get_elog_adapter)],
search: Annotated[str, Query(min_length=1, max_length=100)],
limit: Annotated[int, Query(ge=1, le=100)] = 20,
) -> list[ElogUser]:
"""Search for users/people by name or email via the e-log provider."""
svc = _require_adapter(adapter)
try:
return await _proxy_upstream(svc.search_users(search, limit=limit))
except NotImplementedError as exc:
raise HTTPException(status_code=501, detail=str(exc) or "User search unsupported")


@router.get(
"/logbooks/{logbook_id}/tags",
dependencies=[Security(require_read_access)],
)
async def list_tags(
logbook_id: str,
adapter: Annotated[ElogAdapter | None, Depends(_get_elog_adapter)],
) -> list[ElogTag]:
return await _proxy_upstream(_require_adapter(adapter).list_tags(logbook_id))


@router.get(
"/last-entry",
dependencies=[Security(require_read_access)],
)
async def get_last_entry(
api_key: Annotated[ApiKeyDTO, Security(get_api_key)],
db: DataBaseDep,
logbook: Annotated[list[str], Query(min_length=1)],
) -> LastEntryResponseDTO:
"""Return the last entry id this api key posted/followed-up for the given logbook set."""
entry_id = await get_last_entry_id(db, api_key_id=api_key.id, logbooks=list(logbook))
return LastEntryResponseDTO(entryId=entry_id)


@router.post("/entries")
async def create_entry(
payload: CreateEntryRequestDTO,
api_key: Annotated[ApiKeyDTO, Security(get_api_key)],
adapter: Annotated[ElogAdapter | None, Depends(_get_elog_adapter)],
db: DataBaseDep,
) -> ElogEntryResult:
"""Create an e-log entry, or a follow-up if ``followsUpEntryId`` is set. Requires write access."""
if not api_key.writeAccess:
raise HTTPException(status_code=401, detail="API key does not have write access")
adapter = _require_adapter(adapter)

request = ElogEntryRequest(
logbooks=payload.logbooks,
title=payload.title,
body_markdown=payload.bodyMarkdown,
tags=payload.tags,
author=api_key.appName,
snapshot_id=payload.snapshotId,
additional_authors=payload.additionalAuthors,
important=payload.important,
event_at=payload.eventAt,
)

try:
if payload.followsUpEntryId:
result = await _proxy_upstream(adapter.create_follow_up(payload.followsUpEntryId, request))
else:
result = await _proxy_upstream(adapter.create_entry(request))
except NotImplementedError as exc:
raise HTTPException(status_code=501, detail=str(exc) or "Follow-up unsupported")

try:
await upsert_last_entry(
db,
api_key_id=api_key.id,
logbooks=payload.logbooks,
entry_id=result.id,
)
except Exception:
logger.exception("Failed to record last elog entry id; continuing")

return result
2 changes: 2 additions & 0 deletions app/api/v1/router.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from fastapi import APIRouter

from app.api.v1.pvs import router as pvs_router
from app.api.v1.elog import router as elog_router
from app.api.v1.jobs import router as jobs_router
from app.api.v1.tags import router as tags_router
from app.api.v1.health import router as health_router
Expand All @@ -16,4 +17,5 @@
router.include_router(snapshots_router)
router.include_router(jobs_router)
router.include_router(websocket_router)
router.include_router(elog_router)
router.include_router(health_router)
10 changes: 10 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ class Settings(BaseSettings):
# Performance
bulk_insert_batch_size: int = 5000

# E-log integration (plugin-based)
elog_provider: str = "" # Key into app.services.elog.ELOG_PROVIDERS; empty = disabled
elog_proxy_url: str = "" # Outbound proxy for e-log calls (control-room gateways)
elog_default_logbooks: list[str] = [] # Pre-selected logbook IDs in the post dialog

# elog-plus adapter
elog_plus_base_url: str = ""
elog_plus_token: str = "" # Service JWT / application token
elog_plus_auth_header: str = "x-vouch-idp-accesstoken"

class Config:
env_file = ".env"
env_prefix = "SQUIRREL_"
Expand Down
7 changes: 7 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

from app.config import get_settings
from app.api.v1.router import router as v1_router
from app.services.elog import shutdown_elog_service
from app.api.v1.websocket import get_diff_manager
from app.services.epics_service import get_epics_service
from app.services.redis_service import get_redis_service
Expand Down Expand Up @@ -110,6 +111,12 @@ async def lifespan(app: FastAPI):
await epics.shutdown()
logger.info("EPICS service shut down")

# Close e-log adapter HTTP client, if initialized
try:
await shutdown_elog_service()
except Exception as e:
logger.error(f"Error shutting down e-log adapter: {e}")

logger.info("Squirrel API shutdown complete")


Expand Down
2 changes: 2 additions & 0 deletions app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from app.models.pv import PV, pv_tag
from app.models.snapshot import Snapshot, SnapshotValue
from app.models.job import Job, JobStatus, JobType
from app.models.elog_last_entry import ElogLastEntry

__all__ = [
"Base",
Expand All @@ -17,4 +18,5 @@
"Job",
"JobStatus",
"JobType",
"ElogLastEntry",
]
23 changes: 23 additions & 0 deletions app/models/elog_last_entry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Tracks the most recent elog entry id per (api_key, logbook set).

The PostToElogDialog uses this to pre-fill the parent entry id when an
operator wants to follow up on their previous post (typical workflow:
morning snapshot then hourly follow-ups for the rest of the day).
"""
from sqlalchemy import Index, String, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column

from app.models.base import Base, UUIDMixin, TimestampMixin


class ElogLastEntry(Base, UUIDMixin, TimestampMixin):
__tablename__ = "elog_last_entry"

api_key_id: Mapped[str] = mapped_column(ForeignKey("api_key.id", ondelete="CASCADE"), nullable=False)
logbooks_key: Mapped[str] = mapped_column(String(1024), nullable=False)
entry_id: Mapped[str] = mapped_column(String(255), nullable=False)

__table_args__ = (
UniqueConstraint("api_key_id", "logbooks_key", name="uq_elog_last_entry_scope"),
Index("ix_elog_last_entry_api_key_id", "api_key_id"),
)
Loading
Loading