Files
maziggy 45921b7a56 Limit groups to selected printers (issue #1727)
A group can now be limited to a set of printers. Its members see and
control only those printers. Every other printer answers 404, as if it
didn't exist.

- Groups gain restrict_printers and a group_printers table (migration
  for SQLite and Postgres). A user's printers are the union of their
  limited groups. Groups without the flag don't limit anything, a user
  in no limited group keeps every printer, and admins see all of them.
- core/printer_scope.py holds the scope. RequestPrinterScope and
  RequirePrinterPermissionIfAuthEnabled apply it to routes: printer
  routes, camera, queue and batches, archives, projects, stats, print
  log, pipeline runs, inventory and Spoolman assignments, maintenance,
  smart plugs, scheduled drying, firmware and Obico status.
- API keys, camera stream, Cam Wall, overlay and WebSocket tokens carry
  the printers of whoever created them. WebSocket broadcasts are
  filtered per connection, and the filtering fails closed.
- Scheduler: "Any <model>" jobs stay on their owner's printers. A job
  pinned to a printer its owner lost waits with a reason. Callers with
  no user identity and limited printers must queue to a specific printer.
- Group editor: new Printer access section, translated into all 15
  locales. Saving a system group no longer resends unchanged
  permissions, which the backend refused.
2026-10-01 14:47:40 +02:00

138 lines
4.6 KiB
Python

"""API routes for AMS sensor history."""
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePrinterPermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.ams_history import AMSSensorHistory
from backend.app.models.user import User
router = APIRouter(prefix="/ams-history", tags=["ams-history"])
class AMSHistoryPoint(BaseModel):
recorded_at: datetime
humidity: float | None
humidity_raw: float | None
temperature: float | None
class AMSHistoryResponse(BaseModel):
printer_id: int
ams_id: int
data: list[AMSHistoryPoint]
min_humidity: float | None
max_humidity: float | None
avg_humidity: float | None
min_temperature: float | None
max_temperature: float | None
avg_temperature: float | None
@router.get("/{printer_id}/{ams_id}", response_model=AMSHistoryResponse)
async def get_ams_history(
printer_id: int,
ams_id: int,
hours: int = Query(default=24, ge=1, le=168, description="Hours of history (1-168)"),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
):
"""Get AMS sensor history for a specific printer and AMS unit."""
since = datetime.now(timezone.utc) - timedelta(hours=hours)
# Get data points
result = await db.execute(
select(AMSSensorHistory)
.where(
and_(
AMSSensorHistory.printer_id == printer_id,
AMSSensorHistory.ams_id == ams_id,
AMSSensorHistory.recorded_at >= since,
)
)
.order_by(AMSSensorHistory.recorded_at)
)
records = result.scalars().all()
# Calculate stats
stats_result = await db.execute(
select(
func.min(AMSSensorHistory.humidity).label("min_humidity"),
func.max(AMSSensorHistory.humidity).label("max_humidity"),
func.avg(AMSSensorHistory.humidity).label("avg_humidity"),
func.min(AMSSensorHistory.temperature).label("min_temp"),
func.max(AMSSensorHistory.temperature).label("max_temp"),
func.avg(AMSSensorHistory.temperature).label("avg_temp"),
).where(
and_(
AMSSensorHistory.printer_id == printer_id,
AMSSensorHistory.ams_id == ams_id,
AMSSensorHistory.recorded_at >= since,
)
)
)
stats = stats_result.one()
return AMSHistoryResponse(
printer_id=printer_id,
ams_id=ams_id,
data=[
AMSHistoryPoint(
recorded_at=r.recorded_at,
humidity=r.humidity,
humidity_raw=r.humidity_raw,
temperature=r.temperature,
)
for r in records
],
min_humidity=stats.min_humidity,
max_humidity=stats.max_humidity,
# ``is not None``, not truthiness: an average of exactly 0 is a
# reading, and the min/max beside it would report it while the average
# showed an em dash. AVG over an empty or all-NULL window is the only
# case that has no answer (#3140).
avg_humidity=round(stats.avg_humidity, 1) if stats.avg_humidity is not None else None,
min_temperature=stats.min_temp,
max_temperature=stats.max_temp,
avg_temperature=round(stats.avg_temp, 1) if stats.avg_temp is not None else None,
)
@router.delete("/{printer_id}")
async def delete_old_history(
printer_id: int,
days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"),
db: AsyncSession = Depends(get_db),
_: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
):
"""Delete old AMS history data for a printer."""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
result = await db.execute(
select(func.count(AMSSensorHistory.id)).where(
and_(
AMSSensorHistory.printer_id == printer_id,
AMSSensorHistory.recorded_at < cutoff,
)
)
)
count = result.scalar()
await db.execute(
AMSSensorHistory.__table__.delete().where(
and_(
AMSSensorHistory.printer_id == printer_id,
AMSSensorHistory.recorded_at < cutoff,
)
)
)
await db.commit()
return {"deleted": count, "message": f"Deleted {count} records older than {days} days"}