Skip to content

Add 'duplicate_sample' workflow transition for samples#2895

Open
ramonski wants to merge 31 commits into
2.xfrom
feat/sample-duplicate
Open

Add 'duplicate_sample' workflow transition for samples#2895
ramonski wants to merge 31 commits into
2.xfrom
feat/sample-duplicate

Conversation

@ramonski
Copy link
Copy Markdown
Contributor

@ramonski ramonski commented May 7, 2026

Description of the issue/feature this PR addresses

Adds a duplicate_sample workflow transition on samples. A single
click 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_add form roundtrip.

A global toggle in the SENAITE setup (Sampling fieldset → "Allow
sample 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_add pre-populated with the source's values and
expects 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

  • A new Duplicate action appears in the sample listings on every
    productive sample state (sample_registered through published),
    guarded by the existing senaite.core: Add AnalysisRequest
    permission — same role policy as Copy-to-new (LabClerk,
    LabManager, Manager, Owner). No new permission is introduced.
  • Clicking it creates exactly one sibling Sample per selected
    source. The duplicate has:
    • All schema fields copied from the source (skipping lineage
      pointers, results, attachments, source-run remarks/rejection/
      interpretation — see DUPLICATE_SKIP_FIELDS for the full
      list with rationale).
    • Analyses re-instantiated from the source's services with the
      source's results ranges, all results empty. Retracted /
      rejected / cancelled analyses and retest descendants are
      skipped.
    • A fresh Sample ID from the regular AnalysisRequest counter
      (e.g. water-0042water-0043), same format Copy-to-new
      produces. Integrators can opt into a dedicated ID format by
      adding an AnalysisRequestDuplicate row in the ID Server admin
      (e.g. {parent_ar_id}-D{duplicate_count:02d}).
    • The IAnalysisRequestDuplicate marker interface and the
      DuplicatedFrom UID reference back to the source for type
      distinction and lineage.
  • A single auditlog snapshot with action create reflects the
    full duplicate state (including fields populated via
    copy_field_values).
  • The source's workflow state is unchanged (new_state empty).
  • Sample-structure copying (partitions) follows the existing
    sample_add_form_copy_partitions registry flag, mirroring
    Copy-to-new behaviour.
  • A global toggle sample_duplicate_enabled on the SENAITE setup
    (default True) gates the transition: when disabled, the guard
    returns False, the listing button disappears and the workflow
    endpoint refuses the action.

Implementation summary

  • New IAnalysisRequestDuplicate marker interface and
    DuplicatedFrom UID reference field on AnalysisRequest
    (relationship AnalysisRequestDuplicatedFrom).
  • New create_duplicate_of(sample, request=None, skip_fields=None)
    factory in bika.lims.utils.analysisrequest. Delegates fully to
    create_analysisrequest with a minimal values dict (only
    ID-relevant scalars + DuplicatedFrom); the remaining schema is
    filled in via copy_field_values — avoids AT widget mismatches
    on native multi-valued field values. skip_fields lets callers
    override the default DUPLICATE_SKIP_FIELDS per call.
  • create_analysisrequest gains a skip_snapshots flag that
    pauses the auditlog around _processForm; the caller takes one
    complete create snapshot after copy_field_values has
    populated the rest of the schema.
  • ID Server: SAMPLE_MARKER_TYPE_POLICY table makes marker →
    portal_type resolution explicit. Duplicate is opt-in (falls
    through to AR template unless an AnalysisRequestDuplicate row
    exists); partition / retest / secondary continue to always
    return their own portal_type. get_duplicate_count returns
    len(backrefs) (no +1) since the new duplicate's UIDReference
    back to the source is registered synchronously before
    renameAfterCreation.
  • Workflow transition + guard + after-event registered on
    senaite_sample_workflow (category listing, new_state empty,
    guard-permission senaite.core: Add AnalysisRequest).
  • guard_duplicate_sample short-circuits to False for non-AR
    contexts and when the global setup toggle is off.
  • WorkflowActionDuplicateAdapter triggers the transition and
    reports the new duplicate IDs in a status message — diffs the
    source's backref set before vs. after so prior duplicates are
    not re-listed.
  • New IAfterCreateSampleHook post-creation dispatch in
    create_duplicate_of so Copy-to-new and duplicate share the
    same partition-copying behaviour. Hook ordering convention
    (sort attribute, default 10) is now documented on the
    interface.
  • New sample_duplicate_enabled Bool on SenaiteSetup, default
    True, surfaced in the Sampling fieldset. Read by the guard.
  • metadata.xml bumped to 2727 with upgrade step
    setup_duplicate_sample_transition that re-imports the workflow
    (no new permission is introduced — re-using AddAnalysisRequest).
  • EN + DE translations for the new field labels, setup toggle and
    adapter status messages.
  • Doctest SampleDuplicate.rst covering: factory, transition,
    marker, ID format (default + opt-in), analysis filtering, audit
    snapshot completeness across all copy_field_values paths
    (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.

ramonski added 19 commits May 7, 2026 13:37
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.
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.
@ramonski ramonski requested a review from xispa May 7, 2026 13:43
@ramonski ramonski added the Enhancement ✨ Improvement to existing functionality label May 7, 2026
ramonski added 7 commits May 7, 2026 15:44
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.
@ramonski ramonski changed the title Add 'duplicate' workflow transition for samples Add 'duplicate_sample' workflow transition for samples May 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Enhancement ✨ Improvement to existing functionality

Development

Successfully merging this pull request may close these issues.

1 participant