Merge pull request 'feat(printers): remove printers and devices as archives, purged by retention' (#28) from feat/remove-printers-devices-main-20261105 into main
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Failing after 1s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (push) Failing after 1s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Waiting to run
Shared Java Maven CI / secrets (push) Successful in 17s
Shared Java Maven CI / verify (push) Successful in 3m35s
Shared Java Maven CI / gate (push) Successful in 11s
qrun-release / release (push) Successful in 3m49s
Shared QRun source version / version (push) Successful in 17s
qrun-release / postpublish (push) Successful in 18s
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Failing after 1s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1b3648d9421543350067e95e54959dfb471b26d5] (push) Failing after 1s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Waiting to run
Shared Java Maven CI / secrets (push) Successful in 17s
Shared Java Maven CI / verify (push) Successful in 3m35s
Shared Java Maven CI / gate (push) Successful in 11s
qrun-release / release (push) Successful in 3m49s
Shared QRun source version / version (push) Successful in 17s
qrun-release / postpublish (push) Successful in 18s
This commit was merged in pull request #28.
This commit is contained in:
21 files changed
+393
-26
No files matched your search
@@ -16,6 +16,19 @@ connector 1.2.0; 1.0.x connectors keep working unchanged.
|
||||
approve/disable/set-approval processes, `PrintingService.enablePrinter`). An empty
|
||||
inventory yields an empty list; other connections never receive the frame.
|
||||
- `PrintingService.printerApprovals(deviceId)` exposes the same list to host code.
|
||||
- Remove printers and devices without losing history. New nullable `removed_at` columns on
|
||||
`printing_printer` and `printing_device` (read-only field **Removed at** on both tables, added
|
||||
by the idempotent migration). **Remove printers** (`printingRemoveSelectedPrinters`, on a
|
||||
printer record or a selection) and `PrintingService.removePrinter(deviceId, printerId)` set
|
||||
`removed_at`, clear approval and exposure, and drop the printer from the Printers default
|
||||
view (add the **Removed at is empty** filter), the operator catalog, claims and submissions,
|
||||
the device/printer selectors and the `PRINTERS` frame, which is pushed again on removal. A
|
||||
queue that the device shares again returns exposed but not approved. **Remove device**
|
||||
(`printingRemoveDevices`, confirmation required) revokes access through the existing REVOKE
|
||||
control, then `PrintingService.removeDevice(deviceId)` archives the device and all of its
|
||||
printers; an already revoked device is archived without a new command, and a removed device
|
||||
cannot be restored. Retention cleanup hard-deletes removed printers that no job references
|
||||
and removed devices with no jobs and no printers, bounded by the run's batch size.
|
||||
- Regenerated OpenAPI fragment (`printing.yaml`): semantic version 2.4.0 and the extended
|
||||
`ControlPollRequest` capability enumeration. No authorization, queue, replay or document
|
||||
behavior changed.
|
||||
|
||||
@@ -123,11 +123,13 @@ Typical setup: run **Enroll a print device** from Printing home or Print devices
|
||||
|
||||
For label printers, configure the machine's **Windows Printing Defaults** for the actual stock size and print method before testing PDFs through the service. A test profile selects document size and ZPL resolution; it does not change the Windows driver or printer's media configuration. Our 300 dpi Zebra required 4 × 6-inch stock and Thermal Direct instead of its previous 4.09 × 2-inch Thermal Transfer defaults. After correcting those settings and resuming the printer, the user confirmed the built-in PDF label, alongside the already confirmed ZPL label and laser PDF. Barcode scanning and measured alignment remain separate checks; `SPOOLED` alone is not proof of physical output.
|
||||
|
||||
On Printers, select one or more rows or open a printer record to **Approve printers**, **Disable printers**, **Print a test page**, or **Print a test label**. Standard QQQ review/result screens show the selected scope; preview does not print. Each selected printer is read again under the current user's permissions before execution. Jobs display the related printer and device names; approval, availability, format and status fields have named choices.
|
||||
On Printers, select one or more rows or open a printer record to **Approve printers**, **Disable printers**, **Remove printers**, **Print a test page**, or **Print a test label**. Standard QQQ review/result screens show the selected scope; preview does not print. Each selected printer is read again under the current user's permissions before execution. Jobs display the related printer and device names; approval, availability, format and status fields have named choices.
|
||||
|
||||
Removing is archiving, not deleting. **Remove printers** sets **Removed at** on the selected printers, clears their approval and exposure, and takes them out of the operator catalog, claims, the `PRINTERS` frame and every device/printer selector; their rows and job history stay. The Printers and Print devices tables have no server-side default filter, so add the filter **Removed at is empty** to the view you use day to day. If the device shares a removed queue again, the next inventory brings it back exposed but **not approved**, exactly like a newly discovered queue. **Remove device** (a device action that requires confirmation) revokes the device's access through the existing REVOKE control, then archives the device and all of its printers; an already revoked device is archived without a new command. A removed device cannot be restored; connect the computer again with a fresh enrollment, which creates a new device. Retention cleanup later hard-deletes removed printers and devices that no job references (see [storage and retention](docs/STORAGE-AND-RETENTION.md)).
|
||||
|
||||
Metadata names follow QBit conventions: tables and data sources use `printing_*`, while processes/widgets use `printing...`. The initial namespace is fixed to `printing`. The QBit preserves the host's `QBrandingMetaData`; customer identity belongs in the host branding, not a custom QBit build.
|
||||
|
||||
Two independent settings govern dispatch: `available` means the queue is present in the latest locally selected inventory; `enabled` means a QQQ administrator approved it. New submissions and claims require both. A connector whose HELLO advertises `printer-approval-v1` (extension 2.4.0, connector 1.2.0) receives a `PRINTERS` frame listing `{ printerId, enabled }` for every queue in its latest accepted inventory after each accepted inventory and whenever an approval changes through any path; older connectors never receive it and keep reading the operator catalog. Removing a queue from local selection makes it unavailable without deleting its approval/history. An empty inventory withdraws all queues. Withdrawing a queue cannot retract a job already accepted by Windows.
|
||||
Two independent settings govern dispatch: `available` means the queue is present in the latest locally selected inventory; `enabled` means a QQQ administrator approved it. New submissions and claims require both. A connector whose HELLO advertises `printer-approval-v1` (extension 2.4.0, connector 1.2.0) receives a `PRINTERS` frame listing `{ printerId, enabled }` for every queue in its latest accepted inventory after each accepted inventory and whenever an approval changes through any path; older connectors never receive it and keep reading the operator catalog. Removing a queue from local selection makes it unavailable without deleting its approval/history; removing a printer in QQQ additionally drops it from the frame until the device shares it again. An empty inventory withdraws all queues. Withdrawing a queue cannot retract a job already accepted by Windows.
|
||||
|
||||
Application code calls `printing.submit(JSONObject)` with the shared `SubmitJobRequest` fields. A stable business idempotency key returns the same job when retried; changing document/options/target under the same key fails. PDF is sent as bytes encoded once in base64; ZPL is UTF-8 printer-ready bytes. Default decoded limits are 100 MiB for PDF and 20 MiB for ZPL, both configurable in the native Printing connection and limits process. Queue approval and format compatibility are checked before insertion.
|
||||
|
||||
|
||||
+3
-1
@@ -125,5 +125,7 @@ processes or application code calling `enablePrinter`). Every approval path
|
||||
publishes the existing commit wakeup; the connection's serialized writer compares
|
||||
the current state with the last frame it sent and resends only on a change, so an
|
||||
unrelated job wakeup does not repeat it. An empty inventory yields
|
||||
`{ "printers": [] }`. The client does not acknowledge the frame; it carries no job
|
||||
`{ "printers": [] }`. A printer removed in QQQ (`removed_at` set) is left out and
|
||||
the frame is pushed again; it reappears, not approved, once the device shares the
|
||||
queue again. The client does not acknowledge the frame; it carries no job
|
||||
or document data. Connections without the capability never receive it.
|
||||
@@ -36,6 +36,8 @@ Printing administration exposes retention settings and a reviewed **Run cleanup*
|
||||
|
||||
When the host supplies a scheduler, maintenance runs every five minutes in bounded batches of at most 200. That permits a nominal 57,600 eligible documents per day before storage latency or failures; it is not measured production throughput. Changing a retention setting does not itself purge documents. Disabling cleanup preserves content. Without a configured scheduler there is no automatic job; the administration screen must show that condition.
|
||||
|
||||
The same run purges archived inventory. **Remove printers** and **Remove device** never delete rows; they set `removed_at` (shown as **Removed at**) so the rows leave the default views, selectors, catalog, claims and the realtime `PRINTERS` frame while job history keeps resolving to them. After the document step, each cleanup run hard-deletes, within the remaining batch allowance, removed printers that no `printing_job` references (neither by `printer_row_id` nor by device and queue identity) and then removed devices that have no jobs and no printer rows left; a removed device's control receipts and resource accounting go with it. Anything a job references is never deleted, so a removed printer or device stays, hidden, for as long as its jobs exist. The purge counts toward the run's reported total and is logged with the printer and device counts; disabling cleanup pauses it together with document removal. Cleanup never deletes an active (non-removed) printer or device, and never deletes a job.
|
||||
|
||||
Only eligible successful terminal jobs lose their document content. Active, failed or uncertain work remains protected. Job metadata, document hashes, receipts and idempotency records remain available for auditing and duplicate prevention. Legacy completion times are recovered only from durable successful events; unknown completion dates remain protected. Existing inline database documents remain readable; the initial storage integration does not silently migrate old bytes to S3. Reprinting a job whose document has been removed returns an explicit unavailable-document result.
|
||||
|
||||
## Failure and versioning behavior
|
||||
|
||||
@@ -18,7 +18,13 @@ Open **Printers** in QQQ. The operational columns show the printer name, named p
|
||||
|
||||
## Act on one printer or a selection
|
||||
|
||||
Select one or more printer rows and open **Actions**, or open an individual printer record and use its actions. Available operations include **Approve printers**, **Disable printers**, **Print a test page**, and **Print a test label**. The standard QQQ review screen describes the selection; reviewing does not print. Submitting a test action queues one job per selected eligible printer.
|
||||
Select one or more printer rows and open **Actions**, or open an individual printer record and use its actions. Available operations include **Approve printers**, **Disable printers**, **Remove printers**, **Print a test page**, and **Print a test label**. The standard QQQ review screen describes the selection; reviewing does not print. Submitting a test action queues one job per selected eligible printer.
|
||||
|
||||
**Remove printers** archives the selection: each printer gets a **Removed at** time, loses approval and exposure, and disappears from the operator catalog, the connector's approval frame and the device/printer choices in the setup and test-print forms. Its record and job history stay. The Printers table has no server-side default filter, so add **Removed at is empty** to the Printers view you keep (the screenshots above predate this field) to hide archived rows; clear it to audit them. If the Windows companion shares the same queue again, the next inventory brings the printer back as exposed but not approved, so approve it again before it can print.
|
||||
|
||||
## Remove a device
|
||||
|
||||
Open a Print devices record or select rows and choose **Remove device**. The action requires the confirmation checkbox. It revokes the device's access permanently through the same control as **Revoke device access**, then marks the device and all of its printers removed. The device drops out of the Print devices view (add **Removed at is empty** there too), the setup and test-print device choices and every enrollment, claim and control path; its jobs remain in Print jobs and its control history keeps the REVOKE receipt. A removed device cannot be un-removed; enroll the computer again to create a new device. Retention cleanup eventually hard-deletes removed devices and printers that no job references.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -93,6 +93,16 @@ public final class PrintingAgentAuthorization
|
||||
cursor = page.isNull("nextCursor") ? null : page.getString("nextCursor");
|
||||
}
|
||||
while(cursor != null);
|
||||
// Removed printers are hidden from the page but still belong to the device's row set: a re-shared queue reactivates one.
|
||||
for(var removed : PrintingControlAuthorization.query(new QueryInput().withTableName(PrintingTables.PRINTER)
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("device_id", QCriteriaOperator.EQUALS, device)).withCriteria(new QFilterCriteria("removed_at", QCriteriaOperator.IS_NOT_BLANK)).withLimit(10001))).getRecords())
|
||||
{
|
||||
authorized.add(removed.getValueString("id"));
|
||||
if(authorized.size() > 10000)
|
||||
{
|
||||
throw new PrintingException(429, "Inventory exceeds the bounded native review limit");
|
||||
}
|
||||
}
|
||||
// Even an empty inventory must have native table READ access.
|
||||
PrintingControlAuthorization.query(new QueryInput().withTableName(PrintingTables.PRINTER)
|
||||
.withFilter(new QQueryFilter(new QFilterCriteria("device_id", QCriteriaOperator.EQUALS, device)).withLimit(1)));
|
||||
|
||||
@@ -133,7 +133,8 @@ public final class PrintingControlAuthorization
|
||||
PrintingDeviceProcesses.Action selected = null;
|
||||
for(PrintingDeviceProcesses.Action action : PrintingDeviceProcesses.Action.values())
|
||||
{
|
||||
if(action.wire.equals(request.optString("action")))
|
||||
// The first action owning a wire value is its control process; Remove device reuses REVOKE's wire without owning it.
|
||||
if(selected == null && action.wire.equals(request.optString("action")))
|
||||
{
|
||||
selected = action;
|
||||
}
|
||||
|
||||
@@ -82,6 +82,8 @@ public final class PrintingDeviceProcesses
|
||||
"Connected agents will be asked to resume normal traffic. An offline computer cannot be reached immediately."),
|
||||
REVOKE("printingRevokeDevices", "Revoke device access", "REVOKE", true, false,
|
||||
"Access will be revoked permanently. The old credential cannot be restored; local re-enrollment is required."),
|
||||
REMOVE("printingRemoveDevices", "Remove device", "REVOKE", true, false,
|
||||
"Access will be revoked permanently and the device and its printers will be hidden from the printing views. Job history is kept. A removed device cannot be restored; a new enrollment creates a new device."),
|
||||
REFRESH("printingRefreshDevices", "Refresh printer inventory", "REFRESH_INVENTORY", false, false,
|
||||
"A bounded refresh request will wait for a compatible agent. Acceptance does not mean it has executed."),
|
||||
CLEAR("printingClearDeviceQueues", "Cancel queued jobs", "CLEAR_QUEUE", true, false,
|
||||
@@ -326,8 +328,17 @@ public final class PrintingDeviceProcesses
|
||||
}
|
||||
try
|
||||
{
|
||||
JSONObject result = PrintingControlAuthorization.deviceAction(deviceId, request);
|
||||
String status = result.getString("status");
|
||||
String status;
|
||||
if(action == Action.REMOVE)
|
||||
{
|
||||
// Removal reuses the REVOKE control for the credential; an already revoked device only needs archiving.
|
||||
status = current.getValueString("revoked_at") == null ? PrintingControlAuthorization.deviceAction(deviceId, request).getString("status") : "SUCCEEDED";
|
||||
PrintingProcesses.currentService().removeDevice(deviceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
status = PrintingControlAuthorization.deviceAction(deviceId, request).getString("status");
|
||||
}
|
||||
ProcessSummaryLine summary = "PENDING".equals(status) ? pending : ("SUCCEEDED".equals(status) ? applied : unresolved);
|
||||
summary.incrementCountAndAddPrimaryKey(current.getValue("id"));
|
||||
output.getRecords().add(current);
|
||||
|
||||
@@ -157,9 +157,10 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
.withBlock(text("Printing queue and storage limits").withLink(new BlockLink("/app/printingAdmissionSettings/?returnTo=%2Fapp%2Fprinting%2F")))
|
||||
.withBlock(text("Run retention cleanup").withLink(new BlockLink("/app/printingRunRetention/?returnTo=%2Fapp%2Fprinting%2F")))
|
||||
.withBlock(text("Printing setup and help").withLink(new BlockLink("https://github.com/QRun-IO/qbit-printing/blob/main/docs/OPERATIONS.md")));
|
||||
if(count(DEVICE, new QQueryFilter()) == 0)
|
||||
QQueryFilter present = new QQueryFilter().withCriteria("removed_at", QCriteriaOperator.IS_BLANK);
|
||||
if(count(DEVICE, present) == 0)
|
||||
{
|
||||
data.withBlock(text("No print devices are connected. Open Print devices and use Enroll a print device; then connect the Windows agent and expose its printers.").withLink(new BlockLink(tableUrl(DEVICE, new QQueryFilter()))));
|
||||
data.withBlock(text("No print devices are connected. Open Print devices and use Enroll a print device; then connect the Windows agent and expose its printers.").withLink(new BlockLink(tableUrl(DEVICE, present))));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -175,6 +175,7 @@ public final class PrintingOperatorAuthorization
|
||||
}
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, 1))
|
||||
.withCriteria(new QFilterCriteria("available", QCriteriaOperator.EQUALS, 1))
|
||||
.withCriteria(new QFilterCriteria("removed_at", QCriteriaOperator.IS_BLANK))
|
||||
.withCriteria(new QFilterCriteria("tenant_id", QCriteriaOperator.EQUALS, PrintingProcesses.currentService().getTenantId()))
|
||||
.withOrderBy(new QFilterOrderBy("id", true)).withLimit(limit + 1);
|
||||
if(after != null)
|
||||
|
||||
@@ -89,6 +89,7 @@ public class PrintingProcesses implements BackendStep
|
||||
selected(instance, "printingTestSelectedZpl", "Print a test label", "Each selected ZPL printer will receive one test label after you continue.");
|
||||
selected(instance, "printingEnableSelectedPrinters", "Approve printers", "The selected printers will be approved for printing after you continue.");
|
||||
selected(instance, "printingDisableSelectedPrinters", "Disable printers", "The selected printers will stop receiving new print jobs after you continue.");
|
||||
selected(instance, "printingRemoveSelectedPrinters", "Remove printers", "The selected printers will be removed from Printers, the operator catalog and their connectors after you continue. Job history is kept; sharing a queue again from its device brings it back awaiting approval.");
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +155,8 @@ public class PrintingProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QFieldMetaData deviceField()
|
||||
{
|
||||
return field("deviceId", "Print device").withPossibleValueSourceName(PrintingTables.DEVICE);
|
||||
return field("deviceId", "Print device").withPossibleValueSourceName(PrintingTables.DEVICE)
|
||||
.withPossibleValueSourceFilter(new QQueryFilter(new QFilterCriteria("removed_at", QCriteriaOperator.IS_BLANK)));
|
||||
}
|
||||
|
||||
|
||||
@@ -164,7 +166,8 @@ public class PrintingProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QFieldMetaData printerField(@Nullable String format)
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("device_id", QCriteriaOperator.EQUALS, "${input.deviceId}"));
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("device_id", QCriteriaOperator.EQUALS, "${input.deviceId}"))
|
||||
.withCriteria(new QFilterCriteria("removed_at", QCriteriaOperator.IS_BLANK));
|
||||
if(format != null)
|
||||
{
|
||||
filter.withCriteria(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, 1));
|
||||
@@ -532,6 +535,7 @@ public class PrintingProcesses implements BackendStep
|
||||
case "printingTestSelectedPdf" -> submitTest(printer, true, input.getValueString("testProfile"), testRequestKey(input, printer));
|
||||
case "printingTestSelectedZpl" -> submitTest(printer, false, input.getValueString("testProfile"), testRequestKey(input, printer));
|
||||
case "printingEnableSelectedPrinters", "printingDisableSelectedPrinters" -> currentService().enablePrinter(printer.getValueString("device_id"), printer.getValueString("printer_id"), "printingEnableSelectedPrinters".equals(input.getProcessName()));
|
||||
case "printingRemoveSelectedPrinters" -> currentService().removePrinter(printer.getValueString("device_id"), printer.getValueString("printer_id"));
|
||||
default -> throw new QException("Unknown selected printing action");
|
||||
}
|
||||
done.incrementCountAndAddPrimaryKey(printer.getValue("id"));
|
||||
|
||||
@@ -96,7 +96,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
config.getService().configureStorage(instance, config.getStorageTableName());
|
||||
instance.addQBit(new QBitMetaData().withGroupId("com.kingsrook.qbits").withArtifactId("qbit-printing").withVersion(version()).withNamespace("printing").withConfig(config));
|
||||
instance.addSecurityKeyType(new QSecurityKeyType().withName("printing_tenant"));
|
||||
QTableMetaData devices = table(PrintingTables.DEVICE, "Print devices", List.of("id", "tenant_id", "facility_id", "name", "display_name", "computer_name", "description", "location", "agent_version", "last_seen_at"));
|
||||
QTableMetaData devices = table(PrintingTables.DEVICE, "Print devices", List.of("id", "tenant_id", "facility_id", "name", "display_name", "computer_name", "description", "location", "agent_version", "last_seen_at", "removed_at"));
|
||||
devices.withField(new QFieldMetaData("enabled", QFieldType.INTEGER).withLabel("Enabled").withPossibleValueSourceName("printing_enabled"));
|
||||
devices.withField(new QFieldMetaData("dispatch_paused", QFieldType.INTEGER).withLabel("Printing paused").withPossibleValueSourceName("printing_paused"));
|
||||
devices.withField(new QFieldMetaData("connection_state", QFieldType.STRING).withLabel("Requested connection").withPossibleValueSourceName("printing_connectionState"));
|
||||
@@ -110,7 +110,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
devices.withVirtualField(new QVirtualFieldMetaData("control_status", QFieldType.STRING).withLabel("Control application").withPossibleValueSourceName("printing_controlApplication").withIsQuerySelectable(true));
|
||||
devices.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(PrintingDeviceStatus.class));
|
||||
devices.getField("facility_id").withLabel("Facility").withPossibleValueSourceName(PrintingFacilities.register(instance, config));
|
||||
QTableMetaData printers = table(PrintingTables.PRINTER, "Printers", List.of("id", "tenant_id", "device_id", "printer_id", "name", "display_name", "queue_name", "description", "location", "driver_name", "port_name", "status", "formats", "last_seen_at"));
|
||||
QTableMetaData printers = table(PrintingTables.PRINTER, "Printers", List.of("id", "tenant_id", "device_id", "printer_id", "name", "display_name", "queue_name", "description", "location", "driver_name", "port_name", "status", "formats", "last_seen_at", "removed_at"));
|
||||
printers.withField(new QFieldMetaData("enabled", QFieldType.INTEGER).withLabel("Approved for printing").withPossibleValueSourceName("printing_approved"));
|
||||
printers.withField(new QFieldMetaData("available", QFieldType.INTEGER).withLabel("Exposed by device").withPossibleValueSourceName("printing_available"));
|
||||
printers.withField(new QFieldMetaData("media_profiles_json", QFieldType.STRING).withLabel("Reported queue default media"));
|
||||
|
||||
@@ -167,6 +167,7 @@ public final class PrintingRecordProcesses
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, 1))
|
||||
.withCriteria(new QFilterCriteria("available", QCriteriaOperator.EQUALS, 1))
|
||||
.withCriteria(new QFilterCriteria("removed_at", QCriteriaOperator.IS_BLANK))
|
||||
.withCriteria(new QFilterCriteria("formats", QCriteriaOperator.CONTAINS, "PDF"));
|
||||
return new QFieldMetaData(PRINTER_FIELD, QFieldType.STRING).withLabel("Printer").withIsRequired(true)
|
||||
.withPossibleValueSourceName(PrintingTables.PRINTER).withPossibleValueSourceFilter(filter);
|
||||
|
||||
@@ -177,7 +177,7 @@ public class PrintingRetentionProcesses implements BackendStep
|
||||
else if("execute".equals(input.getStepName()) && (RUN_NOW.equals(input.getProcessName()) || scheduled))
|
||||
{
|
||||
int removed = service.runRetention();
|
||||
output.addValue("result", "Removed " + removed + " eligible documents or abandoned uploads. Job receipts and uncertain or active work were preserved. Cleanup processes one bounded batch per run.");
|
||||
output.addValue("result", "Removed " + removed + " eligible documents, abandoned uploads or purged removed printers and devices that no job references. Job receipts and uncertain or active work were preserved. Cleanup processes one bounded batch per run.");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -212,7 +212,7 @@ public class PrintingRetentionProcesses implements BackendStep
|
||||
output.addValue("progress", "Eligible documents waiting: " + settings.optLong("eligibleBacklog", 0)
|
||||
+ "; last run: " + (lastRun.isBlank() ? "Not run yet" : lastRun) + "; removed: " + settings.optInt("lastRunCount", 0)
|
||||
+ (settings.optString("lastRunError", "").isBlank() ? "" : "; last run problem: " + settings.optString("lastRunError")));
|
||||
output.addValue("safety", "Cleanup removes only old successful-terminal document content. Active, failed and uncertain jobs, receipt metadata and deduplication records remain. Removed documents cannot be reprinted from history. Continue to edit the policy or run the selected cleanup action.");
|
||||
output.addValue("safety", "Cleanup removes only old successful-terminal document content, and purges removed printers and devices that no job references. Active, failed and uncertain jobs, receipt metadata and deduplication records remain. Removed documents cannot be reprinted from history. Continue to edit the policy or run the selected cleanup action.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -37,10 +37,12 @@ import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.uber.nullaway.annotations.EnsuresNonNull;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -50,6 +52,7 @@ import org.jspecify.annotations.Nullable;
|
||||
public class PrintingService
|
||||
{
|
||||
static final String CANCELED_BEFORE_DISPATCH = "Canceled by operator before dispatch";
|
||||
private static final QLogger LOG = QLogger.getLogger(PrintingService.class);
|
||||
private static final String JOB_COLUMNS = "id, tenant_id, device_id, printer_id, title, format, content_hash, content_length, options_json, idempotency_key, created_at, status, message, spooler_job_id, printer_row_id";
|
||||
private final DataSource dataSource;
|
||||
private final String tenantId;
|
||||
@@ -281,6 +284,8 @@ public class PrintingService
|
||||
"CREATE TABLE IF NOT EXISTS printing_printer (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL, device_id VARCHAR(36) NOT NULL REFERENCES printing_device(id), printer_id VARCHAR(200) NOT NULL, name VARCHAR(300) NOT NULL, driver_name VARCHAR(300), port_name VARCHAR(300), status VARCHAR(40) NOT NULL, formats VARCHAR(100) NOT NULL, enabled INTEGER NOT NULL, last_seen_at VARCHAR(40) NOT NULL, UNIQUE(device_id, printer_id))",
|
||||
"ALTER TABLE printing_printer ADD COLUMN IF NOT EXISTS available INTEGER DEFAULT 0 NOT NULL",
|
||||
"ALTER TABLE printing_printer ADD COLUMN IF NOT EXISTS media_profiles_json TEXT DEFAULT '[]' NOT NULL",
|
||||
"ALTER TABLE printing_printer ADD COLUMN IF NOT EXISTS removed_at VARCHAR(40)",
|
||||
"ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS removed_at VARCHAR(40)",
|
||||
"CREATE TABLE IF NOT EXISTS printing_job (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL, device_id VARCHAR(36) NOT NULL REFERENCES printing_device(id), printer_id VARCHAR(200) NOT NULL, title VARCHAR(300) NOT NULL, format VARCHAR(10) NOT NULL, content_hash VARCHAR(64) NOT NULL, content_base64 TEXT NOT NULL, content_length INTEGER NOT NULL, options_json VARCHAR(2000) NOT NULL, idempotency_key VARCHAR(300) NOT NULL, created_at VARCHAR(40) NOT NULL, status VARCHAR(40) NOT NULL, message VARCHAR(2000), spooler_job_id VARCHAR(100), UNIQUE(tenant_id, idempotency_key))",
|
||||
"CREATE TABLE IF NOT EXISTS printing_event (id VARCHAR(100) PRIMARY KEY, job_id VARCHAR(36) NOT NULL REFERENCES printing_job(id), status VARCHAR(40) NOT NULL, event_json VARCHAR(4000) NOT NULL, created_at VARCHAR(40) NOT NULL)",
|
||||
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS printer_row_id VARCHAR(36)",
|
||||
@@ -782,6 +787,11 @@ public class PrintingService
|
||||
String now = Instant.now().toString();
|
||||
String driver = optionalText(printer.optString("driverName"), "driverName", 1024);
|
||||
String port = optionalText(printer.optString("portName"), "portName", 1024);
|
||||
if(update(connection, "UPDATE printing_printer SET removed_at=NULL, enabled=0 WHERE device_id=? AND printer_id=? AND removed_at IS NOT NULL", deviceId, printerId) > 0)
|
||||
{
|
||||
// A removed queue shared again re-enters the inventory as it was first discovered: exposed, awaiting approval, history intact.
|
||||
LOG.info("A removed printer re-appeared in the device inventory and awaits approval again", logPair("deviceId", deviceId), logPair("printerId", printerId));
|
||||
}
|
||||
int changed = update(connection, "UPDATE printing_printer SET queue_name=?, name=COALESCE(NULLIF(display_name,''),?), driver_name=?, port_name=?, status=?, formats=?, last_seen_at=?, available=1 WHERE device_id=? AND printer_id=?", name, name, driver, port, status, formats, now, deviceId, printerId);
|
||||
if(changed == 0)
|
||||
{
|
||||
@@ -808,7 +818,7 @@ public class PrintingService
|
||||
** Approval of every printer in the device's latest accepted inventory, for
|
||||
** the realtime PRINTERS frame: printerId and enabled per available queue,
|
||||
** nothing else. A withdrawn queue is not listed even though its approval and
|
||||
** history are retained.
|
||||
** history are retained; a removed queue is never listed.
|
||||
***************************************************************************/
|
||||
public JSONArray printerApprovals(String deviceId) throws SQLException
|
||||
{
|
||||
@@ -816,7 +826,7 @@ public class PrintingService
|
||||
{
|
||||
requireDevice(connection, deviceId);
|
||||
JSONArray result = new JSONArray();
|
||||
for(JSONObject row : query(connection, "SELECT printer_id, enabled FROM printing_printer WHERE tenant_id=? AND device_id=? AND available=1 ORDER BY printer_id LIMIT 200", tenantId, deviceId))
|
||||
for(JSONObject row : query(connection, "SELECT printer_id, enabled FROM printing_printer WHERE tenant_id=? AND device_id=? AND available=1 AND removed_at IS NULL ORDER BY printer_id LIMIT 200", tenantId, deviceId))
|
||||
{
|
||||
result.put(new JSONObject().put("printerId", row.getString("printer_id")).put("enabled", row.getInt("enabled") == 1));
|
||||
}
|
||||
@@ -836,7 +846,33 @@ public class PrintingService
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
requireDevice(connection, deviceId);
|
||||
if(update(connection, "UPDATE printing_printer SET enabled=? WHERE device_id=? AND printer_id=?", enabled ? 1 : 0, deviceId, printerId) == 0)
|
||||
if(update(connection, "UPDATE printing_printer SET enabled=? WHERE device_id=? AND printer_id=? AND removed_at IS NULL", enabled ? 1 : 0, deviceId, printerId) == 0)
|
||||
{
|
||||
if(!query(connection, "SELECT id FROM printing_printer WHERE device_id=? AND printer_id=?", deviceId, printerId).isEmpty())
|
||||
{
|
||||
throw new PrintingException(409, "Printer was removed; share it from the device again before changing its approval");
|
||||
}
|
||||
throw new PrintingException(404, "Printer not found");
|
||||
}
|
||||
}
|
||||
realtimeBus.publish(tenantId, deviceId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Archive a printer: it leaves the Printers view, the operator catalog, claims
|
||||
** and the PRINTERS frame, but its row and job history stay. A later inventory
|
||||
** that shares the same queue again brings it back exposed and not approved.
|
||||
** Removing an already removed printer changes nothing.
|
||||
***************************************************************************/
|
||||
public void removePrinter(String deviceId, String printerId) throws SQLException
|
||||
{
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
requireDeviceRow(connection, deviceId);
|
||||
if(update(connection, "UPDATE printing_printer SET removed_at=?, enabled=0, available=0 WHERE tenant_id=? AND device_id=? AND printer_id=? AND removed_at IS NULL", Instant.now().toString(), tenantId, deviceId, printerId) == 0
|
||||
&& query(connection, "SELECT id FROM printing_printer WHERE tenant_id=? AND device_id=? AND printer_id=?", tenantId, deviceId, printerId).isEmpty())
|
||||
{
|
||||
throw new PrintingException(404, "Printer not found");
|
||||
}
|
||||
@@ -846,6 +882,47 @@ public class PrintingService
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Archive a device whose access has already been revoked: the device and all
|
||||
** of its printers leave the default views and every dispatch path, while job
|
||||
** history stays readable. Revocation is the existing REVOKE control action
|
||||
** (credential rotation, disconnect, pending-command outcome); this method
|
||||
** never performs it and refuses a device that still holds a live credential.
|
||||
** A removed device cannot be restored; a new enrollment creates a new device.
|
||||
***************************************************************************/
|
||||
public void removeDevice(String deviceId) throws SQLException
|
||||
{
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
JSONObject device = requireDeviceRow(connection, deviceId);
|
||||
if(!device.has("revoked_at"))
|
||||
{
|
||||
throw new PrintingException(409, "Revoke the device's access before removing it");
|
||||
}
|
||||
String now = Instant.now().toString();
|
||||
update(connection, "UPDATE printing_device SET removed_at=? WHERE tenant_id=? AND id=? AND removed_at IS NULL", now, tenantId, deviceId);
|
||||
update(connection, "UPDATE printing_printer SET removed_at=?, enabled=0, available=0 WHERE tenant_id=? AND device_id=? AND removed_at IS NULL", now, tenantId, deviceId);
|
||||
}
|
||||
realtimeBus.publish(tenantId, deviceId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Tenant ownership of a device row regardless of its revoked or removed state.
|
||||
***************************************************************************/
|
||||
private JSONObject requireDeviceRow(Connection connection, String deviceId) throws SQLException
|
||||
{
|
||||
List<JSONObject> rows = query(connection, "SELECT id, revoked_at, removed_at FROM printing_device WHERE id=? AND tenant_id=?", deviceId, tenantId);
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
throw new PrintingException(404, "Device not found");
|
||||
}
|
||||
return rows.getFirst();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Durably enqueue an immutable document. Repeated identical keys return it.
|
||||
***************************************************************************/
|
||||
@@ -1095,7 +1172,7 @@ public class PrintingService
|
||||
throw new SQLException("Interrupted while waiting for the existing submission", exception);
|
||||
}
|
||||
}
|
||||
List<JSONObject> printers = query(connection, "SELECT * FROM printing_printer WHERE device_id=? AND printer_id=? AND enabled=1 AND available=1", deviceId, printerId);
|
||||
List<JSONObject> printers = query(connection, "SELECT * FROM printing_printer WHERE device_id=? AND printer_id=? AND enabled=1 AND available=1 AND removed_at IS NULL", deviceId, printerId);
|
||||
if(printers.isEmpty() || !new JSONArray(printers.getFirst().getString("formats")).toList().contains(format))
|
||||
{
|
||||
throw new PrintingException(400, "Printer is not enabled for this format");
|
||||
@@ -1123,7 +1200,7 @@ public class PrintingService
|
||||
requireDevice(connection, deviceId);
|
||||
query(connection, "SELECT id FROM printing_device WHERE id=? FOR UPDATE", deviceId);
|
||||
requireDevice(connection, deviceId);
|
||||
printers = query(connection, "SELECT * FROM printing_printer WHERE device_id=? AND printer_id=? AND enabled=1 AND available=1", deviceId, printerId);
|
||||
printers = query(connection, "SELECT * FROM printing_printer WHERE device_id=? AND printer_id=? AND enabled=1 AND available=1 AND removed_at IS NULL", deviceId, printerId);
|
||||
if(printers.isEmpty() || !new JSONArray(printers.getFirst().getString("formats")).toList().contains(format))
|
||||
{
|
||||
throw new PrintingException(400, "Printer is not enabled for this format");
|
||||
@@ -1404,7 +1481,7 @@ public class PrintingService
|
||||
connection.commit();
|
||||
return new JSONObject().put("job", JSONObject.NULL);
|
||||
}
|
||||
List<JSONObject> candidates = query(connection, "SELECT j." + JOB_COLUMNS.replace(", ", ", j.") + " FROM printing_job j JOIN printing_printer p ON p.device_id=j.device_id AND p.printer_id=j.printer_id WHERE j.tenant_id=? AND j.device_id=? AND p.enabled=1 AND p.available=1 AND j.status IN ('QUEUED','CLAIMED') AND ((j.format='PDF' AND p.formats LIKE '%\"PDF\"%') OR (j.format='ZPL' AND p.formats LIKE '%\"ZPL\"%'))" + (authorizedJobId == null ? "" : " AND j.id=?") + " ORDER BY j.created_at, j.id LIMIT 1", authorizedJobId == null ? new Object[] { tenantId, deviceId } : new Object[] { tenantId, deviceId, authorizedJobId });
|
||||
List<JSONObject> candidates = query(connection, "SELECT j." + JOB_COLUMNS.replace(", ", ", j.") + " FROM printing_job j JOIN printing_printer p ON p.device_id=j.device_id AND p.printer_id=j.printer_id WHERE j.tenant_id=? AND j.device_id=? AND p.enabled=1 AND p.available=1 AND p.removed_at IS NULL AND j.status IN ('QUEUED','CLAIMED') AND ((j.format='PDF' AND p.formats LIKE '%\"PDF\"%') OR (j.format='ZPL' AND p.formats LIKE '%\"ZPL\"%'))" + (authorizedJobId == null ? "" : " AND j.id=?") + " ORDER BY j.created_at, j.id LIMIT 1", authorizedJobId == null ? new Object[] { tenantId, deviceId } : new Object[] { tenantId, deviceId, authorizedJobId });
|
||||
if(candidates.isEmpty())
|
||||
{
|
||||
connection.commit();
|
||||
@@ -1564,7 +1641,7 @@ public class PrintingService
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
JSONArray result = new JSONArray();
|
||||
for(JSONObject row : query(connection, "SELECT * FROM printing_device WHERE tenant_id=? ORDER BY name", tenantId))
|
||||
for(JSONObject row : query(connection, "SELECT * FROM printing_device WHERE tenant_id=? AND removed_at IS NULL ORDER BY name", tenantId))
|
||||
{
|
||||
result.put(new JSONObject().put("deviceId", row.getString("id")).put("facilityId", row.getString("facility_id")).put("deviceName", row.getString("name")).put("computerName", row.getString("computer_name"))
|
||||
.put("profile", new JSONObject().put("name", row.optString("display_name")).put("description", row.optString("description")).put("location", row.optString("location")))
|
||||
@@ -1607,7 +1684,7 @@ public class PrintingService
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
requireDevice(connection, deviceId);
|
||||
String sql = "SELECT * FROM printing_printer WHERE tenant_id=? AND device_id=?";
|
||||
String sql = "SELECT * FROM printing_printer WHERE tenant_id=? AND device_id=? AND removed_at IS NULL";
|
||||
List<JSONObject> rows = name == null ? query(connection, sql + " ORDER BY name,id LIMIT ?", tenantId, deviceId, limit + 1)
|
||||
: query(connection, sql + " AND (name>? OR (name=? AND id>?)) ORDER BY name,id LIMIT ?", tenantId, deviceId, name, name, id, limit + 1);
|
||||
JSONArray result = new JSONArray();
|
||||
@@ -1971,7 +2048,7 @@ public class PrintingService
|
||||
***************************************************************************/
|
||||
private void requireDevice(Connection connection, String deviceId) throws SQLException
|
||||
{
|
||||
if(query(connection, "SELECT id FROM printing_device WHERE id=? AND tenant_id=? AND enabled=1 AND revoked_at IS NULL", deviceId, tenantId).isEmpty())
|
||||
if(query(connection, "SELECT id FROM printing_device WHERE id=? AND tenant_id=? AND enabled=1 AND revoked_at IS NULL AND removed_at IS NULL", deviceId, tenantId).isEmpty())
|
||||
{
|
||||
throw new PrintingException(404, "Device not found");
|
||||
}
|
||||
@@ -2086,6 +2163,7 @@ public class PrintingService
|
||||
renewRetentionLease(connection, token);
|
||||
progress[0] += cleanupOrphan(connection, orphan.getString("id"));
|
||||
}
|
||||
progress[0] += purgeRemoved(connection, Math.max(0, policy.batchSize() - progress[0]), token);
|
||||
}
|
||||
update(connection, "UPDATE printing_settings SET last_run_at=?, last_run_count=?, last_run_error='', lease_token=NULL, lease_expires_at=NULL WHERE tenant_id=? AND lease_token=?", Instant.now().toString(), progress[0], tenantId, token);
|
||||
connection.commit();
|
||||
@@ -2103,6 +2181,48 @@ public class PrintingService
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Hard-delete removed printers that no job references (by printer row or by
|
||||
** device and queue identity) and removed devices that have no jobs and no
|
||||
** printers left, at most `limit` rows per run. Anything a job references is
|
||||
** never deleted, so history stays queryable for as long as the job exists.
|
||||
** A removed device's control receipts go with it; its jobs would have kept it.
|
||||
***************************************************************************/
|
||||
private int purgeRemoved(Connection connection, int limit, String token) throws SQLException
|
||||
{
|
||||
if(limit < 1)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
int printers = 0;
|
||||
int devices = 0;
|
||||
String unreferencedPrinter = " AND NOT EXISTS (SELECT 1 FROM printing_job j WHERE j.printer_row_id=printing_printer.id OR (j.device_id=printing_printer.device_id AND j.printer_id=printing_printer.printer_id))";
|
||||
for(JSONObject row : query(connection, "SELECT id FROM printing_printer WHERE tenant_id=? AND removed_at IS NOT NULL" + unreferencedPrinter + " ORDER BY removed_at, id LIMIT ?", tenantId, limit))
|
||||
{
|
||||
renewRetentionLease(connection, token);
|
||||
printers += update(connection, "DELETE FROM printing_printer WHERE tenant_id=? AND id=? AND removed_at IS NOT NULL" + unreferencedPrinter, tenantId, row.getString("id"));
|
||||
}
|
||||
String unreferencedDevice = " AND NOT EXISTS (SELECT 1 FROM printing_job j WHERE j.device_id=printing_device.id) AND NOT EXISTS (SELECT 1 FROM printing_printer p WHERE p.device_id=printing_device.id)";
|
||||
for(JSONObject row : query(connection, "SELECT id FROM printing_device WHERE tenant_id=? AND removed_at IS NOT NULL" + unreferencedDevice + " ORDER BY removed_at, id LIMIT ?", tenantId, Math.max(0, limit - printers)))
|
||||
{
|
||||
renewRetentionLease(connection, token);
|
||||
if(query(connection, "SELECT id FROM printing_device WHERE tenant_id=? AND id=? AND removed_at IS NOT NULL" + unreferencedDevice, tenantId, row.getString("id")).isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
update(connection, "DELETE FROM printing_device_command WHERE tenant_id=? AND device_id=?", tenantId, row.getString("id"));
|
||||
update(connection, "DELETE FROM printing_resource_usage WHERE tenant_id=? AND device_id=?", tenantId, row.getString("id"));
|
||||
devices += update(connection, "DELETE FROM printing_device WHERE tenant_id=? AND id=?", tenantId, row.getString("id"));
|
||||
}
|
||||
if(printers > 0 || devices > 0)
|
||||
{
|
||||
LOG.info("Purged removed printing rows that no job references", logPair("tenantId", tenantId), logPair("printers", printers), logPair("devices", devices));
|
||||
}
|
||||
return printers + devices;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Stop safely if another worker acquired an expired maintenance lease.
|
||||
***************************************************************************/
|
||||
|
||||
@@ -132,6 +132,58 @@ class PrintingDeviceProcessesTest
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Remove device revokes through the existing control, archives the device and
|
||||
** its printers, keeps job history readable, and replays without a new command.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void removeDeviceRevokesArchivesAndKeepsHistory() throws Exception
|
||||
{
|
||||
var fixture = PrintingProcessesTest.fixture();
|
||||
var process = fixture.instance().getProcess(PrintingDeviceProcesses.Action.REMOVE.process);
|
||||
assertEquals("Remove device", process.getLabel());
|
||||
assertTrue(process.getStep("confirmControl") instanceof com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData);
|
||||
assertTrue(PrintingDeviceProcesses.Action.REMOVE.preview.contains("history is kept"));
|
||||
String job = submit(fixture.service(), fixture.device());
|
||||
RunBackendStepInput input = prepared(PrintingDeviceProcesses.Action.REMOVE, fixture.device());
|
||||
assertThrows(QException.class, () -> new PrintingDeviceProcesses.Load().runOnePage(input, new RunBackendStepOutput()));
|
||||
assertEquals(2, fixture.service().devices().length());
|
||||
input.addValue(PrintingDeviceProcesses.CONFIRM, true);
|
||||
new PrintingDeviceProcesses.Load().runOnePage(input, new RunBackendStepOutput());
|
||||
QRecord device = get(PrintingTables.DEVICE, fixture.device());
|
||||
assertFalse(device.getValueString("revoked_at").isBlank());
|
||||
assertFalse(device.getValueString("removed_at").isBlank());
|
||||
assertEquals(0, device.getValueInteger("enabled"));
|
||||
assertEquals(List.of(fixture.otherDevice()), fixture.service().devices().toList().stream().map(row -> ((Map<?, ?>) row).get("deviceId")).toList());
|
||||
QRecord printer = get(PrintingTables.PRINTER, fixture.printerId());
|
||||
assertFalse(printer.getValueString("removed_at").isBlank());
|
||||
assertEquals(0, printer.getValueInteger("enabled"));
|
||||
assertEquals(0, printer.getValueInteger("available"));
|
||||
assertEquals(404, assertThrows(PrintingException.class, () -> fixture.service().printers(fixture.device())).getStatus());
|
||||
assertEquals(404, assertThrows(PrintingException.class, () -> fixture.service().claim(fixture.device())).getStatus());
|
||||
assertEquals("QUEUED", fixture.service().getJob(job).getString("status"));
|
||||
assertEquals(job, get(PrintingTables.JOB, job).getValueString("id"));
|
||||
var commands = fixture.service().deviceCommandsPage(fixture.device(), 50, null).getJSONArray("commands");
|
||||
assertEquals(1, commands.length());
|
||||
assertEquals("REVOKE", commands.getJSONObject(0).getString("action"));
|
||||
new PrintingDeviceProcesses.Load().runOnePage(input, new RunBackendStepOutput());
|
||||
assertEquals(1, fixture.service().deviceCommandsPage(fixture.device(), 50, null).getJSONArray("commands").length());
|
||||
assertEquals(device.getValueString("removed_at"), get(PrintingTables.DEVICE, fixture.device()).getValueString("removed_at"));
|
||||
QueryInput visible = new QueryInput().withTableName(PrintingTables.DEVICE).withFilter(new QQueryFilter().withCriteria("removed_at", QCriteriaOperator.IS_BLANK));
|
||||
assertEquals(List.of(fixture.otherDevice()), new QueryAction().execute(visible).getRecords().stream().map(row -> row.getValueString("id")).toList());
|
||||
RunBackendStepInput revokedOnly = prepared(PrintingDeviceProcesses.Action.REVOKE, fixture.otherDevice());
|
||||
revokedOnly.addValue(PrintingDeviceProcesses.CONFIRM, true);
|
||||
new PrintingDeviceProcesses.Load().runOnePage(revokedOnly, new RunBackendStepOutput());
|
||||
assertEquals(1, fixture.service().devices().length());
|
||||
RunBackendStepInput removeRevoked = prepared(PrintingDeviceProcesses.Action.REMOVE, fixture.otherDevice());
|
||||
removeRevoked.addValue(PrintingDeviceProcesses.CONFIRM, true);
|
||||
new PrintingDeviceProcesses.Load().runOnePage(removeRevoked, new RunBackendStepOutput());
|
||||
assertEquals(0, fixture.service().devices().length());
|
||||
assertEquals(1, fixture.service().deviceCommandsPage(fixture.otherDevice(), 50, null).getJSONArray("commands").length());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Selected late jobs produce valid, stable wire request IDs and one command.
|
||||
***************************************************************************/
|
||||
|
||||
@@ -120,7 +120,7 @@ class PrintingExtractTest
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
assertEquals(14, checked);
|
||||
assertEquals(16, checked);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -110,6 +110,26 @@ class PrintingOperatorTest
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A removed printer leaves the operator catalog and refuses submissions until
|
||||
** its device shares it again and an administrator approves it.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void removedPrintersLeaveTheCatalog() throws Exception
|
||||
{
|
||||
var f = PrintingProcessesTest.fixture();
|
||||
f.service().removePrinter(f.device(), "laser");
|
||||
QContext.getQSession().setPermissions(Set.of("printing_operator"));
|
||||
assertEquals(List.of(f.otherPrinterId()), PrintingOperatorAuthorization.catalog(50, null).getJSONArray("printers").toList().stream().map(row -> ((Map<?, ?>) row).get("printerRowId")).toList());
|
||||
assertThrows(QException.class, () -> PrintingOperatorAuthorization.submit(request(f.printerId(), "removed")));
|
||||
f.service().inventory(f.device(), new JSONArray().put(new JSONObject().put("printerId", "laser").put("name", "Packing laser").put("formats", new JSONArray(List.of("PDF")))));
|
||||
assertEquals(1, PrintingOperatorAuthorization.catalog(50, null).getJSONArray("printers").length());
|
||||
f.service().enablePrinter(f.device(), "laser", true);
|
||||
assertEquals(2, PrintingOperatorAuthorization.catalog(50, null).getJSONArray("printers").length());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Tenant and parent device locks apply to catalog, submit and replay reads.
|
||||
***************************************************************************/
|
||||
|
||||
@@ -506,6 +506,66 @@ class PrintingProcessesTest
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Remove printers archives the selection: hidden from the list, the approval
|
||||
** frame, claims and the selectors, history kept; a re-shared queue returns
|
||||
** exposed but awaiting approval again.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void removedPrintersAreArchivedUntilTheDeviceSharesThemAgain() throws Exception
|
||||
{
|
||||
Fixture fixture = fixture();
|
||||
var process = fixture.instance().getProcess("printingRemoveSelectedPrinters");
|
||||
assertEquals("Remove printers", process.getLabel());
|
||||
assertEquals(PrintingTables.PRINTER, process.getTableName());
|
||||
assertEquals(1, process.getMinInputRecords());
|
||||
assertEquals(PrintingAdminPermission.class.getName(), process.getPermissionRules().getCustomPermissionChecker().getName());
|
||||
assertEquals("Removed at", fixture.instance().getTable(PrintingTables.PRINTER).getField("removed_at").getLabel());
|
||||
assertEquals("Removed at", fixture.instance().getTable(PrintingTables.DEVICE).getField("removed_at").getLabel());
|
||||
String queued = PrintingProcesses.submitTest(PrintingProcesses.resolvePrinter(fixture.printerId(), fixture.device()), true);
|
||||
RunBackendStepOutput preview = new RunBackendStepOutput();
|
||||
new PrintingProcesses.SelectedTransform().runOnePage(new RunBackendStepInput().withProcessName(process.getName())
|
||||
.withRecords(List.of(new QRecord().withValue("id", fixture.printerId()))), preview);
|
||||
assertEquals(1, preview.getRecords().size());
|
||||
assertEquals(1, fixture.service().printers(fixture.device()).length());
|
||||
var result = new RunProcessAction().execute(new RunProcessInput().withProcessName(process.getName())
|
||||
.withValue("recordIds", fixture.printerId()).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP));
|
||||
assertFalse(result.getException().isPresent(), () -> result.getException().toString());
|
||||
assertEquals(0, fixture.service().printers(fixture.device()).length());
|
||||
assertEquals(0, fixture.service().printerApprovals(fixture.device()).length());
|
||||
assertTrue(fixture.service().claim(fixture.device()).isNull("job"));
|
||||
assertEquals("QUEUED", fixture.service().getJob(queued).getString("status"));
|
||||
assertEquals(409, assertThrows(PrintingException.class, () -> fixture.service().enablePrinter(fixture.device(), "laser", true)).getStatus());
|
||||
assertThrows(PrintingException.class, () -> fixture.service().submit(new JSONObject().put("deviceId", fixture.device()).put("printerId", "laser").put("title", "after removal")
|
||||
.put("format", "PDF").put("contentBase64", java.util.Base64.getEncoder().encodeToString(PrintingTestDocuments.pdf())).put("idempotencyKey", "after-removal")));
|
||||
QueryInput visible = new QueryInput().withTableName(PrintingTables.PRINTER)
|
||||
.withFilter(new com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter().withCriteria("removed_at", com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.IS_BLANK));
|
||||
assertEquals(List.of(fixture.otherPrinterId()), new QueryAction().execute(visible).getRecords().stream().map(row -> row.getValueString("id")).toList());
|
||||
QRecord archived = PrintingProcesses.resolvePrinter(fixture.printerId(), fixture.device());
|
||||
assertFalse(archived.getValueString("removed_at").isBlank());
|
||||
assertEquals(0, archived.getValueInteger("enabled"));
|
||||
assertEquals(0, archived.getValueInteger("available"));
|
||||
QFieldMetaData selector = ((QFrontendStepMetaData) fixture.instance().getProcess("printingTestPdf").getStep("details")).getFormField("printerId");
|
||||
var filter = selector.getPossibleValueSourceFilter().clone();
|
||||
filter.interpretValues(Map.of("input", Map.of("deviceId", fixture.device())), PossibleValueSearchFilterUseCase.FORM);
|
||||
assertTrue(new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput().withPossibleValueSourceName(PrintingTables.PRINTER).withDefaultQueryFilter(filter)).getResults().isEmpty());
|
||||
var replay = new RunProcessAction().execute(new RunProcessInput().withProcessName(process.getName())
|
||||
.withValue("recordIds", fixture.printerId()).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP));
|
||||
assertFalse(replay.getException().isPresent(), () -> replay.getException().toString());
|
||||
fixture.service().inventory(fixture.device(), new JSONArray().put(new JSONObject().put("printerId", "laser").put("name", "Packing laser").put("formats", new JSONArray(List.of("PDF")))));
|
||||
JSONObject shared = fixture.service().printers(fixture.device()).getJSONObject(0);
|
||||
assertTrue(shared.getBoolean("available"));
|
||||
assertFalse(shared.getBoolean("enabled"));
|
||||
assertEquals("[{\"printerId\":\"laser\",\"enabled\":false}]", fixture.service().printerApprovals(fixture.device()).toString());
|
||||
assertEquals(fixture.printerId(), PrintingProcesses.resolvePrinter(fixture.printerId(), fixture.device()).getValueString("id"));
|
||||
assertEquals(null, PrintingProcesses.resolvePrinter(fixture.printerId(), fixture.device()).getValueString("removed_at"));
|
||||
assertTrue(fixture.service().claim(fixture.device()).isNull("job"));
|
||||
fixture.service().enablePrinter(fixture.device(), "laser", true);
|
||||
assertEquals(queued, fixture.service().claim(fixture.device()).getJSONObject("job").getString("jobId"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** An altered record payload cannot select another tenant's queue.
|
||||
***************************************************************************/
|
||||
|
||||
@@ -159,6 +159,9 @@ public class PrintingRealtimeTest
|
||||
capable.take("JOB_AVAILABLE");
|
||||
assertFalse(drain(capable).stream().anyMatch(frame -> "PRINTERS".equals(frame.getString("type"))), "An unrelated commit wakeup must not resend PRINTERS");
|
||||
service.cancel(device, job);
|
||||
service.removePrinter(device, "queue");
|
||||
assertEquals(new JSONArray().put(new JSONObject().put("printerId", "laser").put("enabled", true)).toString(),
|
||||
capable.take("PRINTERS").getJSONObject("payload").getJSONArray("printers").toString(), "A removed printer leaves the approval frame");
|
||||
capable.received.clear();
|
||||
capable.send("INVENTORY", "approval-empty-inventory", new JSONObject().put("printers", new JSONArray()));
|
||||
capable.take("ACK");
|
||||
@@ -174,7 +177,9 @@ public class PrintingRealtimeTest
|
||||
assertEquals("legacy-inventory", legacy.take("ACK").getJSONObject("payload").getString("acknowledgedMessageId"));
|
||||
service.enablePrinter(device, "laser", false);
|
||||
assertFalse(drain(legacy).stream().anyMatch(frame -> "PRINTERS".equals(frame.getString("type"))), "A connection without printer-approval-v1 never receives PRINTERS");
|
||||
assertEquals(1, service.printerApprovals(device).toList().stream().filter(value -> Boolean.TRUE.equals(((java.util.Map<?, ?>) value).get("enabled"))).count());
|
||||
// The re-shared queue came back awaiting approval, so with laser disabled nothing is approved.
|
||||
assertEquals(new JSONArray().put(new JSONObject().put("printerId", "laser").put("enabled", false)).put(new JSONObject().put("printerId", "queue").put("enabled", false)).toString(),
|
||||
service.printerApprovals(device).toString());
|
||||
}
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -236,6 +236,62 @@ class PrintingServiceTest
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Cleanup purges removed printers and devices only when no job references
|
||||
** them; a removed device must already be revoked, and its history survives.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void purgeDeletesOnlyUnreferencedRemovedRows() throws Exception
|
||||
{
|
||||
DriverDataSource source = source();
|
||||
PrintingService service = new PrintingService(source, "tenant-a");
|
||||
service.initialize();
|
||||
String device = enroll(service);
|
||||
service.inventory(device, inventory().put(new JSONObject().put("printerId", "spare").put("name", "Spare").put("formats", new JSONArray(List.of("PDF")))));
|
||||
service.enablePrinter(device, "zebra", true);
|
||||
String job = service.submit(request(device)).getString("jobId");
|
||||
service.removePrinter(device, "zebra");
|
||||
service.removePrinter(device, "spare");
|
||||
service.removePrinter(device, "spare");
|
||||
assertEquals(404, assertThrows(PrintingException.class, () -> service.removePrinter(device, "missing")).getStatus());
|
||||
assertEquals(0, service.printers(device).length());
|
||||
assertEquals(409, assertThrows(PrintingException.class, () -> service.removeDevice(device)).getStatus());
|
||||
String other = enroll(service);
|
||||
service.inventory(other, inventory());
|
||||
service.deviceAction(other, new JSONObject().put("requestId", "revoke-other").put("issuedAt", java.time.Instant.now().toString()).put("action", "REVOKE"), "tester");
|
||||
service.removeDevice(other);
|
||||
service.removeDevice(other);
|
||||
assertEquals(1, service.devices().length());
|
||||
assertEquals(2, count(source, "SELECT COUNT(*) FROM printing_device"));
|
||||
assertEquals(3, count(source, "SELECT COUNT(*) FROM printing_printer"));
|
||||
// Two unreferenced printers (spare, the removed device's zebra) and the removed device itself.
|
||||
assertEquals(3, service.runRetention());
|
||||
assertEquals(1, count(source, "SELECT COUNT(*) FROM printing_device"));
|
||||
assertEquals(1, count(source, "SELECT COUNT(*) FROM printing_printer"));
|
||||
assertEquals(1, count(source, "SELECT COUNT(*) FROM printing_printer WHERE printer_id='zebra' AND removed_at IS NOT NULL"));
|
||||
assertEquals(0, count(source, "SELECT COUNT(*) FROM printing_printer WHERE printer_id='spare'"));
|
||||
assertEquals(0, count(source, "SELECT COUNT(*) FROM printing_device_command"));
|
||||
assertEquals("QUEUED", service.getJob(job).getString("status"));
|
||||
assertEquals(0, service.runRetention());
|
||||
assertEquals(404, assertThrows(PrintingException.class, () -> service.printers(other)).getStatus());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Direct row counts; the service deliberately hides archived rows.
|
||||
***************************************************************************/
|
||||
private static long count(DriverDataSource source, String sql) throws Exception
|
||||
{
|
||||
try(var connection = source.getConnection(); var statement = connection.prepareStatement(sql); var result = statement.executeQuery())
|
||||
{
|
||||
result.next();
|
||||
return result.getLong(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** QBit metadata exposes safe fields and real setup/test-print processes.
|
||||
***************************************************************************/
|
||||
|
||||
Reference in new issue
Block a user