A request could not say which printer resolution to render a label for:
RenderRequest.targetDpi existed and nothing set it.
DocumentGenerationService.Request gains a trailing targetDpi (absent, 203,
300 or 600; every earlier constructor stays and means none). It is stored
in a new nullable column, target_dpi, on document_generation_request,
compared on retry and lookup beside destination, media and copies, framed
into the intent digest as target-dpi/v1 when stated, and passed to the
renderer. A request without a target has exactly the digest it had, so
earlier rows and their retries are unchanged; the same key with another
target, or with none, is another intent.
Nobody who asks for 300 dpi may silently get another resolution, so a
target is refused before any row exists when it cannot be honoured: for a
PDF, and for any renderer that does not declare the new
IsolatedRenderer.supportsRenderTarget() (false by default, so an adapter
written before the target cannot be handed one and ignore it).
A host applies one statement, from the shipped script for its database:
db/migrations/20261112-render-target-dpi-{h2,mysql,postgres}.sql
(ALTER TABLE document_generation_request ADD COLUMN target_dpi INTEGER
NULL). The generate process form offers the target and the request status
shows it. Batches, the queue and reprints carry none.
Also from the review of the ports: RenderRequest and PreviewRequest hold
their own unmodifiable copy of the context (null values allowed) and
print only their format and render target (and the request id), never the
record data or the template source. Neither wire form changes.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
425 lines
22 KiB
Java
425 lines
22 KiB
Java
/*
|
|
* 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.documentgeneration;
|
|
|
|
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
import java.util.UUID;
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
|
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
|
import com.kingsrook.qqq.backend.core.context.QContext;
|
|
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
|
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
|
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.processes.RunBackendStepInput;
|
|
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
|
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.AdornmentType;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment;
|
|
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.MetaDataWithPermissionRules;
|
|
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.QFunctionInputMetaData;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
|
import com.kingsrook.qqq.backend.core.model.metadata.processes.QRecordListMetaData;
|
|
|
|
|
|
/*******************************************************************************
|
|
** Native QQQ record actions use the same authorized services as API callers.
|
|
** Submitted records carry only identifiers; their data is always loaded afresh.
|
|
*******************************************************************************/
|
|
public final class GenerationProcesses
|
|
{
|
|
private static final ObjectMapper JSON = new ObjectMapper().enable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
|
|
private static final List<String> OPERATIONS = List.of("generate", "enqueue", "status", "download", "reprint", "reconcile", "advance", "background", "cancel", "recover", "reconcileItem");
|
|
|
|
/*******************************************************************************
|
|
** Metadata utility.
|
|
*******************************************************************************/
|
|
private GenerationProcesses() {}
|
|
|
|
/*******************************************************************************
|
|
** Explicit host source tables expose actions; user bindings still determine
|
|
** which published templates may actually run against each selected record.
|
|
*******************************************************************************/
|
|
public static void register(QInstance instance, List<String> sourceTables) throws QException
|
|
{
|
|
DocumentGenerationQBitConfig config = config(instance);
|
|
if(sourceTables == null || sourceTables.size() > 100)
|
|
{
|
|
throw new QBadRequestException("Generation process source table limit exceeded.");
|
|
}
|
|
for(String table : List.copyOf(sourceTables))
|
|
{
|
|
if(table == null || instance.getTable(table) == null || table.startsWith("document_generation_"))
|
|
{
|
|
throw new QBadRequestException("Unknown generation process source table.");
|
|
}
|
|
register(instance, table, "generate", "Generate document", 1);
|
|
register(instance, table, "enqueue", "Queue documents", config.getMaximumBatch());
|
|
}
|
|
for(String operation : List.of("status", "download", "reprint", "reconcile"))
|
|
{
|
|
register(instance, GenerationTables.REQUEST, operation, switch(operation)
|
|
{
|
|
case "status" -> "Document generation status";
|
|
case "download" -> "Download retained original";
|
|
case "reprint" -> "Reprint retained original";
|
|
default -> "Reconcile retained original";
|
|
}, 1);
|
|
}
|
|
for(String operation : List.of("status", "advance", "cancel", "recover", "reconcileItem"))
|
|
{
|
|
register(instance, GenerationTables.QUEUE, operation, switch(operation)
|
|
{
|
|
case "status" -> "Batch item status";
|
|
case "advance" -> "Process next queued document";
|
|
case "cancel" -> "Cancel queued documents";
|
|
case "recover" -> "Verify abandoned invocation termination";
|
|
default -> "Reconcile batch item";
|
|
}, 1);
|
|
}
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** Standard form/response steps remain usable by the existing QQQ process UI.
|
|
*******************************************************************************/
|
|
static void register(QInstance instance, String table, String operation, String label, int maximum)
|
|
{
|
|
QProcessMetaData process = new QProcessMetaData().withName(name(table, operation)).withTableName(table).withLabel(label)
|
|
.withMinInputRecords(1).withMaxInputRecords(maximum)
|
|
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED).withCustomPermissionChecker(new QCodeReference(Permission.class)))
|
|
.withStep(new QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(Prepare.class))
|
|
.withInputData(new QFunctionInputMetaData().withRecordListMetaData(new QRecordListMetaData().withTableName(table))));
|
|
QFrontendStepMetaData form = new QFrontendStepMetaData().withName("options").withLabel(label)
|
|
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM));
|
|
if(List.of("generate", "enqueue").contains(operation))
|
|
{
|
|
form.withFormField(new QFieldMetaData("templateId", QFieldType.INTEGER).withLabel("Published template").withPossibleValueSourceName(GenerationTables.TEMPLATE));
|
|
form.withFormField(new QFieldMetaData("format", QFieldType.STRING).withLabel("Output format").withPossibleValueSourceName(GenerationAuthoringProcesses.FORMAT));
|
|
}
|
|
if("generate".equals(operation))
|
|
{
|
|
form.withFormField(new QFieldMetaData("targetDpi", QFieldType.INTEGER).withLabel("Render target in dpi: 203, 300 or 600 (optional, ZPL only; default is the stock's resolution)"));
|
|
}
|
|
if(List.of("generate", "enqueue", "reprint").contains(operation))
|
|
{
|
|
form.withFormField(new QFieldMetaData("destination", QFieldType.STRING).withLabel("Print destination (optional for generation)").withMaxLength(255));
|
|
}
|
|
if("status".equals(operation) && GenerationTables.QUEUE.equals(table))
|
|
{
|
|
form.withFormField(new QFieldMetaData("afterOrdinal", QFieldType.INTEGER).withLabel("After item ordinal (default -1)"));
|
|
}
|
|
if(List.of("recover", "reconcileItem").contains(operation))
|
|
{
|
|
form.withFormField(new QFieldMetaData("itemId", QFieldType.INTEGER).withLabel("Batch item ID"));
|
|
}
|
|
process.withStep(form).withStep(new QBackendStepMetaData().withName("execute").withCode(new QCodeReference(Execute.class)))
|
|
.withStep(GenerationResultFields.add(new QFrontendStepMetaData().withName("result").withLabel("Document action result")
|
|
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM))
|
|
.withViewField(new QFieldMetaData("resultJson", QFieldType.TEXT).withLabel("Result")
|
|
.withFieldAdornment(new FieldAdornment(AdornmentType.CODE_EDITOR).withValue("languageMode", "json")))));
|
|
instance.addProcess(process);
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** Stable names retain the owning source table and fixed action vocabulary.
|
|
*******************************************************************************/
|
|
public static String name(String table, String operation)
|
|
{
|
|
return (table + "_document_" + operation);
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** Resolve the installed host configuration, never a static global service.
|
|
*******************************************************************************/
|
|
static DocumentGenerationQBitConfig config(QInstance instance) throws QException
|
|
{
|
|
return (DocumentGenerationQBitConfig.from(instance));
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** Resolve only a registered operation, including direct backend-step calls.
|
|
*******************************************************************************/
|
|
private static String operation(QProcessMetaData process) throws QBadRequestException
|
|
{
|
|
for(String operation : OPERATIONS)
|
|
{
|
|
if(name(process.getTableName(), operation).equals(process.getName()))
|
|
{
|
|
return (operation);
|
|
}
|
|
}
|
|
throw new QBadRequestException("Unknown document generation process.");
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** Discovery and execution both require the action-specific capability.
|
|
*******************************************************************************/
|
|
public static class Permission implements CustomPermissionChecker
|
|
{
|
|
/*******************************************************************************
|
|
** Do not rely on permissive host authentication defaults.
|
|
*******************************************************************************/
|
|
@Override
|
|
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
|
|
{
|
|
String suffix = metadata instanceof QProcessMetaData process && (process.getName().endsWith("_status") || process.getName().endsWith("_download")) ? "view"
|
|
: metadata instanceof QProcessMetaData process && process.getName().endsWith("_reprint") ? "print" : "generate";
|
|
GenerationSecurity.require(suffix);
|
|
}
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** Capture bounded selected identities and one idempotency token in process state.
|
|
*******************************************************************************/
|
|
public static class Prepare implements BackendStep
|
|
{
|
|
/*******************************************************************************
|
|
** No selected QRecord body is trusted as rendering context.
|
|
*******************************************************************************/
|
|
@Override
|
|
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
|
{
|
|
String operation = operation(input.getProcess());
|
|
new Permission().checkPermissionsThrowing(input, input.getProcess());
|
|
int maximum = "enqueue".equals(operation) ? config(QContext.getQInstance()).getMaximumBatch() : 1;
|
|
if(input.getRecords() == null || input.getRecords().isEmpty() || input.getRecords().size() > maximum)
|
|
{
|
|
throw new QBadRequestException("Document action selection exceeds its bounds.");
|
|
}
|
|
String table = input.getProcess().getTableName();
|
|
String key = QContext.getQInstance().getTable(table).getPrimaryKeyField();
|
|
List<String> selected = new ArrayList<>();
|
|
for(QRecord row : input.getRecords())
|
|
{
|
|
String id = row.getValueString(key);
|
|
DocumentDefinitionService.text(id, 255);
|
|
selected.add(id);
|
|
}
|
|
output.addValue("selectionJson", json(selected));
|
|
output.addValue("requestKey", UUID.randomUUID().toString());
|
|
output.addValue("format", "PDF");
|
|
}
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** Calls the same public services; all source and immutable version checks run
|
|
** again at execution time, including when submitted process values are changed.
|
|
*******************************************************************************/
|
|
public static class Execute implements BackendStep
|
|
{
|
|
/*******************************************************************************
|
|
** One advance processes at most one item; this action is not a background daemon.
|
|
*******************************************************************************/
|
|
@Override
|
|
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
|
{
|
|
String operation = operation(input.getProcess());
|
|
new Permission().checkPermissionsThrowing(input, input.getProcess());
|
|
DocumentGenerationQBitConfig config = config(QContext.getQInstance());
|
|
List<String> selected = selection(input.getValueString("selectionJson"), "enqueue".equals(operation) ? config.getMaximumBatch() : 1);
|
|
String table = input.getProcess().getTableName();
|
|
DocumentGenerationService service = new DocumentGenerationService(config);
|
|
Object result;
|
|
if(List.of("generate", "enqueue").contains(operation))
|
|
{
|
|
List<GenerationPorts.Source> sources = selected.stream().map(id -> new GenerationPorts.Source(table, id)).toList();
|
|
GenerationPorts.Format format;
|
|
try
|
|
{
|
|
format = GenerationPorts.Format.valueOf(input.getValueString("format"));
|
|
}
|
|
catch(RuntimeException failure)
|
|
{
|
|
throw new QBadRequestException("Select PDF or ZPL output.");
|
|
}
|
|
result = "generate".equals(operation)
|
|
? service.generate(request(input, sources.getFirst(), format))
|
|
: new GenerationQueue(config).enqueue(input.getValueInteger("templateId"), sources, format, input.getValueString("requestKey"), input.getValueString("destination"));
|
|
}
|
|
else
|
|
{
|
|
Integer id;
|
|
try
|
|
{
|
|
id = Integer.valueOf(selected.getFirst());
|
|
}
|
|
catch(NumberFormatException failure)
|
|
{
|
|
throw new QBadRequestException("Invalid generation identity.");
|
|
}
|
|
if(GenerationTables.QUEUE.equals(table))
|
|
{
|
|
GenerationQueue queue = new GenerationQueue(config);
|
|
result = switch(operation)
|
|
{
|
|
case "status" -> queue.status(id, input.getValueInteger("afterOrdinal") == null ? -1 : input.getValueInteger("afterOrdinal"), 50);
|
|
case "advance" -> queue.advance(id);
|
|
case "background" -> GenerationJobs.launch(id);
|
|
case "cancel" ->
|
|
{
|
|
queue.cancel(id);
|
|
yield "Cancellation requested; in-flight external work may still finish.";
|
|
}
|
|
case "recover" -> queue.recoverTerminated(id, input.getValueInteger("itemId"));
|
|
case "reconcileItem" -> queue.reconcile(id, input.getValueInteger("itemId"));
|
|
default -> throw new QBadRequestException("Unsupported queue action.");
|
|
};
|
|
}
|
|
else if(GenerationTables.REQUEST.equals(table))
|
|
{
|
|
result = switch(operation)
|
|
{
|
|
case "status" -> requestStatus(id);
|
|
case "download" ->
|
|
{
|
|
GenerationPorts.DownloadGrant grant = new GenerationDownloadService(config).issue(id);
|
|
GenerationResultFields.delivery(output, id, grant);
|
|
yield java.util.Map.of("versionId", grant.versionId(), "downloadUrl", grant.url(), "issuedAt", grant.issuedAt().toString(), "observation", "LINK_ISSUED",
|
|
"delivery", grant.authenticated() ? "AUTHENTICATED_FETCH" : "SIGNED_URL");
|
|
}
|
|
case "reprint" -> service.reprint(id, input.getValueString("requestKey"), input.getValueString("destination"));
|
|
case "reconcile" -> service.reconcile(id);
|
|
default -> throw new QBadRequestException("Unsupported request action.");
|
|
};
|
|
}
|
|
else
|
|
{
|
|
throw new QBadRequestException("Document action table is invalid.");
|
|
}
|
|
}
|
|
if(result instanceof DocumentGenerationService.Result generated)
|
|
{
|
|
GenerationResultFields.request(output, generated.requestId(), config);
|
|
}
|
|
else if("enqueue".equals(operation) && result instanceof Integer queued)
|
|
{
|
|
GenerationResultFields.record(output, GenerationTables.QUEUE, queued);
|
|
}
|
|
else if(GenerationTables.REQUEST.equals(table))
|
|
{
|
|
GenerationResultFields.request(output, Integer.valueOf(selected.getFirst()), config);
|
|
}
|
|
else if(GenerationTables.QUEUE.equals(table))
|
|
{
|
|
GenerationResultFields.record(output, GenerationTables.QUEUE, Integer.valueOf(selected.getFirst()));
|
|
}
|
|
output.addValue("resultJson", json(result));
|
|
}
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** The generate action's intent: one record, with the optional render target.
|
|
** A target that is not a resolution a printer can have is refused here, as a
|
|
** bad request, before the service sees it.
|
|
*******************************************************************************/
|
|
private static DocumentGenerationService.Request request(RunBackendStepInput input, GenerationPorts.Source source, GenerationPorts.Format format) throws QException
|
|
{
|
|
try
|
|
{
|
|
return (new DocumentGenerationService.Request(input.getValueInteger("templateId"), source, format, input.getValueString("requestKey"), input.getValueString("destination"),
|
|
null, null, 1, input.getValueInteger("targetDpi")));
|
|
}
|
|
catch(IllegalArgumentException invalid)
|
|
{
|
|
throw new QBadRequestException("A render target is 203, 300 or 600 dpi.");
|
|
}
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** Status exposes only the currently authorized request, never hidden context.
|
|
*******************************************************************************/
|
|
private static java.util.Map<String, java.io.Serializable> requestStatus(Integer id) throws QException
|
|
{
|
|
GenerationSecurity.require("view");
|
|
QRecord row = GenerationStore.get(GenerationTables.REQUEST, id);
|
|
GenerationRetainedAccess.require(row, false);
|
|
java.util.Map<String, java.io.Serializable> result = new java.util.LinkedHashMap<>();
|
|
for(String field : List.of("id", "state", "format", "tableName", "recordId", "revisionId", "fileId", "versionId", "sha256", "length", "available", "printJobId", "errorCode", "mediaProfileId", "mediaProfileRevision", "targetDpi"))
|
|
{
|
|
result.put(field, row.getValue(field));
|
|
}
|
|
return (result);
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** Parse a bounded plain array; no polymorphic objects or host deserialization.
|
|
*******************************************************************************/
|
|
private static List<String> selection(String text, int maximum) throws QException
|
|
{
|
|
if(text == null || text.length() > 300000)
|
|
{
|
|
throw new QBadRequestException("Invalid selected record identities.");
|
|
}
|
|
try
|
|
{
|
|
JsonNode array = JSON.readTree(text);
|
|
if(!array.isArray() || array.isEmpty() || array.size() > maximum)
|
|
{
|
|
throw new QBadRequestException("Invalid selected record identities.");
|
|
}
|
|
List<String> ids = new ArrayList<>();
|
|
for(JsonNode item : array)
|
|
{
|
|
if(!item.isTextual())
|
|
{
|
|
throw new QBadRequestException("Selected identities must be strings.");
|
|
}
|
|
DocumentDefinitionService.text(item.textValue(), 255);
|
|
ids.add(item.textValue());
|
|
}
|
|
return (ids);
|
|
}
|
|
catch(java.io.IOException failure)
|
|
{
|
|
throw new QBadRequestException("Invalid selected record identities.");
|
|
}
|
|
}
|
|
|
|
/*******************************************************************************
|
|
** Pure result serialization is for display, never executable template source.
|
|
*******************************************************************************/
|
|
private static String json(Object value) throws QException
|
|
{
|
|
try
|
|
{
|
|
return (JSON.writeValueAsString(value));
|
|
}
|
|
catch(java.io.IOException failure)
|
|
{
|
|
throw new QException("Could not serialize document action result.", failure);
|
|
}
|
|
}
|
|
}
|