فهرست منبع

Add variant-group data model for cross-model queue alternatives (#671)

Adds file_variant_groups plus variant_group_id / variant_position on
library_files, so a set of files that are the same job sliced for
different printers can be resolved to whichever printer frees up first.

Backfills groups from the sliced_from_library_file_id provenance that
slice_and_persist and the pipeline runner have been writing into
file_metadata since they shipped, and which nothing has ever read.
Only sources with two or more children carrying distinct
sliced_for_model values are grouped: a single candidate is not a
choice, and two slices for the same printer give the resolver no basis
to prefer one.
maziggy 1 ماه پیش
والد
کامیت
da07c5884b

+ 110 - 0
backend/app/core/database.py

@@ -3949,6 +3949,116 @@ async def run_migrations(conn):
             conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT false"
             conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT false"
         )
         )
 
 
+    # Migration: variant grouping for library files (#671 / #2570). The
+    # `file_variant_groups` table itself needs no migration — create_all() above
+    # builds it — but the two member-side columns do. INTEGER and the inline
+    # REFERENCES clause are spelled identically on SQLite and Postgres, and
+    # SQLite accepts a REFERENCES on ADD COLUMN (same form as the
+    # pipeline_runs.parent_run_id migration at the top of this function).
+    await _safe_execute(
+        conn,
+        "ALTER TABLE library_files ADD COLUMN variant_group_id INTEGER "
+        "REFERENCES file_variant_groups(id) ON DELETE SET NULL",
+    )
+    await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN variant_position INTEGER DEFAULT 0")
+    # The model declares index=True, so fresh installs get this from create_all();
+    # migrated databases need it spelled out. Resolution looks members up by group
+    # on every scheduler pass that touches a grouped item.
+    await _safe_execute(
+        conn,
+        "CREATE INDEX IF NOT EXISTS ix_library_files_variant_group_id ON library_files (variant_group_id)",
+    )
+    await _migrate_backfill_variant_groups(conn)
+
+
+async def _migrate_backfill_variant_groups(conn) -> None:
+    """Build variant groups from the slice provenance already on disk (#671 / #2570).
+
+    ``sliced_from_library_file_id`` has been stamped into ``file_metadata`` by the
+    Slice button (routes/library.py) and the pipeline runner (routes/pipeline_runs.py)
+    since those features shipped, and until now nothing ever read it back — the
+    link existed but was inert. This promotes it to real group membership so an
+    existing library arrives with its slice sets already grouped instead of
+    requiring the user to re-declare by hand what Bambuddy itself recorded.
+
+    Only sources with **two or more** sliced children carrying **distinct**
+    ``sliced_for_model`` values produce a group:
+
+    - Fewer than two candidates is not a choice, and a one-member group would
+      change nothing at print time while creating a row per sliced file in every
+      library on earth.
+    - Two children sliced for the same printer are not alternatives — the
+      resolver has no basis to prefer one, so grouping them would turn a
+      harmless duplicate into an arbitrary pick. Those sources are skipped
+      whole; the user can still group them by hand and choose an order.
+
+    The unsliced source file is deliberately not a member. It has no
+    ``sliced_for_model``, so it can never be a dispatch candidate; showing it
+    alongside its variants is a File Manager listing concern, which is out of
+    scope.
+
+    Idempotent: only files with no group yet are considered, so a re-run after a
+    partial apply resumes rather than duplicating, and a user who has since
+    ungrouped files by hand does not get them silently regrouped.
+    """
+    from sqlalchemy import text
+
+    from backend.app.models.library import FileVariantGroup
+
+    if is_sqlite():
+        source_expr = "json_extract(file_metadata, '$.sliced_from_library_file_id')"
+        model_expr = "json_extract(file_metadata, '$.sliced_for_model')"
+    else:
+        # file_metadata is JSON, not JSONB — cast before using the -> operators,
+        # matching _migrate_drop_library_print_name above.
+        source_expr = "file_metadata::jsonb->>'sliced_from_library_file_id'"
+        model_expr = "file_metadata::jsonb->>'sliced_for_model'"
+
+    async with conn.begin_nested():
+        rows = (
+            await conn.execute(
+                text(
+                    f"SELECT id, {source_expr} AS source_id, {model_expr} AS model "  # noqa: S608 — dialect literals
+                    "FROM library_files "
+                    f"WHERE {source_expr} IS NOT NULL AND {model_expr} IS NOT NULL "
+                    "AND variant_group_id IS NULL AND deleted_at IS NULL "
+                    "ORDER BY id"
+                )
+            )
+        ).fetchall()
+
+        by_source: dict[str, list[tuple[int, str]]] = {}
+        for file_id, source_id, model in rows:
+            by_source.setdefault(str(source_id), []).append((file_id, str(model)))
+
+        for source_id, members in by_source.items():
+            if len(members) < 2:
+                continue
+            models = [m for _, m in members]
+            if len(set(models)) != len(models):
+                # Same printer sliced twice — ambiguous, leave it to the user.
+                continue
+
+            # Name the group after the source file when it is still around; its
+            # filename is what the user recognises. A deleted source leaves the
+            # variants perfectly usable, so fall back rather than skip.
+            name_row = (
+                await conn.execute(
+                    text("SELECT filename FROM library_files WHERE id = :sid"),
+                    {"sid": int(source_id)},
+                )
+            ).fetchone()
+            group_name = name_row[0] if name_row else f"{members[0][1]} + {len(members) - 1} more"
+
+            result = await conn.execute(FileVariantGroup.__table__.insert().values(name=group_name))
+            group_id = result.inserted_primary_key[0]
+
+            for position, (file_id, _model) in enumerate(members):
+                await conn.execute(
+                    text("UPDATE library_files SET variant_group_id = :gid, variant_position = :pos WHERE id = :fid"),
+                    {"gid": group_id, "pos": position, "fid": file_id},
+                )
+
 
 
 _USER_PRINT_TEMPLATE_RENAMES: tuple[tuple[str, str, str], ...] = (
 _USER_PRINT_TEMPLATE_RENAMES: tuple[tuple[str, str, str], ...] = (
     ("user_print_start", "User Print Started", "User Print Started Email"),
     ("user_print_start", "User Print Started", "User Print Started Email"),

+ 2 - 1
backend/app/models/__init__.py

@@ -8,7 +8,7 @@ from backend.app.models.filament import Filament
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.group import Group, user_groups
 from backend.app.models.group import Group, user_groups
 from backend.app.models.kprofile_note import KProfileNote
 from backend.app.models.kprofile_note import KProfileNote
-from backend.app.models.library import LibraryFile, LibraryFolder
+from backend.app.models.library import FileVariantGroup, LibraryFile, LibraryFolder
 from backend.app.models.local_preset import LocalPreset
 from backend.app.models.local_preset import LocalPreset
 from backend.app.models.location import Location
 from backend.app.models.location import Location
 from backend.app.models.long_lived_token import LongLivedToken
 from backend.app.models.long_lived_token import LongLivedToken
@@ -61,6 +61,7 @@ __all__ = [
     "PrintBatch",
     "PrintBatch",
     "LibraryFolder",
     "LibraryFolder",
     "LibraryFile",
     "LibraryFile",
+    "FileVariantGroup",
     "Location",
     "Location",
     "User",
     "User",
     "Group",
     "Group",

+ 47 - 0
backend/app/models/library.py

@@ -60,6 +60,41 @@ class LibraryFolder(Base):
     archive: Mapped["PrintArchive | None"] = relationship()
     archive: Mapped["PrintArchive | None"] = relationship()
 
 
 
 
+class FileVariantGroup(Base):
+    """A set of library files that are the same job sliced for different printers.
+
+    Members are peers, not a source/output hierarchy. The group answers one
+    question — "which of these files goes to an H2S, and which to an H2C" — and
+    both open features need that answer from opposite ends: the print queue
+    picks the printer and needs the matching file (#671), the File Manager's
+    print action has the printer already and needs the same match (#2570).
+
+    The group deliberately stores no model information of its own. Each
+    member's target model comes from its own ``file_metadata['sliced_for_model']``,
+    parsed out of the 3MF, so a group can never disagree with the files it
+    contains. It also carries no pointer to an unsliced source file: that is a
+    display concern for the grouped File Manager listing, which is not built.
+
+    Deleting a group ungroups its files rather than deleting them (the member
+    side is ON DELETE SET NULL) — every member is independently printable.
+    """
+
+    __tablename__ = "file_variant_groups"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(255))
+
+    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())
+    created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+
+    files: Mapped[list["LibraryFile"]] = relationship(
+        back_populates="variant_group",
+        order_by="LibraryFile.variant_position",
+    )
+    created_by: Mapped["User | None"] = relationship()
+
+
 class LibraryFile(Base):
 class LibraryFile(Base):
     """File stored in the library."""
     """File stored in the library."""
 
 
@@ -98,6 +133,17 @@ class LibraryFile(Base):
     source_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
     source_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
     source_url: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True)
     source_url: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True)
 
 
