diff --git a/alembic/versions/0002_environments.py b/alembic/versions/0002_environments.py new file mode 100644 index 0000000..f096036 --- /dev/null +++ b/alembic/versions/0002_environments.py @@ -0,0 +1,45 @@ +"""Add environments table and environment_id FK on tickets + +Revision ID: 0002 +Revises: 0001 +Create Date: 2026-05-25 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0002" +down_revision: Union[str, None] = "0001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "environments", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("prefix", sa.String(16), nullable=False, unique=True), + sa.Column("name", sa.String(64), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, + server_default=sa.func.now()), + ) + + op.execute("INSERT INTO environments (prefix, name) VALUES ('HOME', 'Homelab')") + + op.add_column("tickets", + sa.Column("environment_id", sa.Integer(), + sa.ForeignKey("environments.id", ondelete="SET NULL"), nullable=True)) + + op.execute( + "UPDATE tickets SET environment_id = (SELECT id FROM environments WHERE prefix = 'HOME')" + ) + + op.create_index("ix_tickets_environment_id", "tickets", ["environment_id"]) + + +def downgrade() -> None: + op.drop_index("ix_tickets_environment_id", table_name="tickets") + op.drop_column("tickets", "environment_id") + op.drop_table("environments") diff --git a/corvid/models.py b/corvid/models.py index 3a9512f..1fd91e6 100644 --- a/corvid/models.py +++ b/corvid/models.py @@ -9,6 +9,19 @@ class Base(DeclarativeBase): pass +class Environment(Base): + __tablename__ = "environments" + + id: Mapped[int] = mapped_column(primary_key=True) + prefix: Mapped[str] = mapped_column(String(16), unique=True, nullable=False) + name: Mapped[str] = mapped_column(String(64), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc)) + + tickets: Mapped[list["Ticket"]] = relationship("Ticket", back_populates="environment") + + class TicketAttachment(Base): __tablename__ = "ticket_attachments" @@ -26,19 +39,20 @@ class TicketAttachment(Base): class Ticket(Base): __tablename__ = "tickets" - id: Mapped[int] = mapped_column(primary_key=True) - guid: Mapped[str] = mapped_column(String(36), unique=True, nullable=False, - default=lambda: str(uuid.uuid4())) - user: Mapped[str] = mapped_column(String(64), nullable=False, default="kevin") - title: Mapped[str] = mapped_column(String(256), nullable=False) + id: Mapped[int] = mapped_column(primary_key=True) + guid: Mapped[str] = mapped_column(String(36), unique=True, nullable=False, + default=lambda: str(uuid.uuid4())) + user: Mapped[str] = mapped_column(String(64), nullable=False, default="kevin") + title: Mapped[str] = mapped_column(String(256), nullable=False) description: Mapped[str | None] = mapped_column(Text) - submitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), - default=lambda: datetime.now(timezone.utc)) - status: Mapped[str] = mapped_column(String(16), nullable=False, default="open") - type: Mapped[str] = mapped_column(String(16), nullable=False, default="feature") + submitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc)) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="open") + type: Mapped[str] = mapped_column(String(16), nullable=False, default="feature") parent_id: Mapped[int | None] = mapped_column(ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True) effort: Mapped[int | None] = mapped_column(Integer, nullable=True) startup_script: Mapped[str | None] = mapped_column(Text, nullable=True) + environment_id: Mapped[int | None] = mapped_column(ForeignKey("environments.id", ondelete="SET NULL"), nullable=True) attachments: Mapped[list["TicketAttachment"]] = relationship( "TicketAttachment", @@ -47,7 +61,13 @@ class Ticket(Base): order_by="TicketAttachment.created_at", lazy="selectin", ) + environment: Mapped["Environment | None"] = relationship( + "Environment", + back_populates="tickets", + lazy="selectin", + ) @property def human_id(self) -> str: - return f"HOME-{self.id:03d}" + prefix = self.environment.prefix if self.environment else "HOME" + return f"{prefix}-{self.id:03d}" diff --git a/corvid/router.py b/corvid/router.py index 1e75812..2c8df8c 100644 --- a/corvid/router.py +++ b/corvid/router.py @@ -3,16 +3,30 @@ import uuid from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request from pydantic import BaseModel -from sqlalchemy import or_, select +from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from .models import Ticket, TicketAttachment +from .models import Environment, Ticket, TicketAttachment VALID_STATUSES = {"open", "ongoing", "completed", "abandoned"} VALID_TYPES = {"feature", "bug", "chore", "project"} VALID_USERS = {"kevin", "claude"} +# ── Pydantic schemas ────────────────────────────────────────────────────────── + +class EnvironmentCreate(BaseModel): + prefix: str + name: str + description: str | None = None + + +class EnvironmentUpdate(BaseModel): + prefix: str | None = None + name: str | None = None + description: str | None = None + + class TicketCreate(BaseModel): user: str = "kevin" title: str @@ -22,6 +36,7 @@ class TicketCreate(BaseModel): parent_id: int | None = None effort: int | None = None startup_script: str | None = None + environment_id: int | None = None class TicketUpdate(BaseModel): @@ -33,6 +48,7 @@ class TicketUpdate(BaseModel): parent_id: int | None = None effort: int | None = None startup_script: str | None = None + environment_id: int | None = None class AttachmentCreate(BaseModel): @@ -46,6 +62,18 @@ class AttachmentUpdate(BaseModel): content: str | None = None +# ── Serialisers ─────────────────────────────────────────────────────────────── + +def _env_dict(e: Environment) -> dict: + return { + "id": e.id, + "prefix": e.prefix, + "name": e.name, + "description": e.description, + "created_at": e.created_at.isoformat() if e.created_at else None, + } + + async def _ticket_dict(t: Ticket, db: AsyncSession) -> dict: child_ids = (await db.execute( select(Ticket.id).where(Ticket.parent_id == t.id).order_by(Ticket.id) @@ -65,6 +93,7 @@ async def _ticket_dict(t: Ticket, db: AsyncSession) -> dict: "attachment_count": len(t.attachments), "effort": t.effort, "startup_script": t.startup_script, + "environment_id": t.environment_id, } @@ -79,6 +108,8 @@ def _att_dict(a: TicketAttachment) -> dict: } +# ── Helpers ─────────────────────────────────────────────────────────────────── + async def _get_ticket_or_404(db: AsyncSession, ticket_id: int) -> Ticket: t = (await db.execute(select(Ticket).where(Ticket.id == ticket_id))).scalar_one_or_none() if not t: @@ -86,9 +117,23 @@ async def _get_ticket_or_404(db: AsyncSession, ticket_id: int) -> Ticket: return t +async def _get_env_or_404(db: AsyncSession, env_id: int) -> Environment: + e = await db.get(Environment, env_id) + if not e: + raise HTTPException(404, "environment not found") + return e + + +async def _default_env_id(db: AsyncSession) -> int | None: + e = (await db.execute(select(Environment).order_by(Environment.id))).scalar_one_or_none() + return e.id if e else None + + +# ── Router factory ──────────────────────────────────────────────────────────── + def make_router(api_key: str, get_db, require_user=None) -> APIRouter: """ - Return a FastAPI APIRouter with all /api/tickets/* endpoints. + Return a FastAPI APIRouter with all /api/environments/* and /api/tickets/* endpoints. Args: api_key: Value of TICKETS_API_KEY — requests presenting this in @@ -115,21 +160,95 @@ def make_router(api_key: str, get_db, require_user=None) -> APIRouter: if require_user is not None: return require_user(request) if not api_key: - # No key configured — open / local mode return {"username": "local", "email": "local@localhost"} raise HTTPException(401, "unauthorized") + # ── Environment endpoints ───────────────────────────────────────────────── + + @router.get("/api/environments") + async def list_environments( + db: AsyncSession = Depends(get_db), + _auth=Depends(require_ticket_auth), + ): + rows = (await db.execute(select(Environment).order_by(Environment.id))).scalars().all() + return [_env_dict(e) for e in rows] + + @router.post("/api/environments", status_code=201) + async def create_environment( + body: EnvironmentCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(require_ticket_auth), + ): + prefix = body.prefix.strip().upper() + if not prefix or len(prefix) > 16: + raise HTTPException(400, "prefix must be 1–16 characters") + existing = (await db.execute( + select(Environment).where(Environment.prefix == prefix) + )).scalar_one_or_none() + if existing: + raise HTTPException(409, f"prefix '{prefix}' already exists") + e = Environment(prefix=prefix, name=body.name.strip(), description=body.description) + db.add(e) + await db.commit() + await db.refresh(e) + return _env_dict(e) + + @router.get("/api/environments/{env_id}") + async def get_environment( + env_id: int, + db: AsyncSession = Depends(get_db), + _auth=Depends(require_ticket_auth), + ): + return _env_dict(await _get_env_or_404(db, env_id)) + + @router.patch("/api/environments/{env_id}") + async def update_environment( + env_id: int, + body: EnvironmentUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(require_ticket_auth), + ): + e = await _get_env_or_404(db, env_id) + if body.prefix is not None: + e.prefix = body.prefix.strip().upper() + if body.name is not None: + e.name = body.name.strip() + if body.description is not None: + e.description = body.description + await db.commit() + await db.refresh(e) + return _env_dict(e) + + @router.delete("/api/environments/{env_id}") + async def delete_environment( + env_id: int, + db: AsyncSession = Depends(get_db), + _auth=Depends(require_ticket_auth), + ): + e = await _get_env_or_404(db, env_id) + count = (await db.execute( + select(func.count()).select_from(Ticket).where(Ticket.environment_id == env_id) + )).scalar() + if count: + raise HTTPException(409, f"environment has {count} ticket(s); reassign before deleting") + await db.delete(e) + await db.commit() + return {"ok": True} + # ── Ticket endpoints ────────────────────────────────────────────────────── @router.get("/api/tickets") async def list_tickets( - status: list[str] = Query(default=[]), - type: list[str] = Query(default=[]), - q: str = Query(default=""), - db: AsyncSession = Depends(get_db), + status: list[str] = Query(default=[]), + type: list[str] = Query(default=[]), + q: str = Query(default=""), + environment_id: int | None = Query(default=None), + db: AsyncSession = Depends(get_db), _auth=Depends(require_ticket_auth), ): stmt = select(Ticket).order_by(Ticket.submitted_at.desc()) + if environment_id is not None: + stmt = stmt.where(Ticket.environment_id == environment_id) if status: stmt = stmt.where(Ticket.status.in_(status)) if type: @@ -156,6 +275,11 @@ def make_router(api_key: str, get_db, require_user=None) -> APIRouter: raise HTTPException(400, "effort must be between 1 and 10") if body.parent_id is not None: await _get_ticket_or_404(db, body.parent_id) + env_id = body.environment_id + if env_id is not None: + await _get_env_or_404(db, env_id) + else: + env_id = await _default_env_id(db) t = Ticket( guid=str(uuid.uuid4()), user=body.user, @@ -166,6 +290,7 @@ def make_router(api_key: str, get_db, require_user=None) -> APIRouter: parent_id=body.parent_id, effort=body.effort, startup_script=body.startup_script, + environment_id=env_id, ) db.add(t) await db.commit() @@ -215,6 +340,9 @@ def make_router(api_key: str, get_db, require_user=None) -> APIRouter: t.effort = body.effort if "startup_script" in body.model_fields_set: t.startup_script = body.startup_script + if "environment_id" in body.model_fields_set and body.environment_id is not None: + await _get_env_or_404(db, body.environment_id) + t.environment_id = body.environment_id await db.commit() return await _ticket_dict(await _get_ticket_or_404(db, ticket_id), db) diff --git a/corvid/templates/tickets.html b/corvid/templates/tickets.html index 1675ca6..c86d6f1 100644 --- a/corvid/templates/tickets.html +++ b/corvid/templates/tickets.html @@ -209,11 +209,52 @@ textarea.desc-input { pointer-events: none; } .search-match { background: #4f8ef733; border-radius: 2px; } + +/* ── Environment selector ── */ +.env-selector { position: relative; display: inline-block; } +.env-pill { + display: flex; align-items: center; gap: 6px; + padding: 5px 12px; border-radius: 20px; + border: 1px solid var(--primary); background: #4f8ef710; + color: var(--primary); font-size: 12px; font-weight: 600; + cursor: pointer; transition: background .15s; +} +.env-pill:hover { background: #4f8ef720; opacity: 1; } +.env-dropdown { + display: none; position: absolute; top: calc(100% + 4px); left: 0; + background: var(--surface); border: 1px solid var(--border); + border-radius: 6px; box-shadow: 0 4px 16px #0006; + min-width: 210px; z-index: 200; overflow: hidden; +} +.env-dropdown.open { display: block; } +.env-dd-item { + padding: 8px 14px; font-size: 12px; cursor: pointer; + display: flex; align-items: center; gap: 8px; transition: background .1s; +} +.env-dd-item:hover { background: var(--raised); } +.env-dd-item.active { color: var(--primary); font-weight: 600; } +.env-dd-divider { border-top: 1px solid var(--divider); margin: 2px 0; } +.env-prefix-badge { + font-family: monospace; font-size: 10px; background: var(--raised); + padding: 1px 5px; border-radius: 3px; color: var(--fg-sec); flex-shrink: 0; +} {% endblock %} {% block content %} + +