mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
Compare commits
No commits in common. "main" and "v0.2.4.6" have entirely different histories.
705 changed files with 29069 additions and 152387 deletions
72
.env.example
72
.env.example
|
|
@ -55,75 +55,3 @@ LOG_TO_FILE=true
|
|||
# In Docker, also bind-mount the host path into the container at the same
|
||||
# location (see docker-compose.yml for the matching volume snippet).
|
||||
# BAMBUDDY_EXTERNAL_ROOTS=
|
||||
|
||||
# Local-login recovery bypass (#1589) — set to "true" / "1" / "yes" to
|
||||
# accept username + password credentials on /auth/login (and to allow the
|
||||
# /auth/forgot-password flow) even when the in-app setting "Disable local
|
||||
# login" is turned on. This is the documented "SSO is broken, let me back
|
||||
# in" path for an operator whose only normal sign-in route is via OIDC.
|
||||
# /auth/advanced-auth/status also reports local_login_enabled=true while
|
||||
# this is set, so the login page shows the credentials form to match.
|
||||
# LDAP is governed by its own ldap_enabled toggle and is not affected.
|
||||
# Leave unset for normal operation.
|
||||
# BAMBUDDY_LOCAL_LOGIN=true
|
||||
|
||||
# --- OIDC provider from the environment (#2593) ------------------------------
|
||||
# Defines ONE OIDC provider declaratively, for deployments that are managed by
|
||||
# compose files or GitOps and never touch the settings UI. Providers created in
|
||||
# the UI are unaffected and keep working alongside this one.
|
||||
#
|
||||
# Activates only when all four required vars below are set; an empty value
|
||||
# counts as unset. The provider is written on startup and re-applied on every
|
||||
# boot, so the UI shows it as read-only and the API refuses to change it -- an
|
||||
# edit there would be reverted at the next restart anyway.
|
||||
#
|
||||
# Removing the vars DISABLES the provider rather than deleting it: accounts
|
||||
# linked to it would otherwise lose their link permanently. Re-adding the vars
|
||||
# enables it again with those links intact.
|
||||
#
|
||||
# If you lock yourself out, BAMBUDDY_LOCAL_LOGIN=true above is the way back in.
|
||||
#
|
||||
# Required:
|
||||
# BAMBUDDY_OIDC_NAME=Keycloak
|
||||
# BAMBUDDY_OIDC_ISSUER_URL=https://sso.example.com/realms/main
|
||||
# BAMBUDDY_OIDC_CLIENT_ID=bambuddy
|
||||
# BAMBUDDY_OIDC_CLIENT_SECRET=your-client-secret
|
||||
#
|
||||
# Optional, shown with their defaults:
|
||||
# BAMBUDDY_OIDC_SCOPES=openid email profile
|
||||
# BAMBUDDY_OIDC_ENABLED=true
|
||||
# BAMBUDDY_OIDC_AUTO_CREATE_USERS=false
|
||||
# BAMBUDDY_OIDC_AUTO_LINK_EXISTING=false
|
||||
# BAMBUDDY_OIDC_EMAIL_CLAIM=email
|
||||
# BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED=true
|
||||
# BAMBUDDY_OIDC_ICON_URL=
|
||||
# BAMBUDDY_OIDC_AUTOLOGIN=false
|
||||
# BAMBUDDY_OIDC_DEFAULT_GROUP=
|
||||
#
|
||||
# Booleans accept true/1/yes or false/0/no (case-insensitive). Blank or unset
|
||||
# uses the default; any other value is rejected and the provider is skipped.
|
||||
#
|
||||
# DEFAULT_GROUP is the group new users land in when AUTO_CREATE_USERS is on;
|
||||
# without it they get Viewers. It matches a group NAME exactly (case-sensitive)
|
||||
# -- group ids are assigned per install, so the same compose file would point at
|
||||
# a different group on every deployment. A name that matches no group is
|
||||
# refused: the provider is left as it was and the reason is logged, rather than
|
||||
# quietly creating under-privileged users the locked UI could not correct. On a
|
||||
# FIRST boot that means no provider is created at all and no SSO button appears
|
||||
# -- create the group first. Removing the variable clears the group again.
|
||||
#
|
||||
# AUTO_LINK_EXISTING binds an OIDC identity to an existing local account with
|
||||
# the same email address. With EMAIL_CLAIM=email it is refused unless
|
||||
# REQUIRE_EMAIL_VERIFIED=true, because an identity provider that does not
|
||||
# verify addresses would let anyone claim someone else's account. The whole
|
||||
# config is then skipped and logged; the app still starts.
|
||||
#
|
||||
# ISSUER_URL must be https:// and publicly reachable -- private, loopback,
|
||||
# link-local, numeric-encoded and IPv4-mapped hosts are rejected. An in-cluster
|
||||
# URL like http://keycloak:8080 is refused with a single log line and no SSO
|
||||
# button; use the externally-reachable HTTPS issuer URL instead.
|
||||
#
|
||||
# NAME is matched against the existing providers on every boot: setting it to
|
||||
# the name of one you already created in the UI ADOPTS and OVERWRITES it (its
|
||||
# issuer, client id and secret are replaced and it becomes read-only). Pick a
|
||||
# name that doesn't collide unless that takeover is intended.
|
||||
|
|
|
|||
328
.github/scripts/ghcr_inject.py
vendored
328
.github/scripts/ghcr_inject.py
vendored
|
|
@ -1,328 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Inject GHCR container-download stats into the jgehrcke/github-repo-stats report.
|
||||
|
||||
GHCR exposes a container's total + 30-day daily-pull series only in the
|
||||
package page HTML. There is no REST or GraphQL API for it. This script
|
||||
scrapes that page once per workflow run, merges the rolling 30-day window
|
||||
into a sidecar CSV on gh-pages, and re-injects a Vega-Lite chart at the
|
||||
top of ``latest-report/report.html``.
|
||||
|
||||
Why the merge: each run only sees the last 30 days, but the CSV grows
|
||||
forever — days that fall off GitHub's 30-day window stay in the CSV
|
||||
because they were captured while in-window. Overlapping dates are
|
||||
overwritten on each run, so GitHub's late-arriving revisions to recent
|
||||
days self-correct.
|
||||
|
||||
Hard-fails if either scrape pattern stops matching. Silent fallbacks
|
||||
would let the chart freeze at last-known-good and nobody would notice.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
GHCR_URL = "https://github.com/{owner}/{pkg}/pkgs/container/{pkg}"
|
||||
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) bambuddy-stats"
|
||||
|
||||
TOTAL_RE = re.compile(
|
||||
r'Total downloads</span>\s*<h3 title="(\d+)">([^<]+)</h3>',
|
||||
re.DOTALL,
|
||||
)
|
||||
RECT_MERGE_FIRST_RE = re.compile(r'data-merge-count="(\d+)"[^>]*data-date="(\d{4}-\d{2}-\d{2})"')
|
||||
RECT_DATE_FIRST_RE = re.compile(r'data-date="(\d{4}-\d{2}-\d{2})"[^>]*data-merge-count="(\d+)"')
|
||||
|
||||
|
||||
def fetch_ghcr(owner: str, pkg: str) -> str:
|
||||
req = urllib.request.Request(
|
||||
GHCR_URL.format(owner=owner, pkg=pkg),
|
||||
headers={"User-Agent": USER_AGENT, "Accept": "text/html"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return resp.read().decode("utf-8")
|
||||
|
||||
|
||||
def parse_total(html: str) -> tuple[int, str]:
|
||||
m = TOTAL_RE.search(html)
|
||||
if not m:
|
||||
raise RuntimeError(
|
||||
"GHCR scrape: 'Total downloads' marker not found. GitHub markup likely changed — update TOTAL_RE."
|
||||
)
|
||||
return int(m.group(1)), m.group(2).strip()
|
||||
|
||||
|
||||
def parse_daily(html: str) -> dict[str, int]:
|
||||
daily: dict[str, int] = {}
|
||||
for m in RECT_MERGE_FIRST_RE.finditer(html):
|
||||
daily[m.group(2)] = int(m.group(1))
|
||||
for m in RECT_DATE_FIRST_RE.finditer(html):
|
||||
daily.setdefault(m.group(1), int(m.group(2)))
|
||||
if not daily:
|
||||
raise RuntimeError(
|
||||
"GHCR scrape: 30-day sparkline rects not found. GitHub markup likely changed — update RECT_*_RE."
|
||||
)
|
||||
return daily
|
||||
|
||||
|
||||
def merge_csv(csv_path: Path, fresh: dict[str, int]) -> dict[str, int]:
|
||||
merged: dict[str, int] = {}
|
||||
if csv_path.exists():
|
||||
with csv_path.open() as fp:
|
||||
for row in csv.DictReader(fp):
|
||||
merged[row["date"]] = int(row["daily_count"])
|
||||
merged.update(fresh)
|
||||
return dict(sorted(merged.items()))
|
||||
|
||||
|
||||
def write_csv(csv_path: Path, series: dict[str, int]) -> None:
|
||||
csv_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with csv_path.open("w", newline="") as fp:
|
||||
w = csv.writer(fp)
|
||||
w.writerow(["date", "daily_count"])
|
||||
for date, count in series.items():
|
||||
w.writerow([date, count])
|
||||
|
||||
|
||||
# Cloned verbatim from jgehrcke's "Total clones" chart so the new chart
|
||||
# inherits the report's theme (fonts, palette, axis colors).
|
||||
VEGA_CONFIG = {
|
||||
"arc": {"fill": "#1b1e23"},
|
||||
"area": {"fill": "#1b1e23"},
|
||||
"axisBottom": {
|
||||
"domainColor": "#a9b4c4",
|
||||
"gridColor": "#a9b4c4",
|
||||
"labelColor": "#1b1e23",
|
||||
"labelFont": "relative-mono-11-pitch-pro, Menlo, monospace",
|
||||
"tickColor": "#a9b4c4",
|
||||
"titleColor": "#1b1e23",
|
||||
"titleFont": "relative-mono-11-pitch-pro, Menlo, monospace",
|
||||
},
|
||||
"axisLeft": {
|
||||
"domainColor": "#a9b4c4",
|
||||
"gridColor": "#a9b4c4",
|
||||
"labelColor": "#1b1e23",
|
||||
"labelFont": "relative-mono-11-pitch-pro, Menlo, monospace",
|
||||
"tickColor": "#a9b4c4",
|
||||
"titleColor": "#1b1e23",
|
||||
"titleFont": "relative-mono-11-pitch-pro, Menlo, monospace",
|
||||
},
|
||||
"axisX": {"grid": False},
|
||||
"axisY": {"grid": False, "labelBound": True},
|
||||
"background": "#FFFFFF",
|
||||
"group": {"fill": "#FFFFFF"},
|
||||
"header": {
|
||||
"fontWeight": 400,
|
||||
"labelFont": "relative-mono-11-pitch-pro, Menlo, monospace",
|
||||
"titleFont": "relative-mono-11-pitch-pro, Menlo, monospace",
|
||||
},
|
||||
"legend": {
|
||||
"labelFont": "relative-mono-11-pitch-pro, Menlo, monospace",
|
||||
"symbolSize": 200,
|
||||
"symbolType": "circle",
|
||||
"titleFont": "relative-mono-11-pitch-pro, Menlo, monospace",
|
||||
},
|
||||
"line": {"color": "#1b1e23", "stroke": "#1b1e23"},
|
||||
"path": {"stroke": "#1b1e23"},
|
||||
"point": {
|
||||
"color": "#1b1e23",
|
||||
"cursor": "pointer",
|
||||
"filled": True,
|
||||
"size": 20,
|
||||
},
|
||||
"range": {
|
||||
"category": ["#85a2f7", "#ea9755", "#7eb36a", "#f07071", "#bc85d9", "#e587b6", "#a9b4c4", "#d4c05e", "#64b9c4"],
|
||||
},
|
||||
"style": {
|
||||
"bar": {"fill": "#1b1e23"},
|
||||
"text": {
|
||||
"font": "relative-mono-11-pitch-pro, Menlo, monospace",
|
||||
"fontWeight": 400,
|
||||
},
|
||||
},
|
||||
"symbol": {"shape": "circle"},
|
||||
"title": {
|
||||
"anchor": "start",
|
||||
"font": "relative-mono-11-pitch-pro, Menlo, monospace",
|
||||
"fontWeight": 400,
|
||||
},
|
||||
"trail": {"color": "#1b1e23", "stroke": "#1b1e23"},
|
||||
"view": {"stroke": None},
|
||||
}
|
||||
|
||||
|
||||
def build_vega_spec(series: dict[str, int]) -> dict:
|
||||
rows = [{"time": f"{date}T00:00:00+00:00", "daily_count": count} for date, count in series.items()]
|
||||
counts = [r["daily_count"] for r in rows] or [1]
|
||||
y_max = max(counts)
|
||||
dates = sorted(series.keys())
|
||||
x_domain = [dates[0], dates[-1]] if dates else None
|
||||
return {
|
||||
"$schema": "https://vega.github.io/schema/vega-lite/v4.17.0.json",
|
||||
"config": VEGA_CONFIG,
|
||||
"data": {"name": "data-ghcr-pulls"},
|
||||
"datasets": {"data-ghcr-pulls": rows},
|
||||
"encoding": {
|
||||
"tooltip": [
|
||||
{"field": "daily_count", "format": ".0f", "title": "pulls", "type": "quantitative"},
|
||||
{"field": "time", "format": "%B %e, %Y", "title": "date", "type": "temporal"},
|
||||
],
|
||||
"x": {
|
||||
"axis": {"labelAngle": 25},
|
||||
"field": "time",
|
||||
"scale": {"domain": x_domain} if x_domain else {},
|
||||
"timeUnit": "yearmonthdate",
|
||||
"title": "date",
|
||||
"type": "temporal",
|
||||
},
|
||||
"y": {
|
||||
# Linear, not symlog: pulls sit in a tight band far above zero, so a log-ish
|
||||
# axis squeezes the whole series into its top decade and flattens the line.
|
||||
"axis": {"format": "~s"},
|
||||
"field": "daily_count",
|
||||
"scale": {
|
||||
"domain": [0, y_max * 1.1 if y_max > 0 else 1],
|
||||
"type": "linear",
|
||||
"zero": True,
|
||||
},
|
||||
"title": "container pulls per day",
|
||||
"type": "quantitative",
|
||||
},
|
||||
},
|
||||
"height": 200,
|
||||
"mark": {"point": True, "type": "line"},
|
||||
"padding": 10,
|
||||
"width": "container",
|
||||
}
|
||||
|
||||
|
||||
TOC_START = "<!-- ghcr:toc-start -->"
|
||||
TOC_END = "<!-- ghcr:toc-end -->"
|
||||
SECTION_START = "<!-- ghcr:section-start -->"
|
||||
SECTION_END = "<!-- ghcr:section-end -->"
|
||||
SCRIPT_START = "<!-- ghcr:script-start -->"
|
||||
SCRIPT_END = "<!-- ghcr:script-end -->"
|
||||
|
||||
|
||||
def _strip_existing(html: str, start: str, end: str) -> str:
|
||||
pattern = re.compile(re.escape(start) + r".*?" + re.escape(end) + r"\n?", re.DOTALL)
|
||||
return pattern.sub("", html)
|
||||
|
||||
|
||||
def patch_report(
|
||||
report_path: Path,
|
||||
spec: dict,
|
||||
cumulative: int,
|
||||
cumulative_display: str,
|
||||
fetched_at: str,
|
||||
owner: str,
|
||||
pkg: str,
|
||||
) -> None:
|
||||
html = report_path.read_text(encoding="utf-8")
|
||||
|
||||
# Idempotency: if a prior run left markers (shouldn't happen because
|
||||
# jgehrcke regenerates the file, but guard against partial re-runs),
|
||||
# strip them before re-injecting.
|
||||
for s, e in (
|
||||
(TOC_START, TOC_END),
|
||||
(SECTION_START, SECTION_END),
|
||||
(SCRIPT_START, SCRIPT_END),
|
||||
):
|
||||
html = _strip_existing(html, s, e)
|
||||
|
||||
toc_block = f'{TOC_START}\n<li><a href="#ghcr-pulls">Container pulls (ghcr.io)</a></li>\n{TOC_END}\n'
|
||||
section_block = (
|
||||
f"{SECTION_START}\n"
|
||||
f'<h2 id="ghcr-pulls">Container pulls (ghcr.io)</h2>\n'
|
||||
f"<p>Daily pulls of <code>ghcr.io/{owner}/{pkg}</code>. "
|
||||
f"Cumulative: <strong>{cumulative:,}</strong> "
|
||||
f"({cumulative_display}). Source refreshed {fetched_at}.</p>\n"
|
||||
f'<h4 id="ghcr-pulls-daily">Pulls per day</h4>\n'
|
||||
f'<div id="chart_ghcr_pulls_daily" class="full-width-chart">\n\n</div>\n'
|
||||
f'<div class="pagebreak-for-print">\n\n</div>\n'
|
||||
f"{SECTION_END}\n"
|
||||
)
|
||||
script_block = (
|
||||
f"{SCRIPT_START}\n"
|
||||
f'<script type="text/javascript">\n'
|
||||
f"vegaEmbed('#chart_ghcr_pulls_daily', "
|
||||
f"{json.dumps(spec, separators=(',', ':'))}, "
|
||||
f'{{"actions": false, "renderer": "svg"}}).catch(console.error);\n'
|
||||
f"</script>\n"
|
||||
f"{SCRIPT_END}\n"
|
||||
)
|
||||
|
||||
toc_anchor = "<p>Table of contents:</p>\n<ul>\n"
|
||||
if toc_anchor not in html:
|
||||
raise RuntimeError("report.html: TOC anchor not found; layout drift?")
|
||||
html = html.replace(toc_anchor, toc_anchor + toc_block, 1)
|
||||
|
||||
section_anchor = "</nav>\n"
|
||||
if section_anchor not in html:
|
||||
raise RuntimeError("report.html: section anchor (</nav>) not found; layout drift?")
|
||||
html = html.replace(section_anchor, section_anchor + section_block, 1)
|
||||
|
||||
script_anchor = "</article>\n"
|
||||
if script_anchor not in html:
|
||||
raise RuntimeError("report.html: script anchor (</article>) not found; layout drift?")
|
||||
html = html.replace(script_anchor, script_block + script_anchor, 1)
|
||||
|
||||
report_path.write_text(html, encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--report", required=True, type=Path)
|
||||
parser.add_argument("--csv", required=True, type=Path)
|
||||
parser.add_argument("--owner", required=True)
|
||||
parser.add_argument("--pkg", required=True)
|
||||
parser.add_argument(
|
||||
"--ghcr-cache",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Read GHCR HTML from a local file instead of fetching. For local dry-runs only.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.report.exists():
|
||||
print(f"::error::report not found: {args.report}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.ghcr_cache:
|
||||
html = args.ghcr_cache.read_text(encoding="utf-8")
|
||||
print(f"Loaded cached GHCR HTML: {args.ghcr_cache} ({len(html):,} bytes)")
|
||||
else:
|
||||
html = fetch_ghcr(args.owner, args.pkg)
|
||||
print(f"Fetched GHCR page: {len(html):,} bytes")
|
||||
|
||||
cumulative, cumulative_display = parse_total(html)
|
||||
fresh_daily = parse_daily(html)
|
||||
print(f"Cumulative pulls: {cumulative:,} ({cumulative_display})")
|
||||
print(f"Fresh days from sparkline: {len(fresh_daily)}")
|
||||
|
||||
merged = merge_csv(args.csv, fresh_daily)
|
||||
write_csv(args.csv, merged)
|
||||
print(f"Merged CSV rows: {len(merged)} -> {args.csv}")
|
||||
|
||||
spec = build_vega_spec(merged)
|
||||
fetched_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
patch_report(
|
||||
args.report,
|
||||
spec,
|
||||
cumulative,
|
||||
cumulative_display,
|
||||
fetched_at,
|
||||
args.owner,
|
||||
args.pkg,
|
||||
)
|
||||
print(f"Patched: {args.report}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
44
.github/workflows/ci.yml
vendored
44
.github/workflows/ci.yml
vendored
|
|
@ -42,10 +42,7 @@ jobs:
|
|||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Install ruff
|
||||
# Install the exact pin from requirements-dev.txt rather than the latest
|
||||
# release, so CI and contributors run the same linter. `pip install ruff`
|
||||
# silently drifted ahead of every local venv.
|
||||
run: pip install "$(grep -E '^ruff==' requirements-dev.txt)"
|
||||
run: pip install ruff
|
||||
|
||||
- name: Run ruff check
|
||||
run: ruff check backend/
|
||||
|
|
@ -68,10 +65,7 @@ jobs:
|
|||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
# Upgrade setuptools too: the runner's Python toolcache ships an old
|
||||
# setuptools that trips pip-audit (PYSEC-2026-3447, fixed in 83.0.0).
|
||||
# A fix exists, so we upgrade rather than --ignore-vuln.
|
||||
python -m pip install --upgrade pip setuptools
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pip-audit
|
||||
|
||||
|
|
@ -201,47 +195,15 @@ jobs:
|
|||
if path and not info.get('dev') and not info.get('devOptional'):
|
||||
prod.add(path.split('node_modules/')[-1])
|
||||
vulns = data.get('vulnerabilities', {})
|
||||
# Documented advisory exceptions: high/critical findings whose only offered
|
||||
# 'fix' is a semver-major change and which do not apply to how Bambuddy ships.
|
||||
# Keyed by GHSA id. An entry only holds while the fix stays major-only (see
|
||||
# fix_is_major below) - once upstream backports, the gate fails until we take
|
||||
# the patch. That is what retired the one entry this list used to carry:
|
||||
# GHSA-qwww-vcr4-c8h2 (React Router RSC-mode CSRF) shipped in 7.18.2, so the
|
||||
# pin moved rather than the exception staying.
|
||||
ALLOWLIST = set()
|
||||
def advisory_ids(name, seen=None):
|
||||
seen = seen if seen is not None else set()
|
||||
if name in seen:
|
||||
return set()
|
||||
seen.add(name)
|
||||
ids = set()
|
||||
for item in vulns.get(name, {}).get('via', []):
|
||||
if isinstance(item, dict):
|
||||
url = item.get('url', '')
|
||||
if '/advisories/' in url:
|
||||
ids.add(url.rsplit('/', 1)[-1])
|
||||
elif isinstance(item, str):
|
||||
ids |= advisory_ids(item, seen)
|
||||
return ids
|
||||
def fix_is_major(v):
|
||||
fa = v.get('fixAvailable')
|
||||
return isinstance(fa, dict) and fa.get('isSemVerMajor')
|
||||
def exempt(name, v):
|
||||
ids = advisory_ids(name)
|
||||
return bool(ids) and ids <= ALLOWLIST and fix_is_major(v)
|
||||
fixable = {n: v for n, v in vulns.items()
|
||||
if n in prod and v.get('severity') in ('high', 'critical')
|
||||
and v.get('fixAvailable') and not exempt(n, v)}
|
||||
if n in prod and v.get('severity') in ('high', 'critical') and v.get('fixAvailable')}
|
||||
skipped = len(vulns) - len({n: v for n, v in vulns.items() if n in prod})
|
||||
if fixable:
|
||||
for name, v in fixable.items():
|
||||
print(f'FIXABLE {v[\"severity\"].upper()}: {name}')
|
||||
sys.exit(1)
|
||||
total = sum(1 for n, v in vulns.items() if n in prod and v.get('severity') in ('high', 'critical'))
|
||||
exempted = sorted(n for n, v in vulns.items() if n in prod and exempt(n, v))
|
||||
print(f'npm audit: {total} high/critical (0 fixable), {len(vulns)} total ({skipped} npm-internal filtered)')
|
||||
if exempted:
|
||||
print('exempted (documented, unreachable): ' + ', '.join(exempted))
|
||||
"
|
||||
|
||||
frontend-typecheck:
|
||||
|
|
|
|||
2
.github/workflows/cleanup-ghcr.yml
vendored
2
.github/workflows/cleanup-ghcr.yml
vendored
|
|
@ -31,7 +31,7 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
package: [bambuddy]
|
||||
package: [bambuddy, bambuddy-beta]
|
||||
steps:
|
||||
- name: Cleanup ${{ matrix.package }}
|
||||
env:
|
||||
|
|
|
|||
45
.github/workflows/repo-stats.yml
vendored
45
.github/workflows/repo-stats.yml
vendored
|
|
@ -19,48 +19,3 @@ jobs:
|
|||
repository: maziggy/bambuddy
|
||||
ghtoken: ${{ secrets.GHRS_GITHUB_API_TOKEN }}
|
||||
ghpagesprefix: https://maziggy.github.io/bambuddy
|
||||
|
||||
# Inject ghcr.io container-download stats (cumulative + 30-day
|
||||
# sparkline merged into a sidecar CSV so the chart grows beyond
|
||||
# the 30-day window GHCR exposes). Runs after jgehrcke regenerates
|
||||
# report.html on gh-pages.
|
||||
- name: checkout-source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: source
|
||||
|
||||
# jgehrcke/github-repo-stats commits to the `github-repo-stats`
|
||||
# branch by default (NOT gh-pages — Pages is configured to serve
|
||||
# from that branch). Direct git clone with the PAT surfaces real
|
||||
# git stderr if anything fails, unlike actions/checkout@v4 which
|
||||
# swallows errors as opaque "exit code 1".
|
||||
- name: checkout-data-branch
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GHRS_GITHUB_API_TOKEN }}
|
||||
run: |
|
||||
git clone --branch github-repo-stats --depth 1 \
|
||||
"https://x-access-token:${GH_PAT}@github.com/${{ github.repository }}.git" \
|
||||
data
|
||||
cd data
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: inject-ghcr-pulls
|
||||
run: |
|
||||
python3 source/.github/scripts/ghcr_inject.py \
|
||||
--report data/maziggy/bambuddy/latest-report/report.html \
|
||||
--csv data/maziggy/bambuddy/ghcr-pulls.csv \
|
||||
--owner maziggy \
|
||||
--pkg bambuddy
|
||||
|
||||
- name: commit-and-push
|
||||
working-directory: data
|
||||
run: |
|
||||
if [ -z "$(git status --porcelain)" ]; then
|
||||
echo "no changes — skipping commit"
|
||||
exit 0
|
||||
fi
|
||||
git add maziggy/bambuddy/latest-report/report.html \
|
||||
maziggy/bambuddy/ghcr-pulls.csv
|
||||
git commit -m "ghcr-pulls: refresh container downloads chart"
|
||||
git push
|
||||
|
|
|
|||
42
.github/workflows/security.yml
vendored
42
.github/workflows/security.yml
vendored
|
|
@ -125,10 +125,7 @@ jobs:
|
|||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
# Upgrade setuptools too: the runner's Python toolcache ships an old
|
||||
# setuptools that trips pip-audit (PYSEC-2026-3447, fixed in 83.0.0).
|
||||
# A fix exists, so we upgrade rather than --ignore-vuln.
|
||||
python -m pip install --upgrade pip setuptools
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pip-audit
|
||||
|
||||
|
|
@ -308,46 +305,13 @@ jobs:
|
|||
}
|
||||
}
|
||||
const vulns = results.vulnerabilities || {};
|
||||
// Documented advisory exceptions (keyed by GHSA id) - see ci.yml for the
|
||||
// full rationale and the matching hard gate. GHSA-qwww-vcr4-c8h2: React
|
||||
// Router RSC-mode CSRF, not reachable from Bambuddy's BrowserRouter SPA
|
||||
// (@react-router/server not installed); react-router/-dom pinned to 7.18.1
|
||||
// (the most-patched 7.x), no non-major fix exists. Auto-surfaces again if a
|
||||
// non-major fix ships.
|
||||
const ALLOWLIST = new Set(['GHSA-qwww-vcr4-c8h2']);
|
||||
function advisoryIds(name, seen) {
|
||||
seen = seen || new Set();
|
||||
if (seen.has(name)) return new Set();
|
||||
seen.add(name);
|
||||
const ids = new Set();
|
||||
for (const item of (vulns[name] || {}).via || []) {
|
||||
if (item && typeof item === 'object') {
|
||||
const url = item.url || '';
|
||||
if (url.includes('/advisories/')) ids.add(url.split('/').pop());
|
||||
} else if (typeof item === 'string') {
|
||||
for (const id of advisoryIds(item, seen)) ids.add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
function fixIsMajor(info) {
|
||||
const fa = info.fixAvailable;
|
||||
return fa && typeof fa === 'object' && fa.isSemVerMajor;
|
||||
}
|
||||
function exempt(name, info) {
|
||||
const ids = advisoryIds(name);
|
||||
return ids.size > 0 && [...ids].every(id => ALLOWLIST.has(id)) && fixIsMajor(info);
|
||||
}
|
||||
const filtered = {};
|
||||
const flagged = {};
|
||||
for (const [name, info] of Object.entries(vulns)) {
|
||||
if (!prodDeps.has(name)) continue;
|
||||
filtered[name] = info;
|
||||
if (!exempt(name, info)) flagged[name] = info;
|
||||
if (prodDeps.has(name)) filtered[name] = info;
|
||||
}
|
||||
results.vulnerabilities = filtered;
|
||||
fs.writeFileSync('npm-audit-results.json', JSON.stringify(results, null, 2));
|
||||
const count = Object.keys(flagged).length;
|
||||
const count = Object.keys(filtered).length;
|
||||
console.log(count > 0
|
||||
? count + ' production vulnerabilities found'
|
||||
: 'No production vulnerabilities (filtered ' + Object.keys(vulns).length + ' npm-internal entries)');
|
||||
|
|
|
|||
182
.github/workflows/windows-installer.yml
vendored
182
.github/workflows/windows-installer.yml
vendored
|
|
@ -1,182 +0,0 @@
|
|||
name: Windows Installer
|
||||
|
||||
# Build the Windows installer .exe.
|
||||
#
|
||||
# Triggers:
|
||||
# - Tag push matching v* (release builds, uploaded as a release asset)
|
||||
# - Manual dispatch (for testing the build pipeline)
|
||||
#
|
||||
# Release tags are Authenticode-signed through the SignPath Foundation OSS
|
||||
# program. Daily prereleases are deliberately left unsigned so they don't burn
|
||||
# the OSS signing quota; use the `sign` dispatch input to exercise the signing
|
||||
# path by hand.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sign:
|
||||
description: 'Submit the installer to SignPath for signing'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# Least-privilege per CodeQL actions/missing-workflow-permissions.
|
||||
# contents: write is required by softprops/action-gh-release to attach
|
||||
# the .exe to a tag release; the manual-dispatch path doesn't trigger
|
||||
# that step and could run with read-only, but a single workflow-level
|
||||
# block keeps the surface auditable in one place.
|
||||
# actions: read lets the SignPath connector download the uploaded artifact
|
||||
# through the API. Declaring a permissions block at all drops every scope we
|
||||
# don't name to `none`, so the signing step fails to fetch the artifact
|
||||
# without it.
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
env:
|
||||
# Sign real release tags but not `-daily.` prereleases, and let a manual
|
||||
# run opt in. GitHub's `||` returns the *last* operand when everything is
|
||||
# falsy (an empty string here, not `false`), so every use site compares
|
||||
# against the string 'true' rather than treating this as a boolean.
|
||||
SIGN: ${{ (startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-daily.')) || inputs.sign }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
# Inno Setup 6.x is pre-installed on windows-latest runners (under
|
||||
# C:\Program Files (x86)\Inno Setup 6\). No install step needed.
|
||||
|
||||
- name: Stage installer artifacts
|
||||
working-directory: installers/windows
|
||||
run: python build.py
|
||||
shell: pwsh
|
||||
|
||||
- name: Compile installer (ISCC)
|
||||
working-directory: installers/windows
|
||||
run: |
|
||||
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" bambuddy.iss
|
||||
shell: pwsh
|
||||
|
||||
# SignPath signs a *GitHub artifact*, not a workspace path: the connector
|
||||
# pulls the artifact back out through the API, which is why this upload
|
||||
# has to happen before signing and why upload-artifact must be v4 or newer
|
||||
# (older versions expose no `artifact-id` output). Kept as a separate,
|
||||
# clearly-named artifact so an unsigned build is never mistaken for a
|
||||
# signed one when downloading from the run page.
|
||||
- name: Upload unsigned installer
|
||||
id: upload_unsigned
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: bambuddy-windows-installer-unsigned
|
||||
path: installers/windows/build/output/*.exe
|
||||
if-no-files-found: error
|
||||
|
||||
# The artifact arrives at SignPath as a .zip (that is simply what
|
||||
# upload-artifact produces), so the artifact configuration on the SignPath
|
||||
# side describes a <zip-file> wrapping the <pe-file>. With skip-decompress
|
||||
# left at its default the signed archive is extracted again here, so
|
||||
# `signed/` ends up holding the bare .exe.
|
||||
- name: Sign installer (SignPath)
|
||||
if: env.SIGN == 'true'
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
# Not a credential -- the organization ID appears in ordinary SignPath
|
||||
# URLs and is useless without the API token above.
|
||||
organization-id: '4d7e5b59-d0fb-4a6b-b385-b861e18c6386'
|
||||
project-slug: 'bambuddy'
|
||||
signing-policy-slug: 'test-signing'
|
||||
github-artifact-id: ${{ steps.upload_unsigned.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: installers/windows/build/signed
|
||||
|
||||
# Replace the unsigned binary in-place so every downstream step (alias,
|
||||
# artifact upload, release attachment) keeps working off one directory and
|
||||
# cannot accidentally publish the unsigned copy.
|
||||
- name: Promote signed installer
|
||||
if: env.SIGN == 'true'
|
||||
shell: pwsh
|
||||
working-directory: installers/windows/build
|
||||
run: |
|
||||
$signed = @(Get-ChildItem -Path signed -Filter *.exe)
|
||||
if ($signed.Count -ne 1) {
|
||||
throw "expected exactly one signed .exe, found $($signed.Count)"
|
||||
}
|
||||
Move-Item -Force $signed[0].FullName (Join-Path output $signed[0].Name)
|
||||
Write-Host "promoted signed installer: $($signed[0].Name)"
|
||||
|
||||
# Fail loudly rather than shipping an unsigned .exe under a signed
|
||||
# release. The test certificate is self-signed, so Windows reports the
|
||||
# signature as untrusted (`UnknownError`) -- that is expected and is not
|
||||
# what this checks. Only the absence of a signature is treated as a
|
||||
# failure; swap in a stricter assertion once the production certificate
|
||||
# is imported.
|
||||
- name: Verify signature
|
||||
if: env.SIGN == 'true'
|
||||
shell: pwsh
|
||||
working-directory: installers/windows/build/output
|
||||
run: |
|
||||
Get-ChildItem -Filter *.exe | ForEach-Object {
|
||||
$sig = Get-AuthenticodeSignature $_.FullName
|
||||
if ($sig.Status -eq 'NotSigned') {
|
||||
throw "$($_.Name) carries no Authenticode signature"
|
||||
}
|
||||
Write-Host "$($_.Name): $($sig.Status) / $($sig.SignerCertificate.Subject)"
|
||||
}
|
||||
|
||||
# Stable + beta tag releases (e.g. v0.2.5b1, v0.3.0) get an unversioned
|
||||
# copy alongside the versioned filename so external surfaces (website,
|
||||
# wiki, newsletters) can link to a stable URL that survives version
|
||||
# bumps:
|
||||
#
|
||||
# https://github.com/maziggy/bambuddy/releases/latest/download/bambuddy-windows-x64-setup.exe
|
||||
#
|
||||
# GitHub's `latest` redirect excludes prereleases, so this URL always
|
||||
# points at whatever was released as a full release. Daily prereleases
|
||||
# are excluded from the alias because (a) the unversioned name would be
|
||||
# semantically confusing next to the date-stamped versioned name on a
|
||||
# daily prerelease page, and (b) there's no stable "latest daily" URL
|
||||
# anyway (`latest` skips prereleases), so the alias adds no value there.
|
||||
#
|
||||
# Runs after signing so the alias is a copy of the *signed* binary.
|
||||
- name: Create unversioned alias (non-daily tags only)
|
||||
if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-daily.')
|
||||
shell: pwsh
|
||||
working-directory: installers/windows/build/output
|
||||
run: |
|
||||
$versioned = Get-ChildItem -Filter "bambuddy-*-windows-x64-setup.exe" | Select-Object -First 1
|
||||
if (-not $versioned) { throw "no versioned installer .exe found" }
|
||||
Copy-Item $versioned.FullName "bambuddy-windows-x64-setup.exe"
|
||||
Write-Host "alias: bambuddy-windows-x64-setup.exe -> $($versioned.Name)"
|
||||
|
||||
- name: Upload installer artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: bambuddy-windows-installer
|
||||
path: installers/windows/build/output/*.exe
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Attach installer to release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: installers/windows/build/output/*.exe
|
||||
fail_on_unmatched_files: true
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -90,8 +90,3 @@ advertisements/
|
|||
gitleaks-report.json
|
||||
|
||||
scripts/pip-audit.sh
|
||||
|
||||
security/
|
||||
|
||||
test_pipeline_archive_source.3mf
|
||||
test_pipeline_run_1.3mf
|
||||
|
|
|
|||
|
|
@ -30,9 +30,7 @@ repos:
|
|||
exclude: ^(static/|frontend/tsconfig\.)
|
||||
- id: check-added-large-files
|
||||
args: ['--maxkb=1000']
|
||||
# CHANGELOG.md is intentionally large (detailed per-release entries over
|
||||
# many versions); exempt it while keeping the 1 MB guard for everything else.
|
||||
exclude: ^(static/assets/|CHANGELOG\.md$)
|
||||
exclude: ^static/assets/
|
||||
- id: check-merge-conflict
|
||||
- id: debug-statements
|
||||
- id: detect-private-key
|
||||
|
|
|
|||
29
BACKERS.md
29
BACKERS.md
|
|
@ -13,7 +13,7 @@ If you sponsor and your name isn't here within 48h, please write an email to mar
|
|||
|
||||
## Corporate Sponsors ($300/mo+)
|
||||
|
||||
- [@northpole3dprinting](https://github.com/northpole3dprinting)
|
||||
*None yet — be the first. Your logo on the bambuddy.cool homepage and press.html, plus co-marketing.*
|
||||
|
||||
## Sustaining Sponsors ($150/mo+)
|
||||
|
||||
|
|
@ -24,20 +24,15 @@ If you sponsor and your name isn't here within 48h, please write an email to mar
|
|||
- [@VREmma](https://github.com/VREmma)
|
||||
- [@pwostran](https://github.com/pwostran)
|
||||
- [@Praxeis](https://github.com/Praxeis)
|
||||
- [@jmclaren7](https://github.com/jmclaren7)
|
||||
- [@RoBoT24-web](https://github.com/RoBoT24-web)
|
||||
- [@Rayvenhaus](https://github.com/Rayvenhaus)
|
||||
- [@TheUltimateC0der](https://github.com/TheUltimateC0der)
|
||||
- [@rstocks](https://github.com/rstocks)
|
||||
|
||||
## Supporters ($15/mo+)
|
||||
|
||||
- [@rewart01](https://github.com/rewart01)
|
||||
- [@rstocks](https://github.com/rstocks)
|
||||
- [@sixfootseven](https://github.com/sixfootseven)
|
||||
- [@MethodicalMartian](https://github.com/MethodicalMartian)
|
||||
- [@jmclaren7](https://github.com/jmclaren7)
|
||||
- [@brianharwell](https://github.com/brianharwell)
|
||||
- [@shosier01](https://github.com/shosier01)
|
||||
- [@freifunk-bamberg](https://github.com/freifunk-bamberg)
|
||||
|
||||
## Backers ($5/mo+)
|
||||
|
||||
|
|
@ -54,24 +49,6 @@ If you sponsor and your name isn't here within 48h, please write an email to mar
|
|||
- [@Geoff-S](https://github.com/Geoff-S)
|
||||
- [@andyspinball](https://github.com/andyspinball
|
||||
- [@avandeputte](https://github.com/avandeputte)
|
||||
- [@joeferrante](https://github.com/joeferrante)
|
||||
- [@GPop61](https://github.com)
|
||||
- [@CooleyMcCoolson](https://github.com/CooleyMcCoolson)
|
||||
- [@mikeloveridge](https://github.com/mikeloveridge)
|
||||
- [@boernie](https://github.com/boernie)
|
||||
- [@qoatzelcoat](https://github.com/qoatzelcoat)
|
||||
- [@Sanaki](https://github.com/Sanaki)
|
||||
- [@jlofshult](https://github.com/jlofshult)
|
||||
- [@TriadX1](https://github.com/TriadX1)
|
||||
- [@hazzardr](https://github.com/hazzardr)
|
||||
- [@Shihchiun](https://github.com/Shihchiun)
|
||||
- [@kycrna](https://github.com/kycrna)
|
||||
- [@iljur](https://github.com/iljur)
|
||||
- [@bhamiltoncx](https://github.com/bhamiltoncx)
|
||||
- [@g7ufo](https://github.com/g7ufo)
|
||||
- [@Heidelberger2000](https://github.com/Heidelberger2000)
|
||||
- [@MorganMLGman](https://github.com/MorganMLGman)
|
||||
|
||||
---
|
||||
|
||||
## One-time and historical supporters
|
||||
|
|
|
|||
440
CHANGELOG.md
440
CHANGELOG.md
File diff suppressed because one or more lines are too long
|
|
@ -117,9 +117,8 @@ pip install -r requirements-dev.txt # Dev/test dependencies (pytest, ruff, band
|
|||
pip install pre-commit
|
||||
pre-commit install
|
||||
|
||||
# Run backend (--loop asyncio matches production; avoids a uvloop TLS bug
|
||||
# that can truncate Virtual Printer FTP uploads on slow storage — see #1896)
|
||||
DEBUG=true uvicorn backend.app.main:app --reload --host 0.0.0.0 --port 8000 --loop asyncio
|
||||
# Run backend
|
||||
DEBUG=true uvicorn backend.app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### Frontend Setup
|
||||
|
|
@ -223,18 +222,21 @@ The frontend uses [react-i18next](https://react.i18next.com/) for all user-facin
|
|||
|
||||
### Locale Files
|
||||
|
||||
Translations live in `frontend/src/i18n/locales/`. `en.ts` is the reference locale; every other `*.ts` file in that directory is checked against it. The parity check discovers the directory at runtime, so a new locale is picked up automatically — this file never needs updating when one is added.
|
||||
Translations live in `frontend/src/i18n/locales/`:
|
||||
|
||||
To see the current set of locales and check your work:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run check:i18n
|
||||
```
|
||||
| File | Language |
|
||||
|------|----------|
|
||||
| `en.ts` | English (primary) |
|
||||
| `de.ts` | German |
|
||||
| `fr.ts` | French |
|
||||
| `ja.ts` | Japanese |
|
||||
| `pt-BR.ts` | Brazilian Portuguese |
|
||||
[...]
|
||||
check for possibly more files!!!
|
||||
|
||||
### Adding New Strings
|
||||
|
||||
1. Add the key to the appropriate section in **every** locale file
|
||||
1. Add the key to the appropriate section in **all three** locale files
|
||||
2. Use the `useTranslation` hook in your component:
|
||||
|
||||
```tsx
|
||||
|
|
@ -250,9 +252,9 @@ function MyComponent() {
|
|||
|
||||
### Important Notes
|
||||
|
||||
- Every locale file must use the **same key structure** — same nesting, same key paths
|
||||
- Always add keys to **every** locale to maintain parity, with real translations rather than English placeholders — the check flags leaves that are identical to `en`
|
||||
- Run `npm run test:run` before pushing — it chains the parity check, which CI runs too. Plain `npm test` is vitest in watch mode and skips it
|
||||
- All three locale files must use the **same key structure** — same nesting, same key paths
|
||||
- Always add keys to all three locales to maintain parity
|
||||
- Run frontend tests after changes — locale parity is validated
|
||||
- If you find structural inconsistencies between locales, fix them — different key paths cause silent fallback to English
|
||||
|
||||
## Authentication & Permissions
|
||||
|
|
@ -293,11 +295,7 @@ Permissions follow the `resource:action` pattern (e.g., `filaments:read`, `print
|
|||
| `update` | Modify existing resources |
|
||||
| `delete` | Remove resources |
|
||||
|
||||
Some resources have additional actions. Examples: `printers:control` for live printer controls
|
||||
such as stop/pause/resume, `printers:files` for printer storage access, `queue:create` for
|
||||
creating queue items that may dispatch immediately when scheduled ASAP, `library:upload` for
|
||||
File Manager uploads/imports, and `archives:reprint_own` / `archives:reprint_all` for archive
|
||||
reprint eligibility. Archive reprint still needs `queue:create` before it can enqueue a job.
|
||||
Some resources have additional actions (e.g., `printers:control` for start/stop, `printers:files` for file transfer).
|
||||
|
||||
### Adding New Permissions
|
||||
|
||||
|
|
|
|||
22
Dockerfile
22
Dockerfile
|
|
@ -54,7 +54,7 @@ RUN setcap cap_net_bind_service=+ep "$(readlink -f /usr/local/bin/python3)"
|
|||
# wheels (so a hostile wheel could hijack stdlib imports during install).
|
||||
COPY requirements.txt ./
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --root-user-action=ignore --upgrade 'pip>=26.1.2' \
|
||||
pip install --root-user-action=ignore --upgrade 'pip>=26.1' \
|
||||
&& pip install --root-user-action=ignore -r requirements.txt
|
||||
|
||||
# Copy backend
|
||||
|
|
@ -147,22 +147,6 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
|||
|
||||
# Run the application
|
||||
# Use standard asyncio loop (uvloop has permission issues in some Docker environments)
|
||||
# Port is configurable via PORT (default 8000); bind address via HOST (default
|
||||
# 0.0.0.0). Set HOST=127.0.0.1 to bind loopback only, e.g. when a reverse proxy
|
||||
# on the same host fronts the app.
|
||||
#
|
||||
# `exec` is load-bearing, not style. Without it the shell stays as PID 1 and
|
||||
# uvicorn runs as its child; dash does not forward signals, so `docker stop`
|
||||
# SIGTERMs the shell and uvicorn never hears about it. Every stop then ran to
|
||||
# the end of the grace period and died on SIGKILL (exit 137) — no WAL
|
||||
# checkpoint, no MQTT disconnect, no virtual-printer teardown, on every restart
|
||||
# and every image update. With `exec`, uvicorn *is* PID 1 and gets the signal.
|
||||
#
|
||||
# --timeout-graceful-shutdown caps the wait on in-flight requests. Uvicorn's
|
||||
# default is to wait forever, and an MJPEG camera stream is a response that
|
||||
# never completes, so a single open camera tile would otherwise pin the process
|
||||
# past Docker's 10s grace and back into SIGKILL. On timeout uvicorn cancels the
|
||||
# request tasks; the camera generators already unwind cleanly on CancelledError.
|
||||
ENV UVICORN_TIMEOUT_GRACEFUL_SHUTDOWN=5
|
||||
# Port is configurable via PORT environment variable (default: 8000)
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["sh", "-c", "exec uvicorn backend.app.main:app --host ${HOST:-0.0.0.0} --port ${PORT:-8000} --loop asyncio --timeout-graceful-shutdown ${UVICORN_TIMEOUT_GRACEFUL_SHUTDOWN}"]
|
||||
CMD ["sh", "-c", "uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8000} --loop asyncio"]
|
||||
|
|
|
|||
76
README.md
76
README.md
|
|
@ -6,28 +6,23 @@
|
|||
|
||||
<p align="center">
|
||||
<strong>Your printers. No cloud. Your rules.</strong><br>
|
||||
Self-hosted command center for Bambu Lab — from one A1 to an entire print farm.
|
||||
Self-hosted command center for Bambu Lab — from one A1 to a 40-printer farm.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/maziggy/bambuddy/releases"><img src="https://img.shields.io/github/v/release/maziggy/bambuddy?style=flat-square&color=blue&cacheSeconds=3600" alt="Release"></a>
|
||||
<a href="https://github.com/maziggy/bambuddy/releases"><img src="https://img.shields.io/github/v/release/maziggy/bambuddy?style=flat-square&color=blue" alt="Release"></a>
|
||||
<img src="https://github.com/maziggy/bambuddy/actions/workflows/ci.yml/badge.svg?branch=main">
|
||||
<img src="https://github.com/maziggy/bambuddy/actions/workflows/github-code-scanning/codeql/badge.svg">
|
||||
<img src="https://github.com/maziggy/bambuddy/actions/workflows/security.yml/badge.svg">
|
||||
<a href="https://github.com/maziggy/bambuddy/blob/main/LICENSE"><img src="https://img.shields.io/github/license/maziggy/bambuddy?style=flat-square&cacheSeconds=3600" alt="License"></a>
|
||||
<a href="https://github.com/maziggy/bambuddy/stargazers"><img src="https://img.shields.io/github/stars/maziggy/bambuddy?style=flat-square&cacheSeconds=3600" alt="Stars"></a>
|
||||
<a href="https://github.com/maziggy/bambuddy/issues"><img src="https://img.shields.io/github/issues/maziggy/bambuddy?style=flat-square&cacheSeconds=3600" alt="Issues"></a>
|
||||
<a href="https://github.com/maziggy/bambuddy/blob/main/LICENSE"><img src="https://img.shields.io/github/license/maziggy/bambuddy?style=flat-square" alt="License"></a>
|
||||
<a href="https://github.com/maziggy/bambuddy/stargazers"><img src="https://img.shields.io/github/stars/maziggy/bambuddy?style=flat-square" alt="Stars"></a>
|
||||
<a href="https://github.com/maziggy/bambuddy/issues"><img src="https://img.shields.io/github/issues/maziggy/bambuddy?style=flat-square" alt="Issues"></a>
|
||||
<a href="https://discord.gg/aFS3ZfScHM"><img src="https://img.shields.io/discord/1461241694715645994?style=flat-square&logo=discord&logoColor=white&label=Discord&color=5865F2" alt="Discord"></a>
|
||||
<a href="https://github.com/sponsors/maziggy"><img src="https://img.shields.io/badge/GitHub_Sponsors-Sponsor-ea4aaa?style=flat-square&logo=github-sponsors&logoColor=white" alt="GitHub Sponsors"></a>
|
||||
<a href="https://sponsors.bambuddy.cool"><img src="https://img.shields.io/badge/Sponsors_Portal-sponsors.bambuddy.cool-2dd4bf?style=flat-square&logo=heart&logoColor=white" alt="Sponsors Portal"></a>
|
||||
<a href="https://ko-fi.com/maziggy"><img src="https://img.shields.io/badge/Ko--fi-Support-ff5e5b?style=flat-square&logo=ko-fi&logoColor=white" alt="Ko-fi" target=_blank></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><strong>Backed by</strong></sub><br>
|
||||
<a href="https://northpole3dprinting.com/"><img src="static/img/sponsors/northpole-3d-printing.jpg" alt="North Pole 3D Printing" height="60"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://demo.bambuddy.cool"><strong>🎮 Try the Live Demo</strong></a> •
|
||||
<a href="#-features">Features</a> •
|
||||
|
|
@ -54,12 +49,9 @@
|
|||
> — Adam Conway, [XDA-Developers](https://www.xda-developers.com/finally-have-full-control-bambu-lab-printer-ditched-bambu-cloud/)
|
||||
|
||||
<p align="center">
|
||||
<a href="https://hackaday.com/2026/06/13/bambuddy-says-bye-to-bambu-lab-cloud-services/"><img src="https://img.shields.io/badge/Hackaday-Read-F2A724?style=flat-square&labelColor=000000" alt="Hackaday"></a>
|
||||
<a href="https://www.xda-developers.com/finally-have-full-control-bambu-lab-printer-ditched-bambu-cloud/"><img src="https://img.shields.io/badge/XDA--Developers-Read-C8102E?style=flat-square" alt="XDA-Developers"></a>
|
||||
<a href="https://www.howtogeek.com/free-your-bambu-lab-3d-printer-from-the-cloud/"><img src="https://img.shields.io/badge/How--To%20Geek-Read-33A6CA?style=flat-square" alt="How-To Geek"></a>
|
||||
<a href="https://www.makeuseof.com/free-browser-tool-beats-bambu-lab-at-own-game/"><img src="https://img.shields.io/badge/MakeUseOf-Read-E02D2D?style=flat-square" alt="MakeUseOf"></a>
|
||||
<a href="https://www.fabbaloo.com/news/bambuddy-launches-as-open-source-alternative-to-bambu-labs-cloud"><img src="https://img.shields.io/badge/Fabbaloo-Read-F77B0F?style=flat-square" alt="Fabbaloo"></a>
|
||||
<a href="https://itsfoss.com/news/bambuddy-self-hosted-bambu-lab-alternative/"><img src="https://img.shields.io/badge/It's%20FOSS-Read-00B5AD?style=flat-square" alt="It's FOSS"></a>
|
||||
<a href="https://www.igorslab.de/en/bambuddy-the-silent-alternative-to-the-bamboo-cloud/"><img src="https://img.shields.io/badge/Igor's%20Lab-Read-E10000?style=flat-square" alt="Igor's Lab"></a>
|
||||
<a href="https://3druck.com/en/programs/bambuddy-open-source-tool-replaces-bambu-cloud-for-management-and-automation-of-3d-print-jobs-38153226/"><img src="https://img.shields.io/badge/3Druck-Read-0080C0?style=flat-square" alt="3Druck"></a>
|
||||
<a href="https://www.fastblinker.com/bambuddy-the-open-source-solution-thats-revolutionizing-bambu-lab-3d-printer-management/"><img src="https://img.shields.io/badge/FastBlinker-Read-00B0FF?style=flat-square" alt="FastBlinker"></a>
|
||||
|
|
@ -112,20 +104,6 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
|
|||
|
||||
---
|
||||
|
||||
## 🧩 NEW: Slicer Pipelines — Save a Recipe, Reuse in One Click
|
||||
|
||||
**Stop re-picking the same printer + process + filament + bed-type combination every slice.** Save a Slicer **Pipeline** once from the Slice dialog, then apply the whole bundle to any file with a single click — from File Manager, Archives, or MakerWorld imports.
|
||||
|
||||
- 🧩 **One-click reuse** — A pipeline captures the entire Slice modal selection (printer + process + per-AMS-slot filaments + bed type) and surfaces as **Run with pipeline → \<name\>** on every sliceable row.
|
||||
- 🎯 **Specific printer or printer class** — Pin a pipeline to one printer, or to a *class* (e.g. *any X1C*) and let the queue scheduler pick the first available match. Identical-fleet farms get a single recipe instead of one-per-printer.
|
||||
- 🪢 **Multi-copy fanout** — Slice once, dispatch up to N copies. With class targeting the copies fan out across the matching printers in parallel — **Spread** (fastest wall-clock), **Single printer** (minimise colour-change overhead), or **First N** (one to each).
|
||||
- 📊 **Runs dashboard** — A new **Pipelines** tab on the Print Queue page lists every run with colour-coded status badges (queued / slicing / dispatching / in-progress / completed / partial-failure / failed / cancelled), per-copy detail on expand, filter dropdowns (Pipeline / Status / Target), and a **Retry failed** button that re-runs only the copies that didn't complete — successful copies are never re-printed.
|
||||
- 🔒 **Permission-gated** — Three permissions (`pipelines:read` / `pipelines:write` / `pipelines:run`) let you split authoring the recipe from spending filament with it.
|
||||
|
||||
👉 **[Slicer Pipelines Guide →](https://wiki.bambuddy.cool/features/slicer-pipelines/)**
|
||||
|
||||
---
|
||||
|
||||
## Why Bambuddy?
|
||||
|
||||
- **Own your data** — All print history stored locally, no cloud dependency
|
||||
|
|
@ -156,36 +134,29 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
|
|||
|
||||
### 📊 Monitoring & Control
|
||||
- Real-time printer status via WebSocket
|
||||
- **Print progress in the browser tab** — optional (off by default, toggle under Settings → Appearance): shows the soonest-finishing print's percentage in the tab title and a progress-ring favicon in your theme accent colour
|
||||
- Live camera streaming (MJPEG) & snapshots with multi-viewer support — most Bambu printers only allow one upstream connection, so Bambuddy fans out a single shared stream to all browser tabs / cards / overlays
|
||||
- **Cam Wall view** — Toggle the Printers page from cards into a responsive grid of camera tiles for at-a-glance monitoring across the whole farm. On-screen tiles stream live up to a configurable cap (default 4) so RPi installs stay sustainable; the rest fall back to periodic snapshot polling, and off-screen tiles pause entirely. Per-user settings (live cap, snapshot interval); click any tile to open the floating viewer or the dedicated camera window depending on your existing camera-view preference
|
||||
- **Long-lived camera tokens** for Home Assistant / Frigate / kiosks — mint a token from Settings → API Keys, paste it once, capped at 365 days, revocable at any time (no infinite tokens — leaked permanent tokens are unsafe by design)
|
||||
- **Streaming overlay for OBS** - Embeddable page with camera + status for live streaming (`/overlay/:printerId`), configurable FPS (`?fps=30`), status-only mode (`?camera=false`)
|
||||
- External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse
|
||||
- **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)
|
||||
- Fan monitoring and **speed control** for part-cooling, auxiliary, and chamber fans (0–100% with customizable quick-select presets)
|
||||
- Printer control (stop, pause, resume, chamber light, print speed, **airduct mode** for P2S/H2*, **temperature setpoints** for nozzle / bed / **chamber heater** on H2C/H2D/H2DPro/H2S/X2D, **Z-jog / XY-jog / extruder jog**, customizable temperature & fan presets under Settings → Workflow)
|
||||
- Fan status monitoring (part cooling, auxiliary, chamber)
|
||||
- Printer control (stop, pause, resume, chamber light, print speed, **airduct mode** for P2S/H2*, **build-plate Z-jog** with Studio-style not-homed warning)
|
||||
- **Status badges on printer card**: SD Card (green / red), Enclosure Door (green / yellow — X1/P1S/P2S/H2*), Airduct Mode (cooling / heating)
|
||||
- **Force Refresh** menu item — request a full status push from the printer without reconnecting
|
||||
- **Maintenance Mode** — put a printer "out of service" without removing it. Toggle from the card's three-dot menu, the in-card amber banner, or the Edit Printer dialog; the printer disconnects MQTT, drops out of queue dispatch, the scheduler, model-based filament lookups, metrics, and notifications until you take it out again. The card stays visible (amber wrench banner + Exit button) so the printer never disappears from your dashboard. Useful for parallel Bambuddy installs sharing the same hardware, printers under repair or awaiting parts, and temporary suspension.
|
||||
- Bulk printer actions (multi-select cards, then stop/pause/resume/clear all — select by state or location)
|
||||
- Printer search and filters — live search by name/model/location/serial plus status and location dropdown filters (WebSocket-reactive, mobile-friendly)
|
||||
- Resizable printer cards (S/M/L/XL)
|
||||
- Skip objects during print
|
||||
- AMS slot RFID re-read
|
||||
- **AMS slot Load / Unload from the printer card** — Hover any AMS slot or external spool, click the menu button, and load that tray or unload the currently-loaded one without going to the touchscreen; supports dual-extruder H2D (Ext-L / Ext-R drive their own nozzle)
|
||||
- **AMS Filament Backup status + control with pair view** — Mirrors BambuStudio's per-printer "AMS Filament Backup" auto-switch (when a spool runs out, the printer rolls over to a same-preset, same-colour spool in another slot). A small badge in the Filaments section header on each printer card shows the live state (blue circular-arrow icon = ON, dim = OFF, "?" = A1 family with no `cfg` field yet); click to open the AMS Filament Backup modal — a BambuStudio Auto Refill-style ring graphic per backup pair, with the filament colour as the ring fill and member slot labels (e.g. `A·1`, `B·3`) on contrast-aware pills around the band. Dual-extruder printers (H2D / H2C / X2D) carry an `R` / `L` badge per ring because the firmware can't cross extruders. State syncs in real time whether you toggled from Bambuddy, BambuStudio, or the printer's touchscreen. Bambuddy's "insufficient filament" check is **backup-aware**: when Backup is ON, the deficit check pools remaining grams across same-`(preset, colour)` spools on the printer, so the warning doesn't fire spuriously when the firmware will swap to a peer mid-print (#1762). Bambuddy's **Prefer Lowest Remaining Filament** sort also respects the toggle — when Backup is OFF the dispatcher skips the prefer-lowest sort entirely so it won't reach for a near-empty spool the printer can't roll off of.
|
||||
- AMS slot configuration (model-filtered presets, K profiles, color picker, pre-population for configured slots)
|
||||
- AMS info card (hover for serial number, firmware version) with custom friendly names that persist across printers
|
||||
- **AMS remote drying** — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page with filament-based temperature/duration presets, optional spool rotation; automatic PSU detection and HMS power error reporting. Rotate-spool toggle is disabled per-AMS when any tray has filament threaded into the feed tube (the AMS mechanism is locked there — rotating would jam the filament)
|
||||
- **AMS remote drying** — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page with filament-based temperature/duration presets, optional spool rotation; automatic PSU detection and HMS power error reporting
|
||||
- **Queue auto-drying** — Automatically dry filament between scheduled prints when humidity exceeds threshold; configurable presets per filament type, optional blocking mode
|
||||
- **Ambient drying** — Automatically keep filament dry on idle printers based on humidity, regardless of whether prints are queued
|
||||
- **Continue drying while printing** — On capable hardware (H2D 01.03.00.00+, H2C / H2S / P2S / H2D Pro 01.02.00.00+, X2D / A2L 01.01.00.00+, X1C 01.11.02.00+), auto-drying can keep running during a print. Default off, opt-in toggle in Settings → Print Queue. Drying temperature is automatically capped 5°C below the idle preset (floor 40°C) to protect spools inside the hot enclosure
|
||||
- Configurable drying presets per filament type (temperature & duration for AMS 2 Pro and AMS-HT)
|
||||
- **Per-filament humidity threshold** — Set a different humidity trigger per filament type (e.g. Nylon at 20%, PLA at 60%, ASA at 30%) instead of one global value. Mixed-material AMS units use the most-restrictive threshold across the loaded spools so a single PLA + Nylon unit triggers at Nylon's level. Drives both the auto-drying scheduler and the hourly humidity alarm so the two can never disagree on whether a unit is "too humid"
|
||||
- Dual external spool support for H2D (Ext-L / Ext-R)
|
||||
- **HMS error monitoring with one-click actions** — Live HMS error log with history and the same Resume / Stop / Continue / Retry / Check Assistant / Don't Remind Me action buttons BambuStudio shows. Click and the matching MQTT command goes back to the printer — no more walking to the device just to dismiss a paused-print dialog. Catalog covers every Bambu model (X1 / P1 / A1 / H2 series); buttons are translated in all 13 supported locales
|
||||
- **Heater history charts** — Bambuddy logs nozzle, bed, and chamber readings every minute and surfaces them via a tiny chart icon on each heater tile in the printer card. Click for a per-heater modal with current / average / min / max stats, target overlay, and a 6h / 24h / 48h / 7d time range — works on read-only chamber sensors (X1C / P2S) too. AMS humidity and temperature get the same treatment (already shipped).
|
||||
- HMS error monitoring with history and clear errors
|
||||
- Print success rates & trends
|
||||
- Filament usage tracking
|
||||
- Cost analytics & failure analysis
|
||||
|
|
@ -194,10 +165,9 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
|
|||
- CSV/Excel export
|
||||
|
||||
### ⏰ Scheduling & Automation
|
||||
- **Unified dispatch through the queue** — Every print Bambuddy starts (File Manager, archive reprint, printer-card upload-and-print, scheduled queue items) flows through the same queue scheduler, so each print is visible on the queue page, attributable to the user that started it, deficit-checked, and cancellable from one place. FTP uploads and print-start commands run in the background with real-time WebSocket progress toasts (per-job upload bars, status badges, cancel button). Installations with custom groups or API keys: the immediate-print actions now require the `queue:create` permission alongside the existing `printers:control` — see [the permissions guide](https://wiki.bambuddy.cool/admin/permissions/) if you've granted control without queue-create
|
||||
- Print queue with three tabs (Queue / History / Timeline), multi-select drag-and-drop, batch grouping, and a Gantt-style timeline
|
||||
- **Background print dispatch** — FTP uploads and print-start commands run in the background with real-time WebSocket progress toasts (per-job upload bars, status badges, cancel button)
|
||||
- Print queue with drag-and-drop and timeline schedule view
|
||||
- Multi-printer selection (send to multiple printers at once)
|
||||
- Batch grouping — multi-plate prints auto-group into a collapsible row; any 2+ selected items can be grouped manually via "Group as batch", with ungroup on the batch parent
|
||||
- Batch print quantity (print multiple copies — set quantity in the print/schedule dialog, first copy prints immediately, rest are queued)
|
||||
- Staggered batch start (start printers in groups with configurable interval to avoid power spikes — works in both Print and Queue dialogs)
|
||||
- Configurable default print options (bed levelling, flow/vibration calibration, first layer inspection, timelapse) in Settings → Workflow
|
||||
|
|
@ -211,7 +181,6 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
|
|||
- Queue Only mode (stage without auto-start)
|
||||
- Clear plate confirmation between queued prints (can be disabled in settings for farm workflows)
|
||||
- Auto-print G-code injection (per-model start/end snippets for Farmloop, SwapMod, AutoClear, Printflow 3D — toggle per queue item)
|
||||
- **Preheat & Heat Soak before queued prints** — Heat the bed (and the chamber, on supported printers) and hold at temperature between FTP upload and print start. Per-print Inherit / On / Off override in the Print Options panel; per-filament chamber-target map under Settings → Workflow so PA wants 50°C, ABS 45°C, PETG-CF 40°C, PLA 0°C (skips chamber phase automatically). Hardware-aware: H-series / X2D / X1E actively heat the chamber via M141; X1C / P2S rely on bed radiation with a chamber-sensor wait; P1S / P1P / A1 family have no chamber sensor so only the soak timer applies. The cooling/heating airduct flap on H-series / X2D / P2S auto-switches to match the resolved chamber target — preheat for ABS opens nothing and recirculates warm air; preheat for PLA opens the exhaust and vents — so engineering filaments actually reach target instead of fighting the open flap, and PLA prints don't inherit a previously-hot recirculation. M191 (wait-for-chamber-temp) isn't honoured by Bambu firmware, so doing this at the orchestration layer is the only place it works
|
||||
- Smart plug integration (Tasmota, Home Assistant, MQTT, REST/Webhook)
|
||||
- REST smart plugs: Control any device with an HTTP API (openHAB, ioBroker, FHEM, Node-RED) with separate power/energy URLs and unit multipliers
|
||||
- MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring
|
||||
|
|
@ -375,8 +344,6 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
|
|||
|
||||
## 📸 Screenshots
|
||||
|
||||
> **Refreshed printer card in 1.2.5b2** — tighter layout, popovers for all controls (temperature setpoints, fan speeds, jog), and a bottom-aligned power row. The screenshots below predate the refresh.
|
||||
|
||||
<details>
|
||||
<summary><strong>Click to expand screenshots</strong></summary>
|
||||
|
||||
|
|
@ -514,21 +481,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
|
|||
|
||||
### Installation
|
||||
|
||||
#### Windows (Native Installer)
|
||||
|
||||
Self-contained `.exe` — no Python, Node, Docker, or Git required on the target machine. The installer bundles Python 3.13, the React frontend, ffmpeg, and registers Bambuddy as a Windows service.
|
||||
|
||||
Download the latest installer:
|
||||
|
||||
> https://github.com/maziggy/bambuddy/releases/latest/download/bambuddy-windows-x64-setup.exe
|
||||
|
||||
Run it (one-time UAC prompt — admin install) → Bambuddy starts as a Windows service and the dashboard opens at **http://localhost:8000** automatically. Data lives at `C:\ProgramData\Bambuddy\`, install at `C:\Program Files\Bambuddy\`. To update, just run a newer installer over the existing install — your database and archives are preserved.
|
||||
|
||||
> **SmartScreen warning:** until our SignPath OSS code-signing approval lands, you'll see "Windows protected your PC" on first run. Click **More info → Run anyway**.
|
||||
|
||||
See the [Windows Installer Guide](https://wiki.bambuddy.cool/getting-started/windows-installer/) for service management, logs, and troubleshooting.
|
||||
|
||||
#### Docker (Linux / macOS / Windows via Docker Desktop)
|
||||
#### Docker (Recommended)
|
||||
|
||||
**Option A: Pre-built image (fastest)**
|
||||
```bash
|
||||
|
|
@ -667,8 +620,8 @@ python3 -m venv venv
|
|||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run (--loop asyncio avoids a uvloop TLS bug that can truncate VP FTP uploads)
|
||||
uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 --loop asyncio
|
||||
# Run
|
||||
uvicorn backend.app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
Open **http://localhost:8000** and add your printer!
|
||||
|
|
@ -729,7 +682,6 @@ Full documentation available at **[wiki.bambuddy.cool](http://wiki.bambuddy.cool
|
|||
| P1 | P1P, P1S |
|
||||
| P2 | P2S |
|
||||
| A1 | A1, A1 Mini |
|
||||
| A2 | A2L |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
"""Pure helper functions for OIDC routes.
|
||||
|
||||
Hosts the public-internet SSRF guard, used for both admin-supplied icon URLs
|
||||
and OIDC issuer URLs (via ``schemas.auth._validate_issuer_url``). Stricter
|
||||
than ``_url_safety.assert_safe_lan_service_url`` — LAN services intentionally
|
||||
allow loopback/RFC-1918 (same-host/same-LAN topology) while an IdP must be
|
||||
reachable on the public internet, so a private address there is an SSRF probe
|
||||
rather than a configuration.
|
||||
Hosts the SSRF guard for admin-supplied icon URLs. Stricter than
|
||||
``_spoolman_helpers.assert_safe_spoolman_url`` — Spoolman intentionally allows
|
||||
loopback/RFC-1918 (same-LAN topology) while OIDC icons must be reachable on
|
||||
the public internet (IdP-hosted), so private addresses there are SSRF probes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -13,21 +11,15 @@ from __future__ import annotations
|
|||
import ipaddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from backend.app.api.routes._url_safety import (
|
||||
CLOUD_METADATA_HOSTNAMES,
|
||||
CLOUD_METADATA_IPS,
|
||||
NUMERIC_IP_RE,
|
||||
unwrap_ipv4_mapped,
|
||||
)
|
||||
from backend.app.api.routes._url_safety import CLOUD_METADATA_IPS, NUMERIC_IP_RE, unwrap_ipv4_mapped
|
||||
|
||||
|
||||
def assert_safe_public_https_url(url: str) -> None:
|
||||
"""Raise ValueError if *url* is unsafe to fetch as a public HTTPS resource.
|
||||
|
||||
Used for OIDC provider icon URLs (#1333) and OIDC issuer URLs. Stricter
|
||||
than the LAN-service SSRF guard: also rejects loopback, private
|
||||
(RFC-1918), and link-local addresses because an IdP and its icon
|
||||
legitimately live only on the public internet.
|
||||
Used for OIDC provider icon URLs (#1333). Stricter than the Spoolman SSRF
|
||||
guard: also rejects loopback, private (RFC-1918), and link-local addresses
|
||||
because an OIDC icon legitimately lives only on the public internet.
|
||||
|
||||
Checks performed:
|
||||
- Scheme must be ``https`` (no ``http://``, ``file://``, ``gopher://``, …).
|
||||
|
|
@ -43,12 +35,9 @@ def assert_safe_public_https_url(url: str) -> None:
|
|||
- IPv4-mapped IPv6 (``::ffff:127.0.0.1``) — unwrapped before the IP-class
|
||||
check so an attacker can't bypass via IPv6 encoding.
|
||||
|
||||
Hostname-based addresses are otherwise accepted without DNS resolution —
|
||||
the operator is trusted to configure a sensible IdP host, and resolving
|
||||
here would both add a TOCTOU gap (DNS can change between validation and
|
||||
request) and make the validator issue network requests of its own. The
|
||||
fixed cloud-metadata hostnames are the exception: matching them is a
|
||||
literal string comparison, not a resolution.
|
||||
Hostname-based addresses are accepted without DNS resolution (consistent
|
||||
with ``_validate_issuer_url`` policy — the operator is trusted to
|
||||
configure a sensible IdP host).
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme.lower() != "https":
|
||||
|
|
@ -56,14 +45,6 @@ def assert_safe_public_https_url(url: str) -> None:
|
|||
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
|
||||
# "https:///path" parses to an empty hostname; without this it reaches the
|
||||
# ip_address() ValueError branch and is accepted as a symbolic hostname.
|
||||
if not hostname:
|
||||
raise ValueError("icon URL must include a hostname")
|
||||
|
||||
if hostname in CLOUD_METADATA_HOSTNAMES:
|
||||
raise ValueError("icon URL must not point to a cloud metadata endpoint")
|
||||
|
||||
if NUMERIC_IP_RE.match(hostname):
|
||||
raise ValueError("icon URL must not use numeric-encoded IP addresses")
|
||||
|
||||
|
|
|
|||
|
|
@ -5,15 +5,17 @@ No heavy dependencies — importable in unit tests without the full backend stac
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from backend.app.api.routes._url_safety import assert_safe_lan_service_url
|
||||
from backend.app.api.routes._url_safety import CLOUD_METADATA_IPS, NUMERIC_IP_RE, unwrap_ipv4_mapped
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -53,7 +55,6 @@ class MappedSpoolFields(TypedDict):
|
|||
updated_at: str | None
|
||||
cost_per_kg: float | None
|
||||
storage_location: str | None
|
||||
location_id: int | None
|
||||
k_profiles: list[Any]
|
||||
|
||||
|
||||
|
|
@ -78,17 +79,61 @@ class NormalizedFilament(TypedDict):
|
|||
|
||||
|
||||
def assert_safe_spoolman_url(url: str) -> None:
|
||||
"""Raise ValueError if the Spoolman *url* should be blocked as an SSRF risk.
|
||||
"""Raise ValueError if *url* should be blocked as an SSRF risk.
|
||||
|
||||
Thin wrapper over the shared LAN-service policy — see
|
||||
``_url_safety.assert_safe_lan_service_url`` for what is and isn't
|
||||
rejected, and why loopback/RFC-1918 are deliberately permitted (running
|
||||
Spoolman on the same host or home LAN is THE normal topology).
|
||||
Bambuddy is typically deployed on a home LAN alongside Spoolman, so
|
||||
loopback (127.0.0.1) and RFC-1918 private ranges (192.168.x.x, 10.x.x.x,
|
||||
172.16-31.x) must be permitted — they are THE normal Spoolman topology.
|
||||
This guard therefore targets the genuinely dangerous cases only.
|
||||
|
||||
Kept as a named function because the "Spoolman URL …" wording in its
|
||||
errors is user-facing and asserted by existing tests.
|
||||
Checks performed:
|
||||
- Scheme must be http or https (no file://, gopher://, dict://, etc.).
|
||||
- Numeric-encoded IP addresses in decimal (e.g. ``2130706433``) or hex
|
||||
(e.g. ``0x7f000001``) are rejected. Python's ``ipaddress`` module raises
|
||||
``ValueError`` for these forms so they would otherwise bypass the
|
||||
explicit-IP block below, but libc (and browsers) resolve them as valid
|
||||
IPv4 addresses.
|
||||
- Cloud provider metadata endpoints (169.254.169.254, 100.100.100.200,
|
||||
fd00:ec2::254) are blocked — the classic SSRF credential-exfil target.
|
||||
- Multicast (224.0.0.0/4, ff00::/8) and unspecified (0.0.0.0, ::) addresses
|
||||
are blocked — pointless as a destination and suggests misuse.
|
||||
- IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) are unwrapped so they cannot
|
||||
bypass the checks above.
|
||||
|
||||
Hostname-based addresses ("localhost", "spoolman.lan", "internal.corp")
|
||||
are out of scope — DNS resolution is deliberately not performed here.
|
||||
"""
|
||||
assert_safe_lan_service_url(url, label="Spoolman URL")
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme.lower() not in ("http", "https"):
|
||||
raise ValueError("Spoolman URL must use http or https")
|
||||
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
|
||||
# Reject decimal- and hex-encoded IPs (e.g. http://2130706433/ or
|
||||
# http://0x7f000001/). These slip past ipaddress.ip_address() but libc
|
||||
# (and browsers) parse them as IPv4 — an obvious bypass if not caught.
|
||||
if NUMERIC_IP_RE.match(hostname):
|
||||
raise ValueError("Spoolman URL must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
|
||||
|
||||
try:
|
||||
addr = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
# Not a bare IP address — includes intentional cases such as "localhost" and
|
||||
# RFC-1918 hostnames ("spoolman.lan", "192.168.1.10" would be caught above as
|
||||
# a dotted-decimal IP; symbolic names resolve via DNS which is out of scope).
|
||||
# Running Spoolman on the same host or home LAN is the standard Bambuddy
|
||||
# topology, so loopback and private ranges are deliberately NOT blocked here.
|
||||
return
|
||||
|
||||
# Unwrap IPv4-mapped IPv6 (::ffff:169.254.169.254 etc.) so attackers can't
|
||||
# encode a blocked IPv4 into an IPv6 literal to bypass the check.
|
||||
effective = unwrap_ipv4_mapped(addr)
|
||||
|
||||
if effective in CLOUD_METADATA_IPS:
|
||||
raise ValueError("Spoolman URL must not point to a cloud metadata endpoint")
|
||||
|
||||
if effective.is_multicast or effective.is_unspecified:
|
||||
raise ValueError("Spoolman URL must not point to a multicast or unspecified address")
|
||||
|
||||
|
||||
_COLOR_HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}$")
|
||||
|
|
@ -301,6 +346,5 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
|
|||
"updated_at": created_at,
|
||||
"cost_per_kg": _safe_optional_float(spool.get("price")),
|
||||
"storage_location": spool.get("location") or None,
|
||||
"location_id": None,
|
||||
"k_profiles": [],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,19 @@
|
|||
"""Shared URL-safety primitives for the SSRF guards in this package.
|
||||
"""Shared URL-safety primitives used by both SSRF guards in this package.
|
||||
|
||||
Bambuddy has exactly two outbound-URL policies, and which one applies is a
|
||||
property of the *service*, not of the caller:
|
||||
|
||||
- **LAN-service** (``assert_safe_lan_service_url`` below) — the service
|
||||
legitimately lives on the same host or home LAN, so loopback and RFC-1918
|
||||
must be permitted; blocking them would break the normal topology. Used for
|
||||
Spoolman, self-hosted notification servers (ntfy, Bark, Gotify, custom
|
||||
webhooks), Home Assistant, the Obico ML endpoint and the slicer sidecars.
|
||||
- **Public-internet** (``_oidc_helpers.assert_safe_public_https_url``) — the
|
||||
resource can only sensibly live on the public internet, so a private
|
||||
address is an SSRF probe rather than a configuration. Used for OIDC issuer
|
||||
and icon URLs.
|
||||
|
||||
Both reject the cases that are dangerous regardless of topology: non-HTTP
|
||||
schemes, numeric-encoded IPs, cloud-metadata endpoints, multicast and
|
||||
unspecified addresses, and IPv4-mapped IPv6 encodings of any of the above.
|
||||
|
||||
The LAN-service policy lives here because it now has several callers; the
|
||||
public-internet policy stays in ``_oidc_helpers`` next to its only consumer.
|
||||
The two top-level assertion functions —
|
||||
``_spoolman_helpers.assert_safe_spoolman_url`` (Spoolman, deliberately allows
|
||||
loopback/RFC-1918 because same-LAN deployment is the standard topology) and
|
||||
``_oidc_helpers.assert_safe_public_https_url`` (OIDC icons, must be reachable
|
||||
on the public internet, so loopback/private are rejected) — share the
|
||||
*data* (cloud-metadata IP set, numeric-encoded-IP regex) but not the
|
||||
*policy*. Only the data lives here. The functions stay in their respective
|
||||
modules with their distinct policies intact.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Cloud-provider metadata endpoints — the classic SSRF credential-exfil
|
||||
# targets. Both guards reject these unconditionally.
|
||||
|
|
@ -40,18 +28,6 @@ CLOUD_METADATA_IPS = frozenset(
|
|||
}
|
||||
)
|
||||
|
||||
# The DNS-name form of the same targets. Neither guard resolves hostnames (see
|
||||
# the TOCTOU note on each), so an IP blocklist alone cannot catch these — but a
|
||||
# literal-string match needs no resolution and costs nothing. These names only
|
||||
# resolve inside the respective cloud, so there is no legitimate reason for any
|
||||
# Bambuddy integration to point at one.
|
||||
CLOUD_METADATA_HOSTNAMES = frozenset(
|
||||
{
|
||||
"metadata.google.internal", # GCP
|
||||
"metadata.goog", # GCP short form
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# libc and browsers parse numeric-encoded IP forms (decimal ``2130706433``
|
||||
# for 127.0.0.1, hex ``0x7f000001``) but Python's ``ipaddress.ip_address``
|
||||
|
|
@ -73,68 +49,3 @@ def unwrap_ipv4_mapped(
|
|||
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
|
||||
return addr.ipv4_mapped
|
||||
return addr
|
||||
|
||||
|
||||
def assert_safe_lan_service_url(url: str, *, label: str) -> None:
|
||||
"""Raise ValueError if *url* is unsafe for a service that may live on the LAN.
|
||||
|
||||
``label`` names the setting in the error message ("Spoolman URL", "ntfy
|
||||
server URL", …) so the user sees which field they need to correct.
|
||||
|
||||
Loopback (127.0.0.1) and RFC-1918 private ranges are deliberately
|
||||
**permitted** — Bambuddy is self-hosted and running Spoolman, ntfy,
|
||||
Bark, Home Assistant, an Obico ML endpoint or a slicer sidecar on the
|
||||
same host or home LAN is THE normal topology, not an attack. A blanket
|
||||
private-address block would break those integrations for most installs.
|
||||
|
||||
What is rejected is dangerous under any topology:
|
||||
|
||||
- Schemes other than http/https. ``httpx`` already raises
|
||||
``UnsupportedProtocol`` for ``file://``/``gopher://`` etc., so this is
|
||||
about returning a clear validation error at configuration time rather
|
||||
than an opaque failure at delivery time.
|
||||
- Numeric-encoded IPv4 (decimal ``2130706433``, hex ``0x7f000001``) —
|
||||
libc and browsers resolve these, but Python's ``ipaddress`` raises
|
||||
ValueError on them, so they would slip past the checks below.
|
||||
- Cloud-provider metadata endpoints — the high-value SSRF target, and
|
||||
never a legitimate destination for any of these services.
|
||||
- Multicast and unspecified addresses — pointless as a destination and
|
||||
indicative of misuse.
|
||||
- IPv4-mapped IPv6 encodings of any of the above.
|
||||
|
||||
Symbolic hostnames are otherwise accepted without DNS resolution, matching
|
||||
the public-internet guard: resolution here would be both a TOCTOU (DNS can
|
||||
change between validation and request) and a request the validator
|
||||
shouldn't be making. The one exception is the fixed set of cloud-metadata
|
||||
hostnames, which is a literal-string match and needs no resolution.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme.lower() not in ("http", "https"):
|
||||
raise ValueError(f"{label} must use http or https")
|
||||
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
|
||||
# "http:///path" parses to an empty hostname. Never a valid destination,
|
||||
# and without this it falls through the ip_address() ValueError branch
|
||||
# below and is accepted as if it were a symbolic hostname.
|
||||
if not hostname:
|
||||
raise ValueError(f"{label} must include a hostname")
|
||||
|
||||
if hostname in CLOUD_METADATA_HOSTNAMES:
|
||||
raise ValueError(f"{label} must not point to a cloud metadata endpoint")
|
||||
|
||||
if NUMERIC_IP_RE.match(hostname):
|
||||
raise ValueError(f"{label} must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
|
||||
|
||||
try:
|
||||
addr = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
return # symbolic hostname — out of scope by design (no DNS check)
|
||||
|
||||
effective = unwrap_ipv4_mapped(addr)
|
||||
|
||||
if effective in CLOUD_METADATA_IPS:
|
||||
raise ValueError(f"{label} must not point to a cloud metadata endpoint")
|
||||
|
||||
if effective.is_multicast or effective.is_unspecified:
|
||||
raise ValueError(f"{label} must not point to a multicast or unspecified address")
|
||||
|
|
|
|||
|
|
@ -65,9 +65,6 @@ async def create_api_key(
|
|||
can_read_status=data.can_read_status,
|
||||
can_manage_library=data.can_manage_library,
|
||||
can_manage_inventory=data.can_manage_inventory,
|
||||
can_manage_maintenance=data.can_manage_maintenance,
|
||||
can_manage_archives=data.can_manage_archives,
|
||||
can_manage_projects=data.can_manage_projects,
|
||||
can_access_cloud=data.can_access_cloud,
|
||||
can_update_energy_cost=data.can_update_energy_cost,
|
||||
printer_ids=data.printer_ids,
|
||||
|
|
@ -89,9 +86,6 @@ async def create_api_key(
|
|||
can_read_status=api_key.can_read_status,
|
||||
can_manage_library=api_key.can_manage_library,
|
||||
can_manage_inventory=api_key.can_manage_inventory,
|
||||
can_manage_maintenance=api_key.can_manage_maintenance,
|
||||
can_manage_archives=api_key.can_manage_archives,
|
||||
can_manage_projects=api_key.can_manage_projects,
|
||||
can_access_cloud=api_key.can_access_cloud,
|
||||
can_update_energy_cost=api_key.can_update_energy_cost,
|
||||
printer_ids=api_key.printer_ids,
|
||||
|
|
@ -145,12 +139,6 @@ async def update_api_key(
|
|||
api_key.can_manage_library = data.can_manage_library
|
||||
if data.can_manage_inventory is not None:
|
||||
api_key.can_manage_inventory = data.can_manage_inventory
|
||||
if data.can_manage_maintenance is not None:
|
||||
api_key.can_manage_maintenance = data.can_manage_maintenance
|
||||
if data.can_manage_archives is not None:
|
||||
api_key.can_manage_archives = data.can_manage_archives
|
||||
if data.can_manage_projects is not None:
|
||||
api_key.can_manage_projects = data.can_manage_projects
|
||||
if data.can_access_cloud is not None:
|
||||
# Same constraint as create — flipping cloud access on a legacy key
|
||||
# without an owner would be silently broken; reject at the route layer.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -15,6 +15,7 @@ from sqlalchemy.orm import selectinload
|
|||
|
||||
from backend.app.api.routes.settings import get_external_login_url
|
||||
from backend.app.core.auth import (
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
ALGORITHM,
|
||||
SECRET_KEY,
|
||||
Permission,
|
||||
|
|
@ -30,12 +31,10 @@ from backend.app.core.auth import (
|
|||
get_user_by_email,
|
||||
get_user_by_username,
|
||||
is_jti_revoked,
|
||||
resolve_session_max_minutes,
|
||||
revoke_jti,
|
||||
security,
|
||||
)
|
||||
from backend.app.core.database import async_session, get_db
|
||||
from backend.app.core.oidc_env import env_bool
|
||||
from backend.app.core.permissions import ALL_PERMISSIONS
|
||||
from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
|
||||
from backend.app.models.group import Group
|
||||
|
|
@ -113,23 +112,6 @@ _TRUSTED_PROXY_IPS: frozenset[str] = frozenset(
|
|||
)
|
||||
|
||||
|
||||
# #1589: read at call time, not import time, so tests can monkeypatch os.environ
|
||||
# between cases without re-importing the module.
|
||||
def _local_login_env_bypass() -> bool:
|
||||
"""Return True when ``BAMBUDDY_LOCAL_LOGIN`` env var is set truthy.
|
||||
|
||||
Bypasses the ``local_login_enabled`` DB setting on the local-credentials
|
||||
code path AND the forgot-password endpoint so a server admin can recover
|
||||
an install whose SSO provider is unreachable. Accepted truthy values:
|
||||
``true``, ``1``, ``yes`` (case-insensitive).
|
||||
"""
|
||||
# strict=False: this runs on the login/forgot-password request path, not at
|
||||
# startup. An unrecognized value must fall back to "off" (the safe default),
|
||||
# never raise -- a 500 on the recovery endpoint is the opposite of what this
|
||||
# bypass is for.
|
||||
return env_bool("BAMBUDDY_LOCAL_LOGIN", False, strict=False)
|
||||
|
||||
|
||||
def _get_client_ip(request: Request) -> str:
|
||||
"""Return the real client IP for rate-limiting purposes.
|
||||
|
||||
|
|
@ -194,15 +176,9 @@ async def set_advanced_auth_enabled(db: AsyncSession, enabled: bool) -> None:
|
|||
|
||||
async def set_auth_enabled(db: AsyncSession, enabled: bool) -> None:
|
||||
"""Set authentication enabled status."""
|
||||
from backend.app.core.auth import invalidate_auth_enabled_cache
|
||||
from backend.app.core.db_dialect import upsert_setting
|
||||
|
||||
await upsert_setting(db, Settings, "auth_enabled", "true" if enabled else "false")
|
||||
# Drop the cached auth-enabled flag so the change takes effect immediately
|
||||
# instead of after the TTL (issue #2572). Safe pre-commit: only enabled=True
|
||||
# is ever cached, and the newly-enabled True isn't visible to other sessions
|
||||
# until this transaction commits, so no stale value can be re-cached here.
|
||||
invalidate_auth_enabled_cache()
|
||||
# Note: Don't commit here - let get_db handle it or commit explicitly in the route
|
||||
|
||||
|
||||
|
|
@ -306,38 +282,6 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
|
|||
detail="Failed to create admin user",
|
||||
)
|
||||
|
||||
if request.auth_enabled:
|
||||
# Enabling auth flips cloud-credential storage from the global
|
||||
# Settings rows to User.cloud_token. Carry any token linked while
|
||||
# auth was off across to the owning admin, or /cloud/* silently
|
||||
# degrades to local presets with no indication anything broke
|
||||
# (#2530). Only migrate when there is exactly one obvious owner:
|
||||
# handing another admin's session a Bambu credential is not a
|
||||
# guess worth making.
|
||||
from backend.app.api.routes.cloud import (
|
||||
get_stored_token,
|
||||
migrate_global_cloud_token_to_user,
|
||||
)
|
||||
|
||||
if admin_created:
|
||||
cloud_owner = admin_user
|
||||
elif len(existing_admin_users) == 1:
|
||||
cloud_owner = existing_admin_users[0]
|
||||
else:
|
||||
cloud_owner = None
|
||||
|
||||
if cloud_owner is not None:
|
||||
if await migrate_global_cloud_token_to_user(db, cloud_owner):
|
||||
logger.info("Migrated global Bambu Cloud credentials to admin '%s'", cloud_owner.username)
|
||||
else:
|
||||
global_token, _, _ = await get_stored_token(db, None)
|
||||
if global_token:
|
||||
logger.warning(
|
||||
"A Bambu Cloud account is linked globally but %s admins exist; "
|
||||
"leaving it unassigned. Re-link the account from Settings after login.",
|
||||
len(existing_admin_users),
|
||||
)
|
||||
|
||||
# Set auth enabled and mark setup as completed
|
||||
await set_auth_enabled(db, request.auth_enabled)
|
||||
await set_setup_completed(db, True)
|
||||
|
|
@ -392,14 +336,6 @@ async def disable_auth(
|
|||
)
|
||||
|
||||
try:
|
||||
# Mirror of the migration in setup_auth: with auth off the cloud routes
|
||||
# read the global Settings rows and never look at User.cloud_token, so
|
||||
# hand this admin's credential over rather than stranding it (#2530).
|
||||
from backend.app.api.routes.cloud import migrate_user_cloud_token_to_global
|
||||
|
||||
if await migrate_user_cloud_token_to_global(db, user):
|
||||
logger.info("Migrated Bambu Cloud credentials from admin '%s' to global storage", user.username)
|
||||
|
||||
await set_auth_enabled(db, False)
|
||||
await db.commit()
|
||||
logger.info("Authentication disabled by admin user: %s", user.username)
|
||||
|
|
@ -442,13 +378,6 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
|
|||
client_ip = _get_client_ip(raw_request)
|
||||
await check_rate_limit(db, client_ip, event_type=EventType.LOGIN_IP, max_attempts=20)
|
||||
|
||||
# Initialize `user` up front so every downstream branch can read/write
|
||||
# it without UnboundLocalError. The LDAP success path sets it inside its
|
||||
# own block; the local-credentials and email-credentials paths set it
|
||||
# below. The original code relied on the local-credentials path running
|
||||
# unconditionally to bind `user`; #1589 made that path skippable, so the
|
||||
# init has to live here.
|
||||
user = None
|
||||
# Check if LDAP is enabled
|
||||
ldap_user = None
|
||||
ldap_settings = await _get_ldap_settings(db)
|
||||
|
|
@ -486,30 +415,12 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
|
|||
logging.getLogger(__name__).warning("LDAP authentication error, falling back to local: %s", e)
|
||||
ldap_user = None
|
||||
|
||||
# #1589: local username/password gate. LDAP keeps its own switch
|
||||
# (ldap_enabled) and is not affected — a delegated directory has its
|
||||
# own policy and lockouts and is closer to SSO than to local creds.
|
||||
# The env-var BAMBUDDY_LOCAL_LOGIN=true bypasses this gate so a server
|
||||
# admin can recover an install whose SSO provider is unreachable
|
||||
# without editing the DB.
|
||||
from backend.app.models.settings import Settings as _Settings_for_local_login
|
||||
|
||||
local_login_allowed = ldap_user is not None or _local_login_env_bypass()
|
||||
if not local_login_allowed:
|
||||
setting_row = await db.execute(
|
||||
select(_Settings_for_local_login).where(_Settings_for_local_login.key == "local_login_enabled")
|
||||
)
|
||||
row = setting_row.scalar_one_or_none()
|
||||
# Default True when the row is absent — matches AppSettings default
|
||||
# so fresh installs and tests behave like every release before #1589.
|
||||
local_login_allowed = row is None or row.value.lower() == "true"
|
||||
|
||||
# Try username-based authentication (skip if already authenticated via LDAP)
|
||||
if not ldap_user and local_login_allowed:
|
||||
if not ldap_user:
|
||||
user = await authenticate_user(db, request.username, request.password)
|
||||
|
||||
# If username auth failed and advanced auth is enabled, try email-based authentication
|
||||
if not user and not ldap_user and local_login_allowed:
|
||||
if not user and not ldap_user:
|
||||
advanced_auth = await is_advanced_auth_enabled(db)
|
||||
if advanced_auth:
|
||||
user = await authenticate_user_by_email(db, request.username, request.password)
|
||||
|
|
@ -517,11 +428,6 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
|
|||
if not user:
|
||||
await record_failed_attempt(db, request.username, event_type=EventType.LOGIN_ATTEMPT)
|
||||
await record_failed_attempt(db, client_ip, event_type=EventType.LOGIN_IP)
|
||||
# Same generic 401 either way — never tell the client whether the
|
||||
# username exists or whether local login was disabled. The Settings
|
||||
# UI and /auth/advanced-auth/status are the channels for that state;
|
||||
# leaking it here would help credential-stuffing distinguish "local
|
||||
# disabled" from "wrong password" across an install fleet.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
|
|
@ -589,9 +495,8 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
|
|||
two_fa_methods=methods,
|
||||
)
|
||||
|
||||
# No 2FA — issue full token immediately. Session lifetime honours the
|
||||
# admin-configurable ceiling (#1706); resolver clamps to [1h, 720h].
|
||||
access_token_expires = timedelta(minutes=await resolve_session_max_minutes(db))
|
||||
# No 2FA — issue full token immediately
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(data={"sub": user.username}, expires_delta=access_token_expires)
|
||||
|
||||
return LoginResponse(
|
||||
|
|
@ -670,7 +575,7 @@ async def get_current_user_info(
|
|||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
jti: str | None = payload.get("jti")
|
||||
if not jti or await is_jti_revoked(jti, db): # B1: logout bypass fix
|
||||
if not jti or await is_jti_revoked(jti): # B1: logout bypass fix
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
|
|
@ -907,39 +812,12 @@ async def disable_advanced_auth(
|
|||
|
||||
@router.get("/advanced-auth/status")
|
||||
async def get_advanced_auth_status(db: AsyncSession = Depends(get_db)):
|
||||
"""Get advanced authentication status.
|
||||
|
||||
Surfaces ``local_login_enabled`` and ``autologin_provider_id`` (#1589)
|
||||
so the LoginPage can decide whether to render the credentials form and
|
||||
whether to redirect unauthenticated visitors directly to an SSO
|
||||
provider, in a single query. ``BAMBUDDY_LOCAL_LOGIN=true`` flips the
|
||||
reported value back to True so the recovery path is visible.
|
||||
"""
|
||||
from backend.app.models.oidc_provider import OIDCProvider
|
||||
from backend.app.models.settings import Settings as _Settings_for_local_login
|
||||
|
||||
"""Get advanced authentication status."""
|
||||
advanced_auth_enabled = await is_advanced_auth_enabled(db)
|
||||
smtp_configured = await get_smtp_settings(db) is not None
|
||||
|
||||
setting_row = await db.execute(
|
||||
select(_Settings_for_local_login).where(_Settings_for_local_login.key == "local_login_enabled")
|
||||
)
|
||||
row = setting_row.scalar_one_or_none()
|
||||
db_local_enabled = row is None or row.value.lower() == "true"
|
||||
local_login_enabled = db_local_enabled or _local_login_env_bypass()
|
||||
|
||||
# Autologin provider must be both flagged AND enabled — disabling a
|
||||
# provider should not silently keep redirecting visitors to it.
|
||||
autologin = await db.execute(
|
||||
select(OIDCProvider.id).where(OIDCProvider.is_autologin.is_(True), OIDCProvider.is_enabled.is_(True)).limit(1)
|
||||
)
|
||||
autologin_provider_id = autologin.scalar_one_or_none()
|
||||
|
||||
return {
|
||||
"advanced_auth_enabled": advanced_auth_enabled,
|
||||
"smtp_configured": smtp_configured,
|
||||
"local_login_enabled": local_login_enabled,
|
||||
"autologin_provider_id": autologin_provider_id,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1005,21 +883,6 @@ async def forgot_password(
|
|||
secure link instead of a plaintext temporary password. The new password is
|
||||
set only when the user clicks the link and POSTs to /forgot-password/confirm.
|
||||
"""
|
||||
# #1589: forgot-password is a local-credentials flow — useless when local
|
||||
# login is disabled (the reset wouldn't grant access anyway). Same gate as
|
||||
# /auth/login, with the same env-var bypass for SSO-broken recovery.
|
||||
if not _local_login_env_bypass():
|
||||
from backend.app.models.settings import Settings as _Settings_for_local_login
|
||||
|
||||
setting_row = await db.execute(
|
||||
select(_Settings_for_local_login).where(_Settings_for_local_login.key == "local_login_enabled")
|
||||
)
|
||||
row = setting_row.scalar_one_or_none()
|
||||
if row is not None and row.value.lower() != "true":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Local login is disabled — use SSO instead.",
|
||||
)
|
||||
# Check if advanced auth is enabled
|
||||
advanced_auth = await is_advanced_auth_enabled(db)
|
||||
if not advanced_auth:
|
||||
|
|
@ -1628,14 +1491,10 @@ async def provision_ldap_user(
|
|||
# =============================================================================
|
||||
# Long-lived camera-stream tokens (#1108)
|
||||
# =============================================================================
|
||||
# A token a user can paste into Home Assistant / Frigate / a kiosk and have it
|
||||
# keep working for days/weeks rather than refreshing the 60-minute ephemeral
|
||||
# token. Permission gate: CAMERA_VIEW (same blast radius as the existing 60-min
|
||||
# token-mint endpoint).
|
||||
#
|
||||
# Two scopes, both minted here — see ALLOWED_SCOPES in services/long_lived_tokens
|
||||
# for what each one reaches: "camera_stream" (video only) and "camwall" (video
|
||||
# plus the Cam Wall's read-only tile metadata, #2531).
|
||||
# Camera-only V1. Issue scope: a token a user can paste into Home Assistant /
|
||||
# Frigate / a kiosk and have it keep working for days/weeks rather than
|
||||
# refreshing the 60-minute ephemeral token. Permission gate: CAMERA_VIEW
|
||||
# (same blast radius as the existing 60-min token-mint endpoint).
|
||||
|
||||
|
||||
def _long_lived_token_to_response(record, *, plaintext: str | None = None) -> dict:
|
||||
|
|
|
|||
32
backend/app/api/routes/background_dispatch.py
Normal file
32
backend/app/api/routes/background_dispatch.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.background_dispatch import background_dispatch
|
||||
|
||||
router = APIRouter(prefix="/background-dispatch", tags=["background-dispatch"])
|
||||
|
||||
|
||||
@router.delete("/{job_id}")
|
||||
async def cancel_dispatch_job(
|
||||
job_id: int,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
|
||||
):
|
||||
"""Cancel a background-dispatch job.
|
||||
|
||||
Queued jobs are cancelled immediately. Active jobs are marked for
|
||||
cooperative cancellation and will stop at the next cancellation checkpoint.
|
||||
"""
|
||||
result = await background_dispatch.cancel_job(job_id)
|
||||
|
||||
if not result["cancelled"]:
|
||||
raise HTTPException(status_code=404, detail="Dispatch job not found")
|
||||
|
||||
return {
|
||||
"status": "cancelling" if result.get("pending") else "cancelled",
|
||||
"job_id": result["job_id"],
|
||||
"source_name": result["source_name"],
|
||||
"printer_id": result["printer_id"],
|
||||
"printer_name": result["printer_name"],
|
||||
}
|
||||
|
|
@ -1,13 +1,10 @@
|
|||
"""Camera streaming API endpoints for Bambu Lab printers."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
|
@ -15,14 +12,12 @@ from fastapi.responses import Response, StreamingResponse
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core import database
|
||||
from backend.app.core.auth import (
|
||||
RequireCameraStreamTokenIfAuthEnabled,
|
||||
RequirePermissionIfAuthEnabled,
|
||||
create_camera_stream_token,
|
||||
)
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.logging_filters import redact_url_credentials
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.user import User
|
||||
|
|
@ -40,7 +35,6 @@ from backend.app.services.camera import (
|
|||
from backend.app.services.camera_fanout import (
|
||||
MjpegBroadcaster,
|
||||
get_or_create_broadcaster,
|
||||
get_subscriber_count,
|
||||
iter_subscriber,
|
||||
shutdown_broadcaster,
|
||||
)
|
||||
|
|
@ -49,27 +43,6 @@ from backend.app.services.camera_profiles import get_camera_profile
|
|||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/printers", tags=["camera"])
|
||||
|
||||
# Grace period for a SIGTERMed ffmpeg to shut down before we SIGKILL it. Only
|
||||
# reachable when ffmpeg genuinely ignores SIGTERM: _terminate_ffmpeg drains the
|
||||
# pipes first, and a drained ffmpeg exits in ~0.15s.
|
||||
_FFMPEG_TERM_TIMEOUT = 2.0
|
||||
|
||||
# Upper bound on waiting for a SIGKILLed ffmpeg to be reaped (#2580).
|
||||
#
|
||||
# The original diagnosis — "a killed ffmpeg stuck in uninterruptible I/O on a
|
||||
# dead RTSP socket" — was wrong, and this bound was capping a deadlock of our
|
||||
# own making rather than waiting out a stuck process. A process that survives
|
||||
# SIGKILL would have to be in uninterruptible sleep (state D); the ffmpeg seen
|
||||
# doing this was in state S, and its returncode was already set to -9 while
|
||||
# wait() was still blocked. The real cause was undrained pipes (see
|
||||
# _terminate_ffmpeg), which made this timeout fire on *every* camera close.
|
||||
#
|
||||
# Kept as a backstop now that the cause is fixed: it should no longer be
|
||||
# reachable, and if it ever is, abandoning the wait is still safe because
|
||||
# cleanup_orphaned_streams' /proc scan reaps any Bambu ffmpeg not attached to
|
||||
# an active stream on its next pass.
|
||||
_FFMPEG_KILL_TIMEOUT = 2.0
|
||||
|
||||
# Track active ffmpeg processes for cleanup
|
||||
_active_streams: dict[str, asyncio.subprocess.Process] = {}
|
||||
|
||||
|
|
@ -99,14 +72,6 @@ _disconnect_events: dict[str, asyncio.Event] = {}
|
|||
# Track last frame time per stream_id (not just per printer_id) for stale detection
|
||||
_stream_last_frame_times: dict[str, float] = {}
|
||||
|
||||
# How much of a streaming ffmpeg's stderr to retain: enough for the input
|
||||
# analysis plus a burst of errors, capped so a long-running stream can't grow it.
|
||||
_FFMPEG_STDERR_TAIL_BYTES = 16384
|
||||
|
||||
# Live stderr collectors by pid — see _FfmpegStderrTail. Present means "this
|
||||
# process's stderr already has a reader; do not open a second one".
|
||||
_stderr_tails: dict[int, "_FfmpegStderrTail"] = {}
|
||||
|
||||
|
||||
def get_buffered_frame(printer_id: int) -> bytes | None:
|
||||
"""Get the last buffered frame for a printer from an active stream.
|
||||
|
|
@ -218,6 +183,8 @@ async def generate_chamber_mjpeg_stream(
|
|||
|
||||
# Save frame to buffer for photo capture and track timestamp
|
||||
if printer_id is not None:
|
||||
import time
|
||||
|
||||
_last_frames[printer_id] = frame
|
||||
_last_frame_times[printer_id] = time.time()
|
||||
|
||||
|
|
@ -249,7 +216,10 @@ async def generate_chamber_mjpeg_stream(
|
|||
_stream_last_frame_times.pop(stream_id, None)
|
||||
|
||||
# Clean up frame buffer and timestamps
|
||||
_release_printer_frame_state(printer_id)
|
||||
if printer_id is not None:
|
||||
_last_frames.pop(printer_id, None)
|
||||
_last_frame_times.pop(printer_id, None)
|
||||
_stream_start_times.pop(printer_id, None)
|
||||
|
||||
# Close the connection
|
||||
try:
|
||||
|
|
@ -260,151 +230,23 @@ async def generate_chamber_mjpeg_stream(
|
|||
logger.info("Chamber image stream stopped for %s (stream_id=%s)", ip_address, stream_id)
|
||||
|
||||
|
||||
def _new_fanout_stream_id(printer_id: int) -> str:
|
||||
"""Registry key for one fan-out stream INSTANCE, not for the printer.
|
||||
|
||||
A plain ``f"{printer_id}-fanout"`` meant every successive stream for a
|
||||
printer shared one key, so a departing generator's cleanup removed the entry
|
||||
its successor had just registered. The external-camera path already carries a
|
||||
per-instance suffix for exactly this reason (#2675); this gives the fan-out
|
||||
path the same property.
|
||||
|
||||
The ``f"{printer_id}-"`` prefix is load-bearing — ``is_stream_active``,
|
||||
``stop_camera_stream`` and ``/camera/status`` all find a printer's streams by
|
||||
scanning for it — so the suffix goes on the end.
|
||||
"""
|
||||
return f"{printer_id}-fanout-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def live_frame_for_capture(printer_id: int) -> tuple[bool, bytes | None]:
|
||||
"""Should a one-shot capture stand down for the live view, and to what frame?
|
||||
|
||||
Returns ``(defer, frame)``. ``defer`` True means DO NOT open a capture of
|
||||
your own: use ``frame`` when it isn't None, and otherwise skip this attempt
|
||||
rather than competing.
|
||||
|
||||
Both camera kinds allow exactly one reader — Bambu firmware permits one
|
||||
connection, and a USB camera permits one V4L2 handle — so a capture that
|
||||
races the live view doesn't degrade, it fails outright. #2707 measured 0 of
|
||||
87 and 0 of 105 layer-timelapse captures on prints watched throughout, and
|
||||
finish photos going out with no image attached.
|
||||
|
||||
Skipping when the buffer is momentarily empty (stream starting, mid-
|
||||
reconnect) rather than falling through to a capture is the #1348 rule:
|
||||
opening a competing handle kicks the viewer off, which is a worse outcome
|
||||
than missing one frame.
|
||||
"""
|
||||
if not is_stream_active(printer_id):
|
||||
return False, None
|
||||
return True, _last_frames.get(printer_id)
|
||||
|
||||
|
||||
def _release_printer_frame_state(printer_id: int | None) -> None:
|
||||
"""Drop a printer's buffered frame and timings — unless a stream still owns them.
|
||||
|
||||
These three dicts are keyed by printer, not by stream, so a departing
|
||||
generator must not clear them while a newer stream for the same printer is
|
||||
running. That used to happen routinely: stream ids were per-printer, so a
|
||||
predecessor's cleanup wiped its successor's state, leaving
|
||||
``is_stream_active()`` False with a viewer attached (which is exactly what
|
||||
the #1348 / #1271 guards read before deciding whether it is safe to open a
|
||||
second camera connection), the janitor free to reap the live ffmpeg as an
|
||||
orphan, and snapshots without a frame to reuse.
|
||||
|
||||
Call this AFTER removing the departing stream's own key, so the check
|
||||
reports on other streams rather than on the caller.
|
||||
"""
|
||||
if printer_id is None or is_stream_active(printer_id):
|
||||
return
|
||||
_last_frames.pop(printer_id, None)
|
||||
_last_frame_times.pop(printer_id, None)
|
||||
_stream_start_times.pop(printer_id, None)
|
||||
|
||||
|
||||
async def _drain_pipe(reader) -> None:
|
||||
"""Read a subprocess pipe to EOF and discard, so it can never block.
|
||||
|
||||
Best-effort by design: any read failure means we cannot drain further, and
|
||||
the caller is tearing the process down regardless.
|
||||
"""
|
||||
if reader is None:
|
||||
return
|
||||
try:
|
||||
while await reader.read(65536):
|
||||
pass
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 — teardown must not fail on a dying pipe
|
||||
return
|
||||
|
||||
|
||||
async def _terminate_ffmpeg(process: asyncio.subprocess.Process, stream_id: str | None = None) -> None:
|
||||
"""Terminate an ffmpeg process gracefully, then kill if needed.
|
||||
|
||||
Drains stdout/stderr throughout, which is load-bearing rather than hygiene.
|
||||
ffmpeg is spawned with both as pipes, and every caller of this has already
|
||||
stopped reading stdout — so by the time we get here ffmpeg is typically
|
||||
blocked in write() on a full 64 KiB pipe. Two things then go wrong:
|
||||
|
||||
* SIGTERM cannot be acted on. ffmpeg's handler only sets a flag that its
|
||||
main loop polls, and a loop blocked in write() never reaches the check,
|
||||
so the whole grace period is dead time.
|
||||
* SIGKILL does kill it, but wait() cannot observe that. asyncio resolves
|
||||
Process.wait()'s waiter through BaseSubprocessTransport._try_finish(),
|
||||
which requires every pipe transport to report disconnected; paused,
|
||||
unread pipes never reach EOF, so wait() blocks with returncode already
|
||||
set. That is what made the "did not exit within Ns of SIGKILL" error
|
||||
fire on every single camera close, and unbounded it was the 12-hour
|
||||
hang in #2580.
|
||||
|
||||
Draining fixes both: SIGTERM becomes actionable and the exit observable.
|
||||
Measured on an H2D: 4.0s of dead time per close before, ~0.15s after —
|
||||
which matters because the printer allows exactly one camera connection,
|
||||
so every one of those seconds was a connection nobody could use.
|
||||
|
||||
Discarding what we drain is deliberate. The stream loop already reads
|
||||
stderr on its error paths (_read_ffmpeg_stderr), and it does so before
|
||||
calling this, so nothing diagnostic is lost.
|
||||
"""
|
||||
"""Terminate an ffmpeg process gracefully, then kill if needed."""
|
||||
if process.returncode is not None:
|
||||
_spawned_ffmpeg_pids.pop(process.pid, None)
|
||||
return # Already dead
|
||||
|
||||
drainers = [asyncio.create_task(_drain_pipe(process.stdout))]
|
||||
# A streaming ffmpeg's stderr already has a reader (_FfmpegStderrTail), and
|
||||
# it keeps draining right through teardown, which is all we need here. Adding
|
||||
# a second reader would race it — asyncio rejects concurrent reads on one
|
||||
# StreamReader — so only drain stderr when nobody else owns it.
|
||||
if process.pid not in _stderr_tails:
|
||||
drainers.append(asyncio.create_task(_drain_pipe(process.stderr)))
|
||||
try:
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=_FFMPEG_TERM_TIMEOUT)
|
||||
await asyncio.wait_for(process.wait(), timeout=2.0)
|
||||
except TimeoutError:
|
||||
logger.warning("ffmpeg didn't terminate gracefully, killing (stream_id=%s)", stream_id)
|
||||
process.kill()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=_FFMPEG_KILL_TIMEOUT)
|
||||
except TimeoutError:
|
||||
# Do NOT keep waiting (#2580): the caller is the stream
|
||||
# generator, and blocking here pins the fan-out pump forever.
|
||||
# The orphan janitor reaps the process later. With the pipes
|
||||
# drained this should be unreachable — see _FFMPEG_KILL_TIMEOUT.
|
||||
logger.error(
|
||||
"ffmpeg did not exit within %.1fs of SIGKILL; abandoning wait (stream_id=%s)",
|
||||
_FFMPEG_KILL_TIMEOUT,
|
||||
stream_id,
|
||||
)
|
||||
await process.wait()
|
||||
except ProcessLookupError:
|
||||
pass # Already dead
|
||||
except OSError as e:
|
||||
logger.warning("Error terminating ffmpeg: %s", e)
|
||||
finally:
|
||||
for drainer in drainers:
|
||||
drainer.cancel()
|
||||
await asyncio.gather(*drainers, return_exceptions=True)
|
||||
_spawned_ffmpeg_pids.pop(process.pid, None)
|
||||
_spawned_ffmpeg_pids.pop(process.pid, None)
|
||||
|
||||
|
||||
def _summarize_ffmpeg_stderr(text: str | None) -> str:
|
||||
|
|
@ -414,15 +256,9 @@ def _summarize_ffmpeg_stderr(text: str | None) -> str:
|
|||
any actual error message. Logging the full banner on every retry floods
|
||||
the log (hundreds of lines per failed stream). This filter drops the
|
||||
banner and caps output at the last 10 meaningful lines.
|
||||
|
||||
Credentials are masked here rather than at each ``logger`` call because
|
||||
this is the one funnel every stderr log in this module passes through.
|
||||
ffmpeg echoes the RTSP input URL back in its ``Input #0`` line, which
|
||||
carries the printer access code.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
text = redact_url_credentials(text) or ""
|
||||
banner_prefixes = (
|
||||
"ffmpeg version ",
|
||||
" built with ",
|
||||
|
|
@ -440,82 +276,6 @@ def _summarize_ffmpeg_stderr(text: str | None) -> str:
|
|||
return "\n".join(meaningful[-10:])
|
||||
|
||||
|
||||
class _FfmpegStderrTail:
|
||||
"""Owns a long-lived ffmpeg's stderr: drains it continuously, keeps the tail.
|
||||
|
||||
Reading stderr only when something has already gone wrong leaves a pipe
|
||||
nobody reads for the whole life of the stream. ffmpeg writes its banner, the
|
||||
input analysis and then a progress line at a steady rate, so a 64 KiB pipe
|
||||
fills eventually and ffmpeg blocks writing to it — at which point it stops
|
||||
producing frames, the stream's own read timeout fires, and the log says
|
||||
"RTSP read timeout" with no hint that we starved it ourselves.
|
||||
|
||||
How long that takes is unmeasured and may be a long time: one H2D upstream
|
||||
ran 21m36s continuously without stalling, so this is a bounded resource
|
||||
being treated as unbounded rather than an observed failure. Draining removes
|
||||
the ceiling either way, and the tail is *better* diagnostic material than
|
||||
the old on-demand read: it holds ffmpeg's most recent output at the moment
|
||||
things went wrong, where reading the buffered pipe returned whatever was
|
||||
printed first (usually the startup banner, which the summariser then strips).
|
||||
|
||||
Registers itself in ``_stderr_tails`` so the two other readers of this pipe
|
||||
can defer to it — asyncio raises if two coroutines read one StreamReader
|
||||
concurrently. See ``_read_ffmpeg_stderr`` and ``_terminate_ffmpeg``.
|
||||
"""
|
||||
|
||||
def __init__(self, process: asyncio.subprocess.Process) -> None:
|
||||
self._process = process
|
||||
self._buffer = bytearray()
|
||||
self._task: asyncio.Task | None = None
|
||||
if process.stderr is None:
|
||||
return
|
||||
self._task = asyncio.create_task(self._pump())
|
||||
_stderr_tails[process.pid] = self
|
||||
|
||||
async def _pump(self) -> None:
|
||||
reader = self._process.stderr
|
||||
try:
|
||||
while True:
|
||||
chunk = await reader.read(8192)
|
||||
if not chunk:
|
||||
return # EOF — ffmpeg has exited
|
||||
self._buffer.extend(chunk)
|
||||
excess = len(self._buffer) - _FFMPEG_STDERR_TAIL_BYTES
|
||||
if excess > 0:
|
||||
del self._buffer[:excess]
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 — a broken pipe just ends the tail
|
||||
return
|
||||
|
||||
def text(self) -> str | None:
|
||||
"""The retained tail, summarised. None when nothing was captured.
|
||||
|
||||
Goes through _summarize_ffmpeg_stderr like every other stderr log in
|
||||
this module: ffmpeg echoes its input URL, which carries the access code.
|
||||
"""
|
||||
if not self._buffer:
|
||||
return None
|
||||
return _summarize_ffmpeg_stderr(self._buffer.decode(errors="replace")) or None
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Stop draining and release ownership of the pipe. Idempotent.
|
||||
|
||||
Awaits the cancelled pump rather than firing and forgetting, so the task
|
||||
is finished before the caller moves on — an abandoned pending task
|
||||
becomes an "unraisable exception" warning at an arbitrary later point,
|
||||
usually during interpreter or loop teardown.
|
||||
"""
|
||||
task, self._task = self._task, None
|
||||
if _stderr_tails.get(self._process.pid) is self:
|
||||
del _stderr_tails[self._process.pid]
|
||||
if task is None:
|
||||
return
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
async def _read_ffmpeg_stderr(process: asyncio.subprocess.Process) -> str | None:
|
||||
"""Read whatever ffmpeg has written to stderr so far (best-effort).
|
||||
|
||||
|
|
@ -526,18 +286,8 @@ async def _read_ffmpeg_stderr(process: asyncio.subprocess.Process) -> str | None
|
|||
banner + stream-analysis lines ffmpeg already printed. Reading in bounded
|
||||
chunks returns the buffered output promptly whether or not ffmpeg has
|
||||
exited. Returns the content with ffmpeg's boilerplate banner stripped.
|
||||
|
||||
When a _FfmpegStderrTail owns this process's stderr — every streaming
|
||||
ffmpeg — its retained tail is returned instead. Reading the pipe here as
|
||||
well would race that collector, and asyncio refuses two concurrent readers
|
||||
on one StreamReader outright.
|
||||
"""
|
||||
if not process:
|
||||
return None
|
||||
tail = _stderr_tails.get(getattr(process, "pid", None))
|
||||
if tail is not None:
|
||||
return tail.text()
|
||||
if not process.stderr:
|
||||
if not process or not process.stderr:
|
||||
return None
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
|
|
@ -658,7 +408,6 @@ async def generate_rtsp_mjpeg_stream(
|
|||
jpeg_end = b"\xff\xd9"
|
||||
reconnect_count = 0
|
||||
process = None
|
||||
stderr_tail: _FfmpegStderrTail | None = None
|
||||
got_any_frames = False
|
||||
|
||||
try:
|
||||
|
|
@ -711,14 +460,6 @@ async def generate_rtsp_mjpeg_stream(
|
|||
reconnect_count += 1
|
||||
continue
|
||||
|
||||
# Take ownership of stderr for the life of this process. Started
|
||||
# only after the immediate-failure check above, which reads the pipe
|
||||
# directly (correct there: the process is already dead, so
|
||||
# read-to-EOF returns at once and cannot be raced by a collector).
|
||||
# Nothing is lost by starting late — the banner ffmpeg printed in the
|
||||
# meantime is still sitting in the pipe.
|
||||
stderr_tail = _FfmpegStderrTail(process)
|
||||
|
||||
# Read JPEG frames from ffmpeg stdout
|
||||
buffer = b""
|
||||
stream_ended = False
|
||||
|
|
@ -762,6 +503,8 @@ async def generate_rtsp_mjpeg_stream(
|
|||
got_any_frames = True
|
||||
|
||||
if printer_id is not None:
|
||||
import time
|
||||
|
||||
_last_frames[printer_id] = frame
|
||||
_last_frame_times[printer_id] = time.time()
|
||||
if stream_id:
|
||||
|
|
@ -792,12 +535,6 @@ async def generate_rtsp_mjpeg_stream(
|
|||
|
||||
# Clean up this ffmpeg process before reconnecting or exiting
|
||||
await _terminate_ffmpeg(process, stream_id)
|
||||
# Released after teardown, not before: _terminate_ffmpeg deliberately
|
||||
# leaves stderr to this collector, which has to keep draining while
|
||||
# the process is stopped or wait() can't observe the exit.
|
||||
if stderr_tail is not None:
|
||||
await stderr_tail.aclose()
|
||||
stderr_tail = None
|
||||
process = None
|
||||
|
||||
if client_gone:
|
||||
|
|
@ -840,16 +577,15 @@ async def generate_rtsp_mjpeg_stream(
|
|||
_stream_last_frame_times.pop(stream_id, None)
|
||||
|
||||
# Clean up frame buffer and timestamps
|
||||
_release_printer_frame_state(printer_id)
|
||||
if printer_id is not None:
|
||||
_last_frames.pop(printer_id, None)
|
||||
_last_frame_times.pop(printer_id, None)
|
||||
_stream_start_times.pop(printer_id, None)
|
||||
|
||||
if process:
|
||||
await _terminate_ffmpeg(process, stream_id)
|
||||
logger.info("Camera stream stopped for %s (stream_id=%s)", ip_address, stream_id)
|
||||
|
||||
# Same order as in the loop: terminate first, then release stderr.
|
||||
if stderr_tail is not None:
|
||||
await stderr_tail.aclose()
|
||||
|
||||
# Shut down the TLS proxy
|
||||
proxy_server.close()
|
||||
await proxy_server.wait_closed()
|
||||
|
|
@ -872,6 +608,7 @@ async def camera_stream(
|
|||
printer_id: int,
|
||||
request: Request,
|
||||
fps: int = 10,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: None = RequireCameraStreamTokenIfAuthEnabled,
|
||||
):
|
||||
"""Stream live video from printer camera as MJPEG.
|
||||
|
|
@ -890,30 +627,12 @@ async def camera_stream(
|
|||
printer_id: Printer ID
|
||||
fps: Target frames per second (default: 10, max: 30)
|
||||
"""
|
||||
# Fetch the printer in a short-lived session so the pooled DB connection is
|
||||
# released BEFORE we start streaming. A live MJPEG stream runs for as long
|
||||
# as the browser tab stays open (potentially hours); holding the
|
||||
# Depends(get_db) session across it pinned one pooled connection per open
|
||||
# camera tab per printer — a top contributor to pool exhaustion on large
|
||||
# farms (issue #2572). expire_on_commit=False keeps the printer's already-
|
||||
# loaded columns readable after the session closes, and everything below
|
||||
# reads only scalar attributes (model, ip_address, access_code,
|
||||
# external_camera_*) — no lazy loads.
|
||||
#
|
||||
# Reference async_session via the module (not a top-level import binding) so
|
||||
# the session maker is looked up at call time — that keeps it in sync with
|
||||
# reinitialize_database() and lets the test harness's patch of
|
||||
# backend.app.core.database.async_session take effect here.
|
||||
async with database.async_session() as db:
|
||||
printer = await get_printer_or_404(printer_id, db)
|
||||
printer = await get_printer_or_404(printer_id, db)
|
||||
|
||||
# Check for external camera first
|
||||
if printer.external_camera_enabled and printer.external_camera_url:
|
||||
# NB: no `import time` / `import uuid` here, and don't reintroduce them.
|
||||
# A local import anywhere in this function makes the name function-local
|
||||
# for the WHOLE function, so the RTSP/chamber path below — which never
|
||||
# executes this branch — would raise UnboundLocalError on any printer
|
||||
# without an external camera. Both are imported at module level.
|
||||
import time
|
||||
|
||||
from backend.app.services.external_camera import generate_mjpeg_stream
|
||||
|
||||
# Limit external camera FPS to reduce browser load
|
||||
|
|
@ -922,79 +641,22 @@ async def camera_stream(
|
|||
"Using external camera (%s) for printer %s at %s fps", printer.external_camera_type, printer_id, fps
|
||||
)
|
||||
|
||||
# Register the stream into the SAME registries the RTSP/chamber paths use
|
||||
# (#2675) so `/camera/stop` and cleanup_orphaned_streams can find and kill
|
||||
# a leaked ffmpeg holding a USB device open. Before this, external streams
|
||||
# only tracked _active_external_streams and were structurally invisible to
|
||||
# both the stop endpoint and the janitor. The stream_id keeps the
|
||||
# `{printer_id}-` prefix both scanners key on, plus a unique suffix so two
|
||||
# concurrent viewers of one printer don't clobber each other's entry.
|
||||
stream_id = f"{printer_id}-ext-{uuid.uuid4().hex[:8]}"
|
||||
stop_event = asyncio.Event()
|
||||
_disconnect_events[stream_id] = stop_event
|
||||
# Track stream start
|
||||
_stream_start_times[printer_id] = time.time()
|
||||
_active_external_streams.add(printer_id)
|
||||
|
||||
# Mutable holder so the wrapper's finally can unregister whatever process
|
||||
# is currently registered (the RTSP path may respawn across reconnects).
|
||||
current_proc: dict[str, asyncio.subprocess.Process] = {}
|
||||
|
||||
def _register_external_process(proc: asyncio.subprocess.Process) -> None:
|
||||
prev = current_proc.get("proc")
|
||||
if prev is not None and prev.pid != proc.pid:
|
||||
_spawned_ffmpeg_pids.pop(prev.pid, None)
|
||||
current_proc["proc"] = proc
|
||||
_active_streams[stream_id] = proc
|
||||
_spawned_ffmpeg_pids[proc.pid] = time.time()
|
||||
_stream_last_frame_times[stream_id] = time.time()
|
||||
|
||||
def _publish_external_frame(frame: bytes) -> None:
|
||||
"""Make the live frame reusable by one-shot consumers (#2707).
|
||||
|
||||
Only the built-in camera paths populated _last_frames, so every
|
||||
external-camera consumer — layer timelapse, finish photo, Obico,
|
||||
plate check — found an empty buffer and opened its own handle on a
|
||||
device that allows exactly one reader, which simply failed while a
|
||||
viewer was attached. Raw frame, not the multipart-wrapped chunk the
|
||||
generator yields, because that is what those consumers expect.
|
||||
"""
|
||||
_last_frames[printer_id] = frame
|
||||
|
||||
async def external_stream_wrapper():
|
||||
"""Wrap external stream to track start/stop and update frame times."""
|
||||
try:
|
||||
async for frame in generate_mjpeg_stream(
|
||||
printer.external_camera_url,
|
||||
printer.external_camera_type,
|
||||
fps,
|
||||
on_process=_register_external_process,
|
||||
on_frame=_publish_external_frame,
|
||||
stop_event=stop_event,
|
||||
printer.external_camera_url, printer.external_camera_type, fps
|
||||
):
|
||||
# generate_mjpeg_stream already handles rate limiting;
|
||||
# track frame times (per-printer + per-stream) for stall detection
|
||||
now = time.time()
|
||||
_last_frame_times[printer_id] = now
|
||||
_stream_last_frame_times[stream_id] = now
|
||||
# just track frame times for stall detection
|
||||
_last_frame_times[printer_id] = time.time()
|
||||
yield frame
|
||||
finally:
|
||||
# Best-effort unregister. If an abrupt disconnect skips this
|
||||
# finally, the registry entries persist — which is exactly what
|
||||
# lets the stop endpoint / janitor reap the leaked process.
|
||||
stop_event.set()
|
||||
proc = current_proc.get("proc")
|
||||
if proc is not None:
|
||||
_spawned_ffmpeg_pids.pop(proc.pid, None)
|
||||
_active_streams.pop(stream_id, None)
|
||||
_disconnect_events.pop(stream_id, None)
|
||||
_stream_last_frame_times.pop(stream_id, None)
|
||||
_active_external_streams.discard(printer_id)
|
||||
# Now that this path publishes a buffered frame, it has to
|
||||
# retract it too — ownership-checked, so a concurrent viewer of
|
||||
# the same printer keeps its own. Also clears the per-printer
|
||||
# timings this path used to leave behind.
|
||||
_release_printer_frame_state(printer_id)
|
||||
logger.info("External camera stream ended for printer %s", printer_id)
|
||||
|
||||
return StreamingResponse(
|
||||
|
|
@ -1026,6 +688,8 @@ async def camera_stream(
|
|||
# attached — otherwise /camera/status would report stream_uptime jumping
|
||||
# backward whenever a second viewer joins. The upstream generator's
|
||||
# finally clears this entry when the upstream actually ends.
|
||||
import time
|
||||
|
||||
_stream_start_times.setdefault(printer_id, time.time())
|
||||
|
||||
# Fan-out broadcaster (#1089): one upstream connection per printer, shared
|
||||
|
|
@ -1038,7 +702,7 @@ async def camera_stream(
|
|||
# broadcaster. Concurrent viewers share that rate; new viewers after
|
||||
# teardown create a fresh broadcaster at their requested fps.
|
||||
fanout_key = f"printer-{printer_id}"
|
||||
upstream_stream_id = _new_fanout_stream_id(printer_id)
|
||||
upstream_stream_id = f"{printer_id}-fanout"
|
||||
|
||||
def _factory(disconnect_event: asyncio.Event):
|
||||
# Re-bind locals into the closure so the async generator below sees
|
||||
|
|
@ -1107,36 +771,17 @@ async def stop_camera_stream(
|
|||
printer_id: int,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
|
||||
):
|
||||
"""Stop active camera streams for a printer.
|
||||
"""Stop all active camera streams for a printer.
|
||||
|
||||
Called by the frontend on viewer unmount (cam-wall tile, embedded viewer,
|
||||
popup window). Accepts both GET and POST (POST for sendBeacon compatibility).
|
||||
|
||||
Reference-count guard: every viewer of a printer subscribes to the same
|
||||
fan-out broadcaster, so a force-shutdown triggered by ONE leaving viewer
|
||||
used to kill the others' streams (cam-wall tile froze when a user opened
|
||||
then closed the embedded viewer). If any subscriber is still attached,
|
||||
skip the force-teardown — the broadcaster's natural grace-shutdown (5 s
|
||||
after subscribers drop to 0) handles cleanup when the leaving viewer's
|
||||
HTTP connection actually closes.
|
||||
This can be called by the frontend when the camera window is closed.
|
||||
Accepts both GET and POST (POST for sendBeacon compatibility).
|
||||
"""
|
||||
broadcaster_key = f"printer-{printer_id}"
|
||||
remaining_subscribers = get_subscriber_count(broadcaster_key)
|
||||
if remaining_subscribers >= 1:
|
||||
logger.info(
|
||||
"Skipping force-shutdown for printer %s: %d subscriber(s) still attached; "
|
||||
"natural cleanup will tear down when last viewer disconnects",
|
||||
printer_id,
|
||||
remaining_subscribers,
|
||||
)
|
||||
return {"stopped": 0, "skipped": True}
|
||||
|
||||
stopped = 0
|
||||
|
||||
# Tear down the fan-out broadcaster first (#1089). This cleanly notifies
|
||||
# all subscribed viewers and asks the upstream generator to stop
|
||||
# reconnecting before we fall back to forcefully killing the process below.
|
||||
if await shutdown_broadcaster(broadcaster_key):
|
||||
if await shutdown_broadcaster(f"printer-{printer_id}"):
|
||||
logger.info("Shut down camera fan-out broadcaster for printer %s", printer_id)
|
||||
|
||||
# Stop ffmpeg/RTSP streams
|
||||
|
|
@ -1149,13 +794,20 @@ async def stop_camera_stream(
|
|||
if event:
|
||||
event.set()
|
||||
if process.returncode is None:
|
||||
# Shared helper, not an inline copy: it bounds the post-kill
|
||||
# wait (#2580) — a killed-but-unreaped ffmpeg used to hang this
|
||||
# request forever, exactly when the user hit Stop to recover a
|
||||
# stuck stream.
|
||||
await _terminate_ffmpeg(process, stream_id)
|
||||
stopped += 1
|
||||
logger.info("Terminated ffmpeg process for stream %s", stream_id)
|
||||
try:
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=2.0)
|
||||
except TimeoutError:
|
||||
logger.warning("ffmpeg didn't terminate gracefully, killing (stream_id=%s)", stream_id)
|
||||
process.kill()
|
||||
await process.wait()
|
||||
stopped += 1
|
||||
logger.info("Terminated ffmpeg process for stream %s", stream_id)
|
||||
except ProcessLookupError:
|
||||
pass # Process already dead
|
||||
except OSError as e:
|
||||
logger.warning("Error stopping stream %s: %s", stream_id, e)
|
||||
_spawned_ffmpeg_pids.pop(process.pid, None)
|
||||
|
||||
for stream_id in to_remove:
|
||||
|
|
@ -1191,6 +843,7 @@ async def stop_camera_stream(
|
|||
@router.get("/{printer_id}/camera/snapshot")
|
||||
async def camera_snapshot(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: None = RequireCameraStreamTokenIfAuthEnabled,
|
||||
):
|
||||
"""Capture a single frame from the printer camera.
|
||||
|
|
@ -1202,15 +855,7 @@ async def camera_snapshot(
|
|||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# Fetch the printer in a short-lived session and release the pooled DB
|
||||
# connection BEFORE the camera capture below (up to 15s, longer under a
|
||||
# saturated FTP/camera pool). Holding a Depends(get_db) session across the
|
||||
# grab pinned one connection per snapshot — and the cam wall polls this
|
||||
# per tile every 8s — so overlapping captures could pile up connections on
|
||||
# a large farm (issue #2572, sibling of the camera_stream fix). Everything
|
||||
# below reads only already-loaded scalar columns (expire_on_commit=False).
|
||||
async with database.async_session() as db:
|
||||
printer = await get_printer_or_404(printer_id, db)
|
||||
printer = await get_printer_or_404(printer_id, db)
|
||||
|
||||
# Check for external camera first
|
||||
if printer.external_camera_enabled and printer.external_camera_url:
|
||||
|
|
@ -1831,14 +1476,9 @@ async def delete_reference(
|
|||
|
||||
|
||||
def _scan_bambu_ffmpeg_pids() -> list[int]:
|
||||
"""Scan /proc for ffmpeg processes that are ours.
|
||||
|
||||
Two shapes are matched, both unambiguously Bambuddy's:
|
||||
- Bambu RTSP: no other software connects to ``rtsp(s)://bblp:``.
|
||||
- External USB (V4L2): an ffmpeg spawned with ``-f v4l2`` is our USB camera
|
||||
stream (#2675). Only orphans are killed — the caller excludes PIDs still in
|
||||
``_active_streams``, so a live USB stream (now registered there) is spared.
|
||||
"""Scan /proc for ffmpeg processes with Bambu RTSP URLs.
|
||||
|
||||
These are definitely ours — no other software connects to rtsp(s)://bblp:.
|
||||
This catches orphans that survive app restarts and are not in any tracking dict.
|
||||
"""
|
||||
import os
|
||||
|
|
@ -1851,11 +1491,8 @@ def _scan_bambu_ffmpeg_pids() -> list[int]:
|
|||
try:
|
||||
with open(f"/proc/{entry}/cmdline", "rb") as f:
|
||||
cmdline = f.read()
|
||||
if b"ffmpeg" not in cmdline:
|
||||
continue
|
||||
# Match both rtsp:// (via TLS proxy) and rtsps:// (direct), plus
|
||||
# the `-f v4l2` input flag our USB camera command always carries.
|
||||
if b"rtsp://bblp:" in cmdline or b"rtsps://bblp:" in cmdline or b"v4l2" in cmdline:
|
||||
# Match both rtsp:// (via TLS proxy) and rtsps:// (direct)
|
||||
if b"ffmpeg" in cmdline and (b"rtsp://bblp:" in cmdline or b"rtsps://bblp:" in cmdline):
|
||||
pids.append(int(entry))
|
||||
except (OSError, PermissionError, ValueError):
|
||||
continue
|
||||
|
|
@ -1943,20 +1580,9 @@ async def cleanup_orphaned_streams():
|
|||
event.set()
|
||||
try:
|
||||
proc.kill()
|
||||
# Bounded (#2580): an unreaped SIGKILLed ffmpeg must not hang
|
||||
# the periodic cleanup loop — this janitor is the safety net
|
||||
# that recovers stalled streams, so it can least afford to
|
||||
# block. The /proc scan above retries the kill next pass.
|
||||
await asyncio.wait_for(proc.wait(), timeout=_FFMPEG_KILL_TIMEOUT)
|
||||
await proc.wait()
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
except TimeoutError:
|
||||
logger.error(
|
||||
"ffmpeg (pid=%d) did not exit within %.1fs of SIGKILL; abandoning wait (stream_id=%s)",
|
||||
proc.pid,
|
||||
_FFMPEG_KILL_TIMEOUT,
|
||||
sid,
|
||||
)
|
||||
_active_streams.pop(sid, None)
|
||||
_disconnect_events.pop(sid, None)
|
||||
_stream_last_frame_times.pop(sid, None)
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
"""Read-only Cam Wall feed for token-authenticated kiosk displays (#2531).
|
||||
|
||||
The Cam Wall inside the SPA runs on the ordinary printers API, behind a JWT. A
|
||||
wall pinned to a TV has no login, so it authenticates with a long-lived
|
||||
``camwall``-scoped token carried in the URL — and a URL on a lobby screen is
|
||||
about as private as a sticky note.
|
||||
|
||||
That is why this endpoint exists instead of letting a token through to
|
||||
``GET /printers``: the printer list carries ``serial_number`` and
|
||||
``ip_address`` (see ``schemas/printer.py``), and neither belongs on a screen in
|
||||
a shared room. What a wall tile actually draws is the whole payload here — a
|
||||
name, a connection flag, a state, a progress bar.
|
||||
|
||||
Notably absent is the print filename. A token wall renders the compact status
|
||||
overlay, so the part being printed is never named to the room; the field simply
|
||||
isn't served rather than being served and then hidden client-side.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequireCamWallTokenIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/camwall", tags=["camwall"])
|
||||
|
||||
|
||||
@router.get("/printers")
|
||||
async def list_camwall_printers(
|
||||
_: None = RequireCamWallTokenIfAuthEnabled,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[dict]:
|
||||
"""Every printer plus the handful of status fields a Cam Wall tile draws.
|
||||
|
||||
One call for the whole wall rather than one per printer: a kiosk polls this
|
||||
on a fixed interval with no WebSocket to invalidate it, and N+1 requests
|
||||
every few seconds is a poor trade for a screen nobody is interacting with.
|
||||
|
||||
Ordered by name so tile positions stay put across polls — a wall that
|
||||
reshuffles itself is unusable to watch.
|
||||
"""
|
||||
result = await db.execute(select(Printer).order_by(Printer.name))
|
||||
printers = list(result.scalars().all())
|
||||
|
||||
payload: list[dict] = []
|
||||
for printer in printers:
|
||||
state = printer_manager.get_status(printer.id)
|
||||
entry: dict = {
|
||||
"id": printer.id,
|
||||
"name": printer.name,
|
||||
"camera_rotation": printer.camera_rotation or 0,
|
||||
# Mirrors get_printer_status(): no state object at all means the
|
||||
# printer was never connected this run; a state object still has
|
||||
# to be asked whether its link is currently up.
|
||||
"connected": bool(state and state.connected),
|
||||
"state": None,
|
||||
"progress": None,
|
||||
"remaining_time": None,
|
||||
"layer_num": None,
|
||||
"total_layers": None,
|
||||
# Codes only — enough for the client to run the same
|
||||
# filterKnownHMSErrors() it uses on the authenticated wall, so the
|
||||
# error chip means the same thing in both modes.
|
||||
"hms_errors": [],
|
||||
}
|
||||
if state is not None:
|
||||
entry.update(
|
||||
{
|
||||
"state": state.state,
|
||||
"progress": state.progress,
|
||||
"remaining_time": state.remaining_time,
|
||||
"layer_num": state.layer_num,
|
||||
"total_layers": state.total_layers,
|
||||
"hms_errors": [
|
||||
{
|
||||
"code": e.code,
|
||||
"attr": e.attr,
|
||||
"module": e.module,
|
||||
"severity": e.severity,
|
||||
"actions": e.actions or [],
|
||||
}
|
||||
for e in (state.hms_errors or [])
|
||||
],
|
||||
}
|
||||
)
|
||||
payload.append(entry)
|
||||
|
||||
return payload
|
||||
|
|
@ -4,16 +4,14 @@ Bambu Lab Cloud API Routes
|
|||
Handles authentication and profile management with Bambu Cloud.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import (
|
||||
|
|
@ -23,7 +21,7 @@ from backend.app.core.auth import (
|
|||
require_permission_if_auth_enabled,
|
||||
security,
|
||||
)
|
||||
from backend.app.core.database import async_session, get_db
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.api_key import APIKey
|
||||
from backend.app.models.settings import Settings
|
||||
|
|
@ -48,7 +46,6 @@ from backend.app.services.bambu_cloud import (
|
|||
BambuCloudAuthError,
|
||||
BambuCloudError,
|
||||
BambuCloudService,
|
||||
invalidate_validation_cache,
|
||||
)
|
||||
from backend.app.utils.filament_ids import filament_id_to_setting_id
|
||||
|
||||
|
|
@ -170,9 +167,6 @@ router = APIRouter(prefix="/cloud", tags=["cloud"], dependencies=[Depends(_cloud
|
|||
CLOUD_TOKEN_KEY = "bambu_cloud_token"
|
||||
CLOUD_EMAIL_KEY = "bambu_cloud_email"
|
||||
CLOUD_REGION_KEY = "bambu_cloud_region"
|
||||
# Global (auth-disabled) counterpart of ``User.cloud_token_invalid_at``. Stores
|
||||
# an ISO timestamp; absent/empty means "not known to be dead".
|
||||
CLOUD_TOKEN_INVALID_KEY = "bambu_cloud_token_invalid_at"
|
||||
|
||||
|
||||
def _normalise_region(region: str | None) -> str:
|
||||
|
|
@ -180,63 +174,6 @@ def _normalise_region(region: str | None) -> str:
|
|||
return region if region in ("global", "china") else "global"
|
||||
|
||||
|
||||
async def is_cloud_token_invalid(db: AsyncSession, user: User | None = None) -> bool:
|
||||
"""Whether the stored Bambu token is known to have been rejected.
|
||||
|
||||
Set by :func:`mark_cloud_token_invalid` the first time Bambu answers 401,
|
||||
cleared on a fresh login/logout. This is the only durable record we have:
|
||||
Bambu's access token is opaque (no readable expiry) and Bambuddy does not
|
||||
persist the refresh token, so without this flag a dead credential looks
|
||||
exactly like a live one.
|
||||
"""
|
||||
if user is not None:
|
||||
return user.cloud_token_invalid_at is not None
|
||||
result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
|
||||
row = result.scalar_one_or_none()
|
||||
return bool(row and row.value)
|
||||
|
||||
|
||||
async def mark_cloud_token_invalid(user_id: int | None) -> None:
|
||||
"""Record that Bambu rejected the stored token.
|
||||
|
||||
Opens its own session on purpose. This runs from
|
||||
``BambuCloudService._on_auth_failure``, i.e. in the middle of a route that
|
||||
is about to fail — writing through that route's session would tie the flag
|
||||
to a transaction the route may still roll back, and the fact that the
|
||||
credential is dead is true regardless of how the request ends.
|
||||
|
||||
Best-effort: a bookkeeping failure must never replace the 401 the caller
|
||||
actually needs to see.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
async with async_session() as db:
|
||||
if user_id is not None:
|
||||
await db.execute(update(User).where(User.id == user_id).values(cloud_token_invalid_at=now))
|
||||
else:
|
||||
result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
|
||||
row = result.scalar_one_or_none()
|
||||
if row:
|
||||
row.value = now.isoformat()
|
||||
else:
|
||||
db.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value=now.isoformat()))
|
||||
await db.commit()
|
||||
logger.warning("Bambu Cloud rejected the stored token (user_id=%s) — marking the sign-in as expired", user_id)
|
||||
except Exception:
|
||||
logger.exception("Could not record the Bambu Cloud token as invalid")
|
||||
|
||||
|
||||
async def _clear_cloud_token_invalid(db: AsyncSession, user: User | None) -> None:
|
||||
"""Clear the rejected-token flag — called on every fresh login and logout."""
|
||||
if user is not None:
|
||||
await db.execute(update(User).where(User.id == user.id).values(cloud_token_invalid_at=None))
|
||||
return
|
||||
result = await db.execute(select(Settings).where(Settings.key == CLOUD_TOKEN_INVALID_KEY))
|
||||
row = result.scalar_one_or_none()
|
||||
if row:
|
||||
await db.delete(row)
|
||||
|
||||
|
||||
async def get_stored_token(db: AsyncSession, user: User | None = None) -> tuple[str | None, str | None, str]:
|
||||
"""Get stored cloud token, email, and region.
|
||||
|
||||
|
|
@ -265,19 +202,15 @@ async def store_token(db: AsyncSession, token: str, email: str, region: str, use
|
|||
|
||||
When a user is provided (auth enabled), stores on the user record.
|
||||
When user is None (auth disabled), stores in global Settings table.
|
||||
|
||||
Always clears the rejected-token flag: this is a *fresh* credential, and
|
||||
leaving the flag set would report the new sign-in as expired.
|
||||
"""
|
||||
region = _normalise_region(region)
|
||||
invalidate_validation_cache(token)
|
||||
if user is not None:
|
||||
# User object is from the auth dependency's session (detached),
|
||||
# so use a direct UPDATE via the route's db session.
|
||||
from sqlalchemy import update
|
||||
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(cloud_token=token, cloud_email=email, cloud_region=region, cloud_token_invalid_at=None)
|
||||
update(User).where(User.id == user.id).values(cloud_token=token, cloud_email=email, cloud_region=region)
|
||||
)
|
||||
await db.commit()
|
||||
return
|
||||
|
|
@ -290,7 +223,6 @@ async def store_token(db: AsyncSession, token: str, email: str, region: str, use
|
|||
setting.value = value
|
||||
else:
|
||||
db.add(Settings(key=key, value=value))
|
||||
await _clear_cloud_token_invalid(db, None)
|
||||
await db.commit()
|
||||
|
||||
|
||||
|
|
@ -299,96 +231,23 @@ async def clear_token(db: AsyncSession, user: User | None = None) -> None:
|
|||
|
||||
When a user is provided (auth enabled), clears that user's credentials.
|
||||
When user is None (auth disabled), clears from global Settings table.
|
||||
|
||||
The rejected-token flag goes with the token: once there is no credential,
|
||||
"the credential is dead" is not a state worth remembering, and leaving it
|
||||
behind would make the next login look expired the moment it is stored.
|
||||
"""
|
||||
token, _email, _region = await get_stored_token(db, user)
|
||||
if token:
|
||||
invalidate_validation_cache(token)
|
||||
|
||||
if user is not None:
|
||||
from sqlalchemy import update
|
||||
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(cloud_token=None, cloud_email=None, cloud_region=None, cloud_token_invalid_at=None)
|
||||
update(User).where(User.id == user.id).values(cloud_token=None, cloud_email=None, cloud_region=None)
|
||||
)
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
# Fallback: global storage (auth disabled)
|
||||
result = await db.execute(
|
||||
select(Settings).where(
|
||||
Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY, CLOUD_TOKEN_INVALID_KEY])
|
||||
)
|
||||
)
|
||||
for setting in result.scalars().all():
|
||||
await db.delete(setting)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def migrate_global_cloud_token_to_user(db: AsyncSession, user: User) -> bool:
|
||||
"""Move a globally-stored cloud token onto ``user`` (auth being enabled).
|
||||
|
||||
``get_stored_token`` reads the global ``Settings`` rows when auth is off and
|
||||
``User.cloud_token`` when it's on. Enabling auth therefore switches which
|
||||
column the cloud routes consult — without this migration the token linked
|
||||
before setup is stranded in ``Settings``, ``build_authenticated_cloud``
|
||||
returns ``None``, and every ``/cloud/*`` route silently degrades (#2530).
|
||||
|
||||
The global rows are deleted after the copy so the credential isn't left at
|
||||
rest in a table nothing reads any more. Does **not** commit — the caller
|
||||
owns the transaction. Returns True when a token was actually migrated.
|
||||
"""
|
||||
token, email, region = await get_stored_token(db, None)
|
||||
if not token:
|
||||
return False
|
||||
|
||||
user.cloud_token = token
|
||||
user.cloud_email = email
|
||||
user.cloud_region = _normalise_region(region)
|
||||
|
||||
result = await db.execute(
|
||||
select(Settings).where(Settings.key.in_([CLOUD_TOKEN_KEY, CLOUD_EMAIL_KEY, CLOUD_REGION_KEY]))
|
||||
)
|
||||
for setting in result.scalars().all():
|
||||
await db.delete(setting)
|
||||
return True
|
||||
|
||||
|
||||
async def migrate_user_cloud_token_to_global(db: AsyncSession, user: User) -> bool:
|
||||
"""Move ``user``'s cloud token into global storage (auth being disabled).
|
||||
|
||||
The mirror of :func:`migrate_global_cloud_token_to_user`: once auth is off,
|
||||
``get_stored_token`` stops consulting ``User.cloud_token`` entirely, so the
|
||||
admin who turns auth off would otherwise lose their own cloud link.
|
||||
|
||||
Refuses to overwrite an existing global token — a stale row from a previous
|
||||
no-auth stint is still someone's credential, and clobbering it silently is
|
||||
worse than leaving this admin to re-link. Does **not** commit. Returns True
|
||||
when a token was actually migrated.
|
||||
"""
|
||||
if not user.cloud_token:
|
||||
return False
|
||||
|
||||
existing, _, _ = await get_stored_token(db, None)
|
||||
if existing:
|
||||
return False
|
||||
|
||||
for key, value in [
|
||||
(CLOUD_TOKEN_KEY, user.cloud_token),
|
||||
(CLOUD_EMAIL_KEY, user.cloud_email),
|
||||
(CLOUD_REGION_KEY, _normalise_region(user.cloud_region)),
|
||||
]:
|
||||
if value is None:
|
||||
continue
|
||||
db.add(Settings(key=key, value=value))
|
||||
|
||||
user.cloud_token = None
|
||||
user.cloud_email = None
|
||||
user.cloud_region = None
|
||||
return True
|
||||
await db.commit()
|
||||
|
||||
|
||||
def _assert_api_key_can_access_cloud(api_key: APIKey) -> None:
|
||||
|
|
@ -425,17 +284,11 @@ async def build_authenticated_cloud(db: AsyncSession, user: User | None) -> Bamb
|
|||
|
||||
Returns ``None`` when no token is stored, so callers can 401 without constructing
|
||||
(and then closing) a useless client. Caller is responsible for ``await cloud.close()``.
|
||||
|
||||
The service is wired to persist a rejected-token flag the moment Bambu
|
||||
answers 401, so every route that builds a client this way makes the whole
|
||||
app agree the sign-in is dead — rather than each feature discovering it
|
||||
separately and reporting Bambu's own opaque "Please login." at the user.
|
||||
"""
|
||||
token, _email, region = await get_stored_token(db, user)
|
||||
if not token:
|
||||
return None
|
||||
user_id = user.id if user is not None else None
|
||||
cloud = BambuCloudService(region=region, on_auth_failure=lambda: mark_cloud_token_invalid(user_id))
|
||||
cloud = BambuCloudService(region=region)
|
||||
cloud.set_token(token)
|
||||
return cloud
|
||||
|
||||
|
|
@ -447,55 +300,27 @@ async def get_auth_status(
|
|||
):
|
||||
"""Get current cloud authentication status.
|
||||
|
||||
"We hold a token" is not the same claim as "Bambu accepts it", and this
|
||||
endpoint used to make the former while reporting the latter: it asked
|
||||
``cloud.is_authenticated``, which was a string-presence check behind a
|
||||
self-renewing expiry, so it answered ``true`` for as long as any token
|
||||
existed — including tokens Bambu had been rejecting for months (#2562
|
||||
follow-up). It now asks Bambu.
|
||||
|
||||
The verdict is cached for five minutes inside the service, so the several
|
||||
components polling this endpoint don't each pay a round-trip. When Bambu
|
||||
can't be reached the answer is ``None`` and we report the last known state
|
||||
rather than signing the user out over a transient outage.
|
||||
|
||||
``region`` is exposed so the frontend can show "Connected (China)" after a
|
||||
reload without relying on local state.
|
||||
Reads the stored credentials in one DB round-trip (we used to call
|
||||
``get_stored_token`` twice — once here and once inside
|
||||
``build_authenticated_cloud``). ``region`` is exposed so the frontend can
|
||||
show "Connected (China)" after a reload without relying on local state.
|
||||
"""
|
||||
token, email, region = await get_stored_token(db, current_user)
|
||||
if not token:
|
||||
return CloudAuthStatus(is_authenticated=False, email=None, region=None, sign_in_expired=False)
|
||||
return CloudAuthStatus(is_authenticated=False, email=None, region=None)
|
||||
|
||||
known_invalid = await is_cloud_token_invalid(db, current_user)
|
||||
|
||||
user_id = current_user.id if current_user is not None else None
|
||||
cloud = BambuCloudService(region=region, on_auth_failure=lambda: mark_cloud_token_invalid(user_id))
|
||||
cloud = BambuCloudService(region=region)
|
||||
cloud.set_token(token)
|
||||
try:
|
||||
if known_invalid:
|
||||
# Already recorded as dead. Don't re-ask Bambu on every poll — only a
|
||||
# new login can change this, and that clears the flag.
|
||||
accepted: bool | None = False
|
||||
else:
|
||||
accepted = await cloud.validate_token()
|
||||
authenticated = cloud.is_authenticated
|
||||
return CloudAuthStatus(
|
||||
is_authenticated=authenticated,
|
||||
email=email if authenticated else None,
|
||||
region=region if authenticated else None,
|
||||
)
|
||||
finally:
|
||||
await cloud.close()
|
||||
|
||||
if accepted is None:
|
||||
# Bambu unreachable / 5xx / Cloudflare challenge. Report what we last
|
||||
# knew — a cloud outage must not present as "your sign-in expired".
|
||||
accepted = not known_invalid
|
||||
|
||||
return CloudAuthStatus(
|
||||
is_authenticated=bool(accepted),
|
||||
email=email if accepted else None,
|
||||
region=region if accepted else None,
|
||||
# Distinguishes "you were signed in and the token died" from "you never
|
||||
# signed in" — the UI shows the same login form either way, but only the
|
||||
# former deserves an explanation for why it reappeared.
|
||||
sign_in_expired=not accepted,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=CloudLoginResponse)
|
||||
async def login(
|
||||
|
|
@ -738,92 +563,6 @@ _filament_cache: dict[str, dict] = {}
|
|||
_filament_cache_time: float = 0
|
||||
FILAMENT_CACHE_TTL = 300 # 5 minutes
|
||||
|
||||
# In-flight cloud lookups, keyed by setting_id (#2572). The printer overview
|
||||
# mounts one filament-info request per printer card, so at farm scale several
|
||||
# browsers ask for the same uncached preset within the same instant. Without
|
||||
# coalescing each request issues its own Bambu Cloud round-trip for the same id
|
||||
# (a thundering herd against a rate-limited API). The first caller to miss a
|
||||
# given id becomes the leader and resolves it; concurrent callers await its
|
||||
# future and reuse the result instead of duplicating the call.
|
||||
_filament_inflight: dict[str, asyncio.Future] = {}
|
||||
|
||||
|
||||
async def _fetch_one_cloud_filament(setting_id: str, cloud: BambuCloudService) -> dict | None:
|
||||
"""Fetch a single filament preset from Bambu Cloud.
|
||||
|
||||
Returns ``{"name", "k"}`` on success (name may be empty when the preset
|
||||
resolves but carries no display name), or ``None`` when the lookup fails.
|
||||
Never raises — a 400 is the expected answer for many bare preset IDs and is
|
||||
logged at DEBUG; anything else is a real fault logged at WARNING.
|
||||
"""
|
||||
try:
|
||||
api_setting_id = _filament_id_to_setting_id(setting_id)
|
||||
data = await cloud.get_setting_detail(api_setting_id)
|
||||
setting = data.get("setting", {})
|
||||
name = data.get("name", "")
|
||||
k_value = setting.get("pressure_advance")
|
||||
if k_value is not None:
|
||||
try:
|
||||
k_value = float(k_value)
|
||||
except (ValueError, TypeError):
|
||||
k_value = None
|
||||
return {"name": name, "k": k_value}
|
||||
except Exception as e:
|
||||
# A 400 here is the *expected* answer, not a fault, and the local-preset
|
||||
# fallback (Phase 3) exists to handle it (#2530). Two routine causes:
|
||||
# * Many official presets are only addressable with a printer variant
|
||||
# suffix — "GFSA00" resolves, "GFSL05" does not, only "GFSL05_07"
|
||||
# (@BBL A1) does. The bare ID is all the AMS reports, so the lookup
|
||||
# legitimately misses.
|
||||
# * Personal presets ("P…") belong to the Bambu account that sliced the
|
||||
# file; another account will never resolve them.
|
||||
# Logging those at WARNING on every AMS tooltip refresh trains users to
|
||||
# ignore the log. Anything else — expired token, 5xx, a connection
|
||||
# failure — stays at WARNING because it is a fault.
|
||||
expected_miss = isinstance(e, BambuCloudError) and e.status_code == 400
|
||||
logger.log(
|
||||
logging.DEBUG if expected_miss else logging.WARNING,
|
||||
"Failed to get cloud preset %s (API ID: %s): %s",
|
||||
setting_id,
|
||||
_filament_id_to_setting_id(setting_id),
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _resolve_cloud_filament(setting_id: str, cloud: BambuCloudService) -> dict | None:
|
||||
"""Resolve one preset via Bambu Cloud, single-flighting concurrent misses (#2572).
|
||||
|
||||
Concurrent callers for the same ``setting_id`` share one cloud round-trip:
|
||||
the first caller resolves it while the rest await the shared future. Returns
|
||||
the info dict (also populating ``_filament_cache``) or ``None`` on failure.
|
||||
"""
|
||||
if setting_id in _filament_cache:
|
||||
return _filament_cache[setting_id]
|
||||
|
||||
existing = _filament_inflight.get(setting_id)
|
||||
if existing is not None:
|
||||
# Another request is already fetching this id — reuse its result.
|
||||
# shield() so our own cancellation can't cancel the shared leader.
|
||||
try:
|
||||
return await asyncio.shield(existing)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
fut: asyncio.Future = asyncio.get_event_loop().create_future()
|
||||
_filament_inflight[setting_id] = fut
|
||||
info: dict | None = None
|
||||
try:
|
||||
info = await _fetch_one_cloud_filament(setting_id, cloud)
|
||||
return info
|
||||
finally:
|
||||
if info is not None:
|
||||
_filament_cache[setting_id] = info
|
||||
if not fut.done():
|
||||
fut.set_result(info)
|
||||
_filament_inflight.pop(setting_id, None)
|
||||
|
||||
|
||||
# Built-in filament ID → name mapping (fallback when cloud API and local profiles
|
||||
# don't have the entry). Based on Bambu Lab's known filament catalogue.
|
||||
_BUILTIN_FILAMENT_NAMES: dict[str, str] = {
|
||||
|
|
@ -1036,23 +775,35 @@ async def get_filament_info(
|
|||
# Phase 2: Try cloud for uncached IDs
|
||||
if unresolved_ids:
|
||||
cloud = await build_authenticated_cloud(db, current_user)
|
||||
# Release the request's DB transaction before the sequential Bambu Cloud
|
||||
# round-trips below (#2572). build_authenticated_cloud has read the
|
||||
# stored token — the only DB access this phase needs — and nothing until
|
||||
# Phase 3 touches the DB again. Without this the session sat "idle in
|
||||
# transaction" for the full duration of N external HTTP calls, pinning a
|
||||
# pooled connection per in-flight request. Phase 3's read transparently
|
||||
# opens a fresh transaction on the same still-open session.
|
||||
await db.rollback()
|
||||
if cloud is not None and cloud.is_authenticated:
|
||||
try:
|
||||
still_unresolved: list[str] = []
|
||||
for setting_id in unresolved_ids:
|
||||
info = await _resolve_cloud_filament(setting_id, cloud)
|
||||
if info is not None:
|
||||
try:
|
||||
api_setting_id = _filament_id_to_setting_id(setting_id)
|
||||
data = await cloud.get_setting_detail(api_setting_id)
|
||||
setting = data.get("setting", {})
|
||||
name = data.get("name", "")
|
||||
k_value = setting.get("pressure_advance")
|
||||
if k_value is not None:
|
||||
try:
|
||||
k_value = float(k_value)
|
||||
except (ValueError, TypeError):
|
||||
k_value = None
|
||||
|
||||
info = {"name": name, "k": k_value}
|
||||
_filament_cache[setting_id] = info
|
||||
result[setting_id] = info
|
||||
if info is None or not info.get("name"):
|
||||
|
||||
if not name:
|
||||
still_unresolved.append(setting_id)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to get cloud preset {setting_id} "
|
||||
f"(API ID: {_filament_id_to_setting_id(setting_id)}): {e}"
|
||||
)
|
||||
still_unresolved.append(setting_id)
|
||||
|
||||
unresolved_ids = still_unresolved
|
||||
finally:
|
||||
await cloud.close()
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ from backend.app.core.permissions import Permission
|
|||
from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.github_backup import (
|
||||
CloudAccountCounts,
|
||||
GitHubBackupConfigCreate,
|
||||
GitHubBackupConfigResponse,
|
||||
GitHubBackupConfigUpdate,
|
||||
|
|
@ -50,21 +49,7 @@ async def _enforce_private_repo(repo_url: str, token: str, provider: str) -> Non
|
|||
|
||||
Used by POST and PATCH /config so a backup configuration can never be
|
||||
saved against a public repository.
|
||||
|
||||
The URL is policy-checked first: the Gitea and Forgejo backends derive
|
||||
their API base from this value (``get_api_base``) and then request it with
|
||||
the supplied token, so an unchecked repository_url is an outbound fetch to
|
||||
an operator-supplied host. A self-hosted Gitea on the LAN is the normal
|
||||
case, so the LAN-service tier applies — this only rules out the targets
|
||||
that are wrong under any topology.
|
||||
"""
|
||||
from backend.app.api.routes._url_safety import assert_safe_lan_service_url
|
||||
|
||||
try:
|
||||
assert_safe_lan_service_url(repo_url, label="Repository URL")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
result = await github_backup_service.test_connection(repo_url, token, provider=provider)
|
||||
if not result.get("success"):
|
||||
message = result.get("message") or "Connection test failed"
|
||||
|
|
@ -76,39 +61,6 @@ async def _enforce_private_repo(repo_url: str, token: str, provider: str) -> Non
|
|||
raise HTTPException(status_code=400, detail=_PUBLIC_REPO_ERROR)
|
||||
|
||||
|
||||
async def _count_cloud_accounts(db: AsyncSession) -> tuple[int, int]:
|
||||
"""How many Bambu / Orca accounts a backup would collect from.
|
||||
|
||||
Asks the collector itself rather than re-deriving the rule, so the number
|
||||
the UI gates on can't drift from the number the backup actually uses
|
||||
(#2717). Counts only — never who.
|
||||
"""
|
||||
try:
|
||||
bambu, orca = await github_backup_service.cloud_accounts(db)
|
||||
return len(bambu), len(orca)
|
||||
except Exception:
|
||||
# A settings page must still render when a credential store is
|
||||
# unreadable; the toggle simply shows as unavailable.
|
||||
logger.warning("Failed to count connected cloud accounts", exc_info=True)
|
||||
return 0, 0
|
||||
|
||||
|
||||
@router.get("/cloud-accounts", response_model=CloudAccountCounts)
|
||||
async def get_cloud_accounts(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
||||
):
|
||||
"""How many cloud accounts the Cloud Profiles category would collect from.
|
||||
|
||||
Its own endpoint rather than a field on ``/config``, because the settings
|
||||
form needs this before any config exists — ``/config`` answers ``null``
|
||||
until the first save, which would leave the toggle disabled during the
|
||||
very setup it's part of.
|
||||
"""
|
||||
bambu, orca = await _count_cloud_accounts(db)
|
||||
return CloudAccountCounts(bambu=bambu, orca=orca)
|
||||
|
||||
|
||||
def _config_to_response(config: GitHubBackupConfig) -> dict:
|
||||
"""Convert config model to response dict."""
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from sqlalchemy import select
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from backend.app.core.auth import RequireAdminIfAuthEnabled, RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import (
|
||||
ALL_PERMISSIONS,
|
||||
|
|
@ -87,7 +87,6 @@ async def list_groups(
|
|||
@router.post("/", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_group(
|
||||
group_data: GroupCreate,
|
||||
_admin: User | None = RequireAdminIfAuthEnabled(),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_CREATE),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
|
|
@ -136,8 +135,7 @@ async def get_group(
|
|||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a group by ID with user list. Read-only — gated on
|
||||
``GROUPS_READ`` only."""
|
||||
"""Get a group by ID with user list."""
|
||||
result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))
|
||||
group = result.scalar_one_or_none()
|
||||
if not group:
|
||||
|
|
@ -163,7 +161,6 @@ async def get_group(
|
|||
async def update_group(
|
||||
group_id: int,
|
||||
group_data: GroupUpdate,
|
||||
_admin: User | None = RequireAdminIfAuthEnabled(),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
|
|
@ -196,16 +193,6 @@ async def update_group(
|
|||
group.description = group_data.description
|
||||
|
||||
if group_data.permissions is not None:
|
||||
# System groups (Administrators in particular) have fixed permission
|
||||
# sets that the app depends on — stripping them is a denial-of-
|
||||
# service vector that even admin callers shouldn't trigger by
|
||||
# accident through the generic edit form. Mirrors the rename block
|
||||
# immediately above.
|
||||
if group.is_system:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot modify permissions of system groups",
|
||||
)
|
||||
# Validate permissions
|
||||
invalid_perms = [p for p in group_data.permissions if p not in ALL_PERMISSIONS]
|
||||
if invalid_perms:
|
||||
|
|
@ -233,7 +220,6 @@ async def update_group(
|
|||
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_group(
|
||||
group_id: int,
|
||||
_admin: User | None = RequireAdminIfAuthEnabled(),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_DELETE),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
|
|
@ -260,7 +246,6 @@ async def delete_group(
|
|||
async def add_user_to_group(
|
||||
group_id: int,
|
||||
user_id: int,
|
||||
_admin: User | None = RequireAdminIfAuthEnabled(),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
|
|
@ -298,7 +283,6 @@ async def add_user_to_group(
|
|||
async def remove_user_from_group(
|
||||
group_id: int,
|
||||
user_id: int,
|
||||
_admin: User | None = RequireAdminIfAuthEnabled(),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
|||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
|
|
@ -21,14 +20,11 @@ from backend.app.core.permissions import Permission
|
|||
from backend.app.core.websocket import ws_manager
|
||||
from backend.app.models.ams_label import AmsLabel
|
||||
from backend.app.models.color_catalog import ColorCatalogEntry
|
||||
from backend.app.models.location import Location
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.spool import Spool
|
||||
from backend.app.models.spool_assignment import SpoolAssignment
|
||||
from backend.app.models.spool_catalog import SpoolCatalogEntry
|
||||
from backend.app.models.spool_k_profile import SpoolKProfile
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.location import LocationCreate, LocationResponse, LocationUpdate
|
||||
from backend.app.schemas.spool import (
|
||||
SpoolAssignmentCreate,
|
||||
SpoolAssignmentResponse,
|
||||
|
|
@ -42,17 +38,6 @@ from backend.app.schemas.spool import (
|
|||
normalize_extra_colors,
|
||||
)
|
||||
from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
|
||||
from backend.app.services.location_service import (
|
||||
DUPLICATE_LOCATION_NAME,
|
||||
assign_location_name,
|
||||
count_internal_spools_at_location,
|
||||
get_location_by_id,
|
||||
get_location_by_name,
|
||||
location_name_key,
|
||||
prepare_internal_spool_payload,
|
||||
rename_location as rename_location_record,
|
||||
)
|
||||
from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
|
||||
from backend.app.services.spool_csv import (
|
||||
MAX_CSV_IMPORT_BYTES,
|
||||
ImportPreview,
|
||||
|
|
@ -60,7 +45,6 @@ from backend.app.services.spool_csv import (
|
|||
parse_and_validate,
|
||||
serialize,
|
||||
)
|
||||
from backend.app.services.spoolman import SpoolmanClient, get_spoolman_client, init_spoolman_client
|
||||
from backend.app.utils.filament_ids import (
|
||||
GENERIC_FILAMENT_IDS,
|
||||
MATERIAL_TEMPS,
|
||||
|
|
@ -82,6 +66,27 @@ _CSV_UPLOAD_CHUNK_BYTES = 64 * 1024
|
|||
# FilamentColors.xyz API
|
||||
FILAMENT_COLORS_API = "https://filamentcolors.xyz/api"
|
||||
|
||||
# Generic Bambu filament IDs by material — fallback when no specific
|
||||
# preset is resolvable. Keep aligned with the inline table in
|
||||
# apply_spool_to_slot_via_mqtt below; both paths must produce the same
|
||||
# value for a given material.
|
||||
_GENERIC_FILAMENT_IDS: dict[str, str] = {
|
||||
"PLA": "GFL99",
|
||||
"PETG": "GFG99",
|
||||
"ABS": "GFB99",
|
||||
"ASA": "GFB98",
|
||||
"PC": "GFC99",
|
||||
"PA": "GFN99",
|
||||
"NYLON": "GFN99",
|
||||
"TPU": "GFU99",
|
||||
"PVA": "GFS99",
|
||||
"HIPS": "GFS98",
|
||||
"PLA-CF": "GFL98",
|
||||
"PETG-CF": "GFG98",
|
||||
"PA-CF": "GFN98",
|
||||
"PETG HF": "GFG96",
|
||||
}
|
||||
|
||||
|
||||
async def apply_spool_to_slot_via_mqtt(
|
||||
*,
|
||||
|
|
@ -124,24 +129,125 @@ async def apply_spool_to_slot_via_mqtt(
|
|||
)
|
||||
tray_color = spool.rgba or "FFFFFFFF"
|
||||
|
||||
_generic_id_values = _GENERIC_ID_VALUES
|
||||
_known_materials = set(MATERIAL_TEMPS.keys()) | set(GENERIC_FILAMENT_IDS.keys())
|
||||
_generic_id_values = set(_GENERIC_FILAMENT_IDS.values())
|
||||
|
||||
# slicer_filament → (tray_info_idx, setting_id) resolution is shared with
|
||||
# the Spoolman-mode route via this helper (#1713). The helper handles
|
||||
# GFS/PFUS/PFCN cloud lookup, GF normalize, integer LocalPreset id,
|
||||
# the builtin-name realignment, AND the defensive PFUS/PFCN/material-name
|
||||
# sanitization. When it returns an empty tray_info_idx the local
|
||||
# current-tray-state + generic-material fallback below rescues the slot.
|
||||
tray_info_idx, setting_id, sub_brand_override = await resolve_slicer_filament(
|
||||
db=db,
|
||||
current_user=current_user,
|
||||
slicer_filament=spool.slicer_filament,
|
||||
slicer_filament_name=spool.slicer_filament_name,
|
||||
material=spool.material,
|
||||
)
|
||||
if sub_brand_override:
|
||||
tray_sub_brands = sub_brand_override
|
||||
tray_info_idx = ""
|
||||
setting_id = ""
|
||||
sf = spool.slicer_filament or ""
|
||||
|
||||
if sf:
|
||||
base_sf = sf.split("_")[0] if "_" in sf else sf
|
||||
# Cloud-side preset IDs in three known shapes:
|
||||
# GFS… — Bambu official cloud preset
|
||||
# PFUS… — cloud user-created preset
|
||||
# PFCN… — cloud shared / partner preset (e.g. Polymaker's
|
||||
# "(Custom)" Bambu Lab H2D variant, #1648)
|
||||
# All three need a cloud-detail lookup to extract the underlying
|
||||
# filament_id; without it the raw cloud id ends up in tray_info_idx
|
||||
# and the printer's calibration table can't resolve it.
|
||||
if base_sf.startswith("GFS") or base_sf.startswith("PFUS") or base_sf.startswith("PFCN"):
|
||||
setting_id = base_sf
|
||||
try:
|
||||
from backend.app.api.routes.cloud import build_authenticated_cloud
|
||||
|
||||
cloud = await build_authenticated_cloud(db, current_user)
|
||||
if cloud is not None and cloud.is_authenticated:
|
||||
try:
|
||||
detail = await cloud.get_setting_detail(base_sf)
|
||||
if detail.get("filament_id"):
|
||||
tray_info_idx = detail["filament_id"]
|
||||
cloud_name = detail.get("name", "")
|
||||
if cloud_name:
|
||||
tray_sub_brands = cloud_name.replace(r"@.*$", "").split("@")[0].strip()
|
||||
elif detail.get("base_id"):
|
||||
bid = detail["base_id"].split("_")[0]
|
||||
if bid.startswith("GFS") and len(bid) >= 5:
|
||||
tray_info_idx = f"GF{bid[3:]}"
|
||||
else:
|
||||
tray_info_idx = bid
|
||||
finally:
|
||||
await cloud.close()
|
||||
elif cloud is not None:
|
||||
await cloud.close()
|
||||
except Exception as e:
|
||||
logger.warning("Spool assign: cloud lookup failed for %r: %s", sf, e)
|
||||
|
||||
if not tray_info_idx:
|
||||
tray_info_idx, setting_id = normalize_slicer_filament(sf)
|
||||
elif base_sf.startswith("GF"):
|
||||
tray_info_idx, setting_id = normalize_slicer_filament(sf)
|
||||
else:
|
||||
try:
|
||||
local_id = int(sf)
|
||||
from backend.app.models.local_preset import LocalPreset as LP
|
||||
|
||||
lp_result = await db.execute(select(LP).where(LP.id == local_id, LP.preset_type == "filament"))
|
||||
lp = lp_result.scalar_one_or_none()
|
||||
if lp:
|
||||
# Local preset's setting JSON carries the printer-recognized
|
||||
# filament_id (e.g. "P4d64437") — use that directly so the
|
||||
# slicer can resolve the specific preset. Falls through to
|
||||
# generic material id only when the JSON doesn't carry one.
|
||||
lp_filament_id = ""
|
||||
if lp.setting:
|
||||
try:
|
||||
setting_data = json.loads(lp.setting)
|
||||
raw_fid = setting_data.get("filament_id")
|
||||
if isinstance(raw_fid, str) and raw_fid:
|
||||
lp_filament_id = raw_fid
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
if lp_filament_id:
|
||||
tray_info_idx = lp_filament_id
|
||||
setting_id = filament_id_to_setting_id(lp_filament_id)
|
||||
else:
|
||||
mat = (spool.material or lp.filament_type or "").upper().strip()
|
||||
tray_info_idx = (
|
||||
_GENERIC_FILAMENT_IDS.get(mat)
|
||||
or _GENERIC_FILAMENT_IDS.get(mat.split("-")[0].split(" ")[0])
|
||||
or ""
|
||||
)
|
||||
if lp.name:
|
||||
tray_sub_brands = lp.name.split("@")[0].strip()
|
||||
except (ValueError, TypeError):
|
||||
tray_info_idx, setting_id = normalize_slicer_filament(sf)
|
||||
|
||||
if tray_info_idx and spool.slicer_filament_name:
|
||||
from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES
|
||||
|
||||
expected_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx, "")
|
||||
if expected_name and expected_name != spool.slicer_filament_name:
|
||||
for fid, fname in _BUILTIN_FILAMENT_NAMES.items():
|
||||
if fname == spool.slicer_filament_name:
|
||||
tray_info_idx = fid
|
||||
setting_id = filament_id_to_setting_id(fid)
|
||||
break
|
||||
|
||||
# Defend against tray_info_idx values the slicer cannot resolve. Three
|
||||
# shapes leak through and must be discarded so the generic-material
|
||||
# fallback below can rescue the slot:
|
||||
# 1. Literal material names ("PLA", "PETG-CF") that pass through
|
||||
# normalize_slicer_filament unchanged when the spool's slicer_filament
|
||||
# is free-text rather than a real preset ID.
|
||||
# 2. PFUS-prefix cloud setting_ids — valid as setting_id but rejected
|
||||
# by the slicer as tray_info_idx (the printer's calibration table
|
||||
# indexes by filament_id, and a PFUS isn't one). This normally gets
|
||||
# realigned to a P-prefix local id via printer_kp lookup, but the
|
||||
# replay path in main.py.on_ams_change passes current_user=None,
|
||||
# which skips cloud auth and leaves the raw PFUS in tray_info_idx —
|
||||
# overwriting the correctly-configured slot from the original assign.
|
||||
# 3. PFCN-prefix cloud shared / partner presets (e.g. Polymaker's
|
||||
# "(Custom)" H2D variants, #1648) — same shape problem as PFUS.
|
||||
# Valid tray_info_idx values: "GF" + letter + digits (Bambu official) or
|
||||
# "P" followed by hex (user/local presets, NOT "PFUS" or "PFCN").
|
||||
_known_materials = set(MATERIAL_TEMPS.keys()) | set(_GENERIC_FILAMENT_IDS.keys())
|
||||
if tray_info_idx and (
|
||||
tray_info_idx.upper() in _known_materials
|
||||
or tray_info_idx.startswith("PFUS")
|
||||
or tray_info_idx.startswith("PFCN")
|
||||
):
|
||||
tray_info_idx = ""
|
||||
setting_id = ""
|
||||
|
||||
if not tray_info_idx:
|
||||
if (
|
||||
|
|
@ -157,8 +263,8 @@ async def apply_spool_to_slot_via_mqtt(
|
|||
elif tray_type:
|
||||
material = tray_type.upper().strip()
|
||||
generic = (
|
||||
GENERIC_FILAMENT_IDS.get(material)
|
||||
or GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
|
||||
_GENERIC_FILAMENT_IDS.get(material)
|
||||
or _GENERIC_FILAMENT_IDS.get(material.split("-")[0].split(" ")[0])
|
||||
or ""
|
||||
)
|
||||
if generic:
|
||||
|
|
@ -287,39 +393,49 @@ async def apply_spool_to_slot_via_mqtt(
|
|||
spool.id,
|
||||
)
|
||||
|
||||
# Register a read-back verification so the next AMS pushes can confirm the
|
||||
# tray actually accepted this assignment (#2582). We record the same
|
||||
# effective filament id we pushed plus the cali_idx we selected (or -1 for
|
||||
# the Default-K reset above), and the client fires on_assignment_verified
|
||||
# on match/timeout. Colour is informational only — the match keys on the
|
||||
# filament id the slicer echoes back.
|
||||
verify_cali_idx = matching_kp.cali_idx if (matching_kp and matching_kp.cali_idx is not None) else -1
|
||||
client.register_assignment_verification(
|
||||
ams_id=ams_id,
|
||||
tray_id=tray_id,
|
||||
tray_info_idx=effective_tray_info_idx,
|
||||
tray_color=tray_color,
|
||||
cali_idx=verify_cali_idx,
|
||||
)
|
||||
|
||||
# Persist slot preset mapping for UI display (preset_name on hover card).
|
||||
# Shared with the RFID auto-assign path — both must keep this row in sync
|
||||
# with the currently-assigned spool, otherwise the slot card surfaces the
|
||||
# previous spool's preset name (the PrintersPage display chain consults
|
||||
# slot_preset_mappings.preset_name first).
|
||||
from backend.app.services.slot_preset_writer import upsert_slot_preset_for_spool
|
||||
try:
|
||||
from backend.app.models.slot_preset import SlotPresetMapping
|
||||
|
||||
await upsert_slot_preset_for_spool(
|
||||
db=db,
|
||||
spool=spool,
|
||||
printer_id=printer_id,
|
||||
ams_id=ams_id,
|
||||
tray_id=tray_id,
|
||||
tray_info_idx=tray_info_idx,
|
||||
tray_sub_brands=tray_sub_brands,
|
||||
tray_type=tray_type,
|
||||
setting_id=setting_id,
|
||||
)
|
||||
preset_name = spool.slicer_filament_name or tray_sub_brands or tray_type
|
||||
preset_source = "cloud"
|
||||
if sf:
|
||||
base_sf_mapping = sf.split("_")[0] if "_" in sf else sf
|
||||
try:
|
||||
int(base_sf_mapping)
|
||||
preset_id_to_save = f"local_{base_sf_mapping}"
|
||||
preset_source = "local"
|
||||
except (ValueError, TypeError):
|
||||
preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else setting_id
|
||||
else:
|
||||
preset_id_to_save = filament_id_to_setting_id(tray_info_idx) if tray_info_idx else ""
|
||||
|
||||
if preset_id_to_save:
|
||||
existing_mapping = await db.execute(
|
||||
select(SlotPresetMapping).where(
|
||||
SlotPresetMapping.printer_id == printer_id,
|
||||
SlotPresetMapping.ams_id == ams_id,
|
||||
SlotPresetMapping.tray_id == tray_id,
|
||||
)
|
||||
)
|
||||
mapping = existing_mapping.scalar_one_or_none()
|
||||
if mapping:
|
||||
mapping.preset_id = preset_id_to_save
|
||||
mapping.preset_name = preset_name
|
||||
mapping.preset_source = preset_source
|
||||
else:
|
||||
mapping = SlotPresetMapping(
|
||||
printer_id=printer_id,
|
||||
ams_id=ams_id,
|
||||
tray_id=tray_id,
|
||||
preset_id=preset_id_to_save,
|
||||
preset_name=preset_name,
|
||||
preset_source=preset_source,
|
||||
)
|
||||
db.add(mapping)
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save slot preset mapping for spool %d: %s", spool.id, e)
|
||||
|
||||
logger.info(
|
||||
"Auto-configured AMS slot ams=%d tray=%d for spool %d on printer %d",
|
||||
|
|
@ -422,10 +538,6 @@ class ColorLookupResult(BaseModel):
|
|||
material: str | None = None
|
||||
|
||||
|
||||
class ColorByMaterialResult(BaseModel):
|
||||
color_name: str | None = None
|
||||
|
||||
|
||||
# ── Spool Catalog CRUD ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -523,198 +635,6 @@ async def reset_spool_catalog(
|
|||
return {"status": "reset"}
|
||||
|
||||
|
||||
# ── Storage Locations (#1004) ───────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _load_settings_map(db: AsyncSession) -> dict[str, str]:
|
||||
result = await db.execute(select(Settings))
|
||||
return {s.key: s.value for s in result.scalars().all()}
|
||||
|
||||
|
||||
def _spoolman_is_enabled(settings: dict[str, str]) -> bool:
|
||||
return settings.get("spoolman_enabled", "false").lower() == "true"
|
||||
|
||||
|
||||
async def _ensure_spoolman_client(settings: dict[str, str]) -> SpoolmanClient | None:
|
||||
if not _spoolman_is_enabled(settings):
|
||||
return None
|
||||
url = settings.get("spoolman_url", "").strip()
|
||||
if not url:
|
||||
return None
|
||||
from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
|
||||
|
||||
try:
|
||||
assert_safe_spoolman_url(url)
|
||||
except ValueError:
|
||||
return None
|
||||
client = await get_spoolman_client()
|
||||
if not client or client.base_url != url.rstrip("/"):
|
||||
client = await init_spoolman_client(url)
|
||||
return client
|
||||
|
||||
|
||||
async def _spool_counts_for_locations(
|
||||
db: AsyncSession,
|
||||
locations: list[Location],
|
||||
settings: dict[str, str],
|
||||
) -> dict[int, int]:
|
||||
if _spoolman_is_enabled(settings):
|
||||
client = await _ensure_spoolman_client(settings)
|
||||
if client:
|
||||
try:
|
||||
spools = await client.get_all_spools(allow_archived=False)
|
||||
except Exception:
|
||||
logger.warning("Failed to fetch Spoolman spools for location counts", exc_info=True)
|
||||
else:
|
||||
# Use the canonical key helper so this matches what the
|
||||
# migration backfill, Location.name_key, and every other
|
||||
# codepath store as the case-insensitive lookup key. Plain
|
||||
# str.lower() drifts for non-ASCII (Turkish ı/İ, German ß)
|
||||
# and caused mismatched delete-block counts in Spoolman mode.
|
||||
by_key: dict[str, int] = {}
|
||||
for spool in spools:
|
||||
raw = spool.get("location")
|
||||
if not raw or not isinstance(raw, str) or not raw.strip():
|
||||
continue
|
||||
try:
|
||||
key = location_name_key(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
by_key[key] = by_key.get(key, 0) + 1
|
||||
return {loc.id: by_key.get(loc.name_key, 0) for loc in locations}
|
||||
|
||||
counts: dict[int, int] = {}
|
||||
for loc in locations:
|
||||
counts[loc.id] = await count_internal_spools_at_location(db, loc.id)
|
||||
return counts
|
||||
|
||||
|
||||
def _location_to_response(location: Location, spool_count: int) -> LocationResponse:
|
||||
return LocationResponse(
|
||||
id=location.id,
|
||||
name=location.name,
|
||||
identifier=location.identifier,
|
||||
spool_count=spool_count,
|
||||
created_at=location.created_at,
|
||||
updated_at=location.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/locations", response_model=list[LocationResponse])
|
||||
async def list_locations(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
|
||||
):
|
||||
"""List all storage locations with spool counts."""
|
||||
settings = await _load_settings_map(db)
|
||||
result = await db.execute(select(Location).order_by(Location.name))
|
||||
locations = list(result.scalars().all())
|
||||
counts = await _spool_counts_for_locations(db, locations, settings)
|
||||
return [_location_to_response(loc, counts.get(loc.id, 0)) for loc in locations]
|
||||
|
||||
|
||||
@router.post("/locations", response_model=LocationResponse, status_code=201)
|
||||
async def create_location(
|
||||
data: LocationCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
||||
):
|
||||
"""Create a storage location."""
|
||||
existing = await get_location_by_name(db, data.name)
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail=DUPLICATE_LOCATION_NAME)
|
||||
location = Location(identifier=data.identifier)
|
||||
assign_location_name(location, data.name)
|
||||
db.add(location)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=409, detail=DUPLICATE_LOCATION_NAME) from exc
|
||||
await db.refresh(location)
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
return _location_to_response(location, 0)
|
||||
|
||||
|
||||
@router.patch("/locations/{location_id}", response_model=LocationResponse)
|
||||
async def update_location(
|
||||
location_id: int,
|
||||
data: LocationUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
||||
):
|
||||
"""Update a storage location (rename propagates to assigned spools)."""
|
||||
location = await get_location_by_id(db, location_id)
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
|
||||
old_name = location.name
|
||||
if data.identifier is not None:
|
||||
location.identifier = data.identifier or None
|
||||
|
||||
if data.name is not None and data.name != old_name:
|
||||
try:
|
||||
await rename_location_record(db, location, data.name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
# Cascade to Spoolman BEFORE the local commit so a Spoolman failure
|
||||
# rolls back the local rename instead of leaving the catalog and
|
||||
# Spoolman's per-spool `location` field permanently diverged. Without
|
||||
# this ordering, a partial failure makes the next location-sync recreate
|
||||
# the old name as a duplicate catalog row (#1505 review blocker).
|
||||
settings = await _load_settings_map(db)
|
||||
client = await _ensure_spoolman_client(settings)
|
||||
if client:
|
||||
try:
|
||||
await client.rename_location(old_name, location.name)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Spoolman location rename failed for %s -> %s: %s",
|
||||
old_name,
|
||||
location.name,
|
||||
exc,
|
||||
)
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Spoolman rename failed; local rename rolled back",
|
||||
) from exc
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=409, detail=DUPLICATE_LOCATION_NAME) from exc
|
||||
await db.refresh(location)
|
||||
settings = await _load_settings_map(db)
|
||||
counts = await _spool_counts_for_locations(db, [location], settings)
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
return _location_to_response(location, counts.get(location.id, 0))
|
||||
|
||||
|
||||
@router.delete("/locations/{location_id}")
|
||||
async def delete_location(
|
||||
location_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
||||
):
|
||||
"""Delete a storage location when no spools are assigned."""
|
||||
location = await get_location_by_id(db, location_id)
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
|
||||
settings = await _load_settings_map(db)
|
||||
counts = await _spool_counts_for_locations(db, [location], settings)
|
||||
if counts.get(location.id, 0) > 0:
|
||||
raise HTTPException(status_code=409, detail="Location has spools assigned and cannot be deleted")
|
||||
|
||||
await db.delete(location)
|
||||
await db.commit()
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
# ── Color Catalog CRUD ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -895,73 +815,6 @@ async def lookup_color(
|
|||
return ColorLookupResult(found=False)
|
||||
|
||||
|
||||
@router.get("/colors/by-material", response_model=ColorByMaterialResult)
|
||||
async def get_color_by_material(
|
||||
hex: str,
|
||||
material: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_auth_if_enabled),
|
||||
):
|
||||
"""Disambiguated hex→name lookup that respects material context.
|
||||
|
||||
``/colors/map`` collapses every catalog entry sharing a hex to a single
|
||||
name with "Bambu Lab > is_default > first" priority — that loses, e.g.,
|
||||
"PLA Matte Charcoal" (#000000) behind "PLA Basic Black" (also #000000).
|
||||
This endpoint preserves the material context so the queue scheduler's
|
||||
Filament Override label can show the actually-sliced sub-brand colour
|
||||
instead of the generic bucket. #1718.
|
||||
|
||||
Returns ``color_name=None`` when the hex isn't in the catalog at all.
|
||||
When the hex IS in the catalog but no entry matches the requested
|
||||
material (or none was supplied), falls back to the same priority order
|
||||
as ``/colors/map`` so callers without a material hint don't regress.
|
||||
|
||||
Not gated on INVENTORY_READ for the same reason ``/colors/map`` isn't —
|
||||
every queue / archive view that renders a sliced filament colour needs
|
||||
this, including read-only roles.
|
||||
"""
|
||||
key = hex.lstrip("#").lower()[:6]
|
||||
if len(key) != 6:
|
||||
return ColorByMaterialResult(color_name=None)
|
||||
|
||||
material_norm = (material or "").strip().lower()
|
||||
|
||||
# Catalog rows are stored as ``#RRGGBB`` (verified at write time and
|
||||
# against production); lookup uses lower-cased hex equality so mixed-case
|
||||
# writes from older imports still match.
|
||||
result = await db.execute(
|
||||
select(
|
||||
ColorCatalogEntry.color_name,
|
||||
ColorCatalogEntry.manufacturer,
|
||||
ColorCatalogEntry.material,
|
||||
ColorCatalogEntry.is_default,
|
||||
).where(func.lower(ColorCatalogEntry.hex_color) == f"#{key}")
|
||||
)
|
||||
candidates = [(name, mfg, mat, is_default) for name, mfg, mat, is_default in result.all() if name]
|
||||
if not candidates:
|
||||
return ColorByMaterialResult(color_name=None)
|
||||
|
||||
if material_norm:
|
||||
for name, _mfg, mat, _is_default in candidates:
|
||||
if mat and mat.strip().lower() == material_norm:
|
||||
return ColorByMaterialResult(color_name=name)
|
||||
|
||||
# Same priority order as ``/colors/map`` so a caller passing no (or an
|
||||
# unrecognised) material gets the existing answer, not a degraded one.
|
||||
best_name: str | None = None
|
||||
best_priority = -1
|
||||
for name, mfg, _mat, is_default in candidates:
|
||||
priority = 0
|
||||
if mfg and mfg.strip().lower() == "bambu lab":
|
||||
priority += 2
|
||||
if is_default:
|
||||
priority += 1
|
||||
if priority > best_priority:
|
||||
best_name = name
|
||||
best_priority = priority
|
||||
return ColorByMaterialResult(color_name=best_name)
|
||||
|
||||
|
||||
@router.get("/colors/search", response_model=list[ColorEntryResponse])
|
||||
async def search_colors(
|
||||
manufacturer: str | None = None,
|
||||
|
|
@ -1196,49 +1049,6 @@ async def import_spools_csv(
|
|||
)
|
||||
|
||||
|
||||
@router.get("/spools/by-tag", response_model=SpoolResponse)
|
||||
async def get_spool_by_tag(
|
||||
tray_uuid: str | None = None,
|
||||
tag_uid: str | None = None,
|
||||
include_archived: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_UPDATE),
|
||||
):
|
||||
"""Find a single spool by its NFC ``tray_uuid`` and/or ``tag_uid``.
|
||||
|
||||
Lets NFC inventory integrations dedupe a scan without listing the whole
|
||||
inventory. ``tray_uuid`` is the primary identifier (it matches the value the
|
||||
AMS reports over MQTT), so it is tried first; ``tag_uid`` is the fallback.
|
||||
At least one identifier must be supplied. Returns 404 when nothing matches.
|
||||
|
||||
Accepts ``inventory:read`` OR ``inventory:update`` so a Manage-Inventory API
|
||||
key (which has ``inventory:update`` via ``can_manage_inventory``) can read a
|
||||
spool back without widening the global ``INVENTORY_READ`` scope mapping (#1663).
|
||||
"""
|
||||
normalized_tray_uuid = normalize_tray_uuid(tray_uuid) or None
|
||||
normalized_tag_uid = normalize_tag_uid(tag_uid) or None
|
||||
|
||||
if not normalized_tray_uuid and not normalized_tag_uid:
|
||||
raise HTTPException(400, "Provide tray_uuid and/or tag_uid")
|
||||
|
||||
base_query = select(Spool).options(selectinload(Spool.k_profiles))
|
||||
if not include_archived:
|
||||
base_query = base_query.where(Spool.archived_at.is_(None))
|
||||
|
||||
for column, value in (
|
||||
(Spool.tray_uuid, normalized_tray_uuid),
|
||||
(Spool.tag_uid, normalized_tag_uid),
|
||||
):
|
||||
if not value:
|
||||
continue
|
||||
result = await db.execute(base_query.where(func.upper(column) == value).order_by(Spool.id))
|
||||
spool = result.scalars().first()
|
||||
if spool:
|
||||
return spool
|
||||
|
||||
raise HTTPException(404, "Spool not found")
|
||||
|
||||
|
||||
@router.get("/spools/{spool_id}", response_model=SpoolResponse)
|
||||
async def get_spool(
|
||||
spool_id: int,
|
||||
|
|
@ -1260,11 +1070,7 @@ async def create_spool(
|
|||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
||||
):
|
||||
"""Create a new spool."""
|
||||
try:
|
||||
payload = await prepare_internal_spool_payload(db, spool_data.model_dump(), set(spool_data.model_fields_set))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
spool = Spool(**payload)
|
||||
spool = Spool(**spool_data.model_dump())
|
||||
db.add(spool)
|
||||
await db.commit()
|
||||
await db.refresh(spool)
|
||||
|
|
@ -1281,13 +1087,8 @@ async def bulk_create_spools(
|
|||
):
|
||||
"""Create multiple identical spools."""
|
||||
spools = []
|
||||
fields_set = set(data.spool.model_fields_set)
|
||||
try:
|
||||
payload = await prepare_internal_spool_payload(db, data.spool.model_dump(), fields_set)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
for _ in range(data.quantity):
|
||||
spool = Spool(**payload)
|
||||
spool = Spool(**data.spool.model_dump())
|
||||
db.add(spool)
|
||||
spools.append(spool)
|
||||
await db.commit()
|
||||
|
|
@ -1311,10 +1112,6 @@ async def update_spool(
|
|||
raise HTTPException(404, "Spool not found")
|
||||
|
||||
update_data = spool_data.model_dump(exclude_unset=True)
|
||||
try:
|
||||
update_data = await prepare_internal_spool_payload(db, update_data, set(spool_data.model_fields_set))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
# Auto-lock weight when user explicitly sets weight_used
|
||||
if "weight_used" in update_data and "weight_locked" not in update_data:
|
||||
update_data["weight_locked"] = True
|
||||
|
|
@ -1446,126 +1243,6 @@ async def bulk_reset_spool_consumed_counter(
|
|||
return {"reset": len(spools)}
|
||||
|
||||
|
||||
class BulkUpdateRequest(BaseModel):
|
||||
ids: list[int] = Field(..., min_length=1, max_length=500)
|
||||
update: SpoolUpdate
|
||||
|
||||
|
||||
class BulkIdsRequest(BaseModel):
|
||||
ids: list[int] = Field(..., min_length=1, max_length=500)
|
||||
|
||||
|
||||
@router.post("/spools/bulk-update")
|
||||
async def bulk_update_spools(
|
||||
payload: BulkUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
||||
):
|
||||
"""Apply the same partial update to every listed spool.
|
||||
|
||||
Per-spool errors are collected and returned alongside the success count so
|
||||
a single bad ID doesn't abort the whole batch. Unknown IDs are reported
|
||||
in the ``not_found`` list.
|
||||
"""
|
||||
update_data = payload.update.model_dump(exclude_unset=True)
|
||||
fields_set = set(payload.update.model_fields_set)
|
||||
if not update_data:
|
||||
raise HTTPException(status_code=400, detail="update must include at least one field")
|
||||
try:
|
||||
prepared = await prepare_internal_spool_payload(db, update_data, fields_set)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
# Auto-lock weight when the user explicitly sets weight_used — mirrors the
|
||||
# per-spool PATCH behaviour so bulk edits don't desync the lock state.
|
||||
if "weight_used" in prepared and "weight_locked" not in prepared:
|
||||
prepared["weight_locked"] = True
|
||||
|
||||
result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
|
||||
spools = {s.id: s for s in result.scalars().all()}
|
||||
not_found = [sid for sid in payload.ids if sid not in spools]
|
||||
updated_ids: list[int] = []
|
||||
for sid, spool in spools.items():
|
||||
for field, value in prepared.items():
|
||||
setattr(spool, field, value)
|
||||
updated_ids.append(sid)
|
||||
await db.commit()
|
||||
if updated_ids:
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
return {"updated": len(updated_ids), "not_found": not_found}
|
||||
|
||||
|
||||
@router.post("/spools/bulk-delete")
|
||||
async def bulk_delete_spools(
|
||||
payload: BulkIdsRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
||||
):
|
||||
"""Hard-delete every listed spool. Unknown IDs are returned in not_found."""
|
||||
result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
|
||||
spools = list(result.scalars().all())
|
||||
found_ids = {s.id for s in spools}
|
||||
not_found = [sid for sid in payload.ids if sid not in found_ids]
|
||||
for spool in spools:
|
||||
await db.delete(spool)
|
||||
await db.commit()
|
||||
if spools:
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
return {"deleted": len(spools), "not_found": not_found}
|
||||
|
||||
|
||||
@router.post("/spools/bulk-archive")
|
||||
async def bulk_archive_spools(
|
||||
payload: BulkIdsRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
||||
):
|
||||
"""Soft-archive every listed spool (sets archived_at). Already-archived spools are left alone and counted in already_archived."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
|
||||
spools = list(result.scalars().all())
|
||||
found_ids = {s.id for s in spools}
|
||||
not_found = [sid for sid in payload.ids if sid not in found_ids]
|
||||
archived: list[int] = []
|
||||
already: list[int] = []
|
||||
now = datetime.now(timezone.utc)
|
||||
for spool in spools:
|
||||
if spool.archived_at is not None:
|
||||
already.append(spool.id)
|
||||
continue
|
||||
spool.archived_at = now
|
||||
archived.append(spool.id)
|
||||
await db.commit()
|
||||
if archived:
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
return {"archived": len(archived), "already_archived": already, "not_found": not_found}
|
||||
|
||||
|
||||
@router.post("/spools/bulk-restore")
|
||||
async def bulk_restore_spools(
|
||||
payload: BulkIdsRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
||||
):
|
||||
"""Restore every listed archived spool. Non-archived rows are no-ops counted in already_active."""
|
||||
result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
|
||||
spools = list(result.scalars().all())
|
||||
found_ids = {s.id for s in spools}
|
||||
not_found = [sid for sid in payload.ids if sid not in found_ids]
|
||||
restored: list[int] = []
|
||||
already: list[int] = []
|
||||
for spool in spools:
|
||||
if spool.archived_at is None:
|
||||
already.append(spool.id)
|
||||
continue
|
||||
spool.archived_at = None
|
||||
restored.append(spool.id)
|
||||
await db.commit()
|
||||
if restored:
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
return {"restored": len(restored), "already_active": already, "not_found": not_found}
|
||||
|
||||
|
||||
# ── K-Profiles ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -1818,18 +1495,6 @@ async def assign_spool(
|
|||
)
|
||||
except Exception as e:
|
||||
logger.warning("MQTT auto-configure failed for spool %d: %s", spool.id, e)
|
||||
else:
|
||||
# Nudge a fresh pushall so the read-back verification registered in
|
||||
# apply_spool_to_slot_via_mqtt (#2582) has current tray telemetry to
|
||||
# compare against within its window, instead of waiting for the next
|
||||
# idle push. Best-effort — the periodic push is the fallback.
|
||||
if configured:
|
||||
try:
|
||||
client = printer_manager.get_client(data.printer_id)
|
||||
if client:
|
||||
client.request_status_update()
|
||||
except Exception:
|
||||
pass
|
||||
# pending_config is the "config not landed yet" UI marker. True when the
|
||||
# firmware said empty, OR when MQTT couldn't actually publish (printer
|
||||
# offline, no client, transient failure). on_ams_change replay re-fires
|
||||
|
|
@ -2191,7 +1856,6 @@ class FilamentSkuSettingsResponse(BaseModel):
|
|||
material: str
|
||||
subtype: str | None
|
||||
brand: str | None
|
||||
color_name: str | None
|
||||
lead_time_days: int
|
||||
safety_margin_value: int
|
||||
safety_margin_unit: str
|
||||
|
|
@ -2205,7 +1869,6 @@ class FilamentSkuSettingsUpsert(BaseModel):
|
|||
material: str
|
||||
subtype: str | None = None
|
||||
brand: str | None = None
|
||||
color_name: str | None = None
|
||||
lead_time_days: int = 0
|
||||
safety_margin_value: int = 14
|
||||
safety_margin_unit: str = "days"
|
||||
|
|
@ -2242,7 +1905,6 @@ async def upsert_sku_settings(
|
|||
FilamentSkuSettings.material == data.material,
|
||||
FilamentSkuSettings.subtype == data.subtype,
|
||||
FilamentSkuSettings.brand == data.brand,
|
||||
FilamentSkuSettings.color_name == data.color_name,
|
||||
)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
|
|
@ -2256,7 +1918,6 @@ async def upsert_sku_settings(
|
|||
material=data.material,
|
||||
subtype=data.subtype,
|
||||
brand=data.brand,
|
||||
color_name=data.color_name,
|
||||
lead_time_days=data.lead_time_days,
|
||||
safety_margin_value=data.safety_margin_value,
|
||||
safety_margin_unit=data.safety_margin_unit,
|
||||
|
|
@ -2276,7 +1937,6 @@ class ShoppingListItemResponse(BaseModel):
|
|||
material: str
|
||||
subtype: str | None
|
||||
brand: str | None
|
||||
color_name: str | None
|
||||
quantity_spools: int
|
||||
note: str | None
|
||||
status: str
|
||||
|
|
@ -2291,7 +1951,6 @@ class ShoppingListItemCreate(BaseModel):
|
|||
material: str
|
||||
subtype: str | None = None
|
||||
brand: str | None = None
|
||||
color_name: str | None = None
|
||||
quantity_spools: int = 1
|
||||
note: str | None = None
|
||||
|
||||
|
|
@ -2316,7 +1975,6 @@ async def get_shopping_list(
|
|||
material=i.material,
|
||||
subtype=i.subtype,
|
||||
brand=i.brand,
|
||||
color_name=i.color_name,
|
||||
quantity_spools=i.quantity_spools,
|
||||
note=i.note,
|
||||
status=i.status or "pending",
|
||||
|
|
@ -2342,7 +2000,6 @@ async def add_to_shopping_list(
|
|||
material=data.material,
|
||||
subtype=data.subtype,
|
||||
brand=data.brand,
|
||||
color_name=data.color_name,
|
||||
quantity_spools=data.quantity_spools,
|
||||
note=data.note,
|
||||
)
|
||||
|
|
@ -2354,7 +2011,6 @@ async def add_to_shopping_list(
|
|||
material=item.material,
|
||||
subtype=item.subtype,
|
||||
brand=item.brand,
|
||||
color_name=item.color_name,
|
||||
quantity_spools=item.quantity_spools,
|
||||
note=item.note,
|
||||
status=item.status or "pending",
|
||||
|
|
@ -2398,7 +2054,6 @@ async def update_shopping_list_status(
|
|||
material=item.material,
|
||||
subtype=item.subtype,
|
||||
brand=item.brand,
|
||||
color_name=item.color_name,
|
||||
quantity_spools=item.quantity_spools,
|
||||
note=item.note,
|
||||
status=item.status or "pending",
|
||||
|
|
@ -2441,87 +2096,3 @@ async def clear_shopping_list(
|
|||
deleted = len(result.fetchall())
|
||||
await db.commit()
|
||||
return {"deleted": deleted}
|
||||
|
||||
|
||||
class CreateSpoolFromSlotRequest(BaseModel):
|
||||
printer_id: int
|
||||
ams_id: int
|
||||
tray_id: int
|
||||
|
||||
|
||||
@router.post("/spools/from-slot", response_model=SpoolResponse)
|
||||
async def create_spool_from_slot(
|
||||
req: CreateSpoolFromSlotRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
||||
):
|
||||
"""Explicit user action: create an inventory spool from an AMS slot's current tray data.
|
||||
|
||||
Used by the "+ Add to inventory" affordance when auto_add_unknown_rfid is disabled —
|
||||
the user looked at the slot and chose to register it. Also assigns the new spool
|
||||
to the slot in the same call.
|
||||
"""
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
from backend.app.services.spool_tag_matcher import auto_assign_spool, create_spool_from_tray
|
||||
|
||||
state = printer_manager.get_status(req.printer_id)
|
||||
if not state or not state.raw_data:
|
||||
raise HTTPException(status_code=404, detail="Printer not connected or no state available")
|
||||
|
||||
ams_data = state.raw_data.get("ams")
|
||||
ams_units: list[dict] = []
|
||||
if isinstance(ams_data, list):
|
||||
ams_units = ams_data
|
||||
elif isinstance(ams_data, dict):
|
||||
if "ams" in ams_data and isinstance(ams_data["ams"], list):
|
||||
ams_units = ams_data["ams"]
|
||||
elif "tray" in ams_data:
|
||||
ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
|
||||
|
||||
tray: dict | None = None
|
||||
for unit in ams_units:
|
||||
if not isinstance(unit, dict):
|
||||
continue
|
||||
if int(unit.get("id", -1)) != req.ams_id:
|
||||
continue
|
||||
for t in unit.get("tray", []):
|
||||
if isinstance(t, dict) and int(t.get("id", -1)) == req.tray_id:
|
||||
tray = t
|
||||
break
|
||||
if tray:
|
||||
break
|
||||
|
||||
if not tray or not tray.get("tray_type"):
|
||||
raise HTTPException(status_code=400, detail="Slot is empty or has no readable tray data")
|
||||
|
||||
# Guard against ghost-spool creation: a slot without any RFID tag has no
|
||||
# stable identity, so creating an inventory row would just duplicate on
|
||||
# every confirm and never re-link to the physical spool.
|
||||
from backend.app.services.spool_tag_matcher import is_valid_tag
|
||||
|
||||
if not is_valid_tag(tray.get("tag_uid", ""), tray.get("tray_uuid", "")):
|
||||
raise HTTPException(status_code=400, detail="Slot has no RFID tag")
|
||||
|
||||
spool = await create_spool_from_tray(db, tray)
|
||||
await auto_assign_spool(
|
||||
req.printer_id,
|
||||
req.ams_id,
|
||||
req.tray_id,
|
||||
spool,
|
||||
printer_manager,
|
||||
db,
|
||||
tray_info_idx=tray.get("tray_info_idx", ""),
|
||||
)
|
||||
await db.commit()
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
await ws_manager.broadcast(
|
||||
{
|
||||
"type": "spool_auto_assigned",
|
||||
"printer_id": req.printer_id,
|
||||
"ams_id": req.ams_id,
|
||||
"tray_id": req.tray_id,
|
||||
"spool_id": spool.id,
|
||||
}
|
||||
)
|
||||
result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id))
|
||||
return result.scalar_one()
|
||||
|
|
|
|||
|
|
@ -148,9 +148,6 @@ async def set_kprofile(
|
|||
)
|
||||
if not delete_success:
|
||||
raise HTTPException(500, "Failed to delete existing K-profile for edit")
|
||||
ok, detail = await client.await_cali_ack(delete_success)
|
||||
if not ok:
|
||||
raise HTTPException(500, f"Printer rejected the K-profile edit: {detail}")
|
||||
|
||||
# Wait for printer to process the delete before adding
|
||||
await asyncio.sleep(0.5)
|
||||
|
|
@ -182,13 +179,6 @@ async def set_kprofile(
|
|||
if not success:
|
||||
raise HTTPException(500, "Failed to send K-profile command")
|
||||
|
||||
# The printer answers extrusion_cali_set with result/reason, echoing our
|
||||
# sequence_id. Until #2718 that answer was logged at DEBUG and discarded,
|
||||
# so a rejected write was reported to the user as saved.
|
||||
ok, detail = await client.await_cali_ack(success)
|
||||
if not ok:
|
||||
raise HTTPException(500, f"Printer rejected the K-profile: {detail}")
|
||||
|
||||
message = "K-profile updated successfully" if is_edit else "K-profile added successfully"
|
||||
return {"success": True, "message": message}
|
||||
|
||||
|
|
@ -249,10 +239,6 @@ async def set_kprofiles_batch(
|
|||
if not success:
|
||||
raise HTTPException(500, "Failed to send K-profiles batch command")
|
||||
|
||||
ok, detail = await client.await_cali_ack(success)
|
||||
if not ok:
|
||||
raise HTTPException(500, f"Printer rejected the K-profiles: {detail}")
|
||||
|
||||
return {"success": True, "message": f"Added {len(profiles)} K-profiles"}
|
||||
|
||||
|
||||
|
|
@ -297,10 +283,6 @@ async def delete_kprofile(
|
|||
if not success:
|
||||
raise HTTPException(500, "Failed to send K-profile delete command")
|
||||
|
||||
ok, detail = await client.await_cali_ack(success)
|
||||
if not ok:
|
||||
raise HTTPException(500, f"Printer rejected the delete: {detail}")
|
||||
|
||||
# Wait for printer to process the delete before frontend refetches
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
|
|
|
|||
|
|
@ -60,9 +60,6 @@ class LabelRequest(BaseModel):
|
|||
"avery_5160",
|
||||
"avery_l7160",
|
||||
]
|
||||
# Black-and-white thermal printers: drop the colour swatch (prints as a
|
||||
# muddy grey block) and widen the text column instead (#1870).
|
||||
monochrome: bool = False
|
||||
|
||||
|
||||
def _split_extra_colors(raw: str | None) -> list[str] | None:
|
||||
|
|
@ -173,7 +170,7 @@ async def render_local_inventory_labels(
|
|||
deeplink_base = await _resolve_deeplink_base(request, db)
|
||||
data_list = [_spool_to_label_data(s, deeplink_base) for s in ordered]
|
||||
|
||||
pdf = render_labels(body.template, data_list, monochrome=body.monochrome)
|
||||
pdf = render_labels(body.template, data_list)
|
||||
filename = f"bambuddy-labels-{body.template}.pdf"
|
||||
return _stream_pdf(pdf, filename)
|
||||
|
||||
|
|
@ -217,6 +214,6 @@ async def render_spoolman_labels(
|
|||
deeplink_base = await _resolve_deeplink_base(request, db)
|
||||
data_list = [_spoolman_dict_to_label_data(by_id[sid], deeplink_base) for sid in body.spool_ids]
|
||||
|
||||
pdf = render_labels(body.template, data_list, monochrome=body.monochrome)
|
||||
pdf = render_labels(body.template, data_list)
|
||||
filename = f"bambuddy-labels-spoolman-{body.template}.pdf"
|
||||
return _stream_pdf(pdf, filename)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,305 +0,0 @@
|
|||
"""Library tag catalog + per-file assignment endpoints (#1268).
|
||||
|
||||
Tags are global cross-cutting labels for library files — one catalog per
|
||||
install, no per-user partitioning. Designed as the orthogonal complement to
|
||||
folders: folders express hierarchy, tags express attributes ("toy",
|
||||
"kid-safe", "petg-only"). The reporter (#1268) and at least one upvoter
|
||||
asked for them; the design decisions were locked with @maziggy:
|
||||
|
||||
* tags apply to files only (folders already express hierarchy)
|
||||
* the tag filter on the file list intentionally IGNORES the selected folder
|
||||
so "show me every toy regardless of where it lives" works (multi-tag = AND)
|
||||
* bulk-tagging from the multi-select toolbar ships in v1
|
||||
* no auto-tags from 3MF metadata; user-authored only
|
||||
* no color, no icon — label-only chips
|
||||
|
||||
Permission model:
|
||||
|
||||
* **Catalog mutations** (POST / PATCH / DELETE on ``/library/tags``) require
|
||||
:attr:`Permission.LIBRARY_UPDATE_ALL` because the catalog is global —
|
||||
ownership-aware update isn't meaningful for a row no user owns.
|
||||
* **Bulk assignment** is gated by the existing
|
||||
:attr:`Permission.LIBRARY_UPDATE_ALL` / :attr:`Permission.LIBRARY_UPDATE_OWN`
|
||||
pair so a ``*_OWN`` user can only re-tag files they created.
|
||||
* **GET** is gated by :attr:`Permission.LIBRARY_READ_ALL` /
|
||||
:attr:`Permission.LIBRARY_READ_OWN` — ``*_OWN`` callers see every catalog
|
||||
row (it's just labels), but ``file_count`` is filtered to their own files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import delete, distinct, func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import require_ownership_permission, require_permission_if_auth_enabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.library import LibraryFile, LibraryFileTag, LibraryTag
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.library import (
|
||||
TagBulkAssignRequest,
|
||||
TagBulkAssignResponse,
|
||||
TagCreate,
|
||||
TagResponse,
|
||||
TagUpdate,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/library/tags", tags=["library-tags"])
|
||||
|
||||
|
||||
def _name_key(name: str) -> str:
|
||||
"""Case-insensitive uniqueness key — LOWER(TRIM(name)).
|
||||
|
||||
Mirrors the same convention used by Locations (#1505) so the catalog
|
||||
can't end up with "Toys" + "toys" + " TOYS " as separate rows. Empty
|
||||
string after stripping is rejected by Pydantic min_length, so this
|
||||
helper trusts its input.
|
||||
"""
|
||||
return name.strip().lower()
|
||||
|
||||
|
||||
@router.get("", response_model=list[TagResponse])
|
||||
@router.get("/", response_model=list[TagResponse])
|
||||
async def list_tags(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_ownership_permission(
|
||||
Permission.LIBRARY_READ_ALL,
|
||||
Permission.LIBRARY_READ_OWN,
|
||||
)
|
||||
),
|
||||
) -> list[TagResponse]:
|
||||
"""List every tag in the catalog with the count of files using it.
|
||||
|
||||
Catalog rows are global, so a ``read_own`` caller still sees every tag
|
||||
name — that's just the chip set the rest of the UI offers. But the
|
||||
``file_count`` projection is filtered to their own files so the number
|
||||
matches what they'd see when they filter the listing by that tag.
|
||||
"""
|
||||
user, can_read_all = auth_result
|
||||
|
||||
# Count distinct file_ids per tag via the association table joined back
|
||||
# to LibraryFile so soft-deleted (trashed) files don't inflate the chip
|
||||
# counts shown in the management modal.
|
||||
file_filter = LibraryFile.deleted_at.is_(None)
|
||||
if user is not None and not can_read_all:
|
||||
file_filter = file_filter & (LibraryFile.created_by_id == user.id)
|
||||
|
||||
count_subq = (
|
||||
select(
|
||||
LibraryFileTag.tag_id.label("tag_id"),
|
||||
func.count(distinct(LibraryFile.id)).label("file_count"),
|
||||
)
|
||||
.join(LibraryFile, LibraryFile.id == LibraryFileTag.file_id)
|
||||
.where(file_filter)
|
||||
.group_by(LibraryFileTag.tag_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
query = (
|
||||
select(LibraryTag, func.coalesce(count_subq.c.file_count, 0))
|
||||
.outerjoin(count_subq, count_subq.c.tag_id == LibraryTag.id)
|
||||
.order_by(func.lower(LibraryTag.name))
|
||||
)
|
||||
rows = (await db.execute(query)).all()
|
||||
return [
|
||||
TagResponse(
|
||||
id=t.id,
|
||||
name=t.name,
|
||||
file_count=int(count),
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
)
|
||||
for t, count in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("", response_model=TagResponse, status_code=201)
|
||||
@router.post("/", response_model=TagResponse, status_code=201)
|
||||
async def create_tag(
|
||||
payload: TagCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
|
||||
) -> TagResponse:
|
||||
"""Create a tag. Case-insensitive dup → 409."""
|
||||
key = _name_key(payload.name)
|
||||
tag = LibraryTag(name=payload.name.strip(), name_key=key)
|
||||
db.add(tag)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
# Race condition or actual dup — re-fetch the existing row so the
|
||||
# caller can recover by reading the id from the 409 detail string
|
||||
# if they want to. The body is consistent regardless of cause.
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=409, detail="Tag with this name already exists") from None
|
||||
await db.refresh(tag)
|
||||
return TagResponse(id=tag.id, name=tag.name, file_count=0, created_at=tag.created_at, updated_at=tag.updated_at)
|
||||
|
||||
|
||||
@router.patch("/{tag_id}", response_model=TagResponse)
|
||||
async def update_tag(
|
||||
tag_id: int,
|
||||
payload: TagUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
|
||||
) -> TagResponse:
|
||||
"""Rename a tag. Case-insensitive dup → 409 (own-name no-op is allowed)."""
|
||||
tag = (await db.execute(select(LibraryTag).where(LibraryTag.id == tag_id))).scalar_one_or_none()
|
||||
if tag is None:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
new_key = _name_key(payload.name)
|
||||
if new_key != tag.name_key:
|
||||
# Pre-check so the user gets a clean 409 instead of an IntegrityError
|
||||
# that we'd then have to translate. The post-commit IntegrityError
|
||||
# branch still catches the concurrent-create race.
|
||||
existing = (await db.execute(select(LibraryTag).where(LibraryTag.name_key == new_key))).scalar_one_or_none()
|
||||
if existing is not None and existing.id != tag.id:
|
||||
raise HTTPException(status_code=409, detail="Tag with this name already exists")
|
||||
tag.name = payload.name.strip()
|
||||
tag.name_key = new_key
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
raise HTTPException(status_code=409, detail="Tag with this name already exists") from None
|
||||
await db.refresh(tag)
|
||||
|
||||
# Re-count files for the projection so the caller's modal shows the
|
||||
# right number after the rename.
|
||||
file_count = (
|
||||
await db.execute(select(func.count(LibraryFileTag.file_id)).where(LibraryFileTag.tag_id == tag.id))
|
||||
).scalar_one()
|
||||
return TagResponse(
|
||||
id=tag.id,
|
||||
name=tag.name,
|
||||
file_count=int(file_count or 0),
|
||||
created_at=tag.created_at,
|
||||
updated_at=tag.updated_at,
|
||||
)
|
||||
|
||||
|
||||
# response_model=None is load-bearing under `from __future__ import annotations`:
|
||||
# the `-> None` return annotation reaches FastAPI as the string "None", which it
|
||||
# resolves to NoneType — a truthy class — and then asserts a 204 may carry no
|
||||
# response body. fastapi >= 0.116 special-cases NoneType; on the 0.109-0.115
|
||||
# releases requirements.txt still allows, the app fails at import without this.
|
||||
@router.delete("/{tag_id}", status_code=204, response_model=None)
|
||||
async def delete_tag(
|
||||
tag_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
|
||||
) -> None:
|
||||
"""Delete a tag. Association rows ON DELETE CASCADE — files are untouched."""
|
||||
tag = (await db.execute(select(LibraryTag).where(LibraryTag.id == tag_id))).scalar_one_or_none()
|
||||
if tag is None:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
await db.delete(tag)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/bulk-assign", response_model=TagBulkAssignResponse)
|
||||
async def bulk_assign(
|
||||
payload: TagBulkAssignRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_ownership_permission(
|
||||
Permission.LIBRARY_UPDATE_ALL,
|
||||
Permission.LIBRARY_UPDATE_OWN,
|
||||
)
|
||||
),
|
||||
) -> TagBulkAssignResponse:
|
||||
"""Add / remove / replace tag assignments across multiple files.
|
||||
|
||||
Implemented as set-style operations against the association table —
|
||||
cheaper than re-doing the M2M list per file and idempotent on retries.
|
||||
A caller without ``*_UPDATE_ALL`` can only modify files they created
|
||||
(per the existing ownership pair); silently-skipped files are
|
||||
excluded from the response counts so the UI can detect partial
|
||||
application.
|
||||
"""
|
||||
user, can_update_all = auth_result
|
||||
|
||||
# Resolve the file scope FIRST — anything not visible to the caller is
|
||||
# quietly dropped, so a malicious or buggy client can't tag files it
|
||||
# doesn't own. This is the same posture as bulk-delete in
|
||||
# library_trash.py.
|
||||
file_q = select(LibraryFile.id).where(
|
||||
LibraryFile.id.in_(payload.file_ids),
|
||||
LibraryFile.deleted_at.is_(None),
|
||||
)
|
||||
if user is not None and not can_update_all:
|
||||
file_q = file_q.where(LibraryFile.created_by_id == user.id)
|
||||
file_ids = list((await db.execute(file_q)).scalars().all())
|
||||
if not file_ids:
|
||||
return TagBulkAssignResponse(files_updated=0, associations_added=0, associations_removed=0)
|
||||
|
||||
# Validate tag ids exist. Unknown tag_ids are silently dropped from
|
||||
# the operation rather than raising — matches the bulk-trash shape
|
||||
# and keeps a partial-success result usable.
|
||||
tag_ids: list[int] = []
|
||||
if payload.tag_ids:
|
||||
tag_ids = list(
|
||||
(await db.execute(select(LibraryTag.id).where(LibraryTag.id.in_(payload.tag_ids)))).scalars().all()
|
||||
)
|
||||
|
||||
added = 0
|
||||
removed = 0
|
||||
|
||||
if payload.action == "add":
|
||||
if not tag_ids:
|
||||
return TagBulkAssignResponse(files_updated=0, associations_added=0, associations_removed=0)
|
||||
# Insert (file_id, tag_id) for every pair that doesn't already exist.
|
||||
# We could use INSERT ... ON CONFLICT DO NOTHING for Postgres + SQLite
|
||||
# 3.24+ but the explicit pre-check keeps the SQLAlchemy core dialect
|
||||
# neutral and lets us count what actually got added.
|
||||
existing = set(
|
||||
(
|
||||
await db.execute(
|
||||
select(LibraryFileTag.file_id, LibraryFileTag.tag_id).where(
|
||||
LibraryFileTag.file_id.in_(file_ids),
|
||||
LibraryFileTag.tag_id.in_(tag_ids),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
)
|
||||
to_insert = [
|
||||
{"file_id": fid, "tag_id": tid} for fid in file_ids for tid in tag_ids if (fid, tid) not in existing
|
||||
]
|
||||
if to_insert:
|
||||
await db.execute(LibraryFileTag.__table__.insert(), to_insert)
|
||||
added = len(to_insert)
|
||||
elif payload.action == "remove":
|
||||
if not tag_ids:
|
||||
return TagBulkAssignResponse(files_updated=0, associations_added=0, associations_removed=0)
|
||||
result = await db.execute(
|
||||
delete(LibraryFileTag).where(
|
||||
LibraryFileTag.file_id.in_(file_ids),
|
||||
LibraryFileTag.tag_id.in_(tag_ids),
|
||||
)
|
||||
)
|
||||
removed = int(result.rowcount or 0)
|
||||
elif payload.action == "replace":
|
||||
# Strip everything currently on these files, then INSERT the new set.
|
||||
del_result = await db.execute(delete(LibraryFileTag).where(LibraryFileTag.file_id.in_(file_ids)))
|
||||
removed = int(del_result.rowcount or 0)
|
||||
if tag_ids:
|
||||
await db.execute(
|
||||
LibraryFileTag.__table__.insert(),
|
||||
[{"file_id": fid, "tag_id": tid} for fid in file_ids for tid in tag_ids],
|
||||
)
|
||||
added = len(file_ids) * len(tag_ids)
|
||||
|
||||
await db.commit()
|
||||
return TagBulkAssignResponse(
|
||||
files_updated=len(file_ids),
|
||||
associations_added=added,
|
||||
associations_removed=removed,
|
||||
)
|
||||
|
|
@ -39,20 +39,6 @@ async def get_status(
|
|||
}
|
||||
|
||||
|
||||
@router.get("/path-check")
|
||||
async def check_path(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
|
||||
):
|
||||
"""Check that the configured output directory can actually be written to.
|
||||
|
||||
Writes and removes a probe file. A path the service cannot write to — a NAS
|
||||
share outside the systemd unit's ReadWritePaths, say — otherwise only shows
|
||||
up as a failed backup hours later (#2544).
|
||||
"""
|
||||
settings = await local_backup_service._load_settings()
|
||||
return local_backup_service.check_path(settings["path"])
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
async def trigger_backup(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
|
||||
|
|
|
|||
|
|
@ -21,12 +21,7 @@ from fastapi.responses import Response
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.api.routes.cloud import (
|
||||
get_stored_token,
|
||||
is_cloud_token_invalid,
|
||||
mark_cloud_token_invalid,
|
||||
resolve_api_key_cloud_owner,
|
||||
)
|
||||
from backend.app.api.routes.cloud import get_stored_token
|
||||
from backend.app.api.routes.library import save_3mf_bytes_to_library
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
|
|
@ -63,16 +58,10 @@ async def _build_service(db: AsyncSession, user: User | None) -> MakerWorldServi
|
|||
stored Bambu Cloud bearer token when available.
|
||||
|
||||
Mirrors ``cloud.build_authenticated_cloud`` — the token is entirely
|
||||
optional; anonymous calls (metadata, URL resolution) still work — and,
|
||||
like it, records a rejected token so the whole app agrees the sign-in is
|
||||
dead rather than each feature failing on its own.
|
||||
optional; anonymous calls (metadata, URL resolution) still work.
|
||||
"""
|
||||
token, _email, _region = await get_stored_token(db, user)
|
||||
user_id = user.id if user is not None else None
|
||||
return MakerWorldService(
|
||||
auth_token=token,
|
||||
on_auth_failure=lambda: mark_cloud_token_invalid(user_id),
|
||||
)
|
||||
return MakerWorldService(auth_token=token)
|
||||
|
||||
|
||||
def _canonical_url(model_id: int, profile_id: int | None = None) -> str:
|
||||
|
|
@ -154,29 +143,11 @@ async def proxy_thumbnail(
|
|||
async def get_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
|
||||
api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
|
||||
):
|
||||
"""Report whether the caller can import 3MFs (needs a Bambu Cloud token).
|
||||
|
||||
API-keyed callers (which return None from ``current_user``) get the
|
||||
owner User via ``resolve_api_key_cloud_owner`` when the key carries the
|
||||
cloud-access scope, so ``has_cloud_token`` reflects the owning user's
|
||||
stored token rather than always reporting ``False`` (#1777, same shape
|
||||
as the cloud-presets fix in #1182).
|
||||
"""
|
||||
cloud_token_user = current_user or api_key_cloud_owner
|
||||
token, _email, _region = await get_stored_token(db, cloud_token_user)
|
||||
"""Report whether the caller can import 3MFs (needs a Bambu Cloud token)."""
|
||||
token, _email, _region = await get_stored_token(db, current_user)
|
||||
has_token = bool(token)
|
||||
# A token Bambu has already rejected downloads nothing. ``can_download``
|
||||
# used to be a bare alias for ``has_cloud_token``, so the import button
|
||||
# stayed enabled against a dead credential and the user found out via a
|
||||
# 401 toast (#2562 follow-up).
|
||||
expired = has_token and await is_cloud_token_invalid(db, cloud_token_user)
|
||||
return MakerWorldStatus(
|
||||
has_cloud_token=has_token,
|
||||
can_download=has_token and not expired,
|
||||
sign_in_expired=expired,
|
||||
)
|
||||
return MakerWorldStatus(has_cloud_token=has_token, can_download=has_token)
|
||||
|
||||
|
||||
@router.post("/resolve", response_model=MakerWorldResolvedModel)
|
||||
|
|
@ -184,7 +155,6 @@ async def resolve_url(
|
|||
body: MakerWorldResolveRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
|
||||
api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
|
||||
):
|
||||
"""Resolve a MakerWorld URL to full model metadata + plate list.
|
||||
|
||||
|
|
@ -197,10 +167,7 @@ async def resolve_url(
|
|||
except MakerWorldError as exc:
|
||||
raise _map_service_error(exc) from exc
|
||||
|
||||
# API-keyed callers carry identity on the key, not in current_user — see
|
||||
# the /status handler comment and #1777 / #1182.
|
||||
cloud_token_user = current_user or api_key_cloud_owner
|
||||
service = await _build_service(db, cloud_token_user)
|
||||
service = await _build_service(db, current_user)
|
||||
try:
|
||||
design = await service.get_design(model_id)
|
||||
instances_envelope = await service.get_design_instances(model_id)
|
||||
|
|
@ -273,7 +240,6 @@ async def import_instance(
|
|||
body: MakerWorldImportRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_IMPORT),
|
||||
api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
|
||||
):
|
||||
"""Download a specific MakerWorld instance (plate configuration) and save
|
||||
the 3MF into the library.
|
||||
|
|
@ -312,12 +278,7 @@ async def import_instance(
|
|||
await db.flush()
|
||||
effective_folder_id = mw_folder.id
|
||||
|
||||
# API-keyed callers carry identity on the key, not in current_user — see
|
||||
# the /status handler comment and #1777 / #1182. The same resolved user
|
||||
# is reused for owner_id on save_3mf_bytes_to_library below so the
|
||||
# library row is attributed to the key's owner rather than NULL.
|
||||
cloud_token_user = current_user or api_key_cloud_owner
|
||||
service = await _build_service(db, cloud_token_user)
|
||||
service = await _build_service(db, current_user)
|
||||
|
||||
# YASTL#51's iot-service endpoint needs the *alphanumeric* modelId
|
||||
# (e.g. "US2bb73b106683e5"), not the integer design id from /models/{N}.
|
||||
|
|
@ -426,7 +387,7 @@ async def import_instance(
|
|||
folder_id=effective_folder_id,
|
||||
source_type=_SOURCE_TYPE,
|
||||
source_url=source_url,
|
||||
owner_id=cloud_token_user.id if cloud_token_user else None,
|
||||
owner_id=current_user.id if current_user else None,
|
||||
)
|
||||
|
||||
return MakerWorldImportResponse(
|
||||
|
|
|
|||
|
|
@ -34,20 +34,20 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Requ
|
|||
from fastapi.responses import RedirectResponse
|
||||
from jwt import PyJWKClient
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload, undefer
|
||||
|
||||
from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
|
||||
from backend.app.api.routes.settings import get_setting, set_setting
|
||||
from backend.app.core.auth import (
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
RequirePermissionIfAuthEnabled,
|
||||
create_access_token,
|
||||
get_current_active_user,
|
||||
get_user_by_email,
|
||||
get_user_by_username,
|
||||
is_auth_enabled,
|
||||
resolve_session_max_minutes,
|
||||
verify_password,
|
||||
)
|
||||
from backend.app.core.database import get_db
|
||||
|
|
@ -467,7 +467,7 @@ def _enforce_auto_link_safety(provider: OIDCProvider) -> None:
|
|||
"""
|
||||
if provider.auto_link_existing_accounts and provider.email_claim == "email" and not provider.require_email_verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=AUTO_LINK_REQUIREMENTS_ERROR,
|
||||
)
|
||||
|
||||
|
|
@ -1242,7 +1242,7 @@ async def verify_2fa(
|
|||
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.username},
|
||||
expires_delta=timedelta(minutes=await resolve_session_max_minutes(db)),
|
||||
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
)
|
||||
result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
|
||||
user = result.scalar_one()
|
||||
|
|
@ -1258,7 +1258,7 @@ async def verify_2fa(
|
|||
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.username},
|
||||
expires_delta=timedelta(minutes=await resolve_session_max_minutes(db)),
|
||||
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
)
|
||||
|
||||
# Reload with groups for permission calculation
|
||||
|
|
@ -1361,7 +1361,7 @@ async def create_oidc_provider(
|
|||
grp_chk = await db.execute(select(Group).where(Group.id == body.default_group_id))
|
||||
if not grp_chk.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="default_group_id references a non-existent group",
|
||||
)
|
||||
|
||||
|
|
@ -1388,34 +1388,16 @@ async def create_oidc_provider(
|
|||
icon_content_type=icon_content_type,
|
||||
icon_etag=icon_etag,
|
||||
default_group_id=body.default_group_id,
|
||||
is_autologin=body.is_autologin,
|
||||
)
|
||||
# SEC-1 + SEC-6: runtime guard mirrors the OIDCProviderCreate model_validator in schemas/auth.py.
|
||||
# Catches any future path that bypasses Pydantic validation (direct ORM, scripts).
|
||||
_enforce_auto_link_safety(provider)
|
||||
db.add(provider)
|
||||
# #1589: at most one provider may be the autologin target. When a new one
|
||||
# is created with the flag set, clear it on all others first so the
|
||||
# session still satisfies the invariant after add.
|
||||
if body.is_autologin:
|
||||
await db.execute(update(OIDCProvider).where(OIDCProvider.is_autologin.is_(True)).values(is_autologin=False))
|
||||
await db.commit()
|
||||
await db.refresh(provider)
|
||||
return _build_provider_response(provider)
|
||||
|
||||
|
||||
def _refuse_if_env_managed(provider: OIDCProvider) -> None:
|
||||
"""Startup rewrites this provider from BAMBUDDY_OIDC_* on every boot, so an
|
||||
edit here would be accepted and then silently reverted at the next restart.
|
||||
BAMBUDDY_LOCAL_LOGIN (#1589) remains the recovery path if it becomes
|
||||
unusable, so refusing outright cannot lock anyone out."""
|
||||
if provider.is_env_managed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="This OIDC provider is managed by environment variables and cannot be modified.",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/oidc/providers/{provider_id}", response_model=OIDCProviderResponse)
|
||||
async def update_oidc_provider(
|
||||
provider_id: int,
|
||||
|
|
@ -1438,13 +1420,12 @@ async def update_oidc_provider(
|
|||
provider = result2.scalar_one_or_none()
|
||||
if not provider:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
|
||||
_refuse_if_env_managed(provider)
|
||||
|
||||
if body.default_group_id is not None:
|
||||
grp_chk = await db.execute(select(Group).where(Group.id == body.default_group_id))
|
||||
if not grp_chk.scalar_one_or_none():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="default_group_id references a non-existent group",
|
||||
)
|
||||
|
||||
|
|
@ -1490,16 +1471,6 @@ async def update_oidc_provider(
|
|||
# partial updates that each pass schema validation individually but are unsafe together.
|
||||
_enforce_auto_link_safety(provider)
|
||||
|
||||
# #1589: at most one provider may be the autologin target. Clear the flag
|
||||
# on every other provider when this one becomes the autologin. Excludes
|
||||
# the current row so SQLAlchemy doesn't fight our in-memory set above.
|
||||
if body.is_autologin is True:
|
||||
await db.execute(
|
||||
update(OIDCProvider)
|
||||
.where(OIDCProvider.id != provider.id, OIDCProvider.is_autologin.is_(True))
|
||||
.values(is_autologin=False)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(provider)
|
||||
return _build_provider_response(provider)
|
||||
|
|
@ -1516,7 +1487,6 @@ async def delete_oidc_provider(
|
|||
provider = result2.scalar_one_or_none()
|
||||
if not provider:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
|
||||
_refuse_if_env_managed(provider)
|
||||
|
||||
await db.delete(provider)
|
||||
await db.commit()
|
||||
|
|
@ -1585,7 +1555,6 @@ async def delete_oidc_provider_icon(
|
|||
provider = result.scalar_one_or_none()
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
|
||||
_refuse_if_env_managed(provider)
|
||||
|
||||
# Setting deferred columns is safe — no read happens, just a write.
|
||||
provider.icon_url = None
|
||||
|
|
@ -1618,7 +1587,6 @@ async def refresh_oidc_provider_icon(
|
|||
provider = result.scalar_one_or_none()
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
|
||||
_refuse_if_env_managed(provider)
|
||||
if not provider.icon_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
|
@ -2178,7 +2146,7 @@ async def oidc_exchange(
|
|||
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.username},
|
||||
expires_delta=timedelta(minutes=await resolve_session_max_minutes(db)),
|
||||
expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||
)
|
||||
|
||||
return LoginResponse(
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
|
|||
# Printer status events
|
||||
"on_printer_offline": provider.on_printer_offline,
|
||||
"on_printer_error": provider.on_printer_error,
|
||||
"on_ai_failure_detection": provider.on_ai_failure_detection,
|
||||
"on_filament_low": provider.on_filament_low,
|
||||
"on_maintenance_due": provider.on_maintenance_due,
|
||||
# AMS environmental alarms (regular AMS)
|
||||
|
|
@ -58,7 +57,6 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
|
|||
"on_ams_ht_temperature_high": provider.on_ams_ht_temperature_high,
|
||||
# Build plate detection
|
||||
"on_plate_not_empty": provider.on_plate_not_empty,
|
||||
"on_plate_clear_required": provider.on_plate_clear_required,
|
||||
# Bed cooled
|
||||
"on_bed_cooled": provider.on_bed_cooled,
|
||||
# First layer complete
|
||||
|
|
@ -129,7 +127,6 @@ async def create_notification_provider(
|
|||
# Printer status events
|
||||
on_printer_offline=provider_data.on_printer_offline,
|
||||
on_printer_error=provider_data.on_printer_error,
|
||||
on_ai_failure_detection=provider_data.on_ai_failure_detection,
|
||||
on_filament_low=provider_data.on_filament_low,
|
||||
on_maintenance_due=provider_data.on_maintenance_due,
|
||||
# AMS environmental alarms (regular AMS)
|
||||
|
|
@ -140,7 +137,6 @@ async def create_notification_provider(
|
|||
on_ams_ht_temperature_high=provider_data.on_ams_ht_temperature_high,
|
||||
# Build plate detection
|
||||
on_plate_not_empty=provider_data.on_plate_not_empty,
|
||||
on_plate_clear_required=provider_data.on_plate_clear_required,
|
||||
# Bed cooled
|
||||
on_bed_cooled=provider_data.on_bed_cooled,
|
||||
# First layer complete
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@ router = APIRouter(prefix="/obico", tags=["obico"])
|
|||
|
||||
class TestConnectionRequest(BaseModel):
|
||||
url: str
|
||||
# Omitted entirely = test with the saved token; "" = test with no token.
|
||||
token: str | None = None
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
|
|
@ -39,43 +37,15 @@ async def get_status(
|
|||
}
|
||||
|
||||
|
||||
@router.get("/printer-status")
|
||||
async def get_printer_status(
|
||||
user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
|
||||
):
|
||||
"""Per-printer live classification for the printer cards (#1546).
|
||||
|
||||
Deliberately excludes configuration (ML URL, action, history) so users
|
||||
with printers:read but no settings:read can still render the badge.
|
||||
"""
|
||||
settings = await obico_detection_service._load_settings()
|
||||
enabled_printers = settings["enabled_printers"]
|
||||
# Error strings can embed configured URLs (ML API base, external URL), so
|
||||
# they stay behind settings:read like the rest of the configuration.
|
||||
can_see_error = user is None or user.has_permission(Permission.SETTINGS_READ.value)
|
||||
return {
|
||||
"enabled": settings["enabled"],
|
||||
# None = all printers are monitored
|
||||
"monitored_printers": sorted(enabled_printers) if enabled_printers is not None else None,
|
||||
"per_printer": obico_detection_service.get_per_printer(),
|
||||
"last_error": obico_detection_service._last_error if can_see_error else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/test-connection")
|
||||
async def test_connection(
|
||||
req: TestConnectionRequest,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Ping the Obico ML API health endpoint and check the token. Returns ok + raw body."""
|
||||
"""Ping the Obico ML API `/hc/` health endpoint. Returns ok + raw body."""
|
||||
if not req.url:
|
||||
return {"ok": False, "status_code": None, "body": None, "error": "URL is empty", "auth_ok": None}
|
||||
token = req.token
|
||||
if token is None:
|
||||
# Field omitted entirely — test what the service actually uses.
|
||||
settings = await obico_detection_service._load_settings()
|
||||
token = settings.get("ml_token") or ""
|
||||
return await obico_detection_service.test_connection(req.url, token)
|
||||
return {"ok": False, "status_code": None, "body": None, "error": "URL is empty"}
|
||||
return await obico_detection_service.test_connection(req.url)
|
||||
|
||||
|
||||
@router.get("/cached-frame/{nonce}")
|
||||
|
|
|
|||
|
|
@ -1,34 +1,31 @@
|
|||
"""
|
||||
Orca Cloud API Routes
|
||||
|
||||
Device-pairing (RFC 8628) connect/disconnect + profile sync endpoints for the
|
||||
Orca Cloud external-app surface.
|
||||
PKCE-based connect/disconnect + profile sync endpoints for the
|
||||
Orca Cloud (Supabase) profile-sync surface.
|
||||
|
||||
Auth shape (see :mod:`backend.app.services.orca_cloud` for the deep dive):
|
||||
|
||||
POST /orca-cloud/device/start
|
||||
Request a device code, persist it server-side (TTL 10 min), return the
|
||||
user_code + verification URIs + poll interval.
|
||||
POST /orca-cloud/device/poll
|
||||
One poll of the token endpoint. Returns an in-progress status while the
|
||||
user approves; on approval, persists the token pair and reports
|
||||
connected. The frontend calls this every ``interval`` seconds.
|
||||
POST /orca-cloud/auth/start
|
||||
Generate PKCE + state, persist them (TTL 10 min), return the auth URL.
|
||||
POST /orca-cloud/auth/finish
|
||||
Parse the pasted callback URL, validate state for CSRF, exchange the
|
||||
code for tokens, persist them atomically.
|
||||
GET /orca-cloud/status
|
||||
Connected/disconnected + user_id.
|
||||
Connected/disconnected + email + user_id.
|
||||
POST /orca-cloud/logout
|
||||
Clear stored tokens (Bambuddy then has no token to use; the user can
|
||||
also disconnect from Orca Cloud's own settings to revoke server-side).
|
||||
Clear stored tokens (no Supabase-side revocation — token still
|
||||
survives until its 1h expiry, but Bambuddy has no way to use it).
|
||||
GET /orca-cloud/profiles
|
||||
List of the user's Orca Cloud profiles, grouped by type. JIT-refreshes
|
||||
the access token if it's within the refresh leeway of expiry.
|
||||
Paginated list of the user's Orca Cloud profiles. JIT-refreshes the
|
||||
access token if it's within the 5-min leeway of expiry.
|
||||
GET /orca-cloud/profiles/{id}
|
||||
Single profile's full content.
|
||||
|
||||
Storage shape mirrors the Bambu Cloud surface: per-user columns on ``users``
|
||||
when auth is enabled, fallback to global ``settings`` keys when auth is
|
||||
disabled. The transient pending device-code state (device_code, interval,
|
||||
started_at) reuses the ``orca_cloud_pending_*`` columns — same dual-mode
|
||||
pattern; no schema change from the previous PKCE flow.
|
||||
Storage shape mirrors the Bambu Cloud surface: per-user columns on
|
||||
``users`` when auth is enabled, fallback to global ``settings`` keys when
|
||||
auth is disabled. The transient PKCE state (verifier, state, pending_at)
|
||||
is stored alongside the tokens — same dual-mode pattern.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -36,7 +33,7 @@ from __future__ import annotations
|
|||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -46,19 +43,23 @@ from backend.app.core.permissions import Permission
|
|||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.orca_cloud import (
|
||||
OrcaAuthFinishRequest,
|
||||
OrcaAuthPasswordRequest,
|
||||
OrcaAuthStartRequest,
|
||||
OrcaAuthStartResponse,
|
||||
OrcaAuthStatusResponse,
|
||||
OrcaDevicePollResponse,
|
||||
OrcaDeviceStartResponse,
|
||||
OrcaProfileDetail,
|
||||
OrcaProfileListResponse,
|
||||
OrcaProfileMeta,
|
||||
)
|
||||
from backend.app.services.orca_cloud import (
|
||||
DEVICE_CODE_TTL,
|
||||
DevicePoll,
|
||||
PENDING_PKCE_TTL,
|
||||
OrcaCloudAuthError,
|
||||
OrcaCloudError,
|
||||
OrcaCloudService,
|
||||
build_authorize_url,
|
||||
generate_pkce,
|
||||
parse_callback_url,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -89,9 +90,9 @@ _ORCA_TYPE_TO_BAMBU = {
|
|||
|
||||
|
||||
def _orca_to_setting(orca_profile: dict) -> OrcaProfileMeta | None:
|
||||
"""Normalize one Orca profile (``{id, name, content, ...}``) into a
|
||||
``SlicerSetting``-shaped row. Returns ``None`` if the content isn't a dict
|
||||
or the type isn't one we render."""
|
||||
"""Normalize one Orca ``ProfileUpsert`` (``{id, name, content, ...}``)
|
||||
into a ``SlicerSetting``-shaped row. Returns ``None`` if the content
|
||||
isn't a dict or the type isn't one we render."""
|
||||
content = orca_profile.get("content") or {}
|
||||
if not isinstance(content, dict):
|
||||
return None
|
||||
|
|
@ -129,16 +130,15 @@ def _str_or_none(value: object) -> str | None:
|
|||
|
||||
# Settings table keys for the auth-disabled fallback. Mirrors the Bambu Cloud
|
||||
# pattern (``bambu_cloud_token`` etc.) so administrators inspecting the
|
||||
# settings table see a consistent prefix. The ``pending_*`` keys hold the
|
||||
# transient device-code state (device_code / interval / started_at).
|
||||
# settings table see a consistent prefix.
|
||||
_SETTINGS_KEYS = {
|
||||
"token": "orca_cloud_token",
|
||||
"refresh_token": "orca_cloud_refresh_token",
|
||||
"expires_at": "orca_cloud_expires_at", # ISO 8601 UTC string
|
||||
"email": "orca_cloud_email",
|
||||
"user_id": "orca_cloud_user_id",
|
||||
"pending_device_code": "orca_cloud_pending_verifier", # reused column
|
||||
"pending_interval": "orca_cloud_pending_state", # reused column
|
||||
"pending_verifier": "orca_cloud_pending_verifier",
|
||||
"pending_state": "orca_cloud_pending_state",
|
||||
"pending_at": "orca_cloud_pending_at", # ISO 8601 UTC string
|
||||
}
|
||||
|
||||
|
|
@ -184,11 +184,7 @@ def _parse_iso(value: str | None) -> datetime | None:
|
|||
class _OrcaCredentials:
|
||||
"""Lightweight bag for stored Orca Cloud credentials. We use a class
|
||||
rather than a dataclass so the helpers can mutate it as needed during
|
||||
JIT-refresh without rebuilding the whole object.
|
||||
|
||||
``pending_device_code`` / ``pending_interval`` / ``pending_at`` hold the
|
||||
in-flight device-code pairing state (reusing the ``orca_cloud_pending_*``
|
||||
columns that the old PKCE flow used for its verifier/state)."""
|
||||
JIT-refresh without rebuilding the whole object."""
|
||||
|
||||
__slots__ = (
|
||||
"token",
|
||||
|
|
@ -196,8 +192,8 @@ class _OrcaCredentials:
|
|||
"expires_at",
|
||||
"email",
|
||||
"user_id",
|
||||
"pending_device_code",
|
||||
"pending_interval",
|
||||
"pending_verifier",
|
||||
"pending_state",
|
||||
"pending_at",
|
||||
)
|
||||
|
||||
|
|
@ -207,8 +203,8 @@ class _OrcaCredentials:
|
|||
self.expires_at: datetime | None = None
|
||||
self.email: str | None = None
|
||||
self.user_id: str | None = None
|
||||
self.pending_device_code: str | None = None
|
||||
self.pending_interval: str | None = None
|
||||
self.pending_verifier: str | None = None
|
||||
self.pending_state: str | None = None
|
||||
self.pending_at: datetime | None = None
|
||||
|
||||
|
||||
|
|
@ -231,8 +227,8 @@ async def _load_credentials(db: AsyncSession, user: User | None) -> _OrcaCredent
|
|||
creds.expires_at = _as_utc(user.orca_cloud_expires_at)
|
||||
creds.email = user.orca_cloud_email
|
||||
creds.user_id = user.orca_cloud_user_id
|
||||
creds.pending_device_code = user.orca_cloud_pending_verifier
|
||||
creds.pending_interval = user.orca_cloud_pending_state
|
||||
creds.pending_verifier = user.orca_cloud_pending_verifier
|
||||
creds.pending_state = user.orca_cloud_pending_state
|
||||
creds.pending_at = _as_utc(user.orca_cloud_pending_at)
|
||||
return creds
|
||||
|
||||
|
|
@ -243,28 +239,27 @@ async def _load_credentials(db: AsyncSession, user: User | None) -> _OrcaCredent
|
|||
creds.expires_at = _parse_iso(raw.get(_SETTINGS_KEYS["expires_at"]))
|
||||
creds.email = raw.get(_SETTINGS_KEYS["email"])
|
||||
creds.user_id = raw.get(_SETTINGS_KEYS["user_id"])
|
||||
creds.pending_device_code = raw.get(_SETTINGS_KEYS["pending_device_code"])
|
||||
creds.pending_interval = raw.get(_SETTINGS_KEYS["pending_interval"])
|
||||
creds.pending_verifier = raw.get(_SETTINGS_KEYS["pending_verifier"])
|
||||
creds.pending_state = raw.get(_SETTINGS_KEYS["pending_state"])
|
||||
creds.pending_at = _parse_iso(raw.get(_SETTINGS_KEYS["pending_at"]))
|
||||
return creds
|
||||
|
||||
|
||||
async def _persist_pending_device(
|
||||
async def _persist_pending_pkce(
|
||||
db: AsyncSession,
|
||||
user: User | None,
|
||||
device_code: str,
|
||||
interval: int,
|
||||
verifier: str,
|
||||
state: str,
|
||||
when: datetime,
|
||||
) -> None:
|
||||
"""Store the transient device-code state used by ``/device/start`` ->
|
||||
``/device/poll``. The device_code is a secret kept server-side."""
|
||||
"""Store the transient PKCE state used by ``/auth/start`` -> ``/auth/finish``."""
|
||||
if user is not None:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(
|
||||
orca_cloud_pending_verifier=device_code,
|
||||
orca_cloud_pending_state=str(interval),
|
||||
orca_cloud_pending_verifier=verifier,
|
||||
orca_cloud_pending_state=state,
|
||||
orca_cloud_pending_at=when,
|
||||
)
|
||||
)
|
||||
|
|
@ -273,38 +268,13 @@ async def _persist_pending_device(
|
|||
await _upsert_settings(
|
||||
db,
|
||||
{
|
||||
_SETTINGS_KEYS["pending_device_code"]: device_code,
|
||||
_SETTINGS_KEYS["pending_interval"]: str(interval),
|
||||
_SETTINGS_KEYS["pending_verifier"]: verifier,
|
||||
_SETTINGS_KEYS["pending_state"]: state,
|
||||
_SETTINGS_KEYS["pending_at"]: _iso(when),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _clear_pending_device(db: AsyncSession, user: User | None) -> None:
|
||||
"""Wipe just the pending device-code state (on terminal poll outcomes),
|
||||
leaving any existing tokens untouched."""
|
||||
if user is not None:
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.id == user.id)
|
||||
.values(
|
||||
orca_cloud_pending_verifier=None,
|
||||
orca_cloud_pending_state=None,
|
||||
orca_cloud_pending_at=None,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
return
|
||||
await _upsert_settings(
|
||||
db,
|
||||
{
|
||||
_SETTINGS_KEYS["pending_device_code"]: None,
|
||||
_SETTINGS_KEYS["pending_interval"]: None,
|
||||
_SETTINGS_KEYS["pending_at"]: None,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _persist_tokens(
|
||||
db: AsyncSession,
|
||||
user: User | None,
|
||||
|
|
@ -315,8 +285,8 @@ async def _persist_tokens(
|
|||
user_id: str | None,
|
||||
) -> None:
|
||||
"""Atomically write the new access/refresh pair to whichever backing store
|
||||
the deployment uses. Also clears the pending device-code state on the same
|
||||
write, since by this point the pairing is complete."""
|
||||
the deployment uses. Also clears the pending PKCE state on the same write,
|
||||
since by this point the handshake is complete."""
|
||||
if user is not None:
|
||||
await db.execute(
|
||||
update(User)
|
||||
|
|
@ -342,8 +312,8 @@ async def _persist_tokens(
|
|||
_SETTINGS_KEYS["expires_at"]: _iso(expires_at),
|
||||
_SETTINGS_KEYS["email"]: email,
|
||||
_SETTINGS_KEYS["user_id"]: user_id,
|
||||
_SETTINGS_KEYS["pending_device_code"]: None,
|
||||
_SETTINGS_KEYS["pending_interval"]: None,
|
||||
_SETTINGS_KEYS["pending_verifier"]: None,
|
||||
_SETTINGS_KEYS["pending_state"]: None,
|
||||
_SETTINGS_KEYS["pending_at"]: None,
|
||||
},
|
||||
)
|
||||
|
|
@ -357,7 +327,7 @@ async def _persist_rotated_tokens(
|
|||
expires_at: datetime | None,
|
||||
) -> None:
|
||||
"""Persist tokens after a refresh — does NOT touch email/user_id and does
|
||||
NOT touch the pending state (refresh happens long after pairing)."""
|
||||
NOT touch the pending PKCE state (refresh happens long after the handshake)."""
|
||||
if user is not None:
|
||||
await db.execute(
|
||||
update(User)
|
||||
|
|
@ -431,34 +401,11 @@ async def _upsert_settings(db: AsyncSession, values: dict[str, str | None]) -> N
|
|||
async def _build_authenticated_service(
|
||||
db: AsyncSession,
|
||||
user: User | None,
|
||||
clear_on_auth_failure: bool = True,
|
||||
) -> OrcaCloudService:
|
||||
"""Construct an :class:`OrcaCloudService` pre-populated with stored
|
||||
credentials. If the access token is within the refresh-leeway of expiry,
|
||||
proactively refresh and persist the new pair BEFORE returning, so the
|
||||
next API call doesn't time out mid-flight on an expired token.
|
||||
|
||||
We don't lock around the refresh: Orca tolerates concurrent refreshes for
|
||||
~60s (each racer gets its own valid pair on the same connection rather than
|
||||
a revoke), so a lost race here is harmless — last-write-wins on the stored
|
||||
pair, and whichever pair we keep is valid.
|
||||
|
||||
``clear_on_auth_failure`` controls what happens when the refresh is
|
||||
rejected. Routes leave it on: the caller is a person looking at the UI, and
|
||||
wiping the dead credentials flips the page to disconnected in front of them
|
||||
so they can pair again. Background jobs pass ``False`` — see the caveat
|
||||
below.
|
||||
|
||||
Why background callers must not clear: Orca reports every rejection with
|
||||
one composite reason (``unknown, expired, revoked, or already used``), so
|
||||
a genuine revocation is indistinguishable from a lost refresh-rotation
|
||||
race. Acting destructively on a signal that can't be disambiguated is the
|
||||
#2562 mistake in a different cloud. It also gains nothing — a route call
|
||||
hits the same failure and clears then, at a moment the user can respond to.
|
||||
A successful refresh is still persisted either way: by that point the old
|
||||
refresh token is consumed, so dropping the new pair would break a working
|
||||
pairing for real.
|
||||
"""
|
||||
next API call doesn't time out mid-flight on an expired token."""
|
||||
creds = await _load_credentials(db, user)
|
||||
if not creds.token:
|
||||
raise HTTPException(status_code=401, detail="Orca Cloud is not connected — sign in first.")
|
||||
|
|
@ -475,11 +422,8 @@ async def _build_authenticated_service(
|
|||
await svc.refresh()
|
||||
except OrcaCloudAuthError as e:
|
||||
# Refresh token was revoked or rotated out from under us. Clear
|
||||
# the stale credentials so the UI flips to disconnected — unless
|
||||
# the caller is a background job, which must not change sign-in
|
||||
# state on its own.
|
||||
if clear_on_auth_failure:
|
||||
await _clear_credentials(db, user)
|
||||
# the stale credentials so the UI flips to disconnected.
|
||||
await _clear_credentials(db, user)
|
||||
raise HTTPException(status_code=401, detail=f"Orca Cloud session refresh failed: {e}") from e
|
||||
except OrcaCloudError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
|
||||
|
|
@ -495,97 +439,128 @@ async def _build_authenticated_service(
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/device/start", response_model=OrcaDeviceStartResponse)
|
||||
async def device_start(
|
||||
request: Request,
|
||||
@router.post("/auth/start", response_model=OrcaAuthStartResponse)
|
||||
async def auth_start(
|
||||
payload: OrcaAuthStartRequest = OrcaAuthStartRequest(),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
|
||||
):
|
||||
"""Begin device pairing. Requests a device code from Orca, stores it
|
||||
server-side (the device_code is a secret and never leaves the backend),
|
||||
and returns the user_code + verification URIs + poll interval for the
|
||||
frontend to display and poll against."""
|
||||
"""Generate PKCE state and return the Supabase authorize URL for the
|
||||
requested OAuth provider (google / apple / github). The frontend opens
|
||||
the URL in a new tab; after sign-in the user pastes the callback URL
|
||||
back into ``/auth/finish``.
|
||||
|
||||
``state`` is generated but NOT sent to Supabase (it would clash with
|
||||
GoTrue's internal redirect_to-tracking state). We still persist it so
|
||||
a future flow change can re-introduce state-based CSRF if needed; CSRF
|
||||
protection today comes from the PKCE verifier itself, which is
|
||||
single-use, server-side, and bound to the caller's user row."""
|
||||
verifier, challenge, state = generate_pkce()
|
||||
await _persist_pending_pkce(db, current_user, verifier, state, datetime.now(timezone.utc))
|
||||
return OrcaAuthStartResponse(auth_url=build_authorize_url(challenge, provider=payload.provider))
|
||||
|
||||
|
||||
@router.post("/auth/password", response_model=OrcaAuthStatusResponse)
|
||||
async def auth_password(
|
||||
payload: OrcaAuthPasswordRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
|
||||
):
|
||||
"""Direct email+password sign-in. No browser redirect, no paste flow —
|
||||
Bambuddy POSTs the credentials to Supabase and stores the returned
|
||||
tokens. Whether this succeeds depends on Orca's Supabase project
|
||||
accepting the password grant; if it rejects (the SDK refuses passwords
|
||||
by design, the backend may follow suit), the caller falls back to an
|
||||
OAuth provider via ``/auth/start``."""
|
||||
svc = OrcaCloudService()
|
||||
# instance_url/label are display-only anti-phishing context on the approval
|
||||
# card. base_url may be off behind a reverse proxy, but it's harmless if so.
|
||||
instance_url = str(request.base_url).rstrip("/") or None
|
||||
try:
|
||||
data = await svc.request_device_code(instance_url=instance_url, instance_label="Bambuddy")
|
||||
await svc.password_login(payload.email, payload.password)
|
||||
except OrcaCloudAuthError as e:
|
||||
# invalid_client etc. — an operator misconfiguration, not user error.
|
||||
raise HTTPException(status_code=502, detail=f"Orca Cloud pairing is misconfigured: {e}") from e
|
||||
raise HTTPException(status_code=400, detail=f"Orca Cloud rejected the sign-in: {e}") from e
|
||||
except OrcaCloudError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
|
||||
|
||||
device_code = data.get("device_code")
|
||||
user_code = data.get("user_code")
|
||||
if not device_code or not user_code:
|
||||
raise HTTPException(status_code=502, detail="Orca Cloud returned an incomplete device-code response.")
|
||||
email: str | None = None
|
||||
user_id: str | None = None
|
||||
try:
|
||||
user_info = await svc.get_user_info()
|
||||
if isinstance(user_info, dict):
|
||||
email = user_info.get("email")
|
||||
user_id = user_info.get("id")
|
||||
except OrcaCloudError as e:
|
||||
logger.warning("Orca Cloud user-info fetch failed after successful password auth: %s", e)
|
||||
|
||||
interval = int(data.get("interval") or 5)
|
||||
expires_in = int(data.get("expires_in") or DEVICE_CODE_TTL.total_seconds())
|
||||
await _persist_pending_device(db, current_user, device_code, interval, datetime.now(timezone.utc))
|
||||
|
||||
return OrcaDeviceStartResponse(
|
||||
user_code=user_code,
|
||||
verification_uri=str(data.get("verification_uri") or ""),
|
||||
verification_uri_complete=str(data.get("verification_uri_complete") or ""),
|
||||
interval=interval,
|
||||
expires_in=expires_in,
|
||||
)
|
||||
await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, email, user_id)
|
||||
return OrcaAuthStatusResponse(connected=True, email=email, user_id=user_id)
|
||||
|
||||
|
||||
@router.post("/device/poll", response_model=OrcaDevicePollResponse)
|
||||
async def device_poll(
|
||||
@router.post("/auth/finish", response_model=OrcaAuthStatusResponse)
|
||||
async def auth_finish(
|
||||
payload: OrcaAuthFinishRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
|
||||
):
|
||||
"""Poll the token endpoint once for the in-flight pairing. Returns an
|
||||
in-progress status while the user approves; on approval persists the token
|
||||
pair (clearing the pending state) and reports connected."""
|
||||
"""Complete the PKCE handshake — parse the pasted callback URL, validate
|
||||
state (CSRF), exchange the code for tokens, persist."""
|
||||
creds = await _load_credentials(db, current_user)
|
||||
if not creds.pending_device_code or not creds.pending_at:
|
||||
if not creds.pending_verifier or not creds.pending_state or not creds.pending_at:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No pending Orca Cloud pairing. Click Connect first to start the flow.",
|
||||
detail="No pending Orca Cloud sign-in. Click Connect first to start the flow.",
|
||||
)
|
||||
|
||||
# creds.pending_at is already tz-aware UTC after _load_credentials' _as_utc
|
||||
# normalization. Subtracting two aware UTC datetimes gives a real delta.
|
||||
# normalization. Subtracting two aware UTC datetimes gives a real wall-clock
|
||||
# delta with no local-offset shift.
|
||||
age = datetime.now(timezone.utc) - creds.pending_at
|
||||
if age > DEVICE_CODE_TTL:
|
||||
await _clear_pending_device(db, current_user)
|
||||
return OrcaDevicePollResponse(status=DevicePoll.EXPIRED, connected=False)
|
||||
if age > PENDING_PKCE_TTL:
|
||||
# Don't leave the stale state in the DB — clear it so the user has to
|
||||
# restart fresh, which forces a new verifier/state pair.
|
||||
await _persist_pending_pkce(db, current_user, "", "", datetime.fromtimestamp(0, tz=timezone.utc))
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"The Orca Cloud sign-in flow expired after {PENDING_PKCE_TTL.total_seconds() / 60:.0f} minutes. "
|
||||
"Click Connect again to start over."
|
||||
),
|
||||
)
|
||||
|
||||
code, _callback_state = parse_callback_url(payload.callback_url)
|
||||
if not code:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No `code` parameter in the pasted callback URL. Copy the full URL from your browser's address bar.",
|
||||
)
|
||||
# We do NOT validate ``state`` here: Supabase doesn't echo back a state we
|
||||
# don't send (see :func:`build_authorize_url` for why we can't send one).
|
||||
# CSRF is protected by PKCE: the verifier is server-side and single-use,
|
||||
# so an attacker can't complete the exchange with a code they obtained
|
||||
# separately. ``pending_state`` is still stored for forward compatibility
|
||||
# if Supabase ever supports a client-passed state alongside redirect_to.
|
||||
|
||||
svc = OrcaCloudService()
|
||||
try:
|
||||
status, token_data = await svc.poll_token(creds.pending_device_code)
|
||||
await svc.exchange_code(code, creds.pending_verifier)
|
||||
except OrcaCloudAuthError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Orca Cloud rejected the sign-in: {e}") from e
|
||||
except OrcaCloudError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
|
||||
|
||||
if status in DevicePoll.ONGOING:
|
||||
return OrcaDevicePollResponse(status=status, connected=False)
|
||||
|
||||
if status in DevicePoll.TERMINAL:
|
||||
# access_denied / expired_token — the attempt is dead; clear it so the
|
||||
# user starts fresh next time.
|
||||
await _clear_pending_device(db, current_user)
|
||||
return OrcaDevicePollResponse(status=status, connected=False)
|
||||
|
||||
# COMPLETE — tokens issued and applied to svc. Introspect for the user_id
|
||||
# (the external API's /me doesn't return an email, so email stays None).
|
||||
# Fetch user info so we can show the connected email in the UI.
|
||||
email: str | None = None
|
||||
user_id: str | None = None
|
||||
try:
|
||||
info = await svc.introspect()
|
||||
if isinstance(info, dict):
|
||||
user_id = _str_or_none(info.get("user_id"))
|
||||
user_info = await svc.get_user_info()
|
||||
if isinstance(user_info, dict):
|
||||
email = user_info.get("email")
|
||||
user_id = user_info.get("id")
|
||||
except OrcaCloudError as e:
|
||||
# Don't fail the whole pairing over the side introspection call — we
|
||||
# have valid tokens, which is the load-bearing part.
|
||||
logger.warning("Orca Cloud introspection failed after successful pairing: %s", e)
|
||||
# Don't fail the whole connect flow just because the user-info side
|
||||
# call hiccuped — we have valid tokens, that's the load-bearing part.
|
||||
logger.warning("Orca Cloud user-info fetch failed after successful auth: %s", e)
|
||||
|
||||
await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, None, user_id)
|
||||
return OrcaDevicePollResponse(status=DevicePoll.COMPLETE, connected=True, email=None, user_id=user_id)
|
||||
await _persist_tokens(db, current_user, svc.access_token, svc.refresh_token, svc.token_expiry, email, user_id)
|
||||
return OrcaAuthStatusResponse(connected=True, email=email, user_id=user_id)
|
||||
|
||||
|
||||
@router.get("/status", response_model=OrcaAuthStatusResponse)
|
||||
|
|
@ -608,9 +583,9 @@ async def logout(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = cloud_caller(Permission.ORCA_CLOUD_AUTH),
|
||||
):
|
||||
"""Clear stored Orca Cloud credentials. Does not call Orca's disconnect
|
||||
endpoint (the user can revoke server-side from Orca Cloud's own settings;
|
||||
Bambuddy will no longer have the token to use either way)."""
|
||||
"""Clear stored Orca Cloud credentials. Does not call Supabase's
|
||||
``/logout`` endpoint (the token would still survive its 1h expiry there
|
||||
either way, and Bambuddy will no longer have it to use)."""
|
||||
await _clear_credentials(db, current_user)
|
||||
return {"success": True}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from pydantic import BaseModel
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_ownership_permission
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.pending_upload import PendingUpload
|
||||
|
|
@ -91,12 +91,7 @@ async def _augment_with_display_name(
|
|||
@router.get("/", response_model=list[PendingUploadResponse])
|
||||
async def list_pending_uploads(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: tuple[User | None, bool] = Depends(
|
||||
require_ownership_permission(
|
||||
Permission.QUEUE_READ_ALL,
|
||||
Permission.QUEUE_READ_OWN,
|
||||
)
|
||||
),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
|
||||
):
|
||||
"""List all pending uploads."""
|
||||
result = await db.execute(
|
||||
|
|
@ -109,12 +104,7 @@ async def list_pending_uploads(
|
|||
@router.get("/count")
|
||||
async def get_pending_count(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: tuple[User | None, bool] = Depends(
|
||||
require_ownership_permission(
|
||||
Permission.QUEUE_READ_ALL,
|
||||
Permission.QUEUE_READ_OWN,
|
||||
)
|
||||
),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
|
||||
):
|
||||
"""Get count of pending uploads."""
|
||||
result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
|
||||
|
|
@ -218,12 +208,7 @@ async def discard_all_pending(
|
|||
async def get_pending_upload(
|
||||
upload_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: tuple[User | None, bool] = Depends(
|
||||
require_ownership_permission(
|
||||
Permission.QUEUE_READ_ALL,
|
||||
Permission.QUEUE_READ_OWN,
|
||||
)
|
||||
),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
|
||||
):
|
||||
"""Get a specific pending upload."""
|
||||
result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
|
||||
|
|
|
|||
|
|
@ -1,968 +0,0 @@
|
|||
"""API routes for Slicer Pipeline runs (#1425 PR B + PR C).
|
||||
|
||||
PR B implemented single-target dispatch: one Run-pipeline click =
|
||||
slice the source once → enqueue ONE print on ``target_printer_id``.
|
||||
|
||||
PR C extends this with:
|
||||
* ``copies > 1`` — slice once, enqueue N copies.
|
||||
* ``target_kind='printer_class'`` — pipeline targets a Bambu model code
|
||||
(X1C / P1S / H2D / …); orchestrator distributes copies across matching
|
||||
printers using the pipeline's ``fanout_strategy``.
|
||||
* Retry-failed runs that re-attempt only the failed/cancelled copies of
|
||||
a partial-failure run.
|
||||
* Dashboard list endpoint (``GET /pipeline-runs``) with status + pipeline
|
||||
filters and pagination.
|
||||
* WebSocket ``pipeline_run_updated`` events on state transitions so the
|
||||
dashboard refreshes live without polling.
|
||||
|
||||
The slice itself runs through ``slice_dispatch`` (same path as the manual
|
||||
SliceModal), so the ``Slicing X — Generating G-code 75%`` toast renders
|
||||
end-to-end. The slice job's id rides on the run response so the frontend
|
||||
can call ``trackJob`` directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import delete, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.config import settings as app_settings
|
||||
from backend.app.core.database import async_session, get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.core.websocket import ws_manager
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.library import LibraryFile
|
||||
from backend.app.models.pipeline_run import PipelineJob, PipelineRun
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.slicer_pipeline import SlicerPipeline
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.pipeline_run import (
|
||||
CheckEligibilityRequest,
|
||||
EligibilityIssueResponse,
|
||||
EligibilityReportResponse,
|
||||
PerPrinterReport as PerPrinterReportResponse,
|
||||
PipelineJobResponse,
|
||||
PipelineRunCreateRequest,
|
||||
PipelineRunListResponse,
|
||||
PipelineRunResponse,
|
||||
)
|
||||
from backend.app.schemas.slicer import PresetRef, SliceRequest
|
||||
from backend.app.services.pipeline_eligibility import (
|
||||
EligibilityReport,
|
||||
check_pipeline_eligibility,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
pipeline_run_create_router = APIRouter(prefix="/slicer-pipelines", tags=["Slicer Pipelines"])
|
||||
pipeline_run_router = APIRouter(prefix="/pipeline-runs", tags=["Slicer Pipelines"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _serialise_status(report: EligibilityReport) -> EligibilityReportResponse:
|
||||
return EligibilityReportResponse(
|
||||
ok=report.ok,
|
||||
target_kind=report.target_kind,
|
||||
target_printer_id=report.target_printer_id,
|
||||
target_printer_name=report.target_printer_name,
|
||||
target_model_class=report.target_model_class,
|
||||
issues=[
|
||||
EligibilityIssueResponse(
|
||||
kind=issue.kind,
|
||||
slot_index=issue.slot_index,
|
||||
expected=issue.expected,
|
||||
actual=issue.actual,
|
||||
)
|
||||
for issue in report.issues
|
||||
],
|
||||
printer_reports=[
|
||||
PerPrinterReportResponse(
|
||||
printer_id=r.printer_id,
|
||||
printer_name=r.printer_name,
|
||||
ok=r.ok,
|
||||
issues=[
|
||||
EligibilityIssueResponse(
|
||||
kind=i.kind,
|
||||
slot_index=i.slot_index,
|
||||
expected=i.expected,
|
||||
actual=i.actual,
|
||||
)
|
||||
for i in r.issues
|
||||
],
|
||||
)
|
||||
for r in report.printer_reports
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def _load_pipeline(db: AsyncSession, pipeline_id: int) -> SlicerPipeline:
|
||||
pipeline = (
|
||||
await db.execute(
|
||||
select(SlicerPipeline).where(
|
||||
SlicerPipeline.id == pipeline_id,
|
||||
SlicerPipeline.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if pipeline is None:
|
||||
raise HTTPException(404, "Pipeline not found")
|
||||
return pipeline
|
||||
|
||||
|
||||
async def _load_printer_status(printer_id: int | None) -> dict | None:
|
||||
"""Snapshot the printer_manager's live PrinterState for the eligibility
|
||||
matcher. Returns ``None`` when the printer has no MQTT client."""
|
||||
if printer_id is None:
|
||||
return None
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
|
||||
state = printer_manager.get_status(printer_id)
|
||||
if state is None:
|
||||
return None
|
||||
return {"connected": state.connected, "raw_data": state.raw_data}
|
||||
|
||||
|
||||
def _make_status_lookup():
|
||||
"""Closure that snapshots the printer_manager once per printer_id call.
|
||||
Passed to the matcher's class-targeting branch so it can read live state
|
||||
for every candidate printer."""
|
||||
|
||||
def _lookup(printer_id: int) -> dict | None:
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
|
||||
state = printer_manager.get_status(printer_id)
|
||||
if state is None:
|
||||
return None
|
||||
return {"connected": state.connected, "raw_data": state.raw_data}
|
||||
|
||||
return _lookup
|
||||
|
||||
|
||||
def _slice_request_from_pipeline(pipeline: SlicerPipeline) -> SliceRequest:
|
||||
try:
|
||||
raw_filaments = json.loads(pipeline.filament_presets_json or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raw_filaments = []
|
||||
filament_presets = [
|
||||
PresetRef(source=r["source"], id=r["id"])
|
||||
for r in raw_filaments
|
||||
if isinstance(r, dict) and "source" in r and "id" in r
|
||||
]
|
||||
return SliceRequest(
|
||||
printer_preset=PresetRef(source=pipeline.printer_preset_source, id=pipeline.printer_preset_id),
|
||||
process_preset=PresetRef(source=pipeline.process_preset_source, id=pipeline.process_preset_id),
|
||||
filament_presets=filament_presets,
|
||||
bed_type=pipeline.bed_type,
|
||||
export_3mf=True,
|
||||
)
|
||||
|
||||
|
||||
def _compute_job_status(
|
||||
persisted: str,
|
||||
queue_entry: PrintQueueItem | None,
|
||||
) -> str:
|
||||
if persisted in ("failed", "cancelled", "completed"):
|
||||
return persisted
|
||||
if queue_entry is None:
|
||||
return persisted
|
||||
qs = queue_entry.status
|
||||
if qs == "completed":
|
||||
return "completed"
|
||||
if qs in ("failed", "aborted"):
|
||||
return "failed"
|
||||
if qs == "cancelled":
|
||||
return "cancelled"
|
||||
if qs == "printing":
|
||||
return "printing"
|
||||
return "queued"
|
||||
|
||||
|
||||
def _roll_up_run_status(
|
||||
persisted: str,
|
||||
job_statuses: list[str],
|
||||
) -> str:
|
||||
"""Compute the run-level status from the per-job statuses.
|
||||
|
||||
Terminal-persisted always wins for explicit cancels / hard failures so
|
||||
the dashboard doesn't flicker when one job's queue entry hasn't caught
|
||||
up. Otherwise:
|
||||
- all completed → completed
|
||||
- any in_progress / printing / queued / dispatching → in_progress
|
||||
- any failed alongside any completed → partial_failure
|
||||
- all failed/cancelled → failed
|
||||
"""
|
||||
if persisted in ("cancelled",):
|
||||
return persisted
|
||||
if not job_statuses:
|
||||
return persisted
|
||||
|
||||
completed = sum(1 for s in job_statuses if s == "completed")
|
||||
failed = sum(1 for s in job_statuses if s == "failed")
|
||||
cancelled = sum(1 for s in job_statuses if s == "cancelled")
|
||||
in_flight = sum(1 for s in job_statuses if s in ("printing", "queued", "awaiting_printer", "pending"))
|
||||
total = len(job_statuses)
|
||||
|
||||
if completed == total:
|
||||
return "completed"
|
||||
if in_flight > 0:
|
||||
return "in_progress" if persisted not in ("queued", "slicing", "dispatching") else persisted
|
||||
# All copies are in terminal states.
|
||||
if failed == 0 and cancelled == total:
|
||||
return "cancelled"
|
||||
if completed > 0 and (failed > 0 or cancelled > 0):
|
||||
return "partial_failure"
|
||||
if failed > 0:
|
||||
return "failed"
|
||||
return persisted
|
||||
|
||||
|
||||
async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunResponse:
|
||||
pipeline_name: str | None = None
|
||||
target_kind = None
|
||||
target_printer_id = None
|
||||
target_model_class = None
|
||||
fanout_strategy = None
|
||||
if run.pipeline_id:
|
||||
pipeline = (
|
||||
await db.execute(select(SlicerPipeline).where(SlicerPipeline.id == run.pipeline_id))
|
||||
).scalar_one_or_none()
|
||||
if pipeline:
|
||||
pipeline_name = pipeline.name
|
||||
target_kind = pipeline.target_kind # type: ignore[assignment]
|
||||
target_printer_id = pipeline.target_printer_id
|
||||
target_model_class = pipeline.target_model_class
|
||||
fanout_strategy = pipeline.fanout_strategy # type: ignore[assignment]
|
||||
|
||||
source_filename: str | None = None
|
||||
if run.source_library_file_id:
|
||||
src = (
|
||||
await db.execute(select(LibraryFile).where(LibraryFile.id == run.source_library_file_id))
|
||||
).scalar_one_or_none()
|
||||
source_filename = src.filename if src else None
|
||||
elif run.source_archive_id:
|
||||
arc = (
|
||||
await db.execute(select(PrintArchive).where(PrintArchive.id == run.source_archive_id))
|
||||
).scalar_one_or_none()
|
||||
source_filename = (arc.print_name or arc.filename) if arc else None
|
||||
|
||||
job_rows = (
|
||||
(
|
||||
await db.execute(
|
||||
select(PipelineJob).where(PipelineJob.pipeline_run_id == run.id).order_by(PipelineJob.copy_index)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
job_responses: list[PipelineJobResponse] = []
|
||||
job_live_statuses: list[str] = []
|
||||
for job in job_rows:
|
||||
queue_entry = None
|
||||
if job.queue_entry_id:
|
||||
queue_entry = (
|
||||
await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
|
||||
).scalar_one_or_none()
|
||||
|
||||
printer_name: str | None = None
|
||||
if job.assigned_printer_id:
|
||||
p = (await db.execute(select(Printer).where(Printer.id == job.assigned_printer_id))).scalar_one_or_none()
|
||||
printer_name = p.name if p else None
|
||||
|
||||
live_job_status = _compute_job_status(job.status, queue_entry)
|
||||
# If the job WAS dispatched (had a queue_entry_id) but the entry has
|
||||
# since been deleted from the queue page, the user's intent was
|
||||
# cancellation. Otherwise the run would stay forever showing as
|
||||
# ``queued`` because the persisted job.status hasn't been updated.
|
||||
if (
|
||||
job.queue_entry_id is not None
|
||||
and queue_entry is None
|
||||
and live_job_status not in ("completed", "failed", "cancelled")
|
||||
):
|
||||
live_job_status = "cancelled"
|
||||
job_live_statuses.append(live_job_status)
|
||||
job_responses.append(
|
||||
PipelineJobResponse(
|
||||
id=job.id,
|
||||
pipeline_run_id=job.pipeline_run_id,
|
||||
copy_index=job.copy_index,
|
||||
assigned_printer_id=job.assigned_printer_id,
|
||||
assigned_printer_name=printer_name,
|
||||
queue_entry_id=job.queue_entry_id,
|
||||
status=live_job_status, # type: ignore[arg-type]
|
||||
error_message=job.error_message,
|
||||
dispatched_at=job.dispatched_at,
|
||||
completed_at=job.completed_at,
|
||||
)
|
||||
)
|
||||
|
||||
rolled_up = _roll_up_run_status(run.status, job_live_statuses)
|
||||
|
||||
return PipelineRunResponse(
|
||||
id=run.id,
|
||||
pipeline_id=run.pipeline_id,
|
||||
pipeline_name=pipeline_name,
|
||||
source_library_file_id=run.source_library_file_id,
|
||||
source_archive_id=run.source_archive_id,
|
||||
source_filename=source_filename,
|
||||
parent_run_id=run.parent_run_id,
|
||||
copies=run.copies,
|
||||
copies_completed=sum(1 for s in job_live_statuses if s == "completed"),
|
||||
copies_failed=sum(1 for s in job_live_statuses if s == "failed"),
|
||||
copies_cancelled=sum(1 for s in job_live_statuses if s == "cancelled"),
|
||||
copies_in_progress=sum(
|
||||
1 for s in job_live_statuses if s in ("printing", "queued", "awaiting_printer", "pending")
|
||||
),
|
||||
status=rolled_up, # type: ignore[arg-type]
|
||||
slice_job_id=run.slice_job_id,
|
||||
sliced_library_file_id=run.sliced_library_file_id,
|
||||
eligibility_overridden=run.eligibility_overridden,
|
||||
error_message=run.error_message,
|
||||
created_by=run.created_by,
|
||||
created_at=run.created_at,
|
||||
started_at=run.started_at,
|
||||
completed_at=run.completed_at,
|
||||
jobs=job_responses,
|
||||
target_kind=target_kind,
|
||||
target_printer_id=target_printer_id,
|
||||
target_model_class=target_model_class,
|
||||
fanout_strategy=fanout_strategy,
|
||||
)
|
||||
|
||||
|
||||
async def _publish_run_event(db: AsyncSession, run: PipelineRun) -> None:
|
||||
"""Broadcast a ``pipeline_run_updated`` event with the full materialised
|
||||
run. Per-user routing via ``broadcast_to_user`` falls back to a global
|
||||
broadcast when ``created_by`` is None (auth-disabled installs)."""
|
||||
try:
|
||||
payload = await _materialise_run(db, run)
|
||||
await ws_manager.broadcast_to_user(
|
||||
run.created_by,
|
||||
{
|
||||
"type": "pipeline_run_updated",
|
||||
"run": payload.model_dump(mode="json"),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to broadcast pipeline_run_updated for run %d", run.id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source resolution + orchestration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
SourceKind = Literal["library_file", "archive"]
|
||||
|
||||
|
||||
async def _resolve_source(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
library_file_id: int | None,
|
||||
archive_id: int | None,
|
||||
user: User | None,
|
||||
) -> tuple[SourceKind, int, str, Path]:
|
||||
# Per-row ownership gate (IDOR fix): a caller may only run a pipeline on a
|
||||
# source they can see. Without this a READ_OWN caller could reference
|
||||
# another user's library file / archive by raw id and have it sliced (and,
|
||||
# via /run, printed) even though a direct GET on that id returned 404.
|
||||
# Auth-disabled and API-key callers (user is None) keep can_read_all=True —
|
||||
# no per-row identity, matching the library/archive read helpers.
|
||||
from backend.app.api.routes.archives import _ensure_archive_visible
|
||||
from backend.app.api.routes.library import _ensure_library_file_visible
|
||||
|
||||
if library_file_id is not None:
|
||||
lib = (await db.execute(select(LibraryFile).where(LibraryFile.id == library_file_id))).scalar_one_or_none()
|
||||
can_read_all = user is None or user.has_permission(Permission.LIBRARY_READ_ALL.value)
|
||||
lib = _ensure_library_file_visible(lib, user, can_read_all)
|
||||
src_path = (
|
||||
Path(app_settings.base_dir) / lib.file_path
|
||||
) # SEC-PATH-OK: lib.file_path is a LibraryFile DB column set only by the upload route, which writes a UUID-named file under base_dir/library_files/.
|
||||
if not src_path.exists():
|
||||
raise HTTPException(404, "Source library file missing on disk")
|
||||
return ("library_file", lib.id, lib.filename, src_path)
|
||||
|
||||
assert archive_id is not None
|
||||
arc = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
|
||||
can_read_all = user is None or user.has_permission(Permission.ARCHIVES_READ_ALL.value)
|
||||
arc = _ensure_archive_visible(arc, user, can_read_all)
|
||||
rel = arc.source_3mf_path or arc.file_path
|
||||
if not rel:
|
||||
raise HTTPException(400, "Archive has no source file to slice")
|
||||
src_path = (
|
||||
Path(app_settings.base_dir) / rel
|
||||
) # SEC-PATH-OK: rel is archive.source_3mf_path / archive.file_path, both set by upload-time validators that already do resolve+relative_to containment.
|
||||
if not src_path.exists():
|
||||
raise HTTPException(404, "Archive source file missing on disk")
|
||||
name = arc.filename or arc.print_name or src_path.name
|
||||
return ("archive", arc.id, name, src_path)
|
||||
|
||||
|
||||
async def _pick_assignments(
|
||||
db: AsyncSession,
|
||||
pipeline: SlicerPipeline,
|
||||
copies: int,
|
||||
) -> list[tuple[int | None, str | None]]:
|
||||
"""Return ``[(printer_id_or_None, target_model_or_None), ...]`` of length
|
||||
``copies`` per the pipeline's fanout strategy. ``target_model_class``
|
||||
items leave ``printer_id`` None so the scheduler picks any free matching
|
||||
printer; specific assignments fill ``printer_id``."""
|
||||
target_kind = pipeline.target_kind or "specific_printer"
|
||||
if target_kind == "specific_printer" or pipeline.target_printer_id is not None:
|
||||
assert pipeline.target_printer_id is not None
|
||||
return [(pipeline.target_printer_id, None)] * copies
|
||||
|
||||
# Class-targeting. Enumerate matching printers + apply the strategy.
|
||||
matching = (
|
||||
(
|
||||
await db.execute(
|
||||
select(Printer)
|
||||
.where(Printer.model == pipeline.target_model_class)
|
||||
.where(Printer.is_active.is_(True))
|
||||
.order_by(Printer.id)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if not matching:
|
||||
# Shouldn't reach here when eligibility passes, but failing gracefully
|
||||
# is better than a TypeError on next-slot pick.
|
||||
return [(None, pipeline.target_model_class)] * copies
|
||||
|
||||
strategy = pipeline.fanout_strategy or "max_parallel"
|
||||
if strategy == "fill_one_first":
|
||||
# Pin every copy to the first match. Scheduler dispatches them serially
|
||||
# to that printer. If the printer breaks, copies wait; that's the
|
||||
# documented trade-off.
|
||||
return [(matching[0].id, None)] * copies
|
||||
if strategy == "round_robin":
|
||||
# Cycle through eligible printers — copy ``i`` lands on
|
||||
# ``matching[i % len(matching)]``. Each item gets a fixed printer_id.
|
||||
return [(matching[i % len(matching)].id, None) for i in range(copies)]
|
||||
# max_parallel — leave printer_id=None, set target_model so the scheduler
|
||||
# picks any free X1C / P1S / … for each item independently.
|
||||
return [(None, pipeline.target_model_class)] * copies
|
||||
|
||||
|
||||
def _make_orchestration_callable(
|
||||
*,
|
||||
run_id: int,
|
||||
pipeline_id: int,
|
||||
src_kind: SourceKind,
|
||||
src_id: int,
|
||||
src_filename: str,
|
||||
src_path: Path,
|
||||
creator_user_id: int | None,
|
||||
copies: int,
|
||||
):
|
||||
"""Returns the async callable that ``slice_dispatch.enqueue`` runs as the
|
||||
background slice job. Wraps slice + multi-copy enqueue + state update."""
|
||||
|
||||
async def _orchestrate(slice_job_id: int) -> dict:
|
||||
from backend.app.api.routes.library import slice_and_persist
|
||||
|
||||
async with async_session() as session:
|
||||
run = (await session.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
|
||||
pipeline = (
|
||||
await session.execute(select(SlicerPipeline).where(SlicerPipeline.id == pipeline_id))
|
||||
).scalar_one_or_none()
|
||||
if run is None or pipeline is None:
|
||||
logger.warning("pipeline_run %d or pipeline %d disappeared mid-orchestration", run_id, pipeline_id)
|
||||
return {}
|
||||
|
||||
# Honour a cancel that landed between ``POST /run`` returning and
|
||||
# this background task starting. If the run was cancelled while
|
||||
# still in ``queued`` we must NOT flip it back to ``slicing`` —
|
||||
# the operator's intent was to stop, and overwriting status here
|
||||
# was the bug that left runs stuck at ``dispatching`` after a
|
||||
# user-side cancel (#1425 PR C bug report).
|
||||
if run.status == "cancelled":
|
||||
logger.info("pipeline_run %d was cancelled before slicing started", run_id)
|
||||
return {}
|
||||
|
||||
run.status = "slicing"
|
||||
run.started_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await _publish_run_event(session, run)
|
||||
|
||||
slice_request = _slice_request_from_pipeline(pipeline)
|
||||
model_bytes = src_path.read_bytes()
|
||||
|
||||
folder_id: int | None = None
|
||||
if src_kind == "library_file":
|
||||
lib = (await session.execute(select(LibraryFile).where(LibraryFile.id == src_id))).scalar_one_or_none()
|
||||
if lib is not None:
|
||||
folder_id = lib.folder_id
|
||||
|
||||
try:
|
||||
slice_response = await slice_and_persist(
|
||||
session,
|
||||
model_bytes=model_bytes,
|
||||
model_filename=src_filename,
|
||||
folder_id=folder_id,
|
||||
extra_metadata={
|
||||
f"sliced_from_{src_kind}_id": src_id,
|
||||
"sliced_via_pipeline_id": pipeline.id,
|
||||
"sliced_via_pipeline_run_id": run.id,
|
||||
},
|
||||
request=slice_request,
|
||||
current_user_id=creator_user_id,
|
||||
job_id=slice_job_id,
|
||||
)
|
||||
except HTTPException as exc:
|
||||
run.status = "failed"
|
||||
run.error_message = f"Slice failed: {exc.detail}"
|
||||
run.completed_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await _publish_run_event(session, run)
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("Pipeline run %d slice raised unexpectedly", run_id)
|
||||
run.status = "failed"
|
||||
run.error_message = f"Slice failed: {exc}"
|
||||
run.completed_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await _publish_run_event(session, run)
|
||||
raise
|
||||
|
||||
run.sliced_library_file_id = slice_response.library_file_id
|
||||
|
||||
# Re-check cancellation: the slice can take minutes, and the
|
||||
# operator may have hit Cancel during that window. Refresh from
|
||||
# the DB rather than trusting our in-memory `run` (the cancel
|
||||
# route writes via a separate session). When cancelled, don't
|
||||
# enqueue print queue items — that's the whole point of cancel.
|
||||
await session.refresh(run)
|
||||
if run.status == "cancelled":
|
||||
logger.info("pipeline_run %d cancelled mid-slice; skipping queue enqueue", run_id)
|
||||
await session.commit()
|
||||
return slice_response.model_dump()
|
||||
|
||||
# PR C: enqueue N copies per the picked assignment strategy.
|
||||
assignments = await _pick_assignments(session, pipeline, copies)
|
||||
|
||||
jobs = (
|
||||
(
|
||||
await session.execute(
|
||||
select(PipelineJob)
|
||||
.where(PipelineJob.pipeline_run_id == run_id)
|
||||
.order_by(PipelineJob.copy_index)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if len(jobs) != copies:
|
||||
logger.warning("pipeline_run %d expected %d jobs, found %d", run_id, copies, len(jobs))
|
||||
|
||||
for job, (printer_id, target_model) in zip(jobs, assignments, strict=False):
|
||||
queue_item = PrintQueueItem(
|
||||
printer_id=printer_id,
|
||||
target_model=target_model,
|
||||
library_file_id=slice_response.library_file_id,
|
||||
created_by_id=creator_user_id,
|
||||
status="pending",
|
||||
)
|
||||
session.add(queue_item)
|
||||
await session.flush()
|
||||
|
||||
job.queue_entry_id = queue_item.id
|
||||
job.assigned_printer_id = printer_id # may be None for max_parallel
|
||||
# Don't write job.status yet — final cancellation check below
|
||||
# may flip it to 'cancelled' instead. dispatched_at is fine to
|
||||
# set unconditionally since the orchestration actually got here.
|
||||
job.dispatched_at = datetime.now(timezone.utc)
|
||||
|
||||
# Final cancellation check before committing 'dispatching'. The
|
||||
# cancel route writes via a separate session so we have to refresh
|
||||
# to see the latest. If the cancel landed in this narrow window —
|
||||
# AFTER the post-slice refresh but BEFORE this commit — the queue
|
||||
# entries we just created would otherwise pick up and print. Mark
|
||||
# them + the per-copy jobs cancelled so the user's intent sticks.
|
||||
await session.refresh(run)
|
||||
if run.status == "cancelled":
|
||||
logger.info(
|
||||
"pipeline_run %d cancelled in the dispatch window; cancelling its %d queue entries",
|
||||
run_id,
|
||||
len(jobs),
|
||||
)
|
||||
for job in jobs:
|
||||
if job.queue_entry_id:
|
||||
qe = (
|
||||
await session.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
|
||||
).scalar_one_or_none()
|
||||
if qe is not None and qe.status in ("pending", "queued"):
|
||||
qe.status = "cancelled"
|
||||
if job.status not in ("completed", "failed", "cancelled"):
|
||||
job.status = "cancelled"
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await _publish_run_event(session, run)
|
||||
return slice_response.model_dump()
|
||||
|
||||
for job in jobs:
|
||||
job.status = "queued"
|
||||
run.status = "dispatching"
|
||||
await session.commit()
|
||||
await _publish_run_event(session, run)
|
||||
|
||||
return slice_response.model_dump()
|
||||
|
||||
return _orchestrate
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /slicer-pipelines/{id}/check-eligibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pipeline_run_create_router.post("/{pipeline_id}/check-eligibility", response_model=EligibilityReportResponse)
|
||||
async def check_eligibility(
|
||||
pipeline_id: int,
|
||||
body: CheckEligibilityRequest,
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
pipeline = await _load_pipeline(db, pipeline_id)
|
||||
await _resolve_source(
|
||||
db,
|
||||
library_file_id=body.source_library_file_id,
|
||||
archive_id=body.source_archive_id,
|
||||
user=current_user,
|
||||
)
|
||||
if pipeline.target_kind == "printer_class" and pipeline.target_printer_id is None:
|
||||
report = await check_pipeline_eligibility(db, pipeline, status_lookup=_make_status_lookup())
|
||||
else:
|
||||
status = await _load_printer_status(pipeline.target_printer_id)
|
||||
report = await check_pipeline_eligibility(db, pipeline, status)
|
||||
return _serialise_status(report)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /slicer-pipelines/{id}/run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pipeline_run_create_router.post("/{pipeline_id}/run", response_model=PipelineRunResponse, status_code=202)
|
||||
async def run_pipeline(
|
||||
pipeline_id: int,
|
||||
body: PipelineRunCreateRequest,
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
from backend.app.services.slice_dispatch import slice_dispatch
|
||||
|
||||
pipeline = await _load_pipeline(db, pipeline_id)
|
||||
src_kind, src_id, src_filename, src_path = await _resolve_source(
|
||||
db,
|
||||
library_file_id=body.source_library_file_id,
|
||||
archive_id=body.source_archive_id,
|
||||
user=current_user,
|
||||
)
|
||||
|
||||
# Cap copies against the configured ceiling.
|
||||
raw_cap = await get_setting(db, "pipeline_max_copies")
|
||||
try:
|
||||
cap = int(raw_cap) if raw_cap else 50
|
||||
except (TypeError, ValueError):
|
||||
cap = 50
|
||||
if body.copies > cap:
|
||||
raise HTTPException(
|
||||
422,
|
||||
f"copies={body.copies} exceeds pipeline_max_copies setting ({cap})",
|
||||
)
|
||||
|
||||
# Eligibility pre-flight.
|
||||
if pipeline.target_kind == "printer_class" and pipeline.target_printer_id is None:
|
||||
report = await check_pipeline_eligibility(db, pipeline, status_lookup=_make_status_lookup())
|
||||
else:
|
||||
status = await _load_printer_status(pipeline.target_printer_id)
|
||||
report = await check_pipeline_eligibility(db, pipeline, status)
|
||||
|
||||
if not report.ok and not body.force:
|
||||
raise HTTPException(status_code=409, detail=_serialise_status(report).model_dump())
|
||||
|
||||
# Need a target — specific or class — to dispatch.
|
||||
if pipeline.target_printer_id is None and not pipeline.target_model_class:
|
||||
raise HTTPException(
|
||||
400,
|
||||
"Pipeline has no target. Open the pipeline in Settings → Workflow → Pipelines and choose a target printer or printer class.",
|
||||
)
|
||||
|
||||
run = PipelineRun(
|
||||
pipeline_id=pipeline.id,
|
||||
source_library_file_id=src_id if src_kind == "library_file" else None,
|
||||
source_archive_id=src_id if src_kind == "archive" else None,
|
||||
copies=body.copies,
|
||||
status="queued",
|
||||
eligibility_overridden=(not report.ok and body.force),
|
||||
created_by=current_user.id if current_user else None,
|
||||
)
|
||||
db.add(run)
|
||||
await db.flush()
|
||||
|
||||
# One PipelineJob per copy. PR B was copies=1, PR C generalises.
|
||||
for i in range(body.copies):
|
||||
db.add(
|
||||
PipelineJob(
|
||||
pipeline_run_id=run.id,
|
||||
copy_index=i,
|
||||
status="pending",
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
await _publish_run_event(db, run)
|
||||
|
||||
orchestrate = _make_orchestration_callable(
|
||||
run_id=run.id,
|
||||
pipeline_id=pipeline.id,
|
||||
src_kind=src_kind,
|
||||
src_id=src_id,
|
||||
src_filename=src_filename,
|
||||
src_path=src_path,
|
||||
creator_user_id=current_user.id if current_user else None,
|
||||
copies=body.copies,
|
||||
)
|
||||
slice_job = await slice_dispatch.enqueue(
|
||||
kind="library_file" if src_kind == "library_file" else "archive",
|
||||
source_id=src_id,
|
||||
source_name=src_filename,
|
||||
owner_id=current_user.id if current_user else None,
|
||||
run=orchestrate,
|
||||
)
|
||||
|
||||
run.slice_job_id = slice_job.id
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
|
||||
return await _materialise_run(db, run)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lists, reads, cancel, retry-failed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pipeline_run_create_router.get("/{pipeline_id}/runs", response_model=PipelineRunListResponse)
|
||||
async def list_runs_for_pipeline(
|
||||
pipeline_id: int,
|
||||
limit: int = 10,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
limit = max(1, min(limit, 100))
|
||||
rows = (
|
||||
(
|
||||
await db.execute(
|
||||
select(PipelineRun)
|
||||
.where(PipelineRun.pipeline_id == pipeline_id)
|
||||
.order_by(PipelineRun.id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
total = (
|
||||
await db.execute(select(func.count()).select_from(PipelineRun).where(PipelineRun.pipeline_id == pipeline_id))
|
||||
).scalar() or 0
|
||||
return PipelineRunListResponse(
|
||||
runs=[await _materialise_run(db, r) for r in rows],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@pipeline_run_router.get("", response_model=PipelineRunListResponse)
|
||||
async def list_all_runs(
|
||||
limit: int = 25,
|
||||
offset: int = 0,
|
||||
pipeline_id: int | None = None,
|
||||
status: str | None = None,
|
||||
target_printer_id: int | None = None,
|
||||
target_model_class: str | None = None,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Dashboard list. Newest first; filters on pipeline_id + status +
|
||||
target_printer_id + target_model_class. The ``status`` filter matches
|
||||
the persisted snapshot, not the live roll-up — in-progress runs may
|
||||
appear under ``dispatching`` until the next state transition writes
|
||||
through. ``target_*`` filters JOIN to the pipeline so runs whose
|
||||
pipeline currently points at the printer / class are returned."""
|
||||
limit = max(1, min(limit, 100))
|
||||
offset = max(0, offset)
|
||||
|
||||
stmt = select(PipelineRun)
|
||||
count_stmt = select(func.count()).select_from(PipelineRun)
|
||||
if pipeline_id is not None:
|
||||
stmt = stmt.where(PipelineRun.pipeline_id == pipeline_id)
|
||||
count_stmt = count_stmt.where(PipelineRun.pipeline_id == pipeline_id)
|
||||
if status:
|
||||
stmt = stmt.where(PipelineRun.status == status)
|
||||
count_stmt = count_stmt.where(PipelineRun.status == status)
|
||||
if target_printer_id is not None or target_model_class is not None:
|
||||
stmt = stmt.join(SlicerPipeline, SlicerPipeline.id == PipelineRun.pipeline_id)
|
||||
count_stmt = count_stmt.join(SlicerPipeline, SlicerPipeline.id == PipelineRun.pipeline_id)
|
||||
if target_printer_id is not None:
|
||||
stmt = stmt.where(SlicerPipeline.target_printer_id == target_printer_id)
|
||||
count_stmt = count_stmt.where(SlicerPipeline.target_printer_id == target_printer_id)
|
||||
if target_model_class is not None:
|
||||
stmt = stmt.where(SlicerPipeline.target_model_class == target_model_class)
|
||||
count_stmt = count_stmt.where(SlicerPipeline.target_model_class == target_model_class)
|
||||
|
||||
rows = (await db.execute(stmt.order_by(desc(PipelineRun.id)).offset(offset).limit(limit))).scalars().all()
|
||||
total = (await db.execute(count_stmt)).scalar() or 0
|
||||
|
||||
return PipelineRunListResponse(
|
||||
runs=[await _materialise_run(db, r) for r in rows],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
_TERMINAL_RUN_STATUSES = ("completed", "failed", "cancelled", "partial_failure")
|
||||
|
||||
|
||||
@pipeline_run_router.post("/clear")
|
||||
async def clear_terminal_runs(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete every terminal pipeline run (completed / failed / cancelled /
|
||||
partial_failure). In-flight runs (queued / slicing / dispatching /
|
||||
in_progress) are preserved — clearing those mid-flight would lose the
|
||||
operator's intent. Cascades to PipelineJob via the ondelete='CASCADE'
|
||||
relationship; the linked PrintQueueItem rows stay (they have their own
|
||||
lifecycle on the queue page)."""
|
||||
# Count first so the response can report how many got cleared. Done
|
||||
# under the same session/transaction as the delete so the numbers can't
|
||||
# drift if another caller races in.
|
||||
count_stmt = select(func.count()).select_from(PipelineRun).where(PipelineRun.status.in_(_TERMINAL_RUN_STATUSES))
|
||||
n = (await db.execute(count_stmt)).scalar() or 0
|
||||
if n > 0:
|
||||
await db.execute(delete(PipelineRun).where(PipelineRun.status.in_(_TERMINAL_RUN_STATUSES)))
|
||||
await db.commit()
|
||||
return {"deleted": n}
|
||||
|
||||
|
||||
@pipeline_run_router.get("/{run_id}", response_model=PipelineRunResponse)
|
||||
async def get_run(
|
||||
run_id: int,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
run = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
|
||||
if run is None:
|
||||
raise HTTPException(404, "Pipeline run not found")
|
||||
return await _materialise_run(db, run)
|
||||
|
||||
|
||||
@pipeline_run_router.post("/{run_id}/cancel", response_model=PipelineRunResponse)
|
||||
async def cancel_run(
|
||||
run_id: int,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Cancel a queued / in-flight run. Cascades to all non-terminal queue
|
||||
entries; in-flight prints continue on the printer (operator must Stop)."""
|
||||
run = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
|
||||
if run is None:
|
||||
raise HTTPException(404, "Pipeline run not found")
|
||||
|
||||
if run.status in ("completed", "failed", "cancelled", "partial_failure"):
|
||||
return await _materialise_run(db, run)
|
||||
|
||||
run.status = "cancelled"
|
||||
run.completed_at = datetime.now(timezone.utc)
|
||||
if not run.error_message:
|
||||
run.error_message = "Cancelled by user"
|
||||
|
||||
job_rows = (await db.execute(select(PipelineJob).where(PipelineJob.pipeline_run_id == run.id))).scalars().all()
|
||||
for job in job_rows:
|
||||
if job.queue_entry_id:
|
||||
queue_entry = (
|
||||
await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == job.queue_entry_id))
|
||||
).scalar_one_or_none()
|
||||
if queue_entry is not None and queue_entry.status in ("pending", "queued"):
|
||||
queue_entry.status = "cancelled"
|
||||
if job.status not in ("completed", "failed", "cancelled"):
|
||||
job.status = "cancelled"
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
await _publish_run_event(db, run)
|
||||
return await _materialise_run(db, run)
|
||||
|
||||
|
||||
@pipeline_run_router.post("/{run_id}/retry-failed", response_model=PipelineRunResponse, status_code=202)
|
||||
async def retry_failed(
|
||||
run_id: int,
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new run with copies = (failed + cancelled count) from the
|
||||
parent. Same pipeline, same source. Eligibility re-checked at run time
|
||||
(it might pass this time — operator may have fixed the issue)."""
|
||||
parent = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
|
||||
if parent is None:
|
||||
raise HTTPException(404, "Pipeline run not found")
|
||||
if parent.pipeline_id is None:
|
||||
raise HTTPException(400, "Original pipeline was deleted; cannot retry")
|
||||
if parent.source_library_file_id is None and parent.source_archive_id is None:
|
||||
raise HTTPException(400, "Original source was deleted; cannot retry")
|
||||
|
||||
# Count the parent's failed + cancelled jobs.
|
||||
parent_jobs = (
|
||||
(await db.execute(select(PipelineJob).where(PipelineJob.pipeline_run_id == parent.id))).scalars().all()
|
||||
)
|
||||
fail_count = 0
|
||||
for j in parent_jobs:
|
||||
queue_entry = None
|
||||
if j.queue_entry_id:
|
||||
queue_entry = (
|
||||
await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == j.queue_entry_id))
|
||||
).scalar_one_or_none()
|
||||
live = _compute_job_status(j.status, queue_entry)
|
||||
if live in ("failed", "cancelled"):
|
||||
fail_count += 1
|
||||
|
||||
if fail_count == 0:
|
||||
raise HTTPException(400, "No failed copies to retry")
|
||||
|
||||
# Build the request payload the same way the user would have via /run.
|
||||
body = PipelineRunCreateRequest(
|
||||
source_library_file_id=parent.source_library_file_id,
|
||||
source_archive_id=parent.source_archive_id,
|
||||
copies=fail_count,
|
||||
force=True, # operator already accepted eligibility on the parent
|
||||
)
|
||||
|
||||
# Reuse the run_pipeline route logic via a direct call — keeps the
|
||||
# orchestration single-sourced. The result inherits parent_run_id.
|
||||
new_run_response = await run_pipeline(parent.pipeline_id, body, current_user=current_user, db=db)
|
||||
|
||||
# Stamp parent_run_id on the freshly-created run.
|
||||
new_row = (await db.execute(select(PipelineRun).where(PipelineRun.id == new_run_response.id))).scalar_one_or_none()
|
||||
if new_row is not None:
|
||||
new_row.parent_run_id = parent.id
|
||||
await db.commit()
|
||||
await db.refresh(new_row)
|
||||
return await _materialise_run(db, new_row)
|
||||
return new_run_response
|
||||
|
|
@ -34,20 +34,11 @@ async def get_print_log(
|
|||
limit: int = Query(default=50, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_ownership_permission(
|
||||
Permission.ARCHIVES_READ_ALL,
|
||||
Permission.ARCHIVES_READ_OWN,
|
||||
)
|
||||
),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
|
||||
):
|
||||
"""Get the print log."""
|
||||
user, can_read_all = auth_result
|
||||
query = select(PrintLogEntry)
|
||||
count_query = select(func.count(PrintLogEntry.id))
|
||||
if user is not None and not can_read_all:
|
||||
query = query.where(PrintLogEntry.created_by_id == user.id)
|
||||
count_query = count_query.where(PrintLogEntry.created_by_id == user.id)
|
||||
|
||||
if printer_id is not None:
|
||||
query = query.where(PrintLogEntry.printer_id == printer_id)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from pathlib import Path
|
|||
|
||||
import defusedxml.ElementTree as ET
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import and_, func, or_, select, update
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
|
|
@ -16,6 +16,7 @@ from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_owners
|
|||
from backend.app.core.config import settings
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.core.tasks import spawn_background_task
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.library import LibraryFile
|
||||
from backend.app.models.print_batch import PrintBatch
|
||||
|
|
@ -24,9 +25,7 @@ from backend.app.models.printer import Printer
|
|||
from backend.app.models.project import Project
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.print_queue import (
|
||||
PrintBatchCreate,
|
||||
PrintBatchResponse,
|
||||
PrintBatchUngroupResponse,
|
||||
PrintQueueBulkUpdate,
|
||||
PrintQueueBulkUpdateResponse,
|
||||
PrintQueueItemCreate,
|
||||
|
|
@ -35,17 +34,9 @@ from backend.app.schemas.print_queue import (
|
|||
PrintQueueReorder,
|
||||
)
|
||||
from backend.app.services.filament_deficit import compute_deficit_for_queue_item
|
||||
from backend.app.services.filament_requirements import overrides_for_plate
|
||||
from backend.app.services.notification_service import notification_service
|
||||
from backend.app.utils.printer_models import (
|
||||
is_gcode_compatible,
|
||||
normalize_printer_model,
|
||||
normalize_printer_model_id,
|
||||
)
|
||||
from backend.app.utils.threemf_tools import (
|
||||
extract_plate_metadata_from_3mf,
|
||||
extract_print_time_from_3mf,
|
||||
)
|
||||
from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
|
||||
from backend.app.utils.threemf_tools import extract_bed_type_from_3mf, extract_filament_usage_from_3mf
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -113,25 +104,55 @@ def _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = Non
|
|||
return sorted(types)
|
||||
|
||||
|
||||
# Local alias kept so existing call sites stay compact; the implementation lives
|
||||
# in utils/threemf_tools.py so the notification path (main.py) can reuse it
|
||||
# without importing from a routes module (#1785).
|
||||
_extract_print_time_from_3mf = extract_print_time_from_3mf
|
||||
def _extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) -> int | None:
|
||||
"""Extract print time (prediction) from a 3MF file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the 3MF file
|
||||
plate_id: Optional plate index to filter for (for multi-plate files)
|
||||
|
||||
Returns:
|
||||
Print time in seconds, or None if not found
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
if "Metadata/slice_info.config" not in zf.namelist():
|
||||
return None
|
||||
|
||||
content = zf.read("Metadata/slice_info.config").decode()
|
||||
root = ET.fromstring(content)
|
||||
|
||||
if plate_id is not None:
|
||||
for plate_elem in root.findall(".//plate"):
|
||||
plate_index = None
|
||||
for meta in plate_elem.findall("metadata"):
|
||||
if meta.get("key") == "index":
|
||||
try:
|
||||
plate_index = int(meta.get("value", "0"))
|
||||
except ValueError:
|
||||
pass # Skip plate with unparseable index
|
||||
break
|
||||
|
||||
if plate_index == plate_id:
|
||||
for meta in plate_elem.findall("metadata"):
|
||||
if meta.get("key") == "prediction":
|
||||
try:
|
||||
return int(meta.get("value", "0"))
|
||||
except ValueError:
|
||||
return None
|
||||
break
|
||||
else:
|
||||
plate_elem = root.find(".//plate")
|
||||
if plate_elem is not None:
|
||||
for meta in plate_elem.findall("metadata"):
|
||||
if meta.get("key") == "prediction":
|
||||
try:
|
||||
return int(meta.get("value", "0"))
|
||||
except ValueError:
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("Failed to extract print time from %s: %s", file_path, e)
|
||||
|
||||
async def _resolve_source_path(db: AsyncSession, item: PrintQueueItem) -> Path | None:
|
||||
"""Resolve an existing queue item's source 3MF on disk, or None."""
|
||||
if item.archive_id:
|
||||
result = await db.execute(select(PrintArchive).where(PrintArchive.id == item.archive_id))
|
||||
archive = result.scalar_one_or_none()
|
||||
if archive:
|
||||
return settings.base_dir / archive.file_path
|
||||
elif item.library_file_id:
|
||||
result = await db.execute(LibraryFile.active().where(LibraryFile.id == item.library_file_id))
|
||||
library_file = result.scalar_one_or_none()
|
||||
if library_file:
|
||||
lib_path = Path(library_file.file_path)
|
||||
return lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -161,24 +182,6 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
|
|||
except json.JSONDecodeError:
|
||||
filament_overrides_parsed = None
|
||||
|
||||
# Parse nozzle_mapping from JSON string (#1780 — H2C rack slicer-pick
|
||||
# preservation). Nullable opaque JSON blob stored verbatim from
|
||||
# BambuStudio's project_file; surface it parsed for the response model
|
||||
# and any future "edit print → nozzle" UI.
|
||||
nozzle_mapping_parsed = None
|
||||
if item.nozzle_mapping:
|
||||
try:
|
||||
nozzle_mapping_parsed = json.loads(item.nozzle_mapping)
|
||||
except json.JSONDecodeError:
|
||||
nozzle_mapping_parsed = None
|
||||
|
||||
nozzles_info_parsed = None
|
||||
if item.nozzles_info:
|
||||
try:
|
||||
nozzles_info_parsed = json.loads(item.nozzles_info)
|
||||
except json.JSONDecodeError:
|
||||
nozzles_info_parsed = None
|
||||
|
||||
# Create response with parsed ams_mapping
|
||||
item_dict = {
|
||||
"id": item.id,
|
||||
|
|
@ -206,8 +209,6 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
|
|||
"timelapse": item.timelapse,
|
||||
"use_ams": item.use_ams,
|
||||
"nozzle_offset_cali": item.nozzle_offset_cali,
|
||||
"preheat_override": item.preheat_override,
|
||||
"preheat_chamber_target_override": item.preheat_chamber_target_override,
|
||||
"status": item.status,
|
||||
"started_at": item.started_at,
|
||||
"completed_at": item.completed_at,
|
||||
|
|
@ -223,10 +224,6 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
|
|||
"been_jumped": item.been_jumped,
|
||||
# Auto-print G-code injection
|
||||
"gcode_injection": item.gcode_injection,
|
||||
# H2C rack-swap nozzle pick (#1780)
|
||||
"nozzle_mapping": nozzle_mapping_parsed,
|
||||
"nozzles_info": nozzles_info_parsed,
|
||||
"cleanup_library_after_dispatch": item.cleanup_library_after_dispatch,
|
||||
}
|
||||
response = PrintQueueItemResponse(**item_dict)
|
||||
if item.archive:
|
||||
|
|
@ -250,35 +247,20 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
|
|||
response.nozzle_diameter = item.archive.nozzle_diameter
|
||||
response.sliced_for_model = item.archive.sliced_for_model
|
||||
response.bed_type = item.archive.bed_type
|
||||
# Marks history/reprint rows whose archive carries the slicer's own
|
||||
# live-resolved AMS-slot pick (extra_data.slicer_ams_mapping) — see
|
||||
# `_extract_slicer_ams_mapping_json` in virtual_printer/manager.py.
|
||||
#
|
||||
# Only when the saved mapping was resolved against *this* row's
|
||||
# printer: a global tray ID means nothing on another printer, so
|
||||
# that's the exact condition under which the mapping is reused. A
|
||||
# badge on a row where nothing gets reused would be a lie (#2700
|
||||
# review). Model-based rows (printer_id None) never match, which is
|
||||
# correct — the mapping is not reused there either.
|
||||
extra = item.archive.extra_data if isinstance(item.archive.extra_data, dict) else {}
|
||||
saved_mapping = extra.get("slicer_ams_mapping")
|
||||
response.archive_has_slicer_ams_mapping = (
|
||||
isinstance(saved_mapping, dict)
|
||||
and isinstance(saved_mapping.get("mapping"), list)
|
||||
and item.printer_id is not None
|
||||
and saved_mapping.get("printer_id") == item.printer_id
|
||||
)
|
||||
if item.plate_id:
|
||||
archive_path = settings.base_dir / item.archive.file_path
|
||||
if archive_path.exists():
|
||||
# One cached parse for all three per-plate overrides (#2573).
|
||||
plate_meta = extract_plate_metadata_from_3mf(archive_path, item.plate_id)
|
||||
if plate_meta.print_time_seconds is not None:
|
||||
response.print_time_seconds = plate_meta.print_time_seconds
|
||||
if plate_meta.filament_used_grams > 0:
|
||||
response.filament_used_grams = plate_meta.filament_used_grams
|
||||
if plate_meta.bed_type:
|
||||
response.bed_type = plate_meta.bed_type
|
||||
plate_time = _extract_print_time_from_3mf(archive_path, item.plate_id)
|
||||
plate_weight = sum(
|
||||
f["used_g"] for f in extract_filament_usage_from_3mf(archive_path, item.plate_id)
|
||||
)
|
||||
plate_bed = extract_bed_type_from_3mf(archive_path, item.plate_id)
|
||||
if plate_time is not None:
|
||||
response.print_time_seconds = plate_time
|
||||
if plate_weight > 0:
|
||||
response.filament_used_grams = plate_weight
|
||||
if plate_bed:
|
||||
response.bed_type = plate_bed
|
||||
if item.library_file:
|
||||
response.library_file_name = (
|
||||
item.library_file.file_metadata.get("print_name") if item.library_file.file_metadata else None
|
||||
|
|
@ -300,14 +282,17 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
|
|||
lib_path = Path(item.library_file.file_path)
|
||||
library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / item.library_file.file_path
|
||||
if library_file_path.exists():
|
||||
# One cached parse for all three per-plate overrides (#2573).
|
||||
plate_meta = extract_plate_metadata_from_3mf(library_file_path, item.plate_id)
|
||||
if plate_meta.print_time_seconds is not None:
|
||||
response.print_time_seconds = plate_meta.print_time_seconds
|
||||
if plate_meta.filament_used_grams > 0:
|
||||
response.filament_used_grams = plate_meta.filament_used_grams
|
||||
if plate_meta.bed_type:
|
||||
response.bed_type = plate_meta.bed_type
|
||||
plate_time = _extract_print_time_from_3mf(library_file_path, item.plate_id)
|
||||
plate_weight = sum(
|
||||
f["used_g"] for f in extract_filament_usage_from_3mf(library_file_path, item.plate_id)
|
||||
)
|
||||
plate_bed = extract_bed_type_from_3mf(library_file_path, item.plate_id)
|
||||
if plate_time is not None:
|
||||
response.print_time_seconds = plate_time
|
||||
if plate_weight > 0:
|
||||
response.filament_used_grams = plate_weight
|
||||
if plate_bed:
|
||||
response.bed_type = plate_bed
|
||||
if item.printer:
|
||||
response.printer_name = item.printer.name
|
||||
return response
|
||||
|
|
@ -321,15 +306,9 @@ async def list_queue(
|
|||
None, description="Filter by target model (also includes model-based items when combined with printer_id)"
|
||||
),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_ownership_permission(
|
||||
Permission.QUEUE_READ_ALL,
|
||||
Permission.QUEUE_READ_OWN,
|
||||
)
|
||||
),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
|
||||
):
|
||||
"""List all queue items, optionally filtered by printer or status."""
|
||||
user, can_read_all = auth_result
|
||||
query = (
|
||||
select(PrintQueueItem)
|
||||
.options(
|
||||
|
|
@ -341,8 +320,6 @@ async def list_queue(
|
|||
)
|
||||
.order_by(PrintQueueItem.printer_id.nulls_first(), PrintQueueItem.position)
|
||||
)
|
||||
if user is not None and not can_read_all:
|
||||
query = query.where(PrintQueueItem.created_by_id == user.id)
|
||||
|
||||
if printer_id is not None:
|
||||
if printer_id == -1:
|
||||
|
|
@ -427,35 +404,6 @@ async def add_to_queue(
|
|||
archive = result.scalar_one_or_none()
|
||||
if not archive:
|
||||
raise HTTPException(400, "Archive not found")
|
||||
# IDOR fix (maziggy/bambuddy-security #2): without this check, a
|
||||
# caller with QUEUE_CREATE could queue any user's archive even
|
||||
# without ARCHIVES_READ on it — Landon's PoC enumerated this on
|
||||
# admin's archives as operator1. Gate on ARCHIVES_READ_ALL OR
|
||||
# ownership of the archive. 404 (not 403) so we don't leak
|
||||
# "this id exists but you can't queue it" for enumeration.
|
||||
if (
|
||||
current_user is not None
|
||||
and not current_user.has_permission(Permission.ARCHIVES_READ_ALL.value)
|
||||
and archive.created_by_id != current_user.id
|
||||
):
|
||||
raise HTTPException(404, "Archive not found")
|
||||
# Reprint perm gate (#1625): the legacy /archives/{id}/reprint endpoint
|
||||
# required ARCHIVES_REPRINT_OWN/ALL; the unified queue route must keep
|
||||
# that gate or an operator with QUEUE_CREATE could reprint via direct
|
||||
# API call even if explicitly denied reprint perm. Mirrors the
|
||||
# frontend `canModify('archives', 'reprint', ...)` helper:
|
||||
# REPRINT_ALL allows any archive, REPRINT_OWN allows own only,
|
||||
# ownerless archives require REPRINT_ALL (fail-closed).
|
||||
if current_user is not None:
|
||||
owns_archive = archive.created_by_id is not None and archive.created_by_id == current_user.id
|
||||
has_reprint = current_user.has_permission(Permission.ARCHIVES_REPRINT_ALL.value) or (
|
||||
owns_archive and current_user.has_permission(Permission.ARCHIVES_REPRINT_OWN.value)
|
||||
)
|
||||
if not has_reprint:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Permission archives:reprint_own or archives:reprint_all required",
|
||||
)
|
||||
|
||||
# Validate library file exists (if provided) and get it for filament extraction
|
||||
library_file = None
|
||||
|
|
@ -464,13 +412,6 @@ async def add_to_queue(
|
|||
library_file = result.scalar_one_or_none()
|
||||
if not library_file:
|
||||
raise HTTPException(400, "Library file not found")
|
||||
# Same shape: gate cross-user library-file queueing on LIBRARY_READ_ALL.
|
||||
if (
|
||||
current_user is not None
|
||||
and not current_user.has_permission(Permission.LIBRARY_READ_ALL.value)
|
||||
and library_file.created_by_id != current_user.id
|
||||
):
|
||||
raise HTTPException(404, "Library file not found")
|
||||
# Bambu SD card is FAT32/exFAT — illegal filename chars would 553 at
|
||||
# FTP upload time (#1540). Reject at queue time so the user gets the
|
||||
# actionable error before waiting in queue.
|
||||
|
|
@ -481,27 +422,11 @@ async def add_to_queue(
|
|||
except InvalidFilenameError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
# Cross-model safety gate (#2578): a G-code 3MF sliced for one model must
|
||||
# not be queued for dispatch to an incompatible model. The UI can no longer
|
||||
# produce such rows, but API-created rows must be rejected here too — the
|
||||
# scheduler assigns model-based items to hardware with no human in the loop.
|
||||
if target_model_norm:
|
||||
sliced_for = None
|
||||
if archive:
|
||||
sliced_for = archive.sliced_for_model
|
||||
elif library_file and library_file.file_metadata:
|
||||
sliced_for = library_file.file_metadata.get("sliced_for_model")
|
||||
if not is_gcode_compatible(sliced_for, target_model_norm):
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"File was sliced for {sliced_for} and cannot be dispatched to {target_model_norm} printers",
|
||||
)
|
||||
|
||||
# Extract filament types for model-based assignment (used by scheduler for validation)
|
||||
required_filament_types = None
|
||||
file_path = None
|
||||
if target_model_norm:
|
||||
# Get file path from archive or library file
|
||||
file_path = None
|
||||
if archive:
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
elif library_file:
|
||||
|
|
@ -517,45 +442,23 @@ async def add_to_queue(
|
|||
# If filament overrides are provided, update required_filament_types to match override types
|
||||
filament_overrides_json = None
|
||||
if data.filament_overrides and target_model_norm:
|
||||
plate_overrides = overrides_for_plate(data.filament_overrides, file_path, data.plate_id)
|
||||
if plate_overrides:
|
||||
filament_overrides_json = json.dumps(plate_overrides)
|
||||
# Update required_filament_types from overrides so scheduler validates against overridden types
|
||||
override_types = sorted({o["type"] for o in plate_overrides if "type" in o})
|
||||
if override_types:
|
||||
# Merge with existing types (overrides may only cover some slots)
|
||||
existing_types = set(json.loads(required_filament_types)) if required_filament_types else set()
|
||||
# Replace types for overridden slots, keep others
|
||||
all_types = existing_types | set(override_types)
|
||||
required_filament_types = json.dumps(sorted(all_types))
|
||||
filament_overrides_json = json.dumps(data.filament_overrides)
|
||||
# Update required_filament_types from overrides so scheduler validates against overridden types
|
||||
override_types = sorted({o["type"] for o in data.filament_overrides if "type" in o})
|
||||
if override_types:
|
||||
# Merge with existing types (overrides may only cover some slots)
|
||||
existing_types = set(json.loads(required_filament_types)) if required_filament_types else set()
|
||||
# Replace types for overridden slots, keep others
|
||||
all_types = existing_types | set(override_types)
|
||||
required_filament_types = json.dumps(sorted(all_types))
|
||||
|
||||
# Validate quantity
|
||||
quantity = max(1, data.quantity)
|
||||
|
||||
# Validate batch_id if provided. Client passes batch_id when adding items
|
||||
# into a pre-created batch (multi-plate auto-batch or "Group as batch" flow).
|
||||
# 404 keeps the existing-id leak surface low.
|
||||
# Create batch if quantity > 1
|
||||
batch = None
|
||||
batch_id = None
|
||||
if data.batch_id is not None:
|
||||
result = await db.execute(select(PrintBatch).where(PrintBatch.id == data.batch_id))
|
||||
existing_batch = result.scalar_one_or_none()
|
||||
if not existing_batch:
|
||||
raise HTTPException(404, "Batch not found")
|
||||
if existing_batch.status != "active":
|
||||
raise HTTPException(400, "Cannot add items to a non-active batch")
|
||||
if (
|
||||
current_user is not None
|
||||
and existing_batch.created_by_id is not None
|
||||
and existing_batch.created_by_id != current_user.id
|
||||
and not current_user.has_permission(Permission.QUEUE_UPDATE_ALL.value)
|
||||
):
|
||||
raise HTTPException(404, "Batch not found")
|
||||
batch = existing_batch
|
||||
batch_id = existing_batch.id
|
||||
|
||||
# Create batch if quantity > 1 and no batch_id provided
|
||||
if batch_id is None and quantity > 1:
|
||||
if quantity > 1:
|
||||
# Derive batch name from source file
|
||||
batch_name_base = "Batch"
|
||||
if archive:
|
||||
|
|
@ -579,59 +482,21 @@ async def add_to_queue(
|
|||
await db.flush() # Get batch.id before creating items
|
||||
batch_id = batch.id
|
||||
|
||||
# Get queue scope for this printer (or for unassigned/model-based items).
|
||||
# Get next position for this printer (or for unassigned/model-based items)
|
||||
if data.printer_id is not None:
|
||||
queue_scope = (
|
||||
PrintQueueItem.printer_id == data.printer_id,
|
||||
PrintQueueItem.status == "pending",
|
||||
result = await db.execute(
|
||||
select(func.max(PrintQueueItem.position))
|
||||
.where(PrintQueueItem.printer_id == data.printer_id)
|
||||
.where(PrintQueueItem.status == "pending")
|
||||
)
|
||||
else:
|
||||
# For unassigned/model-based items, scope across all unassigned.
|
||||
queue_scope = (
|
||||
PrintQueueItem.printer_id.is_(None),
|
||||
PrintQueueItem.status == "pending",
|
||||
# For unassigned/model-based items, get max position across all unassigned
|
||||
result = await db.execute(
|
||||
select(func.max(PrintQueueItem.position))
|
||||
.where(PrintQueueItem.printer_id.is_(None))
|
||||
.where(PrintQueueItem.status == "pending")
|
||||
)
|
||||
|
||||
# Serialize concurrent queue inserts to the same scope (#1625-followup).
|
||||
# The race: two concurrent ASAP inserts both compute MAX(position) before
|
||||
# either commits; in an empty scope, both INSERT at position 1 (duplicate).
|
||||
# In a non-empty scope, Postgres's row-level locks on the UPDATE shift
|
||||
# serialize naturally, but the empty-scope path has no rows to lock.
|
||||
# A transaction-scoped advisory lock keyed on the printer_id closes that
|
||||
# window; the lock is released automatically at commit/rollback. Different
|
||||
# printers don't contend. SQLite serializes writes implicitly so this is a
|
||||
# no-op there.
|
||||
#
|
||||
# Dialect is checked against the actual session binding, NOT the
|
||||
# `is_sqlite()` helper, because the test fixture overrides `get_db` with a
|
||||
# SQLite engine while `settings.database_url` still points at Postgres
|
||||
# (the helper reads settings). Inspecting the connection directly is the
|
||||
# right shape for any code that mutates SQL based on the live dialect.
|
||||
from sqlalchemy import text
|
||||
|
||||
bind = db.get_bind()
|
||||
if bind.dialect.name == "postgresql":
|
||||
scope_key = data.printer_id if data.printer_id is not None else 0
|
||||
# 1625 namespaces the lock so it can't collide with other advisory
|
||||
# locks elsewhere in the codebase.
|
||||
await db.execute(text("SELECT pg_advisory_xact_lock(1625, :k)"), {"k": scope_key})
|
||||
|
||||
insert_position = max(1, data.insert_position or 1)
|
||||
if data.insert_at_top or data.insert_position is not None:
|
||||
result = await db.execute(select(func.max(PrintQueueItem.position)).where(*queue_scope))
|
||||
max_pos = result.scalar() or 0
|
||||
insert_position = min(insert_position, max_pos + 1)
|
||||
await db.execute(
|
||||
update(PrintQueueItem)
|
||||
.where(*queue_scope)
|
||||
.where(PrintQueueItem.position >= insert_position)
|
||||
.values(position=PrintQueueItem.position + quantity)
|
||||
)
|
||||
start_position = insert_position
|
||||
else:
|
||||
result = await db.execute(select(func.max(PrintQueueItem.position)).where(*queue_scope))
|
||||
max_pos = result.scalar() or 0
|
||||
start_position = max_pos + 1
|
||||
max_pos = result.scalar() or 0
|
||||
|
||||
# Resolve print_time_seconds for SJF scheduling (cache on item at creation)
|
||||
cached_print_time = None
|
||||
|
|
@ -661,51 +526,6 @@ async def add_to_queue(
|
|||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
ams_mapping_json = json.dumps(data.ams_mapping) if data.ams_mapping else None
|
||||
# Reprint fallback: the caller didn't specify an explicit ams_mapping (no
|
||||
# per-slot filament-mapping edit was made), but the archive carries the
|
||||
# slicer's own live-resolved AMS-slot pick from the original print (see
|
||||
# `extra_data.slicer_ams_mapping`, written by the VP-queue path via
|
||||
# `_extract_slicer_ams_mapping_json`). Reuse it so the reprint dispatches
|
||||
# to the exact same physical spool instead of the scheduler re-deriving a
|
||||
# (possibly ambiguous) mapping from just the file's static type/color.
|
||||
#
|
||||
# Global tray IDs only mean something relative to the specific printer
|
||||
# they were resolved against, so this only fires when the reprint targets
|
||||
# that exact printer (`extra_data.slicer_ams_mapping.printer_id`) — never
|
||||
# for a model-based dispatch (data.printer_id is None) or a reprint aimed
|
||||
# at a different printer, where the same tray number can hold a
|
||||
# completely different spool (#2700 review).
|
||||
#
|
||||
# It also stands down when the request carries force-color-match overrides:
|
||||
# those are the caller asking the scheduler to match strictly against the
|
||||
# printer's live trays, and they are only ever applied inside
|
||||
# `_compute_ams_mapping_for_printer` — the function a stored mapping makes
|
||||
# the scheduler skip. Same precedence as the VP-side toggle pair (#2700
|
||||
# review).
|
||||
#
|
||||
# Note this is otherwise unconditional — it applies regardless of whether
|
||||
# the physical spool in that slot has changed since the original print.
|
||||
# #1308 covers re-verifying a stored mapping against live AMS state at
|
||||
# dispatch time; that check is a separate PR and, once merged, will also
|
||||
# catch a stale slot inherited through this fallback.
|
||||
wants_live_color_match = any(
|
||||
isinstance(o, dict) and o.get("force_color_match") for o in (data.filament_overrides or [])
|
||||
)
|
||||
if (
|
||||
ams_mapping_json is None
|
||||
and not wants_live_color_match
|
||||
and archive
|
||||
and archive.extra_data
|
||||
and data.printer_id is not None
|
||||
):
|
||||
saved = archive.extra_data.get("slicer_ams_mapping")
|
||||
if (
|
||||
isinstance(saved, dict)
|
||||
and saved.get("printer_id") == data.printer_id
|
||||
and isinstance(saved.get("mapping"), list)
|
||||
and saved["mapping"]
|
||||
):
|
||||
ams_mapping_json = json.dumps(saved["mapping"])
|
||||
items = []
|
||||
for i in range(quantity):
|
||||
item = PrintQueueItem(
|
||||
|
|
@ -730,12 +550,9 @@ async def add_to_queue(
|
|||
timelapse=data.timelapse,
|
||||
use_ams=data.use_ams,
|
||||
nozzle_offset_cali=data.nozzle_offset_cali,
|
||||
preheat_override=data.preheat_override,
|
||||
preheat_chamber_target_override=data.preheat_chamber_target_override,
|
||||
gcode_injection=data.gcode_injection,
|
||||
cleanup_library_after_dispatch=data.cleanup_library_after_dispatch,
|
||||
project_id=data.project_id,
|
||||
position=start_position + i,
|
||||
position=max_pos + 1 + i,
|
||||
status="pending",
|
||||
created_by_id=current_user.id if current_user else None,
|
||||
batch_id=batch_id,
|
||||
|
|
@ -837,10 +654,7 @@ async def bulk_update_queue_items(
|
|||
skipped_count = 0
|
||||
|
||||
for item in items:
|
||||
# Skip non-pending rows and rows a dispatch worker has claimed (#2615) —
|
||||
# editing a claimed row mid-upload would split it from the in-flight
|
||||
# dispatch, so it's excluded from the bulk change (cancel to move it).
|
||||
if item.status != "pending" or item.dispatching_at is not None:
|
||||
if item.status != "pending":
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
|
|
@ -867,124 +681,16 @@ async def bulk_update_queue_items(
|
|||
# --- Batch endpoints ---
|
||||
|
||||
|
||||
@router.post("/batches", response_model=PrintBatchResponse)
|
||||
async def create_batch(
|
||||
data: PrintBatchCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
|
||||
):
|
||||
"""Create a batch.
|
||||
|
||||
Two modes:
|
||||
* ``item_ids`` provided: assign the listed pending queue items to a new
|
||||
batch ("Group as batch" UI action).
|
||||
* ``item_ids`` omitted/empty: create an empty batch so the client can
|
||||
pass the returned ``id`` on subsequent ``POST /queue/`` calls. Used by
|
||||
the multi-plate auto-batch flow in PrintModal.
|
||||
"""
|
||||
if not data.name or not data.name.strip():
|
||||
raise HTTPException(400, "Batch name is required")
|
||||
|
||||
batch = PrintBatch(
|
||||
name=data.name.strip()[:255],
|
||||
archive_id=data.archive_id,
|
||||
library_file_id=data.library_file_id,
|
||||
quantity=len(data.item_ids) if data.item_ids else 1,
|
||||
status="active",
|
||||
created_by_id=current_user.id if current_user else None,
|
||||
)
|
||||
db.add(batch)
|
||||
await db.flush() # Need batch.id before assigning to items
|
||||
|
||||
assigned = 0
|
||||
if data.item_ids:
|
||||
result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id.in_(data.item_ids)))
|
||||
items = result.scalars().all()
|
||||
for item in items:
|
||||
if item.status != "pending":
|
||||
continue
|
||||
if item.batch_id is not None:
|
||||
continue
|
||||
if (
|
||||
current_user is not None
|
||||
and item.created_by_id != current_user.id
|
||||
and not current_user.has_permission(Permission.QUEUE_UPDATE_ALL.value)
|
||||
):
|
||||
continue
|
||||
item.batch_id = batch.id
|
||||
assigned += 1
|
||||
batch.quantity = max(assigned, 1)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(batch)
|
||||
|
||||
logger.info("Created batch %s '%s' with %s assigned items", batch.id, batch.name, assigned)
|
||||
return await _build_batch_response(db, batch)
|
||||
|
||||
|
||||
@router.post("/batches/{batch_id}/ungroup", response_model=PrintBatchUngroupResponse)
|
||||
async def ungroup_batch(
|
||||
batch_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
|
||||
):
|
||||
"""Disband a batch: clear batch_id from all members and delete the batch row.
|
||||
|
||||
Items stay in the queue. Only ungroups items the caller owns (unless they
|
||||
hold QUEUE_UPDATE_ALL). A batch with all members ungrouped is deleted.
|
||||
"""
|
||||
result = await db.execute(select(PrintBatch).where(PrintBatch.id == batch_id))
|
||||
batch = result.scalar_one_or_none()
|
||||
if not batch:
|
||||
raise HTTPException(404, "Batch not found")
|
||||
|
||||
can_modify_all = current_user is None or current_user.has_permission(Permission.QUEUE_UPDATE_ALL.value)
|
||||
if not can_modify_all and batch.created_by_id != (current_user.id if current_user else None):
|
||||
raise HTTPException(404, "Batch not found")
|
||||
|
||||
result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.batch_id == batch_id))
|
||||
items = result.scalars().all()
|
||||
ungrouped = 0
|
||||
remaining = 0
|
||||
for item in items:
|
||||
if not can_modify_all and item.created_by_id != (current_user.id if current_user else None):
|
||||
remaining += 1
|
||||
continue
|
||||
item.batch_id = None
|
||||
ungrouped += 1
|
||||
|
||||
# Delete the batch row only when all members were ungrouped — otherwise it
|
||||
# still owns the items the caller couldn't touch.
|
||||
if remaining == 0:
|
||||
await db.delete(batch)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info("Ungrouped batch %s (%s items)", batch_id, ungrouped)
|
||||
return PrintBatchUngroupResponse(
|
||||
ungrouped_count=ungrouped,
|
||||
message=f"Ungrouped {ungrouped} item(s)",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/batches", response_model=list[PrintBatchResponse])
|
||||
async def list_batches(
|
||||
status: str | None = Query(None, description="Filter by status (active, completed, cancelled)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_ownership_permission(
|
||||
Permission.QUEUE_READ_ALL,
|
||||
Permission.QUEUE_READ_OWN,
|
||||
)
|
||||
),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
|
||||
):
|
||||
"""List all print batches with progress stats."""
|
||||
current_user, can_read_all = auth_result
|
||||
query = select(PrintBatch).order_by(PrintBatch.created_at.desc())
|
||||
if status:
|
||||
query = query.where(PrintBatch.status == status)
|
||||
if current_user is not None and not can_read_all:
|
||||
query = query.where(PrintBatch.created_by_id == current_user.id)
|
||||
result = await db.execute(query)
|
||||
batches = result.scalars().all()
|
||||
|
||||
|
|
@ -998,25 +704,13 @@ async def list_batches(
|
|||
async def get_batch(
|
||||
batch_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_ownership_permission(
|
||||
Permission.QUEUE_READ_ALL,
|
||||
Permission.QUEUE_READ_OWN,
|
||||
)
|
||||
),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
|
||||
):
|
||||
"""Get a print batch with progress stats."""
|
||||
current_user, can_read_all = auth_result
|
||||
result = await db.execute(select(PrintBatch).where(PrintBatch.id == batch_id))
|
||||
batch = result.scalar_one_or_none()
|
||||
if not batch:
|
||||
raise HTTPException(404, "Batch not found")
|
||||
if (
|
||||
current_user is not None
|
||||
and not can_read_all
|
||||
and (batch.created_by_id is None or batch.created_by_id != current_user.id)
|
||||
):
|
||||
raise HTTPException(404, "Batch not found")
|
||||
return await _build_batch_response(db, batch)
|
||||
|
||||
|
||||
|
|
@ -1088,15 +782,9 @@ async def _build_batch_response(db: AsyncSession, batch: PrintBatch) -> PrintBat
|
|||
async def get_queue_item(
|
||||
item_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_ownership_permission(
|
||||
Permission.QUEUE_READ_ALL,
|
||||
Permission.QUEUE_READ_OWN,
|
||||
)
|
||||
),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
|
||||
):
|
||||
"""Get a specific queue item."""
|
||||
current_user, can_read_all = auth_result
|
||||
result = await db.execute(
|
||||
select(PrintQueueItem)
|
||||
.options(
|
||||
|
|
@ -1111,12 +799,6 @@ async def get_queue_item(
|
|||
item = result.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(404, "Queue item not found")
|
||||
if (
|
||||
current_user is not None
|
||||
and not can_read_all
|
||||
and (item.created_by_id is None or item.created_by_id != current_user.id)
|
||||
):
|
||||
raise HTTPException(404, "Queue item not found")
|
||||
return _enrich_response(item)
|
||||
|
||||
|
||||
|
|
@ -1148,14 +830,6 @@ async def update_queue_item(
|
|||
if item.status != "pending":
|
||||
raise HTTPException(400, "Can only update pending items")
|
||||
|
||||
# Dispatch claim (#2615): the row is pending but a scheduler worker has
|
||||
# already claimed it and is uploading to its printer. Editing now (e.g.
|
||||
# reassigning printer_id) would split the queue row from the in-flight
|
||||
# archive/expected-print/physical command. Reject until dispatch finishes;
|
||||
# to move it, cancel first (the coordinated escape) and re-queue.
|
||||
if item.dispatching_at is not None:
|
||||
raise HTTPException(409, "Item is being dispatched — cancel it first to make changes")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# Normalize target_model if being updated
|
||||
|
|
@ -1186,57 +860,16 @@ async def update_queue_item(
|
|||
if not result.scalars().first():
|
||||
raise HTTPException(400, f"No active printers for model: {update_data['target_model']}")
|
||||
|
||||
# Cross-model safety gate (#2578) — same check as the create route, so
|
||||
# a mismatched target can't be introduced by editing either.
|
||||
sliced_for = None
|
||||
if item.archive_id:
|
||||
result = await db.execute(select(PrintArchive.sliced_for_model).where(PrintArchive.id == item.archive_id))
|
||||
sliced_for = result.scalar_one_or_none()
|
||||
elif item.library_file_id:
|
||||
result = await db.execute(select(LibraryFile).where(LibraryFile.id == item.library_file_id))
|
||||
lib = result.scalar_one_or_none()
|
||||
if lib and lib.file_metadata:
|
||||
sliced_for = lib.file_metadata.get("sliced_for_model")
|
||||
if not is_gcode_compatible(sliced_for, update_data["target_model"]):
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"File was sliced for {sliced_for} and cannot be dispatched to {update_data['target_model']} printers",
|
||||
)
|
||||
|
||||
# Serialize ams_mapping to JSON for TEXT column storage
|
||||
if "ams_mapping" in update_data:
|
||||
update_data["ams_mapping"] = json.dumps(update_data["ams_mapping"]) if update_data["ams_mapping"] else None
|
||||
|
||||
# Serialize filament_overrides to JSON for TEXT column storage, keeping only
|
||||
# the slots this item's plate actually prints (#2551 — same shared-override
|
||||
# list the create path narrows).
|
||||
# Serialize filament_overrides to JSON for TEXT column storage
|
||||
if "filament_overrides" in update_data:
|
||||
overrides = update_data["filament_overrides"]
|
||||
if overrides:
|
||||
overrides = overrides_for_plate(
|
||||
overrides,
|
||||
await _resolve_source_path(db, item),
|
||||
update_data.get("plate_id", item.plate_id),
|
||||
)
|
||||
update_data["filament_overrides"] = json.dumps(overrides) if overrides else None
|
||||
|
||||
# Serialize H2C rack-swap nozzle pick (#1780) to JSON for TEXT column
|
||||
# storage; same Text-as-opaque-blob convention as ams_mapping above.
|
||||
if "nozzle_mapping" in update_data:
|
||||
update_data["nozzle_mapping"] = (
|
||||
json.dumps(update_data["nozzle_mapping"]) if update_data["nozzle_mapping"] else None
|
||||
update_data["filament_overrides"] = (
|
||||
json.dumps(update_data["filament_overrides"]) if update_data["filament_overrides"] else None
|
||||
)
|
||||
|
||||
# Re-check the dispatch claim right before mutating (#2615). Several awaited
|
||||
# validations ran since the guard above, and a scheduler worker may have
|
||||
# claimed the row in that gap. A fresh read (item isn't dirty yet, so no
|
||||
# autoflush races the check) narrows the window to effectively nothing.
|
||||
claimed = (
|
||||
await db.execute(select(PrintQueueItem.dispatching_at).where(PrintQueueItem.id == item_id))
|
||||
).scalar_one_or_none()
|
||||
if claimed is not None:
|
||||
raise HTTPException(409, "Item is being dispatched — cancel it first to make changes")
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(item, field, value)
|
||||
|
||||
|
|
@ -1299,64 +932,6 @@ async def reorder_queue(
|
|||
return {"message": f"Reordered {len(data.items)} items"}
|
||||
|
||||
|
||||
@router.post("/printer/{printer_id}/resume")
|
||||
async def resume_queue_after_failure(
|
||||
printer_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
|
||||
):
|
||||
"""Clear the previous-success gate for a printer and restore skipped items.
|
||||
|
||||
Single atomic op (#1818):
|
||||
|
||||
* Sets ``gate_acknowledged=True`` on every ``failed`` / ``aborted`` queue
|
||||
item for this printer that's still in the scheduler's lookback window,
|
||||
so the next ``_check_previous_success`` call ignores them.
|
||||
* Restores ``skipped`` items whose ``error_message`` matches the
|
||||
scheduler's exact "Previous print failed or was aborted" gate string
|
||||
back to ``pending`` (clears ``error_message`` + ``completed_at``).
|
||||
|
||||
Returns counts so the UI can render a precise toast. No-op endpoint
|
||||
(zero counts) when called against a printer with no gate to clear.
|
||||
"""
|
||||
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
||||
printer = result.scalar_one_or_none()
|
||||
if not printer:
|
||||
raise HTTPException(404, "Printer not found")
|
||||
|
||||
ack_result = await db.execute(
|
||||
select(PrintQueueItem)
|
||||
.where(PrintQueueItem.printer_id == printer_id)
|
||||
.where(PrintQueueItem.status.in_(["failed", "aborted"]))
|
||||
.where(PrintQueueItem.gate_acknowledged == False) # noqa: E712
|
||||
)
|
||||
to_ack = ack_result.scalars().all()
|
||||
for failed_item in to_ack:
|
||||
failed_item.gate_acknowledged = True
|
||||
|
||||
restore_result = await db.execute(
|
||||
select(PrintQueueItem)
|
||||
.where(PrintQueueItem.printer_id == printer_id)
|
||||
.where(PrintQueueItem.status == "skipped")
|
||||
.where(PrintQueueItem.error_message == "Previous print failed or was aborted")
|
||||
)
|
||||
to_restore = restore_result.scalars().all()
|
||||
for skipped_item in to_restore:
|
||||
skipped_item.status = "pending"
|
||||
skipped_item.error_message = None
|
||||
skipped_item.completed_at = None
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Resume after failure on printer %s: acknowledged %d failure(s), restored %d skipped item(s)",
|
||||
printer_id,
|
||||
len(to_ack),
|
||||
len(to_restore),
|
||||
)
|
||||
return {"acknowledged": len(to_ack), "restored": len(to_restore)}
|
||||
|
||||
|
||||
@router.post("/{item_id}/cancel")
|
||||
async def cancel_queue_item(
|
||||
item_id: int,
|
||||
|
|
@ -1396,37 +971,19 @@ async def cancel_queue_item(
|
|||
async def stop_queue_item(
|
||||
item_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_ownership_permission(
|
||||
Permission.QUEUE_UPDATE_ALL,
|
||||
Permission.QUEUE_UPDATE_OWN,
|
||||
)
|
||||
),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
|
||||
):
|
||||
"""Stop an actively printing queue item.
|
||||
|
||||
Ownership-scoped (#1625-followup): callers with QUEUE_UPDATE_OWN can stop
|
||||
their own items; callers with QUEUE_UPDATE_ALL can stop any item. Mirrors
|
||||
the /cancel shape. Pre-fix this required QUEUE_UPDATE_ALL — Operators
|
||||
holding only _OWN saw the Stop button in the queue UI but got 403 on click.
|
||||
"""
|
||||
"""Stop an actively printing queue item."""
|
||||
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
|
||||
user, can_modify_all = auth_result
|
||||
from backend.app.services.tasmota import tasmota_service
|
||||
|
||||
result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
|
||||
item = result.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(404, "Queue item not found")
|
||||
|
||||
# Ownership check — mirrors /cancel. Ownerless items (created_by_id IS NULL)
|
||||
# require _ALL: stop is destructive and an _OWN holder can't claim "they
|
||||
# own it" the way /start does (#1670).
|
||||
if not can_modify_all and user is not None:
|
||||
if item.created_by_id is None or item.created_by_id != user.id:
|
||||
raise HTTPException(403, "You can only stop your own queue items")
|
||||
|
||||
if item.status != "printing":
|
||||
raise HTTPException(400, f"Can only stop items that are printing, current status: '{item.status}'")
|
||||
|
||||
|
|
@ -1458,39 +1015,35 @@ async def stop_queue_item(
|
|||
item.status = "cancelled"
|
||||
item.completed_at = datetime.now(timezone.utc)
|
||||
item.error_message = "Stopped by user" if stop_sent else "Stopped by user (printer was offline)"
|
||||
|
||||
# Reconcile the linked archive when the printer is offline (#2603). When the
|
||||
# stop command reaches the printer it later reports the stop over MQTT and
|
||||
# on_print_complete flips the archive to cancelled/failed. When the printer is
|
||||
# offline no such event ever arrives, so the archive would stay "printing"
|
||||
# forever (queue row cancelled, archive still printing — the reporter's
|
||||
# archive 436). Close it out here, mirroring what the MQTT path would have
|
||||
# done. Only touch a still-"printing" archive so we never overwrite a real
|
||||
# completion that raced in.
|
||||
if not stop_sent and item.archive_id:
|
||||
archive = await db.get(PrintArchive, item.archive_id)
|
||||
if archive and archive.status == "printing":
|
||||
archive.status = "cancelled"
|
||||
archive.completed_at = datetime.now(timezone.utc)
|
||||
archive.failure_reason = "Stopped by user (printer was offline)"
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Get smart plug info if auto-off is enabled
|
||||
plug_ip = None
|
||||
if auto_off_after:
|
||||
result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
|
||||
plug = result.scalar_one_or_none()
|
||||
if plug and plug.enabled:
|
||||
plug_ip = plug.ip_address
|
||||
|
||||
logger.info("Stopped printing queue item %s (stop command sent: %s)", item_id, stop_sent)
|
||||
|
||||
# Schedule power-off if the queue item opted in. Delegates to the smart-plug
|
||||
# manager so the off honours each plug's configured strategy (time delay or
|
||||
# temperature threshold), is cancelled if the printer starts printing again,
|
||||
# and never cuts power on a loaded print (#1890). Previously an inline block
|
||||
# hardcoded a 50°C / 600s cooldown wait and powered off on the timeout
|
||||
# regardless of print state.
|
||||
if auto_off_after:
|
||||
from backend.app.services.smart_plug_manager import smart_plug_manager
|
||||
# Schedule background task for cooldown + power off
|
||||
if plug_ip:
|
||||
|
||||
try:
|
||||
await smart_plug_manager.schedule_off_after_queue_job(printer_id, db)
|
||||
except Exception as e:
|
||||
logger.warning("Auto-off: Failed to schedule power-off for printer %s: %s", printer_id, e)
|
||||
async def cooldown_and_poweroff():
|
||||
logger.info("Auto-off: Waiting for printer %s to cool down before power off...", printer_id)
|
||||
await printer_manager.wait_for_cooldown(printer_id, target_temp=50.0, timeout=600)
|
||||
# Re-fetch plug since we're in a new async context
|
||||
from backend.app.core.database import async_session
|
||||
|
||||
async with async_session() as new_db:
|
||||
result = await new_db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
|
||||
plug = result.scalar_one_or_none()
|
||||
if plug and plug.enabled:
|
||||
logger.info("Auto-off: Powering off printer %s", printer_id)
|
||||
await tasmota_service.turn_off(plug)
|
||||
|
||||
spawn_background_task(cooldown_and_poweroff(), name=f"queue-cooldown-poweroff-{printer_id}")
|
||||
|
||||
return {"message": "Print stopped" if stop_sent else "Queue item cancelled (printer was offline)"}
|
||||
|
||||
|
|
@ -1500,21 +1053,10 @@ async def start_queue_item(
|
|||
item_id: int,
|
||||
skip_filament_check: bool = Query(default=False),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_ownership_permission(
|
||||
Permission.QUEUE_UPDATE_ALL,
|
||||
Permission.QUEUE_UPDATE_OWN,
|
||||
)
|
||||
),
|
||||
user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
|
||||
):
|
||||
"""Manually start a staged (manual_start) queue item.
|
||||
|
||||
Ownership-scoped (#1625-followup): callers with QUEUE_UPDATE_OWN can
|
||||
start their own items + claim ownership of NULL-owner items (VP-uploaded
|
||||
items arrive unattributed per #1670). Callers with QUEUE_UPDATE_ALL can
|
||||
start any item. Pre-fix this required QUEUE_UPDATE_OWN with no ownership
|
||||
check, so _OWN holders could start anyone's queue items via direct API.
|
||||
|
||||
Clears the manual_start flag so the scheduler picks it up. When
|
||||
``skip_filament_check`` is false (the default) the live filament
|
||||
deficit (#1496) is checked first — if the assigned spool can't satisfy
|
||||
|
|
@ -1522,8 +1064,6 @@ async def start_queue_item(
|
|||
payload so the caller can show a confirm dialog and retry with
|
||||
``skip_filament_check=true``.
|
||||
"""
|
||||
user, can_modify_all = auth_result
|
||||
|
||||
result = await db.execute(
|
||||
select(PrintQueueItem)
|
||||
.options(
|
||||
|
|
@ -1538,14 +1078,6 @@ async def start_queue_item(
|
|||
if not item:
|
||||
raise HTTPException(404, "Queue item not found")
|
||||
|
||||
# Ownership check — softer than /cancel because /start is the entry point
|
||||
# for #1670's VP-import flow: an unowned item is claimable by the first
|
||||
# _OWN holder who clicks ▶, and the route below credits them as owner.
|
||||
# An item with a DIFFERENT owner → 403.
|
||||
if not can_modify_all and user is not None:
|
||||
if item.created_by_id is not None and item.created_by_id != user.id:
|
||||
raise HTTPException(403, "You can only start your own queue items")
|
||||
|
||||
if item.status != "pending":
|
||||
raise HTTPException(400, f"Can only start pending items, current status: '{item.status}'")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,140 +0,0 @@
|
|||
"""API routes for printer heater (nozzle / bed / chamber) 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 RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.printer_sensor_history import PrinterSensorHistory
|
||||
from backend.app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/printer-sensor-history", tags=["printer-sensor-history"])
|
||||
|
||||
VALID_KINDS = {"nozzle", "nozzle_2", "bed", "chamber"}
|
||||
|
||||
|
||||
class HeaterHistoryPoint(BaseModel):
|
||||
recorded_at: datetime
|
||||
value: float | None
|
||||
target: float | None
|
||||
|
||||
|
||||
class HeaterSeries(BaseModel):
|
||||
sensor_kind: str
|
||||
data: list[HeaterHistoryPoint]
|
||||
min_value: float | None
|
||||
max_value: float | None
|
||||
avg_value: float | None
|
||||
|
||||
|
||||
class PrinterSensorHistoryResponse(BaseModel):
|
||||
printer_id: int
|
||||
series: list[HeaterSeries]
|
||||
|
||||
|
||||
@router.get("/{printer_id}", response_model=PrinterSensorHistoryResponse)
|
||||
async def get_printer_sensor_history(
|
||||
printer_id: int,
|
||||
hours: int = Query(default=24, ge=1, le=168, description="Hours of history (1-168)"),
|
||||
kinds: str | None = Query(
|
||||
default=None,
|
||||
description="Comma-separated list of sensor kinds (nozzle, nozzle_2, bed, chamber). All by default.",
|
||||
),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTER_SENSOR_HISTORY_READ),
|
||||
):
|
||||
"""Return per-sensor heater history for a printer."""
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
|
||||
if kinds:
|
||||
requested = {k.strip() for k in kinds.split(",") if k.strip()}
|
||||
kinds_to_fetch = sorted(requested & VALID_KINDS)
|
||||
else:
|
||||
kinds_to_fetch = sorted(VALID_KINDS)
|
||||
|
||||
series_out: list[HeaterSeries] = []
|
||||
for kind in kinds_to_fetch:
|
||||
result = await db.execute(
|
||||
select(PrinterSensorHistory)
|
||||
.where(
|
||||
and_(
|
||||
PrinterSensorHistory.printer_id == printer_id,
|
||||
PrinterSensorHistory.sensor_kind == kind,
|
||||
PrinterSensorHistory.recorded_at >= since,
|
||||
)
|
||||
)
|
||||
.order_by(PrinterSensorHistory.recorded_at)
|
||||
)
|
||||
records = result.scalars().all()
|
||||
|
||||
stats_result = await db.execute(
|
||||
select(
|
||||
func.min(PrinterSensorHistory.value).label("min_v"),
|
||||
func.max(PrinterSensorHistory.value).label("max_v"),
|
||||
func.avg(PrinterSensorHistory.value).label("avg_v"),
|
||||
).where(
|
||||
and_(
|
||||
PrinterSensorHistory.printer_id == printer_id,
|
||||
PrinterSensorHistory.sensor_kind == kind,
|
||||
PrinterSensorHistory.recorded_at >= since,
|
||||
)
|
||||
)
|
||||
)
|
||||
stats = stats_result.one()
|
||||
|
||||
series_out.append(
|
||||
HeaterSeries(
|
||||
sensor_kind=kind,
|
||||
data=[
|
||||
HeaterHistoryPoint(
|
||||
recorded_at=r.recorded_at,
|
||||
value=r.value,
|
||||
target=r.target,
|
||||
)
|
||||
for r in records
|
||||
],
|
||||
min_value=stats.min_v,
|
||||
max_value=stats.max_v,
|
||||
avg_value=round(stats.avg_v, 1) if stats.avg_v is not None else None,
|
||||
)
|
||||
)
|
||||
|
||||
return PrinterSensorHistoryResponse(printer_id=printer_id, series=series_out)
|
||||
|
||||
|
||||
@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 = RequirePermissionIfAuthEnabled(Permission.PRINTER_SENSOR_HISTORY_READ),
|
||||
):
|
||||
"""Delete old printer sensor history for a printer."""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
|
||||
result = await db.execute(
|
||||
select(func.count(PrinterSensorHistory.id)).where(
|
||||
and_(
|
||||
PrinterSensorHistory.printer_id == printer_id,
|
||||
PrinterSensorHistory.recorded_at < cutoff,
|
||||
)
|
||||
)
|
||||
)
|
||||
count = result.scalar()
|
||||
|
||||
await db.execute(
|
||||
PrinterSensorHistory.__table__.delete().where(
|
||||
and_(
|
||||
PrinterSensorHistory.printer_id == printer_id,
|
||||
PrinterSensorHistory.recorded_at < cutoff,
|
||||
)
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"deleted": count, "message": f"Deleted {count} records older than {days} days"}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -34,7 +34,6 @@ from backend.app.schemas.project import (
|
|||
BOMItemUpdate,
|
||||
ProjectChildPreview,
|
||||
ProjectCreate,
|
||||
ProjectFileProgress,
|
||||
ProjectImport,
|
||||
ProjectListResponse,
|
||||
ProjectResponse,
|
||||
|
|
@ -52,21 +51,6 @@ router = APIRouter(prefix="/projects", tags=["projects"])
|
|||
|
||||
_FAILURE_STATUSES = ("failed", "aborted", "cancelled", "stopped")
|
||||
|
||||
# Soft-deleted archives (#1343) keep their row — and therefore their
|
||||
# ``project_id`` — after their files have been removed from disk, so that global
|
||||
# Quick Stats can still count their filament / time / cost. Nothing in this
|
||||
# module filtered on that, which left deleted prints listed on the project with
|
||||
# thumbnails pointing at files that no longer exist, and no way to unassign them
|
||||
# (the only unassign UI lives on the Archives page, which correctly hides them)
|
||||
# — #2731.
|
||||
#
|
||||
# Every project-scoped query filters them out, counts included: a project that
|
||||
# lists 11 prints must not claim 12. That is a deliberate divergence from the
|
||||
# global Quick Stats behaviour, where the whole point of the soft delete is that
|
||||
# the contribution survives. A project is a piece of work with a definite
|
||||
# membership, not a lifetime total, so a print the user deleted has left it.
|
||||
_LIVE_ARCHIVE = PrintArchive.deleted_at.is_(None)
|
||||
|
||||
|
||||
async def compute_project_stats(
|
||||
db: AsyncSession, project_id: int, target_count: int | None = None, target_parts_count: int | None = None
|
||||
|
|
@ -98,7 +82,7 @@ async def compute_project_stats(
|
|||
func.coalesce(func.sum(PrintLogEntry.energy_cost), 0).label("total_energy_cost"),
|
||||
)
|
||||
.join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
|
||||
.where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
|
||||
.where(PrintArchive.project_id == project_id)
|
||||
)
|
||||
log_stats = log_stats_result.first()
|
||||
total_archives = int(log_stats.total_runs or 0)
|
||||
|
|
@ -119,7 +103,7 @@ async def compute_project_stats(
|
|||
).label("failed_runs"),
|
||||
)
|
||||
.join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
|
||||
.where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
|
||||
.where(PrintArchive.project_id == project_id)
|
||||
)
|
||||
items_split = items_split_result.first()
|
||||
total_items = int(items_split.total_items or 0)
|
||||
|
|
@ -227,7 +211,7 @@ async def list_projects(
|
|||
).label("failed_count"),
|
||||
)
|
||||
.join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
|
||||
.where(PrintArchive.project_id == project.id, _LIVE_ARCHIVE)
|
||||
.where(PrintArchive.project_id == project.id)
|
||||
)
|
||||
log_quick = log_quick_result.first()
|
||||
archive_count = int(log_quick.archive_count or 0)
|
||||
|
|
@ -252,7 +236,7 @@ async def list_projects(
|
|||
# Get archive previews (up to 6 most recent)
|
||||
archives_result = await db.execute(
|
||||
select(PrintArchive)
|
||||
.where(PrintArchive.project_id == project.id, _LIVE_ARCHIVE)
|
||||
.where(PrintArchive.project_id == project.id)
|
||||
.order_by(PrintArchive.created_at.desc())
|
||||
.limit(6)
|
||||
)
|
||||
|
|
@ -278,11 +262,7 @@ async def list_projects(
|
|||
status=project.status,
|
||||
target_count=project.target_count,
|
||||
target_parts_count=project.target_parts_count,
|
||||
target_sets=project.target_sets,
|
||||
budget=project.budget,
|
||||
tags=project.tags,
|
||||
due_date=project.due_date,
|
||||
priority=project.priority,
|
||||
created_at=project.created_at,
|
||||
archive_count=archive_count,
|
||||
total_items=total_items,
|
||||
|
|
@ -321,7 +301,6 @@ async def create_project(
|
|||
color=data.color,
|
||||
target_count=data.target_count,
|
||||
target_parts_count=data.target_parts_count,
|
||||
target_sets=data.target_sets,
|
||||
notes=data.notes,
|
||||
tags=data.tags,
|
||||
due_date=data.due_date,
|
||||
|
|
@ -344,7 +323,6 @@ async def create_project(
|
|||
status=project.status,
|
||||
target_count=project.target_count,
|
||||
target_parts_count=project.target_parts_count,
|
||||
target_sets=project.target_sets,
|
||||
notes=project.notes,
|
||||
attachments=project.attachments,
|
||||
url=project.url,
|
||||
|
|
@ -380,7 +358,7 @@ async def list_templates(
|
|||
for project in templates:
|
||||
# Get archive count
|
||||
archive_count_result = await db.execute(
|
||||
select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id, _LIVE_ARCHIVE)
|
||||
select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id)
|
||||
)
|
||||
archive_count = archive_count_result.scalar() or 0
|
||||
|
||||
|
|
@ -392,12 +370,7 @@ async def list_templates(
|
|||
color=project.color,
|
||||
status=project.status,
|
||||
target_count=project.target_count,
|
||||
target_parts_count=project.target_parts_count,
|
||||
target_sets=project.target_sets,
|
||||
budget=project.budget,
|
||||
tags=project.tags,
|
||||
due_date=project.due_date,
|
||||
priority=project.priority,
|
||||
created_at=project.created_at,
|
||||
archive_count=archive_count,
|
||||
queue_count=0,
|
||||
|
|
@ -435,7 +408,6 @@ async def create_project_from_template(
|
|||
color=template.color,
|
||||
target_count=template.target_count,
|
||||
target_parts_count=template.target_parts_count,
|
||||
target_sets=template.target_sets,
|
||||
notes=template.notes,
|
||||
tags=template.tags,
|
||||
priority=template.priority,
|
||||
|
|
@ -478,7 +450,6 @@ async def create_project_from_template(
|
|||
status=project.status,
|
||||
target_count=project.target_count,
|
||||
target_parts_count=project.target_parts_count,
|
||||
target_sets=project.target_sets,
|
||||
notes=project.notes,
|
||||
attachments=project.attachments,
|
||||
url=project.url,
|
||||
|
|
@ -513,7 +484,6 @@ async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectCh
|
|||
select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
|
||||
PrintArchive.project_id == child.id,
|
||||
PrintArchive.status == "completed",
|
||||
_LIVE_ARCHIVE,
|
||||
)
|
||||
)
|
||||
completed_count = completed_result.scalar() or 0
|
||||
|
|
@ -565,7 +535,6 @@ async def get_project(
|
|||
status=project.status,
|
||||
target_count=project.target_count,
|
||||
target_parts_count=project.target_parts_count,
|
||||
target_sets=project.target_sets,
|
||||
notes=project.notes,
|
||||
attachments=project.attachments,
|
||||
url=project.url,
|
||||
|
|
@ -614,18 +583,11 @@ async def update_project(
|
|||
project.target_count = data.target_count
|
||||
if data.target_parts_count is not None:
|
||||
project.target_parts_count = data.target_parts_count
|
||||
# Sent-but-null clears the copies-per-file target (#1897); omitted leaves it
|
||||
# alone (same #2536 semantics as tags/due_date below).
|
||||
if "target_sets" in data.model_fields_set:
|
||||
project.target_sets = data.target_sets
|
||||
if data.notes is not None:
|
||||
project.notes = data.notes
|
||||
# Sent-but-null clears the field; omitted leaves it alone. Guarding on
|
||||
# ``is not None`` would make an emptied tags field or a removed due date
|
||||
# silently revert to the stored value (#2536).
|
||||
if "tags" in data.model_fields_set:
|
||||
if data.tags is not None:
|
||||
project.tags = data.tags
|
||||
if "due_date" in data.model_fields_set:
|
||||
if data.due_date is not None:
|
||||
project.due_date = data.due_date
|
||||
if data.priority is not None:
|
||||
if data.priority not in ["low", "normal", "high", "urgent"]:
|
||||
|
|
@ -670,7 +632,6 @@ async def update_project(
|
|||
status=project.status,
|
||||
target_count=project.target_count,
|
||||
target_parts_count=project.target_parts_count,
|
||||
target_sets=project.target_sets,
|
||||
notes=project.notes,
|
||||
attachments=project.attachments,
|
||||
url=project.url,
|
||||
|
|
@ -731,7 +692,7 @@ async def list_project_archives(
|
|||
query = (
|
||||
select(PrintArchive)
|
||||
.options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by))
|
||||
.where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
|
||||
.where(PrintArchive.project_id == project_id)
|
||||
.order_by(PrintArchive.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
|
@ -769,76 +730,6 @@ async def list_project_queue(
|
|||
return items
|
||||
|
||||
|
||||
@router.get("/{project_id}/file-progress", response_model=list[ProjectFileProgress])
|
||||
async def get_project_file_progress(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
|
||||
):
|
||||
"""Completed-run counts per library file inside a project (#1897).
|
||||
|
||||
Counts completed ``PrintLogEntry`` rows (same source as the aggregate
|
||||
project stats) of archives attributed to this project, and maps each run to
|
||||
one of the project's library files — the files living in folders linked to
|
||||
the project, the same set the project detail page renders.
|
||||
|
||||
A run is attributed to exactly one file, by the strongest available match:
|
||||
1. ``archive.library_file_id`` (stamped at queue dispatch since #1897),
|
||||
2. content hash (covers historical rows),
|
||||
3. filename (covers hash drift, e.g. re-sliced uploads of the same name).
|
||||
Files with no completed runs are omitted — the frontend treats absence as 0.
|
||||
"""
|
||||
result = await db.execute(select(Project.id).where(Project.id == project_id))
|
||||
if result.scalar_one_or_none() is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
files_result = await db.execute(
|
||||
select(LibraryFile.id, LibraryFile.file_hash, LibraryFile.filename)
|
||||
.join(LibraryFolder, LibraryFile.folder_id == LibraryFolder.id)
|
||||
.where(LibraryFolder.project_id == project_id, LibraryFile.deleted_at.is_(None))
|
||||
)
|
||||
file_rows = files_result.all()
|
||||
if not file_rows:
|
||||
return []
|
||||
|
||||
# First match wins within each tier, so iteration order (file id) is stable
|
||||
# when duplicates share a hash or filename.
|
||||
by_id = {fid for fid, _, _ in file_rows}
|
||||
by_hash: dict[str, int] = {}
|
||||
by_name: dict[str, int] = {}
|
||||
for fid, fhash, fname in file_rows:
|
||||
if fhash and fhash not in by_hash:
|
||||
by_hash[fhash] = fid
|
||||
if fname not in by_name:
|
||||
by_name[fname] = fid
|
||||
|
||||
runs_result = await db.execute(
|
||||
select(
|
||||
PrintArchive.library_file_id,
|
||||
PrintArchive.content_hash,
|
||||
PrintArchive.filename,
|
||||
func.count(PrintLogEntry.id),
|
||||
)
|
||||
.join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
|
||||
.where(PrintArchive.project_id == project_id, PrintLogEntry.status == "completed", _LIVE_ARCHIVE)
|
||||
.group_by(PrintArchive.library_file_id, PrintArchive.content_hash, PrintArchive.filename)
|
||||
)
|
||||
|
||||
counts: dict[int, int] = {}
|
||||
for lib_file_id, content_hash, filename, run_count in runs_result.all():
|
||||
if lib_file_id in by_id:
|
||||
fid = lib_file_id
|
||||
elif content_hash and content_hash in by_hash:
|
||||
fid = by_hash[content_hash]
|
||||
elif filename in by_name:
|
||||
fid = by_name[filename]
|
||||
else:
|
||||
continue
|
||||
counts[fid] = counts.get(fid, 0) + run_count
|
||||
|
||||
return [ProjectFileProgress(file_id=fid, completed_count=n) for fid, n in sorted(counts.items())]
|
||||
|
||||
|
||||
@router.post("/{project_id}/add-archives")
|
||||
async def add_archives_to_project(
|
||||
project_id: int,
|
||||
|
|
@ -1501,7 +1392,6 @@ async def create_template_from_project(
|
|||
color=source.color,
|
||||
target_count=source.target_count,
|
||||
target_parts_count=source.target_parts_count,
|
||||
target_sets=source.target_sets,
|
||||
notes=source.notes,
|
||||
tags=source.tags,
|
||||
priority=source.priority,
|
||||
|
|
@ -1544,7 +1434,6 @@ async def create_template_from_project(
|
|||
status=template.status,
|
||||
target_count=template.target_count,
|
||||
target_parts_count=template.target_parts_count,
|
||||
target_sets=template.target_sets,
|
||||
notes=template.notes,
|
||||
attachments=template.attachments,
|
||||
url=template.url,
|
||||
|
|
@ -1596,7 +1485,7 @@ async def get_project_timeline(
|
|||
# Get archives and add events
|
||||
archives_result = await db.execute(
|
||||
select(PrintArchive)
|
||||
.where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
|
||||
.where(PrintArchive.project_id == project_id)
|
||||
.order_by(PrintArchive.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
|
|
@ -1754,7 +1643,6 @@ async def export_project(
|
|||
"status": project.status,
|
||||
"target_count": project.target_count,
|
||||
"target_parts_count": project.target_parts_count,
|
||||
"target_sets": project.target_sets,
|
||||
"notes": project.notes,
|
||||
"tags": project.tags,
|
||||
"due_date": project.due_date.isoformat() if project.due_date else None,
|
||||
|
|
@ -1806,7 +1694,6 @@ async def import_project(
|
|||
status=data.status,
|
||||
target_count=data.target_count,
|
||||
target_parts_count=data.target_parts_count,
|
||||
target_sets=data.target_sets,
|
||||
notes=data.notes,
|
||||
tags=data.tags,
|
||||
due_date=data.due_date,
|
||||
|
|
@ -1869,7 +1756,6 @@ async def import_project(
|
|||
status=project.status,
|
||||
target_count=project.target_count,
|
||||
target_parts_count=project.target_parts_count,
|
||||
target_sets=project.target_sets,
|
||||
notes=project.notes,
|
||||
attachments=project.attachments,
|
||||
url=project.url,
|
||||
|
|
@ -1933,7 +1819,6 @@ async def import_project_file(
|
|||
status=data.get("status", "active"),
|
||||
target_count=data.get("target_count"),
|
||||
target_parts_count=data.get("target_parts_count"),
|
||||
target_sets=data.get("target_sets"),
|
||||
notes=data.get("notes"),
|
||||
tags=data.get("tags"),
|
||||
due_date=datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None,
|
||||
|
|
@ -2062,7 +1947,6 @@ async def import_project_file(
|
|||
status=project.status,
|
||||
target_count=project.target_count,
|
||||
target_parts_count=project.target_parts_count,
|
||||
target_sets=project.target_sets,
|
||||
notes=project.notes,
|
||||
attachments=project.attachments,
|
||||
url=project.url,
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import zipfile
|
|||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, UploadFile
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled, caller_is_api_key, require_energy_cost_update
|
||||
|
|
@ -35,6 +35,32 @@ _SENSITIVE_FIELDS_FOR_API_KEY = (
|
|||
)
|
||||
|
||||
|
||||
def _sqlalchemy_type_to_sqlite_type(type_repr: str) -> str:
|
||||
"""Map a SQLAlchemy column type's ``str()`` to a SQLite-native column type.
|
||||
|
||||
Used by ``create_backup_zip`` to reconstruct a portable SQLite database
|
||||
file from PostgreSQL data. Falling through to TEXT for binary columns
|
||||
corrupts non-UTF8 bytes — the BLOB branch is the #1333 regression guard
|
||||
for OIDC icon BLOBs.
|
||||
|
||||
Extracted as a pure helper so it can be unit-tested without spinning up
|
||||
the full FastAPI app + backup pipeline.
|
||||
"""
|
||||
type_str = type_repr.upper()
|
||||
if "INT" in type_str:
|
||||
return "INTEGER"
|
||||
if "FLOAT" in type_str or "REAL" in type_str or "NUMERIC" in type_str:
|
||||
return "REAL"
|
||||
if "BOOL" in type_str:
|
||||
return "BOOLEAN"
|
||||
if "BLOB" in type_str or "BYTEA" in type_str or "BINARY" in type_str:
|
||||
# OIDC icon BLOB column (#1333) — without this branch the column
|
||||
# was created as TEXT and non-UTF8 bytes were corrupted during the
|
||||
# PG→SQLite-ZIP backup round trip.
|
||||
return "BLOB"
|
||||
return "TEXT"
|
||||
|
||||
|
||||
async def get_setting(db: AsyncSession, key: str) -> str | None:
|
||||
"""Get a single setting value by key."""
|
||||
result = await db.execute(select(Settings).where(Settings.key == key))
|
||||
|
|
@ -42,88 +68,6 @@ async def get_setting(db: AsyncSession, key: str) -> str | None:
|
|||
return setting.value if setting else None
|
||||
|
||||
|
||||
# Accepted spellings for a boolean settings value. Settings live in a VARCHAR
|
||||
# column and every reader compares them as strings, so these are normalised to
|
||||
# "true"/"false" on the way in. The sets are deliberately generous: these
|
||||
# endpoints are part of the documented REST surface, reached by scripts and by
|
||||
# Home Assistant rest_command, where "True", "1" and "on" are all natural.
|
||||
_TRUTHY_SETTING_VALUES = frozenset({"true", "1", "yes", "on"})
|
||||
_FALSY_SETTING_VALUES = frozenset({"false", "0", "no", "off"})
|
||||
|
||||
|
||||
def setting_is_true(value: object) -> bool:
|
||||
"""Return True if a *stored* settings value means "on".
|
||||
|
||||
Deliberately narrower than the spellings ``normalize_bool_setting`` accepts:
|
||||
it matches only what every other reader in the codebase treats as on
|
||||
(``value.lower() == "true"``). Submitted values are canonicalised on write,
|
||||
so a stored value is always "true"/"false"/""; accepting "1" or "on" here
|
||||
would make this function disagree with the rest of the app about any legacy
|
||||
row containing them.
|
||||
|
||||
A bool is tolerated for the case of a row written before values were
|
||||
normalised, where SQLite coerced a raw bool into the VARCHAR column.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return False
|
||||
return str(value).strip().lower() == "true"
|
||||
|
||||
|
||||
def normalize_bool_setting(key: str, value: object) -> str:
|
||||
"""Coerce a boolean-ish settings value to the canonical "true"/"false".
|
||||
|
||||
Raises HTTPException(400) for values with no sensible interpretation, so an
|
||||
API client gets a message naming the field instead of a 500.
|
||||
|
||||
A JSON boolean is the natural thing for an API client to send, and before
|
||||
this normalisation it caused two distinct failures on
|
||||
``PUT /settings/spoolman``: ``bool.lower()`` raised AttributeError, and the
|
||||
raw bool was written into a VARCHAR column, which SQLite silently coerces
|
||||
to 1/0 while asyncpg rejects outright. Both surfaced as an opaque 500.
|
||||
"""
|
||||
if isinstance(value, bool): # must precede the int branch — bool is an int
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, int):
|
||||
if value in (0, 1):
|
||||
return "true" if value else "false"
|
||||
raise HTTPException(400, f"{key} must be a boolean; got the number {value}")
|
||||
if isinstance(value, str):
|
||||
candidate = value.strip().lower()
|
||||
if not candidate:
|
||||
# Empty is stored verbatim rather than normalised to "false".
|
||||
# get_spoolman_settings reads these with ``or "<default>"``, so an
|
||||
# empty stored value means "use the default" — and two of them
|
||||
# (spoolman_report_partial_usage, auto_add_unknown_rfid) default to
|
||||
# ON. Rewriting "" to "false" would silently switch them off for any
|
||||
# client that submits a blank value.
|
||||
return ""
|
||||
if candidate in _TRUTHY_SETTING_VALUES:
|
||||
return "true"
|
||||
if candidate in _FALSY_SETTING_VALUES:
|
||||
return "false"
|
||||
raise HTTPException(400, f"{key} must be a boolean; got {value!r}")
|
||||
raise HTTPException(400, f"{key} must be a boolean; got {type(value).__name__}")
|
||||
|
||||
|
||||
def normalize_str_setting(key: str, value: object) -> str:
|
||||
"""Return a string settings value, rejecting types that would store garbage.
|
||||
|
||||
``str()`` on a dict or list would persist its repr, so those are refused
|
||||
rather than silently written. Numbers are accepted and stringified: a port
|
||||
or a bare host submitted unquoted is a plausible client mistake, not a
|
||||
reason to fail the request.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bool | int | float):
|
||||
return str(value)
|
||||
raise HTTPException(400, f"{key} must be a string; got {type(value).__name__}")
|
||||
|
||||
|
||||
async def get_external_login_url(db: AsyncSession) -> str:
|
||||
"""Get the external URL for the login page.
|
||||
|
||||
|
|
@ -164,11 +108,9 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
|
|||
"auto_archive",
|
||||
"save_thumbnails",
|
||||
"capture_finish_photo",
|
||||
"finish_photo_restore_plate",
|
||||
"spoolman_enabled",
|
||||
"spoolman_disable_weight_sync",
|
||||
"spoolman_report_partial_usage",
|
||||
"auto_add_unknown_rfid",
|
||||
"disable_filament_warnings",
|
||||
"prefer_lowest_filament",
|
||||
"check_updates",
|
||||
|
|
@ -185,19 +127,16 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
|
|||
"queue_drying_enabled",
|
||||
"queue_drying_block",
|
||||
"ambient_drying_enabled",
|
||||
"print_drying_enabled",
|
||||
"require_plate_clear",
|
||||
"queue_shortest_first",
|
||||
# default_bed_levelling / default_flow_cali / default_nozzle_offset_cali
|
||||
# are tri-state strings (off/on/auto) — parsed via the raw-string else
|
||||
# branch; the TriState validator coerces legacy "true"/"false" rows.
|
||||
"default_bed_levelling",
|
||||
"default_flow_cali",
|
||||
"default_vibration_cali",
|
||||
"default_layer_inspect",
|
||||
"default_timelapse",
|
||||
"default_nozzle_offset_cali",
|
||||
"ldap_enabled",
|
||||
"ldap_auto_provision",
|
||||
"local_login_enabled",
|
||||
"preheat_enabled",
|
||||
]:
|
||||
settings_dict[setting.key] = setting.value.lower() == "true"
|
||||
elif setting.key in [
|
||||
|
|
@ -213,7 +152,6 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
|
|||
"ams_humidity_good",
|
||||
"ams_humidity_fair",
|
||||
"ams_history_retention_days",
|
||||
"printer_sensor_history_retention_days",
|
||||
"ftp_retry_count",
|
||||
"ftp_retry_delay",
|
||||
"ftp_timeout",
|
||||
|
|
@ -221,11 +159,6 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
|
|||
"stagger_group_size",
|
||||
"stagger_interval_minutes",
|
||||
"forecast_global_lead_time_days",
|
||||
"session_max_hours",
|
||||
"pipeline_max_copies",
|
||||
"preheat_max_wait_seconds",
|
||||
"preheat_soak_seconds",
|
||||
"queue_max_concurrent_uploads",
|
||||
]:
|
||||
settings_dict[setting.key] = int(setting.value)
|
||||
elif setting.key == "default_printer_id":
|
||||
|
|
@ -267,39 +200,11 @@ async def get_settings(
|
|||
async def update_settings(
|
||||
settings_update: AppSettingsUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Update application settings."""
|
||||
update_data = settings_update.model_dump(exclude_unset=True)
|
||||
|
||||
# Safety refusals on disabling local login (#1589). Two failure modes
|
||||
# would otherwise lock everyone out of the install:
|
||||
# 1. No enabled OIDC provider exists — nobody could authenticate.
|
||||
# 2. The caller has no UserOIDCLink — they would lock themselves out
|
||||
# even if other admins are linked.
|
||||
# Either case returns HTTP 400 instead of silently saving. The
|
||||
# ``BAMBUDDY_LOCAL_LOGIN=true`` env-var bypass on /auth/login is a
|
||||
# separate recovery path; the refusals here protect the *default*
|
||||
# configuration where the env var is absent.
|
||||
if update_data.get("local_login_enabled") is False:
|
||||
from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
|
||||
|
||||
enabled_count = await db.scalar(select(func.count(OIDCProvider.id)).where(OIDCProvider.is_enabled.is_(True)))
|
||||
if not enabled_count:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot disable local login: no OIDC provider is enabled.",
|
||||
)
|
||||
if current_user is not None:
|
||||
caller_links = await db.scalar(
|
||||
select(func.count(UserOIDCLink.id)).where(UserOIDCLink.user_id == current_user.id)
|
||||
)
|
||||
if not caller_links:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot disable local login: your account has no OIDC link, so you would lock yourself out.",
|
||||
)
|
||||
|
||||
# Check if any MQTT settings are being updated
|
||||
mqtt_keys = {
|
||||
"mqtt_enabled",
|
||||
|
|
@ -435,18 +340,11 @@ _UI_PREFERENCE_FIELDS: tuple[str, ...] = (
|
|||
"time_format",
|
||||
"date_format",
|
||||
"drying_presets",
|
||||
"ams_humidity_thresholds",
|
||||
"ams_humidity_good",
|
||||
"ams_humidity_fair",
|
||||
"ams_temp_good",
|
||||
"ams_temp_fair",
|
||||
"bed_cooled_threshold",
|
||||
# Temperature / fan-speed presets for the printer-card popovers. Numbers
|
||||
# only; no PII / credentials.
|
||||
"nozzle_temp_presets",
|
||||
"bed_temp_presets",
|
||||
"chamber_temp_presets",
|
||||
"fan_speed_presets",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -500,7 +398,6 @@ async def get_spoolman_settings(
|
|||
spoolman_sync_mode = await get_setting(db, "spoolman_sync_mode") or "auto"
|
||||
spoolman_disable_weight_sync = await get_setting(db, "spoolman_disable_weight_sync") or "false"
|
||||
spoolman_report_partial_usage = await get_setting(db, "spoolman_report_partial_usage") or "true"
|
||||
auto_add_unknown_rfid = await get_setting(db, "auto_add_unknown_rfid") or "true"
|
||||
|
||||
return {
|
||||
"spoolman_enabled": spoolman_enabled,
|
||||
|
|
@ -508,7 +405,6 @@ async def get_spoolman_settings(
|
|||
"spoolman_sync_mode": spoolman_sync_mode,
|
||||
"spoolman_disable_weight_sync": spoolman_disable_weight_sync,
|
||||
"spoolman_report_partial_usage": spoolman_report_partial_usage,
|
||||
"auto_add_unknown_rfid": auto_add_unknown_rfid,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -518,20 +414,14 @@ async def update_spoolman_settings(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Update Spoolman integration settings.
|
||||
|
||||
The body is a free-form dict rather than a schema, so each value is
|
||||
normalised before it is persisted — see ``normalize_bool_setting`` for why
|
||||
a JSON boolean used to produce a 500 here.
|
||||
"""
|
||||
"""Update Spoolman integration settings."""
|
||||
if "spoolman_enabled" in settings:
|
||||
was_enabled = setting_is_true(await get_setting(db, "spoolman_enabled"))
|
||||
new_val = normalize_bool_setting("spoolman_enabled", settings["spoolman_enabled"])
|
||||
now_enabled = new_val == "true"
|
||||
old_val = await get_setting(db, "spoolman_enabled") or "false"
|
||||
new_val = settings["spoolman_enabled"]
|
||||
await set_setting(db, "spoolman_enabled", new_val)
|
||||
|
||||
# Switching to Spoolman: clear built-in inventory slot assignments
|
||||
if not was_enabled and now_enabled:
|
||||
if old_val.lower() != "true" and new_val.lower() == "true":
|
||||
from backend.app.models.spool_assignment import SpoolAssignment
|
||||
|
||||
result = await db.execute(delete(SpoolAssignment))
|
||||
|
|
@ -541,32 +431,23 @@ async def update_spoolman_settings(
|
|||
# spoolman_slot_assignments rows linger and would wrongly count as
|
||||
# "assigned" in any mode-agnostic check (e.g. the missing-spool-
|
||||
# assignment notification, which unions both tables — #1473).
|
||||
elif was_enabled and not now_enabled:
|
||||
elif old_val.lower() == "true" and new_val.lower() != "true":
|
||||
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
|
||||
|
||||
result = await db.execute(delete(SpoolmanSlotAssignment))
|
||||
logger.info("Cleared %d Spoolman slot assignments on switch to internal mode", result.rowcount)
|
||||
if "spoolman_url" in settings:
|
||||
await set_setting(db, "spoolman_url", normalize_str_setting("spoolman_url", settings["spoolman_url"]))
|
||||
await set_setting(db, "spoolman_url", settings["spoolman_url"])
|
||||
if "spoolman_sync_mode" in settings:
|
||||
await set_setting(
|
||||
db, "spoolman_sync_mode", normalize_str_setting("spoolman_sync_mode", settings["spoolman_sync_mode"])
|
||||
)
|
||||
for bool_key in ("spoolman_disable_weight_sync", "spoolman_report_partial_usage", "auto_add_unknown_rfid"):
|
||||
if bool_key in settings:
|
||||
await set_setting(db, bool_key, normalize_bool_setting(bool_key, settings[bool_key]))
|
||||
|
||||
spoolman_changed = "spoolman_enabled" in settings or "spoolman_url" in settings
|
||||
await set_setting(db, "spoolman_sync_mode", settings["spoolman_sync_mode"])
|
||||
if "spoolman_disable_weight_sync" in settings:
|
||||
await set_setting(db, "spoolman_disable_weight_sync", settings["spoolman_disable_weight_sync"])
|
||||
if "spoolman_report_partial_usage" in settings:
|
||||
await set_setting(db, "spoolman_report_partial_usage", settings["spoolman_report_partial_usage"])
|
||||
|
||||
await db.commit()
|
||||
db.expire_all()
|
||||
|
||||
if spoolman_changed:
|
||||
from backend.app.services.location_service import maybe_sync_spoolman_locations
|
||||
|
||||
if await maybe_sync_spoolman_locations(db):
|
||||
await db.commit()
|
||||
|
||||
# Return updated settings
|
||||
return await get_spoolman_settings(db)
|
||||
|
||||
|
|
@ -645,31 +526,25 @@ async def create_backup_zip(output_path: Path | None = None) -> tuple[Path, str]
|
|||
import json
|
||||
import sqlite3
|
||||
|
||||
from sqlalchemy import create_engine as create_sync_engine
|
||||
|
||||
from backend.app.core.database import Base, engine
|
||||
|
||||
backup_db_path = temp_path / "bambuddy.db"
|
||||
dst = sqlite3.connect(str(backup_db_path))
|
||||
metadata = Base.metadata
|
||||
|
||||
# Build the portable SQLite schema with SQLAlchemy's own DDL rather
|
||||
# than a hand-rolled CREATE TABLE. metadata.create_all() emits the
|
||||
# exact schema a native SQLite install gets — NOT NULL, DEFAULT
|
||||
# (server_default=func.now() → CURRENT_TIMESTAMP), foreign keys,
|
||||
# unique constraints and indexes. The previous name+type-only
|
||||
# rebuild dropped all of these, so a Postgres→SQLite restore left
|
||||
# server_default columns (e.g. spoolbuddy_devices.created_at) with
|
||||
# no DEFAULT — SQLAlchemy omits such columns on INSERT and the DB
|
||||
# then wrote NULL, which 500'd on the next read (#2526). Using the
|
||||
# real DDL also keeps the #1333 BLOB guard: LargeBinary still
|
||||
# renders as BLOB, so OIDC icon bytes survive the round trip.
|
||||
schema_engine = create_sync_engine(f"sqlite:///{backup_db_path}")
|
||||
try:
|
||||
metadata.create_all(schema_engine)
|
||||
finally:
|
||||
schema_engine.dispose()
|
||||
|
||||
dst = sqlite3.connect(str(backup_db_path))
|
||||
# Create tables in SQLite backup (simplified — just column names and types)
|
||||
for table in metadata.sorted_tables:
|
||||
cols = []
|
||||
pk_cols = [col.name for col in table.columns if col.primary_key]
|
||||
for col in table.columns:
|
||||
col_type = _sqlalchemy_type_to_sqlite_type(str(col.type))
|
||||
# Only inline PRIMARY KEY for single-column PKs
|
||||
pk = " PRIMARY KEY" if col.primary_key and len(pk_cols) == 1 else ""
|
||||
cols.append(f"{col.name} {col_type}{pk}")
|
||||
# Add composite primary key constraint if needed
|
||||
if len(pk_cols) > 1:
|
||||
cols.append(f"PRIMARY KEY ({', '.join(pk_cols)})")
|
||||
dst.execute(f"CREATE TABLE IF NOT EXISTS {table.name} ({', '.join(cols)})") # noqa: S608
|
||||
|
||||
# Export data from Postgres to SQLite
|
||||
async with engine.connect() as conn:
|
||||
|
|
@ -1047,8 +922,8 @@ async def restore_backup(
|
|||
# 3b. Pause timer-based background services BEFORE the DB swap.
|
||||
# close_all_connections() below only disposes the engine's pool,
|
||||
# not the asyncio tasks that opened sessions from it. The print
|
||||
# scheduler (30 s cadence), smart-plug snapshot loop (30 s), and
|
||||
# notification digest loop all
|
||||
# scheduler (30 s cadence), smart-plug snapshot loop (30 s),
|
||||
# notification digest loop, and background dispatch worker all
|
||||
# wake up and call async_session(), which lazily re-creates a
|
||||
# pool connection holding RowExclusiveLock on print_queue /
|
||||
# smart_plug_energy_snapshots / etc. The DROP TABLE CASCADE
|
||||
|
|
@ -1057,6 +932,7 @@ async def restore_backup(
|
|||
# full restore rollback. Successful restore already requires a
|
||||
# container restart, so we don't restart the services here.
|
||||
try:
|
||||
from backend.app.services.background_dispatch import background_dispatch
|
||||
from backend.app.services.notification_service import notification_service
|
||||
from backend.app.services.print_scheduler import scheduler as print_scheduler
|
||||
from backend.app.services.smart_plug_manager import smart_plug_manager
|
||||
|
|
@ -1065,6 +941,7 @@ async def restore_backup(
|
|||
print_scheduler.stop()
|
||||
smart_plug_manager.stop_scheduler()
|
||||
notification_service.stop_digest_scheduler()
|
||||
await background_dispatch.stop()
|
||||
# In-flight loop iterations need a moment to commit + release
|
||||
# their DB sessions before we dispose() the engine pool.
|
||||
await asyncio.sleep(1.0)
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ job_id and a status_url pointing here. The frontend polls this until
|
|||
status flips to `completed` or `failed`.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from backend.app.core.auth import require_ownership_permission
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.slice_dispatch import slice_dispatch
|
||||
|
|
@ -18,27 +18,14 @@ router = APIRouter(prefix="/slice-jobs", tags=["slice-jobs"])
|
|||
@router.get("/{job_id}")
|
||||
async def get_slice_job(
|
||||
job_id: int,
|
||||
# Job IDs are sequential integers and the body leaks source filenames plus
|
||||
# the resulting library_file_id / archive_id. Gate on the library read
|
||||
# permission family (own/all) and then scope per-row: a READ_OWN caller may
|
||||
# only poll jobs they started (SliceJob.owner_id).
|
||||
auth: tuple[User | None, bool] = Depends(
|
||||
require_ownership_permission(
|
||||
Permission.LIBRARY_READ_ALL,
|
||||
Permission.LIBRARY_READ_OWN,
|
||||
)
|
||||
),
|
||||
# Job IDs are sequential integers and the body leaks source filenames
|
||||
# plus the resulting library_file_id / archive_id. Gate on LIBRARY_READ
|
||||
# — same baseline a user needs to see slice sources or results.
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_READ),
|
||||
):
|
||||
user, can_read_all = auth
|
||||
job = slice_dispatch.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="Slice job not found or expired")
|
||||
# Per-row scoping. Jobs started by API-key / auth-disabled callers have
|
||||
# owner_id=None and are visible only to READ_ALL pollers (fail-closed,
|
||||
# mirrors the library ownerless-row rule). 404 not 403 to avoid job-id
|
||||
# enumeration.
|
||||
if not can_read_all and (user is None or job.owner_id != user.id):
|
||||
raise HTTPException(status_code=404, detail="Slice job not found or expired")
|
||||
body: dict = {
|
||||
"job_id": job.id,
|
||||
"status": job.status,
|
||||
|
|
|
|||
|
|
@ -1,199 +0,0 @@
|
|||
"""API routes for Slicer Pipelines (#1425, PR A — definitions only).
|
||||
|
||||
A pipeline bundles printer / process / filament(s) / bed-type picks so the
|
||||
SliceModal can apply them in one click. PR A surfaces only CRUD + an
|
||||
``apply`` helper that returns the pipeline as the four ``PresetRef`` slots a
|
||||
``SliceRequest`` expects. PR B adds single-target dispatch; PR C adds
|
||||
multi-copy fanout and the run dashboard.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.slicer_pipeline import SlicerPipeline
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.slicer import PresetRef
|
||||
from backend.app.schemas.slicer_pipeline import (
|
||||
SlicerPipelineCreate,
|
||||
SlicerPipelineListResponse,
|
||||
SlicerPipelineResponse,
|
||||
SlicerPipelineUpdate,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/slicer-pipelines", tags=["Slicer Pipelines"])
|
||||
|
||||
|
||||
def _to_response(row: SlicerPipeline) -> SlicerPipelineResponse:
|
||||
"""Materialise the JSON filament list back into PresetRef objects so the
|
||||
response shape matches the create/update input shape exactly."""
|
||||
try:
|
||||
raw = json.loads(row.filament_presets_json) if row.filament_presets_json else []
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
# Row was hand-edited or corrupted — return an empty list rather than
|
||||
# 500ing on a list endpoint. Edit/run paths will surface the problem.
|
||||
logger.warning("slicer_pipeline %d has invalid filament_presets_json", row.id)
|
||||
raw = []
|
||||
filament_presets = [PresetRef(**f) for f in raw if isinstance(f, dict)]
|
||||
|
||||
return SlicerPipelineResponse(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
description=row.description,
|
||||
printer_preset=PresetRef(source=row.printer_preset_source, id=row.printer_preset_id),
|
||||
process_preset=PresetRef(source=row.process_preset_source, id=row.process_preset_id),
|
||||
filament_presets=filament_presets,
|
||||
bed_type=row.bed_type,
|
||||
target_kind=row.target_kind, # type: ignore[arg-type]
|
||||
target_printer_id=row.target_printer_id,
|
||||
target_model_class=row.target_model_class,
|
||||
fanout_strategy=row.fanout_strategy, # type: ignore[arg-type]
|
||||
created_by=row.created_by,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=SlicerPipelineListResponse)
|
||||
async def list_pipelines(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all pipelines, newest first. Soft-deleted rows are hidden."""
|
||||
result = await db.execute(
|
||||
select(SlicerPipeline).where(SlicerPipeline.is_deleted.is_(False)).order_by(SlicerPipeline.id.desc())
|
||||
)
|
||||
rows = result.scalars().all()
|
||||
return SlicerPipelineListResponse(pipelines=[_to_response(r) for r in rows])
|
||||
|
||||
|
||||
@router.post("/", response_model=SlicerPipelineResponse, status_code=201)
|
||||
async def create_pipeline(
|
||||
data: SlicerPipelineCreate,
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new pipeline."""
|
||||
row = SlicerPipeline(
|
||||
name=data.name.strip(),
|
||||
description=data.description,
|
||||
printer_preset_source=data.printer_preset.source,
|
||||
printer_preset_id=data.printer_preset.id,
|
||||
process_preset_source=data.process_preset.source,
|
||||
process_preset_id=data.process_preset.id,
|
||||
filament_presets_json=json.dumps([f.model_dump() for f in data.filament_presets]),
|
||||
bed_type=data.bed_type,
|
||||
created_by=current_user.id if current_user else None,
|
||||
)
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return _to_response(row)
|
||||
|
||||
|
||||
@router.get("/{pipeline_id}", response_model=SlicerPipelineResponse)
|
||||
async def get_pipeline(
|
||||
pipeline_id: int,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Read one pipeline by id."""
|
||||
result = await db.execute(
|
||||
select(SlicerPipeline).where(
|
||||
SlicerPipeline.id == pipeline_id,
|
||||
SlicerPipeline.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(404, "Pipeline not found")
|
||||
return _to_response(row)
|
||||
|
||||
|
||||
@router.put("/{pipeline_id}", response_model=SlicerPipelineResponse)
|
||||
async def update_pipeline(
|
||||
pipeline_id: int,
|
||||
data: SlicerPipelineUpdate,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a pipeline. Only fields present in the payload are written."""
|
||||
result = await db.execute(
|
||||
select(SlicerPipeline).where(
|
||||
SlicerPipeline.id == pipeline_id,
|
||||
SlicerPipeline.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(404, "Pipeline not found")
|
||||
|
||||
if data.name is not None:
|
||||
row.name = data.name.strip()
|
||||
if data.description is not None:
|
||||
row.description = data.description
|
||||
if data.printer_preset is not None:
|
||||
row.printer_preset_source = data.printer_preset.source
|
||||
row.printer_preset_id = data.printer_preset.id
|
||||
if data.process_preset is not None:
|
||||
row.process_preset_source = data.process_preset.source
|
||||
row.process_preset_id = data.process_preset.id
|
||||
if data.filament_presets is not None:
|
||||
row.filament_presets_json = json.dumps([f.model_dump() for f in data.filament_presets])
|
||||
if data.bed_type is not None:
|
||||
row.bed_type = data.bed_type
|
||||
|
||||
# PR B target binding. The schema accepts ``target_kind=specific_printer``
|
||||
# without ``target_printer_id`` (operator may be saving the kind first),
|
||||
# but a 'specific_printer' kind with a printer_id of 0 is rejected since
|
||||
# printer ids are always positive — guard against the JSON-coerced
|
||||
# empty-string case from the frontend.
|
||||
if data.target_kind is not None:
|
||||
row.target_kind = data.target_kind
|
||||
if data.target_printer_id is not None:
|
||||
# ``target_printer_id=0`` from the frontend means "clear the target"
|
||||
# (the <option value=""> case). Anything positive must reference an
|
||||
# actual printer row.
|
||||
if data.target_printer_id == 0:
|
||||
row.target_printer_id = None
|
||||
else:
|
||||
row.target_printer_id = data.target_printer_id
|
||||
# PR C — class targeting + fanout strategy. Empty string from the frontend
|
||||
# also clears the class (radio toggled away).
|
||||
if data.target_model_class is not None:
|
||||
row.target_model_class = data.target_model_class or None
|
||||
if data.fanout_strategy is not None:
|
||||
row.fanout_strategy = data.fanout_strategy
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return _to_response(row)
|
||||
|
||||
|
||||
@router.delete("/{pipeline_id}", status_code=204)
|
||||
async def delete_pipeline(
|
||||
pipeline_id: int,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Soft-delete a pipeline (sets is_deleted=True so PR B+ run history can
|
||||
still resolve pipeline metadata)."""
|
||||
result = await db.execute(
|
||||
select(SlicerPipeline).where(
|
||||
SlicerPipeline.id == pipeline_id,
|
||||
SlicerPipeline.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(404, "Pipeline not found")
|
||||
row.is_deleted = True
|
||||
await db.commit()
|
||||
|
|
@ -16,7 +16,7 @@ import json
|
|||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ from backend.app.api.routes.orca_cloud import (
|
|||
_build_authenticated_service as _build_orca_service,
|
||||
_load_credentials as _load_orca_credentials,
|
||||
)
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_ownership_permission
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.config import settings as app_settings
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
|
|
@ -47,8 +47,12 @@ from backend.app.services.orca_cloud import (
|
|||
OrcaCloudError,
|
||||
)
|
||||
from backend.app.services.slicer_api import (
|
||||
BundleNotFoundError,
|
||||
BundleSummary,
|
||||
SlicerApiError,
|
||||
SlicerApiService,
|
||||
SlicerApiUnavailableError,
|
||||
SlicerInputError,
|
||||
)
|
||||
from backend.app.utils.printer_models import PRINTER_MODEL_MAP
|
||||
|
||||
|
|
@ -169,10 +173,11 @@ async def _fetch_cloud_presets(
|
|||
# one-by-one trips Bambu's limiter and returns 429 on every request
|
||||
# for users with large preset libraries (#1150 follow-up).
|
||||
#
|
||||
# The metadata-enrich pass (see _enrich_cloud_metadata) compensates:
|
||||
# a Bambu Cloud entry without its own filament_type/colour inherits
|
||||
# those values from a same-named local / orca_cloud / standard entry
|
||||
# so it can still score for type/colour matches in pickFilamentForSlot.
|
||||
# The dedup pass (see _dedupe_by_name) compensates: when a cloud entry
|
||||
# wins over a same-named local entry, the cloud entry inherits the
|
||||
# local entry's filament_type / filament_colour. So cloud presets that
|
||||
# also exist locally still get metadata-aware pre-pick in the
|
||||
# SliceModal; cloud-only presets fall back to plain priority order.
|
||||
_cloud_cache[cache_key] = (now, slots)
|
||||
return slots, "ok"
|
||||
finally:
|
||||
|
|
@ -259,23 +264,15 @@ async def _fetch_orca_cloud_presets(
|
|||
filament_colour = fc[0]
|
||||
elif isinstance(fc, str):
|
||||
filament_colour = fc
|
||||
preset = UnifiedPreset(
|
||||
id=str(preset_id),
|
||||
name=str(name),
|
||||
source="orca_cloud",
|
||||
filament_type=filament_type,
|
||||
filament_colour=filament_colour,
|
||||
slots[slot].append(
|
||||
UnifiedPreset(
|
||||
id=str(preset_id),
|
||||
name=str(name),
|
||||
source="orca_cloud",
|
||||
filament_type=filament_type,
|
||||
filament_colour=filament_colour,
|
||||
)
|
||||
)
|
||||
if slot in ("process", "filament"):
|
||||
# The profile's own compatible-printer list, straight out of
|
||||
# the content Orca already hands us (#2628). Without it the
|
||||
# SliceModal falls back to reading the printer out of the
|
||||
# profile NAME — and a profile whose name carries no model
|
||||
# ("Overture PLA Matte @0.2") then reads as "can't tell",
|
||||
# which the picker treats as usable and auto-picks for a
|
||||
# printer the profile was never built for.
|
||||
preset.compatible_printers = _content_compatible_printers(content)
|
||||
slots[slot].append(preset)
|
||||
_orca_cloud_cache[cache_key] = (now, slots)
|
||||
return slots, "ok"
|
||||
finally:
|
||||
|
|
@ -305,25 +302,6 @@ async def _fetch_local_presets(db: AsyncSession) -> dict[str, list[UnifiedPreset
|
|||
return slots
|
||||
|
||||
|
||||
def _content_compatible_printers(content: dict) -> list[str] | None:
|
||||
"""Pull ``compatible_printers`` out of an inline profile content dict.
|
||||
|
||||
Orca profiles carry it as a list of printer-preset names (the same shape
|
||||
``orca_profiles.py`` stores on import); a single-printer profile may store
|
||||
a bare string. Returns ``None`` for missing / empty / malformed values so
|
||||
the caller leaves the field unset and the SliceModal falls back to the
|
||||
name-based matcher, rather than treating "no data" as "compatible with
|
||||
nothing".
|
||||
"""
|
||||
raw = content.get("compatible_printers")
|
||||
if isinstance(raw, str):
|
||||
raw = [raw]
|
||||
if not isinstance(raw, list):
|
||||
return None
|
||||
names = [s.strip() for s in raw if isinstance(s, str) and s.strip()]
|
||||
return names or None
|
||||
|
||||
|
||||
def _parse_compatible_printers(raw: str | None) -> list[str] | None:
|
||||
"""``LocalPreset.compatible_printers`` stores a JSON array of printer-preset
|
||||
names. Return the parsed list, or ``None`` on missing / malformed data so
|
||||
|
|
@ -442,7 +420,7 @@ async def _resolve_slicer_api_url(db: AsyncSession) -> str | None:
|
|||
return url or None
|
||||
|
||||
|
||||
def _enrich_cloud_metadata(
|
||||
def _dedupe_by_name(
|
||||
orca_cloud: dict[str, list[UnifiedPreset]],
|
||||
cloud: dict[str, list[UnifiedPreset]],
|
||||
local: dict[str, list[UnifiedPreset]],
|
||||
|
|
@ -453,39 +431,26 @@ def _enrich_cloud_metadata(
|
|||
dict[str, list[UnifiedPreset]],
|
||||
dict[str, list[UnifiedPreset]],
|
||||
]:
|
||||
"""Backfill Bambu Cloud filament metadata; do NOT dedup tiers.
|
||||
"""Filter so each preset name appears in exactly one tier.
|
||||
|
||||
Every tier surfaces its full list — a name that exists in both ``local``
|
||||
and ``orca_cloud`` shows up in BOTH dropdown groups so the user can pick
|
||||
either source. Tier ORDER (``local > orca_cloud > cloud > standard``)
|
||||
is communicated by the SliceModal's group rendering and by the
|
||||
name-collision fallback in ``findPresetByName``; this function does not
|
||||
enforce it.
|
||||
Precedence: ``orca_cloud > cloud > local > standard``. Orca Cloud is
|
||||
highest because a user who set up Orca sync is explicitly curating
|
||||
those profiles for use here; Bambu Cloud follows for the same reason
|
||||
one tier down. Order within each tier is preserved.
|
||||
|
||||
Filament metadata merge: a Bambu Cloud entry without its own
|
||||
``filament_type`` / ``filament_colour`` (Bambu Cloud doesn't surface
|
||||
Filament metadata merges across tiers: a Bambu Cloud entry without its
|
||||
own ``filament_type`` / ``filament_colour`` (Bambu Cloud doesn't surface
|
||||
these in the list response for rate-limiting reasons — see
|
||||
:func:`_fetch_cloud_presets`) inherits values from a same-named entry
|
||||
in ``local`` / ``orca_cloud`` / ``standard``. This is the only reason
|
||||
this function exists post-#1712 — without the enrich the Bambu Cloud
|
||||
tier can't score in ``pickFilamentForSlot``.
|
||||
|
||||
Compatibility merge (#2628): the same name bridge carries
|
||||
``compatible_printers`` onto any process / filament entry that lacks it.
|
||||
Bambu Cloud never ships the list, so a profile whose NAME carries no
|
||||
printer model reads as "compatibility unknown" — which the SliceModal
|
||||
treats as usable and auto-picks for whatever printer is selected. When
|
||||
the very same profile is also present as a local import or an Orca Cloud
|
||||
profile, that copy states the truth; borrowing it turns the auto-pick
|
||||
into a correctly-rejected mismatch. Only ever fills a gap: an entry that
|
||||
carries its own list keeps it.
|
||||
:func:`_fetch_cloud_presets`) inherits values from the same-named local
|
||||
or standard entry. Orca Cloud already carries metadata inline, so no
|
||||
backfill is needed for it.
|
||||
"""
|
||||
# Build a name → metadata lookup from the tiers that carry it (local,
|
||||
# orca_cloud, standard). Bambu cloud is intentionally skipped — it
|
||||
# doesn't populate filament_type/colour in the list response. Take
|
||||
# whichever non-empty entry shows up first.
|
||||
# Build a name → metadata lookup from the tiers that carry it (orca_cloud,
|
||||
# local, standard). Bambu cloud is intentionally skipped — it doesn't
|
||||
# populate filament_type/colour in the list response. Take whichever
|
||||
# non-empty entry shows up first.
|
||||
metadata_by_name: dict[str, tuple[str | None, str | None]] = {}
|
||||
for tier in (local, orca_cloud, standard):
|
||||
for tier in (orca_cloud, local, standard):
|
||||
for p in tier["filament"]:
|
||||
if p.name in metadata_by_name:
|
||||
continue
|
||||
|
|
@ -501,25 +466,27 @@ def _enrich_cloud_metadata(
|
|||
if p.filament_colour is None and c is not None:
|
||||
p.filament_colour = c
|
||||
|
||||
# Compatibility bridge (#2628). Runs over both slots that carry the
|
||||
# list, and in both directions between the cloud tiers — whichever copy
|
||||
# of a profile knows its printers teaches the ones that don't.
|
||||
for slot in ("process", "filament"):
|
||||
compat_by_name: dict[str, list[str]] = {}
|
||||
for tier in (local, orca_cloud, cloud, standard):
|
||||
for p in tier[slot]:
|
||||
if p.compatible_printers and p.name not in compat_by_name:
|
||||
compat_by_name[p.name] = p.compatible_printers
|
||||
if not compat_by_name:
|
||||
continue
|
||||
for tier in (orca_cloud, cloud):
|
||||
for p in tier[slot]:
|
||||
if not p.compatible_printers:
|
||||
borrowed = compat_by_name.get(p.name)
|
||||
if borrowed:
|
||||
p.compatible_printers = list(borrowed)
|
||||
|
||||
return orca_cloud, cloud, local, standard
|
||||
deduped_cloud = _empty_slots()
|
||||
deduped_local = _empty_slots()
|
||||
deduped_standard = _empty_slots()
|
||||
for slot in ("printer", "process", "filament"):
|
||||
seen = {p.name for p in orca_cloud[slot]}
|
||||
for p in cloud[slot]:
|
||||
if p.name in seen:
|
||||
continue
|
||||
deduped_cloud[slot].append(p)
|
||||
seen.add(p.name)
|
||||
for p in local[slot]:
|
||||
if p.name in seen:
|
||||
continue
|
||||
deduped_local[slot].append(p)
|
||||
seen.add(p.name)
|
||||
for p in standard[slot]:
|
||||
if p.name in seen:
|
||||
continue
|
||||
deduped_standard[slot].append(p)
|
||||
seen.add(p.name)
|
||||
return orca_cloud, deduped_cloud, deduped_local, deduped_standard
|
||||
|
||||
|
||||
@router.get("/printer-models")
|
||||
|
|
@ -574,7 +541,7 @@ async def list_unified_presets(
|
|||
local = await _fetch_local_presets(db)
|
||||
standard = await _fetch_bundled_presets(db, refresh=refresh)
|
||||
|
||||
orca_cloud, cloud, local, standard = _enrich_cloud_metadata(orca_cloud, cloud, local, standard)
|
||||
orca_cloud, cloud, local, standard = _dedupe_by_name(orca_cloud, cloud, local, standard)
|
||||
|
||||
return UnifiedPresetsResponse(
|
||||
orca_cloud=UnifiedPresetsBySlot(**orca_cloud),
|
||||
|
|
@ -586,16 +553,164 @@ async def list_unified_presets(
|
|||
)
|
||||
|
||||
|
||||
def _bundle_summary_to_dict(b: BundleSummary) -> dict:
|
||||
"""Serialize a BundleSummary for the JSON response. The frontend uses
|
||||
these arrays to populate the preset dropdowns when a user picks the
|
||||
bundle as the slice source.
|
||||
"""
|
||||
return {
|
||||
"id": b.id,
|
||||
"printer_preset_name": b.printer_preset_name,
|
||||
"printer": b.printer,
|
||||
"process": b.process,
|
||||
"filament": b.filament,
|
||||
"version": b.version,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/bundles", status_code=201)
|
||||
async def import_slicer_bundle(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
|
||||
):
|
||||
"""Forward a BambuStudio Printer Preset Bundle (.bbscfg) to the sidecar.
|
||||
|
||||
The user exports their printer's preset bundle from BambuStudio (File
|
||||
-> Export -> Export Preset Bundle, "Printer preset bundle" option).
|
||||
Uploading it here unpacks the bundle on the sidecar and exposes its
|
||||
inner printer / process / filament presets to subsequent slice
|
||||
requests via the bundle-id selector.
|
||||
|
||||
Idempotent: re-uploading the same file yields the same id (sidecar
|
||||
hashes the zip content), so duplicate uploads collapse rather than
|
||||
accumulate.
|
||||
"""
|
||||
api_url = await _resolve_slicer_api_url(db)
|
||||
if not api_url:
|
||||
raise HTTPException(status_code=503, detail="No slicer sidecar configured")
|
||||
|
||||
# Multer on the sidecar caps bundle uploads at 50MB. We don't enforce
|
||||
# that here — let the sidecar's filter own the limit so it stays in
|
||||
# one place — but we do reject empty / huge files at the FastAPI
|
||||
# layer to avoid pointlessly streaming them to the sidecar first.
|
||||
contents = await file.read()
|
||||
if not contents:
|
||||
raise HTTPException(status_code=400, detail="Bundle file is empty")
|
||||
filename = file.filename or "bundle.bbscfg"
|
||||
|
||||
try:
|
||||
async with SlicerApiService(base_url=api_url) as svc:
|
||||
summary = await svc.import_bundle(contents, filename=filename)
|
||||
except SlicerInputError as e:
|
||||
# Sidecar's 4xx — most likely a non-.bbscfg upload, a corrupt zip,
|
||||
# or a path-traversal entry that the manifest validator caught.
|
||||
# Log the detail so it lands in the support bundle: the FE-only
|
||||
# toast was leaving us blind during triage (#1312).
|
||||
logger.warning(
|
||||
"Bundle import rejected by sidecar (%s, %d bytes): %s",
|
||||
filename,
|
||||
len(contents),
|
||||
e,
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
except SlicerApiUnavailableError as e:
|
||||
logger.warning("Bundle import: sidecar unreachable (%s): %s", api_url, e)
|
||||
raise HTTPException(status_code=503, detail=str(e)) from e
|
||||
except SlicerApiError as e:
|
||||
logger.warning(
|
||||
"Bundle import: sidecar server error (%s, %d bytes): %s",
|
||||
filename,
|
||||
len(contents),
|
||||
e,
|
||||
)
|
||||
# 5xx from the sidecar's import path is rare — usually a disk
|
||||
# write failure inside DATA_PATH/bundles. 502 (bad gateway) is
|
||||
# closer to the truth than 500 here, since we're proxying.
|
||||
raise HTTPException(status_code=502, detail=str(e)) from e
|
||||
return _bundle_summary_to_dict(summary)
|
||||
|
||||
|
||||
@router.get("/bundles")
|
||||
async def list_slicer_bundles(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
|
||||
):
|
||||
"""List every Printer Preset Bundle currently stored on the sidecar.
|
||||
|
||||
Drives the SliceModal's "Bundle" tier and a Settings panel where
|
||||
users can review / delete imported bundles. Returns ``[]`` when the
|
||||
sidecar has no bundles imported yet.
|
||||
"""
|
||||
api_url = await _resolve_slicer_api_url(db)
|
||||
if not api_url:
|
||||
# No sidecar configured: empty list rather than 503 so the modal
|
||||
# renders cleanly. Same shape as the bundled-presets fallback.
|
||||
return []
|
||||
try:
|
||||
async with SlicerApiService(base_url=api_url) as svc:
|
||||
bundles = await svc.list_bundles()
|
||||
except SlicerApiUnavailableError as e:
|
||||
# Sidecar offline: surface as 503 so the frontend can show a
|
||||
# banner. Differs from the bundled-tier behaviour because that
|
||||
# path also has cloud + local fallbacks; bundles is the only
|
||||
# source for its tier.
|
||||
raise HTTPException(status_code=503, detail=str(e)) from e
|
||||
except SlicerApiError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e)) from e
|
||||
return [_bundle_summary_to_dict(b) for b in bundles]
|
||||
|
||||
|
||||
@router.get("/bundles/{bundle_id}")
|
||||
async def get_slicer_bundle(
|
||||
bundle_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
|
||||
):
|
||||
"""Return one bundle by id. 404 if it doesn't exist on the sidecar."""
|
||||
api_url = await _resolve_slicer_api_url(db)
|
||||
if not api_url:
|
||||
raise HTTPException(status_code=503, detail="No slicer sidecar configured")
|
||||
try:
|
||||
async with SlicerApiService(base_url=api_url) as svc:
|
||||
summary = await svc.get_bundle(bundle_id)
|
||||
except BundleNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
except SlicerApiUnavailableError as e:
|
||||
raise HTTPException(status_code=503, detail=str(e)) from e
|
||||
except SlicerApiError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e)) from e
|
||||
return _bundle_summary_to_dict(summary)
|
||||
|
||||
|
||||
@router.delete("/bundles/{bundle_id}", status_code=204)
|
||||
async def delete_slicer_bundle(
|
||||
bundle_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
|
||||
):
|
||||
"""Remove a stored bundle from the sidecar. Future slice requests
|
||||
referencing this id will fail with 404 from the sidecar.
|
||||
"""
|
||||
api_url = await _resolve_slicer_api_url(db)
|
||||
if not api_url:
|
||||
raise HTTPException(status_code=503, detail="No slicer sidecar configured")
|
||||
try:
|
||||
async with SlicerApiService(base_url=api_url) as svc:
|
||||
await svc.delete_bundle(bundle_id)
|
||||
except BundleNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
except SlicerApiUnavailableError as e:
|
||||
raise HTTPException(status_code=503, detail=str(e)) from e
|
||||
except SlicerApiError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.get("/preview-progress/{request_id}")
|
||||
async def get_preview_slice_progress(
|
||||
request_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: tuple[User | None, bool] = Depends(
|
||||
require_ownership_permission(
|
||||
Permission.LIBRARY_READ_ALL,
|
||||
Permission.LIBRARY_READ_OWN,
|
||||
)
|
||||
),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_READ),
|
||||
):
|
||||
"""Proxy to the sidecar's ``GET /slice/progress/:requestId``.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""API routes for smart plug management."""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
|
@ -36,11 +36,9 @@ from backend.app.services.homeassistant import homeassistant_service
|
|||
from backend.app.services.mqtt_relay import mqtt_relay
|
||||
from backend.app.services.mqtt_smart_plug import subscribe_plug_to_mqtt
|
||||
from backend.app.services.notification_service import notification_service
|
||||
from backend.app.services.plug_energy_history import fill_derived_energy
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
from backend.app.services.rest_smart_plug import rest_smart_plug_service
|
||||
from backend.app.services.tasmota import tasmota_service
|
||||
from backend.app.utils.local_time import to_naive_utc, utcnow_naive
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -583,11 +581,10 @@ async def control_smart_plug(
|
|||
plug.last_state = expected_state
|
||||
if expected_state == "ON":
|
||||
plug.auto_off_executed = False # Reset flag when manually turning on
|
||||
elif expected_state == "OFF" and plug.printer_id and plug.controls_printer_power:
|
||||
# Mark printer offline immediately for faster UI update. Skipped for
|
||||
# accessory plugs, which are linked to a printer but don't feed it (#2629).
|
||||
elif expected_state == "OFF" and plug.printer_id:
|
||||
# Mark printer offline immediately for faster UI update
|
||||
printer_manager.mark_printer_offline(plug.printer_id)
|
||||
plug.last_checked = utcnow_naive()
|
||||
plug.last_checked = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
# Trigger associated scripts if this is a main (non-script) plug
|
||||
|
|
@ -674,7 +671,7 @@ async def get_plug_status(
|
|||
# Update last state in database
|
||||
if is_reachable and data.state:
|
||||
plug.last_state = data.state
|
||||
plug.last_checked = utcnow_naive()
|
||||
plug.last_checked = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
energy_data = None
|
||||
|
|
@ -709,7 +706,7 @@ async def get_plug_status(
|
|||
# Update last state in database
|
||||
if status["reachable"]:
|
||||
plug.last_state = status["state"]
|
||||
plug.last_checked = utcnow_naive()
|
||||
plug.last_checked = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
# Fetch energy data if device is reachable
|
||||
|
|
@ -717,11 +714,6 @@ async def get_plug_status(
|
|||
if status["reachable"]:
|
||||
energy = await service.get_energy(plug)
|
||||
if energy:
|
||||
# Most plugs report only a lifetime counter — a Shelly has no notion
|
||||
# of "today" at all, and Home Assistant never reports "yesterday".
|
||||
# Fill those in from the hourly snapshots (#2539). Tasmota, which
|
||||
# knows its own daily figures, is left alone.
|
||||
energy = await fill_derived_energy(db, plug.id, energy)
|
||||
energy_data = SmartPlugEnergy(**energy)
|
||||
|
||||
# Check power alerts
|
||||
|
|
@ -743,10 +735,10 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A
|
|||
# Cooldown: don't alert more than once per 5 minutes
|
||||
cooldown_minutes = 5
|
||||
if plug.power_alert_last_triggered:
|
||||
# Naive UTC on both sides: the column is naive, so a row loaded fresh from
|
||||
# the DB comes back without an offset and subtracting an aware now() would
|
||||
# raise TypeError.
|
||||
time_since_last = utcnow_naive() - to_naive_utc(plug.power_alert_last_triggered)
|
||||
last_triggered = plug.power_alert_last_triggered
|
||||
if last_triggered.tzinfo is None:
|
||||
last_triggered = last_triggered.replace(tzinfo=timezone.utc)
|
||||
time_since_last = datetime.now(timezone.utc) - last_triggered
|
||||
if time_since_last < timedelta(minutes=cooldown_minutes):
|
||||
return
|
||||
|
||||
|
|
@ -767,7 +759,7 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A
|
|||
threshold = plug.power_alert_low
|
||||
|
||||
if alert_triggered:
|
||||
plug.power_alert_last_triggered = utcnow_naive()
|
||||
plug.power_alert_last_triggered = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
# Send notification
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
"""API routes for the in-app sponsor toast."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.sponsor_prompt import (
|
||||
SponsorPromptCheckResponse,
|
||||
SponsorPromptDismissRequest,
|
||||
)
|
||||
from backend.app.services import sponsor_prompt as service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/sponsor-prompt", tags=["sponsor-prompt"])
|
||||
|
||||
|
||||
def _user_id(current_user: User | None) -> int | None:
|
||||
return current_user.id if current_user is not None else None
|
||||
|
||||
|
||||
@router.get("/check", response_model=SponsorPromptCheckResponse)
|
||||
async def check_sponsor_prompt(
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Return the next eligible sponsor-toast trigger, or `{show: false}`."""
|
||||
trigger = await service.evaluate(db, _user_id(current_user))
|
||||
await db.commit()
|
||||
if trigger is None:
|
||||
return SponsorPromptCheckResponse(show=False)
|
||||
return SponsorPromptCheckResponse(
|
||||
show=True,
|
||||
milestone=trigger.milestone,
|
||||
family=trigger.family,
|
||||
threshold=trigger.threshold,
|
||||
payload=trigger.payload,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/dismiss", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def dismiss_sponsor_prompt(
|
||||
data: SponsorPromptDismissRequest,
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Anchor the 14-day cooldown and record the milestone as shown."""
|
||||
await service.dismiss(db, _user_id(current_user), data.milestone)
|
||||
await db.commit()
|
||||
return None
|
||||
|
|
@ -222,11 +222,6 @@ async def sync_printer_ams(
|
|||
skipped: list[SkippedSpool] = []
|
||||
errors = []
|
||||
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
|
||||
_auto_add_raw = await get_setting(db, "auto_add_unknown_rfid")
|
||||
auto_add_unknown_rfid = _auto_add_raw is None or _auto_add_raw.lower() == "true"
|
||||
|
||||
# Handle different AMS data structures
|
||||
# Traditional AMS: list of {"id": N, "tray": [...]} dicts
|
||||
# H2D/newer printers: dict with different structure
|
||||
|
|
@ -331,7 +326,6 @@ async def sync_printer_ams(
|
|||
cached_spools=cached_spools,
|
||||
inventory_remaining=inv_remaining,
|
||||
spoolman_spool_id_hint=hint,
|
||||
auto_add_unknown_rfid=auto_add_unknown_rfid,
|
||||
)
|
||||
if sync_result:
|
||||
synced += 1
|
||||
|
|
@ -344,15 +338,6 @@ async def sync_printer_ams(
|
|||
logger.info(
|
||||
"Synced %s from %s AMS %s tray %s", tray.tray_sub_brands, printer.name, ams_id, tray.tray_id
|
||||
)
|
||||
elif spool_tag and not auto_add_unknown_rfid:
|
||||
skipped.append(
|
||||
SkippedSpool(
|
||||
location=f"AMS {ams_id} T{tray.tray_id}",
|
||||
reason="Auto-add disabled; add to inventory manually",
|
||||
filament_type=tray.tray_type or None,
|
||||
color=tray.tray_color[:6] if tray.tray_color else None,
|
||||
)
|
||||
)
|
||||
elif spool_tag:
|
||||
errors.append(f"Spool not found in Spoolman: AMS {ams_id}:{tray.tray_id}")
|
||||
elif not hint:
|
||||
|
|
@ -437,11 +422,6 @@ async def sync_all_printers(
|
|||
all_skipped: list[SkippedSpool] = []
|
||||
all_errors = []
|
||||
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
|
||||
_auto_add_raw = await get_setting(db, "auto_add_unknown_rfid")
|
||||
auto_add_unknown_rfid = _auto_add_raw is None or _auto_add_raw.lower() == "true"
|
||||
|
||||
# OPTIMIZATION: Fetch all spools once before processing ALL printers/trays
|
||||
# This eliminates redundant API calls across all printers
|
||||
logger.debug("Fetching spools cache for sync-all operation...")
|
||||
|
|
@ -548,7 +528,6 @@ async def sync_all_printers(
|
|||
cached_spools=cached_spools,
|
||||
inventory_remaining=inv_remaining,
|
||||
spoolman_spool_id_hint=hint,
|
||||
auto_add_unknown_rfid=auto_add_unknown_rfid,
|
||||
)
|
||||
if sync_result:
|
||||
total_synced += 1
|
||||
|
|
@ -558,15 +537,6 @@ async def sync_all_printers(
|
|||
if not spool_exists:
|
||||
cached_spools.append(sync_result)
|
||||
logger.debug("Added newly created spool %s to cache", sync_result["id"])
|
||||
elif spool_tag and not auto_add_unknown_rfid:
|
||||
all_skipped.append(
|
||||
SkippedSpool(
|
||||
location=f"{printer.name} AMS {ams_id} T{tray.tray_id}",
|
||||
reason="Auto-add disabled; add to inventory manually",
|
||||
filament_type=tray.tray_type or None,
|
||||
color=tray.tray_color[:6] if tray.tray_color else None,
|
||||
)
|
||||
)
|
||||
elif spool_tag:
|
||||
all_errors.append(f"Spool not found in Spoolman: {printer.name} AMS {ams_id}:{tray.tray_id}")
|
||||
elif not hint:
|
||||
|
|
@ -1138,119 +1108,3 @@ async def unlink_spool(
|
|||
|
||||
logger.info("Unlinked Spoolman spool %s", spool_id)
|
||||
return {"success": True, "message": f"Spool {spool_id} unlinked from AMS"}
|
||||
|
||||
|
||||
class CreateSpoolFromSlotRequest(BaseModel):
|
||||
printer_id: int
|
||||
ams_id: int
|
||||
tray_id: int
|
||||
|
||||
|
||||
@router.post("/spools/from-slot")
|
||||
async def create_spool_from_slot(
|
||||
req: CreateSpoolFromSlotRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
|
||||
):
|
||||
"""Explicit user action: create a Spoolman spool from an AMS slot's current tray data.
|
||||
|
||||
Used by the "+ Add to inventory" affordance when auto_add_unknown_rfid is disabled —
|
||||
the user looked at the slot and chose to register it. Calls sync_ams_tray with the
|
||||
auto-add override on so the spool is created even when the global setting is off.
|
||||
"""
|
||||
sm = await get_spoolman_settings(db)
|
||||
if not sm["enabled"]:
|
||||
raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
|
||||
|
||||
client = await get_spoolman_client()
|
||||
if not client:
|
||||
if sm["url"]:
|
||||
client = await init_spoolman_client(sm["url"])
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
|
||||
|
||||
if not await client.health_check():
|
||||
raise HTTPException(status_code=503, detail="Spoolman is not reachable")
|
||||
|
||||
result = await db.execute(select(Printer).where(Printer.id == req.printer_id))
|
||||
printer = result.scalar_one_or_none()
|
||||
if not printer:
|
||||
raise HTTPException(status_code=404, detail="Printer not found")
|
||||
|
||||
state = printer_manager.get_status(req.printer_id)
|
||||
if not state or not state.raw_data:
|
||||
raise HTTPException(status_code=404, detail="Printer not connected or no state available")
|
||||
|
||||
ams_data = state.raw_data.get("ams")
|
||||
ams_units: list[dict] = []
|
||||
if isinstance(ams_data, list):
|
||||
ams_units = ams_data
|
||||
elif isinstance(ams_data, dict):
|
||||
if "ams" in ams_data and isinstance(ams_data["ams"], list):
|
||||
ams_units = ams_data["ams"]
|
||||
elif "tray" in ams_data:
|
||||
ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
|
||||
|
||||
tray = None
|
||||
for unit in ams_units:
|
||||
if not isinstance(unit, dict):
|
||||
continue
|
||||
if int(unit.get("id", -1)) != req.ams_id:
|
||||
continue
|
||||
for t in unit.get("tray", []):
|
||||
if isinstance(t, dict) and int(t.get("id", -1)) == req.tray_id:
|
||||
tray = client.parse_ams_tray(req.ams_id, t)
|
||||
break
|
||||
if tray:
|
||||
break
|
||||
|
||||
if not tray:
|
||||
raise HTTPException(status_code=400, detail="Slot is empty or has no readable tray data")
|
||||
|
||||
# Same ghost-spool guard as the inventory route: no tag → no stable
|
||||
# identity → confirm would just create a fresh Spoolman row per push.
|
||||
from backend.app.services.spool_tag_matcher import is_valid_tag
|
||||
|
||||
if not is_valid_tag(tray.tag_uid or "", tray.tray_uuid or ""):
|
||||
raise HTTPException(status_code=400, detail="Slot has no RFID tag")
|
||||
|
||||
sync_result = await client.sync_ams_tray(
|
||||
tray,
|
||||
printer.name,
|
||||
disable_weight_sync=True,
|
||||
auto_add_unknown_rfid=True,
|
||||
)
|
||||
if not sync_result:
|
||||
raise HTTPException(status_code=500, detail="Spoolman did not create a spool from the slot")
|
||||
|
||||
# Persist the slot assignment so the new spool shows on the slot tile.
|
||||
# If this fails, surface a 500 — silently returning success while the
|
||||
# binding rolled back leaves the user thinking the spool was added,
|
||||
# then watching the modal re-fire on the next MQTT push.
|
||||
if sync_result.get("id"):
|
||||
try:
|
||||
await db.execute(
|
||||
text(
|
||||
"INSERT INTO spoolman_slot_assignments"
|
||||
" (printer_id, ams_id, tray_id, spoolman_spool_id)"
|
||||
" VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
|
||||
" ON CONFLICT(printer_id, ams_id, tray_id)"
|
||||
" DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
|
||||
),
|
||||
{
|
||||
"printer_id": req.printer_id,
|
||||
"ams_id": req.ams_id,
|
||||
"tray_id": req.tray_id,
|
||||
"spool_id": sync_result["id"],
|
||||
},
|
||||
)
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
logger.exception("Failed to persist Spoolman slot assignment")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Spool created in Spoolman but slot assignment failed: {exc}",
|
||||
) from exc
|
||||
|
||||
return {"success": True, "spool_id": sync_result.get("id")}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ from backend.app.api.routes._spoolman_helpers import (
|
|||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.core.websocket import ws_manager
|
||||
from backend.app.models.ams_label import AmsLabel
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.settings import Settings
|
||||
|
|
@ -43,13 +42,7 @@ from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
|
|||
from backend.app.models.user import User
|
||||
from backend.app.schemas.spool import SpoolKProfileBase
|
||||
from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
|
||||
from backend.app.services.location_service import (
|
||||
enrich_spool_dicts_with_location_id,
|
||||
maybe_sync_spoolman_locations,
|
||||
resolve_spoolman_location_string,
|
||||
)
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
|
||||
from backend.app.services.spoolman import (
|
||||
SpoolmanClient,
|
||||
SpoolmanClientError,
|
||||
|
|
@ -62,7 +55,6 @@ from backend.app.services.spoolman_tracking import get_fallback_spool_tag_for_sl
|
|||
from backend.app.utils.filament_ids import (
|
||||
GENERIC_FILAMENT_IDS,
|
||||
MATERIAL_TEMPS,
|
||||
filament_id_to_setting_id,
|
||||
normalize_slicer_filament,
|
||||
)
|
||||
|
||||
|
|
@ -313,7 +305,6 @@ class SpoolmanInventoryCreate(BaseModel):
|
|||
note: str | None = Field(None, max_length=1000)
|
||||
cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0)
|
||||
storage_location: str | None = Field(None, max_length=255)
|
||||
location_id: int | None = Field(None, gt=0)
|
||||
# BambuStudio slicer preset for this spool. Spoolman has no native field
|
||||
# for this, so we persist it under the bambu_slicer_filament[_name] keys
|
||||
# in the spool's extra dict and read it back in _map_spoolman_spool.
|
||||
|
|
@ -356,7 +347,6 @@ class SpoolmanInventoryUpdate(BaseModel):
|
|||
tag_uid: str | None = Field(None, min_length=8, max_length=30, pattern=r"^[0-9A-Fa-f]+$")
|
||||
tray_uuid: str | None = Field(None, min_length=32, max_length=32, pattern=r"^[0-9A-Fa-f]+$")
|
||||
storage_location: str | None = Field(None, max_length=255)
|
||||
location_id: int | None = Field(None, gt=0)
|
||||
# BambuStudio slicer preset — persisted to Spoolman extra dict (see Create
|
||||
# schema). Pass an empty string to clear; null/omitted leaves unchanged.
|
||||
slicer_filament: str | None = Field(None, max_length=128)
|
||||
|
|
@ -438,13 +428,6 @@ async def list_spools(
|
|||
) -> list[dict]:
|
||||
"""Return all Spoolman spools in the InventorySpool format."""
|
||||
client = await _get_client(db)
|
||||
# Sync after we have the route-resolved client so tests that patch the
|
||||
# route module's get_spoolman_client/init_spoolman_client also catch the
|
||||
# sync's client lookup — otherwise the location_service path imports from
|
||||
# backend.app.services.spoolman directly and bypasses the patch.
|
||||
if await maybe_sync_spoolman_locations(db, client=client):
|
||||
await db.commit()
|
||||
|
||||
async with _translate_spoolman_errors():
|
||||
spools = await client.get_all_spools(allow_archived=include_archived)
|
||||
|
||||
|
|
@ -466,7 +449,6 @@ async def list_spools(
|
|||
for m in mapped:
|
||||
m["k_profiles"] = kp_by_spool.get(m["id"], [])
|
||||
|
||||
await enrich_spool_dicts_with_location_id(db, mapped)
|
||||
return mapped
|
||||
|
||||
|
||||
|
|
@ -488,7 +470,6 @@ async def get_spool(
|
|||
|
||||
kp_result = await db.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id))
|
||||
mapped["k_profiles"] = [_k_profile_to_dict(kp) for kp in kp_result.scalars().all()]
|
||||
await enrich_spool_dicts_with_location_id(db, [mapped])
|
||||
return mapped
|
||||
|
||||
|
||||
|
|
@ -524,18 +505,6 @@ async def create_spool(
|
|||
client = await _get_client(db)
|
||||
filament_id = await _resolve_filament_id(data, client)
|
||||
|
||||
storage_location = data.storage_location
|
||||
if "location_id" in data.model_fields_set or "storage_location" in data.model_fields_set:
|
||||
try:
|
||||
storage_location, _ = await resolve_spoolman_location_string(
|
||||
db,
|
||||
location_id=data.location_id,
|
||||
storage_location=data.storage_location,
|
||||
fields_set=set(data.model_fields_set),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
remaining = max(0.0, data.label_weight - data.weight_used)
|
||||
try:
|
||||
async with _translate_spoolman_errors():
|
||||
|
|
@ -543,7 +512,7 @@ async def create_spool(
|
|||
filament_id=filament_id,
|
||||
remaining_weight=remaining,
|
||||
comment=data.note or None,
|
||||
location=storage_location or None,
|
||||
location=data.storage_location or None,
|
||||
)
|
||||
except HTTPException as exc:
|
||||
if exc.status_code == 404 and data.spoolman_filament_id is not None:
|
||||
|
|
@ -585,7 +554,6 @@ async def create_spool(
|
|||
)
|
||||
|
||||
result = _map_spoolman_spool(spool)
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
if price_warnings:
|
||||
return JSONResponse(status_code=207, content={**result, "warnings": price_warnings})
|
||||
return result
|
||||
|
|
@ -611,18 +579,6 @@ async def bulk_create_spools(
|
|||
) from exc
|
||||
raise
|
||||
|
||||
storage_location = data.storage_location
|
||||
if "location_id" in data.model_fields_set or "storage_location" in data.model_fields_set:
|
||||
try:
|
||||
storage_location, _ = await resolve_spoolman_location_string(
|
||||
db,
|
||||
location_id=data.location_id,
|
||||
storage_location=data.storage_location,
|
||||
fields_set=set(data.model_fields_set),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
remaining = max(0.0, data.label_weight - data.weight_used)
|
||||
created: list[dict] = []
|
||||
failures: list[str] = []
|
||||
|
|
@ -632,7 +588,7 @@ async def bulk_create_spools(
|
|||
filament_id=filament_id,
|
||||
remaining_weight=remaining,
|
||||
comment=data.note or None,
|
||||
location=storage_location or None,
|
||||
location=data.storage_location or None,
|
||||
)
|
||||
except (SpoolmanUnavailableError, SpoolmanClientError, SpoolmanNotFoundError) as exc:
|
||||
logger.warning("Bulk spool creation: one spool failed: %s", exc)
|
||||
|
|
@ -655,8 +611,6 @@ async def bulk_create_spools(
|
|||
if not created:
|
||||
raise HTTPException(status_code=500, detail="Failed to create any spools in Spoolman")
|
||||
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
|
||||
if len(created) < payload.quantity:
|
||||
# Some spool creations failed — return 207 Multi-Status so the caller
|
||||
# can distinguish a full success from a partial one and show a useful message.
|
||||
|
|
@ -723,18 +677,8 @@ async def update_spool(
|
|||
synthetic_used = float(current.get("used_weight") or 0)
|
||||
weight_used = data.weight_used if data.weight_used is not None else synthetic_used
|
||||
note = data.note if data.note is not None else current.get("comment")
|
||||
storage_location_changed = "storage_location" in data.model_fields_set or "location_id" in data.model_fields_set
|
||||
storage_location = data.storage_location if "storage_location" in data.model_fields_set else None
|
||||
if storage_location_changed:
|
||||
try:
|
||||
storage_location, _ = await resolve_spoolman_location_string(
|
||||
db,
|
||||
location_id=data.location_id,
|
||||
storage_location=storage_location,
|
||||
fields_set=set(data.model_fields_set),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
storage_location_changed = "storage_location" in data.model_fields_set
|
||||
storage_location = data.storage_location if storage_location_changed else None
|
||||
|
||||
color_hex = rgba[:6]
|
||||
|
||||
|
|
@ -871,7 +815,6 @@ async def update_spool(
|
|||
async with _translate_spoolman_errors():
|
||||
updated = await client.merge_spool_extra(spool_id, new_extra)
|
||||
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
return _map_spoolman_spool(updated)
|
||||
|
||||
|
||||
|
|
@ -885,7 +828,6 @@ async def delete_spool(
|
|||
client = await _get_client(db)
|
||||
async with _translate_spoolman_errors():
|
||||
await client.delete_spool(spool_id)
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
|
|
@ -900,12 +842,10 @@ async def archive_spool(
|
|||
async with _translate_spoolman_errors():
|
||||
spool = await client.set_spool_archived(spool_id, archived=True)
|
||||
try:
|
||||
mapped = _map_spoolman_spool(spool)
|
||||
return _map_spoolman_spool(spool)
|
||||
except ValueError as exc:
|
||||
logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
|
||||
raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
return mapped
|
||||
|
||||
|
||||
@router.post("/spools/{spool_id}/restore")
|
||||
|
|
@ -919,12 +859,10 @@ async def restore_spool(
|
|||
async with _translate_spoolman_errors():
|
||||
spool = await client.set_spool_archived(spool_id, archived=False)
|
||||
try:
|
||||
mapped = _map_spoolman_spool(spool)
|
||||
return _map_spoolman_spool(spool)
|
||||
except ValueError as exc:
|
||||
logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
|
||||
raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
return mapped
|
||||
|
||||
|
||||
@router.post("/spools/{spool_id}/reset-consumed-counter")
|
||||
|
|
@ -948,129 +886,10 @@ async def reset_spool_consumed_counter(
|
|||
async with _translate_spoolman_errors():
|
||||
spool = await client.reset_spool_usage(spool_id)
|
||||
try:
|
||||
mapped = _map_spoolman_spool(spool)
|
||||
return _map_spoolman_spool(spool)
|
||||
except ValueError as exc:
|
||||
logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
|
||||
raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
return mapped
|
||||
|
||||
|
||||
class SpoolmanBulkUpdateRequest(BaseModel):
|
||||
ids: list[int] = Field(..., min_length=1, max_length=500)
|
||||
update: SpoolmanInventoryUpdate
|
||||
|
||||
|
||||
class SpoolmanBulkIdsRequest(BaseModel):
|
||||
ids: list[int] = Field(..., min_length=1, max_length=500)
|
||||
|
||||
|
||||
@router.post("/spools/bulk-update")
|
||||
async def bulk_update_spools(
|
||||
payload: SpoolmanBulkUpdateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
||||
) -> dict:
|
||||
"""Apply the same partial update to every listed Spoolman spool.
|
||||
|
||||
Loops the per-spool ``update_spool`` route so the filament re-linking +
|
||||
extra-dict + location-resolution rules stay in sync with the single-spool
|
||||
PATCH path. Per-spool errors are collected; one bad ID doesn't abort the
|
||||
batch.
|
||||
"""
|
||||
update_fields = payload.update.model_dump(exclude_unset=True)
|
||||
if not update_fields:
|
||||
raise HTTPException(status_code=400, detail="update must include at least one field")
|
||||
|
||||
updated = 0
|
||||
errors: list[dict] = []
|
||||
for sid in payload.ids:
|
||||
try:
|
||||
await update_spool(spool_id=sid, data=payload.update, db=db, _=None)
|
||||
updated += 1
|
||||
except HTTPException as exc:
|
||||
errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
|
||||
except Exception as exc: # noqa: BLE001 — surface unexpected failures per-row
|
||||
logger.exception("Spoolman bulk-update failed for spool %s", sid)
|
||||
errors.append({"id": sid, "status": 500, "detail": str(exc)})
|
||||
if updated:
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
return {"updated": updated, "errors": errors}
|
||||
|
||||
|
||||
@router.post("/spools/bulk-delete")
|
||||
async def bulk_delete_spools(
|
||||
payload: SpoolmanBulkIdsRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
||||
) -> dict:
|
||||
"""Hard-delete every listed Spoolman spool. Per-spool failures are collected."""
|
||||
client = await _get_client(db)
|
||||
deleted = 0
|
||||
errors: list[dict] = []
|
||||
for sid in payload.ids:
|
||||
try:
|
||||
async with _translate_spoolman_errors():
|
||||
await client.delete_spool(sid)
|
||||
deleted += 1
|
||||
except HTTPException as exc:
|
||||
errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
|
||||
except Exception as exc: # noqa: BLE001 — surface unexpected failures per-row
|
||||
logger.exception("Spoolman bulk-delete failed for spool %s", sid)
|
||||
errors.append({"id": sid, "status": 500, "detail": str(exc)})
|
||||
if deleted:
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
return {"deleted": deleted, "errors": errors}
|
||||
|
||||
|
||||
@router.post("/spools/bulk-archive")
|
||||
async def bulk_archive_spools(
|
||||
payload: SpoolmanBulkIdsRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
||||
) -> dict:
|
||||
"""Archive every listed Spoolman spool. Per-spool failures are collected."""
|
||||
client = await _get_client(db)
|
||||
archived = 0
|
||||
errors: list[dict] = []
|
||||
for sid in payload.ids:
|
||||
try:
|
||||
async with _translate_spoolman_errors():
|
||||
await client.set_spool_archived(sid, archived=True)
|
||||
archived += 1
|
||||
except HTTPException as exc:
|
||||
errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
|
||||
except Exception as exc: # noqa: BLE001 — surface unexpected failures per-row
|
||||
logger.exception("Spoolman bulk-archive failed for spool %s", sid)
|
||||
errors.append({"id": sid, "status": 500, "detail": str(exc)})
|
||||
if archived:
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
return {"archived": archived, "errors": errors}
|
||||
|
||||
|
||||
@router.post("/spools/bulk-restore")
|
||||
async def bulk_restore_spools(
|
||||
payload: SpoolmanBulkIdsRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
||||
) -> dict:
|
||||
"""Restore every listed archived Spoolman spool. Per-spool failures are collected."""
|
||||
client = await _get_client(db)
|
||||
restored = 0
|
||||
errors: list[dict] = []
|
||||
for sid in payload.ids:
|
||||
try:
|
||||
async with _translate_spoolman_errors():
|
||||
await client.set_spool_archived(sid, archived=False)
|
||||
restored += 1
|
||||
except HTTPException as exc:
|
||||
errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
|
||||
except Exception as exc: # noqa: BLE001 — surface unexpected failures per-row
|
||||
logger.exception("Spoolman bulk-restore failed for spool %s", sid)
|
||||
errors.append({"id": sid, "status": 500, "detail": str(exc)})
|
||||
if restored:
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
return {"restored": restored, "errors": errors}
|
||||
|
||||
|
||||
@router.post("/spools/reset-consumed-counter-bulk")
|
||||
|
|
@ -1101,8 +920,6 @@ async def bulk_reset_spool_consumed_counter(
|
|||
reset_count += 1
|
||||
except HTTPException as exc:
|
||||
logger.warning("Spoolman reset-consumed-counter failed for spool %s: %s", spool_id, exc.detail)
|
||||
if reset_count:
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
return {"reset": reset_count}
|
||||
|
||||
|
||||
|
|
@ -1136,7 +953,6 @@ async def sync_spool_weight(
|
|||
upd_filament = updated.get("filament") or {}
|
||||
label_weight = _safe_int(upd_filament.get("weight"), 1000)
|
||||
weight_used = max(0.0, label_weight - remaining)
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
return {"status": "ok", "weight_used": weight_used}
|
||||
|
||||
|
||||
|
|
@ -1179,7 +995,6 @@ async def link_tag_to_spoolman_spool(
|
|||
updated = await client.update_spool_full(spool_id=spool_id, extra=cur_extra)
|
||||
|
||||
logger.info("Linked tag %s to Spoolman spool %s", tag, spool_id)
|
||||
await ws_manager.broadcast({"type": "inventory_changed"})
|
||||
return _map_spoolman_spool(updated)
|
||||
|
||||
|
||||
|
|
@ -1406,7 +1221,7 @@ async def sync_spoolman_ams_weights(
|
|||
async def assign_spoolman_slot(
|
||||
body: SpoolSlotAssignmentRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
|
||||
) -> dict:
|
||||
"""Assign a Spoolman spool to a printer AMS slot (stored in local DB only).
|
||||
|
||||
|
|
@ -1491,43 +1306,13 @@ async def assign_spoolman_slot(
|
|||
if len(tray_color) == 6:
|
||||
tray_color = tray_color + "FF"
|
||||
|
||||
# #1713: resolve the spool's stored slicer_filament reference
|
||||
# (cloud preset, local preset, GF-prefix builtin, or numeric
|
||||
# LocalPreset id) to the printer-side tray_info_idx + setting_id.
|
||||
# Previously the Spoolman path dropped slicer_filament on the
|
||||
# floor and only the generic-material fallback fired; the user-
|
||||
# configured profile never reached the printer. Shared with the
|
||||
# internal-mode route via the same helper so the two flows can't
|
||||
# drift again.
|
||||
tray_info_idx, setting_id, sub_brand_override = await resolve_slicer_filament(
|
||||
db=db,
|
||||
current_user=current_user,
|
||||
slicer_filament=mapped.get("slicer_filament"),
|
||||
slicer_filament_name=mapped.get("slicer_filament_name"),
|
||||
material=tray_type,
|
||||
)
|
||||
if sub_brand_override:
|
||||
tray_sub_brands = sub_brand_override
|
||||
|
||||
material_upper = tray_type.upper().strip()
|
||||
# Fall back to generic-material id when slicer_filament is empty
|
||||
# or the resolver discarded an unresolvable value. Matches the
|
||||
# internal-mode tail in inventory.py:_apply_spool_to_slot_inner.
|
||||
if not tray_info_idx:
|
||||
tray_info_idx = (
|
||||
GENERIC_FILAMENT_IDS.get(material_upper)
|
||||
or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
|
||||
or ""
|
||||
)
|
||||
|
||||
# Ensure setting_id is always derivable from tray_info_idx. The
|
||||
# local-preset path can leave it empty when the LP's setting JSON
|
||||
# has no filament_id and falls through to the generic material id;
|
||||
# without this fallback the slicer gets a half-configured slot
|
||||
# (filament id without setting id) and the slot detail modal
|
||||
# renders empty fields. Same pattern as the internal-mode tail.
|
||||
if tray_info_idx and not setting_id:
|
||||
setting_id = filament_id_to_setting_id(tray_info_idx)
|
||||
tray_info_idx = (
|
||||
GENERIC_FILAMENT_IDS.get(material_upper)
|
||||
or GENERIC_FILAMENT_IDS.get(material_upper.split("-")[0].split(" ")[0])
|
||||
or ""
|
||||
)
|
||||
setting_id = ""
|
||||
|
||||
temp_defaults = MATERIAL_TEMPS.get(material_upper, (200, 240))
|
||||
temp_min = mapped.get("nozzle_temp_min") or temp_defaults[0]
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import logging
|
|||
import os
|
||||
import platform
|
||||
import re
|
||||
import time
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
|
@ -301,115 +300,6 @@ def _get_container_memory_limit() -> int | None:
|
|||
return None
|
||||
|
||||
|
||||
# Above this RSS the heap census is skipped — see _collect_process_info.
|
||||
_GC_CENSUS_RSS_LIMIT = 2 * 1024**3
|
||||
|
||||
|
||||
def _collect_process_info() -> dict:
|
||||
"""Snapshot this process's resource usage, for reports about it growing.
|
||||
|
||||
Bundles used to carry nothing about Bambuddy's own footprint, which made
|
||||
"memory climbs over days until the OOM killer fires" impossible to triage
|
||||
from a bundle alone — the reporter of #2734 had to be asked to run commands
|
||||
by hand, and the numbers that would have identified the mechanism could not
|
||||
be recovered after the fact.
|
||||
|
||||
The four figures below separate the mechanisms that look identical from
|
||||
outside:
|
||||
|
||||
* ``rss_bytes`` vs ``vms_bytes`` — a large virtual size against a modest
|
||||
resident one is address space, not live data: thread stacks or allocator
|
||||
arenas rather than a heap that keeps growing.
|
||||
* ``num_threads`` — every leaked MQTT client reconnect would leave a paho
|
||||
network thread behind, each reserving its stack.
|
||||
* ``children`` — the ffmpeg-per-camera-stream leak class (#776).
|
||||
* ``open_files`` / ``connections`` — descriptors held by streams or sockets
|
||||
that were never closed.
|
||||
|
||||
Everything is best-effort: psutil raises on hardened kernels and inside
|
||||
restricted containers, and a support bundle must still be produced when it
|
||||
does. Child command lines are reduced to the executable name — a full
|
||||
ffmpeg argv carries the camera URL, and with it the camera's password.
|
||||
"""
|
||||
import psutil
|
||||
|
||||
out: dict = {}
|
||||
try:
|
||||
proc = psutil.Process()
|
||||
except Exception:
|
||||
return {"available": False}
|
||||
|
||||
out["available"] = True
|
||||
try:
|
||||
mem = proc.memory_info()
|
||||
out["rss_bytes"] = mem.rss
|
||||
out["rss_formatted"] = _format_bytes(mem.rss)
|
||||
out["vms_bytes"] = mem.vms
|
||||
out["vms_formatted"] = _format_bytes(mem.vms)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out["num_threads"] = proc.num_threads()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out["uptime_seconds"] = int(time.time() - proc.create_time())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out["open_files"] = len(proc.open_files())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out["connections"] = len(proc.net_connections(kind="inet"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Children by executable name only. The count per name is what identifies a
|
||||
# leak; the arguments would leak credentials.
|
||||
try:
|
||||
names: dict[str, int] = {}
|
||||
for child in proc.children(recursive=True):
|
||||
try:
|
||||
names[child.name()] = names.get(child.name(), 0) + 1
|
||||
except Exception:
|
||||
names["<unknown>"] = names.get("<unknown>", 0) + 1
|
||||
out["children_total"] = sum(names.values())
|
||||
out["children_by_name"] = dict(sorted(names.items(), key=lambda kv: -kv[1]))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Live object counts by type, top 15. Identifies a heap that is growing and
|
||||
# what it is growing with — the one thing RSS alone cannot say.
|
||||
#
|
||||
# Skipped above _GC_CENSUS_RSS_LIMIT. gc.get_objects() materialises a list
|
||||
# of every tracked object, so the census costs most on exactly the process
|
||||
# that can least afford it: a bundle generated to diagnose runaway memory
|
||||
# must not be the allocation that tips the host over. The numbers that
|
||||
# actually separate the mechanisms — RSS vs VMS, threads, children — are
|
||||
# collected above and unaffected.
|
||||
rss = out.get("rss_bytes")
|
||||
if rss is not None and rss > _GC_CENSUS_RSS_LIMIT:
|
||||
out["gc_census"] = (
|
||||
f"skipped: process is using {_format_bytes(rss)}, above the "
|
||||
f"{_format_bytes(_GC_CENSUS_RSS_LIMIT)} limit for walking the heap"
|
||||
)
|
||||
return out
|
||||
try:
|
||||
import gc
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
for obj in gc.get_objects():
|
||||
name = type(obj).__name__
|
||||
counts[name] = counts.get(name, 0) + 1
|
||||
out["gc_tracked_objects"] = sum(counts.values())
|
||||
out["gc_top_types"] = dict(sorted(counts.items(), key=lambda kv: -kv[1])[:15])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _format_bytes(size_bytes: int) -> str:
|
||||
"""Format bytes into human-readable string."""
|
||||
if size_bytes < 1024:
|
||||
|
|
@ -757,29 +647,20 @@ async def _collect_slicer_api_info() -> dict:
|
|||
return info
|
||||
|
||||
|
||||
def _parse_obico_enabled_printers(raw: str | None) -> set[int] | None:
|
||||
"""Parse the `obico_enabled_printers` setting the way the detection service does.
|
||||
|
||||
The setting is a JSON array of printer IDs and an empty value means *all*
|
||||
printers — see ``ObicoDetectionService._load_settings``. This used to split
|
||||
on commas and treat empty as *none*, so a bundle from a default Obico setup
|
||||
reported every printer as unmonitored while the service was in fact polling
|
||||
all of them. Returns ``None`` for "all printers"; a comma-separated fallback
|
||||
is kept in case an install ever stored the legacy shape.
|
||||
"""
|
||||
def _parse_obico_enabled_printers(raw: str) -> set[int]:
|
||||
"""Parse the comma-separated `obico_enabled_printers` setting. Same shape as
|
||||
obico_detection.py uses but tolerant of legacy formats."""
|
||||
if not raw or not raw.strip():
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
parsed = None
|
||||
if isinstance(parsed, list):
|
||||
return {int(item) for item in parsed if isinstance(item, (int, str)) and str(item).strip().isdigit()}
|
||||
return set()
|
||||
result: set[int] = set()
|
||||
for token in raw.split(","):
|
||||
token = token.strip()
|
||||
if token.isdigit():
|
||||
if not token:
|
||||
continue
|
||||
try:
|
||||
result.add(int(token))
|
||||
except ValueError:
|
||||
continue
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -788,7 +669,7 @@ async def _collect_support_info() -> dict:
|
|||
in_docker = is_running_in_docker()
|
||||
|
||||
info = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"app": {
|
||||
"version": APP_VERSION,
|
||||
"debug_mode": settings.debug,
|
||||
|
|
@ -809,12 +690,6 @@ async def _collect_support_info() -> dict:
|
|||
"database": {},
|
||||
"printers": [],
|
||||
"settings": {},
|
||||
# Bambuddy's own footprint. Cheap to collect and the only thing that
|
||||
# makes a "memory grows over days" report triageable from the bundle
|
||||
# rather than a round trip of shell commands (#2734). Off the event
|
||||
# loop: the heap census walks every tracked object, and a bundle
|
||||
# request must not stall status ingest while it does.
|
||||
"process": await asyncio.to_thread(_collect_process_info),
|
||||
}
|
||||
|
||||
# Docker-specific info
|
||||
|
|
@ -854,27 +729,18 @@ async def _collect_support_info() -> dict:
|
|||
printers = result.scalars().all()
|
||||
statuses = printer_manager.get_all_statuses()
|
||||
|
||||
# Pre-load the obico settings that decide which printers are monitored.
|
||||
# Settings are loaded later in this function (and would overwrite these
|
||||
# keys in info["settings"]), so do a targeted query here for the
|
||||
# per-printer flag below. ``None`` means every printer is monitored.
|
||||
obico_enabled_set: set[int] | None = None
|
||||
obico_globally_enabled = False
|
||||
# Pre-load the obico per-printer enabled-list. Settings are loaded later
|
||||
# in this function (and would overwrite this key in info["settings"]),
|
||||
# so do a targeted query here for the per-printer flag below.
|
||||
obico_enabled_set: set[int] = set()
|
||||
try:
|
||||
obico_rows = {
|
||||
row.key: row.value
|
||||
for row in (
|
||||
await db.execute(
|
||||
select(Settings).where(Settings.key.in_(["obico_enabled_printers", "obico_enabled"]))
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
}
|
||||
obico_enabled_set = _parse_obico_enabled_printers(obico_rows.get("obico_enabled_printers"))
|
||||
obico_globally_enabled = (obico_rows.get("obico_enabled") or "false").lower() == "true"
|
||||
obico_row = (
|
||||
await db.execute(select(Settings).where(Settings.key == "obico_enabled_printers"))
|
||||
).scalar_one_or_none()
|
||||
if obico_row is not None:
|
||||
obico_enabled_set = _parse_obico_enabled_printers(obico_row.value)
|
||||
except Exception:
|
||||
logger.debug("Failed to load obico settings", exc_info=True)
|
||||
logger.debug("Failed to load obico_enabled_printers", exc_info=True)
|
||||
|
||||
# Check reachability in parallel
|
||||
reachability_tasks = [_check_port(p.ip_address, 8883) for p in printers]
|
||||
|
|
@ -918,8 +784,7 @@ async def _collect_support_info() -> dict:
|
|||
"has_vt_tray": has_vt_tray,
|
||||
"external_camera_configured": bool(printer.external_camera_url),
|
||||
"plate_detection_enabled": printer.plate_detection_enabled,
|
||||
"obico_enabled": obico_globally_enabled
|
||||
and (obico_enabled_set is None or printer.id in obico_enabled_set),
|
||||
"obico_enabled": printer.id in obico_enabled_set,
|
||||
"hms_error_count": len(state.hms_errors) if state else 0,
|
||||
"developer_mode": state.developer_mode if state else None,
|
||||
"nozzle_rack_count": len(state.nozzle_rack) if state else 0,
|
||||
|
|
@ -1254,143 +1119,27 @@ async def _collect_support_info() -> dict:
|
|||
|
||||
|
||||
def _get_log_content(max_bytes: int = 10 * 1024 * 1024, sensitive_strings: dict[str, str] | None = None) -> bytes:
|
||||
"""Get recent log content, limited to max_bytes from the end.
|
||||
|
||||
Spans the rotated files as well as the live one. ``bambuddy.log`` is capped
|
||||
at 5 MB by the RotatingFileHandler, and the bundle used to ship only that
|
||||
file — so on a large fleet with debug logging on, the window we ask a
|
||||
reporter for was far shorter than anyone realised. The 19-printer farm in
|
||||
#2555 emits ~100 lines/s of MQTT frame dumps, which fills 5 MB in under five
|
||||
minutes: the bundle we received to diagnose a *queue* problem barely
|
||||
contained one upload. The three rotated backups were sitting on disk unread.
|
||||
|
||||
Reads oldest -> newest so the result is chronological, then takes the last
|
||||
``max_bytes``, which is where the budget was all along.
|
||||
"""
|
||||
"""Get log file content, limited to max_bytes from the end."""
|
||||
log_file = settings.log_dir / "bambuddy.log"
|
||||
if not log_file.exists():
|
||||
return b"Log file not found"
|
||||
|
||||
# RotatingFileHandler names its backups .log.1 (newest) .. .log.N (oldest).
|
||||
# Walk them in reverse so the concatenation reads forwards in time.
|
||||
candidates: list[Path] = []
|
||||
for index in range(settings.log_backup_count, 0, -1):
|
||||
rotated = log_file.with_name(f"{log_file.name}.{index}")
|
||||
if rotated.exists():
|
||||
candidates.append(rotated)
|
||||
candidates.append(log_file)
|
||||
|
||||
chunks: list[str] = []
|
||||
remaining = max_bytes
|
||||
# Fill from the newest backwards so the byte budget is spent on recent
|
||||
# history, then flip back to chronological order for the reader.
|
||||
for path in reversed(candidates):
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
with open(path, "rb") as f:
|
||||
if size > remaining:
|
||||
f.seek(size - remaining)
|
||||
f.readline() # discard the partial line the seek landed in
|
||||
chunks.append(f.read().decode("utf-8", errors="replace"))
|
||||
remaining -= min(size, remaining)
|
||||
except OSError:
|
||||
logger.debug("Failed to read log file %s for support bundle", path, exc_info=True)
|
||||
|
||||
content = "".join(reversed(chunks))
|
||||
file_size = log_file.stat().st_size
|
||||
if file_size <= max_bytes:
|
||||
content = log_file.read_text(encoding="utf-8", errors="replace")
|
||||
else:
|
||||
# Read last max_bytes
|
||||
with open(log_file, "rb") as f:
|
||||
f.seek(file_size - max_bytes)
|
||||
# Skip partial line at start
|
||||
f.readline()
|
||||
content = f.read().decode("utf-8", errors="replace")
|
||||
|
||||
# Sanitize sensitive data
|
||||
content = sanitize_log_content(content, sensitive_strings)
|
||||
return content.encode("utf-8")
|
||||
|
||||
|
||||
# Top-level push_status keys that carry user-private data (filenames, BambuCloud
|
||||
# IDs). Dropped from the bundled per-printer snapshot. Keep print.cfg /
|
||||
# print.option / ams / vt_tray / vir_slot / mapping — those are the fields that
|
||||
# make the snapshot worth shipping (per-model AMS Backup detection, tray-shape
|
||||
# research, VP regression baselines).
|
||||
_RAW_DATA_DROP_KEYS = frozenset(
|
||||
{
|
||||
"subtask_name",
|
||||
"gcode_file",
|
||||
"gcode_file_prepare_percent",
|
||||
"subtask_id",
|
||||
"task_id",
|
||||
"project_id",
|
||||
"gcode_state", # not sensitive, but mirrors current_print which we strip
|
||||
"design_id",
|
||||
"profile_id",
|
||||
"model_id",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _redact_raw_push_status(raw: dict) -> dict:
|
||||
"""Strip user-private keys from a cached push_status snapshot.
|
||||
|
||||
Drops the keys in :data:`_RAW_DATA_DROP_KEYS` anywhere in the tree, then
|
||||
rewrites every entry under ``net.info[*].ip`` to ``"0.0.0.0"``. Mirrors the
|
||||
LAN-topology leak fixed in the virtual-printer bridge (#1429) — the same
|
||||
field exposes the printer's local IP plus the gateway/peers it sees. Returns
|
||||
a NEW dict; the live ``state.raw_data`` is never mutated.
|
||||
"""
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
|
||||
def _walk(value):
|
||||
if isinstance(value, dict):
|
||||
return {k: _walk(v) for k, v in value.items() if k not in _RAW_DATA_DROP_KEYS}
|
||||
if isinstance(value, list):
|
||||
return [_walk(v) for v in value]
|
||||
return value
|
||||
|
||||
out = _walk(raw)
|
||||
|
||||
# Scrub net.info[*].ip after the structural walk — only meaningful at the
|
||||
# top level; nested "net" blocks don't appear in Bambu push_status payloads.
|
||||
net = out.get("net")
|
||||
if isinstance(net, dict):
|
||||
info_list = net.get("info")
|
||||
if isinstance(info_list, list):
|
||||
net["info"] = [
|
||||
({**entry, "ip": "0.0.0.0"} if isinstance(entry, dict) and "ip" in entry else entry) # nosec B104 - redaction sentinel, not a bind address
|
||||
for entry in info_list
|
||||
]
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _sanitize_push_status_values(node, sensitive_strings: dict[str, str]):
|
||||
"""Sanitize a push_status snapshot's string *values*, never its JSON text.
|
||||
|
||||
This used to run :func:`sanitize_log_content` over the serialised snapshot.
|
||||
That pass includes a generic Bambu-serial regex
|
||||
(``0[0-3][A-Z0-9][A-Z0-9]{9,13}`` in ``log_reader``) which matches the
|
||||
decimal expansion of a float just as happily as a serial: an AMS ``k`` flow
|
||||
factor of ``0.0199999995529652`` came out as ``0.[SERIAL]``, and the bundle
|
||||
shipped invalid JSON — unusable for exactly the ground-truth purpose the
|
||||
snapshot exists for (found while diagnosing #2702).
|
||||
|
||||
Walking the structure instead leaves numbers, bools and None untouched, so
|
||||
the output always parses. Keys are structural and never rewritten.
|
||||
"""
|
||||
if isinstance(node, str):
|
||||
return sanitize_log_content(node, sensitive_strings)
|
||||
if isinstance(node, dict):
|
||||
return {k: _sanitize_push_status_values(v, sensitive_strings) for k, v in node.items()}
|
||||
if isinstance(node, list | tuple):
|
||||
# Tuples too: `json.dumps` renders them as arrays, so stringifying one
|
||||
# here would change the file's shape rather than just its content.
|
||||
return [_sanitize_push_status_values(v, sensitive_strings) for v in node]
|
||||
if node is None or isinstance(node, bool | int | float):
|
||||
return node
|
||||
# Anything else (datetime, Decimal, …) would be stringified by json.dumps'
|
||||
# ``default=str`` *after* this pass and so escape sanitisation entirely.
|
||||
return sanitize_log_content(str(node), sensitive_strings)
|
||||
|
||||
|
||||
async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
|
||||
"""Get recent log lines, sanitized for inclusion in bug reports."""
|
||||
# Collect sensitive strings from DB for redaction
|
||||
|
|
@ -1442,43 +1191,8 @@ async def generate_support_bundle(
|
|||
# Add support info JSON
|
||||
zf.writestr("support-info.json", json.dumps(support_info, indent=2, default=str))
|
||||
|
||||
# Per-printer cached push_status dump. Bambu firmware ships per-model
|
||||
# config in a different shape for every family (the bit-26 / print.cfg
|
||||
# gap that blocked AMS Backup awareness in 85fbd7fc), and shape-of-
|
||||
# vt_tray / mapping / vir_slot has bitten the VP bridge repeatedly.
|
||||
# Including the redacted snapshot turns every future support bundle
|
||||
# into a ground-truth sample for that exact model+firmware. Index
|
||||
# matches the 1-based ordering in support-info.json["printers"] so a
|
||||
# maintainer can cross-reference without re-deriving identifiers.
|
||||
statuses = printer_manager.get_all_statuses()
|
||||
async with async_session() as db:
|
||||
db_printers = (await db.execute(select(Printer))).scalars().all()
|
||||
for i, printer in enumerate(db_printers):
|
||||
state = statuses.get(printer.id)
|
||||
if state is None or not state.raw_data:
|
||||
continue
|
||||
redacted = _redact_raw_push_status(state.raw_data)
|
||||
snapshot = {
|
||||
"model": printer.model or "Unknown",
|
||||
"firmware_version": state.firmware_version,
|
||||
"captured_at": datetime.now(timezone.utc).isoformat(),
|
||||
"raw_data": redacted,
|
||||
}
|
||||
# Belt-and-suspenders: pass every string value through the
|
||||
# string-based sanitizer so any user-named string (printer name,
|
||||
# serial baked into a tray uuid) the structural pass missed still
|
||||
# gets caught. Values only — sanitizing the serialised JSON text
|
||||
# corrupted numeric literals (see _sanitize_push_status_values).
|
||||
snapshot = _sanitize_push_status_values(snapshot, sensitive_strings)
|
||||
zf.writestr(f"push-status/printer-{i + 1}.json", json.dumps(snapshot, indent=2, default=str))
|
||||
|
||||
# Add log file
|
||||
# Off the event loop: this reads up to 10 MB and then runs one full regex
|
||||
# pass per sensitive string over it. Now that the bundle spans the rotated
|
||||
# files it can genuinely reach that ceiling, and the blocking cost scales
|
||||
# with the number of printers (4 redaction patterns each) — i.e. it is
|
||||
# worst on exactly the fleet size this change was written for.
|
||||
log_content = await asyncio.to_thread(_get_log_content, sensitive_strings=sensitive_strings)
|
||||
log_content = _get_log_content(sensitive_strings=sensitive_strings)
|
||||
zf.writestr("bambuddy.log", log_content)
|
||||
|
||||
zip_buffer.seek(0)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import os
|
|||
import platform
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import psutil
|
||||
|
|
@ -16,7 +16,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.config import APP_VERSION, settings
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.local_config import read_local_toml, read_ntp_gate
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.filament import Filament
|
||||
|
|
@ -386,7 +385,7 @@ async def _get_storage_usage_cached(refresh: bool, max_age_seconds: int) -> dict
|
|||
snapshot = await asyncio.to_thread(_scan_storage_usage)
|
||||
_storage_usage_cache = {
|
||||
**snapshot,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
}
|
||||
_storage_usage_cache_ts = time.time()
|
||||
return {
|
||||
|
|
@ -505,10 +504,10 @@ async def get_system_info(
|
|||
# (#1690). On bare metal / VMs PID 1 is the host init, which starts at
|
||||
# boot, so the value matches psutil.boot_time() within a sub-second.
|
||||
try:
|
||||
boot_time = datetime.fromtimestamp(psutil.Process(1).create_time(), tz=timezone.utc)
|
||||
boot_time = datetime.fromtimestamp(psutil.Process(1).create_time())
|
||||
except (psutil.Error, OSError):
|
||||
boot_time = datetime.fromtimestamp(psutil.boot_time(), tz=timezone.utc)
|
||||
uptime_seconds = (datetime.now(timezone.utc) - boot_time).total_seconds()
|
||||
boot_time = datetime.fromtimestamp(psutil.boot_time())
|
||||
uptime_seconds = (datetime.now() - boot_time).total_seconds()
|
||||
|
||||
# Python and system info
|
||||
import sys
|
||||
|
|
@ -604,48 +603,3 @@ async def get_system_health(
|
|||
"""
|
||||
sensitive_strings = await collect_sensitive_strings(db)
|
||||
return await asyncio.to_thread(scan_logs, sensitive_strings=sensitive_strings)
|
||||
|
||||
|
||||
@router.get("/db-pool")
|
||||
async def get_db_pool(
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
|
||||
):
|
||||
"""Live database connection-pool gauges for large-farm diagnostics (#2572).
|
||||
|
||||
Reports the resolved pool configuration plus current checked-out /
|
||||
checked-in / overflow counts. Deliberately takes no DB session — reading
|
||||
the pool's own counters must not itself consume a connection, so this stays
|
||||
truthful even when the pool is saturated. On a healthy install ``checked_out``
|
||||
sits well below ``config.pool_size + config.max_overflow``; sustained
|
||||
saturation points at connections held across slow I/O (see #2572).
|
||||
"""
|
||||
from backend.app.core.database import get_pool_status
|
||||
|
||||
return get_pool_status()
|
||||
|
||||
|
||||
@router.get("/appliance")
|
||||
async def get_appliance_defaults():
|
||||
"""Expose appliance-set state for the SPA's bootstrap surface.
|
||||
|
||||
Two file sources, both optional and silently degraded when absent:
|
||||
|
||||
- ``/etc/bambuddy/local.toml`` — hostname / timezone / locale the
|
||||
firstboot wizard collected.
|
||||
- ``/run/bambuddy/time-synced`` — chrony NTP gate state. The RPi 5 has
|
||||
no battery-backed RTC, so on a fresh boot the clock is wrong until
|
||||
ntp-gate.sh writes "ok" (or "warning" if 3-minute timeout elapsed).
|
||||
A warning state means JWT expiries and TLS validity windows may be
|
||||
misaligned; the UI should surface this.
|
||||
|
||||
No auth required — the frontend bootstrap reads this BEFORE auth might
|
||||
be set up, and the contents are user-set defaults plus a public sync
|
||||
flag (no secrets).
|
||||
"""
|
||||
config = read_local_toml()
|
||||
return {
|
||||
"hostname": config.get("hostname"),
|
||||
"timezone": config.get("timezone"),
|
||||
"locale": config.get("locale"),
|
||||
"time_synced": read_ntp_gate(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,50 +120,6 @@ def _is_ha_addon() -> bool:
|
|||
return bool(os.environ.get("SUPERVISOR_TOKEN"))
|
||||
|
||||
|
||||
def _is_windows_installer_install() -> bool:
|
||||
"""Detect a Windows install that came from the Inno Setup installer.
|
||||
|
||||
The installer stages backend source via ``shutil.copytree`` (no ``.git``
|
||||
directory) and does not bundle ``git.exe`` — so the git-fetch-and-reset
|
||||
update path used everywhere else is structurally inoperable here. We
|
||||
surface this as a distinct ``update_method`` and direct the user at the
|
||||
release asset instead.
|
||||
|
||||
A Windows developer running from a real ``git clone`` keeps the git
|
||||
path (``.git`` present), so this only catches installer users.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
return not (settings.app_dir / ".git").exists()
|
||||
|
||||
|
||||
def _find_windows_installer_asset(release_data: dict) -> str | None:
|
||||
"""Pick the Windows installer .exe out of a GitHub release's assets list.
|
||||
|
||||
Both filenames the workflow uploads end in ``windows-x64-setup.exe``
|
||||
(versioned ``bambuddy-<version>-windows-x64-setup.exe`` and the
|
||||
unversioned alias ``bambuddy-windows-x64-setup.exe`` on non-daily tags
|
||||
only). Either works as a download URL; we prefer the versioned form
|
||||
because it's the one guaranteed to exist on every release including
|
||||
dailies.
|
||||
"""
|
||||
assets = release_data.get("assets") or []
|
||||
versioned: str | None = None
|
||||
unversioned: str | None = None
|
||||
for asset in assets:
|
||||
name = asset.get("name") or ""
|
||||
url = asset.get("browser_download_url")
|
||||
if not isinstance(name, str) or not isinstance(url, str):
|
||||
continue
|
||||
if not name.endswith("windows-x64-setup.exe"):
|
||||
continue
|
||||
if name == "bambuddy-windows-x64-setup.exe":
|
||||
unversioned = url
|
||||
else:
|
||||
versioned = url
|
||||
return versioned or unversioned
|
||||
|
||||
|
||||
def _find_executable(name: str) -> str | None:
|
||||
"""Find an executable in PATH or common locations."""
|
||||
# Try standard PATH first
|
||||
|
|
@ -225,16 +181,12 @@ def _parse_github_remote(url: str) -> tuple[str, str] | None:
|
|||
return (parts[0], parts[1])
|
||||
|
||||
|
||||
async def _origin_points_at_repo(git_path: str, git_config: list[str], app_dir, expected_repo: str) -> bool:
|
||||
async def _origin_points_at_repo(git_path: str, git_config: list[str], base_dir, expected_repo: str) -> bool:
|
||||
"""Return True iff the working tree's `origin` already resolves to
|
||||
`<owner>/<repo>` matching `expected_repo` (e.g. "maziggy/bambuddy"),
|
||||
regardless of whether it's the SSH or HTTPS form. Used to skip the
|
||||
`git remote set-url origin https://...` rewrite when the developer's
|
||||
SSH origin is already correct — see `_perform_update` for context.
|
||||
|
||||
``app_dir`` is the working tree (where ``.git`` lives), not the data
|
||||
dir — see #1715 for the separate-mount layout that proved why this
|
||||
must NOT be ``base_dir``."""
|
||||
SSH origin is already correct — see `_perform_update` for context."""
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
git_path,
|
||||
|
|
@ -242,7 +194,7 @@ async def _origin_points_at_repo(git_path: str, git_config: list[str], app_dir,
|
|||
"remote",
|
||||
"get-url",
|
||||
"origin",
|
||||
cwd=str(app_dir),
|
||||
cwd=str(base_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
|
@ -503,15 +455,10 @@ async def check_for_updates(
|
|||
|
||||
is_docker = _is_docker_environment()
|
||||
is_ha_addon = _is_ha_addon()
|
||||
is_windows_installer = _is_windows_installer_install()
|
||||
installer_download_url: str | None = None
|
||||
if is_ha_addon:
|
||||
update_method = "ha_addon"
|
||||
elif is_docker:
|
||||
update_method = "docker"
|
||||
elif is_windows_installer:
|
||||
update_method = "windows_installer"
|
||||
installer_download_url = _find_windows_installer_asset(release_data)
|
||||
else:
|
||||
update_method = "git"
|
||||
return {
|
||||
|
|
@ -524,9 +471,7 @@ async def check_for_updates(
|
|||
"published_at": published_at,
|
||||
"is_docker": is_docker,
|
||||
"is_ha_addon": is_ha_addon,
|
||||
"is_windows_installer": is_windows_installer,
|
||||
"update_method": update_method,
|
||||
"installer_download_url": installer_download_url,
|
||||
}
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
|
|
@ -610,16 +555,7 @@ async def _perform_update(target_ref: str):
|
|||
global _update_status
|
||||
|
||||
try:
|
||||
# Every git step runs against the working tree (app_dir), NOT base_dir.
|
||||
# On a standard install with DATA_DIR=INSTALL_PATH/data, git happens
|
||||
# to walk up from a subdirectory of the repo to find .git so cwd=base_dir
|
||||
# used to silently work — but only by accident. On a native install with
|
||||
# DATA_DIR mounted at an unrelated path (e.g. /srv/bambuddy/data while
|
||||
# the install is /opt/bambuddy — see #1715), git can't walk up and every
|
||||
# operation fails with "not a git repository". safe.directory has the
|
||||
# same requirement: it must equal the repo root git discovers, not the
|
||||
# data dir, or every call returns "fatal: detected dubious ownership."
|
||||
app_dir = settings.app_dir
|
||||
base_dir = settings.base_dir
|
||||
|
||||
# Find git executable (may not be in PATH when running as systemd service)
|
||||
git_path = _find_executable("git")
|
||||
|
|
@ -634,9 +570,8 @@ async def _perform_update(target_ref: str):
|
|||
|
||||
logger.info("Using git at: %s", git_path)
|
||||
|
||||
# Git config to avoid safe.directory issues — must point at the working
|
||||
# tree (where .git lives), see app_dir comment above.
|
||||
git_config = ["-c", f"safe.directory={app_dir}"]
|
||||
# Git config to avoid safe.directory issues
|
||||
git_config = ["-c", f"safe.directory={base_dir}"]
|
||||
|
||||
_update_status = {
|
||||
"status": "downloading",
|
||||
|
|
@ -658,7 +593,7 @@ async def _perform_update(target_ref: str):
|
|||
# correct repo are preserved; only missing / wrong / corrupted
|
||||
# origins get reset to HTTPS.
|
||||
https_url = f"https://github.com/{GITHUB_REPO}.git"
|
||||
if not await _origin_points_at_repo(git_path, git_config, app_dir, GITHUB_REPO):
|
||||
if not await _origin_points_at_repo(git_path, git_config, base_dir, GITHUB_REPO):
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
git_path,
|
||||
*git_config,
|
||||
|
|
@ -666,7 +601,7 @@ async def _perform_update(target_ref: str):
|
|||
"set-url",
|
||||
"origin",
|
||||
https_url,
|
||||
cwd=str(app_dir),
|
||||
cwd=str(base_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
|
@ -700,7 +635,7 @@ async def _perform_update(target_ref: str):
|
|||
"--tags",
|
||||
"--force",
|
||||
"origin",
|
||||
cwd=str(app_dir),
|
||||
cwd=str(base_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
|
@ -736,7 +671,7 @@ async def _perform_update(target_ref: str):
|
|||
"reset",
|
||||
"--hard",
|
||||
target_ref,
|
||||
cwd=str(app_dir),
|
||||
cwd=str(base_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
|
@ -761,9 +696,12 @@ async def _perform_update(target_ref: str):
|
|||
}
|
||||
|
||||
# Install Python dependencies — must run from the source-code directory
|
||||
# (where requirements.txt lives). app_dir is already resolved at the top
|
||||
# of this function; see the comment there for why every step uses it
|
||||
# instead of base_dir.
|
||||
# (where requirements.txt lives), not the data dir. On native installs
|
||||
# systemd sets DATA_DIR=INSTALL_PATH/data, so `base_dir` is the data dir,
|
||||
# not the working tree. `git reset` above worked from base_dir because
|
||||
# git walks up looking for .git, but `pip install -r requirements.txt`
|
||||
# needs the file in cwd literally.
|
||||
app_dir = settings.app_dir
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
|
|
@ -878,19 +816,6 @@ async def apply_update(
|
|||
"git pull && docker compose build --pull && docker compose up -d"
|
||||
),
|
||||
}
|
||||
if _is_windows_installer_install():
|
||||
# The installer layout has no ``.git`` and no bundled ``git.exe`` —
|
||||
# the git-fetch path would fail. Frontend swaps the "Update now"
|
||||
# button for a Download Installer link via update_method, so this
|
||||
# branch is only reached if /apply is hit directly.
|
||||
return {
|
||||
"success": False,
|
||||
"is_windows_installer": True,
|
||||
"message": (
|
||||
"Windows installations are updated by re-running the installer. "
|
||||
"Download the latest installer from the Bambuddy releases page."
|
||||
),
|
||||
}
|
||||
|
||||
# Discover which release tag to install. Resolved here (where we have
|
||||
# a DB session) and passed into the background task; the BG task can't
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ from backend.app.api.routes.settings import get_external_login_url
|
|||
from backend.app.core.auth import (
|
||||
ALGORITHM,
|
||||
SECRET_KEY,
|
||||
RequireAdminIfAuthEnabled,
|
||||
RequirePermissionIfAuthEnabled,
|
||||
get_current_user_optional,
|
||||
get_password_hash,
|
||||
|
|
@ -67,13 +66,7 @@ async def list_users(
|
|||
_: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all users.
|
||||
|
||||
Read-only — gated on ``USERS_READ`` only. Operator-visible UIs
|
||||
(Stats filter-by-user, Archives Print Log username column, File
|
||||
Manager username autocomplete) consume this endpoint via custom-
|
||||
group ``users:read`` grants without admin role. The admin-only
|
||||
boundary lives on the write endpoints below."""
|
||||
"""List all users."""
|
||||
result = await db.execute(select(User).options(selectinload(User.groups)).order_by(User.created_at))
|
||||
users = result.scalars().all()
|
||||
return [_user_to_response(user) for user in users]
|
||||
|
|
@ -83,7 +76,6 @@ async def list_users(
|
|||
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
user_data: UserCreate,
|
||||
_admin: User | None = RequireAdminIfAuthEnabled(),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
|
|
@ -193,7 +185,7 @@ async def get_user(
|
|||
_: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a user by ID. Read-only — gated on ``USERS_READ`` only."""
|
||||
"""Get a user by ID."""
|
||||
result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
|
|
@ -209,7 +201,6 @@ async def get_user(
|
|||
async def update_user(
|
||||
user_id: int,
|
||||
user_data: UserUpdate,
|
||||
_admin: User | None = RequireAdminIfAuthEnabled(),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
|
|
@ -320,8 +311,7 @@ async def get_user_items_count(
|
|||
_: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get count of items created by this user. Read-only — gated on
|
||||
``USERS_READ`` only."""
|
||||
"""Get count of items created by this user."""
|
||||
# Verify user exists
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
if not result.scalar_one_or_none():
|
||||
|
|
@ -360,7 +350,6 @@ async def get_user_items_count(
|
|||
async def delete_user(
|
||||
user_id: int,
|
||||
delete_items: bool = Query(False, description="Delete all items created by this user"),
|
||||
_admin: User | None = RequireAdminIfAuthEnabled(),
|
||||
current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_DELETE),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -39,8 +39,6 @@ class VirtualPrinterCreate(BaseModel):
|
|||
target_printer_id: int | None = None
|
||||
auto_dispatch: bool = True
|
||||
queue_force_color_match: bool = False
|
||||
save_ams_mapping: bool = False
|
||||
gcode_injection: bool = False
|
||||
bind_ip: str | None = None
|
||||
remote_interface_ip: str | None = None
|
||||
|
||||
|
|
@ -54,8 +52,6 @@ class VirtualPrinterUpdate(BaseModel):
|
|||
target_printer_id: int | None = None
|
||||
auto_dispatch: bool | None = None
|
||||
queue_force_color_match: bool | None = None
|
||||
save_ams_mapping: bool | None = None
|
||||
gcode_injection: bool | None = None
|
||||
bind_ip: str | None = None
|
||||
remote_interface_ip: str | None = None
|
||||
tailscale_disabled: bool | None = None
|
||||
|
|
@ -111,8 +107,6 @@ async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
|
|||
"target_printer_id": vp.target_printer_id,
|
||||
"auto_dispatch": vp.auto_dispatch,
|
||||
"queue_force_color_match": vp.queue_force_color_match,
|
||||
"save_ams_mapping": vp.save_ams_mapping,
|
||||
"gcode_injection": vp.gcode_injection,
|
||||
"bind_ip": vp.bind_ip,
|
||||
"remote_interface_ip": vp.remote_interface_ip,
|
||||
"tailscale_disabled": vp.tailscale_disabled,
|
||||
|
|
@ -248,8 +242,6 @@ async def create_virtual_printer(
|
|||
target_printer_id=body.target_printer_id,
|
||||
auto_dispatch=body.auto_dispatch,
|
||||
queue_force_color_match=body.queue_force_color_match,
|
||||
save_ams_mapping=body.save_ams_mapping,
|
||||
gcode_injection=body.gcode_injection,
|
||||
bind_ip=body.bind_ip,
|
||||
remote_interface_ip=body.remote_interface_ip,
|
||||
serial_suffix=new_suffix,
|
||||
|
|
@ -427,10 +419,6 @@ async def update_virtual_printer(
|
|||
vp.auto_dispatch = body.auto_dispatch
|
||||
if body.queue_force_color_match is not None:
|
||||
vp.queue_force_color_match = body.queue_force_color_match
|
||||
if body.save_ams_mapping is not None:
|
||||
vp.save_ams_mapping = body.save_ams_mapping
|
||||
if body.gcode_injection is not None:
|
||||
vp.gcode_injection = body.gcode_injection
|
||||
if body.bind_ip is not None:
|
||||
vp.bind_ip = body.bind_ip
|
||||
if body.remote_interface_ip is not None:
|
||||
|
|
|
|||
|
|
@ -19,12 +19,11 @@ from __future__ import annotations
|
|||
import logging
|
||||
|
||||
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.core.auth import is_auth_enabled, verify_websocket_token
|
||||
from backend.app.core.database import async_session
|
||||
from backend.app.core.websocket import ws_manager
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.background_dispatch import background_dispatch
|
||||
from backend.app.services.printer_manager import printer_manager, printer_state_to_dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -88,20 +87,6 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
|
|||
# ``broadcast_to_principal()`` helper can filter on it without
|
||||
# touching every call site.
|
||||
websocket.state.bambuddy_principal = principal
|
||||
# Resolve principal username → User.id once at connect so
|
||||
# ``ws_manager.broadcast_to_user()`` can filter without re-querying
|
||||
# per message. Auth-disabled path keeps None (broadcast_to_user fans
|
||||
# out to all when target is None — matches the legacy single-user
|
||||
# toast behaviour). API-keyed principal is empty string → None.
|
||||
principal_user_id: int | None = None
|
||||
if principal:
|
||||
try:
|
||||
async with async_session() as db:
|
||||
row = await db.execute(select(User.id).where(User.username == principal))
|
||||
principal_user_id = row.scalar_one_or_none()
|
||||
except Exception: # SEC-AUTH-EXC: resolution failure is non-fatal — degrades to no per-user routing
|
||||
logger.warning("WebSocket principal resolve failed for %s", principal, exc_info=True)
|
||||
websocket.state.bambuddy_principal_user_id = principal_user_id
|
||||
logger.info("WebSocket client connected")
|
||||
|
||||
try:
|
||||
|
|
@ -112,15 +97,18 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
|
|||
{
|
||||
"type": "printer_status",
|
||||
"printer_id": printer_id,
|
||||
"data": printer_state_to_dict(
|
||||
state,
|
||||
printer_id,
|
||||
printer_manager.get_model(printer_id),
|
||||
printer_manager.get_drying_targets(printer_id),
|
||||
),
|
||||
"data": printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
|
||||
}
|
||||
)
|
||||
|
||||
dispatch_state = await background_dispatch.get_state()
|
||||
if (dispatch_state.get("dispatched", 0) + dispatch_state.get("processing", 0)) > 0:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "background_dispatch",
|
||||
"data": dispatch_state,
|
||||
}
|
||||
)
|
||||
logger.info("Sent initial status for %s printers", len(statuses))
|
||||
|
||||
# Keep connection alive and handle incoming messages.
|
||||
|
|
@ -141,12 +129,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
|
|||
{
|
||||
"type": "printer_status",
|
||||
"printer_id": printer_id,
|
||||
"data": printer_state_to_dict(
|
||||
state,
|
||||
printer_id,
|
||||
printer_manager.get_model(printer_id),
|
||||
printer_manager.get_drying_targets(printer_id),
|
||||
),
|
||||
"data": printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -70,14 +70,6 @@ async def kiosk_bootstrap(
|
|||
# commands via the /spoolbuddy/* routes — all gated by
|
||||
# can_manage_inventory now, so the bundled key must opt in.
|
||||
can_manage_inventory=True,
|
||||
# Kiosk doesn't need maintenance writes; keep it False so the
|
||||
# bundled key stays minimally scoped (#1832 follow-up).
|
||||
can_manage_maintenance=False,
|
||||
# Kiosk doesn't manage print archives either — keep it minimally
|
||||
# scoped (#1888).
|
||||
can_manage_archives=False,
|
||||
# Kiosk doesn't manage projects — keep it minimally scoped (#1893).
|
||||
can_manage_projects=False,
|
||||
printer_ids=None,
|
||||
enabled=True,
|
||||
expires_at=None,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ from __future__ import annotations
|
|||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
|
|
@ -50,33 +49,21 @@ logger = logging.getLogger(__name__)
|
|||
# entries also satisfy "not in the allowlist", so they fail closed regardless.
|
||||
#
|
||||
# Mapping rationale (see wiki/features/api-keys.md):
|
||||
# can_read_status → every ``*_READ`` + camera + stats + system + websocket
|
||||
# can_queue → queue write ops + archive reprint
|
||||
# can_control_printer → physical printer + smart-plug control
|
||||
# can_manage_library → library upload/own + MakerWorld import (separate
|
||||
# trust level from queue management, hence its own flag)
|
||||
# can_manage_inventory → spool/catalog/forecast writes + SpoolBuddy kiosk writes
|
||||
# can_manage_maintenance→ per-printer maintenance log/reset + type-catalog CRUD
|
||||
# admin-only → unmapped (default-deny); covers all create/update/
|
||||
# delete of admin resources, settings writes, user/
|
||||
# group/api-key/backup admin ops, discovery scan,
|
||||
# cloud auth, library ALL-ownership perms, purges
|
||||
# can_read_status → every ``*_READ`` + camera + stats + system + websocket
|
||||
# can_queue → queue write ops + archive reprint
|
||||
# can_control_printer → physical printer + smart-plug control
|
||||
# can_manage_library → library upload/own + MakerWorld import (separate
|
||||
# trust level from queue management, hence its own flag)
|
||||
# admin-only → unmapped (default-deny); covers all create/update/
|
||||
# delete of admin resources, settings writes, user/
|
||||
# group/api-key/backup admin ops, discovery scan,
|
||||
# cloud auth, library ALL-ownership perms, purges
|
||||
_APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
|
||||
# can_read_status — read-only access to status, history, and configuration
|
||||
Permission.PRINTERS_READ: "can_read_status",
|
||||
# Legacy flat permissions retained for back-compat with custom API keys —
|
||||
# the role bootstraps no longer use these, but custom keys may still
|
||||
# carry can_read_status scope mapping. New endpoints gate on the
|
||||
# ARCHIVES_READ_OWN / _ALL split (maziggy/bambuddy-security #2).
|
||||
Permission.ARCHIVES_READ: "can_read_status",
|
||||
Permission.ARCHIVES_READ_OWN: "can_read_status",
|
||||
Permission.ARCHIVES_READ_ALL: "can_read_status",
|
||||
Permission.QUEUE_READ: "can_read_status",
|
||||
Permission.QUEUE_READ_OWN: "can_read_status",
|
||||
Permission.QUEUE_READ_ALL: "can_read_status",
|
||||
Permission.LIBRARY_READ: "can_read_status",
|
||||
Permission.LIBRARY_READ_OWN: "can_read_status",
|
||||
Permission.LIBRARY_READ_ALL: "can_read_status",
|
||||
Permission.PROJECTS_READ: "can_read_status",
|
||||
Permission.FILAMENTS_READ: "can_read_status",
|
||||
Permission.INVENTORY_READ: "can_read_status",
|
||||
|
|
@ -91,7 +78,6 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
|
|||
Permission.EXTERNAL_LINKS_READ: "can_read_status",
|
||||
Permission.FIRMWARE_READ: "can_read_status",
|
||||
Permission.AMS_HISTORY_READ: "can_read_status",
|
||||
Permission.PRINTER_SENSOR_HISTORY_READ: "can_read_status",
|
||||
Permission.STATS_READ: "can_read_status",
|
||||
Permission.STATS_FILTER_BY_USER: "can_read_status",
|
||||
Permission.SYSTEM_READ: "can_read_status",
|
||||
|
|
@ -115,21 +101,13 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
|
|||
Permission.PRINTERS_AMS_RFID: "can_control_printer",
|
||||
Permission.PRINTERS_CLEAR_PLATE: "can_control_printer",
|
||||
Permission.SMART_PLUGS_CONTROL: "can_control_printer",
|
||||
# can_manage_library — file-manager scope (upload/rename/delete library
|
||||
# can_manage_library — file-manager scope (upload/rename/delete OWN library
|
||||
# entries + MakerWorld import which downloads files into the library).
|
||||
# OWN and ALL ownership variants map to the same scope so the
|
||||
# `require_ownership_permission` checker (which gates on `all_perm`)
|
||||
# passes the API key through. This matches `can_queue` and the
|
||||
# archives/inventory scopes — API keys have no per-row ownership identity
|
||||
# (line 1663), so splitting OWN/ALL across allowlist/denylist made the
|
||||
# whole library curation surface unreachable for API keys (#1832).
|
||||
# LIBRARY_PURGE stays admin-only as a genuinely destructive op that
|
||||
# bypasses the soft-delete window.
|
||||
# Bulk/ALL-ownership library ops (UPDATE_ALL / DELETE_ALL / PURGE) stay
|
||||
# admin-only because they cross the user boundary.
|
||||
Permission.LIBRARY_UPLOAD: "can_manage_library",
|
||||
Permission.LIBRARY_UPDATE_OWN: "can_manage_library",
|
||||
Permission.LIBRARY_UPDATE_ALL: "can_manage_library",
|
||||
Permission.LIBRARY_DELETE_OWN: "can_manage_library",
|
||||
Permission.LIBRARY_DELETE_ALL: "can_manage_library",
|
||||
Permission.MAKERWORLD_IMPORT: "can_manage_library",
|
||||
# can_manage_inventory — inventory write scope. Covers the documented
|
||||
# spool/catalog/forecast write surface AND the SpoolBuddy kiosk endpoints
|
||||
|
|
@ -141,44 +119,6 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
|
|||
Permission.INVENTORY_UPDATE: "can_manage_inventory",
|
||||
Permission.INVENTORY_DELETE: "can_manage_inventory",
|
||||
Permission.INVENTORY_FORECAST_WRITE: "can_manage_inventory",
|
||||
# can_manage_maintenance — carved out of the admin denylist so HA-style
|
||||
# automations can log "cleaned nozzle" / reset a maintenance counter via
|
||||
# `POST /maintenance/items/{item_id}/perform` without granting broader
|
||||
# printer control or settings write (#1832 follow-up). Also covers the
|
||||
# per-printer maintenance CRUD (assign/remove items, edit intervals) and
|
||||
# the type-catalog CRUD — the type catalog is a config surface (system
|
||||
# types are auto-seeded, custom types are user-defined), so grouping it
|
||||
# with the item writes matches the operator mental model of "keys that
|
||||
# log maintenance can also manage what gets tracked." MAINTENANCE_READ
|
||||
# stays under can_read_status.
|
||||
Permission.MAINTENANCE_CREATE: "can_manage_maintenance",
|
||||
Permission.MAINTENANCE_UPDATE: "can_manage_maintenance",
|
||||
Permission.MAINTENANCE_DELETE: "can_manage_maintenance",
|
||||
# can_manage_archives — print-history curation. Carved out of the admin
|
||||
# denylist so automations can prune old prints via API key (#1888): the
|
||||
# archive delete/update routes gate on
|
||||
# ``require_ownership_permission(ARCHIVES_*_ALL, ARCHIVES_*_OWN)``, which
|
||||
# resolves the ALL permission for API keys (no per-row ownership identity,
|
||||
# same as can_queue / can_manage_library), so OWN and ALL map to the same
|
||||
# scope. ARCHIVES_PURGE stays admin-only (see denylist) as a genuinely
|
||||
# destructive op that drops the stats contribution, mirroring LIBRARY_PURGE.
|
||||
# ARCHIVES_REPRINT_* stays under can_queue (it enqueues a print).
|
||||
Permission.ARCHIVES_CREATE: "can_manage_archives",
|
||||
Permission.ARCHIVES_UPDATE_OWN: "can_manage_archives",
|
||||
Permission.ARCHIVES_UPDATE_ALL: "can_manage_archives",
|
||||
Permission.ARCHIVES_DELETE_OWN: "can_manage_archives",
|
||||
Permission.ARCHIVES_DELETE_ALL: "can_manage_archives",
|
||||
# can_manage_projects — project curation. Carved out of the admin denylist
|
||||
# so automations can create projects and batch-add archives via API key
|
||||
# (#1893). The project mutation routes gate on plain
|
||||
# ``RequirePermissionIfAuthEnabled(Permission.PROJECTS_*)`` (no OWN/ALL
|
||||
# ownership split — projects have no per-row ownership permission), so the
|
||||
# three CRUD permissions map directly to the one scope. Membership edits
|
||||
# (e.g. add-archives-to-project) gate on PROJECTS_UPDATE, so they're covered.
|
||||
# PROJECTS_READ stays under can_read_status (unchanged).
|
||||
Permission.PROJECTS_CREATE: "can_manage_projects",
|
||||
Permission.PROJECTS_UPDATE: "can_manage_projects",
|
||||
Permission.PROJECTS_DELETE: "can_manage_projects",
|
||||
# can_access_cloud — narrow opt-in scope, gated by the router-level
|
||||
# ``_cloud_api_key_gate`` and additionally enforced here so the route-
|
||||
# level ``cloud_caller(Permission.CLOUD_AUTH)`` dep also fails closed
|
||||
|
|
@ -226,29 +166,24 @@ _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
|
|||
Permission.PRINTERS_CREATE,
|
||||
Permission.PRINTERS_UPDATE,
|
||||
Permission.PRINTERS_DELETE,
|
||||
# ARCHIVES_CREATE / _UPDATE_OWN / _UPDATE_ALL / _DELETE_OWN /
|
||||
# _DELETE_ALL moved to the allowlist under `can_manage_archives`
|
||||
# (#1888) — split between allow/deny made the whole archive-management
|
||||
# surface unreachable for API keys via `require_ownership_permission`
|
||||
# (same regression class as the library/maintenance carve-outs in
|
||||
# #1832). ARCHIVES_PURGE stays denied as a genuinely destructive op
|
||||
# that drops the print's stats contribution.
|
||||
Permission.ARCHIVES_CREATE,
|
||||
Permission.ARCHIVES_UPDATE_OWN,
|
||||
Permission.ARCHIVES_UPDATE_ALL,
|
||||
Permission.ARCHIVES_DELETE_OWN,
|
||||
Permission.ARCHIVES_DELETE_ALL,
|
||||
Permission.ARCHIVES_PURGE,
|
||||
# LIBRARY_UPDATE_ALL / LIBRARY_DELETE_ALL moved to the allowlist
|
||||
# under `can_manage_library` (#1832) — split between allow/deny made
|
||||
# the whole library curation surface unreachable for API keys via
|
||||
# `require_ownership_permission`. Purge stays denied as a genuinely
|
||||
# destructive op.
|
||||
Permission.LIBRARY_UPDATE_ALL,
|
||||
Permission.LIBRARY_DELETE_ALL,
|
||||
Permission.LIBRARY_PURGE,
|
||||
# PROJECTS_CREATE / _UPDATE / _DELETE moved to the allowlist under
|
||||
# `can_manage_projects` (#1893) — they were denied for every API key,
|
||||
# making the project-management surface (create, add-archives, delete)
|
||||
# unreachable, same regression class as the archives/library carve-outs.
|
||||
Permission.PROJECTS_CREATE,
|
||||
Permission.PROJECTS_UPDATE,
|
||||
Permission.PROJECTS_DELETE,
|
||||
Permission.FILAMENTS_CREATE,
|
||||
Permission.FILAMENTS_UPDATE,
|
||||
Permission.FILAMENTS_DELETE,
|
||||
# MAINTENANCE_CREATE / MAINTENANCE_UPDATE / MAINTENANCE_DELETE moved
|
||||
# to the allowlist under `can_manage_maintenance` (#1832 follow-up).
|
||||
Permission.MAINTENANCE_CREATE,
|
||||
Permission.MAINTENANCE_UPDATE,
|
||||
Permission.MAINTENANCE_DELETE,
|
||||
Permission.KPROFILES_CREATE,
|
||||
Permission.KPROFILES_UPDATE,
|
||||
Permission.KPROFILES_DELETE,
|
||||
|
|
@ -265,13 +200,6 @@ _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
|
|||
Permission.SMART_PLUGS_DELETE,
|
||||
# Network scanning — operator only (no API-key scope for this).
|
||||
Permission.DISCOVERY_SCAN,
|
||||
# Slicer Pipelines (#1425) — admin authoring + the print-spending Run
|
||||
# action. PR A only ships CRUD; PR B / PR C may move PIPELINES_RUN onto
|
||||
# `can_queue` (it queues prints) once the run dispatch lands. PR A keeps
|
||||
# all three denied so they fail closed for any API-key surface.
|
||||
Permission.PIPELINES_READ,
|
||||
Permission.PIPELINES_WRITE,
|
||||
Permission.PIPELINES_RUN,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -396,7 +324,7 @@ def require_energy_cost_update():
|
|||
if username is None:
|
||||
raise credentials_exception
|
||||
jti: str | None = payload.get("jti")
|
||||
if not jti or await is_jti_revoked(jti, db):
|
||||
if not jti or await is_jti_revoked(jti):
|
||||
raise credentials_exception
|
||||
iat: int | float | None = payload.get("iat")
|
||||
except JWTError:
|
||||
|
|
@ -483,42 +411,10 @@ def _get_jwt_secret() -> str:
|
|||
SECRET_KEY = _get_jwt_secret()
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 hours (M-2: reduced from 7 days)
|
||||
# Hard ceiling for the admin-configurable session policy (#1706). 30 days
|
||||
# matches the Pydantic le=720 on AppSettings.session_max_hours; defense in
|
||||
# depth so a tampered settings row can't request an absurd lifetime.
|
||||
SESSION_MAX_HOURS_HARD_CEILING = 720
|
||||
|
||||
# HTTP Bearer token
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def resolve_session_max_minutes(db: AsyncSession) -> int:
|
||||
"""Return the session-lifetime ceiling (minutes) honoured by login routes.
|
||||
|
||||
Reads ``session_max_hours`` from the settings table (#1706), clamps to
|
||||
[1h, 720h], and falls back to the audit-default 24h if the row is
|
||||
missing, blank, or unparseable.
|
||||
|
||||
DB errors are NOT caught here — login is already in a DB transaction and
|
||||
a broken DB must abort the login rather than silently extend or shrink
|
||||
the session lifetime.
|
||||
"""
|
||||
default_minutes = ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
result = await db.execute(select(Settings).where(Settings.key == "session_max_hours"))
|
||||
row = result.scalar_one_or_none()
|
||||
if row is None or not row.value:
|
||||
return default_minutes
|
||||
try:
|
||||
hours = int(row.value)
|
||||
except (TypeError, ValueError):
|
||||
return default_minutes
|
||||
if hours < 1:
|
||||
return default_minutes
|
||||
if hours > SESSION_MAX_HOURS_HARD_CEILING:
|
||||
hours = SESSION_MAX_HOURS_HARD_CEILING
|
||||
return hours * 60
|
||||
|
||||
|
||||
# --- Slicer download tokens ---
|
||||
# Short-lived, single-use tokens for slicer protocol handlers that can't send
|
||||
# auth headers. Stored in AuthEphemeralToken (token_type=TokenType.SLICER_DOWNLOAD)
|
||||
|
|
@ -701,44 +597,9 @@ async def verify_camera_stream_token(token: str) -> bool:
|
|||
|
||||
# Long-lived path. Imported lazily so the auth module stays importable
|
||||
# at startup before the long_lived_tokens model is registered.
|
||||
from backend.app.services.long_lived_tokens import STREAM_SCOPES, verify_token as verify_long_lived
|
||||
|
||||
record = await verify_long_lived(db, token, scope=STREAM_SCOPES)
|
||||
return record is not None
|
||||
|
||||
|
||||
async def verify_camwall_token(token: str) -> bool:
|
||||
"""Verify a Cam Wall token (#2531). Reusable — does not consume it.
|
||||
|
||||
Deliberately narrower than :func:`verify_camera_stream_token`: only the
|
||||
long-lived ``camwall`` scope passes. The 60-minute ephemeral token belongs
|
||||
to a logged-in browser, which already reaches the wall's metadata through
|
||||
the ordinary printers API and has no need of this endpoint; and a
|
||||
``camera_stream`` token was handed out for video alone, so it must not
|
||||
acquire the ability to enumerate printers by name just because a new
|
||||
feature shipped.
|
||||
"""
|
||||
async with async_session() as db:
|
||||
from backend.app.services.long_lived_tokens import verify_token as verify_long_lived
|
||||
|
||||
record = await verify_long_lived(db, token, scope="camwall")
|
||||
return record is not None
|
||||
|
||||
|
||||
async def verify_overlay_token(token: str) -> bool:
|
||||
"""Verify a streaming-overlay token (#2613). Reusable — does not consume it.
|
||||
|
||||
Like :func:`verify_camwall_token`, only the matching long-lived scope passes:
|
||||
the overlay status feed names the file being printed, so it must not be
|
||||
reachable by a ``camwall`` token (which is trusted to hide the part name) or
|
||||
a bare ``camera_stream`` token (handed out for video alone). The 60-minute
|
||||
ephemeral token belongs to a logged-in browser, which reaches the same data
|
||||
through the ordinary printers API and has no need of this endpoint.
|
||||
"""
|
||||
async with async_session() as db:
|
||||
from backend.app.services.long_lived_tokens import verify_token as verify_long_lived
|
||||
|
||||
record = await verify_long_lived(db, token, scope="overlay")
|
||||
record = await verify_long_lived(db, token, scope="camera_stream")
|
||||
return record is not None
|
||||
|
||||
|
||||
|
|
@ -778,9 +639,7 @@ def _is_token_fresh(iat: int | float | None, user: User) -> bool:
|
|||
Used to invalidate all sessions after a password reset/change (M-R7-B).
|
||||
All tokens without an iat claim are unconditionally rejected — every token
|
||||
issued by this server carries iat, so absence means the token is forged or
|
||||
from a pre-iat code path whose max TTL at the time (24 h) has long since
|
||||
expired. The post-#1706 admin-set ceiling does not relax this — an iat-less
|
||||
token still cannot have been issued by current code.
|
||||
from a pre-iat code path whose max TTL (24 h) has long since expired.
|
||||
"""
|
||||
if iat is None:
|
||||
return False
|
||||
|
|
@ -816,18 +675,10 @@ async def revoke_jti(jti: str, expires_at: datetime, username: str | None = None
|
|||
await db.rollback() # jti already revoked — desired state, ignore
|
||||
|
||||
|
||||
async def is_jti_revoked(jti: str, db: AsyncSession | None = None) -> bool:
|
||||
"""Return True if the given jti has been revoked.
|
||||
|
||||
Pass ``db`` to reuse the caller's session instead of opening a new one
|
||||
(issue #2572): the permission dependencies already hold a session, and a
|
||||
second checkout per request doubled pool pressure — a login burst then
|
||||
exhausted the pool. With ``db`` omitted a short session is opened as before,
|
||||
for callers that check the jti before they have a session open.
|
||||
"""
|
||||
|
||||
async def _query(session: AsyncSession) -> bool:
|
||||
result = await session.execute(
|
||||
async def is_jti_revoked(jti: str) -> bool:
|
||||
"""Return True if the given jti has been revoked."""
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(AuthEphemeralToken).where(
|
||||
AuthEphemeralToken.token == jti,
|
||||
AuthEphemeralToken.token_type == "revoked_jti",
|
||||
|
|
@ -835,11 +686,6 @@ async def is_jti_revoked(jti: str, db: AsyncSession | None = None) -> bool:
|
|||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
if db is not None:
|
||||
return await _query(db)
|
||||
async with async_session() as own_db:
|
||||
return await _query(own_db)
|
||||
|
||||
|
||||
async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
|
||||
"""Get a user by username (case-insensitive) with groups loaded for permission checks."""
|
||||
|
|
@ -893,33 +739,6 @@ async def authenticate_user_by_email(db: AsyncSession, email: str, password: str
|
|||
return user
|
||||
|
||||
|
||||
# Short-lived cache for the auth-enabled flag (issue #2572). The middleware
|
||||
# and every ownership/permission dependency probe this once (or more) per
|
||||
# request; on a large farm that DB round-trip is pure overhead because the
|
||||
# value changes only when an admin toggles auth.
|
||||
#
|
||||
# SECURITY: only a ``True`` (auth-enabled) result is EVER cached. A disabled /
|
||||
# unconfigured result is never cached, so a stale cache can only ever cause a
|
||||
# request to REQUIRE auth that a moment ago wasn't required — it can never skip
|
||||
# an auth check that is now required. Staleness fails CLOSED, never open (cf.
|
||||
# GHSA-6mf4-q26m-47pv). ``set_auth_enabled`` invalidates explicitly on any
|
||||
# toggle; the TTL is only a backstop for out-of-band changes (a direct DB edit,
|
||||
# or another worker process in a multi-worker deployment).
|
||||
_AUTH_ENABLED_CACHE_TTL_SECONDS = 30.0
|
||||
_auth_enabled_cached_value: bool = False
|
||||
_auth_enabled_cached_until: float = 0.0
|
||||
|
||||
|
||||
def invalidate_auth_enabled_cache() -> None:
|
||||
"""Drop the cached auth-enabled flag so the next probe re-reads the DB.
|
||||
|
||||
Call after any write that toggles the ``auth_enabled`` setting.
|
||||
"""
|
||||
global _auth_enabled_cached_value, _auth_enabled_cached_until
|
||||
_auth_enabled_cached_value = False
|
||||
_auth_enabled_cached_until = 0.0
|
||||
|
||||
|
||||
async def is_auth_enabled(db: AsyncSession) -> bool:
|
||||
"""Check if authentication is enabled.
|
||||
|
||||
|
|
@ -936,25 +755,12 @@ async def is_auth_enabled(db: AsyncSession) -> bool:
|
|||
no exception. Any OTHER failure (connection error, fd exhaustion,
|
||||
schema mismatch, …) propagates so the caller can deny the request
|
||||
(503 / 500). Fail-closed is the only safe default for an auth probe.
|
||||
|
||||
Result is cached briefly to cut per-request DB load on large farms; only
|
||||
the enabled=True result is cached, so a stale read can only fail closed.
|
||||
See the module-level cache comment above.
|
||||
"""
|
||||
global _auth_enabled_cached_value, _auth_enabled_cached_until
|
||||
if _auth_enabled_cached_value and time.monotonic() < _auth_enabled_cached_until:
|
||||
return True
|
||||
|
||||
result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
|
||||
setting = result.scalar_one_or_none()
|
||||
enabled = setting is not None and setting.value.lower() == "true"
|
||||
if enabled:
|
||||
_auth_enabled_cached_value = True
|
||||
_auth_enabled_cached_until = time.monotonic() + _AUTH_ENABLED_CACHE_TTL_SECONDS
|
||||
else:
|
||||
# Never cache "disabled" — keep failing closed on any future staleness.
|
||||
_auth_enabled_cached_value = False
|
||||
return enabled
|
||||
if setting is None:
|
||||
return False
|
||||
return setting.value.lower() == "true"
|
||||
|
||||
|
||||
async def _user_from_api_key(db: AsyncSession, api_key: APIKey) -> User | None:
|
||||
|
|
@ -1044,16 +850,13 @@ async def get_current_user_optional(
|
|||
if username is None:
|
||||
raise _unauthorized
|
||||
jti: str | None = payload.get("jti")
|
||||
if not jti or await is_jti_revoked(jti):
|
||||
raise _unauthorized # I6: revoked token → 401, not anonymous
|
||||
iat: int | float | None = payload.get("iat")
|
||||
except JWTError:
|
||||
raise _unauthorized
|
||||
|
||||
if not jti:
|
||||
raise _unauthorized # I6: revoked token → 401, not anonymous
|
||||
|
||||
async with async_session() as db:
|
||||
if await is_jti_revoked(jti, db):
|
||||
raise _unauthorized # I6: revoked token → 401, not anonymous
|
||||
user = await get_user_by_username(db, username)
|
||||
if user is None or not user.is_active:
|
||||
raise _unauthorized
|
||||
|
|
@ -1080,16 +883,13 @@ async def get_current_user(
|
|||
if username is None:
|
||||
raise credentials_exception
|
||||
jti: str | None = payload.get("jti")
|
||||
if not jti or await is_jti_revoked(jti):
|
||||
raise credentials_exception
|
||||
iat: int | float | None = payload.get("iat")
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
if not jti:
|
||||
raise credentials_exception
|
||||
|
||||
async with async_session() as db:
|
||||
if await is_jti_revoked(jti, db):
|
||||
raise credentials_exception
|
||||
user = await get_user_by_username(db, username)
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
|
@ -1159,7 +959,7 @@ async def require_auth_if_enabled(
|
|||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
jti: str | None = payload.get("jti")
|
||||
if not jti or await is_jti_revoked(jti, db):
|
||||
if not jti or await is_jti_revoked(jti):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
|
|
@ -1219,17 +1019,13 @@ def require_admin_if_auth_enabled():
|
|||
key" — the inner ``admin_checker`` then treated ``None`` as auth-
|
||||
disabled and admitted the caller. If any route had ever adopted this
|
||||
dep, any API key with no scope flags set would have satisfied an
|
||||
admin requirement. The dep distinguishes the two cases by consulting
|
||||
``is_auth_enabled`` directly and rejecting API-keyed requests with
|
||||
403. "Admin" requires a user-identity role, which API keys do not
|
||||
carry.
|
||||
admin requirement.
|
||||
|
||||
Admin semantics: uses ``User.is_admin`` (``role == "admin"`` OR
|
||||
Administrators-group membership) so a default-install operator who
|
||||
was made admin by being added to Administrators rather than by
|
||||
flipping the legacy role column passes. Earlier this check looked
|
||||
only at ``role`` and would have locked group-only admins out of the
|
||||
user-management routes once those routes started requiring it.
|
||||
Today no route uses this dep, but rather than leave the footgun
|
||||
armed, the dep is rewritten to distinguish the two cases by
|
||||
consulting ``is_auth_enabled`` directly and rejecting API-keyed
|
||||
requests with 403. "Admin" requires a user-identity role, which API
|
||||
keys do not carry.
|
||||
"""
|
||||
|
||||
async def admin_checker(
|
||||
|
|
@ -1268,7 +1064,7 @@ def require_admin_if_auth_enabled():
|
|||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
jti: str | None = payload.get("jti")
|
||||
if not jti or await is_jti_revoked(jti, db):
|
||||
if not jti or await is_jti_revoked(jti):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
|
|
@ -1295,7 +1091,7 @@ def require_admin_if_auth_enabled():
|
|||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if not user.is_admin:
|
||||
if user.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Requires admin role",
|
||||
|
|
@ -1508,7 +1304,7 @@ def require_permission(*permissions: str | Permission):
|
|||
if username is None:
|
||||
raise credentials_exception
|
||||
jti: str | None = payload.get("jti")
|
||||
if not jti or await is_jti_revoked(jti, db):
|
||||
if not jti or await is_jti_revoked(jti):
|
||||
raise credentials_exception
|
||||
iat: int | float | None = payload.get("iat")
|
||||
except JWTError:
|
||||
|
|
@ -1595,7 +1391,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
|
|||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
jti: str | None = payload.get("jti")
|
||||
if not jti or await is_jti_revoked(jti, db):
|
||||
if not jti or await is_jti_revoked(jti):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
|
|
@ -1695,7 +1491,7 @@ def require_any_permission_if_auth_enabled(*permissions: str | Permission):
|
|||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
jti: str | None = payload.get("jti")
|
||||
if not jti or await is_jti_revoked(jti, db):
|
||||
if not jti or await is_jti_revoked(jti):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
|
|
@ -1768,54 +1564,6 @@ def require_camera_stream_token_if_auth_enabled():
|
|||
RequireCameraStreamTokenIfAuthEnabled = Depends(require_camera_stream_token_if_auth_enabled())
|
||||
|
||||
|
||||
def require_camwall_token_if_auth_enabled():
|
||||
"""Dependency that validates a Cam Wall token query param when auth is enabled.
|
||||
|
||||
Used by the read-only Cam Wall feed (#2531), which a kiosk browser loads
|
||||
with the token in the URL because it has no login session to carry a JWT.
|
||||
"""
|
||||
|
||||
async def checker(token: str | None = None) -> None:
|
||||
async with async_session() as db:
|
||||
if not await is_auth_enabled(db):
|
||||
return # Auth disabled, allow access
|
||||
if not token or not await verify_camwall_token(token):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Valid Cam Wall token required. Create one under Settings > API Keys with the 'Cam Wall' scope.",
|
||||
)
|
||||
|
||||
return checker
|
||||
|
||||
|
||||
RequireCamWallTokenIfAuthEnabled = Depends(require_camwall_token_if_auth_enabled())
|
||||
|
||||
|
||||
def require_overlay_token_if_auth_enabled():
|
||||
"""Dependency that validates a streaming-overlay token query param when auth
|
||||
is enabled.
|
||||
|
||||
Used by the read-only overlay status feed (#2613), which OBS (or any
|
||||
embed with no login session) loads with the token in the URL because it
|
||||
has no JWT to carry.
|
||||
"""
|
||||
|
||||
async def checker(token: str | None = None) -> None:
|
||||
async with async_session() as db:
|
||||
if not await is_auth_enabled(db):
|
||||
return # Auth disabled, allow access
|
||||
if not token or not await verify_overlay_token(token):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Valid overlay token required. Create one under Settings > API Keys with the 'Streaming Overlay' scope.",
|
||||
)
|
||||
|
||||
return checker
|
||||
|
||||
|
||||
RequireOverlayTokenIfAuthEnabled = Depends(require_overlay_token_if_auth_enabled())
|
||||
|
||||
|
||||
def require_ownership_permission(
|
||||
all_permission: str | Permission,
|
||||
own_permission: str | Permission,
|
||||
|
|
@ -1897,7 +1645,7 @@ def require_ownership_permission(
|
|||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
jti: str | None = payload.get("jti")
|
||||
if not jti or await is_jti_revoked(jti, db):
|
||||
if not jti or await is_jti_revoked(jti):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
|
|
|
|||
|
|
@ -3,11 +3,10 @@ import os
|
|||
import re as _re
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
# Application version - single source of truth
|
||||
APP_VERSION = "1.2.5.2"
|
||||
APP_VERSION = "0.2.4.6"
|
||||
GITHUB_REPO = "maziggy/bambuddy"
|
||||
BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
|
||||
|
||||
|
|
@ -74,32 +73,9 @@ class Settings(BaseSettings):
|
|||
log_dir: Path = _log_dir
|
||||
database_url: str = _external_db_url or f"sqlite+aiosqlite:///{_db_path}"
|
||||
|
||||
# Database connection pool sizing. ``None`` = use the built-in, dialect-aware
|
||||
# default (PostgreSQL: pool_size 20 + max_overflow 80; SQLite: 20 + 200).
|
||||
# Large PostgreSQL printer farms can raise these via the DB_POOL_SIZE /
|
||||
# DB_MAX_OVERFLOW / DB_POOL_TIMEOUT / DB_POOL_RECYCLE env vars (issue #2572).
|
||||
# Make sure PostgreSQL ``max_connections`` comfortably exceeds
|
||||
# (pool_size + max_overflow) x number of app worker processes.
|
||||
db_pool_size: int | None = Field(default=None, gt=0)
|
||||
db_max_overflow: int | None = Field(default=None, ge=0)
|
||||
db_pool_timeout: int | None = Field(default=None, gt=0)
|
||||
db_pool_recycle: int | None = Field(default=None, gt=0)
|
||||
# LIFO checkout (PostgreSQL default on): reuse the most-recently-returned
|
||||
# connection so a bursty farm keeps a small hot set busy and lets the excess
|
||||
# overflow connections age out via pool_recycle instead of churning the whole
|
||||
# pool. Override with DB_POOL_USE_LIFO. No effect on SQLite. (#2572)
|
||||
db_pool_use_lifo: bool | None = Field(default=None)
|
||||
|
||||
# Logging
|
||||
log_level: str = "INFO" # Override with LOG_LEVEL env var or DEBUG=true
|
||||
log_to_file: bool = True # Set to false to disable file logging
|
||||
# Rotation for bambuddy.log. Read by main.py (which owns the handler) and by
|
||||
# the support bundle (which harvests the backups as well as the live file);
|
||||
# they must agree on the backup count or the bundle silently skips history.
|
||||
# Bounded: RotatingFileHandler treats maxBytes=0 as "never rotate", so a
|
||||
# zero/negative override would grow the log without limit.
|
||||
log_max_bytes: int = Field(default=5 * 1024 * 1024, gt=0)
|
||||
log_backup_count: int = Field(default=3, ge=0)
|
||||
|
||||
# API
|
||||
api_prefix: str = "/api/v1"
|
||||
|
|
@ -135,25 +111,6 @@ _INTENTIONAL_UNSETTINGS = {
|
|||
"LOG_DIR", # config.py (above)
|
||||
"LOG_LEVEL", # main.py logging setup
|
||||
"BUG_REPORT_RELAY_URL", # config.py (above)
|
||||
# #1589 — api/routes/auth.py reads this on the login path. Unregistered it
|
||||
# logged "possible typo" at every boot, telling an operator who is locked
|
||||
# out and following the documented recovery that the variable is not real.
|
||||
"BAMBUDDY_LOCAL_LOGIN",
|
||||
# #2593 — core/oidc_env.py reads these directly; they are not Settings
|
||||
# fields because they map to an OIDCProvider row, not to app config.
|
||||
"BAMBUDDY_OIDC_NAME",
|
||||
"BAMBUDDY_OIDC_ISSUER_URL",
|
||||
"BAMBUDDY_OIDC_CLIENT_ID",
|
||||
"BAMBUDDY_OIDC_CLIENT_SECRET",
|
||||
"BAMBUDDY_OIDC_SCOPES",
|
||||
"BAMBUDDY_OIDC_ENABLED",
|
||||
"BAMBUDDY_OIDC_AUTO_CREATE_USERS",
|
||||
"BAMBUDDY_OIDC_AUTO_LINK_EXISTING",
|
||||
"BAMBUDDY_OIDC_EMAIL_CLAIM",
|
||||
"BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED",
|
||||
"BAMBUDDY_OIDC_ICON_URL",
|
||||
"BAMBUDDY_OIDC_AUTOLOGIN",
|
||||
"BAMBUDDY_OIDC_DEFAULT_GROUP",
|
||||
}
|
||||
|
||||
_known_settings_fields = {f.upper() for f in settings.model_fields}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,102 +0,0 @@
|
|||
"""
|
||||
Small readers for appliance-set state files.
|
||||
|
||||
Two distinct surfaces, same shape (defensive, silent on missing files,
|
||||
side-effect-free):
|
||||
|
||||
- ``read_local_toml`` reads ``/etc/bambuddy/local.toml`` (the file the
|
||||
appliance setup wizard writes during firstboot with the user's hostname,
|
||||
timezone, and locale).
|
||||
- ``read_ntp_gate`` reads ``/run/bambuddy/time-synced`` (the appliance's
|
||||
ntp-gate.sh signals time-sync state here once chrony reports sync, or
|
||||
when the 3-minute timeout elapses with a "warning" marker).
|
||||
|
||||
Universal across install shapes:
|
||||
|
||||
- On the Bambuddy Appliance: both files exist by the time bambuddy.service
|
||||
starts; we surface their values to the frontend.
|
||||
- On Docker / manual installs: both files are absent; we degrade silently.
|
||||
|
||||
These readers are read-only and side-effect-free. They do NOT call
|
||||
hostnamectl / timedatectl / chronyc — system-state changes are the
|
||||
appliance's firstboot.sh responsibility (root, runs before this process
|
||||
exists). Here we just expose state so the frontend can render accordingly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
import tomllib
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PATH = Path("/etc/bambuddy/local.toml")
|
||||
DEFAULT_NTP_GATE_PATH = Path("/run/bambuddy/time-synced")
|
||||
|
||||
# Three states: synced ("ok"), gated-and-timed-out ("warning"), or unknown (None).
|
||||
TimeSyncState = Literal["ok", "warning"] | None
|
||||
|
||||
|
||||
class LocalConfig(TypedDict, total=False):
|
||||
hostname: str
|
||||
timezone: str
|
||||
locale: str
|
||||
|
||||
|
||||
def read_local_toml(path: Path = DEFAULT_PATH) -> LocalConfig:
|
||||
"""Read the appliance local.toml. Missing / invalid file returns empty dict.
|
||||
|
||||
Only the keys actually present in the file are returned — the caller checks
|
||||
`if "locale" in config:` rather than relying on defaults. Non-string values
|
||||
are dropped with a warning to keep this defensive on a hand-edited file.
|
||||
"""
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
with path.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
except (OSError, tomllib.TOMLDecodeError) as exc:
|
||||
log.warning("local.toml at %s could not be parsed: %s", path, exc)
|
||||
return {}
|
||||
|
||||
result: LocalConfig = {}
|
||||
for key in ("hostname", "timezone", "locale"):
|
||||
value = data.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
if not isinstance(value, str):
|
||||
log.warning("local.toml: %r is %s, expected str — ignoring", key, type(value).__name__)
|
||||
continue
|
||||
result[key] = value # type: ignore[literal-required]
|
||||
return result
|
||||
|
||||
|
||||
def read_ntp_gate(path: Path = DEFAULT_NTP_GATE_PATH) -> TimeSyncState:
|
||||
"""Read the appliance NTP gate file. Returns "ok", "warning", or None.
|
||||
|
||||
Wire contract with bambuddy-appliance/firstboot/ntp-gate.sh:
|
||||
- File absent: gate hasn't been evaluated yet, or this isn't an appliance
|
||||
install. Caller should treat as "unknown / don't gate."
|
||||
- File content starts with "ok": chrony reported sync within 3 minutes.
|
||||
- File content starts with "warning": 3-minute timeout elapsed without
|
||||
sync. The user has already waited and the wizard proceeded with a
|
||||
degraded clock — auth tokens may have incorrect expiry, TLS certs may
|
||||
fail validation. UI should surface this.
|
||||
- Anything else: defensive fall-through to None.
|
||||
"""
|
||||
try:
|
||||
body = path.read_text(errors="replace").strip()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as exc:
|
||||
log.warning("ntp-gate file at %s could not be read: %s", path, exc)
|
||||
return None
|
||||
|
||||
if body.startswith("ok"):
|
||||
return "ok"
|
||||
if body.startswith("warning"):
|
||||
return "warning"
|
||||
return None
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
"""Logging filters and redaction helpers for the Bambuddy log pipeline.
|
||||
"""Logging filters for the Bambuddy log pipeline.
|
||||
|
||||
Holds two filters: ``WriteRequestsOnlyFilter`` keeps the file-side
|
||||
uvicorn access log focused on state-changing HTTP methods, and
|
||||
|
|
@ -6,58 +6,12 @@ uvicorn access log focused on state-changing HTTP methods, and
|
|||
caused by Starlette's ``BaseHTTPMiddleware`` cancellation propagation
|
||||
(see the filter's docstring for details). Both live here so tests can
|
||||
import them without pulling in ``backend.app.main``'s startup graph.
|
||||
|
||||
Also holds :data:`URL_CREDENTIALS_PATTERN` and
|
||||
:func:`redact_url_credentials`, the single place where the shape of a
|
||||
credentialed URL is defined for the whole backend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
# ``scheme://user:secret@host`` — the only URL shape that carries a secret.
|
||||
# Both userinfo parts exclude ``/`` so the match can never run past the
|
||||
# authority into the path, and exclude whitespace so a wrapped log line can't
|
||||
# glue two URLs together. ``secret`` is otherwise unrestricted and greedy so
|
||||
# it reaches the *last* ``@`` before the path, which is where RFC 3986 ends
|
||||
# the userinfo — that keeps an unescaped ``@`` inside a password (legal in an
|
||||
# external camera URL) from leaving its tail in the log. Named groups let
|
||||
# callers choose how much to mask: the log pipeline keeps the username, the
|
||||
# support-bundle sanitizer drops it (see ``log_reader.sanitize_log_content``).
|
||||
#
|
||||
# The scheme's repetition is bounded deliberately. As an unbounded ``*`` the
|
||||
# match was quadratic in the length of the subject (CodeQL py/polynomial-redos):
|
||||
# on a long run of scheme-legal characters the engine restarts at every offset
|
||||
# and consumes to the end each time before failing to find ``://``. Measured at
|
||||
# 557ms for a 32KB line, quadrupling per doubling. ffmpeg echoes the operator's
|
||||
# camera URL back in its stderr, and that whole string reaches this pattern
|
||||
# before any truncation, so the subject length is attacker-influenced. A cap
|
||||
# makes the work per offset constant. 63 is far above any real scheme (the
|
||||
# longest registered one is under 20 characters), and a longer pseudo-scheme
|
||||
# still gets its secret masked — the match simply starts from a later offset.
|
||||
URL_CREDENTIALS_PATTERN = re.compile(
|
||||
r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.\-]{0,63}://)(?P<user>[^/:@\s]+):(?P<secret>[^/\s]+)@"
|
||||
)
|
||||
|
||||
|
||||
def redact_url_credentials(text: str | None) -> str | None:
|
||||
"""Mask the password in every ``scheme://user:secret@host`` URL in *text*.
|
||||
|
||||
Subprocesses echo their input URL back at us — ffmpeg prints the RTSP
|
||||
input in its ``Input #0`` line, so logging its stderr verbatim publishes
|
||||
the printer access code (or an external camera's password) into
|
||||
``bambuddy.log``, which users routinely attach to public issues.
|
||||
|
||||
The username, host, port and path survive so the line stays useful for
|
||||
diagnosis; only the secret is replaced. Returns *text* unchanged when
|
||||
there is nothing to mask, including ``None``/``""``.
|
||||
"""
|
||||
if not text or "://" not in text or "@" not in text:
|
||||
return text
|
||||
return URL_CREDENTIALS_PATTERN.sub(r"\g<scheme>\g<user>:[REDACTED]@", text)
|
||||
|
||||
|
||||
class WriteRequestsOnlyFilter(logging.Filter):
|
||||
|
|
|
|||
|
|
@ -1,278 +0,0 @@
|
|||
"""Read the single OIDC provider defined by BAMBUDDY_OIDC_* env vars (#2593).
|
||||
|
||||
A declarative deployment (compose, Helm, GitOps) has no way to click through
|
||||
the settings UI, so one provider can be configured entirely from the
|
||||
environment. This module only reads and defaults; validity is decided by the
|
||||
same OIDCProviderCreate schema the API uses, so env config cannot bypass a
|
||||
check the UI enforces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# All four or nothing: a provider missing its secret would be written to the
|
||||
# database and then fail at authorize time, long after the operator could
|
||||
# connect the failure to a typo in their compose file.
|
||||
_REQUIRED = (
|
||||
"BAMBUDDY_OIDC_NAME",
|
||||
"BAMBUDDY_OIDC_ISSUER_URL",
|
||||
"BAMBUDDY_OIDC_CLIENT_ID",
|
||||
"BAMBUDDY_OIDC_CLIENT_SECRET",
|
||||
)
|
||||
|
||||
_TRUTHY = {"true", "1", "yes"}
|
||||
_FALSY = {"false", "0", "no"}
|
||||
|
||||
|
||||
class EnvOIDCConfigError(Exception):
|
||||
"""A BAMBUDDY_OIDC_* value the reader cannot interpret. Only ever carries a
|
||||
boolean variable's name and value -- booleans are not secret, so the message
|
||||
is safe to log in full (unlike client_secret, which never reaches here)."""
|
||||
|
||||
|
||||
def env_bool(key: str, default: bool, *, strict: bool = True) -> bool:
|
||||
"""Parse a boolean env var. Absent or blank -> default (empty == unset).
|
||||
|
||||
strict (the default): an unrecognized non-empty value raises
|
||||
EnvOIDCConfigError, so a typo is refused loudly rather than silently read as
|
||||
the wrong thing. strict=False: an unrecognized value falls back to the
|
||||
default instead -- for a caller on a request path where a raise would be a
|
||||
500, not a skipped startup config (see _local_login_env_bypass).
|
||||
"""
|
||||
value = os.environ.get(key)
|
||||
if value is None or value.strip() == "":
|
||||
return default # absent or blank == unset -> default, per the module's promise
|
||||
norm = value.strip().lower()
|
||||
if norm in _TRUTHY:
|
||||
return True
|
||||
if norm in _FALSY:
|
||||
return False
|
||||
if strict:
|
||||
raise EnvOIDCConfigError(f"{key}={value!r} is not a recognized boolean (use true/1/yes or false/0/no)")
|
||||
return default
|
||||
|
||||
|
||||
def read_env_oidc_config() -> dict | None:
|
||||
"""The provider's fields from the environment, or None if it isn't configured.
|
||||
|
||||
An empty required var counts as unset -- `BAMBUDDY_OIDC_CLIENT_SECRET=` in
|
||||
a compose file is a forgotten value, not an intentional empty secret. Blank
|
||||
means blank *after* stripping, and the surviving value is stripped too: a
|
||||
Kubernetes Secret written as a block scalar (``stringData: secret: |``) or
|
||||
created from a file carries a trailing newline that nothing downstream
|
||||
rejects -- max_length is the only bound the schema puts on these four. An
|
||||
issuer_url with a trailing newline is stored and enabled, and then fails
|
||||
with httpx.InvalidURL on the first click of the SSO button, which is the
|
||||
authorize-time failure the all-or-nothing rule above exists to prevent.
|
||||
"""
|
||||
required = {key: (os.environ.get(key) or "").strip() for key in _REQUIRED}
|
||||
if not all(required.values()):
|
||||
return None
|
||||
|
||||
return {
|
||||
"name": required["BAMBUDDY_OIDC_NAME"],
|
||||
"issuer_url": required["BAMBUDDY_OIDC_ISSUER_URL"],
|
||||
"client_id": required["BAMBUDDY_OIDC_CLIENT_ID"],
|
||||
"client_secret": required["BAMBUDDY_OIDC_CLIENT_SECRET"],
|
||||
"scopes": (os.environ.get("BAMBUDDY_OIDC_SCOPES") or "").strip() or "openid email profile",
|
||||
"is_enabled": env_bool("BAMBUDDY_OIDC_ENABLED", True),
|
||||
"auto_create_users": env_bool("BAMBUDDY_OIDC_AUTO_CREATE_USERS", False),
|
||||
"auto_link_existing_accounts": env_bool("BAMBUDDY_OIDC_AUTO_LINK_EXISTING", False),
|
||||
"email_claim": (os.environ.get("BAMBUDDY_OIDC_EMAIL_CLAIM") or "").strip() or "email",
|
||||
"require_email_verified": env_bool("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", True),
|
||||
"icon_url": (os.environ.get("BAMBUDDY_OIDC_ICON_URL") or "").strip() or None,
|
||||
"is_autologin": env_bool("BAMBUDDY_OIDC_AUTOLOGIN", False),
|
||||
# A name, not an id: ids are assigned per install, so the same compose
|
||||
# file would point at a different group on every deployment. Resolved
|
||||
# against the database in apply_env_oidc_provider -- the reader has no
|
||||
# session and stays dumb.
|
||||
"default_group": (os.environ.get("BAMBUDDY_OIDC_DEFAULT_GROUP") or "").strip() or None,
|
||||
}
|
||||
|
||||
|
||||
# Everything the schema validates and the model stores, except client_secret --
|
||||
# that one goes through the property so it is encrypted at rest.
|
||||
_APPLIED_FIELDS = (
|
||||
"name",
|
||||
"issuer_url",
|
||||
"client_id",
|
||||
"scopes",
|
||||
"is_enabled",
|
||||
"auto_create_users",
|
||||
"auto_link_existing_accounts",
|
||||
"email_claim",
|
||||
"require_email_verified",
|
||||
"icon_url",
|
||||
"is_autologin",
|
||||
# Written on every boot, so a group that is no longer declared is cleared:
|
||||
# the environment is the whole truth for this row, and the API lock means
|
||||
# a lingering value could not be removed in the UI either.
|
||||
"default_group_id",
|
||||
)
|
||||
|
||||
|
||||
async def apply_env_oidc_provider(db: AsyncSession) -> None:
|
||||
"""Upsert the env-managed provider, or release it when the config is gone.
|
||||
|
||||
Never raises: this runs during startup, and a typo in one variable -- or a
|
||||
DB error on commit -- must not stop the app from booting. A rejected
|
||||
config is logged and skipped.
|
||||
"""
|
||||
try:
|
||||
await _apply_env_oidc_provider(db)
|
||||
except Exception as exc: # noqa: BLE001 -- startup must survive any failure here
|
||||
# Never str(exc): a DB error message can echo a configured value. Class only.
|
||||
logger.error("BAMBUDDY_OIDC_* could not be applied: %s", type(exc).__name__)
|
||||
# A commit may have half-applied; roll back so the shared session is
|
||||
# left clean for the rest of startup. Suppressed because rollback on a
|
||||
# wedged connection can itself raise -- and the whole point here is that
|
||||
# nothing in this path takes the boot down. The session is discarded by
|
||||
# the caller's `async with` regardless.
|
||||
with contextlib.suppress(Exception):
|
||||
await db.rollback()
|
||||
|
||||
|
||||
async def _apply_env_oidc_provider(db: AsyncSession) -> None:
|
||||
# Imported here rather than at module scope: app.core is imported by the
|
||||
# models themselves, so a top-level import would be a cycle.
|
||||
from backend.app.models.group import Group
|
||||
from backend.app.models.oidc_provider import OIDCProvider
|
||||
from backend.app.schemas.auth import OIDCProviderCreate
|
||||
|
||||
try:
|
||||
config = read_env_oidc_config()
|
||||
except EnvOIDCConfigError as exc:
|
||||
# Same disposition as a ValidationError or an unmatched DEFAULT_GROUP:
|
||||
# log clearly and leave any running provider as it was. Safe to log the
|
||||
# full message -- EnvOIDCConfigError only ever carries a boolean var.
|
||||
logger.error("BAMBUDDY_OIDC_* config rejected, provider not applied: %s", exc)
|
||||
return
|
||||
|
||||
if config is None:
|
||||
# Nothing to look up by name any more, so the previously managed rows are
|
||||
# found by the flag -- and then released. All of them: the upsert's sweep
|
||||
# should keep that at one, but scalar_one_or_none() would raise
|
||||
# MultipleResultsFound out of the lifespan the moment it isn't, and
|
||||
# losing the boot is too steep a price for an invariant check.
|
||||
released_rows = (
|
||||
(await db.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))).scalars().all()
|
||||
)
|
||||
for released in released_rows:
|
||||
# Disabled, never deleted: user_oidc_links.provider_id is FK ON
|
||||
# DELETE CASCADE, so removing the row would unlink every bound
|
||||
# account and the links would not come back when the variables do.
|
||||
# The flag is cleared as well: with no config behind it, a provider
|
||||
# the API still refuses to edit or delete would be a dead end
|
||||
# reachable only through the database.
|
||||
released.is_enabled = False
|
||||
released.is_env_managed = False
|
||||
# Cleared too, or the released row keeps a latent autologin claim:
|
||||
# update_oidc_provider only re-runs the exclusivity sweep when a
|
||||
# request sets is_autologin=True, so re-enabling this row in the UI
|
||||
# would silently make it the autologin target again.
|
||||
released.is_autologin = False
|
||||
logger.info(
|
||||
"BAMBUDDY_OIDC_* is unset -- provider %r disabled and released to the UI.",
|
||||
released.name,
|
||||
)
|
||||
if released_rows:
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
# Identity is the name, which is unique on the table. Matching on the flag
|
||||
# instead meant an operator who named the env provider after one that
|
||||
# already existed hit that unique constraint during startup -- and this
|
||||
# function runs in the lifespan, so the app would not boot.
|
||||
existing = (await db.execute(select(OIDCProvider).where(OIDCProvider.name == config["name"]))).scalar_one_or_none()
|
||||
|
||||
# Resolved before anything is written, so a name that matches no group
|
||||
# leaves the running provider untouched. Refused rather than defaulted:
|
||||
# falling back would put every auto-created user in Viewers (routes/mfa.py)
|
||||
# for as long as the typo lives, and the API answers 422 for a
|
||||
# default_group_id that does not exist -- env config gets the same answer.
|
||||
group_name = config.pop("default_group", None)
|
||||
if group_name is not None:
|
||||
group = (await db.execute(select(Group).where(Group.name == group_name))).scalar_one_or_none()
|
||||
if group is None:
|
||||
# Spelled out because the two cases differ sharply: an existing
|
||||
# provider keeps running on its last good config, while on a first
|
||||
# boot nothing is created at all and the login page has no SSO
|
||||
# button until the name matches.
|
||||
logger.error(
|
||||
"BAMBUDDY_OIDC_DEFAULT_GROUP=%r matches no group, provider not applied (%s).",
|
||||
group_name,
|
||||
"previous config left running" if existing is not None else "no provider created",
|
||||
)
|
||||
return
|
||||
config["default_group_id"] = group.id
|
||||
|
||||
try:
|
||||
# The same schema the API uses, so env config cannot reach a state the
|
||||
# UI would have refused (notably the SEC-1 auto-link check).
|
||||
validated = OIDCProviderCreate(**config)
|
||||
except ValidationError as exc:
|
||||
# errors(include_input=False) strips the submitted values -- str(exc)
|
||||
# embeds input_value=... and would leak BAMBUDDY_OIDC_CLIENT_SECRET.
|
||||
logger.error(
|
||||
"BAMBUDDY_OIDC_* config rejected, provider not applied: %s",
|
||||
exc.errors(include_input=False),
|
||||
)
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001 -- any rejection must be survivable
|
||||
# Log only the exception class, never str(exc): an unexpected error here
|
||||
# could carry a configured value in its message. Structural guarantee,
|
||||
# not one contingent on which exceptions the schema validators raise.
|
||||
logger.error("BAMBUDDY_OIDC_* config could not be applied: %s", type(exc).__name__)
|
||||
return
|
||||
|
||||
# Computed before `existing` is reassigned below: a freshly-created row is
|
||||
# not an adoption, and a found row that was already env-managed is a
|
||||
# routine re-apply -- only a found row that the UI created is an adoption.
|
||||
adopted_ui_provider = existing is not None and not existing.is_env_managed
|
||||
|
||||
if existing is None:
|
||||
existing = OIDCProvider(is_env_managed=True)
|
||||
db.add(existing)
|
||||
for field in _APPLIED_FIELDS:
|
||||
setattr(existing, field, getattr(validated, field))
|
||||
existing.client_secret = validated.client_secret
|
||||
existing.is_env_managed = True
|
||||
await db.flush() # the id is needed by the sweeps below
|
||||
|
||||
# Renaming BAMBUDDY_OIDC_NAME matches nothing, so the row managed until now
|
||||
# stays behind. Left flagged it would keep a stale issuer and secret on the
|
||||
# login page while the API refuses every edit, disable and delete on it
|
||||
# (409) -- the dead end reachable only through the database that the release
|
||||
# path exists to prevent -- and the next release would find two rows and
|
||||
# take the boot down with MultipleResultsFound. Released, not deleted, for
|
||||
# the same cascade reason as everywhere else.
|
||||
await db.execute(
|
||||
update(OIDCProvider)
|
||||
.where(OIDCProvider.id != existing.id, OIDCProvider.is_env_managed.is_(True))
|
||||
.values(is_env_managed=False, is_enabled=False, is_autologin=False)
|
||||
)
|
||||
|
||||
if existing.is_autologin:
|
||||
await db.execute(
|
||||
update(OIDCProvider)
|
||||
.where(OIDCProvider.id != existing.id, OIDCProvider.is_autologin.is_(True))
|
||||
.values(is_autologin=False)
|
||||
)
|
||||
await db.commit()
|
||||
if adopted_ui_provider:
|
||||
logger.warning(
|
||||
"Env-managed OIDC provider %r adopted an existing UI-created provider of the "
|
||||
"same name; its issuer, client and secret are now managed by BAMBUDDY_OIDC_*.",
|
||||
existing.name,
|
||||
)
|
||||
else:
|
||||
logger.info("Env-managed OIDC provider %r applied.", existing.name)
|
||||
|
|
@ -19,32 +19,25 @@ class Permission(StrEnum):
|
|||
PRINTERS_CREATE = "printers:create"
|
||||
PRINTERS_UPDATE = "printers:update"
|
||||
PRINTERS_DELETE = "printers:delete"
|
||||
PRINTERS_CONTROL = "printers:control" # Printer controls: stop/pause/resume, lights, motors, drying, etc.
|
||||
PRINTERS_CONTROL = "printers:control" # Start/stop/pause/resume prints
|
||||
PRINTERS_FILES = "printers:files" # Send files to printer
|
||||
PRINTERS_AMS_RFID = "printers:ams_rfid" # Re-read AMS RFID tags
|
||||
PRINTERS_CLEAR_PLATE = "printers:clear_plate" # Confirm plate cleared for next print
|
||||
|
||||
# Archives
|
||||
# ARCHIVES_READ kept for backward-compat with legacy custom roles, but new
|
||||
# role bootstraps use the ownership-split variants below. seed_default_groups
|
||||
# migrates pre-existing role rows: Administrators → ALL, everyone else → OWN.
|
||||
ARCHIVES_READ = "archives:read"
|
||||
ARCHIVES_READ_OWN = "archives:read_own"
|
||||
ARCHIVES_READ_ALL = "archives:read_all"
|
||||
ARCHIVES_CREATE = "archives:create"
|
||||
ARCHIVES_UPDATE_OWN = "archives:update_own"
|
||||
ARCHIVES_UPDATE_ALL = "archives:update_all"
|
||||
ARCHIVES_DELETE_OWN = "archives:delete_own"
|
||||
ARCHIVES_DELETE_ALL = "archives:delete_all"
|
||||
ARCHIVES_REPRINT_OWN = "archives:reprint_own" # Reprint own archives; queue:create is also required to enqueue
|
||||
ARCHIVES_REPRINT_ALL = "archives:reprint_all" # Reprint any archive; queue:create is also required to enqueue
|
||||
ARCHIVES_REPRINT_OWN = "archives:reprint_own"
|
||||
ARCHIVES_REPRINT_ALL = "archives:reprint_all"
|
||||
ARCHIVES_PURGE = "archives:purge"
|
||||
|
||||
# Queue
|
||||
QUEUE_READ = "queue:read"
|
||||
QUEUE_READ_OWN = "queue:read_own"
|
||||
QUEUE_READ_ALL = "queue:read_all"
|
||||
QUEUE_CREATE = "queue:create" # Create queue items, including ASAP items eligible for immediate dispatch
|
||||
QUEUE_CREATE = "queue:create"
|
||||
QUEUE_UPDATE_OWN = "queue:update_own"
|
||||
QUEUE_UPDATE_ALL = "queue:update_all"
|
||||
QUEUE_DELETE_OWN = "queue:delete_own"
|
||||
|
|
@ -53,9 +46,7 @@ class Permission(StrEnum):
|
|||
|
||||
# Library
|
||||
LIBRARY_READ = "library:read"
|
||||
LIBRARY_READ_OWN = "library:read_own"
|
||||
LIBRARY_READ_ALL = "library:read_all"
|
||||
LIBRARY_UPLOAD = "library:upload" # Upload/import/slice library files; queue:create is also required to print
|
||||
LIBRARY_UPLOAD = "library:upload"
|
||||
LIBRARY_UPDATE_OWN = "library:update_own"
|
||||
LIBRARY_UPDATE_ALL = "library:update_all"
|
||||
LIBRARY_DELETE_OWN = "library:delete_own"
|
||||
|
|
@ -133,7 +124,6 @@ class Permission(StrEnum):
|
|||
|
||||
# AMS History
|
||||
AMS_HISTORY_READ = "ams_history:read"
|
||||
PRINTER_SENSOR_HISTORY_READ = "printer_sensor_history:read"
|
||||
|
||||
# Stats/Metrics
|
||||
STATS_READ = "stats:read"
|
||||
|
|
@ -178,11 +168,6 @@ class Permission(StrEnum):
|
|||
GROUPS_UPDATE = "groups:update"
|
||||
GROUPS_DELETE = "groups:delete"
|
||||
|
||||
# Slicer Pipelines (#1425)
|
||||
PIPELINES_READ = "pipelines:read" # View pipeline definitions and run history
|
||||
PIPELINES_WRITE = "pipelines:write" # Create / edit / delete pipeline definitions
|
||||
PIPELINES_RUN = "pipelines:run" # Kick off a pipeline run (PR C); separate because spending filament is a different trust dimension than authoring the recipe
|
||||
|
||||
# WebSocket connection
|
||||
WEBSOCKET_CONNECT = "websocket:connect"
|
||||
|
||||
|
|
@ -200,9 +185,7 @@ PERMISSION_CATEGORIES = {
|
|||
Permission.PRINTERS_CLEAR_PLATE,
|
||||
],
|
||||
"Archives": [
|
||||
Permission.ARCHIVES_READ, # legacy — kept for back-compat with custom roles
|
||||
Permission.ARCHIVES_READ_OWN,
|
||||
Permission.ARCHIVES_READ_ALL,
|
||||
Permission.ARCHIVES_READ,
|
||||
Permission.ARCHIVES_CREATE,
|
||||
Permission.ARCHIVES_UPDATE_OWN,
|
||||
Permission.ARCHIVES_UPDATE_ALL,
|
||||
|
|
@ -213,9 +196,7 @@ PERMISSION_CATEGORIES = {
|
|||
Permission.ARCHIVES_PURGE,
|
||||
],
|
||||
"Queue": [
|
||||
Permission.QUEUE_READ, # legacy — kept for back-compat with custom roles
|
||||
Permission.QUEUE_READ_OWN,
|
||||
Permission.QUEUE_READ_ALL,
|
||||
Permission.QUEUE_READ,
|
||||
Permission.QUEUE_CREATE,
|
||||
Permission.QUEUE_UPDATE_OWN,
|
||||
Permission.QUEUE_UPDATE_ALL,
|
||||
|
|
@ -224,9 +205,7 @@ PERMISSION_CATEGORIES = {
|
|||
Permission.QUEUE_REORDER,
|
||||
],
|
||||
"Library": [
|
||||
Permission.LIBRARY_READ, # legacy — kept for back-compat with custom roles
|
||||
Permission.LIBRARY_READ_OWN,
|
||||
Permission.LIBRARY_READ_ALL,
|
||||
Permission.LIBRARY_READ,
|
||||
Permission.LIBRARY_UPLOAD,
|
||||
Permission.LIBRARY_UPDATE_OWN,
|
||||
Permission.LIBRARY_UPDATE_ALL,
|
||||
|
|
@ -301,7 +280,6 @@ PERMISSION_CATEGORIES = {
|
|||
],
|
||||
"Stats & History": [
|
||||
Permission.AMS_HISTORY_READ,
|
||||
Permission.PRINTER_SENSOR_HISTORY_READ,
|
||||
Permission.STATS_READ,
|
||||
Permission.STATS_FILTER_BY_USER,
|
||||
],
|
||||
|
|
@ -342,11 +320,6 @@ PERMISSION_CATEGORIES = {
|
|||
Permission.GROUPS_UPDATE,
|
||||
Permission.GROUPS_DELETE,
|
||||
],
|
||||
"Slicer Pipelines": [
|
||||
Permission.PIPELINES_READ,
|
||||
Permission.PIPELINES_WRITE,
|
||||
Permission.PIPELINES_RUN,
|
||||
],
|
||||
"WebSocket": [
|
||||
Permission.WEBSOCKET_CONNECT,
|
||||
],
|
||||
|
|
@ -377,31 +350,25 @@ DEFAULT_GROUPS = {
|
|||
Permission.PRINTERS_AMS_RFID.value,
|
||||
Permission.PRINTERS_CLEAR_PLATE.value,
|
||||
# Archives - own items only
|
||||
Permission.ARCHIVES_READ_OWN.value,
|
||||
Permission.ARCHIVES_READ.value,
|
||||
Permission.ARCHIVES_CREATE.value,
|
||||
Permission.ARCHIVES_UPDATE_OWN.value,
|
||||
Permission.ARCHIVES_DELETE_OWN.value,
|
||||
Permission.ARCHIVES_REPRINT_OWN.value,
|
||||
# Queue - own items only
|
||||
Permission.QUEUE_READ_OWN.value,
|
||||
Permission.QUEUE_READ.value,
|
||||
Permission.QUEUE_CREATE.value,
|
||||
Permission.QUEUE_UPDATE_OWN.value,
|
||||
Permission.QUEUE_DELETE_OWN.value,
|
||||
Permission.QUEUE_REORDER.value,
|
||||
# Library - own items only
|
||||
Permission.LIBRARY_READ_OWN.value,
|
||||
Permission.LIBRARY_READ.value,
|
||||
Permission.LIBRARY_UPLOAD.value,
|
||||
Permission.LIBRARY_UPDATE_OWN.value,
|
||||
Permission.LIBRARY_DELETE_OWN.value,
|
||||
# MakerWorld integration
|
||||
Permission.MAKERWORLD_VIEW.value,
|
||||
Permission.MAKERWORLD_IMPORT.value,
|
||||
# Orca Cloud — needed for the Slice modal's Orca Cloud preset
|
||||
# picker to populate. Workshops that use Orca Cloud presets
|
||||
# need every operator to be able to authenticate. Bambu Cloud
|
||||
# (CLOUD_AUTH) stays admin-only — that one is a more sensitive
|
||||
# account binding.
|
||||
Permission.ORCA_CLOUD_AUTH.value,
|
||||
# Projects - full access
|
||||
Permission.PROJECTS_READ.value,
|
||||
Permission.PROJECTS_CREATE.value,
|
||||
|
|
@ -457,15 +424,10 @@ DEFAULT_GROUPS = {
|
|||
Permission.FIRMWARE_READ.value,
|
||||
# Stats & History
|
||||
Permission.AMS_HISTORY_READ.value,
|
||||
Permission.PRINTER_SENSOR_HISTORY_READ.value,
|
||||
Permission.STATS_READ.value,
|
||||
Permission.SYSTEM_READ.value,
|
||||
# Settings - read only
|
||||
Permission.SETTINGS_READ.value,
|
||||
# Slicer Pipelines - full access
|
||||
Permission.PIPELINES_READ.value,
|
||||
Permission.PIPELINES_WRITE.value,
|
||||
Permission.PIPELINES_RUN.value,
|
||||
# WebSocket
|
||||
Permission.WEBSOCKET_CONNECT.value,
|
||||
],
|
||||
|
|
@ -476,9 +438,9 @@ DEFAULT_GROUPS = {
|
|||
"permissions": [
|
||||
# Read-only access
|
||||
Permission.PRINTERS_READ.value,
|
||||
Permission.ARCHIVES_READ_OWN.value,
|
||||
Permission.QUEUE_READ_OWN.value,
|
||||
Permission.LIBRARY_READ_OWN.value,
|
||||
Permission.ARCHIVES_READ.value,
|
||||
Permission.QUEUE_READ.value,
|
||||
Permission.LIBRARY_READ.value,
|
||||
Permission.PROJECTS_READ.value,
|
||||
Permission.FILAMENTS_READ.value,
|
||||
Permission.INVENTORY_READ.value,
|
||||
|
|
@ -493,12 +455,9 @@ DEFAULT_GROUPS = {
|
|||
Permission.EXTERNAL_LINKS_READ.value,
|
||||
Permission.FIRMWARE_READ.value,
|
||||
Permission.AMS_HISTORY_READ.value,
|
||||
Permission.PRINTER_SENSOR_HISTORY_READ.value,
|
||||
Permission.STATS_READ.value,
|
||||
Permission.SYSTEM_READ.value,
|
||||
Permission.SETTINGS_READ.value,
|
||||
# Slicer Pipelines - read only
|
||||
Permission.PIPELINES_READ.value,
|
||||
Permission.WEBSOCKET_CONNECT.value,
|
||||
# MakerWorld browsing only (no import — that writes to library)
|
||||
Permission.MAKERWORLD_VIEW.value,
|
||||
|
|
|
|||
|
|
@ -43,42 +43,6 @@ class ConnectionManager:
|
|||
if conn in self.active_connections:
|
||||
self.active_connections.remove(conn)
|
||||
|
||||
async def broadcast_to_user(self, user_id: int | None, message: dict[str, Any]):
|
||||
"""Send a message to every connection authenticated as the given user.
|
||||
|
||||
When ``user_id`` is None the message fans out to all connections —
|
||||
this is the auth-disabled single-user path, where neither the queue
|
||||
item's ``created_by_id`` nor the WS principal is set, and the
|
||||
existing fan-out semantics are exactly what the user wants.
|
||||
|
||||
Per-user routing reads ``websocket.state.bambuddy_principal_user_id``
|
||||
stamped at connect time (``routes/websocket.py``). Connections
|
||||
without a stamped id are skipped on the targeted path so an
|
||||
anonymous reader never receives another user's dispatch toast.
|
||||
"""
|
||||
if user_id is None:
|
||||
await self.broadcast(message)
|
||||
return
|
||||
|
||||
if not self.active_connections:
|
||||
return
|
||||
|
||||
data = json.dumps(message)
|
||||
async with self._lock:
|
||||
disconnected = []
|
||||
for connection in self.active_connections:
|
||||
conn_uid = getattr(connection.state, "bambuddy_principal_user_id", None)
|
||||
if conn_uid != user_id:
|
||||
continue
|
||||
try:
|
||||
await connection.send_text(data)
|
||||
except Exception:
|
||||
disconnected.append(connection)
|
||||
|
||||
for conn in disconnected:
|
||||
if conn in self.active_connections:
|
||||
self.active_connections.remove(conn)
|
||||
|
||||
async def send_printer_status(self, printer_id: int, status: dict):
|
||||
"""Send printer status update to all clients."""
|
||||
await self.broadcast(
|
||||
|
|
@ -127,82 +91,6 @@ class ConnectionManager:
|
|||
}
|
||||
)
|
||||
|
||||
async def send_queue_item_uploading(
|
||||
self,
|
||||
user_id: int | None,
|
||||
queue_item_id: int,
|
||||
printer_id: int,
|
||||
printer_name: str | None,
|
||||
file_name: str,
|
||||
total_bytes: int,
|
||||
):
|
||||
"""Toast trigger: scheduler picked the item up, FTP upload starts."""
|
||||
await self.broadcast_to_user(
|
||||
user_id,
|
||||
{
|
||||
"type": "queue_item_uploading",
|
||||
"queue_item_id": queue_item_id,
|
||||
"printer_id": printer_id,
|
||||
"printer_name": printer_name,
|
||||
"file_name": file_name,
|
||||
"total_bytes": total_bytes,
|
||||
},
|
||||
)
|
||||
|
||||
async def send_queue_item_upload_progress(
|
||||
self,
|
||||
user_id: int | None,
|
||||
queue_item_id: int,
|
||||
bytes_transferred: int,
|
||||
total_bytes: int,
|
||||
):
|
||||
"""Toast update: throttled byte-level progress during the FTP upload."""
|
||||
pct = int(round(100 * bytes_transferred / total_bytes)) if total_bytes else 0
|
||||
await self.broadcast_to_user(
|
||||
user_id,
|
||||
{
|
||||
"type": "queue_item_upload_progress",
|
||||
"queue_item_id": queue_item_id,
|
||||
"bytes_transferred": bytes_transferred,
|
||||
"total_bytes": total_bytes,
|
||||
"pct": pct,
|
||||
},
|
||||
)
|
||||
|
||||
async def send_queue_item_acked(
|
||||
self,
|
||||
user_id: int | None,
|
||||
queue_item_id: int,
|
||||
printer_id: int,
|
||||
):
|
||||
"""Toast trigger: watchdog confirmed the printer transitioned out of pre_state."""
|
||||
await self.broadcast_to_user(
|
||||
user_id,
|
||||
{
|
||||
"type": "queue_item_acked",
|
||||
"queue_item_id": queue_item_id,
|
||||
"printer_id": printer_id,
|
||||
},
|
||||
)
|
||||
|
||||
async def send_queue_item_failed(
|
||||
self,
|
||||
user_id: int | None,
|
||||
queue_item_id: int,
|
||||
printer_id: int | None,
|
||||
reason: str,
|
||||
):
|
||||
"""Toast trigger: dispatch failed at any stage. Toast turns red, auto-dismisses."""
|
||||
await self.broadcast_to_user(
|
||||
user_id,
|
||||
{
|
||||
"type": "queue_item_failed",
|
||||
"queue_item_id": queue_item_id,
|
||||
"printer_id": printer_id,
|
||||
"reason": reason,
|
||||
},
|
||||
)
|
||||
|
||||
async def send_missing_spool_assignment(
|
||||
self,
|
||||
printer_id: int,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
2780
backend/app/main.py
2780
backend/app/main.py
File diff suppressed because it is too large
Load diff
|
|
@ -10,7 +10,6 @@ from backend.app.models.group import Group, user_groups
|
|||
from backend.app.models.kprofile_note import KProfileNote
|
||||
from backend.app.models.library import LibraryFile, LibraryFolder
|
||||
from backend.app.models.local_preset import LocalPreset
|
||||
from backend.app.models.location import Location
|
||||
from backend.app.models.long_lived_token import LongLivedToken
|
||||
from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
|
||||
from backend.app.models.notification import NotificationLog
|
||||
|
|
@ -18,16 +17,12 @@ from backend.app.models.notification_template import NotificationTemplate
|
|||
from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
|
||||
from backend.app.models.orca_base_cache import OrcaBaseProfile
|
||||
from backend.app.models.pending_upload import PendingUpload
|
||||
from backend.app.models.pipeline_run import PipelineJob, PipelineRun
|
||||
from backend.app.models.print_batch import PrintBatch
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.printer_sensor_history import PrinterSensorHistory
|
||||
from backend.app.models.project import Project
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.slicer_pipeline import SlicerPipeline
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
|
||||
from backend.app.models.sponsor_toast_state import SponsorToastState
|
||||
from backend.app.models.spool import Spool
|
||||
from backend.app.models.spool_assignment import SpoolAssignment
|
||||
from backend.app.models.spool_catalog import SpoolCatalogEntry
|
||||
|
|
@ -55,13 +50,11 @@ __all__ = [
|
|||
"Project",
|
||||
"APIKey",
|
||||
"AMSSensorHistory",
|
||||
"PrinterSensorHistory",
|
||||
"AmsLabel",
|
||||
"PendingUpload",
|
||||
"PrintBatch",
|
||||
"LibraryFolder",
|
||||
"LibraryFile",
|
||||
"Location",
|
||||
"User",
|
||||
"Group",
|
||||
"user_groups",
|
||||
|
|
@ -71,9 +64,6 @@ __all__ = [
|
|||
"OIDCProvider",
|
||||
"UserOIDCLink",
|
||||
"OrcaBaseProfile",
|
||||
"PipelineJob",
|
||||
"PipelineRun",
|
||||
"SlicerPipeline",
|
||||
"Spool",
|
||||
"SpoolKProfile",
|
||||
"SpoolAssignment",
|
||||
|
|
@ -81,7 +71,6 @@ __all__ = [
|
|||
"SpoolUsageHistory",
|
||||
"ColorCatalogEntry",
|
||||
"SpoolBuddyDevice",
|
||||
"SponsorToastState",
|
||||
"UserEmailPreference",
|
||||
"UserOTPCode",
|
||||
"UserTOTP",
|
||||
|
|
|
|||
|
|
@ -24,11 +24,7 @@ class ActivePrintSpoolman(Base):
|
|||
archive_id: Mapped[int] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"))
|
||||
|
||||
# Per-filament usage from 3MF: [{"slot_id": 1, "used_g": 50.5, "type": "PLA"}, ...]
|
||||
# Nullable for the no-3MF case ("Untitled" prints where Bambu didn't keep a
|
||||
# .gcode.3mf on the printer): the row still gets created so the completion
|
||||
# path can use ``tray_remain_start`` for an AMS remain%-delta write,
|
||||
# mirroring the internal-inventory Path 2 fallback in usage_tracker (#1820).
|
||||
filament_usage: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
filament_usage: Mapped[list] = mapped_column(JSON)
|
||||
|
||||
# AMS tray state at print start: {0: {"tray_uuid": "...", "tag_uid": "..."}, ...}
|
||||
ams_trays: Mapped[dict] = mapped_column(JSON)
|
||||
|
|
@ -44,10 +40,3 @@ class ActivePrintSpoolman(Base):
|
|||
# Filament properties (density, diameter per filament slot)
|
||||
# Format: {1: {"density": 1.24, "diameter": 1.75, "type": "PLA"}, ...}
|
||||
filament_properties: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
# AMS tray remain% per slot at print start, captured so the completion
|
||||
# path can compute a remain-delta when the 3MF didn't cover a slot (or
|
||||
# there was no 3MF at all — #1820). Matches the internal-inventory
|
||||
# ``tray_remain_start`` snapshot at usage_tracker.py:301.
|
||||
# Format: {"<ams_id>-<tray_id>": {"remain": int, "tray_uuid": str}, ...}
|
||||
tray_remain_start: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
|
|
|||
|
|
@ -36,15 +36,6 @@ class APIKey(Base):
|
|||
can_manage_inventory: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True
|
||||
) # Inventory write ops (incl. SpoolBuddy kiosk NFC/scale/system)
|
||||
can_manage_maintenance: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True
|
||||
) # Log/reset per-printer maintenance, edit intervals, manage the type catalog (#1832 follow-up)
|
||||
can_manage_archives: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True
|
||||
) # Create/update/delete print archives (not purge) (#1888)
|
||||
can_manage_projects: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True
|
||||
) # Create/update/delete projects + manage membership (add archives) (#1893)
|
||||
can_access_cloud: Mapped[bool] = mapped_column(Boolean, default=False) # Read /cloud/* on the owner's behalf
|
||||
# Narrowly-scoped settings write: only POST /settings/electricity-price.
|
||||
# Lets HA/Tibber-style automations push dynamic tariff updates without
|
||||
|
|
|
|||
|
|
@ -12,12 +12,6 @@ class PrintArchive(Base):
|
|||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=True)
|
||||
project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
|
||||
# Which library file this run was dispatched from (#1897). Set by the queue
|
||||
# scheduler when it archives a library-file print; older rows are matched by
|
||||
# content_hash/filename instead. SET NULL so deleting a file keeps history.
|
||||
library_file_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("library_files.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
# File info
|
||||
filename: Mapped[str] = mapped_column(String(255))
|
||||
|
|
@ -32,16 +26,6 @@ class PrintArchive(Base):
|
|||
# both locally and on the printer's SD after extraction — the user
|
||||
# didn't opt in to a timelapse recording.
|
||||
bambuddy_forced_timelapse: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
|
||||
# Video filenames present in the printer's /timelapse directory when this
|
||||
# print started (#2704). The printer writes its video only at print end, so
|
||||
# anything not in this list belongs to this print — a comparison that needs
|
||||
# no clock, which matters because a LAN-only printer can't reach Bambu's NTP
|
||||
# server and its filename timestamps are arbitrarily wrong. Persisted (not
|
||||
# just held in memory) so the diff survives a restart and so the manual
|
||||
# "Scan for Timelapse" button can use it instead of guessing from
|
||||
# timestamps. NULL for archives predating this, and for baselines taken at
|
||||
# completion time, which are useless by construction.
|
||||
timelapse_baseline: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
source_3mf_path: Mapped[str | None] = mapped_column(String(500)) # Original project 3MF from slicer
|
||||
f3d_path: Mapped[str | None] = mapped_column(String(500)) # Fusion 360 design file
|
||||
|
||||
|
|
@ -72,14 +56,6 @@ class PrintArchive(Base):
|
|||
# print and keep the original row instead of cancel-then-create.
|
||||
subtask_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
# Which plate of a multi-plate 3MF this print was for (1-based), copied from
|
||||
# the queue item at dispatch (#2603). A whole multi-plate 3MF is uploaded
|
||||
# under one filename with no plate suffix, so the parser can't recover the
|
||||
# selected plate and extra_data holds all-plates aggregate metadata; without
|
||||
# this the history UI can't tell which plate was printed and falls back to
|
||||
# Plate 1. NULL for archives with no specific selected plate.
|
||||
plate_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# Extended metadata (JSON blob for flexibility)
|
||||
extra_data: Mapped[dict | None] = mapped_column(JSON)
|
||||
|
||||
|
|
|
|||
|
|
@ -13,16 +13,13 @@ class FilamentSkuSettings(Base):
|
|||
__table_args__ = (
|
||||
# sqlite_where ensures NULL columns participate in uniqueness (NULLS NOT DISTINCT).
|
||||
# On PostgreSQL the partial index is not needed — standard UNIQUE handles it.
|
||||
# color_name is part of the key so forecasts distinguish e.g. White vs Black
|
||||
# PLA Matte (#forecast-color-grouping).
|
||||
UniqueConstraint("material", "subtype", "brand", "color_name", name="uq_filament_sku"),
|
||||
UniqueConstraint("material", "subtype", "brand", name="uq_filament_sku"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
material: Mapped[str] = mapped_column(String(50))
|
||||
subtype: Mapped[str | None] = mapped_column(String(50))
|
||||
brand: Mapped[str | None] = mapped_column(String(100))
|
||||
color_name: Mapped[str | None] = mapped_column(String(100))
|
||||
lead_time_days: Mapped[int] = mapped_column(Integer, default=0)
|
||||
safety_margin_value: Mapped[int] = mapped_column(Integer, default=14)
|
||||
safety_margin_unit: Mapped[str] = mapped_column(String(10), default="days")
|
||||
|
|
|
|||
|
|
@ -31,14 +31,6 @@ class LibraryFolder(Base):
|
|||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Real on-disk modification time of the directory this folder mirrors (#2680).
|
||||
# For external folders this is captured from ``os.stat().st_mtime`` on scan so
|
||||
# the tree's "sort by recent activity" matches ``ls -t`` instead of ordering by
|
||||
# the DB row's ``updated_at`` (which is the scan instant, identical for every
|
||||
# row of a bulk scan). Null for managed (internal) folders, which have no
|
||||
# meaningful directory mtime — callers fall back to ``updated_at``/``created_at``.
|
||||
fs_modified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
parent: Mapped["LibraryFolder | None"] = relationship(
|
||||
"LibraryFolder",
|
||||
|
|
@ -110,25 +102,10 @@ class LibraryFile(Base):
|
|||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Real on-disk modification time of the file (#2680). Captured from
|
||||
# ``os.stat().st_mtime`` for external files on scan so the file pane's date
|
||||
# sort and the folder tree's recursive "recent activity" bubble reflect the
|
||||
# actual filesystem mtime (``ls -t``) rather than the DB ``updated_at`` (the
|
||||
# scan instant, identical across a bulk scan). Null for managed uploads —
|
||||
# callers fall back to ``created_at``.
|
||||
fs_modified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
|
||||
project: Mapped["Project | None"] = relationship()
|
||||
created_by: Mapped["User | None"] = relationship()
|
||||
# Tags (#1268). M2M via library_file_tags. Loaded explicitly via
|
||||
# ``selectinload`` in list_files so each row in the listing carries its
|
||||
# chip set without N+1 fetches.
|
||||
tags: Mapped[list["LibraryTag"]] = relationship(
|
||||
secondary="library_file_tags",
|
||||
back_populates="files",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def active(cls) -> "Select[tuple[LibraryFile]]":
|
||||
|
|
@ -142,47 +119,6 @@ class LibraryFile(Base):
|
|||
return select(cls).where(cls.deleted_at.is_(None))
|
||||
|
||||
|
||||
class LibraryTag(Base):
|
||||
"""User-authored cross-cutting label for library files (#1268).
|
||||
|
||||
Folders express hierarchy; tags express orthogonal attributes ("toy",
|
||||
"kid-safe", "petg-only"). Catalog is global (one tag set per install)
|
||||
— the multi-user "private tags" case is not in v1 scope. ``name_key``
|
||||
is ``LOWER(TRIM(name))`` so "Toys" / "toys" / " TOYS " all collide
|
||||
on the UNIQUE index and the route returns 409 instead of silently
|
||||
creating a duplicate.
|
||||
"""
|
||||
|
||||
__tablename__ = "library_tags"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
name_key: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
files: Mapped[list["LibraryFile"]] = relationship(
|
||||
secondary="library_file_tags",
|
||||
back_populates="tags",
|
||||
)
|
||||
|
||||
|
||||
class LibraryFileTag(Base):
|
||||
"""Association between library files and tags (#1268).
|
||||
|
||||
Composite PK so the same (file, tag) pair can't be inserted twice. Both
|
||||
sides ON DELETE CASCADE: deleting a tag drops every association row,
|
||||
deleting a file drops its tag links, and the catalog row survives so
|
||||
other files keep their chip.
|
||||
"""
|
||||
|
||||
__tablename__ = "library_file_tags"
|
||||
|
||||
file_id: Mapped[int] = mapped_column(ForeignKey("library_files.id", ondelete="CASCADE"), primary_key=True)
|
||||
tag_id: Mapped[int] = mapped_column(ForeignKey("library_tags.id", ondelete="CASCADE"), primary_key=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
from backend.app.models.archive import PrintArchive # noqa: E402, F811
|
||||
from backend.app.models.project import Project # noqa: E402, F811
|
||||
from backend.app.models.user import User # noqa: E402, F811
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from backend.app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.app.models.spool import Spool
|
||||
|
||||
|
||||
class Location(Base):
|
||||
"""Physical storage location for filament spools (shelf, drawer, drybox, etc.)."""
|
||||
|
||||
__tablename__ = "locations"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
# Case-insensitive uniqueness — LOWER(TRIM(name)); enforced via migration index.
|
||||
name_key: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
|
||||
# Reserved for Phase 3 RFID shelf tags — unused in Phase 1.
|
||||
identifier: Mapped[str | None] = mapped_column(String(100))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
spools: Mapped[list["Spool"]] = relationship(back_populates="location")
|
||||
|
|
@ -70,7 +70,6 @@ class NotificationProvider(Base):
|
|||
# Event triggers - printer status
|
||||
on_printer_offline = Column(Boolean, default=False)
|
||||
on_printer_error = Column(Boolean, default=False) # AMS issues, etc.
|
||||
on_ai_failure_detection = Column(Boolean, default=False) # Obico spaghetti / failure detection (#1794)
|
||||
on_filament_low = Column(Boolean, default=False)
|
||||
on_maintenance_due = Column(Boolean, default=False) # Maintenance reminder
|
||||
|
||||
|
|
@ -84,8 +83,6 @@ class NotificationProvider(Base):
|
|||
|
||||
# Event triggers - Build plate detection
|
||||
on_plate_not_empty = Column(Boolean, default=True) # Objects detected on plate before print
|
||||
# Off by default: fires after every print, alongside the print-complete alert (#2525)
|
||||
on_plate_clear_required = Column(Boolean, default=False) # Print ended, queue gated until plate is confirmed clear
|
||||
|
||||
# Event triggers - Bed cooled after print
|
||||
on_bed_cooled = Column(Boolean, default=False) # Bed cooled below threshold after print
|
||||
|
|
|
|||
|
|
@ -73,24 +73,12 @@ DEFAULT_TEMPLATES = [
|
|||
"title_template": "Printer Error: {error_type}",
|
||||
"body_template": "{printer}\n{error_detail}",
|
||||
},
|
||||
{
|
||||
"event_type": "ai_failure_detection",
|
||||
"name": "AI Failure Detection",
|
||||
"title_template": "Possible Print Failure Detected",
|
||||
"body_template": "{printer}: {task_name}\nConfidence: {confidence}\nAction taken: {action}",
|
||||
},
|
||||
{
|
||||
"event_type": "plate_not_empty",
|
||||
"name": "Plate Not Empty",
|
||||
"title_template": "Plate Not Empty - Print Paused",
|
||||
"body_template": "{printer}: Objects detected on build plate. Print has been paused. Clear plate and resume.",
|
||||
},
|
||||
{
|
||||
"event_type": "plate_clear_required",
|
||||
"name": "Plate Clear Required",
|
||||
"title_template": "Plate Clear Required",
|
||||
"body_template": "{printer}: print finished. Confirm the build plate is clear before the queue continues.",
|
||||
},
|
||||
{
|
||||
"event_type": "filament_low",
|
||||
"name": "Filament Low",
|
||||
|
|
@ -201,32 +189,28 @@ DEFAULT_TEMPLATES = [
|
|||
"title_template": "Stock Break Risk: {material}",
|
||||
"body_template": "{material} ({brand}) will run out before replenishment arrives.\nStock: {stock_g}g | Rate: {rate_g_day}g/day | Lead time: {lead_time_days}d\nOnly {days_left}d of stock remaining — order immediately.",
|
||||
},
|
||||
# User email notification templates (sent to the print job owner).
|
||||
# Names include " Email" so they aren't confused with the provider-level
|
||||
# `print_*` templates above, which share the same body shape but are
|
||||
# broadcast to admin-configured providers (ntfy/pushover/telegram/discord/
|
||||
# etc.) rather than mailed to a specific user.
|
||||
# User email notification templates (sent to the print job owner)
|
||||
{
|
||||
"event_type": "user_print_start",
|
||||
"name": "User Print Started Email",
|
||||
"name": "User Print Started",
|
||||
"title_template": "Your Print Has Started",
|
||||
"body_template": "Hello {username},\n\nYour print job has started on {printer}.\n\nFile: {filename}\n\nYou will be notified when it completes.",
|
||||
},
|
||||
{
|
||||
"event_type": "user_print_complete",
|
||||
"name": "User Print Completed Email",
|
||||
"name": "User Print Completed",
|
||||
"title_template": "Your Print Is Complete",
|
||||
"body_template": "Hello {username},\n\nYour print job has completed on {printer}.\n\nFile: {filename}",
|
||||
},
|
||||
{
|
||||
"event_type": "user_print_failed",
|
||||
"name": "User Print Failed Email",
|
||||
"name": "User Print Failed",
|
||||
"title_template": "Your Print Has Failed",
|
||||
"body_template": "Hello {username},\n\nYour print job has failed on {printer}.\n\nFile: {filename}",
|
||||
},
|
||||
{
|
||||
"event_type": "user_print_stopped",
|
||||
"name": "User Print Stopped Email",
|
||||
"name": "User Print Stopped",
|
||||
"title_template": "Your Print Has Been Stopped",
|
||||
"body_template": "Hello {username},\n\nYour print job was stopped on {printer}.\n\nFile: {filename}",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -121,17 +121,6 @@ class OIDCProvider(Base):
|
|||
# SHA-256 hex of icon_data, served as the ETag header so clients can
|
||||
# revalidate via If-None-Match and receive 304 Not Modified.
|
||||
icon_etag: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
|
||||
# When True, the LoginPage redirects unauthenticated visitors straight to
|
||||
# this provider's authorize URL on mount (#1589). At most one provider can
|
||||
# carry this flag at a time; setting it on a new provider clears it on the
|
||||
# previous one. The frontend always falls back to the local form if the
|
||||
# authorize-URL fetch fails or times out, and ``/login?fallback=local``
|
||||
# plus ``BAMBUDDY_LOCAL_LOGIN=true`` provide a documented recovery path.
|
||||
is_autologin: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
|
||||
# Marks the single provider defined by BAMBUDDY_OIDC_* env vars. Upserted on
|
||||
# startup; UI/API writes to it are rejected. Never delete-recreated (user_oidc_links
|
||||
# FK is ON DELETE CASCADE).
|
||||
is_env_managed: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
|
||||
|
||||
@property
|
||||
def has_icon(self) -> bool:
|
||||
|
|
|
|||
|
|
@ -1,111 +0,0 @@
|
|||
"""Models for a Slicer Pipeline run (#1425 PR B).
|
||||
|
||||
A PipelineRun is one "Run pipeline" click: slice the source file once with the
|
||||
pipeline's four preset slots, then enqueue a single print on the pipeline's
|
||||
pinned target printer (PR B = single-target dispatch). PR C extends this with
|
||||
copies > 1 and class targeting + fanout strategies.
|
||||
|
||||
Status on a PipelineRun is mostly COMPUTED from the underlying slice_job
|
||||
(in-memory) + the linked queue_entry's state at read time — see
|
||||
``api/routes/pipeline_runs.py`` ``_compute_run_status`` for the rules. The
|
||||
``status`` column is the persisted snapshot used as a fallback / for filtering
|
||||
in list queries; it's updated on terminal transitions (slice failure, cancel,
|
||||
or queue-entry completion).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from backend.app.core.database import Base
|
||||
|
||||
|
||||
class PipelineRun(Base):
|
||||
"""One run-pipeline invocation. PR B always carries exactly one
|
||||
PipelineJob (copies=1); PR C will allow N."""
|
||||
|
||||
__tablename__ = "pipeline_runs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
# Pipeline + source. ``ondelete='SET NULL'`` on both so run history survives
|
||||
# the user soft-deleting a pipeline or removing the source library file.
|
||||
pipeline_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("slicer_pipelines.id", ondelete="SET NULL"))
|
||||
source_library_file_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("library_files.id", ondelete="SET NULL")
|
||||
)
|
||||
# Mutually exclusive with source_library_file_id. When set, the orchestrator
|
||||
# reads ``archive.source_3mf_path`` (falling back to ``file_path``) for the
|
||||
# slice input. Lets ArchiveCard's "Run with pipeline" reuse the same /run
|
||||
# endpoint instead of growing a second route.
|
||||
source_archive_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("print_archives.id", ondelete="SET NULL"))
|
||||
|
||||
# Set when this run was created by ``POST /pipeline-runs/{parent}/retry-failed``.
|
||||
# Chains the new run back to the run whose failed copies it re-attempts so
|
||||
# the dashboard can show "Retry of run #N" inline. ``SET NULL`` so cleaning
|
||||
# up old runs doesn't dangle retries.
|
||||
parent_run_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("pipeline_runs.id", ondelete="SET NULL"))
|
||||
|
||||
copies: Mapped[int] = mapped_column(Integer, default=1)
|
||||
|
||||
# Snapshot status — terminal transitions are persisted here, in-flight
|
||||
# reads compute from slice_job + queue_entry. Values:
|
||||
# 'queued', 'slicing', 'dispatching', 'in_progress',
|
||||
# 'completed', 'failed', 'cancelled'
|
||||
status: Mapped[str] = mapped_column(String(20), default="queued")
|
||||
|
||||
# Slice integration. slice_job_id is the in-memory slice_dispatch id (so
|
||||
# it's a plain int, not an FK). sliced_library_file_id is the produced
|
||||
# gcode.3mf row.
|
||||
slice_job_id: Mapped[int | None] = mapped_column(Integer)
|
||||
sliced_library_file_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("library_files.id", ondelete="SET NULL")
|
||||
)
|
||||
|
||||
# True when the operator chose to "Run anyway" past eligibility issues
|
||||
# (filament mismatch, etc.). Surfaced in run history so the audit log
|
||||
# shows which runs bypassed the pre-flight.
|
||||
eligibility_overridden: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
|
||||
|
||||
error_message: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
|
||||
jobs: Mapped[list["PipelineJob"]] = relationship(
|
||||
back_populates="run",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="PipelineJob.copy_index",
|
||||
)
|
||||
|
||||
|
||||
class PipelineJob(Base):
|
||||
"""One copy within a PipelineRun. PR B: always exactly one per run.
|
||||
|
||||
Each job binds the run to one queue entry (``queue_entry_id``). The
|
||||
queue entry's status drives this job's status; this row mostly carries
|
||||
the run-side narrative (dispatch timestamps, error message) so deleting
|
||||
the queue entry later doesn't lose the audit trail.
|
||||
"""
|
||||
|
||||
__tablename__ = "pipeline_jobs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
pipeline_run_id: Mapped[int] = mapped_column(Integer, ForeignKey("pipeline_runs.id", ondelete="CASCADE"))
|
||||
copy_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
assigned_printer_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("printers.id", ondelete="SET NULL"))
|
||||
queue_entry_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("print_queue.id", ondelete="SET NULL"))
|
||||
|
||||
# Values: 'pending', 'awaiting_printer', 'queued', 'printing',
|
||||
# 'completed', 'failed', 'cancelled'
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
error_message: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
dispatched_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime)
|
||||
|
||||
run: Mapped["PipelineRun"] = relationship(back_populates="jobs")
|
||||
|
|
@ -65,73 +65,19 @@ class PrintQueueItem(Base):
|
|||
# Auto-print G-code injection (#422)
|
||||
gcode_injection: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# How many times the start-watchdog has reverted this item from 'printing'
|
||||
# back to 'pending' (#2555). A printer that accepts project_file but never
|
||||
# starts (#1678) used to be retried forever: upload, wait out the watchdog,
|
||||
# revert, upload again — burning a full 3MF transfer per cycle and, with
|
||||
# the queue dispatching serially, dragging every other printer's start time
|
||||
# out with it. The counter bounds that loop; see DISPATCH_MAX_ATTEMPTS.
|
||||
dispatch_attempts: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
|
||||
# H2C dual-nozzle-rack slicer pick preservation (#1780). BambuStudio's
|
||||
# project_file MQTT command for rack-swap-capable models (O1C2 today)
|
||||
# carries per-filament physical nozzle position IDs in `nozzle_mapping`,
|
||||
# forwarded verbatim through the queue and replayed by the dispatcher so
|
||||
# the firmware honours the user's pick instead of falling back to
|
||||
# "last matching nozzle type" auto-pick. Stored as opaque JSON string
|
||||
# (list[int]); NULL on every other model. `nozzles_info` is a deprecated
|
||||
# column from the original #1780 attempt — kept nullable so old rows still
|
||||
# load; never written to or read from.
|
||||
nozzle_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
nozzles_info: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Printer-card direct uploads create transient library rows. When this is
|
||||
# true, the scheduler deletes the source row/files after archiving a copy.
|
||||
cleanup_library_after_dispatch: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Print options. bed_levelling / flow_cali / nozzle_offset_cali are tri-state
|
||||
# strings (off/on/auto) matching BambuStudio; "auto" = skip if recently done.
|
||||
# The remaining three stay boolean (BambuStudio exposes no auto for them).
|
||||
bed_levelling: Mapped[str] = mapped_column(String(8), default="auto")
|
||||
flow_cali: Mapped[str] = mapped_column(String(8), default="auto")
|
||||
# Print options
|
||||
bed_levelling: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
flow_cali: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
vibration_cali: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
layer_inspect: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
timelapse: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
use_ams: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
# Nozzle offset calibration — dual-nozzle printers only, MQTT-gated (#1682)
|
||||
nozzle_offset_cali: Mapped[str] = mapped_column(String(8), default="auto")
|
||||
|
||||
# Preheat / heat-soak override (#1468). 'inherit' uses the global
|
||||
# preheat_enabled setting; 'on' / 'off' force the per-item decision. The
|
||||
# chamber target falls through: per-item override → max(filament-map[loaded
|
||||
# tray type]) → 0 (skips chamber phase). 'inherit' + global off + override
|
||||
# null = no preheat. Default 'inherit' so existing queue items behave
|
||||
# exactly as before the migration.
|
||||
preheat_override: Mapped[str] = mapped_column(String(10), default="inherit")
|
||||
preheat_chamber_target_override: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
nozzle_offset_cali: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# Status: pending, printing, completed, failed, skipped, cancelled
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
|
||||
# Dispatch claim (#2615). Set atomically by the scheduler the moment it
|
||||
# begins dispatching this row and cleared when dispatch ends. The row stays
|
||||
# `status='pending'` throughout the (slow) FTP upload, which left a window
|
||||
# where a concurrent PATCH could reassign printer_id mid-upload and split the
|
||||
# queue row from the archive/expected-print/physical command. While this is
|
||||
# set the edit routes reject changes (409) and the scheduler won't re-select
|
||||
# the row. Startup reconciliation clears any left over by a crash mid-dispatch
|
||||
# (no coroutine survives a restart), so a stale claim never wedges an item.
|
||||
dispatching_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Cleared by the per-printer "Resume after failure" action (#1818) so the
|
||||
# scheduler's `_check_previous_success` lookback skips this row. Without
|
||||
# this, a single `failed` or `aborted` print poisoned every later
|
||||
# `require_previous_success` item on the same printer forever — the
|
||||
# lookback excluded `skipped` but had no way to dismiss the originating
|
||||
# failure. The flag is per-item, not per-printer, so a fresh failure
|
||||
# after a resume re-gates downstream items independently.
|
||||
gate_acknowledged: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Set by the dispatch scheduler when the assigned spool can't satisfy
|
||||
# this print's per-slot filament weight (#1496). Display-only flag — the
|
||||
# actual deficit is recomputed live every time the user clicks ▶, so
|
||||
|
|
|
|||
|
|
@ -58,9 +58,6 @@ class Printer(Base):
|
|||
)
|
||||
kprofile_notes: Mapped[list["KProfileNote"]] = relationship(back_populates="printer", cascade="all, delete-orphan")
|
||||
ams_history: Mapped[list["AMSSensorHistory"]] = relationship(back_populates="printer", cascade="all, delete-orphan")
|
||||
sensor_history: Mapped[list["PrinterSensorHistory"]] = relationship(
|
||||
back_populates="printer", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
from backend.app.models.ams_history import AMSSensorHistory # noqa: E402
|
||||
|
|
@ -68,5 +65,4 @@ from backend.app.models.archive import PrintArchive # noqa: E402
|
|||
from backend.app.models.kprofile_note import KProfileNote # noqa: E402
|
||||
from backend.app.models.maintenance import PrinterMaintenance # noqa: E402
|
||||
from backend.app.models.notification import NotificationProvider # noqa: E402
|
||||
from backend.app.models.printer_sensor_history import PrinterSensorHistory # noqa: E402
|
||||
from backend.app.models.smart_plug import SmartPlug # noqa: E402
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, Index, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from backend.app.core.database import Base
|
||||
|
||||
|
||||
class PrinterSensorHistory(Base):
|
||||
"""Historical heater readings (nozzle / nozzle_2 / bed / chamber).
|
||||
|
||||
Parallel to AMSSensorHistory, but per-(printer, sensor_kind) rather
|
||||
than per-(printer, ams_id). Sensor counts vary by model (single vs
|
||||
dual nozzle, presence of chamber heater), so a long-format row per
|
||||
sensor reads cleanly and leaves room for future kinds (cpu, motor)
|
||||
without another migration.
|
||||
"""
|
||||
|
||||
__tablename__ = "printer_sensor_history"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
|
||||
sensor_kind: Mapped[str] = mapped_column(String(32)) # nozzle | nozzle_2 | bed | chamber
|
||||
value: Mapped[float | None] = mapped_column(Float) # current temperature, Celsius
|
||||
target: Mapped[float | None] = mapped_column(Float) # target temperature when set, Celsius
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_printer_sensor_history_printer_kind_time",
|
||||
"printer_id",
|
||||
"sensor_kind",
|
||||
"recorded_at",
|
||||
),
|
||||
)
|
||||
|
||||
printer: Mapped["Printer"] = relationship(back_populates="sensor_history")
|
||||
|
||||
|
||||
from backend.app.models.printer import Printer # noqa: E402
|
||||
|
|
@ -30,9 +30,6 @@ class Project(Base):
|
|||
target_parts_count: Mapped[int | None] = mapped_column(
|
||||
Integer, nullable=True
|
||||
) # Optional target number of parts/objects
|
||||
# Optional copies-per-file target (#1897): every printable file in the
|
||||
# project's linked folders should be printed this many times ("sets").
|
||||
target_sets: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# Phase 2: Rich text notes (HTML from WYSIWYG editor)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ class ShoppingListItem(Base):
|
|||
material: Mapped[str] = mapped_column(String(50))
|
||||
subtype: Mapped[str | None] = mapped_column(String(50))
|
||||
brand: Mapped[str | None] = mapped_column(String(100))
|
||||
color_name: Mapped[str | None] = mapped_column(String(100))
|
||||
quantity_spools: Mapped[int] = mapped_column(Integer, default=1)
|
||||
note: Mapped[str | None] = mapped_column(String(500))
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending") # pending | purchased | received
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
"""Model for a Slicing/Printing Pipeline definition (#1425).
|
||||
|
||||
A pipeline bundles the four slot picks a user normally makes in the SliceModal
|
||||
(printer / process / filament(s) / bed type) under a named, reusable preset.
|
||||
This is PR A — bundle definitions only. Run state and dispatch live in
|
||||
``pipeline_runs`` / ``pipeline_jobs`` (PR B + PR C).
|
||||
|
||||
The target_* and fanout_strategy columns are materialised now to avoid a
|
||||
second migration when PR B / PR C land; PR A's API accepts the defaults and
|
||||
the UI doesn't expose them yet.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.app.core.database import Base
|
||||
|
||||
|
||||
class SlicerPipeline(Base):
|
||||
"""A named slicer preset bundle (printer + process + filament[s] + bed)."""
|
||||
|
||||
__tablename__ = "slicer_pipelines"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
description: Mapped[str | None] = mapped_column(String(1000))
|
||||
|
||||
# Preset slots. ``*_source`` mirrors PresetRef.source semantics
|
||||
# (orca_cloud / cloud / local / standard); ``*_id`` is the opaque
|
||||
# source-specific id the slicer pipeline uses to resolve content.
|
||||
printer_preset_source: Mapped[str] = mapped_column(String(20))
|
||||
printer_preset_id: Mapped[str] = mapped_column(String(200))
|
||||
process_preset_source: Mapped[str] = mapped_column(String(20))
|
||||
process_preset_id: Mapped[str] = mapped_column(String(200))
|
||||
# JSON array of {"source": ..., "id": ...} entries — one per AMS slot the
|
||||
# source plate is expected to use. Stored as JSON text per Bambuddy's
|
||||
# convention (see LocalPreset.compatible_printers).
|
||||
filament_presets_json: Mapped[str] = mapped_column(Text)
|
||||
|
||||
bed_type: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
# Target — PR B+ wiring; PR A treats every pipeline as a bundle without
|
||||
# an active target. Kept materialised so PR B is code-only, not a
|
||||
# migration. ``target_kind`` ∈ {"specific_printer", "printer_class"}.
|
||||
target_kind: Mapped[str] = mapped_column(String(20), default="printer_class")
|
||||
target_printer_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("printers.id", ondelete="SET NULL"))
|
||||
target_model_class: Mapped[str | None] = mapped_column(String(20))
|
||||
|
||||
# Fanout strategy for PR C multi-copy runs. PR A defaults it; the UI
|
||||
# doesn't expose it yet. Values: max_parallel / fill_one_first / round_robin.
|
||||
fanout_strategy: Mapped[str] = mapped_column(String(20), default="max_parallel")
|
||||
|
||||
# Audit fields. created_by is nullable so pipelines survive user deletes
|
||||
# and so installs without auth enabled (current_user is None) still work.
|
||||
created_by: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
|
@ -67,30 +67,14 @@ class SmartPlug(Base):
|
|||
rest_power_path: Mapped[str | None] = mapped_column(String(200), nullable=True) # JSON path for power (watts)
|
||||
rest_power_multiplier: Mapped[float] = mapped_column(Float, server_default="1.0") # Unit conversion for power
|
||||
rest_energy_url: Mapped[str | None] = mapped_column(String(500), nullable=True) # Separate URL for energy data
|
||||
# Energy used *today*, resetting at midnight (kWh after the multiplier).
|
||||
rest_energy_path: Mapped[str | None] = mapped_column(String(200), nullable=True) # JSON path for energy (kWh)
|
||||
rest_energy_multiplier: Mapped[float] = mapped_column(
|
||||
Float, server_default="1.0"
|
||||
) # Unit conversion (e.g., 0.001 for Wh→kWh)
|
||||
# Lifetime cumulative counter that never resets (#2539). A Shelly exposes only
|
||||
# this one (`aenergy.total`, in Wh); a Tasmota behind a REST bridge exposes
|
||||
# both. Kept separate from rest_energy_path because a cumulative counter read
|
||||
# as "today" is silently wrong all day, and feeds Yesterday / Total / the
|
||||
# hourly snapshots that the Statistics page's date filters run on.
|
||||
rest_energy_total_path: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
rest_energy_total_multiplier: Mapped[float] = mapped_column(Float, server_default="1.0")
|
||||
|
||||
# Link to printer (multiple plugs/scripts can be linked to one printer)
|
||||
printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
# Whether this plug actually feeds the printer's own power (#2629). The
|
||||
# printer link is also used for accessories that merely follow the print
|
||||
# cycle — filter fans, chamber lights, enclosure heaters. Only a plug that
|
||||
# really cuts printer power may mark the printer offline on auto-off;
|
||||
# doing it for an accessory blanks the printer state and stalls the queue.
|
||||
# Defaults to True so existing plugs keep their previous behaviour.
|
||||
controls_printer_power: Mapped[bool] = mapped_column(Boolean, default=True, server_default="1")
|
||||
|
||||
# Automation settings
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
auto_on: Mapped[bool] = mapped_column(Boolean, default=True) # Turn on at print start
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
"""Per-user (or install-default) state for the sponsor-prompt toast.
|
||||
|
||||
A single row stores which sponsor-toast milestones have already fired for a
|
||||
given user, when the most recent toast was shown (for the 14-day cooldown),
|
||||
and the app version last seen so we can fire the "version-update" trigger
|
||||
exactly once per major bump.
|
||||
|
||||
``user_id`` is nullable: in auth-disabled installs (no user concept), the
|
||||
service stores everything against a single NULL-keyed row.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from backend.app.core.database import Base
|
||||
|
||||
|
||||
class SponsorToastState(Base):
|
||||
__tablename__ = "sponsor_toast_state"
|
||||
__table_args__ = (UniqueConstraint("user_id", name="uq_sponsor_toast_state_user_id"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
last_shown_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
# JSON-serialised list[str] of milestone keys already fired (e.g. ["prints-100", "cost-100"]).
|
||||
# Stored as Text for SQLite/Postgres uniformity; the service serialises with json.dumps.
|
||||
milestones_seen: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
last_seen_version: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, func
|
||||
from sqlalchemy import Boolean, DateTime, Float, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from backend.app.core.database import Base
|
||||
|
|
@ -61,7 +61,6 @@ class Spool(Base):
|
|||
cost_per_kg: Mapped[float | None] = mapped_column(Float) # Cost per kilogram
|
||||
|
||||
storage_location: Mapped[str | None] = mapped_column(String(255)) # User-editable storage location
|
||||
location_id: Mapped[int | None] = mapped_column(ForeignKey("locations.id"), index=True)
|
||||
|
||||
last_used: Mapped[datetime | None] = mapped_column(DateTime) # Last time this spool was used in a print
|
||||
encode_time: Mapped[datetime | None] = mapped_column(DateTime) # When spool was encoded/written to tag
|
||||
|
|
@ -75,9 +74,7 @@ class Spool(Base):
|
|||
|
||||
k_profiles: Mapped[list["SpoolKProfile"]] = relationship(back_populates="spool", cascade="all, delete-orphan")
|
||||
assignments: Mapped[list["SpoolAssignment"]] = relationship(back_populates="spool", cascade="all, delete-orphan")
|
||||
location: Mapped["Location | None"] = relationship(back_populates="spools")
|
||||
|
||||
|
||||
from backend.app.models.location import Location # noqa: E402
|
||||
from backend.app.models.spool_assignment import SpoolAssignment # noqa: E402
|
||||
from backend.app.models.spool_k_profile import SpoolKProfile # noqa: E402
|
||||
|
|
|
|||
|
|
@ -44,14 +44,6 @@ class User(Base):
|
|||
cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
|
||||
# "global" or "china"; NULL treated as "global" for legacy rows.
|
||||
cloud_region: Mapped[str | None] = mapped_column(String(10), nullable=True, default=None)
|
||||
# Set when Bambu answers 401 to a call made with ``cloud_token`` — the token
|
||||
# has expired or been revoked. NULL means "not known to be dead". The token
|
||||
# itself is kept: clearing it would lose the email/region we show on the
|
||||
# re-login form, and a token can only be replaced by signing in again anyway.
|
||||
# Bambu's token is opaque and carries no expiry we can read, and Bambuddy
|
||||
# does not persist the refresh token, so this flag is the *only* record that
|
||||
# a stored credential has stopped working (#2562 follow-up).
|
||||
cloud_token_invalid_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
|
||||
|
||||
# Per-user Orca Cloud credentials. Unlike Bambu Cloud, Orca uses Supabase PKCE
|
||||
# with short-lived access tokens (1h) and rotating single-use refresh tokens,
|
||||
|
|
|
|||
|
|
@ -49,23 +49,6 @@ class VirtualPrinter(Base):
|
|||
) # queue mode: pin per-slot type+color from the 3MF onto the queue
|
||||
# item so the scheduler refuses to dispatch onto a printer with the wrong
|
||||
# filament loaded (#1188).
|
||||
save_ams_mapping: Mapped[bool] = mapped_column(
|
||||
Boolean, server_default="false"
|
||||
) # queue mode: keep the slicer's own live-resolved AMS-slot pick (the
|
||||
# `ams_mapping` field on the MQTT `project_file` command) instead of
|
||||
# re-deriving one from the file's static type/color. Stamps it on the queue
|
||||
# item so THIS print dispatches to those trays, and onto the archive's
|
||||
# `extra_data.slicer_ams_mapping` so a later reprint can reuse the same
|
||||
# physical spools. Off by default: taking the slicer's pick makes the
|
||||
# scheduler skip `_compute_ams_mapping_for_printer`, and with it
|
||||
# `prefer_lowest_filament`, its AMS-backup gate (#1766) and the
|
||||
# inventory-remain overrides — so it stays opt-in per virtual printer
|
||||
# rather than changing behaviour for upgraders (#2700).
|
||||
gcode_injection: Mapped[bool] = mapped_column(
|
||||
Boolean, server_default="false"
|
||||
) # queue mode: opt this VP's Send/Print jobs into per-model G-code snippet
|
||||
# injection (#1516). Default off so existing gcode_snippets users don't
|
||||
# silently start injecting; no-op when no snippets exist for the model.
|
||||
model: Mapped[str | None] = mapped_column(String(50), nullable=True) # SSDP model code (server mode)
|
||||
access_code: Mapped[str | None] = mapped_column(String(8), nullable=True) # 8 chars (server mode)
|
||||
target_printer_id: Mapped[int | None] = mapped_column(
|
||||
|
|
|
|||
|
|
@ -12,11 +12,6 @@ class APIKeyCreate(BaseModel):
|
|||
can_read_status: bool = True
|
||||
can_manage_library: bool = True # Upload / rename / delete own library files + MakerWorld import
|
||||
can_manage_inventory: bool = True # Inventory writes — SpoolBuddy NFC/scale/system, manual stock edits via API
|
||||
can_manage_maintenance: bool = (
|
||||
True # Log/reset maintenance items, edit intervals, manage type catalog (#1832 follow-up)
|
||||
)
|
||||
can_manage_archives: bool = True # Create/update/delete print archives — not purge (#1888)
|
||||
can_manage_projects: bool = True # Create/update/delete projects + membership (add archives) (#1893)
|
||||
can_access_cloud: bool = False # Read /cloud/* on the creator's behalf — default off (#1182)
|
||||
can_update_energy_cost: bool = False # POST /settings/electricity-price only (#1356)
|
||||
printer_ids: list[int] | None = None # null = all printers
|
||||
|
|
@ -32,9 +27,6 @@ class APIKeyUpdate(BaseModel):
|
|||
can_read_status: bool | None = None
|
||||
can_manage_library: bool | None = None
|
||||
can_manage_inventory: bool | None = None
|
||||
can_manage_maintenance: bool | None = None
|
||||
can_manage_archives: bool | None = None
|
||||
can_manage_projects: bool | None = None
|
||||
can_access_cloud: bool | None = None
|
||||
can_update_energy_cost: bool | None = None
|
||||
printer_ids: list[int] | None = None
|
||||
|
|
@ -54,9 +46,6 @@ class APIKeyResponse(BaseModel):
|
|||
can_read_status: bool
|
||||
can_manage_library: bool
|
||||
can_manage_inventory: bool
|
||||
can_manage_maintenance: bool
|
||||
can_manage_archives: bool
|
||||
can_manage_projects: bool
|
||||
can_access_cloud: bool
|
||||
can_update_energy_cost: bool
|
||||
printer_ids: list[int] | None
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ class ArchiveDuplicate(BaseModel):
|
|||
|
||||
id: int
|
||||
print_name: str | None
|
||||
created_at: datetime | None
|
||||
created_at: datetime
|
||||
match_type: str # "exact" (hash match) or "similar" (name match)
|
||||
|
||||
|
||||
|
|
@ -55,7 +55,6 @@ class ArchiveResponse(BaseModel):
|
|||
object_count: int | None = None
|
||||
|
||||
print_name: str | None
|
||||
plate_id: int | None = None # Selected plate of a multi-plate 3MF (#2603)
|
||||
print_time_seconds: int | None # Estimated time from slicer
|
||||
actual_time_seconds: int | None = None # Computed from started_at/completed_at
|
||||
# Percentage: 100 = perfect, >100 = faster than estimated
|
||||
|
|
@ -95,7 +94,7 @@ class ArchiveResponse(BaseModel):
|
|||
energy_kwh: float | None = None
|
||||
energy_cost: float | None = None
|
||||
|
||||
created_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
# User tracking (Issue #206)
|
||||
created_by_id: int | None = None
|
||||
|
|
@ -137,10 +136,8 @@ class ArchiveSlim(BaseModel):
|
|||
started_at: datetime | None
|
||||
completed_at: datetime | None
|
||||
cost: float | None
|
||||
energy_kwh: float | None = None
|
||||
energy_cost: float | None = None
|
||||
quantity: int = 1
|
||||
created_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
@ -222,3 +219,25 @@ class ProjectPageUpdate(BaseModel):
|
|||
copyright: str | None = None
|
||||
profile_title: str | None = None
|
||||
profile_description: str | None = None
|
||||
|
||||
|
||||
class ReprintRequest(BaseModel):
|
||||
"""Request body for reprinting an archive."""
|
||||
|
||||
# Plate selection for multi-plate 3MF files
|
||||
# If not specified, auto-detects from file (legacy behavior for single-plate files)
|
||||
plate_id: int | None = None
|
||||
plate_name: str | None = None
|
||||
|
||||
# AMS slot mapping: list of tray IDs for each filament slot in the 3MF
|
||||
# Global tray ID = (ams_id * 4) + slot_id, external = 254
|
||||
ams_mapping: list[int] | None = None
|
||||
|
||||
# Print options
|
||||
bed_levelling: bool = True
|
||||
flow_cali: bool = False
|
||||
vibration_cali: bool = True
|
||||
layer_inspect: bool = False
|
||||
timelapse: bool = False
|
||||
use_ams: bool = True # Not exposed in UI, but needed for API
|
||||
nozzle_offset_cali: bool = True # Dual-nozzle printers only — MQTT-gated (#1682)
|
||||
|
|
|
|||
|
|
@ -360,37 +360,28 @@ def _validate_icon_url(v: str | None) -> str | None:
|
|||
|
||||
|
||||
def _validate_issuer_url(v: str | None) -> str | None:
|
||||
"""Reject non-HTTPS issuer URLs and SSRF-unsafe hosts.
|
||||
"""Nit4: Reject non-HTTPS issuer URLs and private/loopback/link-local hosts.
|
||||
|
||||
An OIDC provider must be reachable over TLS on the public internet, so
|
||||
this uses the public-internet policy: private, loopback and link-local
|
||||
addresses are all rejected.
|
||||
|
||||
Delegates to the runtime guard ``assert_safe_public_https_url`` for the
|
||||
same reason ``_validate_icon_url`` does — no policy drift between the
|
||||
schema layer and the fetcher. The hand-rolled version this replaced
|
||||
checked only ``is_private | is_loopback | is_link_local``, which left
|
||||
numeric-encoded IPs (``https://2130706433/``), IPv4-mapped IPv6
|
||||
(``https://[::ffff:127.0.0.1]/``), multicast and unspecified addresses
|
||||
able to express a target the policy meant to forbid. The guard's
|
||||
docstring already claimed the two were consistent; now they are.
|
||||
|
||||
Lazy-imported because ``_oidc_helpers`` lives under ``api/routes/`` and
|
||||
schemas avoid top-level imports from that layer.
|
||||
HTTP is no longer accepted — OIDC providers must be reachable over TLS.
|
||||
Private-network and loopback addresses are rejected to prevent SSRF attacks
|
||||
where an admin-supplied URL could reach internal services.
|
||||
"""
|
||||
import ipaddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
if v is None:
|
||||
return v
|
||||
if not v.startswith("https://"):
|
||||
raise ValueError("issuer_url must start with https://")
|
||||
from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
|
||||
|
||||
host = urlparse(v).hostname or ""
|
||||
try:
|
||||
assert_safe_public_https_url(v)
|
||||
addr = ipaddress.ip_address(host)
|
||||
if addr.is_private or addr.is_loopback or addr.is_link_local:
|
||||
raise ValueError("issuer_url must not point to a private, loopback, or link-local address")
|
||||
except ValueError as exc:
|
||||
# The guard's messages say "icon URL" — rewrite for this field so the
|
||||
# user sees the setting they actually submitted.
|
||||
detail = str(exc).replace("icon URL", "issuer_url")
|
||||
raise ValueError(detail) from exc
|
||||
if "issuer_url" in str(exc):
|
||||
raise
|
||||
# hostname is a domain name, not a bare IP — that's fine
|
||||
return v
|
||||
|
||||
|
||||
|
|
@ -422,7 +413,6 @@ class OIDCProviderCreate(BaseModel):
|
|||
require_email_verified: bool = True
|
||||
icon_url: str | None = None
|
||||
default_group_id: int | None = None
|
||||
is_autologin: bool = False # #1589 — at most one provider may carry this
|
||||
|
||||
@field_validator("issuer_url")
|
||||
@classmethod
|
||||
|
|
@ -479,7 +469,6 @@ class OIDCProviderUpdate(BaseModel):
|
|||
require_email_verified: bool | None = None
|
||||
icon_url: str | None = None
|
||||
default_group_id: int | None = None
|
||||
is_autologin: bool | None = None # #1589
|
||||
|
||||
@field_validator("scopes")
|
||||
@classmethod
|
||||
|
|
@ -526,10 +515,6 @@ class OIDCProviderResponse(BaseModel):
|
|||
require_email_verified: bool = True
|
||||
icon_url: str | None = None
|
||||
default_group_id: int | None = None
|
||||
is_autologin: bool = False # #1589
|
||||
# #2593 — the UI renders this provider read-only; without the flag it would
|
||||
# offer editable fields whose writes the API then refuses with 409.
|
||||
is_env_managed: bool = False
|
||||
# Set explicitly in the route handler from `icon_content_type is not None`
|
||||
# rather than `@computed_field` (project policy) or `icon_data is not None`
|
||||
# (would trigger an async lazy-load on the deferred BLOB column).
|
||||
|
|
|
|||
|
|
@ -38,10 +38,6 @@ class CloudAuthStatus(BaseModel):
|
|||
is_authenticated: bool
|
||||
email: str | None = None
|
||||
region: Region | None = None
|
||||
# True when a token is stored but Bambu no longer accepts it. Both this and
|
||||
# "never signed in" render the login form, but only this one warrants
|
||||
# telling the user why it came back.
|
||||
sign_in_expired: bool = False
|
||||
|
||||
|
||||
class CloudTokenRequest(BaseModel):
|
||||
|
|
|
|||
|
|
@ -157,19 +157,6 @@ class GitHubBackupLogResponse(BaseModel):
|
|||
from_attributes = True
|
||||
|
||||
|
||||
class CloudAccountCounts(BaseModel):
|
||||
"""How many connected cloud accounts a backup would collect presets from.
|
||||
|
||||
Counts only, never identities: with auth enabled these are other users'
|
||||
accounts, and whoever administers the backup has no business learning who
|
||||
signed in to what. The number is enough to answer the only question the UI
|
||||
asks — is the Cloud Profiles category worth offering at all (#2717).
|
||||
"""
|
||||
|
||||
bambu: int = Field(default=0, description="Connected Bambu Cloud accounts")
|
||||
orca: int = Field(default=0, description="Connected Orca Cloud accounts")
|
||||
|
||||
|
||||
class GitHubBackupStatus(BaseModel):
|
||||
"""Schema for current backup status."""
|
||||
|
||||
|
|
|
|||
|
|
@ -50,12 +50,6 @@ class FolderResponse(BaseModel):
|
|||
external_readonly: bool = False
|
||||
external_show_hidden: bool = False
|
||||
file_count: int = 0 # Computed field
|
||||
# max(folder.updated_at, max(immediate-child file.updated_at)). Used by the
|
||||
# File Manager folder tree's "sort by recent activity" mode (#1770) so that
|
||||
# adding a file inside a folder bubbles it up — folder.updated_at alone only
|
||||
# tracks rename/move events. Recursion across subfolders is intentionally
|
||||
# left out to keep the route a single GROUP BY rather than a recursive CTE.
|
||||
latest_activity_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
|
@ -63,19 +57,6 @@ class FolderResponse(BaseModel):
|
|||
from_attributes = True
|
||||
|
||||
|
||||
class FolderReadmeResponse(BaseModel):
|
||||
"""Markdown sidebar payload for a folder (#1268).
|
||||
|
||||
``filename`` is the on-disk name (so the UI can show "README.md") and
|
||||
``content`` is the raw markdown — the FE renders it. ``truncated`` is
|
||||
True when the source file was clipped at the size cap.
|
||||
"""
|
||||
|
||||
filename: str
|
||||
content: str
|
||||
truncated: bool
|
||||
|
||||
|
||||
class FolderTreeItem(BaseModel):
|
||||
"""Schema for folder tree item (includes children)."""
|
||||
|
||||
|
|
@ -90,8 +71,6 @@ class FolderTreeItem(BaseModel):
|
|||
external_path: str | None = None
|
||||
external_readonly: bool = False
|
||||
file_count: int = 0
|
||||
# See FolderResponse.latest_activity_at — #1770 folder sort source.
|
||||
latest_activity_at: datetime | None = None
|
||||
children: list["FolderTreeItem"] = []
|
||||
|
||||
class Config:
|
||||
|
|
@ -179,16 +158,6 @@ class FileResponse(BaseModel):
|
|||
from_attributes = True
|
||||
|
||||
|
||||
class TagSummary(BaseModel):
|
||||
"""Compact tag projection — embedded in file listings (#1268)."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class FileListResponse(BaseModel):
|
||||
"""Schema for file list item (lighter than full response)."""
|
||||
|
||||
|
|
@ -205,10 +174,6 @@ class FileListResponse(BaseModel):
|
|||
created_by_id: int | None = None
|
||||
created_by_username: str | None = None
|
||||
created_at: datetime
|
||||
# Real on-disk modification time (#2680). Populated for external files from
|
||||
# their filesystem mtime; null for managed uploads. The file pane's date sort
|
||||
# and the "Modified" column use ``fs_modified_at ?? created_at``.
|
||||
fs_modified_at: datetime | None = None
|
||||
|
||||
# Key metadata fields for display
|
||||
print_name: str | None = None
|
||||
|
|
@ -216,65 +181,10 @@ class FileListResponse(BaseModel):
|
|||
filament_used_grams: float | None = None
|
||||
sliced_for_model: str | None = None
|
||||
|
||||
# Tags assigned to this file (#1268). Empty list when the file has none —
|
||||
# never null, so the FE can iterate without a guard.
|
||||
tags: list[TagSummary] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============ Tag Schemas (#1268) ============
|
||||
|
||||
|
||||
class TagResponse(BaseModel):
|
||||
"""Tag with the count of files currently using it."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
file_count: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TagCreate(BaseModel):
|
||||
"""Create a new tag (catalog row)."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=64)
|
||||
|
||||
|
||||
class TagUpdate(BaseModel):
|
||||
"""Rename a tag. ``name`` is required — there's nothing else to update."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=64)
|
||||
|
||||
|
||||
class TagBulkAssignRequest(BaseModel):
|
||||
"""Bulk tag assignment payload.
|
||||
|
||||
``action='add'`` → append tags to every listed file (idempotent on dup).
|
||||
``action='remove'`` → strip the listed tags from every listed file.
|
||||
``action='replace'`` → REPLACE the tag set on every listed file with the
|
||||
exact set in ``tag_ids`` (omitting tag_ids clears
|
||||
them all).
|
||||
"""
|
||||
|
||||
file_ids: list[int] = Field(..., min_length=1)
|
||||
tag_ids: list[int] = Field(default_factory=list)
|
||||
action: str = Field("add", pattern="^(add|remove|replace)$")
|
||||
|
||||
|
||||
class TagBulkAssignResponse(BaseModel):
|
||||
"""Result of a bulk-assign call."""
|
||||
|
||||
files_updated: int
|
||||
associations_added: int
|
||||
associations_removed: int
|
||||
|
||||
|
||||
class FileMoveRequest(BaseModel):
|
||||
"""Schema for moving files to a folder."""
|
||||
|
||||
|
|
@ -282,6 +192,33 @@ class FileMoveRequest(BaseModel):
|
|||
folder_id: int | None = None # None = move to root
|
||||
|
||||
|
||||
class FilePrintRequest(BaseModel):
|
||||
"""Schema for printing a file from the library.
|
||||
|
||||
Note: printer_id is passed as a query parameter, not in the body.
|
||||
"""
|
||||
|
||||
# Print options (same as archive reprint)
|
||||
plate_id: int | None = None
|
||||
plate_name: str | None = None
|
||||
ams_mapping: list[int] | None = None
|
||||
bed_levelling: bool = True
|
||||
flow_cali: bool = False
|
||||
vibration_cali: bool = True
|
||||
layer_inspect: bool = False
|
||||
timelapse: bool = False
|
||||
use_ams: bool = True
|
||||
nozzle_offset_cali: bool = True # Dual-nozzle printers only — MQTT-gated (#1682)
|
||||
# Project to associate the resulting archive with
|
||||
project_id: int | None = None
|
||||
# When true, delete the LibraryFile row + disk file after the archive has
|
||||
# been created and the print has been dispatched. Used by the Printers-page
|
||||
# Direct-Print flow (click / drag-drop a file onto a printer card) so the
|
||||
# transient upload doesn't linger in File Manager. Cleanup is skipped on
|
||||
# external library files.
|
||||
cleanup_library_after_dispatch: bool = False
|
||||
|
||||
|
||||
class FileUploadResponse(BaseModel):
|
||||
"""Schema for file upload response."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from backend.app.services.location_service import normalize_location_name
|
||||
|
||||
|
||||
class LocationCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
identifier: str | None = Field(default=None, max_length=100)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, v: str) -> str:
|
||||
return normalize_location_name(v)
|
||||
|
||||
|
||||
class LocationUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
identifier: str | None = Field(default=None, max_length=100)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
return normalize_location_name(v)
|
||||
|
||||
|
||||
class LocationResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
identifier: str | None = None
|
||||
spool_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
@ -109,7 +109,3 @@ class MakerWorldStatus(BaseModel):
|
|||
|
||||
has_cloud_token: bool = Field(description="Whether the caller's account has a stored Bambu Cloud token")
|
||||
can_download: bool = Field(description="Shortcut: has_cloud_token AND it looks valid. Downloads require it.")
|
||||
sign_in_expired: bool = Field(
|
||||
default=False,
|
||||
description="A token is stored but Bambu has rejected it — the user must sign in to Bambu Cloud again.",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ class ProviderType(StrEnum):
|
|||
DISCORD = "discord"
|
||||
WEBHOOK = "webhook"
|
||||
HOMEASSISTANT = "homeassistant"
|
||||
BARK = "bark"
|
||||
|
||||
|
||||
class NotificationProviderBase(BaseModel):
|
||||
|
|
@ -44,10 +43,6 @@ class NotificationProviderBase(BaseModel):
|
|||
# Event triggers - printer status
|
||||
on_printer_offline: bool = Field(default=False, description="Notify when printer goes offline")
|
||||
on_printer_error: bool = Field(default=False, description="Notify on printer errors (AMS, etc.)")
|
||||
on_ai_failure_detection: bool = Field(
|
||||
default=False,
|
||||
description="Notify when Obico AI detects a possible print failure (spaghetti)",
|
||||
)
|
||||
on_filament_low: bool = Field(default=False, description="Notify when filament is running low")
|
||||
on_maintenance_due: bool = Field(default=False, description="Notify when maintenance is due")
|
||||
|
||||
|
|
@ -63,9 +58,6 @@ class NotificationProviderBase(BaseModel):
|
|||
|
||||
# Event triggers - Build plate detection
|
||||
on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print")
|
||||
on_plate_clear_required: bool = Field(
|
||||
default=False, description="Notify when a finished print is waiting for plate-clear confirmation"
|
||||
)
|
||||
|
||||
# Event triggers - Bed cooled
|
||||
on_bed_cooled: bool = Field(default=False, description="Notify when bed cools after print")
|
||||
|
|
@ -136,7 +128,6 @@ class NotificationProviderUpdate(BaseModel):
|
|||
# Event triggers - printer status
|
||||
on_printer_offline: bool | None = None
|
||||
on_printer_error: bool | None = None
|
||||
on_ai_failure_detection: bool | None = None
|
||||
on_filament_low: bool | None = None
|
||||
on_maintenance_due: bool | None = None
|
||||
|
||||
|
|
@ -150,7 +141,6 @@ class NotificationProviderUpdate(BaseModel):
|
|||
|
||||
# Event triggers - Build plate detection
|
||||
on_plate_not_empty: bool | None = None
|
||||
on_plate_clear_required: bool | None = None
|
||||
|
||||
# Event triggers - Bed cooled
|
||||
on_bed_cooled: bool | None = None
|
||||
|
|
@ -238,10 +228,6 @@ class PushoverConfig(BaseModel):
|
|||
user_key: str = Field(..., description="Your Pushover user key")
|
||||
app_token: str = Field(..., description="Your Pushover application token")
|
||||
priority: int = Field(default=0, ge=-2, le=2, description="Message priority (-2 to 2)")
|
||||
# Emergency priority (2) only: how often to re-alert and when to stop.
|
||||
# Pushover requires retry >= 30s and expire <= 10800s (3h).
|
||||
retry: int = Field(default=60, ge=30, le=10800, description="Emergency re-alert interval in seconds (priority 2)")
|
||||
expire: int = Field(default=3600, ge=30, le=10800, description="Emergency alert expiry in seconds (priority 2)")
|
||||
|
||||
|
||||
class TelegramConfig(BaseModel):
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue