Skip to content
Merged
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
36 changes: 36 additions & 0 deletions migrations/versions/8a5444d1e3e6_add_app_setting_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""add app_setting table

Revision ID: 8a5444d1e3e6
Revises: 0c42f39a04cc
Create Date: 2026-05-26 01:09:23.839749

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '8a5444d1e3e6'
down_revision: Union[str, None] = '0c42f39a04cc'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('app_setting',
sa.Column('key', sa.String(length=64), nullable=False),
sa.Column('value', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('key')
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('app_setting')
# ### end Alembic commands ###
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "opal"
version = "1.3.0"
version = "1.3.2"
description = "Operations, Procedures, Assets, Logistics - ERP for small teams and hardware projects"
readme = "README.md"
requires-python = ">=3.11"
Expand All @@ -21,6 +21,7 @@ dependencies = [
"httpx>=0.28.0",
"segno>=1.6.0",
"packaging>=24.0",
"mcp>=1.0",
]

[project.optional-dependencies]
Expand Down
5 changes: 5 additions & 0 deletions src/opal/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ def cmd_migrate(args: argparse.Namespace) -> None:

from opal.config import get_active_settings

# Honor --project / --database so migrations can target any project's DB,
# not just whatever the ambient environment points to.
_setup_project(args)

# Find project root by looking for alembic.ini
opal_dir = Path(__file__).resolve().parent.parent.parent
if not (opal_dir / "alembic.ini").exists():
Expand Down Expand Up @@ -234,6 +238,7 @@ def add_project_args(p: argparse.ArgumentParser) -> None:
)
migrate_parser.add_argument("--revision", type=str, help="Target revision")
migrate_parser.add_argument("--message", "-m", type=str, help="Migration message")
add_project_args(migrate_parser)
migrate_parser.set_defaults(func=cmd_migrate)

# seed command
Expand Down
10 changes: 10 additions & 0 deletions src/opal/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
settings = get_settings()
settings.ensure_directories()

# Overlay DB-stored AppSetting values (Onshape credentials edited via
# /settings/onshape) on top of env-loaded settings before we read any
# integration config.
from opal.config import apply_db_overlay, get_active_settings
from opal.db.base import SessionLocal

with contextlib.suppress(Exception), SessionLocal() as _db:
apply_db_overlay(_db)
settings = get_active_settings()

# Start Onshape polling if enabled
polling_task: asyncio.Task | None = None
if settings.onshape_enabled and settings.onshape_poll_interval_minutes > 0:
Expand Down
44 changes: 43 additions & 1 deletion src/opal/api/routes/attachments.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@

from opal.api.deps import CurrentUserId, DbSession
from opal.config import get_active_settings
from opal.core.audit import log_create, log_delete
from opal.core.audit import get_model_dict, log_create, log_delete, log_update
from opal.db.models.attachment import Attachment
from opal.db.models.execution import ProcedureInstance, StepExecution
from opal.db.models.issue import Issue

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


ALLOWED_KINDS: set[str | None] = {None, "inline", "reference", "closeout"}


class AttachmentResponse(BaseModel):
"""Schema for attachment response."""

Expand All @@ -35,6 +38,12 @@ class AttachmentResponse(BaseModel):
model_config = {"from_attributes": True}


class AttachmentPatchRequest(BaseModel):
"""Partial update payload for an attachment."""

kind: str | None = None


def _attachment_to_response(att: Attachment) -> AttachmentResponse:
return AttachmentResponse(
id=att.id,
Expand Down Expand Up @@ -68,6 +77,12 @@ async def upload_attachment(
procedure template. `procedure_id` scopes inline images used in step
instructions so they're cleaned up when the procedure is deleted.
"""
if kind is not None and kind not in ALLOWED_KINDS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid kind '{kind}'. Allowed: {sorted(k for k in ALLOWED_KINDS if k)}",
)

settings = get_active_settings()

# Validate MIME type
Expand Down Expand Up @@ -207,6 +222,33 @@ async def list_attachments(
return [_attachment_to_response(a) for a in attachments]


@router.patch("/{attachment_id}", response_model=AttachmentResponse)
async def patch_attachment(
db: DbSession,
attachment_id: int,
payload: AttachmentPatchRequest,
user_id: CurrentUserId,
) -> AttachmentResponse:
"""Update mutable fields on an attachment (currently just `kind`)."""
attachment = db.query(Attachment).filter(Attachment.id == attachment_id).first()
if not attachment:
raise HTTPException(status_code=404, detail="Attachment not found")

new_kind = payload.kind
if new_kind is not None and new_kind not in ALLOWED_KINDS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid kind '{new_kind}'. Allowed: {sorted(k for k in ALLOWED_KINDS if k)}",
)

old_values = get_model_dict(attachment)
attachment.kind = new_kind
log_update(db, attachment, old_values, user_id)
db.commit()
db.refresh(attachment)
return _attachment_to_response(attachment)


@router.delete("/{attachment_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_attachment(
db: DbSession,
Expand Down
72 changes: 72 additions & 0 deletions src/opal/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from pydantic_settings import BaseSettings, SettingsConfigDict

if TYPE_CHECKING:
from sqlalchemy.orm import Session

from opal.project import ProjectConfig


Expand Down Expand Up @@ -226,3 +228,73 @@ def get_active_settings() -> Settings:
def get_active_project() -> "ProjectConfig | None":
"""Get the currently active project configuration."""
return _active_project


# Subset of Settings fields editable from the in-app settings UI. The DB
# overlay only touches these; everything else stays env-driven.
_DB_OVERLAY_FIELDS: tuple[str, ...] = (
"onshape_access_key",
"onshape_secret_key",
"onshape_base_url",
"onshape_poll_interval_minutes",
"onshape_webhook_secret",
)


def apply_db_overlay(db: "Session") -> Settings:
"""Overlay DB-stored AppSetting values onto the active Settings.

Reads every row from the ``app_setting`` table and, for each key listed
in :data:`_DB_OVERLAY_FIELDS`, replaces the matching field on the active
Settings instance. Rebuilds ``_runtime_settings`` so future
``get_active_settings()`` callers see the overlay. Idempotent — safe to
call after any settings edit.
"""
global _runtime_settings

from opal.db.models.app_setting import AppSetting

base = get_active_settings()
overrides: dict[str, object] = {}
rows = db.query(AppSetting).filter(AppSetting.key.in_(_DB_OVERLAY_FIELDS)).all()
for row in rows:
if row.value is None:
continue
field = Settings.model_fields.get(row.key)
if field is None:
continue
# Coerce text → field type. int is the only non-str we currently overlay.
if field.annotation is int:
try:
overrides[row.key] = int(row.value)
except ValueError:
continue
else:
overrides[row.key] = row.value

if not overrides:
return base

merged = base.model_dump()
merged.update(overrides)
_runtime_settings = Settings(**merged)
return _runtime_settings


def set_app_setting(db: "Session", key: str, value: str | None) -> None:
"""Upsert a single AppSetting row. Caller commits."""
from opal.db.models.app_setting import AppSetting

row = db.query(AppSetting).filter(AppSetting.key == key).first()
if row is None:
row = AppSetting(key=key, value=value)
db.add(row)
else:
row.value = value


def get_app_setting(db: "Session", key: str) -> str | None:
from opal.db.models.app_setting import AppSetting

row = db.query(AppSetting).filter(AppSetting.key == key).first()
return row.value if row else None
2 changes: 2 additions & 0 deletions src/opal/db/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Database models."""

from opal.db.models.app_setting import AppSetting
from opal.db.models.attachment import Attachment
from opal.db.models.audit import AuditLog
from opal.db.models.dataset import DataPoint, Dataset
Expand Down Expand Up @@ -35,6 +36,7 @@
from opal.db.models.workcenter import Workcenter

__all__ = [
"AppSetting",
"AssemblyComponent",
"Attachment",
"AuditLog",
Expand Down
27 changes: 27 additions & 0 deletions src/opal/db/models/app_setting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Runtime-mutable application settings stored in the database.

Provides a key/value escape hatch for config that needs to be edited from
the UI without touching .env. The Settings class still loads env defaults;
values present in this table override them.
"""

from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column

from opal.db.base import Base, TimestampMixin


class AppSetting(Base, TimestampMixin):
"""A single runtime-mutable application setting.

`key` is the canonical name (e.g. ``onshape_access_key``). `value` is
stored as text; consumers cast as needed.
"""

__tablename__ = "app_setting"

key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[str | None] = mapped_column(Text, nullable=True)

def __repr__(self) -> str:
return f"<AppSetting(key={self.key!r})>"
2 changes: 1 addition & 1 deletion src/opal/db/models/attachment.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class Attachment(Base, IdMixin, TimestampMixin):
String(20),
nullable=True,
index=True,
comment="'inline' = embedded in markdown content; 'reference' = downloadable doc; null = legacy/unscoped",
comment="'inline' = embedded in markdown content; 'reference' = downloadable doc; 'closeout' = end-item closeout photo surfaced in build reports; null = legacy/unscoped",
)

# Optional links - attachment can belong to instance, step, issue, procedure, or neither
Expand Down
4 changes: 4 additions & 0 deletions src/opal/integrations/onshape/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,10 @@ def _request(

# ── API Methods ───────────────────────────────────────────────────

def get_session_info(self) -> dict:
"""Return current API session info. Use as a credential smoke-test."""
return self._request("GET", "/api/v6/users/sessioninfo")

def get_document(self, document_id: str) -> OnshapeDocument:
"""Get document metadata."""
data = self._request("GET", f"/api/v6/documents/{document_id}")
Expand Down
Loading
Loading