Browse Source

Let jobs wait for staff review before they print (#1620)

maziggy 5 days ago
parent
commit
24509b17e0
38 changed files with 747 additions and 17 deletions
  1. 0 0
      CHANGELOG.md
  2. 2 0
      backend/app/api/routes/groups.py
  3. 4 0
      backend/app/api/routes/library.py
  4. 8 1
      backend/app/api/routes/pipeline_runs.py
  5. 38 1
      backend/app/api/routes/print_queue.py
  6. 17 0
      backend/app/api/routes/webhook.py
  7. 62 0
      backend/app/core/auth.py
  8. 27 0
      backend/app/core/database.py
  9. 6 0
      backend/app/core/permissions.py
  10. 368 0
      backend/tests/integration/test_queue_review_1620.py
  11. 86 1
      frontend/src/__tests__/components/PrintModal.test.tsx
  12. 39 1
      frontend/src/__tests__/pages/QueuePage.test.tsx
  13. 1 1
      frontend/src/api/client.ts
  14. 10 1
      frontend/src/components/PrintModal/ScheduleOptions.tsx
  15. 18 5
      frontend/src/components/PrintModal/index.tsx
  16. 2 0
      frontend/src/components/PrintModal/types.ts
  17. 3 0
      frontend/src/i18n/locales/de.ts
  18. 3 0
      frontend/src/i18n/locales/en.ts
  19. 3 0
      frontend/src/i18n/locales/es.ts
  20. 3 0
      frontend/src/i18n/locales/fr.ts
  21. 3 0
      frontend/src/i18n/locales/it.ts
  22. 3 0
      frontend/src/i18n/locales/ja.ts
  23. 3 0
      frontend/src/i18n/locales/ko.ts
  24. 3 0
      frontend/src/i18n/locales/nl.ts
  25. 3 0
      frontend/src/i18n/locales/pt-BR.ts
  26. 3 0
      frontend/src/i18n/locales/ru.ts
  27. 3 0
      frontend/src/i18n/locales/sv.ts
  28. 3 0
      frontend/src/i18n/locales/tr.ts
  29. 3 0
      frontend/src/i18n/locales/uk.ts
  30. 3 0
      frontend/src/i18n/locales/zh-CN.ts
  31. 3 0
      frontend/src/i18n/locales/zh-TW.ts
  32. 12 3
      frontend/src/pages/QueuePage.tsx
  33. 0 0
      static/assets/ImagePreviewModal-BLQUNr6I.js
  34. 0 1
      static/assets/PdfPreviewModal-D8qp0De6.js
  35. 0 0
      static/assets/SpreadsheetPreviewModal-CG37FAae.js
  36. 1 1
      static/assets/index-B48HgPjM.js
  37. 0 0
      static/assets/pdf-uoJsYYEu.js
  38. 1 1
      static/index.html

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 2 - 0
backend/app/api/routes/groups.py

@@ -37,6 +37,8 @@ router = APIRouter(prefix="/groups", tags=["groups"])
 # box in the group editor has nothing else to go on (#1894).
 # box in the group editor has nothing else to go on (#1894).
 _PERMISSION_LABEL_OVERRIDES: dict[Permission, str] = {
 _PERMISSION_LABEL_OVERRIDES: dict[Permission, str] = {
     Permission.USERS_READ_SLIM: "List User Names (id + username only)",
     Permission.USERS_READ_SLIM: "List User Names (id + username only)",
+    # "Start Unreviewed Queue" says nothing about what happens without it (#1620)
+    Permission.QUEUE_START_UNREVIEWED: "Print Without Review (else jobs wait for someone to start them)",
 }
 }
 
 
 
 

+ 4 - 0
backend/app/api/routes/library.py

@@ -25,6 +25,7 @@ from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
 from backend.app.api.routes.library_variants import normalize_model_name, resolve_variant_model
 from backend.app.api.routes.library_variants import normalize_model_name, resolve_variant_model
 from backend.app.api.routes.print_queue import _extract_filament_types_from_3mf
 from backend.app.api.routes.print_queue import _extract_filament_types_from_3mf
 from backend.app.core.auth import (
 from backend.app.core.auth import (
+    QueueReviewRequired,
     RequestPrinterScope,
     RequestPrinterScope,
     require_media_token_ownership,
     require_media_token_ownership,
     require_ownership_permission,
     require_ownership_permission,
@@ -3012,6 +3013,7 @@ async def add_files_to_queue(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.QUEUE_CREATE)),
     current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.QUEUE_CREATE)),
     printer_scope: PrinterScope = RequestPrinterScope,
     printer_scope: PrinterScope = RequestPrinterScope,
+    review_required: bool = QueueReviewRequired,
 ):
 ):
     """Add library files to the print queue.
     """Add library files to the print queue.
 
 
@@ -3193,6 +3195,8 @@ async def add_files_to_queue(
                 # on `created_by_id` — so the user who queued the file could not
                 # on `created_by_id` — so the user who queued the file could not
                 # see it in their own queue.
                 # see it in their own queue.
                 created_by_id=current_user.id if current_user else None,
                 created_by_id=current_user.id if current_user else None,
+                # Waits for someone to start it unless they may print without review (#1620)
+                manual_start=review_required,
             )
             )
             db.add(queue_item)
             db.add(queue_item)
 
 

+ 8 - 1
backend/app/api/routes/pipeline_runs.py

@@ -34,7 +34,7 @@ from sqlalchemy import delete, desc, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
 from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
-from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled
+from backend.app.core.auth import QueueReviewRequired, RequestPrinterScope, RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import async_session, get_db
 from backend.app.core.database import async_session, get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
@@ -477,6 +477,7 @@ def _make_orchestration_callable(
     creator_user_id: int | None,
     creator_user_id: int | None,
     copies: int,
     copies: int,
     printer_scope: PrinterScope = ALL_PRINTERS,
     printer_scope: PrinterScope = ALL_PRINTERS,
+    review_required: bool = False,
 ):
 ):
     """Returns the async callable that ``slice_dispatch.enqueue`` runs as the
     """Returns the async callable that ``slice_dispatch.enqueue`` runs as the
     background slice job. Wraps slice + multi-copy enqueue + state update."""
     background slice job. Wraps slice + multi-copy enqueue + state update."""
@@ -589,6 +590,8 @@ def _make_orchestration_callable(
                     created_by_id=creator_user_id,
                     created_by_id=creator_user_id,
                     status="pending",
                     status="pending",
                     confirm_outcome=confirm_outcome,
                     confirm_outcome=confirm_outcome,
+                    # The copies wait for review like any other job of theirs (#1620)
+                    manual_start=review_required,
                 )
                 )
                 session.add(queue_item)
                 session.add(queue_item)
                 await session.flush()
                 await session.flush()
