feat(admin): delete print stations and printers; "Print station"; an aggregate is a read
Delete print stations (printingDeleteDevices) and Delete printers (printingDeleteSelectedPrinters), on a selection and on the record screen, behind a new permission, printing_delete, required in addition to printing_admin. A station is revoked through the existing REVOKE control, then its queued jobs are canceled, jobs sent without a reported result are closed as OUTCOME_UNKNOWN, and its control receipts, printers and row are deleted in one transaction. A station or printer that is connected and printing is refused by name; the rest of the selection is still deleted. Jobs are never deleted. printing_job gains device_name, computer_name, printer_name and queue_name (the names at submission), keeps device_id, printer_id and printer_row_id as the identifiers the records had, and loses its foreign key to printing_device. printing_deletion records who deleted what, when and what went with it (table Deletion history). A computer enrolled again is a new station; a queue shared again is a new printer awaiting approval. Labels: "Print device" becomes "Print station" everywhere an administrator reads it, from one place (PrintingNames). Table, field and process names, the HTTP API texts and the agent protocol are unchanged. PrintingReadPermission refused an aggregate (and an export) as a write, for everybody. Both are reads and now need exactly the permission a query needs. Schema step for hosts that own the schema is in CHANGELOG.md. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
40089937bf
commit
7ac849d09f
22 files changed
+1879
-76
No files matched your search
@@ -38,6 +38,103 @@ printing schema must add one column before deploying this version** (below).
|
||||
QQQ cannot read the Print jobs table. Apply the schema step first; the previous library
|
||||
keeps working on the new schema.
|
||||
|
||||
### Delete print stations and printers; "Print station"; grouped reads
|
||||
|
||||
No wire or contract change (extension 2.4.0, wire v1, subprotocol unchanged); connectors need
|
||||
no update. **Schema change: a host that owns the printing schema applies the step below
|
||||
before deploying this version.**
|
||||
|
||||
- **Names.** The computer that runs the print client is a **Print station** in every label,
|
||||
action, preview, home page text and process message (it was "Print device", which read like
|
||||
the printer). **Printer** is unchanged. The words live in `PrintingNames`; a different
|
||||
choice is one edit there. Table, field and process names (`printing_device`,
|
||||
`printingEnrollDevice`, ...), the HTTP API texts and the agent protocol are unchanged.
|
||||
Changed labels a host may have help or tests for: tables **Print stations** and **Print
|
||||
station control history**; actions **Enroll a print station**, **Disconnect print
|
||||
stations**, **Reconnect print stations**, **Revoke print station access**, **Remove print
|
||||
station**; the enrollment field `deviceName` is **Computer name**.
|
||||
- **Delete print stations** (`printingDeleteDevices`) and **Delete printers**
|
||||
(`printingDeleteSelectedPrinters`), on the list selection and on the record screen. Each
|
||||
asks for a confirmation, then shows what will happen (printers that go with a station,
|
||||
queued jobs canceled, jobs closed as outcome unknown, finished jobs kept) and names every
|
||||
record it will not delete, with the reason. Service: `PrintingService.deleteDevice(deviceId,
|
||||
actor)`, `deletePrinter(printerRowId, actor)`, and the read-only `deviceDeletionPlan` /
|
||||
`printerDeletionPlan`.
|
||||
- A station is first revoked through the existing REVOKE control (credential rotated,
|
||||
disconnected, pending commands closed), then, in one transaction: its queued jobs become
|
||||
`FAILED` ("Canceled before dispatch: its print station was deleted"); jobs it was sent
|
||||
without a reported result (`CLAIMED`, `RECEIVED`, `SUBMITTING`) become `OUTCOME_UNKNOWN`;
|
||||
each of those gets a `printing_event` row (`event_origin` `SERVER_DELETION`) naming the
|
||||
actor; its control receipts, its printers and the station row are deleted.
|
||||
- A printer: its queued jobs are canceled and its sent-but-unreported jobs closed the same
|
||||
way; the printer row is deleted; its station stays in service.
|
||||
- Refused, per record: a station (or a printer on a station) that holds a live credential,
|
||||
shows as ONLINE or PAUSED and has jobs in `CLAIMED`, `RECEIVED` or `SUBMITTING`. Wait, or
|
||||
use Disconnect or Revoke access first. A record that is gone or not the caller's is
|
||||
reported and skipped. The other records of the selection are still deleted.
|
||||
- Jobs are never deleted. `printing_job` gains `device_name`, `computer_name`,
|
||||
`printer_name` and `queue_name`: the names the station and printer had when the job was
|
||||
submitted (written by the same `INSERT`; existing jobs are filled from the current rows;
|
||||
a delete fills any job that still lacks them). They are the read-only fields **Print
|
||||
station**, **Computer name**, **Printer** and **Windows queue name** on Print jobs, and
|
||||
the first two replace the record links in the default columns. `device_id`, `printer_id`
|
||||
and `printer_row_id` keep the identifiers the deleted records had; they are no longer
|
||||
references that must resolve, so the foreign key from `printing_job.device_id` to
|
||||
`printing_device` is dropped.
|
||||
- A computer enrolled again is a new station with a new identifier. A queue that a station
|
||||
still shares is reported by its next inventory and comes back as a new printer, exposed
|
||||
and not approved. Old jobs are not attached to either.
|
||||
- Everything a deleted station sends afterwards (heartbeat, inventory, claim, content,
|
||||
event, control poll, control receipt) and every management action on it is answered
|
||||
`404 Device not found`; its token no longer authenticates (`401`). **Print again** on one
|
||||
of its jobs is refused with the reason.
|
||||
- New table `printing_deletion`, read-only as **Deletion history** in the Printing app: one
|
||||
row per deleted station and per deleted printer with what it was (kind, name, computer
|
||||
name, queue name, facility, the identifiers it had), who deleted it (`actor`), when
|
||||
(`deleted_time`, a UTC timestamp) and what went with it (printers, queued jobs canceled,
|
||||
jobs closed as unknown, jobs kept). It has no reference to the deleted rows.
|
||||
- New permission **`printing_delete`** (`PrintingDeletePermission`). Both actions require
|
||||
`printing_delete` **and** `printing_admin`; `printing_admin` alone does not delete and is
|
||||
not offered the actions. A host grants it where it grants the others.
|
||||
- **Remove printers**, **Remove print station** and **Revoke print station access** are
|
||||
unchanged (same process names, `printing_admin`). Remove still archives (`removed_at`,
|
||||
record kept); a removed record can be deleted.
|
||||
- **A grouped read is a read.** `PrintingReadPermission` let a query, a get and a count
|
||||
through and refused everything else as a write, so an aggregate ("print jobs by format")
|
||||
was refused for everybody, administrators included, wherever permissions are enforced. It
|
||||
now accepts `AggregateInput` and `ExportInput` with exactly the permission a query needs
|
||||
(`printing_operator` or `printing_admin` on stations and printers, `printing_admin` on the
|
||||
other tables). Inserts, updates and deletes through table actions stay refused.
|
||||
- Schema step for a host that owns the schema (PostgreSQL, one transaction):
|
||||
|
||||
```sql
|
||||
ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS device_name VARCHAR(1024);
|
||||
ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS computer_name VARCHAR(1024);
|
||||
ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS printer_name VARCHAR(1024);
|
||||
ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS queue_name VARCHAR(1024);
|
||||
UPDATE printing_job j SET device_name = d.name, computer_name = d.computer_name
|
||||
FROM printing_device d WHERE d.id = j.device_id AND j.device_name IS NULL;
|
||||
UPDATE printing_job j SET printer_name = p.name, queue_name = p.queue_name
|
||||
FROM printing_printer p WHERE p.id = j.printer_row_id AND j.printer_name IS NULL;
|
||||
ALTER TABLE printing_job DROP CONSTRAINT IF EXISTS printing_job_device_id_fkey;
|
||||
CREATE TABLE IF NOT EXISTS printing_deletion (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL,
|
||||
kind VARCHAR(20) NOT NULL, record_id VARCHAR(36) NOT NULL, name VARCHAR(1024) NOT NULL,
|
||||
computer_name VARCHAR(1024), queue_name VARCHAR(1024), facility_id VARCHAR(200), device_id VARCHAR(36),
|
||||
actor VARCHAR(200) NOT NULL, deleted_time TIMESTAMP WITHOUT TIME ZONE NOT NULL,
|
||||
printer_count INTEGER DEFAULT 0 NOT NULL, cancelled_job_count INTEGER DEFAULT 0 NOT NULL,
|
||||
unknown_job_count INTEGER DEFAULT 0 NOT NULL, kept_job_count INTEGER DEFAULT 0 NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS printing_deletion_tenant_idx ON printing_deletion(tenant_id, deleted_time, id);
|
||||
```
|
||||
|
||||
`printing_job_device_id_fkey` is the name PostgreSQL gives the inline `REFERENCES` of the
|
||||
original `CREATE TABLE`; a schema that named it otherwise drops that name. `initialize()`
|
||||
does all of this itself and finds the foreign key by metadata.
|
||||
- Without the step this version fails loudly: every submission is rolled back with the
|
||||
database's missing-column error for `device_name`, and QQQ cannot read Print jobs. If only
|
||||
the foreign key is left in place, deleting a station that has jobs is refused (409, naming
|
||||
the step) before anything is revoked. The previous library keeps working on the new schema;
|
||||
jobs it writes get their names when their station or printer is deleted.
|
||||
|
||||
## 1.1.2
|
||||
|
||||
Runtime and publication move to the QRun-IO Gitea Maven registry; no printing contract
|
||||
|
||||
@@ -119,15 +119,17 @@ Configure a dedicated private printing bucket for the customer/environment throu
|
||||
|
||||
Enrollment uses a named facility selector. Set `withFacilityPossibleValueSourceName("your_existing_facility_source")` to reuse an already registered host source and its record security. Without it, the standalone choice is **Default facility**. The backend validates the selection under the current user's permissions before issuing an enrollment code; typing or forging an unknown/foreign-tenant ID cannot bypass that check. Existing stored facility IDs are preserved; configure their original source to resolve their names.
|
||||
|
||||
For native QQQ screens, grant `printing_admin` and populate the session's `printing_tenant` security key with the tenant ID. The Printing home shows device health, queue status and work needing attention, with Devices, Printers and Print jobs beneath it. It nests under an existing `admin` app when available. Processes are dashboard/table/record actions, not navigation entries. Secret/token and document columns are excluded from metadata. Table mutations are rejected; use the service/processes instead.
|
||||
For native QQQ screens, grant `printing_admin` and populate the session's `printing_tenant` security key with the tenant ID; grant `printing_delete` as well to whoever may delete print stations and printers. A **print station** is the computer that runs the print client (table `printing_device`); a **printer** is a Windows queue it shares. The Printing home shows print station health, queue status and work needing attention, with Print stations, Printers, Print jobs, Print station control history and Deletion history beneath it. It nests under an existing `admin` app when available. Processes are dashboard/table/record actions, not navigation entries. Secret/token and document columns are excluded from metadata. Table mutations are rejected; use the service/processes instead.
|
||||
|
||||
Typical setup: run **Enroll a print device** from Printing home or Print devices, enroll the Windows connector, and select local queues to expose in its companion/tray. Use **Set printer approval** to choose a named device and one of its printers. Test-print forms offer only exposed, approved printers supporting the chosen format. Device and printer choices respect the current user's tenant access.
|
||||
Typical setup: run **Enroll a print station** from Printing home or Print stations, enroll the Windows connector, and select local queues to expose in its companion/tray. Use **Set printer approval** to choose a named print station and one of its printers. Test-print forms offer only exposed, approved printers supporting the chosen format. Device and printer choices respect the current user's tenant access.
|
||||
|
||||
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**, **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.
|
||||
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 printer and print station names they were submitted under (stored on the job, so they read the same after a rename or a delete); 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)).
|
||||
Deleting is separate from removing. **Delete printers** and **Delete print stations** (on a selection or a record; they need `printing_delete` as well as `printing_admin`) take the record out of every list for good. A station is revoked first, so its computer must be enrolled again and then is a new station; its printers go with it. Queued jobs are canceled, jobs already sent without a reported result are closed as outcome unknown, and finished jobs stay in Print jobs with the names they were printed under. A record is refused, by name, while its station is connected and printing. A printer that its station still shares comes back from the next inventory as a new printer awaiting approval. Every deletion is recorded in **Deletion history** (who, what, when, what went with it). See the [walkthrough](docs/UI-WALKTHROUGH.md#delete-a-print-station-or-a-printer).
|
||||
|
||||
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 stations 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 print station** (an action that requires confirmation) revokes the station's access through the existing REVOKE control, then archives the station and all of its printers; an already revoked station is archived without a new command. A removed station cannot be restored; connect the computer again with a fresh enrollment, which creates a new station. A removed record can be deleted. 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.
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ 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.
|
||||
**Delete printers** and **Delete print stations** delete the record at once and keep every job (see the [walkthrough](UI-WALKTHROUGH.md#delete-a-print-station-or-a-printer)); a deleted station's retained-byte counter stays until cleanup has removed its last stored document, then cleanup deletes the counter. The same run purges archived inventory. **Remove printers** and **Remove print station** 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.
|
||||
|
||||
|
||||
+20
-5
@@ -10,21 +10,36 @@ Open **Printing** in the navigation. The home presents the current tenant and re
|
||||
|
||||
## Enroll a facility computer
|
||||
|
||||
Choose **Enroll a print device** from Printing home or the Print devices table. Use the resulting short-lived enrollment code in the Windows companion's Connection settings. Once enrolled, select the installed printer queues to expose in the companion. Exposure is opt-in and persists in the service.
|
||||
Choose **Enroll a print station** from Printing home or the Print stations table. A print station is the computer that runs the print client. Use the resulting short-lived enrollment code in the Windows companion's Connection settings. Once enrolled, select the installed printer queues to expose in the companion. Exposure is opt-in and persists in the service.
|
||||
|
||||
Open **Printers** in QQQ. The operational columns show the printer name, named print device, status, approval, local exposure, and last contact. Approval and exposure are independent: a printer must be both approved in QQQ and exposed on the computer before new jobs can be dispatched.
|
||||
Open **Printers** in QQQ. The operational columns show the printer name, named print station, status, approval, local exposure, and last contact. Approval and exposure are independent: a printer must be both approved in QQQ and exposed on the computer before new jobs can be dispatched.
|
||||
|
||||

|
||||
|
||||
## 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**, **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.
|
||||
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**, **Delete 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
|
||||
## Remove a print station
|
||||
|
||||
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.
|
||||
Open a Print stations record or select rows and choose **Remove print station**. The action requires the confirmation checkbox. It revokes the station's access permanently through the same control as **Revoke print station access**, then marks the station and all of its printers removed. The station drops out of the Print stations view once you add **Removed at is empty** there, the setup and test-print choices and every enrollment, claim and control path; its jobs remain in Print jobs and its control history keeps the REVOKE receipt. A removed station cannot be un-removed; enroll the computer again to create a new station. Retention cleanup eventually hard-deletes removed stations and printers that no job references. To take the record out of the list now, delete it.
|
||||
|
||||
## Delete a print station or a printer
|
||||
|
||||
Deleting needs the `printing_delete` permission in addition to `printing_admin`; without it the two actions are not offered.
|
||||
|
||||
Open a Print stations record, or select rows in Print stations, and choose **Delete print stations**. Tick the confirmation and continue. The review screen then says exactly what will happen for the selection: how many stations are deleted, how many printers go with them, how many queued jobs are canceled, how many jobs already sent to a station are closed as outcome unknown, and how many finished jobs keep their history. It also lists, by name, every record that will not be deleted and why. Nothing has changed yet. Submit to delete.
|
||||
|
||||
- The station's access is revoked first. The computer cannot connect as that station again; enroll it again to print, and it is then a new station.
|
||||
- Its printers are deleted with it.
|
||||
- Queued jobs become **Failed** with the message "Canceled before dispatch: its print station was deleted". Jobs that were already sent to the station and had not reported a result become **Outcome unknown**: check the printer before printing them again.
|
||||
- Finished jobs stay in Print jobs. Their **Print station**, **Computer name**, **Printer** and **Windows queue name** columns show the names they were printed under.
|
||||
- A station that is connected (status Online or Paused) and printing is refused. Wait for the jobs to finish, or use **Disconnect print stations** or **Revoke print station access** first.
|
||||
- **Deletion history** in the Printing app records who deleted what and when.
|
||||
|
||||
**Delete printers** works the same way on Printers. The printer's station stays in service. If the station still shares that Windows queue, its next inventory reports it again and it comes back as a new printer that is exposed and not approved; stop sharing it in the print client on that computer to keep it away.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.printing;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.UseOtherPermissionNameCustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deleting a print station or a printer needs its own grant, printing_delete,
|
||||
** in addition to printing_admin: an administrator who may pause, approve and
|
||||
** revoke does not delete records unless a host grants this permission too.
|
||||
*******************************************************************************/
|
||||
public class PrintingDeletePermission extends UseOtherPermissionNameCustomPermissionChecker
|
||||
{
|
||||
public static final String NAME = "printing_delete";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Expose the permission in QQQ's available-permission catalog.
|
||||
***************************************************************************/
|
||||
public PrintingDeletePermission()
|
||||
{
|
||||
withPermissionName(NAME);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Both grants are required; neither implies the other.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput input, @Nullable MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
||||
{
|
||||
if(QContext.getQSession() == null || !QContext.getQSession().hasPermission(NAME) || !QContext.getQSession().hasPermission("printing_admin"))
|
||||
{
|
||||
throw new QPermissionDeniedException("Permission to delete print stations and printers is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.printing;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
import org.json.JSONObject;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Delete print stations and printers from their lists and record screens.
|
||||
**
|
||||
** Both actions need printing_delete as well as printing_admin. They ask for a
|
||||
** confirmation, then show exactly what will happen (printers that go with a
|
||||
** station, queued jobs canceled, jobs closed as outcome unknown, job history
|
||||
** kept) and which records are refused and why. Nothing changes before the
|
||||
** final step; every record is read again there with the caller's access, and
|
||||
** a record that cannot be deleted is reported by name without stopping the
|
||||
** others. PrintingService.deleteDevice and deletePrinter do the work.
|
||||
*******************************************************************************/
|
||||
public final class PrintingDeleteProcesses
|
||||
{
|
||||
public static final String DEVICES = "printingDeleteDevices";
|
||||
public static final String PRINTERS = "printingDeleteSelectedPrinters";
|
||||
public static final String DEVICES_LABEL = "Delete " + PrintingNames.STATIONS_LOWER;
|
||||
public static final String PRINTERS_LABEL = "Delete " + PrintingNames.PRINTERS_LOWER;
|
||||
static final String CONFIRM = "confirmPrintingDelete";
|
||||
static final String DEVICES_PREVIEW = "Each selected " + PrintingNames.STATION_LOWER + " is deleted together with its " + PrintingNames.PRINTERS_LOWER + ". Its access is revoked first, so the computer must be enrolled again before it can print."
|
||||
+ " Queued jobs are canceled, and jobs already sent to it without a reported result are closed as outcome unknown. Finished print jobs are kept, with the " + PrintingNames.STATION_LOWER + " and "
|
||||
+ PrintingNames.PRINTER_LOWER + " names they were printed under. This cannot be undone.";
|
||||
static final String PRINTERS_PREVIEW = "Each selected " + PrintingNames.PRINTER_LOWER + " is deleted; its " + PrintingNames.STATION_LOWER + " stays in service. Queued jobs for it are canceled, and jobs already sent without a reported result are closed as outcome unknown."
|
||||
+ " Finished print jobs are kept, with the " + PrintingNames.PRINTER_LOWER + " and " + PrintingNames.STATION_LOWER + " names they were printed under. A " + PrintingNames.PRINTER_LOWER + " that its " + PrintingNames.STATION_LOWER
|
||||
+ " still shares comes back as a new " + PrintingNames.PRINTER_LOWER + " awaiting approval; stop sharing it in the print client to keep it away. This cannot be undone.";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Utility class.
|
||||
***************************************************************************/
|
||||
private PrintingDeleteProcesses()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One action on each table, for a selection and for one record.
|
||||
***************************************************************************/
|
||||
public static void register(QInstance instance)
|
||||
{
|
||||
add(instance, DEVICES, DEVICES_LABEL, PrintingTables.DEVICE, DEVICES_PREVIEW,
|
||||
"I understand: the selected " + PrintingNames.STATIONS_LOWER + " and their " + PrintingNames.PRINTERS_LOWER + " are deleted, access is revoked and each computer must be enrolled again");
|
||||
add(instance, PRINTERS, PRINTERS_LABEL, PrintingTables.PRINTER, PRINTERS_PREVIEW,
|
||||
"I understand: the selected " + PrintingNames.PRINTERS_LOWER + " are deleted and their queued jobs are canceled");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The grant every step of both actions checks.
|
||||
***************************************************************************/
|
||||
public static QPermissionRules permissionRules()
|
||||
{
|
||||
return new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withCustomPermissionChecker(new QCodeReference(PrintingDeletePermission.class));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** prepare, confirm, review (what will happen), execute, result.
|
||||
***************************************************************************/
|
||||
private static void add(QInstance instance, String name, String label, String table, String preview, String confirmation)
|
||||
{
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, table);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, preview);
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(PrintingExtract.class, Transform.class, Load.class, values)
|
||||
.withName(name).withLabel(label).withTableName(table).withMinInputRecords(1).withPermissionRules(permissionRules());
|
||||
process.withStep(0, new QBackendStepMetaData().withName("prepareDelete").withCode(new QCodeReference(Prepare.class)));
|
||||
process.withStep(1, new QFrontendStepMetaData().withName("confirmDelete").withLabel(label)
|
||||
.withFormField(new QFieldMetaData(CONFIRM, QFieldType.BOOLEAN).withLabel(confirmation).withDefaultValue(false))
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)));
|
||||
instance.addProcess(process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The signed-in user who holds both grants, in this printing tenant.
|
||||
***************************************************************************/
|
||||
static String actor(RunBackendStepInput input) throws QException
|
||||
{
|
||||
new PrintingDeletePermission().checkPermissionsThrowing(input, null);
|
||||
if(!DEVICES.equals(input.getProcessName()) && !PRINTERS.equals(input.getProcessName()))
|
||||
{
|
||||
throw new QException("Unknown printing delete action");
|
||||
}
|
||||
return PrintingDeviceProcesses.actor(input);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The record as the caller may read it now, or null when it is gone or not
|
||||
** the caller's to see.
|
||||
***************************************************************************/
|
||||
private static @Nullable QRecord current(String table, @Nullable String id) throws QException
|
||||
{
|
||||
if(id == null || id.isBlank())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
GetInput get = new GetInput();
|
||||
get.setTableName(table);
|
||||
get.setPrimaryKey(id);
|
||||
get.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
return new GetAction().execute(get).getRecord();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What a delete of this record would do, or the reason it is refused.
|
||||
***************************************************************************/
|
||||
private static JSONObject plan(RunBackendStepInput input, QRecord record) throws QException
|
||||
{
|
||||
try
|
||||
{
|
||||
PrintingService service = PrintingProcesses.currentService();
|
||||
return DEVICES.equals(input.getProcessName()) ? service.deviceDeletionPlan(record.getValueString("id")) : service.printerDeletionPlan(record.getValueString("id"));
|
||||
}
|
||||
catch(PrintingException exception)
|
||||
{
|
||||
return new JSONObject().put("refusal", exception.getStatus() == 404 ? name(input, record) + " no longer exists" : name(input, record) + ": " + exception.getMessage());
|
||||
}
|
||||
catch(java.sql.SQLException exception)
|
||||
{
|
||||
throw new QException("Could not read what the delete would do", exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The words for one record in a message.
|
||||
***************************************************************************/
|
||||
private static String name(RunBackendStepInput input, QRecord record)
|
||||
{
|
||||
return (DEVICES.equals(input.getProcessName()) ? PrintingNames.STATION : PrintingNames.PRINTER) + " " + record.getValueString("name");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The line listing every record that is not deleted, with its reason.
|
||||
***************************************************************************/
|
||||
private static void addRefusals(List<ProcessSummaryLineInterface> lines, List<String> refusals, boolean past)
|
||||
{
|
||||
if(!refusals.isEmpty())
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.ERROR, refusals.size(), refusals.size() == 1 ? (past ? "record was not deleted" : "record will not be deleted") : (past ? "records were not deleted" : "records will not be deleted"))
|
||||
.withBulletsOfText(new ArrayList<>(refusals)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Keep the selection across the confirmation form.
|
||||
***************************************************************************/
|
||||
public static class Prepare implements BackendStep
|
||||
{
|
||||
/************************************************************************
|
||||
** Nothing is read or changed here beyond the caller's authority.
|
||||
************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
actor(input);
|
||||
PrintingProcesses.keepSelection(input, output);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The review: counts what will happen and names what is refused. Read-only.
|
||||
***************************************************************************/
|
||||
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final List<String> refusals = new ArrayList<>();
|
||||
private final ProcessSummaryLine ready = new ProcessSummaryLine(Status.OK, 0, null);
|
||||
private boolean devices;
|
||||
private long printers;
|
||||
private long queued;
|
||||
private long inProgress;
|
||||
private long kept;
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** Only records that can be deleted now go on to the final step.
|
||||
************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
actor(input);
|
||||
devices = DEVICES.equals(input.getProcessName());
|
||||
for(QRecord selected : input.getRecords())
|
||||
{
|
||||
QRecord record = current(devices ? PrintingTables.DEVICE : PrintingTables.PRINTER, selected.getValueString("id"));
|
||||
if(record == null)
|
||||
{
|
||||
refusals.add("A selected record no longer exists or is not yours to delete");
|
||||
continue;
|
||||
}
|
||||
JSONObject plan = plan(input, record);
|
||||
if(plan.has("refusal"))
|
||||
{
|
||||
refusals.add(plan.getString("refusal"));
|
||||
continue;
|
||||
}
|
||||
printers += plan.optLong("printers", 0);
|
||||
queued += plan.getLong("queuedJobs");
|
||||
inProgress += plan.getLong("jobsInProgress");
|
||||
kept += plan.getLong("jobs") - plan.getLong("queuedJobs") - plan.getLong("jobsInProgress");
|
||||
ready.incrementCountAndAddPrimaryKey(record.getValue("id"));
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The records refused in review, for the final step's result.
|
||||
************************************************************************/
|
||||
List<String> refusals()
|
||||
{
|
||||
return refusals;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** One line for each consequence that applies, then the refusals.
|
||||
************************************************************************/
|
||||
// QQQ ProcessSummaryProvider requires this concrete return type; retain binary/API compatibility.
|
||||
@SuppressWarnings("NonApiType")
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
if(devices)
|
||||
{
|
||||
ready.withSingularFutureMessage(PrintingNames.STATION_LOWER + " will be deleted; its access is revoked and the computer must be enrolled again before it can print")
|
||||
.withPluralFutureMessage(PrintingNames.STATIONS_LOWER + " will be deleted; their access is revoked and each computer must be enrolled again before it can print");
|
||||
}
|
||||
else
|
||||
{
|
||||
ready.withSingularFutureMessage(PrintingNames.PRINTER_LOWER + " will be deleted").withPluralFutureMessage(PrintingNames.PRINTERS_LOWER + " will be deleted");
|
||||
}
|
||||
ready.addSelfToListIfAnyCount(lines);
|
||||
add(lines, Status.WARNING, printers, PrintingNames.PRINTER_LOWER + " will be deleted with its " + PrintingNames.STATION_LOWER, PrintingNames.PRINTERS_LOWER + " will be deleted with their " + PrintingNames.STATIONS_LOWER);
|
||||
add(lines, Status.WARNING, queued, "queued job will be canceled", "queued jobs will be canceled");
|
||||
add(lines, Status.WARNING, inProgress, "job already sent without a reported result will be closed as outcome unknown", "jobs already sent without a reported result will be closed as outcome unknown");
|
||||
add(lines, Status.INFO, kept, "finished print job keeps its history and the names it was printed under", "finished print jobs keep their history and the names they were printed under");
|
||||
addRefusals(lines, refusals, false);
|
||||
return lines;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** A counted consequence, shown only when it applies.
|
||||
************************************************************************/
|
||||
private static void add(List<ProcessSummaryLineInterface> lines, Status status, long count, String singular, String plural)
|
||||
{
|
||||
if(count > 0)
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(status, Math.toIntExact(count), count == 1 ? singular : plural));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The delete itself, after the confirmation and the review.
|
||||
***************************************************************************/
|
||||
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final List<String> refusals = new ArrayList<>();
|
||||
private final ProcessSummaryLine deleted = new ProcessSummaryLine(Status.OK, 0, null);
|
||||
private boolean devices;
|
||||
private long printers;
|
||||
private long cancelled;
|
||||
private long unknown;
|
||||
private long kept;
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** Each record is read again with the caller's access and deleted on its
|
||||
** own; a refusal names the record and does not stop the others.
|
||||
************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
String actor = actor(input);
|
||||
if(!Boolean.TRUE.equals(input.getValueBoolean(CONFIRM)))
|
||||
{
|
||||
throw new QException("Confirm the delete before continuing");
|
||||
}
|
||||
devices = DEVICES.equals(input.getProcessName());
|
||||
PrintingService service = PrintingProcesses.currentService();
|
||||
for(QRecord selected : input.getRecords())
|
||||
{
|
||||
QRecord record = current(devices ? PrintingTables.DEVICE : PrintingTables.PRINTER, selected.getValueString("id"));
|
||||
if(record == null)
|
||||
{
|
||||
refusals.add("A selected record no longer exists or is not yours to delete");
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
JSONObject result = devices ? service.deleteDevice(record.getValueString("id"), actor) : service.deletePrinter(record.getValueString("id"), actor);
|
||||
printers += result.optLong("printers", 0);
|
||||
cancelled += result.getLong("cancelledJobs");
|
||||
unknown += result.getLong("unknownJobs");
|
||||
kept += result.getLong("keptJobs") - result.getLong("cancelledJobs") - result.getLong("unknownJobs");
|
||||
deleted.incrementCount();
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
catch(PrintingException exception)
|
||||
{
|
||||
refusals.add(exception.getStatus() == 409 ? exception.getMessage() : exception.getStatus() == 404 ? name(input, record) + " no longer exists" : name(input, record) + ": " + exception.getMessage());
|
||||
}
|
||||
catch(java.sql.SQLException exception)
|
||||
{
|
||||
throw new QException(name(input, record) + " could not be deleted; nothing was changed for it. Earlier records in this selection were deleted.", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** What was deleted and what went with it, then every refusal, including
|
||||
** the records the review already set aside.
|
||||
************************************************************************/
|
||||
// QQQ ProcessSummaryProvider requires this concrete return type; retain binary/API compatibility.
|
||||
@SuppressWarnings("NonApiType")
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
|
||||
if(devices)
|
||||
{
|
||||
deleted.withSingularPastMessage(PrintingNames.STATION_LOWER + " was deleted; its access is revoked").withPluralPastMessage(PrintingNames.STATIONS_LOWER + " were deleted; their access is revoked");
|
||||
}
|
||||
else
|
||||
{
|
||||
deleted.withSingularPastMessage(PrintingNames.PRINTER_LOWER + " was deleted").withPluralPastMessage(PrintingNames.PRINTERS_LOWER + " were deleted");
|
||||
}
|
||||
deleted.addSelfToListIfAnyCount(lines);
|
||||
Transform.add(lines, Status.OK, printers, PrintingNames.PRINTER_LOWER + " was deleted with its " + PrintingNames.STATION_LOWER, PrintingNames.PRINTERS_LOWER + " were deleted with their " + PrintingNames.STATIONS_LOWER);
|
||||
Transform.add(lines, Status.WARNING, cancelled, "queued job was canceled", "queued jobs were canceled");
|
||||
Transform.add(lines, Status.WARNING, unknown, "job was closed as outcome unknown; check the printer for output before printing it again", "jobs were closed as outcome unknown; check the printer for output before printing them again");
|
||||
Transform.add(lines, Status.INFO, kept, "finished print job keeps its history and the names it was printed under", "finished print jobs keep their history and the names they were printed under");
|
||||
List<String> all = new ArrayList<>(refusals);
|
||||
if(getTransformStep() instanceof Transform review)
|
||||
{
|
||||
all.addAll(review.refusals());
|
||||
}
|
||||
addRefusals(lines, all, true);
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,15 +75,16 @@ public final class PrintingDeviceProcesses
|
||||
PAUSE("printingPauseDevices", "Pause printing", "PAUSE_DISPATCH", false, false,
|
||||
"New work will stop being dispatched. Jobs already received by Windows may still print."),
|
||||
RESUME("printingResumeDevices", "Resume printing", "RESUME_DISPATCH", false, false,
|
||||
"Dispatch will resume for connected, authorized devices. Administrative disconnect remains unchanged."),
|
||||
DISCONNECT("printingDisconnectDevices", "Disconnect devices", "DISCONNECT", false, false,
|
||||
"Dispatch will resume for connected, authorized " + PrintingNames.STATIONS_LOWER + ". Administrative disconnect remains unchanged."),
|
||||
DISCONNECT("printingDisconnectDevices", "Disconnect " + PrintingNames.STATIONS_LOWER, "DISCONNECT", false, false,
|
||||
"Normal printing traffic will stop. The outbound control channel remains available for reconnect."),
|
||||
RECONNECT("printingReconnectDevices", "Reconnect devices", "RECONNECT", false, false,
|
||||
RECONNECT("printingReconnectDevices", "Reconnect " + PrintingNames.STATIONS_LOWER, "RECONNECT", false, false,
|
||||
"Connected agents will be asked to resume normal traffic. An offline computer cannot be reached immediately."),
|
||||
REVOKE("printingRevokeDevices", "Revoke device access", "REVOKE", true, false,
|
||||
REVOKE("printingRevokeDevices", "Revoke " + PrintingNames.STATION_LOWER + " 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."),
|
||||
REMOVE("printingRemoveDevices", "Remove " + PrintingNames.STATION_LOWER, "REVOKE", true, false,
|
||||
"Access will be revoked permanently and the " + PrintingNames.STATION_LOWER + " and its " + PrintingNames.PRINTERS_LOWER + " will be hidden from the printing views; the records stay, marked removed. Job history is kept. A removed "
|
||||
+ PrintingNames.STATION_LOWER + " cannot be restored; a new enrollment creates a new " + PrintingNames.STATION_LOWER + ". To take the record out of the list as well, use " + PrintingDeleteProcesses.DEVICES_LABEL + "."),
|
||||
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,
|
||||
@@ -182,7 +183,7 @@ public final class PrintingDeviceProcesses
|
||||
if(QContext.getQSession() == null || QContext.getQSession().getUser() == null
|
||||
|| QContext.getQSession().getUser().getIdReference() == null || QContext.getQSession().getUser().getIdReference().isBlank())
|
||||
{
|
||||
throw new QException("Sign in with an authorized QQQ user before controlling a print device");
|
||||
throw new QException("Sign in with an authorized QQQ user before controlling a " + PrintingNames.STATION_LOWER);
|
||||
}
|
||||
return QContext.getQSession().getUser().getIdReference();
|
||||
}
|
||||
@@ -202,7 +203,7 @@ public final class PrintingDeviceProcesses
|
||||
QRecord record = new GetAction().execute(get).getRecord();
|
||||
if(record == null)
|
||||
{
|
||||
throw new QException("Choose an accessible print " + (action.job ? "job" : "device"));
|
||||
throw new QException("Choose an accessible " + (action.job ? "print job" : PrintingNames.STATION_LOWER));
|
||||
}
|
||||
if(action.job && "QUEUED".equals(record.getValueString("status")))
|
||||
{
|
||||
@@ -287,8 +288,8 @@ public final class PrintingDeviceProcesses
|
||||
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine applied = new ProcessSummaryLine(Status.OK, 0, null)
|
||||
.withSingularPastMessage("server control action was applied; device observation is shown separately")
|
||||
.withPluralPastMessage("server control actions were applied; device observations are shown separately");
|
||||
.withSingularPastMessage("server control action was applied; what the " + PrintingNames.STATION_LOWER + " reports is shown separately")
|
||||
.withPluralPastMessage("server control actions were applied; what each " + PrintingNames.STATION_LOWER + " reports is shown separately");
|
||||
private final ProcessSummaryLine pending = new ProcessSummaryLine(Status.WARNING, 0, null)
|
||||
.withSingularPastMessage("agent request is pending; no remote execution is confirmed")
|
||||
.withPluralPastMessage("agent requests are pending; no remote execution is confirmed");
|
||||
|
||||
@@ -84,11 +84,11 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
{
|
||||
add(instance, app, "printingHomeHeader", "Printing operations", WidgetType.COMPOSITE, "header", 0, 12);
|
||||
add(instance, app, "printingHomeAttention", "Needs attention", WidgetType.TABLE, "systemAttention", 10, 12);
|
||||
add(instance, app, "printingHomeOffline", "Disconnected devices", WidgetType.STATISTICS, "systemHealth", 20, 3);
|
||||
add(instance, app, "printingHomePrinters", "Printers needing attention", WidgetType.STATISTICS, "systemHealth", 21, 3);
|
||||
add(instance, app, "printingHomeOffline", "Disconnected " + PrintingNames.STATIONS_LOWER, WidgetType.STATISTICS, "systemHealth", 20, 3);
|
||||
add(instance, app, "printingHomePrinters", PrintingNames.PRINTERS + " needing attention", WidgetType.STATISTICS, "systemHealth", 21, 3);
|
||||
add(instance, app, "printingHomeFailures", "Failed or uncertain jobs", WidgetType.STATISTICS, "systemHealth", 22, 3);
|
||||
add(instance, app, "printingHomePending", "Pending print jobs", WidgetType.STATISTICS, "systemHealth", 23, 3);
|
||||
add(instance, app, "printingHomeControls", "Pending device commands", WidgetType.STATISTICS, "systemHealth", 24, 12);
|
||||
add(instance, app, "printingHomeControls", "Pending " + PrintingNames.STATION_LOWER + " commands", WidgetType.STATISTICS, "systemHealth", 24, 12);
|
||||
add(instance, app, "printingHomeWork", "Pending and recent print work", WidgetType.TABLE, "queue", 30, 12);
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
case "printingHomeFailures" -> statistic(JOB, failures(now), "Jobs created in last 24 hours · target 0 failed or uncertain");
|
||||
case "printingHomePending" -> statistic(JOB, statuses(PENDING), "Jobs · now · target queue drains after printing");
|
||||
case "printingHomeControls" -> statistic(PrintingTables.COMMAND, statuses(List.of("PENDING"))
|
||||
.withCriteria("expires_at", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.toString()), "Device commands · now · awaiting an agent receipt; not confirmed applied");
|
||||
.withCriteria("expires_at", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.toString()), PrintingNames.STATION + " commands · now · awaiting an agent receipt; not confirmed applied");
|
||||
case "printingHomeWork" -> work(now);
|
||||
default -> throw new QException("Unknown printing home widget");
|
||||
};
|
||||
@@ -149,9 +149,9 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
***************************************************************************/
|
||||
private static CompositeWidgetData header() throws QException
|
||||
{
|
||||
CompositeWidgetData data = column().withBlock(text("Keep packing slips and shipping labels moving by resolving disconnected devices, printer issues and print failures."))
|
||||
.withBlock(text("Scope: current tenant. Devices/printers: now. Job exceptions/recent work: last 24 hours."))
|
||||
.withBlock(text("Enroll a print device").withLink(new BlockLink("/app/printingEnrollDevice/?returnTo=%2Fapp%2Fprinting%2F")))
|
||||
CompositeWidgetData data = column().withBlock(text("Keep packing slips and shipping labels moving by resolving disconnected " + PrintingNames.STATIONS_LOWER + ", " + PrintingNames.PRINTER_LOWER + " issues and print failures."))
|
||||
.withBlock(text("Scope: current tenant. " + PrintingNames.STATIONS + " and " + PrintingNames.PRINTERS_LOWER + ": now. Job exceptions/recent work: last 24 hours."))
|
||||
.withBlock(text("Enroll a " + PrintingNames.STATION_LOWER).withLink(new BlockLink("/app/printingEnrollDevice/?returnTo=%2Fapp%2Fprinting%2F")))
|
||||
.withBlock(text("Printing retention settings").withLink(new BlockLink("/app/printingRetentionSettings/?returnTo=%2Fapp%2Fprinting%2F")))
|
||||
.withBlock(text("Printing connection and limits").withLink(new BlockLink("/app/printingRealtimeSettings/?returnTo=%2Fapp%2Fprinting%2F")))
|
||||
.withBlock(text("Printing queue and storage limits").withLink(new BlockLink("/app/printingAdmissionSettings/?returnTo=%2Fapp%2Fprinting%2F")))
|
||||
@@ -160,7 +160,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
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, present))));
|
||||
data.withBlock(text("No " + PrintingNames.STATIONS_LOWER + " are connected. Open " + PrintingNames.STATIONS + " and use Enroll a " + PrintingNames.STATION_LOWER + "; then connect the Windows agent and expose its " + PrintingNames.PRINTERS_LOWER + ".").withLink(new BlockLink(tableUrl(DEVICE, present))));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -181,25 +181,25 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
{
|
||||
boolean disconnected = "DISCONNECTED".equals(device.getValueString("connection_state"));
|
||||
rows.add(issue(DEVICE, device, device.getValueString("name"), disconnected ? "Administratively disconnected" : "No connection for " + freshnessSeconds + " seconds",
|
||||
disconnected ? "Open the device and use Reconnect; review its observed control revision." : "Open the Windows agent and check its service, sign-in and outbound connection.", "last_seen_at"));
|
||||
disconnected ? "Open the " + PrintingNames.STATION_LOWER + " and use Reconnect; review its observed control revision." : "Open the Windows agent and check its service, sign-in and outbound connection.", "last_seen_at"));
|
||||
}
|
||||
for(QRecord device : query(DEVICE, new QQueryFilter().withCriteria("enabled", QCriteriaOperator.EQUALS, 1)
|
||||
.withCriteria("dispatch_paused", QCriteriaOperator.EQUALS, 1).withOrderBy(new QFilterOrderBy("name")).withLimit(10)))
|
||||
{
|
||||
rows.add(issue(DEVICE, device, device.getValueString("name"), "Dispatch paused", "Open the device queue, then use Resume dispatch when it is ready.", "control_last_poll_at"));
|
||||
rows.add(issue(DEVICE, device, device.getValueString("name"), "Dispatch paused", "Open the " + PrintingNames.STATION_LOWER + " queue, then use Resume printing when it is ready.", "control_last_poll_at"));
|
||||
}
|
||||
for(QRecord command : query(PrintingTables.COMMAND, statuses(List.of("PENDING", "FAILED", "EXPIRED", "OUTCOME_UNKNOWN"))
|
||||
.withCriteria("created_at", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minusSeconds(86400).toString())
|
||||
.withOrderBy(new QFilterOrderBy("created_at", false)).withLimit(10)))
|
||||
{
|
||||
rows.add(issue(PrintingTables.COMMAND, command, command.getValueString("action"), command.getValueString("command_status"),
|
||||
"Review command history and the device connection before submitting another request.", "created_at"));
|
||||
"Review command history and the " + PrintingNames.STATION_LOWER + " connection before submitting another request.", "created_at"));
|
||||
}
|
||||
for(QRecord printer : query(PRINTER, printerIssues().withOrderBy(new QFilterOrderBy("last_seen_at", true)).withLimit(10)))
|
||||
{
|
||||
rows.add(issue(PRINTER, printer, printer.getValueString("name"), printer.getValueString("status"), "Check paper, paused/error state and whether this queue is still exposed by the agent.", "last_seen_at"));
|
||||
}
|
||||
return table(rows, "No issues in this scope. If no devices are enrolled, start from Print devices.",
|
||||
return table(rows, "No issues in this scope. If no " + PrintingNames.STATIONS_LOWER + " are enrolled, start from " + PrintingNames.STATIONS + ".",
|
||||
new TableData.Column("composite", "Issue / record", "issue", null, "left"),
|
||||
new TableData.Column("default", "Next step", "action", null, "left"),
|
||||
new TableData.Column("default", "Observed at", "observed", null, "left"));
|
||||
@@ -287,7 +287,7 @@ public class PrintingHome extends AbstractWidgetRenderer
|
||||
private static StatisticsData offlineStatistic(Instant now) throws QException
|
||||
{
|
||||
long seconds = freshnessSeconds();
|
||||
return statistic(DEVICE, offline(now, seconds), "Enabled devices · now · disconnected by an administrator or unseen for " + seconds + " seconds");
|
||||
return statistic(DEVICE, offline(now, seconds), "Enabled " + PrintingNames.STATIONS_LOWER + " · now · disconnected by an administrator or unseen for " + seconds + " seconds");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ public class PrintingJobProcesses
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, retry
|
||||
? "Each selected job will create a new print job. Previously spooled or uncertain output may print twice. Inspect the printer before continuing."
|
||||
: "Only selected jobs still queued in QQQ will be canceled. Jobs already received by a device must be inspected in the Windows companion.");
|
||||
: "Only selected jobs still queued in QQQ will be canceled. Jobs already received by a " + PrintingNames.STATION_LOWER + " must be inspected in the Windows companion.");
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(PrintingExtract.class, Transform.class, Load.class, values)
|
||||
.withName(name).withLabel(retry ? "Print again" : "Cancel queued jobs").withTableName(PrintingTables.JOB).withMinInputRecords(1)
|
||||
.withPermissionRules(PrintingProcesses.permissionRules());
|
||||
@@ -243,6 +243,10 @@ public class PrintingJobProcesses
|
||||
}
|
||||
catch(java.sql.SQLException | PrintingException exception)
|
||||
{
|
||||
if(exception instanceof PrintingException refused && refused.getStatus() == 404 && "Device not found".equals(refused.getMessage()))
|
||||
{
|
||||
throw new QException("Job " + job.getValueString("title") + " cannot be processed: its " + PrintingNames.STATION_LOWER + " was deleted, removed or revoked. Print the document again from where it was printed.", exception);
|
||||
}
|
||||
throw new QException("Print job action failed: " + exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.printing;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The words an administrator reads for the two things printing manages. Every
|
||||
** label, action label, preview and message is built from these constants, so
|
||||
** a different choice of name is one edit here. Table, field and process names
|
||||
** (printing_device, printingEnrollDevice, ...) and the agent wire protocol do
|
||||
** not change with them.
|
||||
**
|
||||
** - Station: the computer that runs the print client (table printing_device).
|
||||
** - Printer: a physical printer, seen as a Windows print queue that a station
|
||||
** shares (table printing_printer).
|
||||
*******************************************************************************/
|
||||
public final class PrintingNames
|
||||
{
|
||||
public static final String STATION = "Print station";
|
||||
public static final String STATIONS = "Print stations";
|
||||
public static final String STATION_LOWER = "print station";
|
||||
public static final String STATIONS_LOWER = "print stations";
|
||||
public static final String PRINTER = "Printer";
|
||||
public static final String PRINTERS = "Printers";
|
||||
public static final String PRINTER_LOWER = "printer";
|
||||
public static final String PRINTERS_LOWER = "printers";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Constants only.
|
||||
***************************************************************************/
|
||||
private PrintingNames()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -81,15 +81,16 @@ public class PrintingProcesses implements BackendStep
|
||||
{
|
||||
PrintingJobProcesses.register(instance);
|
||||
registerProfiles(instance);
|
||||
add(instance, "printingEnrollDevice", "Enroll a print device", List.of(PrintingFacilities.field(instance), field("deviceName", "Device name")));
|
||||
add(instance, "printingEnablePrinter", "Set printer approval", List.of(deviceField(), printerField(null), new QFieldMetaData("enabled", QFieldType.BOOLEAN).withLabel("Approved for printing").withIsRequired(true)));
|
||||
add(instance, "printingEnrollDevice", "Enroll a " + PrintingNames.STATION_LOWER, List.of(PrintingFacilities.field(instance), field("deviceName", "Computer name")));
|
||||
add(instance, "printingEnablePrinter", "Set " + PrintingNames.PRINTER_LOWER + " approval", List.of(deviceField(), printerField(null), new QFieldMetaData("enabled", QFieldType.BOOLEAN).withLabel("Approved for printing").withIsRequired(true)));
|
||||
add(instance, "printingTestZpl", "Print a test label", List.of(deviceField(), printerField("ZPL"), profileField(false)));
|
||||
add(instance, "printingTestPdf", "Print a test page", List.of(deviceField(), printerField("PDF"), profileField(true)));
|
||||
selected(instance, "printingTestSelectedPdf", "Print a test page", "Each selected PDF printer will receive one test page after you continue.");
|
||||
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.");
|
||||
selected(instance, "printingEnableSelectedPrinters", "Approve " + PrintingNames.PRINTERS_LOWER, "The selected " + PrintingNames.PRINTERS_LOWER + " will be approved for printing after you continue.");
|
||||
selected(instance, "printingDisableSelectedPrinters", "Disable " + PrintingNames.PRINTERS_LOWER, "The selected " + PrintingNames.PRINTERS_LOWER + " will stop receiving new print jobs after you continue.");
|
||||
selected(instance, "printingRemoveSelectedPrinters", "Remove " + PrintingNames.PRINTERS_LOWER, "The selected " + PrintingNames.PRINTERS_LOWER + " will be hidden from the operator catalog, the selectors and their " + PrintingNames.STATIONS_LOWER + " after you continue; the records stay, marked removed. Job history is kept; sharing a queue again from its "
|
||||
+ PrintingNames.STATION_LOWER + " brings it back awaiting approval. To take the record out of the list as well, use " + PrintingDeleteProcesses.PRINTERS_LABEL + ".");
|
||||
}
|
||||
|
||||
|
||||
@@ -155,7 +156,7 @@ public class PrintingProcesses implements BackendStep
|
||||
***************************************************************************/
|
||||
private static QFieldMetaData deviceField()
|
||||
{
|
||||
return field("deviceId", "Print device").withPossibleValueSourceName(PrintingTables.DEVICE)
|
||||
return field("deviceId", PrintingNames.STATION).withPossibleValueSourceName(PrintingTables.DEVICE)
|
||||
.withPossibleValueSourceFilter(new QQueryFilter(new QFilterCriteria("removed_at", QCriteriaOperator.IS_BLANK)));
|
||||
}
|
||||
|
||||
@@ -174,7 +175,7 @@ public class PrintingProcesses implements BackendStep
|
||||
filter.withCriteria(new QFilterCriteria("available", QCriteriaOperator.EQUALS, 1));
|
||||
filter.withCriteria(new QFilterCriteria("formats", QCriteriaOperator.CONTAINS, format));
|
||||
}
|
||||
return field("printerId", "Printer").withPossibleValueSourceName(PrintingTables.PRINTER)
|
||||
return field("printerId", PrintingNames.PRINTER).withPossibleValueSourceName(PrintingTables.PRINTER)
|
||||
.withPossibleValueSourceFilter(filter).withPossibleValueSourceDependsOn(List.of("deviceId"));
|
||||
}
|
||||
|
||||
@@ -313,7 +314,7 @@ public class PrintingProcesses implements BackendStep
|
||||
QRecord printer = new GetAction().execute(get).getRecord();
|
||||
if(printer == null || (deviceId != null && !deviceId.equals(printer.getValueString("device_id"))))
|
||||
{
|
||||
throw new QException("Choose an accessible printer belonging to the selected device");
|
||||
throw new QException("Choose an accessible " + PrintingNames.PRINTER_LOWER + " belonging to the selected " + PrintingNames.STATION_LOWER);
|
||||
}
|
||||
return printer;
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Register device, printer and job tables and the Printing navigation app.
|
||||
** Register the station (device), printer, job, control and deletion tables and the Printing navigation app.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void produce(QInstance instance, String namespace) throws QException
|
||||
@@ -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", "removed_at"));
|
||||
QTableMetaData devices = table(PrintingTables.DEVICE, PrintingNames.STATIONS, 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"));
|
||||
@@ -106,22 +106,28 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
devices.withField(new QFieldMetaData("control_capabilities", QFieldType.STRING).withLabel("Agent control capabilities"));
|
||||
devices.withField(new QFieldMetaData("control_last_poll_at", QFieldType.STRING).withLabel("Last control connection"));
|
||||
devices.withField(new QFieldMetaData("revoked_at", QFieldType.STRING).withLabel("Access revoked at"));
|
||||
devices.withVirtualField(new QVirtualFieldMetaData("device_status", QFieldType.STRING).withLabel("Device status").withPossibleValueSourceName("printing_deviceStatus").withIsQuerySelectable(true));
|
||||
devices.withVirtualField(new QVirtualFieldMetaData("device_status", QFieldType.STRING).withLabel(PrintingNames.STATION + " status").withPossibleValueSourceName("printing_deviceStatus").withIsQuerySelectable(true));
|
||||
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", "removed_at"));
|
||||
QTableMetaData printers = table(PrintingTables.PRINTER, PrintingNames.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("available", QFieldType.INTEGER).withLabel("Exposed by " + PrintingNames.STATION_LOWER).withPossibleValueSourceName("printing_available"));
|
||||
printers.withField(new QFieldMetaData("media_profiles_json", QFieldType.STRING).withLabel("Reported queue default media"));
|
||||
QTableMetaData jobs = table(PrintingTables.JOB, "Print jobs", List.of("id", "tenant_id", "device_id", "printer_row_id", "title", "format", "content_hash", "options_json", "idempotency_key", "created_at", "status", "message", "spooler_job_id"));
|
||||
QTableMetaData jobs = table(PrintingTables.JOB, "Print jobs", List.of("id", "tenant_id", "device_id", "printer_row_id", "title", "format", "content_hash", "options_json", "idempotency_key", "created_at", "status", "message", "spooler_job_id",
|
||||
"device_name", "computer_name", "printer_name", "queue_name"));
|
||||
// What the station and printer were called when the job was submitted: the job reads the same after either is renamed or deleted.
|
||||
jobs.getField("device_name").withLabel(PrintingNames.STATION).withMaxLength(1024);
|
||||
jobs.getField("computer_name").withLabel("Computer name").withMaxLength(1024);
|
||||
jobs.getField("printer_name").withLabel(PrintingNames.PRINTER).withMaxLength(1024);
|
||||
jobs.getField("queue_name").withLabel("Windows queue name").withMaxLength(1024);
|
||||
jobs.withField(new QFieldMetaData("content_length", QFieldType.INTEGER).withLabel("Document size (bytes)"));
|
||||
// created_at stays the ISO-8601 text the queue SQL orders by; created_time is the same instant as a real date-time for relative filters, sorting and date buckets.
|
||||
jobs.withField(new QFieldMetaData("created_time", QFieldType.DATE_TIME).withLabel("Created"));
|
||||
jobs.getField("created_at").withLabel("Created at (ISO text)");
|
||||
QTableMetaData commands = table(PrintingTables.COMMAND, "Device control history", List.of("id", "tenant_id", "device_id", "request_id", "action", "job_id", "actor", "status", "created_at", "expires_at", "result_code"));
|
||||
QTableMetaData commands = table(PrintingTables.COMMAND, PrintingNames.STATION + " control history", List.of("id", "tenant_id", "device_id", "request_id", "action", "job_id", "actor", "status", "created_at", "expires_at", "result_code"));
|
||||
commands.withField(new QFieldMetaData("cancelled_job_count", QFieldType.INTEGER).withLabel("Queued jobs canceled"));
|
||||
commands.getField("device_id").withLabel("Print device").withPossibleValueSourceName(PrintingTables.DEVICE);
|
||||
commands.getField("device_id").withLabel(PrintingNames.STATION).withPossibleValueSourceName(PrintingTables.DEVICE);
|
||||
commands.getField("job_id").withLabel("Print job").withPossibleValueSourceName(PrintingTables.JOB);
|
||||
commands.getField("action").withPossibleValueSourceName("printing_controlAction");
|
||||
commands.getField("status").withLabel("Recorded status").withPossibleValueSourceName("printing_commandStatus");
|
||||
@@ -129,7 +135,7 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
.withPossibleValueSourceName("printing_commandStatus").withIsQuerySelectable(true));
|
||||
commands.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(PrintingCommandStatus.class));
|
||||
commands.withRecordLabelFormatAndFields("%s", "action");
|
||||
jobs.getField("printer_row_id").withLabel("Printer").withPossibleValueSourceName(PrintingTables.PRINTER);
|
||||
jobs.getField("printer_row_id").withLabel(PrintingNames.PRINTER + " record").withPossibleValueSourceName(PrintingTables.PRINTER);
|
||||
jobs.getField("format").withPossibleValueSourceName("printing_format");
|
||||
jobs.getField("status").withPossibleValueSourceName("printing_jobStatus");
|
||||
printers.getField("status").withPossibleValueSourceName("printing_printerStatus");
|
||||
@@ -159,30 +165,50 @@ public class PrintingQBitProducer implements QBitProducer
|
||||
printers.withRecordLabelFormatAndFields("%s", "name");
|
||||
jobs.withRecordLabelFormatAndFields("%s", "title");
|
||||
printers.getField("device_id").withPossibleValueSourceName(PrintingTables.DEVICE);
|
||||
jobs.getField("device_id").withLabel("Print device").withPossibleValueSourceName(PrintingTables.DEVICE);
|
||||
printers.getField("device_id").withLabel("Print device");
|
||||
jobs.getField("device_id").withLabel(PrintingNames.STATION + " record").withPossibleValueSourceName(PrintingTables.DEVICE);
|
||||
printers.getField("device_id").withLabel(PrintingNames.STATION);
|
||||
instance.addPossibleValueSource(com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource.newForTable(PrintingTables.DEVICE));
|
||||
instance.addPossibleValueSource(com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource.newForTable(PrintingTables.PRINTER));
|
||||
instance.addPossibleValueSource(com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource.newForTable(PrintingTables.JOB));
|
||||
layout(devices, List.of("name", "device_status", "control_status", "location", "facility_id", "last_seen_at"));
|
||||
layout(printers, List.of("name", "location", "description", "device_id", "status", "enabled", "available", "last_seen_at"));
|
||||
layout(jobs, List.of("title", "device_id", "printer_row_id", "status", "created_time", "format"));
|
||||
layout(jobs, List.of("title", "printer_name", "device_name", "status", "created_time", "format"));
|
||||
QTableMetaData deletions = table(PrintingTables.DELETION, "Deletion history", List.of("id", "tenant_id", "kind", "name", "computer_name", "queue_name", "facility_id", "actor", "record_id", "device_id"));
|
||||
deletions.withField(new QFieldMetaData("deleted_time", QFieldType.DATE_TIME).withLabel("Deleted"));
|
||||
deletions.withField(new QFieldMetaData("printer_count", QFieldType.INTEGER).withLabel(PrintingNames.PRINTERS + " deleted with it"));
|
||||
deletions.withField(new QFieldMetaData("cancelled_job_count", QFieldType.INTEGER).withLabel("Queued jobs canceled"));
|
||||
deletions.withField(new QFieldMetaData("unknown_job_count", QFieldType.INTEGER).withLabel("Jobs closed as outcome unknown"));
|
||||
deletions.withField(new QFieldMetaData("kept_job_count", QFieldType.INTEGER).withLabel("Print jobs kept"));
|
||||
deletions.getField("kind").withLabel("What was deleted").withPossibleValueSourceName("printing_deletionKind");
|
||||
deletions.getField("name").withMaxLength(1024);
|
||||
deletions.getField("computer_name").withLabel("Computer name").withMaxLength(1024);
|
||||
deletions.getField("queue_name").withLabel("Windows queue name").withMaxLength(1024);
|
||||
deletions.getField("facility_id").withLabel("Facility").withPossibleValueSourceName(devices.getField("facility_id").getPossibleValueSourceName());
|
||||
deletions.getField("actor").withLabel("Deleted by");
|
||||
deletions.getField("record_id").withLabel("Identifier it had");
|
||||
deletions.getField("device_id").withLabel("Identifier of its " + PrintingNames.STATION_LOWER);
|
||||
deletions.withRecordLabelFormatAndFields("%s", "name");
|
||||
instance.addPossibleValueSource(new QPossibleValueSource().withName("printing_deletionKind").withType(QPossibleValueSourceType.ENUM)
|
||||
.withEnumValues(List.of(new QPossibleValue<>("DEVICE", PrintingNames.STATION), new QPossibleValue<>("PRINTER", PrintingNames.PRINTER))));
|
||||
layout(deletions, List.of("kind", "name", "computer_name", "actor", "deleted_time", "printer_count", "cancelled_job_count", "unknown_job_count", "kept_job_count"));
|
||||
layout(commands, List.of("device_id", "action", "command_status", "created_at", "expires_at", "result_code", "cancelled_job_count"));
|
||||
instance.addTable(devices);
|
||||
instance.addTable(printers);
|
||||
instance.addTable(jobs);
|
||||
instance.addTable(commands);
|
||||
related(instance, devices, printers, "device_id", "Printers");
|
||||
instance.addTable(deletions);
|
||||
related(instance, devices, printers, "device_id", PrintingNames.PRINTERS);
|
||||
related(instance, devices, jobs, "device_id", "Jobs and queue");
|
||||
related(instance, devices, commands, "device_id", "Control history");
|
||||
related(instance, printers, jobs, "printer_row_id", "Jobs and queue");
|
||||
QAppMetaData app = new QAppMetaData().withName("printing").withLabel("Printing").withIcon(new QIcon("print")).withChild(devices).withChild(printers).withChild(jobs).withChild(commands);
|
||||
QAppMetaData app = new QAppMetaData().withName("printing").withLabel("Printing").withIcon(new QIcon("print")).withChild(devices).withChild(printers).withChild(jobs).withChild(commands).withChild(deletions);
|
||||
app.withPermissionRules(PrintingProcesses.permissionRules());
|
||||
PrintingProcesses.register(instance, app);
|
||||
PrintingOperatorProcesses.register(instance);
|
||||
PrintingListProcesses.register(instance);
|
||||
PrintingRenderProcesses.register(instance);
|
||||
PrintingDeviceProcesses.register(instance);
|
||||
PrintingDeleteProcesses.register(instance);
|
||||
PrintingUiApi.register(instance);
|
||||
PrintingRetentionProcesses.register(instance, config);
|
||||
PrintingRealtimeProcesses.register(instance, app);
|
||||
|
||||
@@ -26,6 +26,8 @@ import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.metadata.TableMetaDataInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.reporting.ExportInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
@@ -53,12 +55,26 @@ public class PrintingReadPermission extends PrintingAdminPermission
|
||||
{
|
||||
super.checkPermissionsThrowing(input, metadata);
|
||||
}
|
||||
if(input != null && !(input instanceof QueryInput) && !(input instanceof GetInput) && !(input instanceof CountInput)
|
||||
&& !(input instanceof MetaDataInput) && !(input instanceof TableMetaDataInput))
|
||||
if(input != null && !isRead(input))
|
||||
{
|
||||
throw new QPermissionDeniedException("Use the printing management service to change operational state");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The inputs that only read rows or describe the table. QQQ does not tell a
|
||||
** custom checker whether it is being asked about a read or a write, so the
|
||||
** input's own type decides: a query, a get, a count, an aggregate (a grouped
|
||||
** read: counts and sums by a field) and an export (a query written to a file)
|
||||
** read; anything else is treated as a write and refused.
|
||||
***************************************************************************/
|
||||
static boolean isRead(AbstractActionInput input)
|
||||
{
|
||||
return input instanceof QueryInput || input instanceof GetInput || input instanceof CountInput || input instanceof AggregateInput
|
||||
|| input instanceof ExportInput || input instanceof MetaDataInput || input instanceof TableMetaDataInput;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ public class PrintingRealtimeProcesses implements BackendStep
|
||||
new Setting("maxPdfMiB", "maxPdfBytes", "Maximum PDF size (MiB)", MEBIBYTE),
|
||||
new Setting("maxZplMiB", "maxZplBytes", "Maximum ZPL size (MiB)", MEBIBYTE),
|
||||
new Setting("maxCopies", "maxCopies", "Maximum copies per job", 1),
|
||||
new Setting("maxStagedMiB", "maxStagedBytes", "Maximum staged documents on each device (MiB)", MEBIBYTE));
|
||||
new Setting("maxStagedMiB", "maxStagedBytes", "Maximum staged documents on each " + PrintingNames.STATION_LOWER + " (MiB)", MEBIBYTE));
|
||||
private static final List<Setting> TRANSFERS = List.of(
|
||||
new Setting("maxConcurrentDownloads", "maxConcurrentDownloads", "Concurrent document downloads", 1),
|
||||
new Setting("maxConcurrentPrints", "maxConcurrentPrints", "Concurrent printer submissions", 1),
|
||||
@@ -76,16 +76,16 @@ public class PrintingRealtimeProcesses implements BackendStep
|
||||
new Setting("maxQueuedFrames", "maxQueuedFrames", "Maximum queued control messages", 1),
|
||||
new Setting("maxQueuedControlMiB", "maxQueuedFrameBytes", "Maximum queued control message data (MiB)", MEBIBYTE));
|
||||
private static final List<Setting> ADMISSION_COUNTS = List.of(
|
||||
new Setting("deviceOutstandingJobs", "deviceOutstandingJobs", "Outstanding jobs per device", 1),
|
||||
new Setting("deviceOutstandingJobs", "deviceOutstandingJobs", "Outstanding jobs per " + PrintingNames.STATION_LOWER, 1),
|
||||
new Setting("tenantOutstandingJobs", "tenantOutstandingJobs", "Outstanding jobs across this tenant", 1),
|
||||
new Setting("deviceJobsPerHour", "deviceJobsPerHour", "Jobs allocated per device per hour", 1),
|
||||
new Setting("deviceJobsPerHour", "deviceJobsPerHour", "Jobs allocated per " + PrintingNames.STATION_LOWER + " per hour", 1),
|
||||
new Setting("tenantJobsPerHour", "tenantJobsPerHour", "Jobs allocated across this tenant per hour", 1),
|
||||
new Setting("devicePrinters", "devicePrinters", "Printers per device", 1),
|
||||
new Setting("devicePrinters", "devicePrinters", PrintingNames.PRINTERS + " per " + PrintingNames.STATION_LOWER, 1),
|
||||
new Setting("tenantPrinters", "tenantPrinters", "Printers across this tenant", 1));
|
||||
private static final List<Setting> ADMISSION_STORAGE = List.of(
|
||||
new Setting("deviceOutstandingMiB", "deviceOutstandingBytes", "Outstanding document data per device (MiB)", MEBIBYTE),
|
||||
new Setting("deviceOutstandingMiB", "deviceOutstandingBytes", "Outstanding document data per " + PrintingNames.STATION_LOWER + " (MiB)", MEBIBYTE),
|
||||
new Setting("tenantOutstandingMiB", "tenantOutstandingBytes", "Outstanding document data across this tenant (MiB)", MEBIBYTE),
|
||||
new Setting("deviceRetainedGiB", "deviceRetainedBytes", "Retained document data per device (GiB)", 1024 * MEBIBYTE),
|
||||
new Setting("deviceRetainedGiB", "deviceRetainedBytes", "Retained document data per " + PrintingNames.STATION_LOWER + " (GiB)", 1024 * MEBIBYTE),
|
||||
new Setting("tenantRetainedGiB", "tenantRetainedBytes", "Retained document data across this tenant (GiB)", 1024 * MEBIBYTE));
|
||||
|
||||
|
||||
@@ -150,9 +150,9 @@ public class PrintingRealtimeProcesses implements BackendStep
|
||||
output.addValue(setting.input(), BigDecimal.valueOf(policy.getLong(setting.policy())).divide(BigDecimal.valueOf(setting.unit())));
|
||||
}
|
||||
}
|
||||
output.addValue("transport", "The device opens a two-way WebSocket to this server. Documents use separate HTTPS transfers; job and command polling is not used.");
|
||||
output.addValue("transport", "The " + PrintingNames.STATION_LOWER + " opens a two-way WebSocket to this server. Documents use separate HTTPS transfers; job and command polling is not used.");
|
||||
output.addValue("scope", admission ? "These limits apply to this tenant. Capacity is checked when jobs and printers are admitted. Changing a limit does not remove existing jobs, documents or history."
|
||||
: "These settings apply to this tenant. Saved changes are sent to connected devices. Unsupported device limits require an explicit configuration correction; they are not silently reduced.");
|
||||
: "These settings apply to this tenant. Saved changes are sent to connected " + PrintingNames.STATIONS_LOWER + ". Unsupported " + PrintingNames.STATION_LOWER + " limits require an explicit configuration correction; they are not silently reduced.");
|
||||
output.addValue("revisionLabel", admission ? "Tenant admission policy" : "Policy revision " + policy.getLong("revision"));
|
||||
}
|
||||
else if("execute".equals(input.getStepName()))
|
||||
@@ -167,7 +167,7 @@ public class PrintingRealtimeProcesses implements BackendStep
|
||||
}
|
||||
JSONObject saved = admission ? service.saveAdmissionSettings(requested) : service.saveRealtimeSettings(requested);
|
||||
output.addValue("result", admission ? "Queue, rate, printer and storage limits saved for this tenant. Existing jobs and history were preserved."
|
||||
: "Connection and document limits saved as policy revision " + saved.getLong("revision") + ". Connected devices receive the update over their existing WebSocket; no documents or job history were removed.");
|
||||
: "Connection and document limits saved as policy revision " + saved.getLong("revision") + ". Connected " + PrintingNames.STATIONS_LOWER + " receive the update over their existing WebSocket; no documents or job history were removed.");
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -189,7 +189,7 @@ public final class PrintingRecordProcesses
|
||||
if(!Integer.valueOf(1).equals(printer.getValueInteger("enabled")) || !Integer.valueOf(1).equals(printer.getValueInteger("available"))
|
||||
|| printer.getValueString("formats") == null || !new org.json.JSONArray(printer.getValueString("formats")).toList().contains("PDF"))
|
||||
{
|
||||
throw new QException("Printer " + printer.getValueString("name") + " must be exposed by its device, approved, and support PDF");
|
||||
throw new QException("Printer " + printer.getValueString("name") + " must be exposed by its " + PrintingNames.STATION_LOWER + ", approved, and support PDF");
|
||||
}
|
||||
return printer;
|
||||
}
|
||||
|
||||
@@ -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, 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.");
|
||||
output.addValue("result", "Removed " + removed + " eligible documents, abandoned uploads or purged removed " + PrintingNames.PRINTERS_LOWER + " and " + PrintingNames.STATIONS_LOWER + " 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, 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.");
|
||||
output.addValue("safety", "Cleanup removes only old successful-terminal document content, and purges removed " + PrintingNames.PRINTERS_LOWER + " and " + PrintingNames.STATIONS_LOWER + " 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.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.uber.nullaway.annotations.EnsuresNonNull;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
@@ -53,6 +54,9 @@ public class PrintingService
|
||||
{
|
||||
static final String CANCELED_BEFORE_DISPATCH = "Canceled by operator before dispatch";
|
||||
private static final QLogger LOG = QLogger.getLogger(PrintingService.class);
|
||||
// The jobs of one printer: by its row, or by station and queue identity for a job written before printer_row_id existed. Parameters: printer row id, device id, printer_id.
|
||||
private static final String PRINTER_JOBS = "(printer_row_id=? OR (printer_row_id IS NULL AND device_id=? AND printer_id=?))";
|
||||
private static final String SCHEMA_STEP_MISSING = "Print jobs are still tied to their print station by a database foreign key (printing_job.device_id). Apply the schema step of this qbit-printing version, then delete again.";
|
||||
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;
|
||||
@@ -323,6 +327,14 @@ public class PrintingService
|
||||
"ALTER TABLE printing_printer ADD COLUMN IF NOT EXISTS location VARCHAR(512)",
|
||||
"UPDATE printing_device SET computer_name=name WHERE computer_name IS NULL",
|
||||
"UPDATE printing_printer SET queue_name=name WHERE queue_name IS NULL",
|
||||
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS device_name VARCHAR(1024)",
|
||||
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS computer_name VARCHAR(1024)",
|
||||
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS printer_name VARCHAR(1024)",
|
||||
"ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS queue_name VARCHAR(1024)",
|
||||
"UPDATE printing_job SET device_name=(SELECT d.name FROM printing_device d WHERE d.id=printing_job.device_id), computer_name=(SELECT d.computer_name FROM printing_device d WHERE d.id=printing_job.device_id) WHERE device_name IS NULL",
|
||||
"UPDATE printing_job SET printer_name=(SELECT p.name FROM printing_printer p WHERE p.id=printing_job.printer_row_id), queue_name=(SELECT p.queue_name FROM printing_printer p WHERE p.id=printing_job.printer_row_id) WHERE printer_name IS NULL",
|
||||
"CREATE TABLE IF NOT EXISTS printing_deletion (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL, kind VARCHAR(20) NOT NULL, record_id VARCHAR(36) NOT NULL, name VARCHAR(1024) NOT NULL, computer_name VARCHAR(1024), queue_name VARCHAR(1024), facility_id VARCHAR(200), device_id VARCHAR(36), actor VARCHAR(200) NOT NULL, deleted_time TIMESTAMP NOT NULL, printer_count INTEGER DEFAULT 0 NOT NULL, cancelled_job_count INTEGER DEFAULT 0 NOT NULL, unknown_job_count INTEGER DEFAULT 0 NOT NULL, kept_job_count INTEGER DEFAULT 0 NOT NULL)",
|
||||
"CREATE INDEX IF NOT EXISTS printing_deletion_tenant_idx ON printing_deletion(tenant_id, deleted_time, id)",
|
||||
"CREATE INDEX IF NOT EXISTS printing_job_dispatch_idx ON printing_job(tenant_id, device_id, status, created_at, id)",
|
||||
"CREATE INDEX IF NOT EXISTS printing_job_history_idx ON printing_job(tenant_id, created_at, id)",
|
||||
"CREATE INDEX IF NOT EXISTS printing_job_created_time_idx ON printing_job(tenant_id, created_time, id)",
|
||||
@@ -344,6 +356,7 @@ public class PrintingService
|
||||
PrintingAdmission.initialize(connection, tenantId);
|
||||
PrintingDeviceControls.initialize(connection);
|
||||
update(connection, "ALTER TABLE printing_device ADD COLUMN IF NOT EXISTS bound_user_id VARCHAR(200)");
|
||||
dropJobDeviceForeignKey(connection);
|
||||
widenColumn(connection, "printing_device", "name", 1024);
|
||||
widenColumn(connection, "printing_enrollment", "device_name", 1024);
|
||||
widenColumn(connection, "printing_printer", "name", 1024);
|
||||
@@ -359,6 +372,62 @@ public class PrintingService
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A print job outlives the print station it was sent to: once a station can
|
||||
** be deleted, printing_job.device_id is the identifier the station had, not
|
||||
** a reference that must resolve. Drop the foreign key the first schema
|
||||
** created (its name is database-generated, so it is found, not assumed).
|
||||
***************************************************************************/
|
||||
private static void dropJobDeviceForeignKey(Connection connection) throws SQLException
|
||||
{
|
||||
for(String constraint : jobDeviceForeignKeys(connection))
|
||||
{
|
||||
try(PreparedStatement statement = connection.prepareStatement("ALTER TABLE printing_job DROP CONSTRAINT \"" + constraint + "\""))
|
||||
{
|
||||
statement.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The names of the foreign keys from printing_job to printing_device in the
|
||||
** connection's schema; none once this version's schema step has run.
|
||||
***************************************************************************/
|
||||
private static Set<String> jobDeviceForeignKeys(Connection connection) throws SQLException
|
||||
{
|
||||
List<String> jobTables = new ArrayList<>();
|
||||
try(ResultSet tables = connection.getMetaData().getTables(connection.getCatalog(), connection.getSchema(), "%", new String[] {"TABLE"}))
|
||||
{
|
||||
while(tables.next())
|
||||
{
|
||||
if("printing_job".equalsIgnoreCase(tables.getString("TABLE_NAME")))
|
||||
{
|
||||
jobTables.add(tables.getString("TABLE_NAME"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Set<String> constraints = new java.util.LinkedHashSet<>();
|
||||
for(String jobTable : jobTables)
|
||||
{
|
||||
try(ResultSet keys = connection.getMetaData().getImportedKeys(connection.getCatalog(), connection.getSchema(), jobTable))
|
||||
{
|
||||
while(keys.next())
|
||||
{
|
||||
String name = keys.getString("FK_NAME");
|
||||
if("printing_device".equalsIgnoreCase(keys.getString("PKTABLE_NAME")) && name != null && name.matches("[A-Za-z0-9_$]+"))
|
||||
{
|
||||
constraints.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return constraints;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Widen fixed application columns without changing identifiers or content.
|
||||
***************************************************************************/
|
||||
@@ -853,7 +922,7 @@ public class PrintingService
|
||||
{
|
||||
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(409, "Printer was removed; share it from its " + PrintingNames.STATION_LOWER + " again before changing its approval");
|
||||
}
|
||||
throw new PrintingException(404, "Printer not found");
|
||||
}
|
||||
@@ -900,7 +969,7 @@ public class PrintingService
|
||||
JSONObject device = requireDeviceRow(connection, deviceId);
|
||||
if(!device.has("revoked_at"))
|
||||
{
|
||||
throw new PrintingException(409, "Revoke the device's access before removing it");
|
||||
throw new PrintingException(409, "Revoke the " + PrintingNames.STATION_LOWER + "'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);
|
||||
@@ -911,6 +980,374 @@ public class PrintingService
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What deleting a print station would do, without changing anything: the
|
||||
** printers that go with it, the queued jobs that would be canceled, the jobs
|
||||
** already sent to it that would be closed as outcome unknown, the jobs whose
|
||||
** history is kept, and "refusal" when it cannot be deleted now.
|
||||
***************************************************************************/
|
||||
public JSONObject deviceDeletionPlan(String deviceId) throws SQLException
|
||||
{
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
return deviceDeletionPlan(connection, deviceId, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What deleting one printer would do, without changing anything.
|
||||
***************************************************************************/
|
||||
public JSONObject printerDeletionPlan(String printerRowId) throws SQLException
|
||||
{
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
return printerDeletionPlan(connection, printerRowId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Delete a print station and its printers for good.
|
||||
**
|
||||
** 1. Refused (409) while the station is connected and has jobs it has been
|
||||
** sent but not reported on: their result would be lost.
|
||||
** 2. Its credential is revoked through the REVOKE control, exactly as Revoke
|
||||
** access does, so the computer is disconnected and cannot come back as
|
||||
** this identity. An already revoked station skips this step.
|
||||
** 3. In one transaction: every job of the station gets the station and
|
||||
** printer names it was printed under (if it does not have them yet);
|
||||
** queued jobs become FAILED (canceled before dispatch); jobs sent to the
|
||||
** station without a reported result become OUTCOME_UNKNOWN; each of those
|
||||
** gets a job event naming the actor; the deletion is written to
|
||||
** printing_deletion (one row for the station, one for each printer); the
|
||||
** control receipts, the printers and the station row are deleted.
|
||||
**
|
||||
** Jobs are never deleted and keep device_id, printer_id and printer_row_id
|
||||
** as the identifiers the station and printer had. A computer enrolled again
|
||||
** is a new station with a new identifier. Anything that arrives later from
|
||||
** the deleted station (heartbeat, claim, event, control poll or receipt) is
|
||||
** answered "Device not found".
|
||||
***************************************************************************/
|
||||
public JSONObject deleteDevice(String deviceId, String actor) throws SQLException
|
||||
{
|
||||
requireActor(actor);
|
||||
JSONObject plan;
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
plan = deviceDeletionPlan(connection, deviceId, false);
|
||||
// Checked before anything is revoked: a station is never left revoked but undeletable.
|
||||
if(plan.getLong("jobs") > 0 && !jobDeviceForeignKeys(connection).isEmpty())
|
||||
{
|
||||
throw new PrintingException(409, SCHEMA_STEP_MISSING);
|
||||
}
|
||||
}
|
||||
refuseIfPlanned(plan);
|
||||
boolean revoked = false;
|
||||
if(plan.getBoolean("credentialLive"))
|
||||
{
|
||||
revokeBeforeDelete(deviceId, actor);
|
||||
revoked = true;
|
||||
}
|
||||
JSONObject result;
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
PrintingAdmission.initializeTenant(connection, tenantId);
|
||||
connection.setAutoCommit(false);
|
||||
try
|
||||
{
|
||||
PrintingAdmission.lock(connection, tenantId);
|
||||
plan = deviceDeletionPlan(connection, deviceId, true);
|
||||
refuseIfPlanned(plan);
|
||||
Instant now = Instant.now();
|
||||
String name = plan.getString("name");
|
||||
String computer = plan.getString("computerName");
|
||||
update(connection, "UPDATE printing_job SET device_name=COALESCE(device_name,?), computer_name=COALESCE(computer_name,?) WHERE tenant_id=? AND device_id=?", name, computer, tenantId, deviceId);
|
||||
List<JSONObject> printers = query(connection, "SELECT id, printer_id, name, queue_name FROM printing_printer WHERE tenant_id=? AND device_id=? ORDER BY name, id", tenantId, deviceId);
|
||||
for(JSONObject printer : printers)
|
||||
{
|
||||
namePrinterJobs(connection, printer, deviceId);
|
||||
}
|
||||
int[] closed = closeOpenJobs(connection, "device_id=?", new Object[] {deviceId}, PrintingNames.STATION_LOWER, actor, now);
|
||||
long kept = query(connection, "SELECT COUNT(*) AS n FROM printing_job WHERE tenant_id=? AND device_id=?", tenantId, deviceId).getFirst().getLong("n");
|
||||
for(JSONObject printer : printers)
|
||||
{
|
||||
recordDeletion(connection, "PRINTER", printer.getString("id"), printer.getString("name"), computer, printer.optString("queue_name", printer.getString("name")), plan.optString("facilityId", null), deviceId, actor, now, 0, 0, 0, 0);
|
||||
}
|
||||
recordDeletion(connection, "DEVICE", deviceId, name, computer, null, plan.optString("facilityId", null), null, actor, now, printers.size(), closed[0], closed[1], kept);
|
||||
update(connection, "DELETE FROM printing_device_command WHERE tenant_id=? AND device_id=?", tenantId, deviceId);
|
||||
update(connection, "DELETE FROM printing_printer WHERE tenant_id=? AND device_id=?", tenantId, deviceId);
|
||||
// The retained-byte counter stays while stored documents of this station's jobs are still charged to it; cleanup removes it afterwards.
|
||||
update(connection, "DELETE FROM printing_resource_usage WHERE tenant_id=? AND device_id=? AND retained_bytes=0", tenantId, deviceId);
|
||||
update(connection, "DELETE FROM printing_device WHERE tenant_id=? AND id=?", tenantId, deviceId);
|
||||
connection.commit();
|
||||
result = new JSONObject().put("deleted", true).put("deviceId", deviceId).put("name", name).put("printers", printers.size())
|
||||
.put("cancelledJobs", closed[0]).put("unknownJobs", closed[1]).put("keptJobs", kept).put("credentialRevoked", revoked);
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
connection.rollback();
|
||||
refuseIfSchemaStepMissing(exception);
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
LOG.info("A print station was deleted", logPair("tenantId", tenantId), logPair("deviceId", deviceId), logPair("actor", actor), logPair("printers", result.getInt("printers")),
|
||||
logPair("cancelledJobs", result.getInt("cancelledJobs")), logPair("unknownJobs", result.getInt("unknownJobs")));
|
||||
realtimeBus.publish(tenantId, deviceId);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Delete one printer for good, leaving its print station in service.
|
||||
**
|
||||
** Refused (409) while its station is connected and has jobs for this printer
|
||||
** that it has been sent but not reported on. Otherwise, in one transaction:
|
||||
** the printer's jobs get the printer and station names they were printed
|
||||
** under (if missing); its queued jobs become FAILED (canceled before
|
||||
** dispatch); jobs sent without a reported result become OUTCOME_UNKNOWN;
|
||||
** the deletion is written to printing_deletion; the printer row is deleted.
|
||||
**
|
||||
** A queue the station still shares is reported again by its next inventory
|
||||
** and comes back as a new printer, exposed and not approved.
|
||||
***************************************************************************/
|
||||
public JSONObject deletePrinter(String printerRowId, String actor) throws SQLException
|
||||
{
|
||||
requireActor(actor);
|
||||
JSONObject result;
|
||||
String deviceId;
|
||||
try(Connection connection = dataSource.getConnection())
|
||||
{
|
||||
PrintingAdmission.initializeTenant(connection, tenantId);
|
||||
connection.setAutoCommit(false);
|
||||
try
|
||||
{
|
||||
PrintingAdmission.lock(connection, tenantId);
|
||||
deviceId = printerDeletionPlan(connection, printerRowId).getString("deviceId");
|
||||
query(connection, "SELECT id FROM printing_device WHERE tenant_id=? AND id=? FOR UPDATE", tenantId, deviceId);
|
||||
JSONObject plan = printerDeletionPlan(connection, printerRowId);
|
||||
refuseIfPlanned(plan);
|
||||
Instant now = Instant.now();
|
||||
JSONObject printer = new JSONObject().put("id", printerRowId).put("printer_id", plan.getString("printerKey")).put("name", plan.getString("name")).put("queue_name", plan.getString("queueName"));
|
||||
update(connection, "UPDATE printing_job SET device_name=COALESCE(device_name,?), computer_name=COALESCE(computer_name,?) WHERE tenant_id=? AND " + PRINTER_JOBS, plan.getString("stationName"), plan.getString("computerName"), tenantId, printerRowId, deviceId, plan.getString("printerKey"));
|
||||
namePrinterJobs(connection, printer, deviceId);
|
||||
int[] closed = closeOpenJobs(connection, PRINTER_JOBS, new Object[] {printerRowId, deviceId, plan.getString("printerKey")}, PrintingNames.PRINTER_LOWER, actor, now);
|
||||
long kept = query(connection, "SELECT COUNT(*) AS n FROM printing_job WHERE tenant_id=? AND " + PRINTER_JOBS, tenantId, printerRowId, deviceId, plan.getString("printerKey")).getFirst().getLong("n");
|
||||
recordDeletion(connection, "PRINTER", printerRowId, plan.getString("name"), plan.getString("computerName"), plan.getString("queueName"), plan.optString("facilityId", null), deviceId, actor, now, 0, closed[0], closed[1], kept);
|
||||
update(connection, "DELETE FROM printing_printer WHERE tenant_id=? AND id=?", tenantId, printerRowId);
|
||||
connection.commit();
|
||||
result = new JSONObject().put("deleted", true).put("printerId", printerRowId).put("deviceId", deviceId).put("name", plan.getString("name"))
|
||||
.put("cancelledJobs", closed[0]).put("unknownJobs", closed[1]).put("keptJobs", kept);
|
||||
}
|
||||
catch(Exception exception)
|
||||
{
|
||||
connection.rollback();
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
LOG.info("A printer was deleted", logPair("tenantId", tenantId), logPair("printerRowId", printerRowId), logPair("deviceId", deviceId), logPair("actor", actor),
|
||||
logPair("cancelledJobs", result.getInt("cancelledJobs")), logPair("unknownJobs", result.getInt("unknownJobs")));
|
||||
realtimeBus.publish(tenantId, deviceId);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Revoke exactly as the Revoke access action does (credential rotated,
|
||||
** disconnected, pending commands closed, connection woken). Kept as its own
|
||||
** step so that anything a host hangs on revocation also runs before delete.
|
||||
***************************************************************************/
|
||||
private void revokeBeforeDelete(String deviceId, String actor) throws SQLException
|
||||
{
|
||||
deviceAction(deviceId, new JSONObject().put("requestId", "delete-" + UUID.randomUUID()).put("issuedAt", Instant.now().toString()).put("action", "REVOKE"), actor);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read (and with lock, hold) one station and count what a delete touches.
|
||||
***************************************************************************/
|
||||
private JSONObject deviceDeletionPlan(Connection connection, String deviceId, boolean lock) throws SQLException
|
||||
{
|
||||
List<JSONObject> rows = query(connection, "SELECT * FROM printing_device WHERE tenant_id=? AND id=?" + (lock ? " FOR UPDATE" : ""), tenantId, deviceId);
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
throw new PrintingException(404, "Device not found");
|
||||
}
|
||||
JSONObject device = rows.getFirst();
|
||||
String name = device.getString("name");
|
||||
long inProgress = jobCount(connection, "device_id=? AND status IN ('CLAIMED','RECEIVED','SUBMITTING')", deviceId);
|
||||
JSONObject plan = new JSONObject().put("deviceId", deviceId).put("name", name).put("computerName", device.optString("computer_name", name)).put("facilityId", device.getString("facility_id"))
|
||||
.put("printers", query(connection, "SELECT COUNT(*) AS n FROM printing_printer WHERE tenant_id=? AND device_id=?", tenantId, deviceId).getFirst().getLong("n"))
|
||||
.put("queuedJobs", jobCount(connection, "device_id=? AND status='QUEUED'", deviceId)).put("jobsInProgress", inProgress).put("jobs", jobCount(connection, "device_id=?", deviceId))
|
||||
.put("credentialLive", device.getInt("enabled") == 1 && !device.has("revoked_at"));
|
||||
if(inProgress > 0 && reporting(device, Instant.now()))
|
||||
{
|
||||
plan.put("refusal", PrintingNames.STATION + " " + name + " is connected and is printing " + inProgress + (inProgress == 1 ? " job" : " jobs")
|
||||
+ " right now. Wait until " + (inProgress == 1 ? "it finishes" : "they finish") + ", or use Disconnect or Revoke access first (the result is then recorded as unknown), and delete it again.");
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read one printer with its station and count what a delete touches.
|
||||
***************************************************************************/
|
||||
private JSONObject printerDeletionPlan(Connection connection, String printerRowId) throws SQLException
|
||||
{
|
||||
List<JSONObject> rows = query(connection, "SELECT id, device_id, printer_id, name, queue_name FROM printing_printer WHERE tenant_id=? AND id=?", tenantId, printerRowId);
|
||||
if(rows.isEmpty())
|
||||
{
|
||||
throw new PrintingException(404, "Printer not found");
|
||||
}
|
||||
JSONObject printer = rows.getFirst();
|
||||
String deviceId = printer.getString("device_id");
|
||||
String key = printer.getString("printer_id");
|
||||
String name = printer.getString("name");
|
||||
JSONObject device = query(connection, "SELECT * FROM printing_device WHERE tenant_id=? AND id=?", tenantId, deviceId).getFirst();
|
||||
long inProgress = jobCount(connection, PRINTER_JOBS + " AND status IN ('CLAIMED','RECEIVED','SUBMITTING')", printerRowId, deviceId, key);
|
||||
JSONObject plan = new JSONObject().put("printerId", printerRowId).put("deviceId", deviceId).put("printerKey", key).put("name", name).put("queueName", printer.optString("queue_name", name))
|
||||
.put("stationName", device.getString("name")).put("computerName", device.optString("computer_name", device.getString("name"))).put("facilityId", device.getString("facility_id"))
|
||||
.put("queuedJobs", jobCount(connection, PRINTER_JOBS + " AND status='QUEUED'", printerRowId, deviceId, key)).put("jobsInProgress", inProgress)
|
||||
.put("jobs", jobCount(connection, PRINTER_JOBS, printerRowId, deviceId, key));
|
||||
if(inProgress > 0 && reporting(device, Instant.now()))
|
||||
{
|
||||
plan.put("refusal", PrintingNames.PRINTER + " " + name + " is printing " + inProgress + (inProgress == 1 ? " job" : " jobs") + " right now on " + PrintingNames.STATION_LOWER + " " + device.getString("name")
|
||||
+ ". Wait until " + (inProgress == 1 ? "it finishes" : "they finish") + ", or disconnect the " + PrintingNames.STATION_LOWER + " first (the result is then recorded as unknown), and delete it again.");
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Count this tenant's jobs matching a fixed predicate.
|
||||
***************************************************************************/
|
||||
private long jobCount(Connection connection, String predicate, Object... values) throws SQLException
|
||||
{
|
||||
Object[] scoped = new Object[values.length + 1];
|
||||
scoped[0] = tenantId;
|
||||
System.arraycopy(values, 0, scoped, 1, values.length);
|
||||
return query(connection, "SELECT COUNT(*) AS n FROM printing_job WHERE tenant_id=? AND " + predicate, scoped).getFirst().getLong("n");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Can this station still report the result of a job it holds? Only one that
|
||||
** holds a live credential and shows as ONLINE or PAUSED in the Status column
|
||||
** (seen in the last 90 seconds and not disconnected by an administrator).
|
||||
***************************************************************************/
|
||||
private static boolean reporting(JSONObject device, Instant now)
|
||||
{
|
||||
if(device.getInt("enabled") != 1 || device.has("revoked_at"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
QRecord record = new QRecord().withValue("enabled", device.getInt("enabled")).withValue("connection_state", device.optString("connection_state", "CONNECTED"))
|
||||
.withValue("last_seen_at", device.optString("last_seen_at", null)).withValue("control_last_poll_at", device.optString("control_last_poll_at", null))
|
||||
.withValue("dispatch_paused", device.optInt("dispatch_paused", 0));
|
||||
return Set.of("ONLINE", "PAUSED").contains(PrintingDeviceStatus.presence(record, now));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A plan that names a refusal is not carried out.
|
||||
***************************************************************************/
|
||||
private static void refuseIfPlanned(JSONObject plan)
|
||||
{
|
||||
if(plan.has("refusal"))
|
||||
{
|
||||
throw new PrintingException(409, plan.getString("refusal"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A deletion is always attributed to a real, bounded actor.
|
||||
***************************************************************************/
|
||||
private static void requireActor(@Nullable String actor)
|
||||
{
|
||||
if(actor == null || actor.isBlank() || actor.length() > 200)
|
||||
{
|
||||
throw new PrintingException(400, "A bounded authenticated actor is required");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Give the printer's jobs the printer and queue names, where they have none.
|
||||
***************************************************************************/
|
||||
private void namePrinterJobs(Connection connection, JSONObject printer, String deviceId) throws SQLException
|
||||
{
|
||||
String name = printer.getString("name");
|
||||
update(connection, "UPDATE printing_job SET printer_name=COALESCE(printer_name,?), queue_name=COALESCE(queue_name,?) WHERE tenant_id=? AND " + PRINTER_JOBS,
|
||||
name, printer.optString("queue_name", name), tenantId, printer.getString("id"), deviceId, printer.getString("printer_id"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Close work that can no longer complete: a queued job is canceled (FAILED),
|
||||
** a job that was sent without a reported result becomes OUTCOME_UNKNOWN.
|
||||
** Each gets a job event naming the actor. Returns {canceled, unknown}.
|
||||
***************************************************************************/
|
||||
private int[] closeOpenJobs(Connection connection, String predicate, Object[] values, String what, String actor, Instant now) throws SQLException
|
||||
{
|
||||
Object[] scoped = new Object[values.length + 1];
|
||||
scoped[0] = tenantId;
|
||||
System.arraycopy(values, 0, scoped, 1, values.length);
|
||||
int[] closed = new int[2];
|
||||
for(JSONObject job : query(connection, "SELECT id, status FROM printing_job WHERE tenant_id=? AND " + predicate + " AND status IN ('QUEUED','CLAIMED','RECEIVED','SUBMITTING') ORDER BY created_at, id FOR UPDATE", scoped))
|
||||
{
|
||||
boolean queued = "QUEUED".equals(job.getString("status"));
|
||||
String status = queued ? "FAILED" : "OUTCOME_UNKNOWN";
|
||||
String message = queued ? "Canceled before dispatch: its " + what + " was deleted" : "Its " + what + " was deleted before a result was reported; check the printer for output before printing again";
|
||||
JSONObject event = new JSONObject().put("type", "DELETED").put("origin", "SERVER_DELETION").put("status", status).put("actor", actor).put("message", message);
|
||||
update(connection, "INSERT INTO printing_event (id, job_id, status, event_json, created_at, event_origin) VALUES (?, ?, ?, ?, ?, 'SERVER_DELETION')", UUID.randomUUID().toString(), job.getString("id"), status, event.toString(), now.toString());
|
||||
update(connection, "UPDATE printing_job SET status=?, message=? WHERE id=?", status, message, job.getString("id"));
|
||||
closed[queued ? 0 : 1]++;
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The permanent record of a deletion: who deleted what, when, and what went
|
||||
** with it. It has no reference to the deleted rows, only what they were.
|
||||
***************************************************************************/
|
||||
private void recordDeletion(Connection connection, String kind, String recordId, String name, @Nullable String computer, @Nullable String queue, @Nullable String facility, @Nullable String deviceId,
|
||||
String actor, Instant now, int printers, int cancelled, int unknown, long kept) throws SQLException
|
||||
{
|
||||
update(connection, "INSERT INTO printing_deletion (id, tenant_id, kind, record_id, name, computer_name, queue_name, facility_id, device_id, actor, deleted_time, printer_count, cancelled_job_count, unknown_job_count, kept_job_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
UUID.randomUUID().toString(), tenantId, kind, recordId, name, computer, queue, facility, deviceId, actor, java.time.LocalDateTime.ofInstant(now, java.time.ZoneOffset.UTC), printers, cancelled, unknown, kept);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A host that owns its schema and has not applied this version's step still
|
||||
** has the job-to-station foreign key: say so instead of a constraint error.
|
||||
***************************************************************************/
|
||||
private static void refuseIfSchemaStepMissing(Exception exception)
|
||||
{
|
||||
if(exception instanceof SQLException sql && "23503".equals(sql.getSQLState()))
|
||||
{
|
||||
throw new PrintingException(409, SCHEMA_STEP_MISSING);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Tenant ownership of a device row regardless of its revoked or removed state.
|
||||
***************************************************************************/
|
||||
@@ -1228,7 +1665,10 @@ public class PrintingService
|
||||
checkSubmission(guard);
|
||||
// One instant, two columns: created_at is the ISO-8601 text the queue SQL and protocol compare; created_time is the UTC timestamp QQQ filters as a date-time.
|
||||
Instant createdAt = Instant.now();
|
||||
update(connection, "INSERT INTO printing_job (id, tenant_id, device_id, printer_id, title, format, content_hash, content_base64, content_length, options_json, idempotency_key, created_at, created_time, status, printer_row_id, content_storage_table, content_reference, content_binding) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'QUEUED', ?, ?, ?, ?)", id, tenantId, deviceId, printerId, title, format, digest, storageTableName == null ? Base64.getEncoder().encodeToString(content) : null, content.length, options.toString(), key, createdAt.toString(), java.time.LocalDateTime.ofInstant(createdAt, java.time.ZoneOffset.UTC), printers.getFirst().getString("id"), storageTableName, storageTableName == null ? null : reference, storageTableName == null ? null : binding);
|
||||
// The names the station and printer have now are stored on the job, so its history reads the same after either is renamed or deleted.
|
||||
JSONObject station = query(connection, "SELECT name, computer_name FROM printing_device WHERE tenant_id=? AND id=?", tenantId, deviceId).getFirst();
|
||||
update(connection, "INSERT INTO printing_job (id, tenant_id, device_id, printer_id, title, format, content_hash, content_base64, content_length, options_json, idempotency_key, created_at, created_time, status, printer_row_id, content_storage_table, content_reference, content_binding, device_name, computer_name, printer_name, queue_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'QUEUED', ?, ?, ?, ?, ?, ?, ?, ?)", id, tenantId, deviceId, printerId, title, format, digest, storageTableName == null ? Base64.getEncoder().encodeToString(content) : null, content.length, options.toString(), key, createdAt.toString(), java.time.LocalDateTime.ofInstant(createdAt, java.time.ZoneOffset.UTC), printers.getFirst().getString("id"), storageTableName, storageTableName == null ? null : reference, storageTableName == null ? null : binding,
|
||||
station.getString("name"), station.optString("computer_name", station.getString("name")), printers.getFirst().getString("name"), printers.getFirst().optString("queue_name", printers.getFirst().getString("name")));
|
||||
if(bound != null)
|
||||
{
|
||||
String eventId = UUID.randomUUID().toString();
|
||||
@@ -2219,6 +2659,8 @@ public class PrintingService
|
||||
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"));
|
||||
}
|
||||
// A deleted station's retained-byte counter stays until its last stored document is purged; then nothing refers to it.
|
||||
update(connection, "DELETE FROM printing_resource_usage WHERE tenant_id=? AND device_id<>'' AND retained_bytes=0 AND NOT EXISTS (SELECT 1 FROM printing_device d WHERE d.id=printing_resource_usage.device_id)", tenantId);
|
||||
if(printers > 0 || devices > 0)
|
||||
{
|
||||
LOG.info("Purged removed printing rows that no job references", logPair("tenantId", tenantId), logPair("printers", printers), logPair("devices", devices));
|
||||
|
||||
@@ -30,6 +30,7 @@ public final class PrintingTables
|
||||
public static final String PRINTER = "printing_printer";
|
||||
public static final String JOB = "printing_job";
|
||||
public static final String COMMAND = "printing_device_command";
|
||||
public static final String DELETION = "printing_deletion";
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.printing;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.reporting.ExportInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateResult;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.GroupBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deleting print stations and printers: what goes, what is refused, what the
|
||||
** job history keeps, who may do it, and reading grouped counts of the tables.
|
||||
** Fixtures use in-memory SQL and never contact Windows or physical printers.
|
||||
*******************************************************************************/
|
||||
class PrintingDeleteTest
|
||||
{
|
||||
private static final String FOREIGN_KEYS = "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE LOWER(TABLE_NAME)='printing_job' AND CONSTRAINT_TYPE='FOREIGN KEY'";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Prevent one test's authority from leaking into another test.
|
||||
***************************************************************************/
|
||||
@AfterEach
|
||||
void clearContext()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Both actions are on their table, need their own grant and ask to confirm.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void metadataOffersDeleteWithItsOwnPermissionAndNames() throws Exception
|
||||
{
|
||||
var fixture = PrintingProcessesTest.fixture();
|
||||
var stations = fixture.instance().getProcess(PrintingDeleteProcesses.DEVICES);
|
||||
var printers = fixture.instance().getProcess(PrintingDeleteProcesses.PRINTERS);
|
||||
assertEquals("Delete print stations", stations.getLabel());
|
||||
assertEquals("Delete printers", printers.getLabel());
|
||||
assertEquals(PrintingTables.DEVICE, stations.getTableName());
|
||||
assertEquals(PrintingTables.PRINTER, printers.getTableName());
|
||||
for(var process : List.of(stations, printers))
|
||||
{
|
||||
assertEquals(PrintingDeletePermission.class.getName(), process.getPermissionRules().getCustomPermissionChecker().getName());
|
||||
assertTrue(process.getStep("confirmDelete") instanceof QFrontendStepMetaData);
|
||||
assertNotNull(process.getStep("prepareDelete"));
|
||||
}
|
||||
assertEquals("printing_delete", PrintingDeletePermission.NAME);
|
||||
assertEquals("Print stations", fixture.instance().getTable(PrintingTables.DEVICE).getLabel());
|
||||
assertEquals("Printers", fixture.instance().getTable(PrintingTables.PRINTER).getLabel());
|
||||
assertEquals("Print station", fixture.instance().getTable(PrintingTables.PRINTER).getField("device_id").getLabel());
|
||||
assertEquals("Print station", fixture.instance().getTable(PrintingTables.JOB).getField("device_name").getLabel());
|
||||
assertEquals("Printer", fixture.instance().getTable(PrintingTables.JOB).getField("printer_name").getLabel());
|
||||
assertEquals("Deletion history", fixture.instance().getTable(PrintingTables.DELETION).getLabel());
|
||||
assertTrue(fixture.instance().getApp("printing").getChildren().stream().anyMatch(child -> PrintingTables.DELETION.equals(child.getName())));
|
||||
assertFalse(PrintingDeleteProcesses.DEVICES_PREVIEW.toLowerCase(java.util.Locale.ROOT).contains("device"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One station: the review counts and changes nothing; the delete revokes the
|
||||
** credential, cancels the queued job, removes the station and its printer,
|
||||
** keeps the finished job with the names it had and records who deleted it.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void deleteOneStationRevokesCancelsAndKeepsHistory() throws Exception
|
||||
{
|
||||
var fixture = PrintingProcessesTest.fixture();
|
||||
grantDelete();
|
||||
PrintingService service = fixture.service();
|
||||
JSONObject enrolled = enroll(service, "Dock computer");
|
||||
String station = enrolled.getString("deviceId");
|
||||
String printer = share(service, station, "Dock label printer");
|
||||
String finished = submit(service, station);
|
||||
service.claim(station);
|
||||
service.event(station, finished, new JSONObject().put("eventId", "finished-" + finished).put("status", "SPOOLED").put("spoolerJobId", "7"));
|
||||
String queued = submit(service, station);
|
||||
assertEquals(station, service.authenticate(enrolled.getString("deviceToken")));
|
||||
silence(fixture, station);
|
||||
|
||||
RunBackendStepInput input = input(PrintingDeleteProcesses.DEVICES, station);
|
||||
PrintingDeleteProcesses.Transform review = new PrintingDeleteProcesses.Transform();
|
||||
RunBackendStepOutput reviewed = new RunBackendStepOutput();
|
||||
review.runOnePage(input, reviewed);
|
||||
assertEquals(1, reviewed.getRecords().size());
|
||||
List<String> lines = text(review.getProcessSummary(reviewed, false), false);
|
||||
assertTrue(lines.contains("1 print station will be deleted; its access is revoked and the computer must be enrolled again before it can print"), lines.toString());
|
||||
assertTrue(lines.contains("1 printer will be deleted with its print station"), lines.toString());
|
||||
assertTrue(lines.contains("1 queued job will be canceled"), lines.toString());
|
||||
assertTrue(lines.contains("1 finished print job keeps its history and the names it was printed under"), lines.toString());
|
||||
assertNotNull(get(PrintingTables.DEVICE, station));
|
||||
assertEquals("QUEUED", service.getJob(queued).getString("status"));
|
||||
|
||||
assertThrows(QException.class, () -> new PrintingDeleteProcesses.Load().runOnePage(input, new RunBackendStepOutput()));
|
||||
assertNotNull(get(PrintingTables.DEVICE, station));
|
||||
input.addValue(PrintingDeleteProcesses.CONFIRM, true);
|
||||
PrintingDeleteProcesses.Load load = new PrintingDeleteProcesses.Load();
|
||||
load.runOnePage(input, new RunBackendStepOutput());
|
||||
List<String> result = text(load.getProcessSummary(new RunBackendStepOutput(), true), true);
|
||||
assertTrue(result.contains("1 print station was deleted; its access is revoked"), result.toString());
|
||||
assertTrue(result.contains("1 queued job was canceled"), result.toString());
|
||||
|
||||
assertNull(get(PrintingTables.DEVICE, station));
|
||||
assertNull(get(PrintingTables.PRINTER, printer));
|
||||
assertFalse(service.devices().toList().toString().contains(station));
|
||||
assertEquals(401, assertThrows(PrintingException.class, () -> service.authenticate(enrolled.getString("deviceToken"))).getStatus());
|
||||
assertEquals(2, new QueryAction().execute(new QueryInput(PrintingTables.DEVICE)).getRecords().size());
|
||||
|
||||
QRecord kept = get(PrintingTables.JOB, finished);
|
||||
assertEquals("SPOOLED", kept.getValueString("status"));
|
||||
assertEquals("Dock computer", kept.getValueString("device_name"));
|
||||
assertEquals("Dock computer", kept.getValueString("computer_name"));
|
||||
assertEquals("Dock label printer", kept.getValueString("printer_name"));
|
||||
assertEquals("Dock label printer", kept.getValueString("queue_name"));
|
||||
assertEquals(station, kept.getValueString("device_id"));
|
||||
assertEquals(station, service.getJob(finished).getString("deviceId"));
|
||||
QRecord cancelled = get(PrintingTables.JOB, queued);
|
||||
assertEquals("FAILED", cancelled.getValueString("status"));
|
||||
assertEquals("Canceled before dispatch: its print station was deleted", cancelled.getValueString("message"));
|
||||
assertEquals("Dock label printer", cancelled.getValueString("printer_name"));
|
||||
assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_event WHERE job_id='" + queued + "' AND event_origin='SERVER_DELETION' AND status='FAILED' AND event_json LIKE '%\"actor\":\"operator\"%'"));
|
||||
|
||||
List<QRecord> history = new QueryAction().execute(new QueryInput(PrintingTables.DELETION)).getRecords();
|
||||
assertEquals(2, history.size());
|
||||
QRecord stationRow = history.stream().filter(row -> "DEVICE".equals(row.getValueString("kind"))).findFirst().orElseThrow();
|
||||
assertEquals(station, stationRow.getValueString("record_id"));
|
||||
assertEquals("Dock computer", stationRow.getValueString("name"));
|
||||
assertEquals("operator", stationRow.getValueString("actor"));
|
||||
assertEquals(1, stationRow.getValueInteger("printer_count"));
|
||||
assertEquals(1, stationRow.getValueInteger("cancelled_job_count"));
|
||||
assertEquals(0, stationRow.getValueInteger("unknown_job_count"));
|
||||
assertEquals(2, stationRow.getValueInteger("kept_job_count"));
|
||||
assertNotNull(stationRow.getValue("deleted_time"));
|
||||
QRecord printerRow = history.stream().filter(row -> "PRINTER".equals(row.getValueString("kind"))).findFirst().orElseThrow();
|
||||
assertEquals(printer, printerRow.getValueString("record_id"));
|
||||
assertEquals(station, printerRow.getValueString("device_id"));
|
||||
assertEquals("Dock label printer", printerRow.getValueString("name"));
|
||||
assertEquals(0, count(fixture, "SELECT COUNT(*) FROM printing_device_command WHERE device_id='" + station + "'"));
|
||||
assertEquals("QUEUED", service.getJob(submit(service, fixture.device())).getString("status"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A selection: a station that is connected and printing is refused by name,
|
||||
** another tenant's station is not the caller's to delete, and the rest are
|
||||
** deleted. Once the refused station is disconnected its job is closed as
|
||||
** outcome unknown and everything it sends afterwards is refused cleanly.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void deleteSeveralRefusesEachRecordItCannotDeleteAndLateTrafficIsRefused() throws Exception
|
||||
{
|
||||
var fixture = PrintingProcessesTest.fixture();
|
||||
grantDelete();
|
||||
PrintingService service = fixture.service();
|
||||
service.deviceControlPoll(fixture.device(), new JSONObject().put("controlVersion", 1).put("agentVersion", "test")
|
||||
.put("capabilities", new JSONArray(List.of("REFRESH_INVENTORY"))).put("lastAppliedRevision", 0));
|
||||
String printing = submit(service, fixture.device());
|
||||
service.claim(fixture.device());
|
||||
String pending = service.deviceAction(fixture.device(), new JSONObject().put("requestId", "refresh-1").put("issuedAt", Instant.now().toString()).put("action", "REFRESH_INVENTORY"), "operator").getString("commandId");
|
||||
|
||||
RunBackendStepInput input = input(PrintingDeleteProcesses.DEVICES, fixture.device(), fixture.otherDevice(), fixture.foreignDevice());
|
||||
input.addValue(PrintingDeleteProcesses.CONFIRM, true);
|
||||
PrintingDeleteProcesses.Transform review = new PrintingDeleteProcesses.Transform();
|
||||
RunBackendStepOutput reviewed = new RunBackendStepOutput();
|
||||
review.runOnePage(input, reviewed);
|
||||
assertEquals(List.of(fixture.otherDevice()), reviewed.getRecords().stream().map(row -> row.getValueString("id")).toList());
|
||||
ProcessSummaryLine refused = (ProcessSummaryLine) review.getProcessSummary(reviewed, false).stream().filter(line -> ((ProcessSummaryLine) line).getStatus() == Status.ERROR).findFirst().orElseThrow();
|
||||
assertEquals(2, refused.getCount());
|
||||
assertTrue(refused.getBulletsOfText().stream().anyMatch(line -> line.startsWith("Print station Packing station is connected and is printing 1 job right now")), refused.getBulletsOfText().toString());
|
||||
assertTrue(refused.getBulletsOfText().contains("A selected record no longer exists or is not yours to delete"));
|
||||
|
||||
PrintingDeleteProcesses.Load load = new PrintingDeleteProcesses.Load();
|
||||
load.runOnePage(input, new RunBackendStepOutput());
|
||||
assertNotNull(get(PrintingTables.DEVICE, fixture.device()));
|
||||
assertNull(get(PrintingTables.DEVICE, fixture.otherDevice()));
|
||||
assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_device WHERE id='" + fixture.foreignDevice() + "'"));
|
||||
assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_printer WHERE id='" + fixture.foreignPrinterId() + "'"));
|
||||
assertEquals("CLAIMED", service.getJob(printing).getString("status"));
|
||||
assertNull(get(PrintingTables.DEVICE, fixture.device()).getValueString("revoked_at"));
|
||||
ProcessSummaryLine notDeleted = (ProcessSummaryLine) load.getProcessSummary(new RunBackendStepOutput(), true).stream().filter(line -> ((ProcessSummaryLine) line).getStatus() == Status.ERROR).findFirst().orElseThrow();
|
||||
assertEquals(2, notDeleted.getCount());
|
||||
|
||||
service.deviceAction(fixture.device(), new JSONObject().put("requestId", "disconnect-1").put("issuedAt", Instant.now().toString()).put("action", "DISCONNECT"), "operator");
|
||||
JSONObject deleted = service.deleteDevice(fixture.device(), "operator");
|
||||
assertEquals(1, deleted.getInt("unknownJobs"));
|
||||
assertTrue(deleted.getBoolean("credentialRevoked"));
|
||||
QRecord uncertain = get(PrintingTables.JOB, printing);
|
||||
assertEquals("OUTCOME_UNKNOWN", uncertain.getValueString("status"));
|
||||
assertEquals("Its print station was deleted before a result was reported; check the printer for output before printing again", uncertain.getValueString("message"));
|
||||
assertEquals("Packing station", uncertain.getValueString("device_name"));
|
||||
assertEquals("Packing laser", uncertain.getValueString("printer_name"));
|
||||
|
||||
JSONObject spooled = new JSONObject().put("eventId", "late-" + printing).put("status", "SPOOLED").put("spoolerJobId", "9");
|
||||
assertEquals(404, assertThrows(PrintingException.class, () -> service.event(fixture.device(), printing, spooled)).getStatus());
|
||||
assertEquals(404, assertThrows(PrintingException.class, () -> service.deviceControlReceipt(fixture.device(), pending,
|
||||
new JSONObject().put("receiptId", "late-receipt").put("outcome", "INVENTORY_REFRESHED").put("completedAt", Instant.now().toString()))).getStatus());
|
||||
assertEquals(404, assertThrows(PrintingException.class, () -> service.deviceControlPoll(fixture.device(), new JSONObject().put("controlVersion", 1).put("agentVersion", "test")
|
||||
.put("capabilities", new JSONArray()).put("lastAppliedRevision", 0))).getStatus());
|
||||
assertEquals(404, assertThrows(PrintingException.class, () -> service.heartbeat(fixture.device(), new JSONObject().put("agentVersion", "test"))).getStatus());
|
||||
assertEquals(404, assertThrows(PrintingException.class, () -> service.claim(fixture.device())).getStatus());
|
||||
assertEquals(404, assertThrows(PrintingException.class, () -> service.content(fixture.device(), printing)).getStatus());
|
||||
assertEquals(404, assertThrows(PrintingException.class, () -> service.inventory(fixture.device(), new JSONArray())).getStatus());
|
||||
assertEquals(404, assertThrows(PrintingException.class, () -> service.deviceAction(fixture.device(),
|
||||
new JSONObject().put("requestId", "pause-late").put("issuedAt", Instant.now().toString()).put("action", "PAUSE_DISPATCH"), "operator")).getStatus());
|
||||
assertEquals(404, assertThrows(PrintingException.class, () -> service.deleteDevice(fixture.device(), "operator")).getStatus());
|
||||
assertEquals("OUTCOME_UNKNOWN", service.getJob(printing).getString("status"));
|
||||
assertEquals(0, count(fixture, "SELECT COUNT(*) FROM printing_event WHERE id='late-" + printing + "'"));
|
||||
QException again = assertThrows(QException.class, () -> new PrintingJobProcesses.Load().runOnePage(new RunBackendStepInput().withProcessName(PrintingJobProcesses.RETRY)
|
||||
.withRecords(List.of(new QRecord().withValue("id", printing))).withValues(new java.util.HashMap<>(java.util.Map.<String, java.io.Serializable>of("printingRetryRequestId", "again-1", "confirmDuplicateRisk", true))), new RunBackendStepOutput()));
|
||||
assertTrue(String.valueOf(again.getMessage()).contains("its print station was deleted"), again.getMessage());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A computer enrolled again after its station was deleted is a new station,
|
||||
** its printer is a new printer awaiting approval, and the old jobs stay with
|
||||
** the identifiers and names of the deleted records.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void reEnrollmentAfterDeleteIsANewStationWithNewPrinters() throws Exception
|
||||
{
|
||||
var fixture = PrintingProcessesTest.fixture();
|
||||
grantDelete();
|
||||
PrintingService service = fixture.service();
|
||||
String job = submit(service, fixture.device());
|
||||
silence(fixture, fixture.device());
|
||||
service.deleteDevice(fixture.device(), "operator");
|
||||
|
||||
JSONObject enrolled = enroll(service, "Packing station");
|
||||
String station = enrolled.getString("deviceId");
|
||||
assertNotEquals(fixture.device(), station);
|
||||
assertEquals(station, service.authenticate(enrolled.getString("deviceToken")));
|
||||
String printer = share(service, station, "Packing laser");
|
||||
assertNotEquals(fixture.printerId(), printer);
|
||||
service.initialize();
|
||||
QRecord old = get(PrintingTables.JOB, job);
|
||||
assertEquals(fixture.device(), old.getValueString("device_id"));
|
||||
assertEquals(fixture.printerId(), old.getValueString("printer_row_id"));
|
||||
assertEquals("Packing laser", old.getValueString("printer_name"));
|
||||
assertEquals(0, new QueryAction().execute(new QueryInput(PrintingTables.JOB).withFilter(new QQueryFilter().withCriteria("device_id", QCriteriaOperator.EQUALS, station))).getRecords().size());
|
||||
assertEquals(0, new QueryAction().execute(new QueryInput(PrintingTables.JOB).withFilter(new QQueryFilter().withCriteria("printer_row_id", QCriteriaOperator.EQUALS, printer))).getRecords().size());
|
||||
String fresh = submit(service, station);
|
||||
assertEquals("QUEUED", service.getJob(fresh).getString("status"));
|
||||
assertEquals(station, get(PrintingTables.JOB, fresh).getValueString("device_id"));
|
||||
assertEquals(404, assertThrows(PrintingException.class, () -> service.retry(fixture.device(), job, new JSONObject().put("requestId", "r1"))).getStatus());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Deleting a printer cancels its queued job, leaves its station in service
|
||||
** and keeps its finished job with the names it had. A queue the station
|
||||
** still shares comes back as a new printer that is not approved, and the
|
||||
** old jobs are not attached to it, even after the schema is initialized again.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void deletePrinterKeepsTheStationAndComesBackAsANewPrinter() throws Exception
|
||||
{
|
||||
var fixture = PrintingProcessesTest.fixture();
|
||||
grantDelete();
|
||||
PrintingService service = fixture.service();
|
||||
String finished = submit(service, fixture.device());
|
||||
service.claim(fixture.device());
|
||||
service.event(fixture.device(), finished, new JSONObject().put("eventId", "finished-" + finished).put("status", "SPOOLED").put("spoolerJobId", "3"));
|
||||
String queued = submit(service, fixture.device());
|
||||
|
||||
RunBackendStepInput input = input(PrintingDeleteProcesses.PRINTERS, fixture.printerId(), fixture.foreignPrinterId());
|
||||
input.addValue(PrintingDeleteProcesses.CONFIRM, true);
|
||||
PrintingDeleteProcesses.Transform review = new PrintingDeleteProcesses.Transform();
|
||||
RunBackendStepOutput reviewed = new RunBackendStepOutput();
|
||||
review.runOnePage(input, reviewed);
|
||||
List<String> lines = text(review.getProcessSummary(reviewed, false), false);
|
||||
assertTrue(lines.contains("1 printer will be deleted"), lines.toString());
|
||||
assertTrue(lines.contains("1 queued job will be canceled"), lines.toString());
|
||||
assertTrue(lines.contains("1 record will not be deleted"), lines.toString());
|
||||
assertNotNull(get(PrintingTables.PRINTER, fixture.printerId()));
|
||||
|
||||
new PrintingDeleteProcesses.Load().runOnePage(input, new RunBackendStepOutput());
|
||||
assertNull(get(PrintingTables.PRINTER, fixture.printerId()));
|
||||
assertNotNull(get(PrintingTables.DEVICE, fixture.device()));
|
||||
assertNull(get(PrintingTables.DEVICE, fixture.device()).getValueString("revoked_at"));
|
||||
assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_printer WHERE id='" + fixture.foreignPrinterId() + "'"));
|
||||
assertEquals("FAILED", service.getJob(queued).getString("status"));
|
||||
assertEquals("Canceled before dispatch: its printer was deleted", get(PrintingTables.JOB, queued).getValueString("message"));
|
||||
QRecord kept = get(PrintingTables.JOB, finished);
|
||||
assertEquals("SPOOLED", kept.getValueString("status"));
|
||||
assertEquals("Packing laser", kept.getValueString("printer_name"));
|
||||
assertEquals("Packing station", kept.getValueString("device_name"));
|
||||
assertEquals(0, service.printers(fixture.device()).length());
|
||||
QRecord record = new QueryAction().execute(new QueryInput(PrintingTables.DELETION)).getRecords().getFirst();
|
||||
assertEquals("PRINTER", record.getValueString("kind"));
|
||||
assertEquals("operator", record.getValueString("actor"));
|
||||
assertEquals(1, record.getValueInteger("cancelled_job_count"));
|
||||
assertEquals(404, assertThrows(PrintingException.class, () -> service.deletePrinter(fixture.printerId(), "operator")).getStatus());
|
||||
assertEquals(400, assertThrows(PrintingException.class, () -> service.submit(job(fixture.device()))).getStatus());
|
||||
|
||||
service.inventory(fixture.device(), new JSONArray().put(new JSONObject().put("printerId", "laser").put("name", "Packing laser").put("formats", new JSONArray(List.of("PDF")))));
|
||||
List<QRecord> rediscovered = new QueryAction().execute(new QueryInput(PrintingTables.PRINTER).withFilter(new QQueryFilter().withCriteria("device_id", QCriteriaOperator.EQUALS, fixture.device()))).getRecords();
|
||||
assertEquals(1, rediscovered.size());
|
||||
String again = rediscovered.getFirst().getValueString("id");
|
||||
assertNotEquals(fixture.printerId(), again);
|
||||
assertEquals(0, rediscovered.getFirst().getValueInteger("enabled"));
|
||||
assertEquals(1, rediscovered.getFirst().getValueInteger("available"));
|
||||
assertNull(rediscovered.getFirst().getValueString("removed_at"));
|
||||
assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_printer WHERE device_id='" + fixture.device() + "'"));
|
||||
service.initialize();
|
||||
assertEquals(fixture.printerId(), get(PrintingTables.JOB, finished).getValueString("printer_row_id"));
|
||||
assertEquals(0, new QueryAction().execute(new QueryInput(PrintingTables.JOB).withFilter(new QQueryFilter().withCriteria("printer_row_id", QCriteriaOperator.EQUALS, again))).getRecords().size());
|
||||
assertTrue(service.claim(fixture.device()).isNull("job"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** printing_admin alone does not delete: every step refuses, nothing changes.
|
||||
** printing_delete alone is not enough either.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void permissionIsRefusedWithoutTheGrant() throws Exception
|
||||
{
|
||||
var fixture = PrintingProcessesTest.fixture();
|
||||
silence(fixture, fixture.device());
|
||||
for(Set<String> permissions : List.of(Set.of("printing_admin"), Set.of("printing_delete"), Set.of("printing_operator", "printing_delete"), Set.<String>of()))
|
||||
{
|
||||
QContext.getQSession().setPermissions(new java.util.HashSet<>(permissions));
|
||||
for(String process : List.of(PrintingDeleteProcesses.DEVICES, PrintingDeleteProcesses.PRINTERS))
|
||||
{
|
||||
RunBackendStepInput input = new RunBackendStepInput().withProcessName(process)
|
||||
.withRecords(List.of(new QRecord().withValue("id", PrintingDeleteProcesses.DEVICES.equals(process) ? fixture.device() : fixture.printerId())));
|
||||
input.addValue(PrintingDeleteProcesses.CONFIRM, true);
|
||||
assertThrows(QPermissionDeniedException.class, () -> new PrintingDeleteProcesses.Prepare().run(input, new RunBackendStepOutput()));
|
||||
assertThrows(QPermissionDeniedException.class, () -> new PrintingDeleteProcesses.Transform().runOnePage(input, new RunBackendStepOutput()));
|
||||
assertThrows(QPermissionDeniedException.class, () -> new PrintingDeleteProcesses.Load().runOnePage(input, new RunBackendStepOutput()));
|
||||
assertThrows(QPermissionDeniedException.class, () -> new PrintingDeletePermission().checkPermissionsThrowing(input, fixture.instance().getProcess(process)));
|
||||
}
|
||||
}
|
||||
assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_device WHERE id='" + fixture.device() + "' AND revoked_at IS NULL"));
|
||||
assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_printer WHERE id='" + fixture.printerId() + "'"));
|
||||
assertEquals(0, count(fixture, "SELECT COUNT(*) FROM printing_deletion"));
|
||||
grantDelete();
|
||||
RunBackendStepInput allowed = input(PrintingDeleteProcesses.DEVICES, fixture.device());
|
||||
allowed.addValue(PrintingDeleteProcesses.CONFIRM, true);
|
||||
new PrintingDeleteProcesses.Load().runOnePage(allowed, new RunBackendStepOutput());
|
||||
assertEquals(0, count(fixture, "SELECT COUNT(*) FROM printing_device WHERE id='" + fixture.device() + "'"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The schema step: initialize() drops the job-to-station foreign key and
|
||||
** gives older jobs their names; a database that still has the key refuses
|
||||
** the delete before anything is revoked.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void schemaStepDetachesJobsAndNamesOlderJobs() throws Exception
|
||||
{
|
||||
var fixture = PrintingProcessesTest.fixture();
|
||||
String job = submit(fixture.service(), fixture.device());
|
||||
silence(fixture, fixture.device());
|
||||
assertEquals(0, count(fixture, FOREIGN_KEYS));
|
||||
execute(fixture, "UPDATE printing_job SET device_name=NULL, computer_name=NULL, printer_name=NULL, queue_name=NULL");
|
||||
execute(fixture, "ALTER TABLE printing_job ADD CONSTRAINT printing_job_device_id_fkey FOREIGN KEY (device_id) REFERENCES printing_device(id)");
|
||||
assertEquals(1, count(fixture, FOREIGN_KEYS));
|
||||
PrintingException refused = assertThrows(PrintingException.class, () -> fixture.service().deleteDevice(fixture.device(), "operator"));
|
||||
assertEquals(409, refused.getStatus());
|
||||
assertTrue(String.valueOf(refused.getMessage()).contains("Apply the schema step"), refused.getMessage());
|
||||
assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_device WHERE id='" + fixture.device() + "' AND revoked_at IS NULL AND enabled=1"));
|
||||
fixture.service().initialize();
|
||||
assertEquals(0, count(fixture, FOREIGN_KEYS));
|
||||
assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_job WHERE id='" + job + "' AND device_name='Packing station' AND computer_name='Packing station' AND printer_name='Packing laser' AND queue_name='Packing laser'"));
|
||||
execute(fixture, "UPDATE printing_job SET device_name=NULL, printer_name=NULL");
|
||||
fixture.service().deleteDevice(fixture.device(), "operator");
|
||||
assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_job WHERE id='" + job + "' AND device_name='Packing station' AND printer_name='Packing laser' AND status='FAILED'"));
|
||||
assertEquals(400, assertThrows(PrintingException.class, () -> fixture.service().deleteDevice(fixture.otherDevice(), " ")).getStatus());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A grouped read is a read: it is allowed to whoever may query the table and
|
||||
** refused to whoever may not, and an export is treated the same way. Writes
|
||||
** stay refused for everybody.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aggregateIsAllowedToAReaderAndRefusedWithoutTheReadPermission() throws Exception
|
||||
{
|
||||
var fixture = PrintingProcessesTest.fixture();
|
||||
submit(fixture.service(), fixture.device());
|
||||
submit(fixture.service(), fixture.device());
|
||||
Aggregate count = new Aggregate("id", AggregateOperator.COUNT);
|
||||
List<AggregateResult> byFormat = new AggregateAction().execute(enforced(new AggregateInput(PrintingTables.JOB).withAggregate(count).withGroupBy(new GroupBy(QFieldType.STRING, "format")))).getResults();
|
||||
assertEquals(1, byFormat.size());
|
||||
assertEquals("2", String.valueOf(byFormat.getFirst().getAggregateValue(count)));
|
||||
assertEquals(1, new AggregateAction().execute(enforced(new AggregateInput(PrintingTables.PRINTER).withAggregate(count).withGroupBy(new GroupBy(QFieldType.STRING, "status")))).getResults().size());
|
||||
|
||||
var jobs = fixture.instance().getTable(PrintingTables.JOB);
|
||||
var printers = fixture.instance().getTable(PrintingTables.PRINTER);
|
||||
PrintingReadPermission checker = new PrintingReadPermission();
|
||||
checker.checkPermissionsThrowing(new AggregateInput(PrintingTables.JOB), jobs);
|
||||
checker.checkPermissionsThrowing(new ExportInput(), jobs);
|
||||
for(var write : List.of(new InsertInput(PrintingTables.JOB), new UpdateInput(PrintingTables.JOB), new DeleteInput(PrintingTables.JOB), new RunBackendStepInput()))
|
||||
{
|
||||
assertEquals("Use the printing management service to change operational state", assertThrows(QPermissionDeniedException.class, () -> checker.checkPermissionsThrowing(write, jobs)).getMessage());
|
||||
}
|
||||
|
||||
QContext.getQSession().setPermissions(new java.util.HashSet<>(Set.of("printing_operator")));
|
||||
assertEquals(1, new AggregateAction().execute(enforced(new AggregateInput(PrintingTables.PRINTER).withAggregate(count).withGroupBy(new GroupBy(QFieldType.STRING, "status")))).getResults().size());
|
||||
assertThrows(QPermissionDeniedException.class, () -> new AggregateAction().execute(enforced(new AggregateInput(PrintingTables.JOB).withAggregate(count).withGroupBy(new GroupBy(QFieldType.STRING, "format")))));
|
||||
assertEquals("Printing administrator permission is required", assertThrows(QPermissionDeniedException.class, () -> checker.checkPermissionsThrowing(new AggregateInput(PrintingTables.JOB), jobs)).getMessage());
|
||||
|
||||
QContext.getQSession().setPermissions(new java.util.HashSet<>());
|
||||
assertThrows(QPermissionDeniedException.class, () -> new AggregateAction().execute(enforced(new AggregateInput(PrintingTables.JOB).withAggregate(count).withGroupBy(new GroupBy(QFieldType.STRING, "format")))));
|
||||
assertThrows(QPermissionDeniedException.class, () -> new AggregateAction().execute(enforced(new AggregateInput(PrintingTables.PRINTER).withAggregate(count).withGroupBy(new GroupBy(QFieldType.STRING, "status")))));
|
||||
assertEquals("Printing operator permission is required", assertThrows(QPermissionDeniedException.class, () -> checker.checkPermissionsThrowing(new AggregateInput(PrintingTables.PRINTER), printers)).getMessage());
|
||||
assertThrows(QPermissionDeniedException.class, () -> checker.checkPermissionsThrowing(new ExportInput(), jobs));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The aggregate asks for permission enforcement as a user's request does.
|
||||
***************************************************************************/
|
||||
private static AggregateInput enforced(AggregateInput input)
|
||||
{
|
||||
input.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE);
|
||||
return input;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The fixture's administrator also holds the delete grant.
|
||||
***************************************************************************/
|
||||
private static void grantDelete()
|
||||
{
|
||||
QContext.getQSession().setPermissions(new java.util.HashSet<>(Set.of("printing_admin", "printing_delete")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Enroll a computer and keep its one-time credential.
|
||||
***************************************************************************/
|
||||
private static JSONObject enroll(PrintingService service, String name) throws Exception
|
||||
{
|
||||
return service.enroll(service.createEnrollment("warehouse", name).put("deviceName", name).put("agentVersion", "test"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Share and approve one synthetic queue; returns the printer row.
|
||||
***************************************************************************/
|
||||
private static String share(PrintingService service, String station, String name) throws Exception
|
||||
{
|
||||
service.inventory(station, new JSONArray().put(new JSONObject().put("printerId", "laser").put("name", name).put("formats", new JSONArray(List.of("PDF")))));
|
||||
service.enablePrinter(station, "laser", true);
|
||||
JSONArray printers = service.printersPage(station, 50, null).getJSONArray("printers");
|
||||
assertEquals(1, printers.length());
|
||||
List<QRecord> rows = new QueryAction().execute(new QueryInput(PrintingTables.PRINTER).withFilter(new QQueryFilter().withCriteria("device_id", QCriteriaOperator.EQUALS, station))).getRecords();
|
||||
assertEquals(1, rows.size());
|
||||
return rows.getFirst().getValueString("id");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A station that has not been heard from for a while (it shows OFFLINE).
|
||||
***************************************************************************/
|
||||
private static void silence(PrintingProcessesTest.Fixture fixture, String station) throws Exception
|
||||
{
|
||||
execute(fixture, "UPDATE printing_device SET last_seen_at='" + Instant.now().minusSeconds(600) + "' WHERE id='" + station + "'");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One synthetic PDF request for the station's "laser" queue.
|
||||
***************************************************************************/
|
||||
private static JSONObject job(String station)
|
||||
{
|
||||
return new JSONObject().put("deviceId", station).put("printerId", "laser").put("title", "Delete fixture")
|
||||
.put("format", "PDF").put("contentBase64", Base64.getEncoder().encodeToString("%PDF-1.4 fixture".getBytes(StandardCharsets.UTF_8)))
|
||||
.put("idempotencyKey", UUID.randomUUID().toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Synthetic jobs never leave the in-memory server fixture.
|
||||
***************************************************************************/
|
||||
private static String submit(PrintingService service, String station) throws Exception
|
||||
{
|
||||
return service.submit(job(station)).getString("jobId");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A selection as the review and final steps receive it, after preparation.
|
||||
***************************************************************************/
|
||||
private static RunBackendStepInput input(String process, String... ids) throws QException
|
||||
{
|
||||
List<QRecord> records = new ArrayList<>();
|
||||
for(String id : ids)
|
||||
{
|
||||
records.add(new QRecord().withValue("id", id));
|
||||
}
|
||||
RunBackendStepInput input = new RunBackendStepInput().withProcessName(process).withRecords(records);
|
||||
new PrintingDeleteProcesses.Prepare().run(input, new RunBackendStepOutput());
|
||||
return input;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The summary as an administrator reads it: "count message".
|
||||
***************************************************************************/
|
||||
private static List<String> text(List<ProcessSummaryLineInterface> lines, boolean result)
|
||||
{
|
||||
List<String> out = new ArrayList<>();
|
||||
for(ProcessSummaryLineInterface line : lines)
|
||||
{
|
||||
line.prepareForFrontend(result);
|
||||
ProcessSummaryLine summary = (ProcessSummaryLine) line;
|
||||
out.add(summary.getCount() + " " + summary.getMessage());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read through QQQ with the session's permissions and tenant.
|
||||
***************************************************************************/
|
||||
private static QRecord get(String table, String id) throws QException
|
||||
{
|
||||
GetInput input = new GetInput();
|
||||
input.setTableName(table);
|
||||
input.setPrimaryKey(id);
|
||||
return new GetAction().execute(input).getRecord();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The fixture's database, reached through the QQQ backend it is defined on.
|
||||
***************************************************************************/
|
||||
private static Connection connection(PrintingProcessesTest.Fixture fixture) throws Exception
|
||||
{
|
||||
var backend = (com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData) fixture.instance().getBackends().values().iterator().next();
|
||||
return java.sql.DriverManager.getConnection(backend.getJdbcUrl(), "sa", "");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One number straight from the database, across tenants.
|
||||
***************************************************************************/
|
||||
private static int count(PrintingProcessesTest.Fixture fixture, String sql) throws Exception
|
||||
{
|
||||
try(Connection connection = connection(fixture); var statement = connection.createStatement(); var rows = statement.executeQuery(sql))
|
||||
{
|
||||
rows.next();
|
||||
return rows.getInt(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Arrange database state a test needs that no service call produces.
|
||||
***************************************************************************/
|
||||
private static void execute(PrintingProcessesTest.Fixture fixture, String sql) throws Exception
|
||||
{
|
||||
try(Connection connection = connection(fixture); var statement = connection.createStatement())
|
||||
{
|
||||
statement.execute(sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,7 @@ class PrintingDeviceProcessesTest
|
||||
{
|
||||
var fixture = PrintingProcessesTest.fixture();
|
||||
var process = fixture.instance().getProcess(PrintingDeviceProcesses.Action.REMOVE.process);
|
||||
assertEquals("Remove device", process.getLabel());
|
||||
assertEquals("Remove print station", 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());
|
||||
|
||||
@@ -120,7 +120,7 @@ class PrintingExtractTest
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
assertEquals(16, checked);
|
||||
assertEquals(18, checked);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ class PrintingHomeTest
|
||||
try
|
||||
{
|
||||
String header = JsonUtils.toJson(render(instance, "printingHomeHeader"));
|
||||
assertTrue(header.contains("No print devices are connected"));
|
||||
assertTrue(header.contains("No print stations are connected"));
|
||||
assertTrue(header.contains("/app/printingEnrollDevice/?returnTo=%2Fapp%2Fprinting%2F"));
|
||||
assertEquals(PrintingTables.DEVICE, instance.getProcess("printingEnrollDevice").getTableName());
|
||||
assertEquals(0, instance.getProcess("printingEnrollDevice").getMinInputRecords());
|
||||
|
||||
Reference in new issue
Block a user