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

View file

@ -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.

View file

@ -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())

View file

@ -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:

View file

@ -31,7 +31,7 @@ jobs:
strategy:
fail-fast: false
matrix:
package: [bambuddy]
package: [bambuddy, bambuddy-beta]
steps:
- name: Cleanup ${{ matrix.package }}
env:

View file

@ -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

View file

@ -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)');

View file

@ -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
View file

@ -90,8 +90,3 @@ advertisements/
gitleaks-report.json
scripts/pip-audit.sh
security/
test_pipeline_archive_source.3mf
test_pipeline_run_1.3mf

View file

@ -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

View file

@ -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

File diff suppressed because one or more lines are too long

View file

@ -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

View file

@ -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"]

View file

@ -6,28 +6,23 @@
<p align="center">
<strong>Your printers. No cloud. Your rules.</strong><br>
Self-hosted command center for Bambu Lab &mdash; from one A1 to an entire print farm.
Self-hosted command center for Bambu Lab &mdash; 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 (0100% 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 |
---

View file

@ -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")

View file

@ -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": [],
}

View file

@ -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")

View file

@ -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

View file

@ -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:

View 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"],
}

View file

@ -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)

View file

@ -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

View file

@ -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()

View file

@ -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 {

View file

@ -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),
):

View file

@ -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()

View file

@ -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)

View file

@ -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

View file

@ -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,
)

View file

@ -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),

View file

@ -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(

View file

@ -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(

View file

@ -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

View file

@ -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}")

View file

@ -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}

View file

@ -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))

View file

@ -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

View file

@ -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)

View file

@ -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}'")

View file

@ -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

View file

@ -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,

View file

@ -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)

View file

@ -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,

View file

@ -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()

View file

@ -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``.

View file

@ -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

View file

@ -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

View file

@ -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")}

View file

@ -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]

View file

@ -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)

View file

@ -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(),
}

View file

@ -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

View file

@ -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),
):

View file

@ -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:

View file

@ -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)),
}
)

View file

@ -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,

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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):

View file

@ -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)

View file

@ -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,

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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")

View file

@ -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

View file

@ -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")

View file

@ -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

View file

@ -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}",
},

View file

@ -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:

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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())

View file

@ -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

View file

@ -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())

View file

@ -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

View file

@ -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,

View file

@ -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(

View file

@ -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

View file

@ -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)

View file

@ -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).

View file

@ -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):

View file

@ -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."""

View file

@ -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."""

View file

@ -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

View file

@ -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.",
)

View file

@ -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