@@ -680,6 +683,7 @@ async def run_pipeline(
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     printer_scope: PrinterScope = RequestPrinterScope,
     printer_scope: PrinterScope = RequestPrinterScope,
+    review_required: bool = QueueReviewRequired,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     from backend.app.api.routes.settings import get_setting
     from backend.app.api.routes.settings import get_setting
@@ -779,6 +783,7 @@ async def run_pipeline(
         creator_user_id=creator.id if creator else None,
         creator_user_id=creator.id if creator else None,
         copies=body.copies,
         copies=body.copies,
         printer_scope=printer_scope,
         printer_scope=printer_scope,
+        review_required=review_required,
     )
     )
     slice_job = await slice_dispatch.enqueue(
     slice_job = await slice_dispatch.enqueue(
         kind="library_file" if src_kind == "library_file" else "archive",
         kind="library_file" if src_kind == "library_file" else "archive",
@@ -986,6 +991,7 @@ async def retry_failed(
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
     printer_scope: PrinterScope = RequestPrinterScope,
     printer_scope: PrinterScope = RequestPrinterScope,
+    review_required: bool = QueueReviewRequired,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Create a new run with copies = (failed + cancelled count) from the
     """Create a new run with copies = (failed + cancelled count) from the
@@ -1034,6 +1040,7 @@ async def retry_failed(
         current_user=current_user,
         current_user=current_user,
         api_key_cloud_owner=api_key_cloud_owner,
         api_key_cloud_owner=api_key_cloud_owner,
         printer_scope=printer_scope,
         printer_scope=printer_scope,
+        review_required=review_required,
         db=db,
         db=db,
     )
     )
 
 

+ 38 - 1
backend/app/api/routes/print_queue.py

@@ -15,9 +15,11 @@ from sqlalchemy.orm import selectinload
 
 
 from backend.app.api.routes.library_variants import normalize_model_name, resolve_variant_model
 from backend.app.api.routes.library_variants import normalize_model_name, resolve_variant_model
 from backend.app.core.auth import (
 from backend.app.core.auth import (
+    QueueReviewRequired,
     RequestPrinterScope,
     RequestPrinterScope,
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     RequirePrinterPermissionIfAuthEnabled,
     RequirePrinterPermissionIfAuthEnabled,
+    may_start_queue_item,
     require_ownership_permission,
     require_ownership_permission,
 )
 )
 from backend.app.core.config import settings
 from backend.app.core.config import settings
@@ -73,6 +75,9 @@ logger = logging.getLogger(__name__)
 
 
 router = APIRouter(prefix="/queue", tags=["queue"])
 router = APIRouter(prefix="/queue", tags=["queue"])
 
 
+# Answer to a caller who may not start a job that waits for review (#1620)
+_AWAITING_REVIEW = "This job waits for review: someone who can manage all queue jobs has to start it"
+
 
 
 def _variant_summaries(item: PrintQueueItem) -> list[QueueVariantSummary]:
 def _variant_summaries(item: PrintQueueItem) -> list[QueueVariantSummary]:
     """Cross-model candidates for display (#671), or [] if they weren't loaded.
     """Cross-model candidates for display (#671), or [] if they weren't loaded.
@@ -821,6 +826,7 @@ async def add_to_queue(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
     printer_scope: PrinterScope = RequestPrinterScope,
     printer_scope: PrinterScope = RequestPrinterScope,
+    review_required: bool = QueueReviewRequired,
 ):
 ):
     """Add an item to the print queue."""
     """Add an item to the print queue."""
     # Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E").
     # Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E").
@@ -1167,7 +1173,9 @@ async def add_to_queue(
             scheduled_time=data.scheduled_time,
             scheduled_time=data.scheduled_time,
             require_previous_success=data.require_previous_success,
             require_previous_success=data.require_previous_success,
             auto_off_after=data.auto_off_after,
             auto_off_after=data.auto_off_after,
-            manual_start=data.manual_start,
+            # Without queue:start_unreviewed the job waits for someone to
+            # start it, whatever the request asked for (#1620)
+            manual_start=data.manual_start or review_required,
             skip_filament_check=data.skip_filament_check,
             skip_filament_check=data.skip_filament_check,
             ams_mapping=ams_mapping_json,
             ams_mapping=ams_mapping_json,
             nozzle_rack_choice=nozzle_rack_choice_json,
             nozzle_rack_choice=nozzle_rack_choice_json,
@@ -1318,6 +1326,17 @@ async def bulk_update_queue_items(
             skipped_count += 1
             skipped_count += 1
             continue
             continue
 
 
+        # Clearing "wait for manual start" starts the job, which needs the
+        # same right as the start button (#1620)
+        if (
+            item.manual_start
+            and "manual_start" in update_data
+            and not update_data["manual_start"]
+            and not may_start_queue_item(user, can_modify_all, item.created_by_id)
+        ):
+            skipped_count += 1
+            continue
+
         item_update_data = update_data.copy()
         item_update_data = update_data.copy()
         if validates_billing_fields:
         if validates_billing_fields:
             trusted_estimated_cost = await _trusted_item_estimated_cost(
             trusted_estimated_cost = await _trusted_item_estimated_cost(
@@ -1612,6 +1631,7 @@ async def dispatch_batch(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
     printer_scope: PrinterScope = RequestPrinterScope,
     printer_scope: PrinterScope = RequestPrinterScope,
+    review_required: bool = QueueReviewRequired,
 ):
 ):
     """Queue the runs this order still owes (#342).
     """Queue the runs this order still owes (#342).
 
 
@@ -1648,6 +1668,11 @@ async def dispatch_batch(
     except HTTPException:
     except HTTPException:
         await db.rollback()
         await db.rollback()
         raise
         raise
+    # A clone copies its template's "wait for manual start", which is off once
+    # staff started the template; the dispatcher's own jobs still wait (#1620)
+    if review_required:
+        for item in created:
+            item.manual_start = True
 
 
     await db.commit()
     await db.commit()
     await db.refresh(batch)
     await db.refresh(batch)
@@ -1984,6 +2009,16 @@ async def update_queue_item(
 
 
     update_data = data.model_dump(exclude_unset=True)
     update_data = data.model_dump(exclude_unset=True)
 
 
+    # Clearing "wait for manual start" starts the job, which needs the same
+    # right as the start button (#1620)
+    if (
+        item.manual_start
+        and "manual_start" in update_data
+        and not update_data["manual_start"]
+        and not may_start_queue_item(user, can_modify_all, item.created_by_id)
+    ):
+        raise HTTPException(403, _AWAITING_REVIEW)
+
     # Normalize target_model if being updated (see add_to_queue for why the
     # Normalize target_model if being updated (see add_to_queue for why the
     # code map has to run first).
     # code map has to run first).
     if "target_model" in update_data and update_data["target_model"]:
     if "target_model" in update_data and update_data["target_model"]:
@@ -2467,6 +2502,8 @@ async def start_queue_item(
     if not can_modify_all and user is not None:
     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:
         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")
             raise HTTPException(403, "You can only start your own queue items")
+    if not may_start_queue_item(user, can_modify_all, item.created_by_id):
+        raise HTTPException(403, _AWAITING_REVIEW)
 
 
     if item.status != "pending":
     if item.status != "pending":
         raise HTTPException(400, f"Can only start pending items, current status: '{item.status}'")
         raise HTTPException(400, f"Can only start pending items, current status: '{item.status}'")

+ 17 - 0
backend/app/api/routes/webhook.py

@@ -10,6 +10,9 @@ from backend.app.core.auth import (
     check_webhook_permission,
     check_webhook_permission,
     ensure_api_key_printer_access,
     ensure_api_key_printer_access,
     get_api_key,
     get_api_key,
+    is_auth_enabled,
+    queue_review_required_for,
+    resolve_apikey_owner,
 )
 )
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
@@ -150,6 +153,8 @@ async def webhook_add_to_queue(
         # for the person whose key it is. Legacy keys predating per-user ownership
         # for the person whose key it is. Legacy keys predating per-user ownership
         # have no `user_id`, and those rows stay ownerless.
         # have no `user_id`, and those rows stay ownerless.
         created_by_id=api_key.user_id,
         created_by_id=api_key.user_id,
+        # Waits for someone to start it unless the owner may print without review (#1620)
+        manual_start=await is_auth_enabled(db) and queue_review_required_for(await resolve_apikey_owner(db, api_key)),
     )
     )
     db.add(queue_item)
     db.add(queue_item)
     await db.flush()
     await db.flush()
@@ -207,6 +212,18 @@ async def webhook_start_print(
     if not queue_item:
     if not queue_item:
         raise HTTPException(status_code=404, detail="No pending prints in queue")
         raise HTTPException(status_code=404, detail="No pending prints in queue")
 
 
+    # Starting a waiting job is a review decision (#1620): a key whose owner
+    # needs review for their own jobs can't make one for anybody's
+    if (
+        queue_item.manual_start
+        and await is_auth_enabled(db)
+        and queue_review_required_for(await resolve_apikey_owner(db, api_key))
+    ):
+        raise HTTPException(
+            status_code=403,
+            detail="The next job waits for review: someone who can manage all queue jobs has to start it",
+        )
+
     # Clear manual_start so the scheduler will dispatch. If the item was
     # Clear manual_start so the scheduler will dispatch. If the item was
     # already auto-dispatchable this is a no-op; the scheduler will still
     # already auto-dispatchable this is a no-op; the scheduler will still
     # pick it up on its next tick.
     # pick it up on its next tick.

+ 62 - 0
backend/app/core/auth.py

@@ -141,6 +141,7 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str | tuple[str, ...]] = {
     Permission.QUEUE_DELETE_OWN: "can_queue",
     Permission.QUEUE_DELETE_OWN: "can_queue",
     Permission.QUEUE_DELETE_ALL: "can_queue",
     Permission.QUEUE_DELETE_ALL: "can_queue",
     Permission.QUEUE_REORDER: "can_queue",
     Permission.QUEUE_REORDER: "can_queue",
+    Permission.QUEUE_START_UNREVIEWED: "can_queue",
     Permission.ARCHIVES_REPRINT_OWN: "can_queue",
     Permission.ARCHIVES_REPRINT_OWN: "can_queue",
     Permission.ARCHIVES_REPRINT_ALL: "can_queue",
     Permission.ARCHIVES_REPRINT_ALL: "can_queue",
     # can_control_printer — physical-world side effects on hardware
     # can_control_printer — physical-world side effects on hardware
@@ -1948,6 +1949,67 @@ async def get_printer_scope_if_auth_enabled(
 RequestPrinterScope = Depends(get_printer_scope_if_auth_enabled)
 RequestPrinterScope = Depends(get_printer_scope_if_auth_enabled)
 
 
 
 
+def queue_review_required_for(user: User | None) -> bool:
+    """Whether jobs ``user`` queues must wait for someone to start them (#1620).
+
+    None is auth-off or a legacy ownerless API key, neither of which has a
+    reviewer above it. Whoever may start every job (queue:update_all) is the
+    reviewer, so their own jobs don't wait either.
+    """
+    return (
+        user is not None
+        and not user.has_permission(Permission.QUEUE_START_UNREVIEWED.value)
+        and not user.has_permission(Permission.QUEUE_UPDATE_ALL.value)
+    )
+
+
+def may_start_queue_item(user: User | None, can_modify_all: bool, created_by_id: int | None) -> bool:
+    """Whether the caller may start a waiting queue item (#1620).
+
+    ``can_modify_all`` is what ``require_ownership_permission`` answered for
+    queue:update_all; an API key only gets it when its owner holds that. Anyone
+    else needs to be allowed to print without review, and the item must be
+    theirs or have no owner yet (a virtual-printer upload, claimed by starting
+    it, #1670).
+    """
+    if can_modify_all or user is None:
+        return True
+    if queue_review_required_for(user):
+        return False
+    return created_by_id is None or created_by_id == user.id
+
+
+async def get_queue_review_required(
+    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+    x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+) -> bool:
+    """FastAPI dependency: must the caller's new queue items wait for review (#1620)?
+
+    Permission dependencies answer API-key requests with no user, so the key's
+    owner decides here. Declare it after the permission dependency. With auth
+    on and no usable principal it answers True.
+    """
+    async with async_session() as db:
+        if not await is_auth_enabled(db):
+            return False
+        api_key = await validated_api_key_from_request(credentials, x_api_key)
+        if api_key is not None:
+            return queue_review_required_for(await resolve_apikey_owner(db, api_key))
+        if credentials is None:
+            return True
+        cached = _authenticated_user.get()
+        if cached is not None and cached[0] == credentials.credentials:
+            user = cached[1]
+        else:
+            user = await get_current_user_optional(credentials)
+            if user is None:
+                return True
+        return queue_review_required_for(user)
+
+
+QueueReviewRequired = Depends(get_queue_review_required)
+
+
 async def get_media_or_request_printer_scope(
 async def get_media_or_request_printer_scope(
     token: str | None = None,
     token: str | None = None,
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,

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

@@ -5876,6 +5876,13 @@ async def seed_notification_templates():
         await session.commit()
         await session.commit()
 
 
 
 
+# Groups holding any of these before #1620 could queue, start or run jobs that
+# went ahead on their own, so the upgrade lets them keep doing that.
+_QUEUE_REVIEW_BACKFILL_FROM = frozenset(
+    {"queue:create", "queue:update_own", "queue:update_all", "printers:control", "pipelines:run"}
+)
+
+
 async def seed_default_groups():
 async def seed_default_groups():
     """Seed default groups and migrate existing users to appropriate groups.
     """Seed default groups and migrate existing users to appropriate groups.
 
 
@@ -6173,6 +6180,26 @@ async def seed_default_groups():
                 group.permissions = perms
                 group.permissions = perms
         await session.commit()
         await session.commit()
 
 
+        # queue:start_unreviewed (#1620): jobs of users without it wait for
+        # someone to start them. Granted once to every group that could queue,
+        # start or run jobs before it existed, so nothing changes on upgrade. Once
+        # only, unlike the backfills above: an admin removing it from a group is
+        # the whole point, and a per-boot backfill would hand it straight back.
+        from backend.app.models.settings import Settings
+
+        review_flag = "_backfill_1620_queue_start_unreviewed_done"
+        if (await session.execute(select(Settings).where(Settings.key == review_flag))).scalar_one_or_none() is None:
+            result = await session.execute(select(Group))
+            for group in result.scalars().all():
+                perms = list(group.permissions or [])
+                if "queue:start_unreviewed" in perms:
+                    continue
+                if _QUEUE_REVIEW_BACKFILL_FROM.intersection(perms):
+                    group.permissions = [*perms, "queue:start_unreviewed"]
+                    logger.info("Added queue:start_unreviewed to group '%s' (#1620)", group.name)
+            session.add(Settings(key=review_flag, value="true"))
+            await session.commit()
+
         # Migrate existing users to groups if they're not already in any group
         # Migrate existing users to groups if they're not already in any group
         if groups_created:
         if groups_created:
             # Refresh to get newly created groups
             # Refresh to get newly created groups

+ 6 - 0
backend/app/core/permissions.py

@@ -50,6 +50,10 @@ class Permission(StrEnum):
     QUEUE_DELETE_OWN = "queue:delete_own"
     QUEUE_DELETE_OWN = "queue:delete_own"
     QUEUE_DELETE_ALL = "queue:delete_all"
     QUEUE_DELETE_ALL = "queue:delete_all"
     QUEUE_REORDER = "queue:reorder"
     QUEUE_REORDER = "queue:reorder"
+    # Without it, every job the user queues waits until someone with
+    # queue:update_all starts it (#1620). Only the waiting is enforced here;
+    # queue:create still decides whether they may queue at all.
+    QUEUE_START_UNREVIEWED = "queue:start_unreviewed"
 
 
     # Library
     # Library
     LIBRARY_READ = "library:read"
     LIBRARY_READ = "library:read"
@@ -233,6 +237,7 @@ PERMISSION_CATEGORIES = {
         Permission.QUEUE_DELETE_OWN,
         Permission.QUEUE_DELETE_OWN,
         Permission.QUEUE_DELETE_ALL,
         Permission.QUEUE_DELETE_ALL,
         Permission.QUEUE_REORDER,
         Permission.QUEUE_REORDER,
+        Permission.QUEUE_START_UNREVIEWED,
     ],
     ],
     "Library": [
     "Library": [
         Permission.LIBRARY_READ,  # legacy — kept for back-compat with custom roles
         Permission.LIBRARY_READ,  # legacy — kept for back-compat with custom roles
@@ -406,6 +411,7 @@ DEFAULT_GROUPS = {
             Permission.QUEUE_UPDATE_OWN.value,
             Permission.QUEUE_UPDATE_OWN.value,
             Permission.QUEUE_DELETE_OWN.value,
             Permission.QUEUE_DELETE_OWN.value,
             Permission.QUEUE_REORDER.value,
             Permission.QUEUE_REORDER.value,
+            Permission.QUEUE_START_UNREVIEWED.value,
             # Library - own items only
             # Library - own items only
             Permission.LIBRARY_READ_OWN.value,
             Permission.LIBRARY_READ_OWN.value,
             Permission.LIBRARY_UPLOAD.value,
             Permission.LIBRARY_UPLOAD.value,

+ 368 - 0
backend/tests/integration/test_queue_review_1620.py

@@ -0,0 +1,368 @@
+"""Jobs that wait for review (#1620).
+
+A user without ``queue:start_unreviewed`` may still queue, but every job they
+queue waits until someone with ``queue:update_all`` starts it. These tests pin
+every way a job can be created or set going:
+
+* POST /queue/, the library's add-to-queue, a batch dispatch, an API key and the
+  webhook all leave such a user's job waiting, whatever the request asked for;
+* the start button, clearing "wait for manual start" in the editor or the bulk
+  editor, and the webhook start all refuse them, also for ownerless
+  virtual-printer jobs a user with the permission may claim by starting;
+* staff can start them, and users with the permission keep today's behaviour;
+* the upgrade grants the permission once, so removing it from a group sticks.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.core import database as _database_module
+from backend.app.core.config import settings as app_settings
+from backend.app.core.database import seed_default_groups
+from backend.app.models.group import Group
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.settings import Settings
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+# What a student needs to queue an archive and look after their own jobs
+STUDENT = [
+    "printers:read",
+    "archives:read_all",
+    "archives:reprint_all",
+    "queue:read_own",
+    "queue:create",
+    "queue:update_own",
+    "queue:delete_own",
+    "api_keys:create",
+]
+STAFF = [*STUDENT, "queue:read_all", "queue:update_all", "queue:delete_all"]
+
+
+def _auth(jwt: str) -> dict[str, str]:
+    return {"Authorization": f"Bearer {jwt}"}
+
+
+async def _admin_token(async_client: AsyncClient) -> str:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "reviewadmin", "admin_password": "AdminPass1!"},
+    )
+    login = await async_client.post("/api/v1/auth/login", json={"username": "reviewadmin", "password": "AdminPass1!"})
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"]
+
+
+async def _user(async_client: AsyncClient, admin_jwt: str, username: str, permissions: list[str]) -> tuple[str, int]:
+    group = await async_client.post(
+        "/api/v1/groups/", headers=_auth(admin_jwt), json={"name": f"g_{username}", "permissions": permissions}
+    )
+    assert group.status_code == 201, group.text
+    created = await async_client.post(
+        "/api/v1/users/",
+        headers=_auth(admin_jwt),
+        json={"username": username, "password": "UserPass1!", "group_ids": [group.json()["id"]]},
+    )
+    assert created.status_code in (200, 201), created.text
+    login = await async_client.post("/api/v1/auth/login", json={"username": username, "password": "UserPass1!"})
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"], created.json()["id"]
+
+
+async def _queue(async_client: AsyncClient, headers: dict, printer_id: int, archive_id: int, **extra) -> dict:
+    response = await async_client.post(
+        "/api/v1/queue/", headers=headers, json={"printer_id": printer_id, "archive_id": archive_id, **extra}
+    )
+    assert response.status_code == 200, response.text
+    return response.json()
+
+
+async def _manual_start(item_id: int) -> bool:
+    async with _database_module.async_session() as session:
+        item = (await session.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
+        return item.manual_start
+
+
+@pytest.fixture
+async def setup(async_client, printer_factory, archive_factory):
+    printer = await printer_factory(name="Lab")
+    archive = await archive_factory(printer.id)
+    admin = await _admin_token(async_client)
+    student, student_id = await _user(async_client, admin, "student", STUDENT)
+    staff, _ = await _user(async_client, admin, "staff", STAFF)
+    trusted, _ = await _user(async_client, admin, "trusted", [*STUDENT, "queue:start_unreviewed"])
+    return {
+        "printer": printer,
+        "archive": archive,
+        "admin": admin,
+        "student": student,
+        "student_id": student_id,
+        "staff": staff,
+        "trusted": trusted,
+    }
+
+
+class TestQueueing:
+    async def test_a_students_job_waits_whatever_they_asked_for(self, async_client, setup):
+        item = await _queue(
+            async_client, _auth(setup["student"]), setup["printer"].id, setup["archive"].id, manual_start=False
+        )
+        assert item["manual_start"] is True
+
+    async def test_a_trusted_users_job_starts_on_its_own(self, async_client, setup):
+        item = await _queue(async_client, _auth(setup["trusted"]), setup["printer"].id, setup["archive"].id)
+        assert item["manual_start"] is False
+
+    async def test_staff_jobs_do_not_wait_without_the_permission(self, async_client, setup):
+        """Whoever may start every job is the reviewer; their own jobs don't wait."""
+        item = await _queue(async_client, _auth(setup["staff"]), setup["printer"].id, setup["archive"].id)
+        assert item["manual_start"] is False
+
+    async def test_auth_off_changes_nothing(self, async_client, printer_factory, archive_factory):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        item = await _queue(async_client, {}, printer.id, archive.id)
+        assert item["manual_start"] is False
+
+    async def test_library_add_to_queue_waits(self, async_client, setup, db_session):
+        from backend.app.models.library import LibraryFile
+
+        rel_path = "archive/library/files/review_probe.gcode.3mf"
+        abs_path = Path(app_settings.base_dir) / rel_path
+        abs_path.parent.mkdir(parents=True, exist_ok=True)
+        abs_path.write_bytes(b"probe")
+        try:
+            lib_file = LibraryFile(
+                filename="review_probe.gcode.3mf",
+                file_path=rel_path,
+                file_size=5,
+                file_type="3mf",
+                created_by_id=setup["student_id"],
+            )
+            db_session.add(lib_file)
+            await db_session.commit()
+
+            response = await async_client.post(
+                "/api/v1/library/files/add-to-queue",
+                headers=_auth(setup["student"]),
+                json={"file_ids": [lib_file.id], "printer_id": setup["printer"].id},
+            )
+            assert response.status_code == 200, response.text
+            added = response.json()["added"]
+            assert len(added) == 1
+            assert await _manual_start(added[0]["queue_item_id"]) is True
+        finally:
+            abs_path.unlink(missing_ok=True)
+
+    async def test_dispatching_more_of_an_order_waits_again(self, async_client, setup):
+        """The clones copy the template's flag, which is off once staff started it."""
+        student = _auth(setup["student"])
+        order = await async_client.post(
+            "/api/v1/queue/batches",
+            headers=student,
+            json={
+                "name": "Order",
+                "archive_id": setup["archive"].id,
+                "plates": [{"plate_id": 1, "quantity_target": 3}],
+            },
+        )
+        assert order.status_code == 200, order.text
+        first = await _queue(
+            async_client, student, setup["printer"].id, setup["archive"].id, batch_id=order.json()["id"], plate_id=1
+        )
+        started = await async_client.post(f"/api/v1/queue/{first['id']}/start", headers=_auth(setup["staff"]))
+        assert started.status_code == 200, started.text
+
+        dispatched = await async_client.post(
+            f"/api/v1/queue/batches/{order.json()['id']}/dispatch", headers=student, json={}
+        )
+        assert dispatched.status_code == 200, dispatched.text
+
+        async with _database_module.async_session() as session:
+            clones = (
+                (
+                    await session.execute(
+                        select(PrintQueueItem).where(
+                            PrintQueueItem.batch_id == order.json()["id"], PrintQueueItem.id != first["id"]
+                        )
+                    )
+                )
+                .scalars()
+                .all()
+            )
+        assert len(clones) == 2
+        assert all(clone.manual_start for clone in clones)
+
+
+class TestStarting:
+    async def test_a_student_cannot_start_their_own_waiting_job(self, async_client, setup):
+        item = await _queue(async_client, _auth(setup["student"]), setup["printer"].id, setup["archive"].id)
+
+        response = await async_client.post(f"/api/v1/queue/{item['id']}/start", headers=_auth(setup["student"]))
+        assert response.status_code == 403
+        assert "review" in response.json()["detail"]
+        assert await _manual_start(item["id"]) is True
+
+    async def test_staff_start_it(self, async_client, setup):
+        item = await _queue(async_client, _auth(setup["student"]), setup["printer"].id, setup["archive"].id)
+
+        response = await async_client.post(f"/api/v1/queue/{item['id']}/start", headers=_auth(setup["staff"]))
+        assert response.status_code == 200, response.text
+        assert await _manual_start(item["id"]) is False
+
+    async def test_a_trusted_user_still_starts_their_own_staged_job(self, async_client, setup):
+        trusted = _auth(setup["trusted"])
+        item = await _queue(async_client, trusted, setup["printer"].id, setup["archive"].id, manual_start=True)
+
+        response = await async_client.post(f"/api/v1/queue/{item['id']}/start", headers=trusted)
+        assert response.status_code == 200, response.text
+
+    async def test_an_ownerless_job_is_not_claimable_by_a_student(self, async_client, setup):
+        """Virtual-printer uploads arrive without an owner and are claimed by starting them (#1670)."""
+        item = await _queue(async_client, _auth(setup["admin"]), setup["printer"].id, setup["archive"].id)
+        async with _database_module.async_session() as session:
+            row = (await session.execute(select(PrintQueueItem).where(PrintQueueItem.id == item["id"]))).scalar_one()
+            row.created_by_id = None
+            row.manual_start = True
+            await session.commit()
+
+        refused = await async_client.post(f"/api/v1/queue/{item['id']}/start", headers=_auth(setup["student"]))
+        assert refused.status_code == 403
+        claimed = await async_client.post(f"/api/v1/queue/{item['id']}/start", headers=_auth(setup["trusted"]))
+        assert claimed.status_code == 200, claimed.text
+
+
+class TestEditing:
+    async def test_clearing_wait_in_the_editor_is_refused(self, async_client, setup):
+        student = _auth(setup["student"])
+        item = await _queue(async_client, student, setup["printer"].id, setup["archive"].id)
+
+        response = await async_client.patch(
+            f"/api/v1/queue/{item['id']}", headers=student, json={"manual_start": False}
+        )
+        assert response.status_code == 403
+        assert await _manual_start(item["id"]) is True
+
+    async def test_other_edits_still_work(self, async_client, setup):
+        student = _auth(setup["student"])
+        item = await _queue(async_client, student, setup["printer"].id, setup["archive"].id)
+
+        response = await async_client.patch(
+            f"/api/v1/queue/{item['id']}",
+            headers=student,
+            json={"manual_start": True, "require_previous_success": True},
+        )
+        assert response.status_code == 200, response.text
+
+    async def test_staff_may_clear_it_in_the_editor(self, async_client, setup):
+        item = await _queue(async_client, _auth(setup["student"]), setup["printer"].id, setup["archive"].id)
+
+        response = await async_client.patch(
+            f"/api/v1/queue/{item['id']}", headers=_auth(setup["staff"]), json={"manual_start": False}
+        )
+        assert response.status_code == 200, response.text
+        assert await _manual_start(item["id"]) is False
+
+    async def test_the_bulk_editor_skips_it(self, async_client, setup):
+        student = _auth(setup["student"])
+        item = await _queue(async_client, student, setup["printer"].id, setup["archive"].id)
+
+        response = await async_client.patch(
+            "/api/v1/queue/bulk", headers=student, json={"item_ids": [item["id"]], "manual_start": False}
+        )
+        assert response.status_code == 200, response.text
+        assert response.json()["updated_count"] == 0
+        assert await _manual_start(item["id"]) is True
+
+
+class TestApiKeys:
+    async def _key(self, async_client, jwt: str, **flags) -> dict[str, str]:
+        created = await async_client.post(
+            "/api/v1/api-keys/", headers=_auth(jwt), json={"name": "k", "can_queue": True, **flags}
+        )
+        assert created.status_code in (200, 201), created.text
+        return {"X-API-Key": created.json()["key"]}
+
+    async def test_a_students_key_queues_jobs_that_wait(self, async_client, setup):
+        key = await self._key(async_client, setup["student"])
+        item = await _queue(async_client, key, setup["printer"].id, setup["archive"].id)
+        assert item["manual_start"] is True
+
+    async def test_a_trusted_users_key_does_not(self, async_client, setup):
+        key = await self._key(async_client, setup["trusted"])
+        item = await _queue(async_client, key, setup["printer"].id, setup["archive"].id)
+        assert item["manual_start"] is False
+
+    async def test_webhook_queue_add_waits(self, async_client, setup):
+        key = await self._key(async_client, setup["student"])
+        response = await async_client.post(
+            "/api/v1/webhook/queue/add",
+            headers=key,
+            json={"printer_id": setup["printer"].id, "archive_id": setup["archive"].id},
+        )
+        assert response.status_code == 200, response.text
+        assert await _manual_start(response.json()["id"]) is True
+
+    async def test_webhook_start_refuses_a_key_whose_owner_needs_review(self, async_client, setup):
+        admin = setup["admin"]
+        # Printer control, but their own jobs wait: they can't release anyone's
+        controller, _ = await _user(async_client, admin, "controller", [*STUDENT, "printers:control"])
+        item = await _queue(async_client, _auth(setup["student"]), setup["printer"].id, setup["archive"].id)
+        key = await self._key(async_client, controller, can_control_printer=True)
+
+        refused = await async_client.post(f"/api/v1/webhook/printer/{setup['printer'].id}/start", headers=key)
+        assert refused.status_code == 403
+        assert await _manual_start(item["id"]) is True
+
+        staff_key = await self._key(async_client, admin, can_control_printer=True)
+        started = await async_client.post(f"/api/v1/webhook/printer/{setup['printer'].id}/start", headers=staff_key)
+        assert started.status_code == 200, started.text
+        assert await _manual_start(item["id"]) is False
+
+
+class TestUpgrade:
+    async def test_granted_once_to_groups_that_could_print(self, async_client):
+        async with _database_module.async_session() as session:
+            session.add_all(
+                [
+                    Group(name="queuers", permissions=["queue:create"], is_system=False),
+                    Group(name="controllers", permissions=["printers:control"], is_system=False),
+                    # Could start their own staged jobs, or run pipelines, without queue:create
+                    Group(name="starters", permissions=["queue:read_all", "queue:update_own"], is_system=False),
+                    Group(name="pipeliners", permissions=["pipelines:run"], is_system=False),
+                    Group(name="readers", permissions=["queue:read_own"], is_system=False),
+                ]
+            )
+            flag = (
+                await session.execute(
+                    select(Settings).where(Settings.key == "_backfill_1620_queue_start_unreviewed_done")
+                )
+            ).scalar_one_or_none()
+            # Seeding already ran once for this database; make it an upgrade again
+            if flag is not None:
+                await session.delete(flag)
+            await session.commit()
+
+        await seed_default_groups()
+
+        async with _database_module.async_session() as session:
+            groups = {g.name: g for g in (await session.execute(select(Group))).scalars().all()}
+            assert "queue:start_unreviewed" in groups["queuers"].permissions
+            assert "queue:start_unreviewed" in groups["controllers"].permissions
+            assert "queue:start_unreviewed" in groups["starters"].permissions
+            assert "queue:start_unreviewed" in groups["pipeliners"].permissions
+            assert "queue:start_unreviewed" not in groups["readers"].permissions
+            # An admin takes it away from the students...
+            groups["queuers"].permissions = ["queue:create"]
+            await session.commit()
+
+        # ...and a restart doesn't hand it back
+        await seed_default_groups()
+        async with _database_module.async_session() as session:
+            queuers = (await session.execute(select(Group).where(Group.name == "queuers"))).scalar_one()
+            assert "queue:start_unreviewed" not in queuers.permissions

+ 86 - 1
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -6,7 +6,7 @@
  * - 'edit-queue-item': Edit existing queue item (single printer)
  * - 'edit-queue-item': Edit existing queue item (single printer)
  */
  */
 
 
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
 import type React from 'react';
 import type React from 'react';
 import { screen, waitFor, fireEvent, within, render as rtlRender } from '@testing-library/react';
 import { screen, waitFor, fireEvent, within, render as rtlRender } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import userEvent from '@testing-library/user-event';
@@ -18,6 +18,7 @@ import { AuthProvider } from '../../contexts/AuthContext';
 import { ThemeProvider } from '../../contexts/ThemeContext';
 import { ThemeProvider } from '../../contexts/ThemeContext';
 import { ToastProvider } from '../../contexts/ToastContext';
 import { ToastProvider } from '../../contexts/ToastContext';
 import { http, HttpResponse } from 'msw';
 import { http, HttpResponse } from 'msw';
+import { setAuthToken } from '../../api/client';
 import { server } from '../mocks/server';
 import { server } from '../mocks/server';
 import type { PrintQueueItem } from '../../api/client';
 import type { PrintQueueItem } from '../../api/client';
 
 
@@ -415,6 +416,90 @@ describe('PrintModal', () => {
     });
     });
   });
   });
 
 
+  // Without queue:start_unreviewed every job waits for staff to start it (#1620)
+  describe('when the user needs review', () => {
+    beforeEach(() => {
+      setAuthToken('test-token', 'session');
+      server.use(
+        http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
+        http.get('*/api/v1/auth/me', () =>
+          HttpResponse.json({
+            id: 7,
+            username: 'student',
+            is_admin: false,
+            permissions: ['printers:read', 'queue:create', 'queue:update_own'],
+          }),
+        ),
+      );
+    });
+
+    afterEach(() => {
+      setAuthToken(null);
+    });
+
+    it('says the job waits and still asks for that when saving as ASAP', async () => {
+      let body: Record<string, unknown> | null = null;
+      server.use(
+        http.patch('/api/v1/queue/:id', async ({ request }) => {
+          body = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ id: 1, status: 'pending' });
+        }),
+      );
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="edit-queue-item"
+          archiveId={1}
+          archiveName="Test Print"
+          queueItem={createMockQueueItem({ manual_start: true, created_by_id: 7 })}
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(
+        await screen.findByText('Your print waits for review: it starts once someone who manages the queue starts it.'),
+      ).toBeInTheDocument();
+      expect(screen.queryByText('Require manual start')).not.toBeInTheDocument();
+
+      await user.click(screen.getByText('ASAP'));
+      await user.click(screen.getByRole('button', { name: /save/i }));
+
+      // Sending false would clear the wait, which the server refuses for them
+      await waitFor(() => expect(body).not.toBeNull());
+      expect(body!.manual_start).toBe(true);
+    });
+  });
+
+  // A student's scheduled job waits too (#1620); staff editing it must not start it
+  it('keeps a scheduled job waiting when it is edited', async () => {
+    let body: Record<string, unknown> | null = null;
+    server.use(
+      http.patch('/api/v1/queue/:id', async ({ request }) => {
+        body = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json({ id: 1, status: 'pending' });
+      }),
+    );
+    const user = userEvent.setup();
+    render(
+      <PrintModal
+        mode="edit-queue-item"
+        archiveId={1}
+        archiveName="Test Print"
+        queueItem={createMockQueueItem({
+          manual_start: true,
+          // Tomorrow: over six months ahead reads as the "no date" placeholder
+          scheduled_time: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
+        })}
+        onClose={mockOnClose}
+      />
+    );
+
+    await user.click(screen.getByRole('button', { name: /save/i }));
+
+    await waitFor(() => expect(body).not.toBeNull());
+    expect(body!.manual_start).toBe(true);
+  });
+
   describe('edit-queue-item mode', () => {
   describe('edit-queue-item mode', () => {
     it('renders the modal title', () => {
     it('renders the modal title', () => {
       const item = createMockQueueItem();
       const item = createMockQueueItem();

+ 39 - 1
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -2,13 +2,14 @@
  * Tests for the QueuePage component.
  * Tests for the QueuePage component.
  */
  */
 
 
-import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
 import { screen, waitFor, within } from '@testing-library/react';
 import { screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { render } from '../utils';
 import { QueuePage } from '../../pages/QueuePage';
 import { QueuePage } from '../../pages/QueuePage';
 import { http, HttpResponse } from 'msw';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 import { server } from '../mocks/server';
+import { setAuthToken } from '../../api/client';
 
 
 // Mock queue data
 // Mock queue data
 const mockQueueItems = [
 const mockQueueItems = [
@@ -1457,6 +1458,43 @@ describe('QueuePage', () => {
         expect(screen.getByTitle('Start Print')).toBeInTheDocument();
         expect(screen.getByTitle('Start Print')).toBeInTheDocument();
       });
       });
     });
     });
+
+    // Without queue:start_unreviewed their jobs wait for staff to start them (#1620)
+    describe('waiting for review', () => {
+      const signInWith = (permissions: string[]) => {
+        setAuthToken('test-token', 'session');
+        server.use(
+          http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
+          http.get('*/api/v1/auth/me', () =>
+            HttpResponse.json({ id: 7, username: 'student', is_admin: false, permissions }),
+          ),
+          http.get('/api/v1/queue/', () =>
+            HttpResponse.json([{ ...mockQueueItems[0], manual_start: true, created_by_id: 7 }]),
+          ),
+        );
+      };
+
+      afterEach(() => {
+        setAuthToken(null);
+      });
+
+      it('tells a student their job waits for review and offers no start', async () => {
+        signInWith(['queue:read_own', 'queue:update_own', 'queue:delete_own']);
+        render(<QueuePage />);
+
+        expect(await screen.findByText('Waiting for review')).toBeInTheDocument();
+        const start = screen.getByTitle('Waiting for review: someone who manages the queue starts this job');
+        expect(start).toBeDisabled();
+      });
+
+      it('keeps the start button for users who may print without review', async () => {
+        signInWith(['queue:read_own', 'queue:update_own', 'queue:start_unreviewed']);
+        render(<QueuePage />);
+
+        expect(await screen.findByText('Staged')).toBeInTheDocument();
+        expect(screen.getByTitle('Start Print')).not.toBeDisabled();
+      });
+    });
   });
   });
 
 
   describe('auto power off badge', () => {
   describe('auto power off badge', () => {

+ 1 - 1
frontend/src/api/client.ts

@@ -4255,7 +4255,7 @@ export type Permission =
   | 'archives:reprint_own' | 'archives:reprint_all' | 'archives:purge'
   | 'archives:reprint_own' | 'archives:reprint_all' | 'archives:purge'
   | 'queue:read' | 'queue:read_own' | 'queue:read_all' | 'queue:create'
   | 'queue:read' | 'queue:read_own' | 'queue:read_all' | 'queue:create'
   | 'queue:update_own' | 'queue:update_all' | 'queue:delete_own' | 'queue:delete_all'
   | 'queue:update_own' | 'queue:update_all' | 'queue:delete_own' | 'queue:delete_all'
-  | 'queue:reorder'
+  | 'queue:reorder' | 'queue:start_unreviewed'
   | 'library:read' | 'library:read_own' | 'library:read_all' | 'library:upload'
   | 'library:read' | 'library:read_own' | 'library:read_all' | 'library:upload'
   | 'library:update_own' | 'library:update_all' | 'library:delete_own' | 'library:delete_all'
   | 'library:update_own' | 'library:update_all' | 'library:delete_own' | 'library:delete_all'
   | 'library:purge'
   | 'library:purge'

+ 10 - 1
frontend/src/components/PrintModal/ScheduleOptions.tsx

@@ -29,6 +29,7 @@ export function ScheduleOptionsPanel({
   showStagger = false,
   showStagger = false,
   printerCount = 0,
   printerCount = 0,
   hasGcodeSnippets = false,
   hasGcodeSnippets = false,
+  needsReview = false,
 }: ScheduleOptionsProps) {
 }: ScheduleOptionsProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [dateValue, setDateValue] = useState('');
   const [dateValue, setDateValue] = useState('');
@@ -227,8 +228,16 @@ export function ScheduleOptionsPanel({
         </div>
         </div>
       )}
       )}
 
 
+      {/* Their jobs always wait, so the checkbox would only mislead (#1620) */}
+      {needsReview && (
+        <p className="flex items-start gap-1.5 text-sm text-bambu-gray">
+          <Hand className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
+          {t('printModal.awaitingReviewNote')}
+        </p>
+      )}
+
       {/* Manual start */}
       {/* Manual start */}
-      {options.scheduleType === 'queue' && (
+      {!needsReview && options.scheduleType === 'queue' && (
         <div className="flex items-center gap-2">
         <div className="flex items-center gap-2">
           <input
           <input
             type="checkbox"
             type="checkbox"

+ 18 - 5
frontend/src/components/PrintModal/index.tsx

@@ -99,7 +99,10 @@ export function PrintModal({
   const { t } = useTranslation();
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
-  const { hasPermission, user } = useAuth();
+  const { hasPermission, hasAnyPermission, user } = useAuth();
+  // Their jobs wait for someone to start them (#1620); the server enforces it,
+  // this keeps the request and the dialog honest about it.
+  const needsReview = !hasAnyPermission('queue:start_unreviewed', 'queue:update_all');
 
 
   // Determine if we're printing a library file
   // Determine if we're printing a library file
   const isLibraryFile = !!libraryFileId && !archiveId;
   const isLibraryFile = !!libraryFileId && !archiveId;
@@ -919,6 +922,15 @@ export function PrintModal({
     return amsMapping;
     return amsMapping;
   };
   };
 
 
+  // Saving an existing job. Only Queue has a manual-start checkbox, so a
+  // scheduled job keeps whatever wait it had: a student's scheduled job waits
+  // for review (#1620), and editing it must not start it.
+  const editManualStart =
+    needsReview ||
+    (scheduleOptions.scheduleType === 'queue'
+      ? scheduleOptions.requireManualStart
+      : scheduleOptions.scheduleType === 'scheduled' && !!queueItem?.manual_start);
+
   const handleSubmit = async (e?: React.FormEvent, options?: { skipFilamentCheck?: boolean }) => {
   const handleSubmit = async (e?: React.FormEvent, options?: { skipFilamentCheck?: boolean }) => {
     e?.preventDefault();
     e?.preventDefault();
 
 
@@ -1168,7 +1180,7 @@ export function PrintModal({
           require_previous_success: scheduleOptions.requirePreviousSuccess,
           require_previous_success: scheduleOptions.requirePreviousSuccess,
           auto_off_after: scheduleOptions.autoOffAfter,
           auto_off_after: scheduleOptions.autoOffAfter,
           gcode_injection: scheduleOptions.gcodeInjection,
           gcode_injection: scheduleOptions.gcodeInjection,
-          manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
+          manual_start: needsReview || (scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart),
           scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
           scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
             ? new Date(scheduleOptions.scheduledTime).toISOString()
             ? new Date(scheduleOptions.scheduledTime).toISOString()
             : undefined,
             : undefined,
@@ -1269,7 +1281,7 @@ export function PrintModal({
       require_previous_success: scheduleOptions.requirePreviousSuccess,
       require_previous_success: scheduleOptions.requirePreviousSuccess,
       auto_off_after: scheduleOptions.autoOffAfter,
       auto_off_after: scheduleOptions.autoOffAfter,
       gcode_injection: scheduleOptions.gcodeInjection,
       gcode_injection: scheduleOptions.gcodeInjection,
-      manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
+      manual_start: needsReview || (scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart),
       // When the user clicks "Print Anyway" on the frontend deficit warning,
       // When the user clicks "Print Anyway" on the frontend deficit warning,
       // persist that acknowledgement so the scheduler doesn't immediately
       // persist that acknowledgement so the scheduler doesn't immediately
       // re-flag the item on its first dispatch tick (#1698-followup).
       // re-flag the item on its first dispatch tick (#1698-followup).
@@ -1312,7 +1324,7 @@ export function PrintModal({
               require_previous_success: scheduleOptions.requirePreviousSuccess,
               require_previous_success: scheduleOptions.requirePreviousSuccess,
               auto_off_after: scheduleOptions.autoOffAfter,
               auto_off_after: scheduleOptions.autoOffAfter,
               gcode_injection: scheduleOptions.gcodeInjection,
               gcode_injection: scheduleOptions.gcodeInjection,
-              manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
+              manual_start: editManualStart,
               ams_mapping: undefined,
               ams_mapping: undefined,
               plate_id: plateId,
               plate_id: plateId,
               scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
               scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
@@ -1374,7 +1386,7 @@ export function PrintModal({
                 require_previous_success: scheduleOptions.requirePreviousSuccess,
                 require_previous_success: scheduleOptions.requirePreviousSuccess,
                 auto_off_after: scheduleOptions.autoOffAfter,
                 auto_off_after: scheduleOptions.autoOffAfter,
                 gcode_injection: scheduleOptions.gcodeInjection,
                 gcode_injection: scheduleOptions.gcodeInjection,
-                manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
+                manual_start: editManualStart,
                 ams_mapping: printerMapping,
                 ams_mapping: printerMapping,
                 // null, not undefined: an operator who cleared their picks
                 // null, not undefined: an operator who cleared their picks
                 // means "assign these again", and undefined would leave the
                 // means "assign these again", and undefined would leave the
@@ -1977,6 +1989,7 @@ export function PrintModal({
               showStagger={!isEditing && assignmentMode === 'printer' && selectedPrinters.length > 1}
               showStagger={!isEditing && assignmentMode === 'printer' && selectedPrinters.length > 1}
               printerCount={selectedPrinters.length}
               printerCount={selectedPrinters.length}
               hasGcodeSnippets={!!settings?.gcode_snippets}
               hasGcodeSnippets={!!settings?.gcode_snippets}
+              needsReview={needsReview}
             />
             />
 
 
             {/* Outcome prompt (#1898) sits outside the collapsed Print Options
             {/* Outcome prompt (#1898) sits outside the collapsed Print Options

+ 2 - 0
frontend/src/components/PrintModal/types.ts

@@ -328,4 +328,6 @@ export interface ScheduleOptionsProps {
   printerCount?: number;
   printerCount?: number;
   /** Whether G-code snippets are configured in settings */
   /** Whether G-code snippets are configured in settings */
   hasGcodeSnippets?: boolean;
   hasGcodeSnippets?: boolean;
+  /** The user's jobs wait for someone to start them (#1620) */
+  needsReview?: boolean;
 }
 }

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -1509,6 +1509,7 @@ export default {
     },
     },
     // Badges
     // Badges
     badges: {
     badges: {
+      awaitingReview: 'Wartet auf Freigabe',
       staged: 'Bereitgestellt',
       staged: 'Bereitgestellt',
       requiresPrevious: 'Erfordert vorherigen Erfolg',
       requiresPrevious: 'Erfordert vorherigen Erfolg',
       autoPowerOff: 'Automatisch ausschalten',
       autoPowerOff: 'Automatisch ausschalten',
@@ -1646,6 +1647,7 @@ export default {
     permissions: {
     permissions: {
       noStopPrint: 'Sie haben keine Berechtigung, Drucke zu stoppen',
       noStopPrint: 'Sie haben keine Berechtigung, Drucke zu stoppen',
       noStartPrint: 'Sie haben keine Berechtigung, Drucke zu starten',
       noStartPrint: 'Sie haben keine Berechtigung, Drucke zu starten',
+      awaitingReview: 'Wartet auf Freigabe: Jemand, der die Warteschlange verwaltet, startet diesen Auftrag',
       noEdit: 'Sie haben keine Berechtigung, dieses Warteschlangenelement zu bearbeiten',
       noEdit: 'Sie haben keine Berechtigung, dieses Warteschlangenelement zu bearbeiten',
       noCancel: 'Sie haben keine Berechtigung, dieses Warteschlangenelement abzubrechen',
       noCancel: 'Sie haben keine Berechtigung, dieses Warteschlangenelement abzubrechen',
       noRequeue: 'Sie haben keine Berechtigung, Elemente erneut einzureihen',
       noRequeue: 'Sie haben keine Berechtigung, Elemente erneut einzureihen',
@@ -5344,6 +5346,7 @@ export default {
     invalidDateTime: 'Bitte ein gültiges Datum und eine gültige Uhrzeit eingeben',
     invalidDateTime: 'Bitte ein gültiges Datum und eine gültige Uhrzeit eingeben',
     openCalendar: 'Kalender öffnen',
     openCalendar: 'Kalender öffnen',
     requireManualStart: 'Manuellen Start erfordern',
     requireManualStart: 'Manuellen Start erfordern',
+    awaitingReviewNote: 'Dein Druck wartet auf Freigabe: Er beginnt, sobald jemand, der die Warteschlange verwaltet, ihn startet.',
     requirePreviousSuccess: 'Nur starten, wenn der vorherige Druck erfolgreich war',
     requirePreviousSuccess: 'Nur starten, wenn der vorherige Druck erfolgreich war',
     autoOffAfter: 'Drucker nach Abschluss ausschalten',
     autoOffAfter: 'Drucker nach Abschluss ausschalten',
     helpAsap: 'Der Druck wird oben in die Warteschlange eingefügt und startet, sobald ein geeigneter Drucker im Leerlauf ist.',
     helpAsap: 'Der Druck wird oben in die Warteschlange eingefügt und startet, sobald ein geeigneter Drucker im Leerlauf ist.',

+ 3 - 0
frontend/src/i18n/locales/en.ts

@@ -1525,6 +1525,7 @@ export default {
     },
     },
     // Badges
     // Badges
     badges: {
     badges: {
+      awaitingReview: 'Waiting for review',
       staged: 'Staged',
       staged: 'Staged',
       requiresPrevious: 'Requires previous success',
       requiresPrevious: 'Requires previous success',
       autoPowerOff: 'Auto power off',
       autoPowerOff: 'Auto power off',
@@ -1663,6 +1664,7 @@ export default {
     permissions: {
     permissions: {
       noStopPrint: 'You do not have permission to stop prints',
       noStopPrint: 'You do not have permission to stop prints',
       noStartPrint: 'You do not have permission to start prints',
       noStartPrint: 'You do not have permission to start prints',
+      awaitingReview: 'Waiting for review: someone who manages the queue starts this job',
       noEdit: 'You do not have permission to edit this queue item',
       noEdit: 'You do not have permission to edit this queue item',
       noCancel: 'You do not have permission to cancel this queue item',
       noCancel: 'You do not have permission to cancel this queue item',
       noRequeue: 'You do not have permission to re-queue items',
       noRequeue: 'You do not have permission to re-queue items',
@@ -5389,6 +5391,7 @@ export default {
     invalidDateTime: 'Please enter a valid date and time',
     invalidDateTime: 'Please enter a valid date and time',
     openCalendar: 'Open calendar',
     openCalendar: 'Open calendar',
     requireManualStart: 'Require manual start',
     requireManualStart: 'Require manual start',
+    awaitingReviewNote: 'Your print waits for review: it starts once someone who manages the queue starts it.',
     requirePreviousSuccess: 'Only start if previous print succeeded',
     requirePreviousSuccess: 'Only start if previous print succeeded',
     autoOffAfter: 'Power off printer when done',
     autoOffAfter: 'Power off printer when done',
     helpAsap: 'Print will be added to the top of the queue and start as soon as an eligible printer is idle.',
     helpAsap: 'Print will be added to the top of the queue and start as soon as an eligible printer is idle.',

+ 3 - 0
frontend/src/i18n/locales/es.ts

@@ -1509,6 +1509,7 @@ export default {
     },
     },
     // Badges
     // Badges
     badges: {
     badges: {
+      awaitingReview: 'Esperando revisión',
       staged: 'Preparado',
       staged: 'Preparado',
       requiresPrevious: 'Requiere éxito previo',
       requiresPrevious: 'Requiere éxito previo',
       autoPowerOff: 'Apagado automático',
       autoPowerOff: 'Apagado automático',
@@ -1646,6 +1647,7 @@ export default {
     permissions: {
     permissions: {
       noStopPrint: 'No tiene permiso para detener impresiones',
       noStopPrint: 'No tiene permiso para detener impresiones',
       noStartPrint: 'No tiene permiso para iniciar impresiones',
       noStartPrint: 'No tiene permiso para iniciar impresiones',
+      awaitingReview: 'Esperando revisión: alguien que gestiona la cola inicia este trabajo',
       noEdit: 'No tiene permiso para editar este elemento de la cola',
       noEdit: 'No tiene permiso para editar este elemento de la cola',
       noCancel: 'No tiene permiso para cancelar este elemento de la cola',
       noCancel: 'No tiene permiso para cancelar este elemento de la cola',
       noRequeue: 'No tiene permiso para volver a encolar elementos',
       noRequeue: 'No tiene permiso para volver a encolar elementos',
@@ -5351,6 +5353,7 @@ export default {
     invalidDateTime: 'Introduzca una fecha y hora válidas',
     invalidDateTime: 'Introduzca una fecha y hora válidas',
     openCalendar: 'Abrir calendario',
     openCalendar: 'Abrir calendario',
     requireManualStart: 'Requerir inicio manual',
     requireManualStart: 'Requerir inicio manual',
+    awaitingReviewNote: 'Tu impresión espera revisión: empieza cuando alguien que gestiona la cola la inicie.',
     requirePreviousSuccess: 'Iniciar solo si la impresión anterior se completó correctamente',
     requirePreviousSuccess: 'Iniciar solo si la impresión anterior se completó correctamente',
     autoOffAfter: 'Apagar la impresora al terminar',
     autoOffAfter: 'Apagar la impresora al terminar',
     helpAsap: 'La impresión se añadirá al principio de la cola y comenzará en cuanto haya una impresora elegible inactiva.',
     helpAsap: 'La impresión se añadirá al principio de la cola y comenzará en cuanto haya una impresora elegible inactiva.',

+ 3 - 0
frontend/src/i18n/locales/fr.ts

@@ -1509,6 +1509,7 @@ export default {
     },
     },
     // Badges
     // Badges
     badges: {
     badges: {
+      awaitingReview: 'En attente de validation',
       staged: 'Préparé',
       staged: 'Préparé',
       requiresPrevious: 'Nécessite succès précédent',
       requiresPrevious: 'Nécessite succès précédent',
       autoPowerOff: 'Extinction auto',
       autoPowerOff: 'Extinction auto',
@@ -1646,6 +1647,7 @@ export default {
     permissions: {
     permissions: {
       noStopPrint: 'Pas d\'autorisation d\'arrêt',
       noStopPrint: 'Pas d\'autorisation d\'arrêt',
       noStartPrint: 'Pas d\'autorisation de démarrage',
       noStartPrint: 'Pas d\'autorisation de démarrage',
+      awaitingReview: 'En attente de validation : une personne qui gère la file lance ce travail',
       noEdit: 'Pas d\'autorisation de modification',
       noEdit: 'Pas d\'autorisation de modification',
       noCancel: 'Pas d\'autorisation d\'annulation',
       noCancel: 'Pas d\'autorisation d\'annulation',
       noRequeue: 'Pas d\'autorisation de remise en file',
       noRequeue: 'Pas d\'autorisation de remise en file',
@@ -5331,6 +5333,7 @@ export default {
     invalidDateTime: 'Veuillez saisir une date et une heure valides',
     invalidDateTime: 'Veuillez saisir une date et une heure valides',
     openCalendar: 'Ouvrir calendrier',
     openCalendar: 'Ouvrir calendrier',
     requireManualStart: 'Démarrage manuel requis',
     requireManualStart: 'Démarrage manuel requis',
+    awaitingReviewNote: 'Votre impression attend une validation : elle démarre quand une personne qui gère la file la lance.',
     requirePreviousSuccess: 'Démarrer seulement si l\'impression précédente a réussi',
     requirePreviousSuccess: 'Démarrer seulement si l\'impression précédente a réussi',
     autoOffAfter: 'Éteindre l\'imprimante à la fin',
     autoOffAfter: 'Éteindre l\'imprimante à la fin',
     helpAsap: 'L\'impression sera ajoutée en haut de la file et démarrera dès qu\'une imprimante éligible sera inactive.',
     helpAsap: 'L\'impression sera ajoutée en haut de la file et démarrera dès qu\'une imprimante éligible sera inactive.',

+ 3 - 0
frontend/src/i18n/locales/it.ts

@@ -1509,6 +1509,7 @@ export default {
     },
     },
     // Badges
     // Badges
     badges: {
     badges: {
+      awaitingReview: 'In attesa di revisione',
       staged: 'In staging',
       staged: 'In staging',
       requiresPrevious: 'Richiede successo precedente',
       requiresPrevious: 'Richiede successo precedente',
       autoPowerOff: 'Spegnimento automatico',
       autoPowerOff: 'Spegnimento automatico',
@@ -1646,6 +1647,7 @@ export default {
     permissions: {
     permissions: {
       noStopPrint: 'Non hai il permesso di fermare stampe',
       noStopPrint: 'Non hai il permesso di fermare stampe',
       noStartPrint: 'Non hai il permesso di avviare stampe',
       noStartPrint: 'Non hai il permesso di avviare stampe',
+      awaitingReview: 'In attesa di revisione: qualcuno che gestisce la coda avvia questo lavoro',
       noEdit: 'Non hai il permesso di modificare questo elemento coda',
       noEdit: 'Non hai il permesso di modificare questo elemento coda',
       noCancel: 'Non hai il permesso di annullare questo elemento coda',
       noCancel: 'Non hai il permesso di annullare questo elemento coda',
       noRequeue: 'Non hai il permesso di rimettere in coda elementi',
       noRequeue: 'Non hai il permesso di rimettere in coda elementi',
@@ -5330,6 +5332,7 @@ export default {
     invalidDateTime: 'Inserisci data e ora valide',
     invalidDateTime: 'Inserisci data e ora valide',
     openCalendar: 'Apri calendario',
     openCalendar: 'Apri calendario',
     requireManualStart: 'Richiedi avvio manuale',
     requireManualStart: 'Richiedi avvio manuale',
+    awaitingReviewNote: 'La tua stampa attende la revisione: parte quando qualcuno che gestisce la coda la avvia.',
     requirePreviousSuccess: 'Avvia solo se la stampa precedente è riuscita',
     requirePreviousSuccess: 'Avvia solo se la stampa precedente è riuscita',
     autoOffAfter: 'Spegni la stampante al termine',
     autoOffAfter: 'Spegni la stampante al termine',
     helpAsap: 'La stampa verrà aggiunta in cima alla coda e partirà appena una stampante idonea è inattiva.',
     helpAsap: 'La stampa verrà aggiunta in cima alla coda e partirà appena una stampante idonea è inattiva.',

+ 3 - 0
frontend/src/i18n/locales/ja.ts

@@ -1508,6 +1508,7 @@ export default {
     },
     },
     // Badges
     // Badges
     badges: {
     badges: {
+      awaitingReview: 'レビュー待ち',
       staged: 'ステージ済み',
       staged: 'ステージ済み',
       requiresPrevious: '前の成功が必要',
       requiresPrevious: '前の成功が必要',
       autoPowerOff: '自動電源オフ',
       autoPowerOff: '自動電源オフ',
@@ -1645,6 +1646,7 @@ export default {
     permissions: {
     permissions: {
       noStopPrint: '印刷を停止する権限がありません',
       noStopPrint: '印刷を停止する権限がありません',
       noStartPrint: '印刷を開始する権限がありません',
       noStartPrint: '印刷を開始する権限がありません',
+      awaitingReview: 'レビュー待ち:キューを管理する人がこのジョブを開始します',
       noEdit: 'このキューアイテムを編集する権限がありません',
       noEdit: 'このキューアイテムを編集する権限がありません',
       noCancel: 'このキューアイテムをキャンセルする権限がありません',
       noCancel: 'このキューアイテムをキャンセルする権限がありません',
       noRequeue: 'アイテムを再キューする権限がありません',
       noRequeue: 'アイテムを再キューする権限がありません',
@@ -5344,6 +5346,7 @@ export default {
     invalidDateTime: '有効な日時を入力してください',
     invalidDateTime: '有効な日時を入力してください',
     openCalendar: 'カレンダーを開く',
     openCalendar: 'カレンダーを開く',
     requireManualStart: '手動開始を要求',
     requireManualStart: '手動開始を要求',
+    awaitingReviewNote: 'この印刷はレビュー待ちです。キューを管理する人が開始すると印刷が始まります。',
     requirePreviousSuccess: '前の印刷が成功した場合のみ開始',
     requirePreviousSuccess: '前の印刷が成功した場合のみ開始',
     autoOffAfter: '完了後にプリンターの電源を切る',
     autoOffAfter: '完了後にプリンターの電源を切る',
     helpAsap: '印刷はキューの先頭に追加され、対象プリンターがアイドルになるとすぐに開始します。',
     helpAsap: '印刷はキューの先頭に追加され、対象プリンターがアイドルになるとすぐに開始します。',

+ 3 - 0
frontend/src/i18n/locales/ko.ts

@@ -1440,6 +1440,7 @@ export default {
       descendingNewest: '내림차순 (최신 것 먼저)'
       descendingNewest: '내림차순 (최신 것 먼저)'
     },
     },
     badges: {
     badges: {
+      awaitingReview: '검토 대기 중',
       staged: '준비됨',
       staged: '준비됨',
       requiresPrevious: '이전 성공 필요',
       requiresPrevious: '이전 성공 필요',
       autoPowerOff: '자동 전원 끄기',
       autoPowerOff: '자동 전원 끄기',
@@ -1569,6 +1570,7 @@ export default {
     permissions: {
     permissions: {
       noStopPrint: '인쇄를 정지할 권한이 없습니다',
       noStopPrint: '인쇄를 정지할 권한이 없습니다',
       noStartPrint: '인쇄를 시작할 권한이 없습니다',
       noStartPrint: '인쇄를 시작할 권한이 없습니다',
+      awaitingReview: '검토 대기 중: 대기열을 관리하는 사람이 이 작업을 시작합니다',
       noEdit: '이 대기열 항목을 편집할 권한이 없습니다',
       noEdit: '이 대기열 항목을 편집할 권한이 없습니다',
       noCancel: '이 대기열 항목을 취소할 권한이 없습니다',
       noCancel: '이 대기열 항목을 취소할 권한이 없습니다',
       noRequeue: '항목을 재대기할 권한이 없습니다',
       noRequeue: '항목을 재대기할 권한이 없습니다',
@@ -5113,6 +5115,7 @@ export default {
     invalidDateTime: '유효한 날짜와 시간을 입력하세요',
     invalidDateTime: '유효한 날짜와 시간을 입력하세요',
     openCalendar: '달력 열기',
     openCalendar: '달력 열기',
     requireManualStart: '수동 시작 필요',
     requireManualStart: '수동 시작 필요',
+    awaitingReviewNote: '이 출력은 검토를 기다립니다. 대기열을 관리하는 사람이 시작하면 출력이 시작됩니다.',
     requirePreviousSuccess: '이전 인쇄가 성공한 경우에만 시작',
     requirePreviousSuccess: '이전 인쇄가 성공한 경우에만 시작',
     autoOffAfter: '완료 후 프린터 전원 끄기',
     autoOffAfter: '완료 후 프린터 전원 끄기',
     helpAsap: '인쇄가 대기열 맨 앞에 추가되고 적합한 프린터가 유휴 상태가 되는 즉시 시작됩니다.',
     helpAsap: '인쇄가 대기열 맨 앞에 추가되고 적합한 프린터가 유휴 상태가 되는 즉시 시작됩니다.',

+ 3 - 0
frontend/src/i18n/locales/nl.ts

@@ -1525,6 +1525,7 @@ export default {
     },
     },
     // Badges
     // Badges
     badges: {
     badges: {
+      awaitingReview: 'Wacht op beoordeling',
       staged: 'Gereed voor handmatige start',
       staged: 'Gereed voor handmatige start',
       requiresPrevious: 'Vereist dat vorige taak slaagt',
       requiresPrevious: 'Vereist dat vorige taak slaagt',
       autoPowerOff: 'Automatisch uitschakelen',
       autoPowerOff: 'Automatisch uitschakelen',
@@ -1663,6 +1664,7 @@ export default {
     permissions: {
     permissions: {
       noStopPrint: 'Je hebt geen toestemming om afdrukken te stoppen',
       noStopPrint: 'Je hebt geen toestemming om afdrukken te stoppen',
       noStartPrint: 'Je hebt geen toestemming om afdrukken te starten',
       noStartPrint: 'Je hebt geen toestemming om afdrukken te starten',
+      awaitingReview: 'Wacht op beoordeling: iemand die de wachtrij beheert start deze taak',
       noEdit: 'Je hebt geen toestemming om dit wachtrij-item te bewerken',
       noEdit: 'Je hebt geen toestemming om dit wachtrij-item te bewerken',
       noCancel: 'Je hebt geen toestemming om dit wachtrij-item te annuleren',
       noCancel: 'Je hebt geen toestemming om dit wachtrij-item te annuleren',
       noRequeue: 'Je hebt geen toestemming om items opnieuw in de wachtrij te plaatsen',
       noRequeue: 'Je hebt geen toestemming om items opnieuw in de wachtrij te plaatsen',
@@ -5389,6 +5391,7 @@ export default {
     invalidDateTime: 'Voer een geldige datum en tijd in',
     invalidDateTime: 'Voer een geldige datum en tijd in',
     openCalendar: 'Kalender openen',
     openCalendar: 'Kalender openen',
     requireManualStart: 'Handmatige start vereisen',
     requireManualStart: 'Handmatige start vereisen',
+    awaitingReviewNote: 'Je afdruk wacht op beoordeling: hij begint zodra iemand die de wachtrij beheert hem start.',
     requirePreviousSuccess: 'Alleen starten als vorige afdruk is geslaagd',
     requirePreviousSuccess: 'Alleen starten als vorige afdruk is geslaagd',
     autoOffAfter: 'Printer uitschakelen wanneer klaar',
     autoOffAfter: 'Printer uitschakelen wanneer klaar',
     helpAsap: 'Afdruk wordt bovenaan de wachtrij geplaatst en start zodra een geschikte printer inactief is.',
     helpAsap: 'Afdruk wordt bovenaan de wachtrij geplaatst en start zodra een geschikte printer inactief is.',

+ 3 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -1509,6 +1509,7 @@ export default {
     },
     },
     // Badges
     // Badges
     badges: {
     badges: {
+      awaitingReview: 'Aguardando revisão',
       staged: 'Preparado (início manual)',
       staged: 'Preparado (início manual)',
       requiresPrevious: 'Requer sucesso anterior',
       requiresPrevious: 'Requer sucesso anterior',
       autoPowerOff: 'Desligamento automático',
       autoPowerOff: 'Desligamento automático',
@@ -1646,6 +1647,7 @@ export default {
     permissions: {
     permissions: {
       noStopPrint: 'Você não tem permissão para parar impressões',
       noStopPrint: 'Você não tem permissão para parar impressões',
       noStartPrint: 'Você não tem permissão para iniciar impressões',
       noStartPrint: 'Você não tem permissão para iniciar impressões',
+      awaitingReview: 'Aguardando revisão: alguém que gerencia a fila inicia este trabalho',
       noEdit: 'Você não tem permissão para editar este item da fila',
       noEdit: 'Você não tem permissão para editar este item da fila',
       noCancel: 'Você não tem permissão para cancelar este item da fila',
       noCancel: 'Você não tem permissão para cancelar este item da fila',
       noRequeue: 'Você não tem permissão para reenfileirar itens',
       noRequeue: 'Você não tem permissão para reenfileirar itens',
@@ -5330,6 +5332,7 @@ export default {
     invalidDateTime: 'Digite uma data e hora válidas',
     invalidDateTime: 'Digite uma data e hora válidas',
     openCalendar: 'Abrir calendário',
     openCalendar: 'Abrir calendário',
     requireManualStart: 'Exigir início manual',
     requireManualStart: 'Exigir início manual',
+    awaitingReviewNote: 'Sua impressão aguarda revisão: ela começa quando alguém que gerencia a fila a iniciar.',
     requirePreviousSuccess: 'Iniciar somente se a impressão anterior foi concluída com sucesso',
     requirePreviousSuccess: 'Iniciar somente se a impressão anterior foi concluída com sucesso',
     autoOffAfter: 'Desligar impressora ao finalizar',
     autoOffAfter: 'Desligar impressora ao finalizar',
     helpAsap: 'A impressão será adicionada ao topo da fila e começará assim que uma impressora elegível estiver ociosa.',
     helpAsap: 'A impressão será adicionada ao topo da fila e começará assim que uma impressora elegível estiver ociosa.',

+ 3 - 0
frontend/src/i18n/locales/ru.ts

@@ -1450,6 +1450,7 @@ export default {
       descendingNewest: "По убыванию (сначала новые)",
       descendingNewest: "По убыванию (сначала новые)",
     },
     },
     badges: {
     badges: {
+      awaitingReview: 'Ожидает проверки',
       staged: "Подготовлено",
       staged: "Подготовлено",
       requiresPrevious: "Требуется успешное предыдущее задание",
       requiresPrevious: "Требуется успешное предыдущее задание",
       autoPowerOff: "Автоотключение питания",
       autoPowerOff: "Автоотключение питания",
@@ -1579,6 +1580,7 @@ export default {
     permissions: {
     permissions: {
       noStopPrint: "У вас нет разрешения останавливать печать",
       noStopPrint: "У вас нет разрешения останавливать печать",
       noStartPrint: "У вас нет разрешения запускать печать",
       noStartPrint: "У вас нет разрешения запускать печать",
+      awaitingReview: "Ожидает проверки: это задание запускает тот, кто управляет очередью",
       noEdit: "У вас нет разрешения изменять это задание",
       noEdit: "У вас нет разрешения изменять это задание",
       noCancel: "У вас нет разрешения отменять это задание",
       noCancel: "У вас нет разрешения отменять это задание",
       noRequeue: "У вас нет разрешения возвращать задания в очередь",
       noRequeue: "У вас нет разрешения возвращать задания в очередь",
@@ -5102,6 +5104,7 @@ export default {
     invalidDateTime: "Введите корректные дату и время",
     invalidDateTime: "Введите корректные дату и время",
     openCalendar: "Открыть календарь",
     openCalendar: "Открыть календарь",
     requireManualStart: "Требовать ручного запуска",
     requireManualStart: "Требовать ручного запуска",
+    awaitingReviewNote: "Ваша печать ожидает проверки: она начнётся, когда её запустит тот, кто управляет очередью.",
     requirePreviousSuccess: "Запускать только после успешной предыдущей печати",
     requirePreviousSuccess: "Запускать только после успешной предыдущей печати",
     autoOffAfter: "Выключить принтер после завершения",
     autoOffAfter: "Выключить принтер после завершения",
     helpAsap: "Задание будет добавлено в начало очереди и запустится, как только подходящий принтер освободится.",
     helpAsap: "Задание будет добавлено в начало очереди и запустится, как только подходящий принтер освободится.",

+ 3 - 0
frontend/src/i18n/locales/sv.ts

@@ -1525,6 +1525,7 @@ export default {
     },
     },
     // Badges
     // Badges
     badges: {
     badges: {
+      awaitingReview: 'Väntar på granskning',
       staged: 'Förberedd',
       staged: 'Förberedd',
       requiresPrevious: 'Kräver tidigare framgång',
       requiresPrevious: 'Kräver tidigare framgång',
       autoPowerOff: 'Auto-avstängning',
       autoPowerOff: 'Auto-avstängning',
@@ -1663,6 +1664,7 @@ export default {
     permissions: {
     permissions: {
       noStopPrint: 'Du har inte behörighet att stoppa utskrifter',
       noStopPrint: 'Du har inte behörighet att stoppa utskrifter',
       noStartPrint: 'Du har inte behörighet att starta utskrifter',
       noStartPrint: 'Du har inte behörighet att starta utskrifter',
+      awaitingReview: 'Väntar på granskning: någon som hanterar kön startar det här jobbet',
       noEdit: 'Du har inte behörighet att redigera denna köpost',
       noEdit: 'Du har inte behörighet att redigera denna köpost',
       noCancel: 'Du har inte behörighet att avbryta denna köpost',
       noCancel: 'Du har inte behörighet att avbryta denna köpost',
       noRequeue: 'Du har inte behörighet att lägga tillbaka poster i kön',
       noRequeue: 'Du har inte behörighet att lägga tillbaka poster i kön',
@@ -5387,6 +5389,7 @@ errors: {
     invalidDateTime: 'Ange ett giltigt datum och tid',
     invalidDateTime: 'Ange ett giltigt datum och tid',
     openCalendar: 'Öppna kalender',
     openCalendar: 'Öppna kalender',
     requireManualStart: 'Kräv manuell start',
     requireManualStart: 'Kräv manuell start',
+    awaitingReviewNote: 'Din utskrift väntar på granskning: den börjar när någon som hanterar kön startar den.',
     requirePreviousSuccess: 'Starta endast om föregående utskrift lyckades',
     requirePreviousSuccess: 'Starta endast om föregående utskrift lyckades',
     autoOffAfter: 'Stäng av skrivaren när den är klar',
     autoOffAfter: 'Stäng av skrivaren när den är klar',
     helpAsap: 'Utskriften kommer att läggas till i toppen av kön och starta så snart en lämplig skrivare är ledig.',
     helpAsap: 'Utskriften kommer att läggas till i toppen av kön och starta så snart en lämplig skrivare är ledig.',

+ 3 - 0
frontend/src/i18n/locales/tr.ts

@@ -1509,6 +1509,7 @@ export default {
     },
     },
     // Rozetler
     // Rozetler
     badges: {
     badges: {
+      awaitingReview: 'İnceleme bekliyor',
       staged: 'Hazırlandı',
       staged: 'Hazırlandı',
       requiresPrevious: 'Önceki başarı gerekli',
       requiresPrevious: 'Önceki başarı gerekli',
       autoPowerOff: 'Otomatik kapanma',
       autoPowerOff: 'Otomatik kapanma',
@@ -1646,6 +1647,7 @@ export default {
     permissions: {
     permissions: {
       noStopPrint: 'Baskıları durdurma izniniz yok',
       noStopPrint: 'Baskıları durdurma izniniz yok',
       noStartPrint: 'Baskıları başlatma izniniz yok',
       noStartPrint: 'Baskıları başlatma izniniz yok',
+      awaitingReview: 'İnceleme bekliyor: bu işi kuyruğu yöneten biri başlatır',
       noEdit: 'Bu kuyruk öğesini düzenleme izniniz yok',
       noEdit: 'Bu kuyruk öğesini düzenleme izniniz yok',
       noCancel: 'Bu kuyruk öğesini iptal etme izniniz yok',
       noCancel: 'Bu kuyruk öğesini iptal etme izniniz yok',
       noRequeue: 'Öğeleri yeniden kuyruklama izniniz yok',
       noRequeue: 'Öğeleri yeniden kuyruklama izniniz yok',
@@ -5319,6 +5321,7 @@ export default {
     invalidDateTime: 'Lütfen geçerli bir tarih ve saat girin',
     invalidDateTime: 'Lütfen geçerli bir tarih ve saat girin',
     openCalendar: 'Takvimi aç',
     openCalendar: 'Takvimi aç',
     requireManualStart: 'Manuel başlatma gerektir',
     requireManualStart: 'Manuel başlatma gerektir',
+    awaitingReviewNote: 'Baskınız incelemeyi bekliyor: kuyruğu yöneten biri başlattığında başlar.',
     requirePreviousSuccess: 'Yalnızca önceki baskı başarılıysa başlat',
     requirePreviousSuccess: 'Yalnızca önceki baskı başarılıysa başlat',
     autoOffAfter: 'Bittiğinde yazıcıyı kapat',
     autoOffAfter: 'Bittiğinde yazıcıyı kapat',
     helpAsap: 'Baskı kuyruğun en üstüne eklenir ve uygun bir yazıcı boştaysa başlar.',
     helpAsap: 'Baskı kuyruğun en üstüne eklenir ve uygun bir yazıcı boştaysa başlar.',

+ 3 - 0
frontend/src/i18n/locales/uk.ts

@@ -1524,6 +1524,7 @@ export default {
     },
     },
     // Badges
     // Badges
     badges: {
     badges: {
+      awaitingReview: 'Очікує перевірки',
       staged: "Очікує ручного запуску",
       staged: "Очікує ручного запуску",
       requiresPrevious: "Потрібне успішне завершення попереднього завдання",
       requiresPrevious: "Потрібне успішне завершення попереднього завдання",
       autoPowerOff: "Автоматичне вимкнення",
       autoPowerOff: "Автоматичне вимкнення",
@@ -1662,6 +1663,7 @@ export default {
     permissions: {
     permissions: {
       noStopPrint: "Ви не маєте дозволу зупиняти друк",
       noStopPrint: "Ви не маєте дозволу зупиняти друк",
       noStartPrint: "Ви не маєте дозволу розпочинати друк",
       noStartPrint: "Ви не маєте дозволу розпочинати друк",
+      awaitingReview: "Очікує перевірки: це завдання запускає той, хто керує чергою",
       noEdit: "Ви не маєте дозволу редагувати цей елемент черги",
       noEdit: "Ви не маєте дозволу редагувати цей елемент черги",
       noCancel: "Ви не маєте дозволу скасувати цей елемент черги",
       noCancel: "Ви не маєте дозволу скасувати цей елемент черги",
       noRequeue: "Ви не маєте дозволу повторно ставити елементи в чергу",
       noRequeue: "Ви не маєте дозволу повторно ставити елементи в чергу",
@@ -5384,6 +5386,7 @@ export default {
     invalidDateTime: "Введіть дійсну дату й час",
     invalidDateTime: "Введіть дійсну дату й час",
     openCalendar: "Відкрити календар",
     openCalendar: "Відкрити календар",
     requireManualStart: "Потрібен ручний запуск",
     requireManualStart: "Потрібен ручний запуск",
+    awaitingReviewNote: "Ваш друк очікує перевірки: він почнеться, коли його запустить той, хто керує чергою.",
     requirePreviousSuccess: "Починати, лише якщо попередній друк вдався",
     requirePreviousSuccess: "Починати, лише якщо попередній друк вдався",
     autoOffAfter: "Вимкнути принтер після завершення",
     autoOffAfter: "Вимкнути принтер після завершення",
     helpAsap: "Завдання буде додано на початок черги й розпочнеться, щойно відповідний принтер звільниться.",
     helpAsap: "Завдання буде додано на початок черги й розпочнеться, щойно відповідний принтер звільниться.",

+ 3 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -1509,6 +1509,7 @@ export default {
     },
     },
     // Badges
     // Badges
     badges: {
     badges: {
+      awaitingReview: '等待审核',
       staged: '已暂存',
       staged: '已暂存',
       requiresPrevious: '需要前一个成功',
       requiresPrevious: '需要前一个成功',
       autoPowerOff: '自动关机',
       autoPowerOff: '自动关机',
@@ -1646,6 +1647,7 @@ export default {
     permissions: {
     permissions: {
       noStopPrint: '您没有停止打印的权限',
       noStopPrint: '您没有停止打印的权限',
       noStartPrint: '您没有开始打印的权限',
       noStartPrint: '您没有开始打印的权限',
+      awaitingReview: '等待审核:由管理队列的人启动此任务',
       noEdit: '您没有编辑此队列项目的权限',
       noEdit: '您没有编辑此队列项目的权限',
       noCancel: '您没有取消此队列项目的权限',
       noCancel: '您没有取消此队列项目的权限',
       noRequeue: '您没有重新排队的权限',
       noRequeue: '您没有重新排队的权限',
@@ -5330,6 +5332,7 @@ export default {
     invalidDateTime: '请输入有效的日期和时间',
     invalidDateTime: '请输入有效的日期和时间',
     openCalendar: '打开日历',
     openCalendar: '打开日历',
     requireManualStart: '要求手动开始',
     requireManualStart: '要求手动开始',
+    awaitingReviewNote: '你的打印正在等待审核:管理队列的人启动后才会开始。',
     requirePreviousSuccess: '仅在上一次打印成功后开始',
     requirePreviousSuccess: '仅在上一次打印成功后开始',
     autoOffAfter: '完成后关闭打印机',
     autoOffAfter: '完成后关闭打印机',
     helpAsap: '打印将添加到队列顶部,并在符合条件的打印机空闲后立即开始。',
     helpAsap: '打印将添加到队列顶部,并在符合条件的打印机空闲后立即开始。',

+ 3 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -1509,6 +1509,7 @@ export default {
     },
     },
     // Badges
     // Badges
     badges: {
     badges: {
+      awaitingReview: '等待審核',
       staged: '已暫存',
       staged: '已暫存',
       requiresPrevious: '需要前一個成功',
       requiresPrevious: '需要前一個成功',
       autoPowerOff: '自動關機',
       autoPowerOff: '自動關機',
@@ -1646,6 +1647,7 @@ export default {
     permissions: {
     permissions: {
       noStopPrint: '您沒有停止列印的權限',
       noStopPrint: '您沒有停止列印的權限',
       noStartPrint: '您沒有開始列印的權限',
       noStartPrint: '您沒有開始列印的權限',
+      awaitingReview: '等待審核:由管理佇列的人啟動此工作',
       noEdit: '您沒有編輯此佇列項目的權限',
       noEdit: '您沒有編輯此佇列項目的權限',
       noCancel: '您沒有取消此佇列項目的權限',
       noCancel: '您沒有取消此佇列項目的權限',
       noRequeue: '您沒有重新佇列的權限',
       noRequeue: '您沒有重新佇列的權限',
@@ -5330,6 +5332,7 @@ export default {
     invalidDateTime: '請輸入有效的日期和時間',
     invalidDateTime: '請輸入有效的日期和時間',
     openCalendar: '開啟日曆',
     openCalendar: '開啟日曆',
     requireManualStart: '要求手動開始',
     requireManualStart: '要求手動開始',
+    awaitingReviewNote: '你的列印正在等待審核:管理佇列的人啟動後才會開始。',
     requirePreviousSuccess: '僅在上一次列印成功後開始',
     requirePreviousSuccess: '僅在上一次列印成功後開始',
     autoOffAfter: '完成後關閉印表機',
     autoOffAfter: '完成後關閉印表機',
     helpAsap: '列印將新增到佇列頂端,並在符合條件的印表機閒置後立即開始。',
     helpAsap: '列印將新增到佇列頂端,並在符合條件的印表機閒置後立即開始。',

+ 12 - 3
frontend/src/pages/QueuePage.tsx

@@ -555,6 +555,9 @@ function SortableQueueItem({
   etaNow?: number;
   etaNow?: number;
   t: (key: string, options?: Record<string, unknown>) => string;
   t: (key: string, options?: Record<string, unknown>) => string;
 }) {
 }) {
+  const { hasAnyPermission } = useAuth();
+  // Their waiting jobs are started by someone who manages the queue (#1620)
+  const awaitingReview = !hasAnyPermission('queue:update_all', 'queue:start_unreviewed');
   const hasPhysicalAmsMapping =
   const hasPhysicalAmsMapping =
     item.printer_id != null && (item.ams_mapping?.some((trayId) => trayId >= 0) ?? false);
     item.printer_id != null && (item.ams_mapping?.some((trayId) => trayId >= 0) ?? false);
 
 
@@ -941,7 +944,7 @@ function SortableQueueItem({
             {item.manual_start && (
             {item.manual_start && (
               <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-purple-50 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 rounded-full border border-purple-200 dark:border-purple-500/20 flex items-center gap-1">
               <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-purple-50 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 rounded-full border border-purple-200 dark:border-purple-500/20 flex items-center gap-1">
                 <Hand className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
                 <Hand className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
-                {t('queue.badges.staged')}
+                {awaitingReview ? t('queue.badges.awaitingReview') : t('queue.badges.staged')}
               </span>
               </span>
             )}
             )}
             {item.require_previous_success && (
             {item.require_previous_success && (
@@ -1082,8 +1085,14 @@ function SortableQueueItem({
                     variant="ghost"
                     variant="ghost"
                     size="sm"
                     size="sm"
                     onClick={onStart}
                     onClick={onStart}
-                    disabled={!canModify('queue', 'update', item.created_by_id)}
-                    title={!canModify('queue', 'update', item.created_by_id) ? t('queue.permissions.noStartPrint') : t('queue.actions.startPrint')}
+                    disabled={awaitingReview || !canModify('queue', 'update', item.created_by_id)}
+                    title={
+                      awaitingReview
+                        ? t('queue.permissions.awaitingReview')
+                        : !canModify('queue', 'update', item.created_by_id)
+                          ? t('queue.permissions.noStartPrint')
+                          : t('queue.actions.startPrint')
+                    }
                     className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10 p-1.5 sm:p-2"
                     className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10 p-1.5 sm:p-2"
                   >
                   >
                     <Play className="w-4 h-4" />
                     <Play className="w-4 h-4" />

File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-BLQUNr6I.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-D8qp0De6.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpreadsheetPreviewModal-CG37FAae.js


File diff suppressed because it is too large
+ 1 - 1
static/assets/index-B48HgPjM.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf-uoJsYYEu.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DERg1WHl.js"></script>
+    <script type="module" crossorigin src="/assets/index-B48HgPjM.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="stylesheet" crossorigin href="/assets/index-DjfwCy3_.css">
     <link rel="stylesheet" crossorigin href="/assets/index-DjfwCy3_.css">
   </head>
   </head>

Some files were not shown because too many files changed in this diff