Add 'duplicate_sample' workflow transition for samples#2895
Open
ramonski wants to merge 31 commits into
Open
Conversation
Implements a single-click sibling sample creation flow that bypasses the ar_add form. The duplicate inherits all schema fields from the source, re-instantiates analyses with empty results, and gets a fresh Sample ID from the regular AnalysisRequest counter (same format Copy-to-new produces). Type distinction comes from the new IAnalysisRequestDuplicate marker interface and the DuplicatedFrom UID reference, both of which are queryable from listings, reports and adapters. - New IAnalysisRequestDuplicate marker and DuplicatedFrom UID reference field on AnalysisRequest (relationship AnalysisRequestDuplicatedFrom). - create_duplicate_of factory in bika.lims.utils.analysisrequest; uses _createObjectByType + copy_field_values + direct setters (mirrors create_retest's pattern). Bypassing _processForm avoids the AT widget mismatch when native multi-valued field values are passed in. Partition copying delegates to the existing IAfterCreateSampleHook so the sample_add_form_copy_partitions registry flag still governs structure replication. - Workflow transition 'duplicate' on senaite_sample_workflow, available from every productive sample state, with new permission TransitionDuplicateSample (granted to LabClerk, LabManager, Manager). - WorkflowActionDuplicateAdapter triggers the transition and reports the new duplicate IDs in a status message. - metadata.xml bumped to 2727 with upgrade step setup_duplicate_transition that re-imports rolemap and workflow. - EN+DE translations for the new field labels and adapter status messages. - Doctest SampleDuplicate.rst covers factory, transition, ID generation and analysis re-instantiation.
The duplicate ID is generated from the regular AnalysisRequest template, not a dedicated AnalysisRequestDuplicate one. Note that integrators can still opt into a custom template via an IIdServerTypeID adapter, so the IAnalysisRequestDuplicate marker remains useful.
Mirror create_retest's exclusion of intermediate analyses: a duplicate inherits the source's *active* analysis set, not its retired entries. Skips analyses in retracted, rejected or cancelled states and any retest descendants. Each duplicate still starts with empty results, ready for fresh measurement.
Verifies that duplicates honour an integrator's overridden
AnalysisRequest ID template (e.g. {sampleType}-{year}-{seq:03d})
since duplicates share the regular AR ID generation path.
Verifies that an integrator can wire a dedicated ID template for duplicates by registering an IIdServerTypeID adapter (returning 'AnalysisRequestDuplicate') plus an IIdServerVariables adapter (exposing parent_ar_id) and adding a matching row in the ID Server admin. The IAnalysisRequestDuplicate marker is the hook that makes this opt-in cheap — no senaite.core change required. Plain sample creation is verified to remain unaffected: the type-id adapter only fires for objects providing the marker.
Mirror the partition/retest/secondary pattern: the ID Server now recognises the AnalysisRequestDuplicate portal type natively. get_type_id returns it for objects providing IAnalysisRequestDuplicate *if* a matching id_formatting row is configured, otherwise falls through to the regular AnalysisRequest template (so duplicates share the standard Sample ID format and counter by default). get_variables exposes parent_ar_id, parent_base_id, parent_analysisrequest and a new duplicate_count helper for templates that opt in. The IDServerView preview list also exposes duplicate_count. Integrators no longer need an IIdServerTypeID adapter to introduce a dedicated duplicate template — adding a row to id_formatting is enough. Doctest opt-in section simplified accordingly.
The custom-template + opt-in sections relied on counters starting
at 1, but the prior sections already incremented the
analysisrequest-water seq. After the template change the storage
key is still keyed on 'water', so the counter continued from 5
(not 1) and the assertions failed in CI.
Use fresh SampleTypes ('soil' for the custom-template section,
'air' for the opt-in section) so each section's persistent counter
starts at 1. The opt-in section also creates its own source so the
per-source duplicate_count starts at 1.
Drop the duplicated _createObjectByType + workflow-init + rename tail. The factory now passes the ID-relevant scalars (Client, Contact, SampleType, DateSampled, SamplingDate) plus DuplicatedFrom through values to create_analysisrequest, which handles services, hidden-service promotion, custom units, marker application, workflow init and rename uniformly. Multi-valued source fields (CCEmails, CCContact, etc.) are then copied via copy_field_values, which bypasses the AT widget process_form path that would otherwise choke on native tuple/list values. This is the smallest split that lets us reuse create_analysisrequest end-to-end. create_analysisrequest applies the IAnalysisRequestDuplicate marker when DuplicatedFrom is in the values dict, so the dedicated AnalysisRequestDuplicate ID template path stays available for integrators that opt in.
The snapshot captured during _processForm only sees the minimal values dict we pass to create_analysisrequest (Client, Contact, SampleType, dates, DuplicatedFrom). Without a refresh, the fields filled in afterwards by copy_field_values would silently miss the audit trail. Replace the existing 'create' snapshot in storage[0] with a fresh capture of the duplicate's full state. Replacing rather than appending an 'edit' entry keeps the log accurate: a duplicate is one logical create event, not a create+immediate-edit.
Cleaner than the post-hoc snapshot replacement: when callers know they will populate additional fields after create_analysisrequest returns, they can suppress the partial 'create' snapshot taken during _processForm and take a single complete one at the end. create_duplicate_of now uses this: pass pause_snapshots=True, run copy_field_values for the remaining fields, then resume snapshots and call snap_api.take_snapshot(duplicate, action='create'). This avoids reaching into snapshot storage internals to overwrite storage[0]; a duplicate ends up with exactly one 'create' entry that reflects its full state.
Two new sections:
- Verify the duplicate has exactly one snapshot with action 'create'
and that inherited fields (SampleType, Contact) are present.
Confirms the pause_snapshots + take_snapshot-after-copy approach
produces a single, complete entry rather than a partial one or
two separate entries.
- A standalone check using a fresh SampleType ('juice') exercises
CCEmails — the exact multi-valued field that previously tripped
AT widget process_form. Asserts the snapshot of the duplicate
contains both source emails.
SampleType and Contact ride the values dict and would land in _processForm's snapshot anyway — they don't actually exercise the copy_field_values path. Replace those assertions with fields that are NOT passed to create_analysisrequest: CCEmails (multi-valued, the original failure case), ClientOrderNumber, ClientReference, EnvironmentalConditions. Set them on a fresh source, duplicate, then assert: - the duplicate's field accessors return the source values, - the duplicate's snapshot contains the same values, - exactly one snapshot exists with action 'create'. This proves the pause_snapshots + post-copy take_snapshot path captures the copied fields the audit trail would otherwise miss.
Make the audit-log suppression bracket explicit at both call sites: - create_analysisrequest(skip_snapshots=True) pauses snapshots before _processForm and resumes them before returning, so the sample is returned in the normal 'snapshots enabled' state. Callers no longer have to remember to resume. - create_duplicate_of pauses snapshots itself around the copy_field_values call, then takes a single 'create' snapshot reflecting the duplicate's full schema state. The repeated pause/resume pair makes the audit-trail handling visible at the call site rather than leaking responsibility to the caller, while still ensuring no partial snapshot escapes.
The previous assertions only exercised text-shaped fields. Add:
- Composite (boolean) -- different code path in field setters
- CCContact (multi-valued UIDReferenceField) -- the relationship/
backref maintenance path inside copy_field_values
- Profiles (multi-valued UIDReferenceField pointing at a setup
AnalysisProfile) -- a different relationship target
Verify both the duplicate's accessor returns the source value and
the snapshot contains it. UID serialisation format depends on
SuperModel.to_dict(), so we check via UID-substring membership in
repr() to stay robust to UID-string vs dict-summary formats.
Inject 'Duplicate' as a custom_transitions entry right after 'Copy to new' in the samples listing (samples/view.py add_custom_transitions). The new get_duplicate_transition mirrors get_copy_to_new_transition: gated by TransitionDuplicateSample permission, falls back to the user's client folder if the permission is missing on the current context. Drop category='listing' from the WF transition's action so it does not render itself in the listing's bulk-actions menu — that would have been a duplicate entry alongside the custom one. The WF transition itself still drives permission/guard/after-event semantics; only the listing UI hook moves to the custom slot.
This reverts commit d0d809c.
Add get_duplicate_transition mirroring get_copy_to_new_transition (same permission-fallback pattern: try current context, fall back to user's client folder) and append it right after copy_to_new in add_custom_transitions. Permission gate: TransitionDuplicateSample. The WF transition's category='listing' is intentionally left untouched so the underlying workflow registration stays the canonical source of permission/guard/after-event semantics.
The Duplicate WF transition is auto-rendered by senaite.app.listing via api.get_transitions_for; injecting it into custom_transitions in samples/view.py was redundant — the listing's transition dict is keyed by tid, so the WF entry overwrote the custom one anyway. Drop the get_duplicate_transition helper, the import, and the add_custom_transitions append. The listing surfaces Duplicate via the standard WF mechanism going forward.
- get_duplicate_count: don't add 1 — the new duplicate's UIDReference back to the source is registered before renameAfterCreation, so it is already counted in the backrefs. - Doctest: use print(...) for str values to avoid py2 u'' repr drift, and update CCEmails expectation to include the space inserted by the field setter normalization.
Avoids id collision with the existing 'duplicate' transition on analysis services (which carries a button-style mapping in senaite.app.listing's ButtonBar). With the new id 'duplicate_sample' the sample duplicate button picks up the default outline-secondary style — same as Copy-to-new — without needing per-listing CSS.
New Bool schema field on SenaiteSetup, surfaced under the Sampling fieldset and defaulting to True. The 'duplicate_sample' workflow guard returns False when the toggle is disabled, so the transition disappears from the listing button bar and the workflow_action endpoint refuses it. EN+DE translations added; doctest covers both the disabled state (transition not offered, do_action_for fails) and the re-enabled state.
- Adapter status message now lists only duplicates created by this invocation (snapshot backref UIDs before/after instead of reading the full backref set, which included prior duplicates). - Rename setup field 'allow_sample_duplicate' to 'sample_duplicate_enabled' to match neighbour fields (printing_workflow_enabled, sampling_workflow_enabled, etc.). - Update stale 'duplicate' references in docstrings/comments to 'duplicate_sample' (idserver.get_duplicate_count, IAnalysisRequestDuplicate marker, DuplicatedFrom field comment). - Correct create_duplicate_of docstring: marker is applied based on getDuplicatedFrom() after _processForm, not by reading the values dict. - Drop misleading mention of 'default ID template for AnalysisRequestDuplicate' from upgrade step description (no template is installed; integrators opt in).
- IAfterCreateSampleHook docstring documents the 'sort' attribute convention (lower runs first; default 10). - DUPLICATE_SKIP_FIELDS gains per-group rationale comments explaining why each field is excluded (analyses recreated empty; source-run state/lineage must not leak; remarks/rejection/ interpretation reference results the duplicate doesn't have yet). - create_duplicate_of accepts a 'skip_fields' parameter so callers can override the default skip list per call. - Drop the dedicated TransitionDuplicateSample permission. The duplicate_sample workflow transition now reuses the existing AddAnalysisRequest permission, matching Copy-to-new's role policy (LabClerk/LabManager/Manager/Owner) without introducing a parallel permission to maintain. - Refactor get_type_id marker handling into an explicit SAMPLE_MARKER_TYPE_POLICY table + resolve_marker_type_id helper, making the asymmetry (opt-in for duplicate, always for partition/retest/secondary) explicit and easy to extend. - Sort SAMPLE_TYPES alphabetically. - guard_duplicate_sample short-circuits with False when the context is not an AnalysisRequest, defending against accidental wiring. - Document the synchronous UIDReference assumption in get_duplicate_count so future maintainers know what to revisit if relationship indexing ever becomes deferred.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description of the issue/feature this PR addresses
Adds a
duplicate_sampleworkflow transition on samples. A singleclick creates a sibling Sample with the source's schema fields
carried over (Client, SampleType, Batch, Contacts, dates, …) and
its analyses re-instantiated empty, ready for fresh result entry.
No
ar_addform roundtrip.A global toggle in the SENAITE setup (
Samplingfieldset → "Allowsample duplication") lets administrators disable the feature
site-wide. Default: enabled.
Current behavior before PR
Replicating a sample requires going through Copy-to-new, which
redirects to
ar_addpre-populated with the source's values andexpects the user to confirm the form. For routine "do this sample
again" operations (method repeatability, customer re-runs, etc.)
the form roundtrip is unnecessary friction.
Desired behavior after PR is merged
Duplicateaction appears in the sample listings on everyproductive sample state (
sample_registeredthroughpublished),guarded by the existing
senaite.core: Add AnalysisRequestpermission — same role policy as Copy-to-new (LabClerk,
LabManager, Manager, Owner). No new permission is introduced.
source. The duplicate has:
pointers, results, attachments, source-run remarks/rejection/
interpretation — see
DUPLICATE_SKIP_FIELDSfor the fulllist with rationale).
source's results ranges, all results empty. Retracted /
rejected / cancelled analyses and retest descendants are
skipped.
(e.g.
water-0042→water-0043), same format Copy-to-newproduces. Integrators can opt into a dedicated ID format by
adding an
AnalysisRequestDuplicaterow in the ID Server admin(e.g.
{parent_ar_id}-D{duplicate_count:02d}).IAnalysisRequestDuplicatemarker interface and theDuplicatedFromUID reference back to the source for typedistinction and lineage.
createreflects thefull duplicate state (including fields populated via
copy_field_values).new_stateempty).sample_add_form_copy_partitionsregistry flag, mirroringCopy-to-new behaviour.
sample_duplicate_enabledon the SENAITE setup(default
True) gates the transition: when disabled, the guardreturns
False, the listing button disappears and the workflowendpoint refuses the action.
Implementation summary
IAnalysisRequestDuplicatemarker interface andDuplicatedFromUID reference field onAnalysisRequest(relationship
AnalysisRequestDuplicatedFrom).create_duplicate_of(sample, request=None, skip_fields=None)factory in
bika.lims.utils.analysisrequest. Delegates fully tocreate_analysisrequestwith a minimal values dict (onlyID-relevant scalars +
DuplicatedFrom); the remaining schema isfilled in via
copy_field_values— avoids AT widget mismatcheson native multi-valued field values.
skip_fieldslets callersoverride the default
DUPLICATE_SKIP_FIELDSper call.create_analysisrequestgains askip_snapshotsflag thatpauses the auditlog around
_processForm; the caller takes onecomplete
createsnapshot aftercopy_field_valueshaspopulated the rest of the schema.
SAMPLE_MARKER_TYPE_POLICYtable makes marker →portal_type resolution explicit. Duplicate is opt-in (falls
through to AR template unless an
AnalysisRequestDuplicaterowexists); partition / retest / secondary continue to always
return their own portal_type.
get_duplicate_countreturnslen(backrefs)(no+1) since the new duplicate's UIDReferenceback to the source is registered synchronously before
renameAfterCreation.senaite_sample_workflow(categorylisting,new_stateempty,guard-permission
senaite.core: Add AnalysisRequest).guard_duplicate_sampleshort-circuits toFalsefor non-ARcontexts and when the global setup toggle is off.
WorkflowActionDuplicateAdaptertriggers the transition andreports the new duplicate IDs in a status message — diffs the
source's backref set before vs. after so prior duplicates are
not re-listed.
IAfterCreateSampleHookpost-creation dispatch increate_duplicate_ofso Copy-to-new and duplicate share thesame partition-copying behaviour. Hook ordering convention
(
sortattribute, default 10) is now documented on theinterface.
sample_duplicate_enabledBool onSenaiteSetup, defaultTrue, surfaced in the Sampling fieldset. Read by the guard.metadata.xmlbumped to 2727 with upgrade stepsetup_duplicate_sample_transitionthat re-imports the workflow(no new permission is introduced — re-using AddAnalysisRequest).
adapter status messages.
SampleDuplicate.rstcovering: factory, transition,marker, ID format (default + opt-in), analysis filtering, audit
snapshot completeness across all
copy_field_valuespaths(text/bool/single-UID/multi-UID), counter advancement and the
global toggle.
I confirm I have tested this PR thoroughly and coded it according to PEP8
and Plone's Python styleguide standards.