+    # Variant grouping (#671 / #2570). A file belongs to at most one group of
+    # "same job, sliced for a different printer" siblings. SET NULL on group
+    # delete: ungrouping must never take the files with it. ``variant_position``
+    # is the user's priority order within the group — when two printers are idle
+    # at the same scheduler tick, the lowest position wins, so the pick is
+    # reproducible instead of depending on which match the scheduler found first.
+    variant_group_id: Mapped[int | None] = mapped_column(
+        ForeignKey("file_variant_groups.id", ondelete="SET NULL"), nullable=True, index=True
+    )
+    variant_position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+
     # User tracking (Issue #206)
     # User tracking (Issue #206)
     created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
     created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
 
 
@@ -122,6 +168,7 @@ class LibraryFile(Base):
     folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
     folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
     project: Mapped["Project | None"] = relationship()
     project: Mapped["Project | None"] = relationship()
     created_by: Mapped["User | None"] = relationship()
     created_by: Mapped["User | None"] = relationship()
+    variant_group: Mapped["FileVariantGroup | None"] = relationship(back_populates="files")
     # Tags (#1268). M2M via library_file_tags. Loaded explicitly via
     # Tags (#1268). M2M via library_file_tags. Loaded explicitly via
     # ``selectinload`` in list_files so each row in the listing carries its
     # ``selectinload`` in list_files so each row in the listing carries its
     # chip set without N+1 fetches.
     # chip set without N+1 fetches.

+ 302 - 0
backend/tests/unit/test_variant_group_backfill_migration.py

@@ -0,0 +1,302 @@
+"""Tests for the variant-group backfill migration (#671 / #2570).
+
+`sliced_from_library_file_id` has been written into `library_files.file_metadata`
+by the Slice button and the pipeline runner since those features shipped, and
+nothing ever read it back. The migration promotes that inert provenance into
+real `file_variant_groups` membership so an existing library arrives with its
+slice sets already grouped.
+
+The interesting behaviour is all in what it refuses to group: a lone child, two
+children sliced for the same printer, files the user has already grouped by
+hand, and trashed rows.
+"""
+
+from __future__ import annotations
+
+import json
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import run_migrations
+
+
+@pytest.fixture(autouse=True)
+def force_sqlite_dialect(monkeypatch):
+    """Force the SQLite branch regardless of test env settings."""
+    from backend.app.core import db_dialect
+
+    monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+    monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+    from backend.app.core import database as database_module
+
+    monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+def _register_all_models():
+    from backend.app.models import (  # noqa: F401
+        ams_history,
+        ams_label,
+        api_key,
+        archive,
+        color_catalog,
+        external_link,
+        filament,
+        group,
+        kprofile_note,
+        library,
+        maintenance,
+        notification,
+        notification_template,
+        print_log,
+        print_queue,
+        printer,
+        project,
+        project_bom,
+        settings,
+        slot_preset,
+        smart_plug,
+        smart_plug_energy_snapshot,
+        spool,
+        spool_assignment,
+        spool_catalog,
+        spool_k_profile,
+        spool_usage_history,
+        spoolbuddy_device,
+        user,
+        user_email_pref,
+        virtual_printer,
+    )
+
+
+@pytest.fixture
+async def engine():
+    from backend.app.core.database import Base
+
+    _register_all_models()
+
+    eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with eng.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    yield eng
+    await eng.dispose()
+
+
+async def _insert_file(
+    conn,
+    *,
+    file_id: int,
+    filename: str,
+    metadata: dict | None = None,
+    deleted: bool = False,
+    variant_group_id: int | None = None,
+) -> None:
+    """Insert a minimal LibraryFile row; only the columns the migration reads."""
+    await conn.execute(
+        text(
+            "INSERT INTO library_files "
+            "(id, filename, file_path, file_type, file_size, is_external, print_count, "
+            " file_metadata, deleted_at, variant_group_id, variant_position) "
+            "VALUES (:id, :filename, :path, 'gcode.3mf', 0, 0, 0, :meta, :deleted, :gid, 0)"
+        ),
+        {
+            "id": file_id,
+            "filename": filename,
+            "path": f"/lib/{file_id}",
+            "meta": json.dumps(metadata) if metadata is not None else None,
+            "deleted": "2026-01-01 00:00:00" if deleted else None,
+            "gid": variant_group_id,
+        },
+    )
+
+
+def _variant(source_id: int, model: str) -> dict:
+    return {"sliced_from_library_file_id": source_id, "sliced_for_model": model}
+
+
+async def _members(conn) -> dict[int, tuple[int | None, int]]:
+    rows = (
+        await conn.execute(text("SELECT id, variant_group_id, variant_position FROM library_files ORDER BY id"))
+    ).fetchall()
+    return {r[0]: (r[1], r[2]) for r in rows}
+
+
+async def _group_count(conn) -> int:
+    return (await conn.execute(text("SELECT COUNT(*) FROM file_variant_groups"))).scalar()
+
+
+@pytest.mark.asyncio
+async def test_groups_two_variants_of_the_same_source(engine):
+    """The whole point: an H2S slice and an H2C slice of one model become a group."""
+    async with engine.begin() as conn:
+        await _insert_file(conn, file_id=1, filename="bracket.3mf")
+        await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
+        await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"))
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        assert await _group_count(conn) == 1
+        members = await _members(conn)
+        gid = members[2][0]
+        assert gid is not None
+        assert members[3][0] == gid, "both slices land in the same group"
+        assert members[1][0] is None, "the unsliced source is not a dispatch candidate"
+        assert (members[2][1], members[3][1]) == (0, 1), "position follows id order, deterministically"
+
+        name = (await conn.execute(text("SELECT name FROM file_variant_groups"))).scalar()
+        assert name == "bracket.3mf", "the group is named after the source the user recognises"
+
+
+@pytest.mark.asyncio
+async def test_single_variant_produces_no_group(engine):
+    """One candidate is not a choice — grouping it would add a row per sliced
+    file in every library while changing nothing at print time."""
+    async with engine.begin() as conn:
+        await _insert_file(conn, file_id=1, filename="bracket.3mf")
+        await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        assert await _group_count(conn) == 0
+        assert (await _members(conn))[2][0] is None
+
+
+@pytest.mark.asyncio
+async def test_duplicate_model_is_skipped_whole(engine):
+    """Two slices for the same printer are not alternatives — the resolver would
+    have no basis to prefer one, so the source is left entirely alone."""
+    async with engine.begin() as conn:
+        await _insert_file(conn, file_id=1, filename="bracket.3mf")
+        await _insert_file(conn, file_id=2, filename="bracket_draft.gcode.3mf", metadata=_variant(1, "H2S"))
+        await _insert_file(conn, file_id=3, filename="bracket_fine.gcode.3mf", metadata=_variant(1, "H2S"))
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        assert await _group_count(conn) == 0
+        members = await _members(conn)
+        assert members[2][0] is None and members[3][0] is None
+
+
+@pytest.mark.asyncio
+async def test_variant_without_model_is_not_a_candidate(engine):
+    """A child with no `sliced_for_model` can never be matched to a printer, so
+    it does not count towards the two-candidate threshold."""
+    async with engine.begin() as conn:
+        await _insert_file(conn, file_id=1, filename="bracket.3mf")
+        await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
+        await _insert_file(
+            conn,
+            file_id=3,
+            filename="bracket_unknown.gcode.3mf",
+            metadata={"sliced_from_library_file_id": 1},
+        )
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        assert await _group_count(conn) == 0
+
+
+@pytest.mark.asyncio
+async def test_trashed_variants_are_excluded(engine):
+    """A soft-deleted file is not printable, so it must not make up the second
+    candidate that tips a source into being grouped."""
+    async with engine.begin() as conn:
+        await _insert_file(conn, file_id=1, filename="bracket.3mf")
+        await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
+        await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"), deleted=True)
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        assert await _group_count(conn) == 0
+
+
+@pytest.mark.asyncio
+async def test_missing_source_still_groups_with_fallback_name(engine):
+    """Deleting the source model does not make its slices any less usable
+    together, so the group is still built — just named differently."""
+    async with engine.begin() as conn:
+        await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(99, "H2S"))
+        await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(99, "H2C"))
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        assert await _group_count(conn) == 1
+        name = (await conn.execute(text("SELECT name FROM file_variant_groups"))).scalar()
+        assert name == "H2S + 1 more"
+
+
+@pytest.mark.asyncio
+async def test_separate_sources_get_separate_groups(engine):
+    async with engine.begin() as conn:
+        await _insert_file(conn, file_id=1, filename="bracket.3mf")
+        await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
+        await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"))
+        await _insert_file(conn, file_id=4, filename="clip.3mf")
+        await _insert_file(conn, file_id=5, filename="clip_h2s.gcode.3mf", metadata=_variant(4, "H2S"))
+        await _insert_file(conn, file_id=6, filename="clip_h2c.gcode.3mf", metadata=_variant(4, "H2C"))
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        assert await _group_count(conn) == 2
+        members = await _members(conn)
+        assert members[2][0] == members[3][0]
+        assert members[5][0] == members[6][0]
+        assert members[2][0] != members[5][0]
+
+
+@pytest.mark.asyncio
+async def test_backfill_is_idempotent(engine):
+    """Every boot re-runs the migration set; the second pass must not clone the
+    group or renumber its members."""
+    async with engine.begin() as conn:
+        await _insert_file(conn, file_id=1, filename="bracket.3mf")
+        await _insert_file(conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"))
+        await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"))
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+    async with engine.connect() as conn:
+        first = await _members(conn)
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+    async with engine.connect() as conn:
+        assert await _group_count(conn) == 1
+        assert await _members(conn) == first
+
+
+@pytest.mark.asyncio
+async def test_hand_grouped_files_are_left_alone(engine):
+    """A user who has already grouped (or deliberately ungrouped) files owns that
+    decision — the backfill only ever considers files with no group yet."""
+    async with engine.begin() as conn:
+        await conn.execute(text("INSERT INTO file_variant_groups (id, name) VALUES (7, 'my own grouping')"))
+        await _insert_file(conn, file_id=1, filename="bracket.3mf")
+        await _insert_file(
+            conn, file_id=2, filename="bracket_h2s.gcode.3mf", metadata=_variant(1, "H2S"), variant_group_id=7
+        )
+        await _insert_file(conn, file_id=3, filename="bracket_h2c.gcode.3mf", metadata=_variant(1, "H2C"))
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        assert await _group_count(conn) == 1, "no second group is invented"
+        members = await _members(conn)
+        assert members[2][0] == 7, "the user's grouping survives"
+        assert members[3][0] is None, "and the leftover sibling is not force-joined to it"

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 0
static/assets/index-3tpP1N3E.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-DDecIyHp.js


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است