Skip to content

feat(annotator): add nexuslims_annotate app for dataset annotation#16

Merged
jat255 merged 25 commits intomainfrom
feat/annotator-app
Mar 29, 2026
Merged

feat(annotator): add nexuslims_annotate app for dataset annotation#16
jat255 merged 25 commits intomainfrom
feat/annotator-app

Conversation

@jat255
Copy link
Copy Markdown
Contributor

@jat255 jat255 commented Mar 24, 2026

Summary

Adds the nexuslims_annotate Django app, which lets authenticated users with write access annotate NexusLIMS experiment records in two ways:

  • Descriptions -- attach plain-language text to individual datasets. Stored in the XML under <dataset>/<description> and rendered in the gallery, dataset activity tables, and metadata modals.
  • Dataset reassignment -- move datasets between acquisition activities via drag-and-drop or multi-select batch move.

Entry points

  • Side panel -- an Annotate Record button in the detail page action bar opens a slide-in offcanvas panel. Datasets are listed grouped by acquisition activity, each with a preview thumbnail and a text field. Save via Save Annotations or Ctrl+Enter / Cmd+Enter.
  • Inline editing -- hovering over a row in a dataset activity table reveals a pencil icon. Clicking opens a small floating popup for quick single-dataset edits (Ctrl+Enter to save, Escape to cancel).
  • Full-page editor -- the expand icon in the panel header opens /annotate/<record_id>/ as a full-page form with all dataset cards visible at once.

A ? help button is available in the full-page editor.

Full-page editor features

Descriptions

Type into any card's text field and save with Save Annotations or Ctrl+Enter. Cards are color-coded:

  • Green -- description saved
  • Orange -- unsaved change
  • Gray -- no description yet
  • Indigo -- pending move (not yet saved)

Dataset reassignment

Datasets can be moved between activities three ways:

  1. Drag and drop -- drag any card to a different activity section.
  2. Multi-select + batch move -- check one or more cards, then use the Move to Activity toolbar at the bottom of the screen.
  3. Drag selected cards together -- check several cards, then drag any one of them; all selected cards move to the destination activity together.

Shift-click on checkboxes selects all cards between the last clicked and the current one, making it easy to select large ranges without clicking every card individually.

Moves are staged (shown in indigo with a "Moved" badge) and saved atomically with descriptions when you click Save Annotations. Individual moves can be undone via the "Moved" badge or the Undo all moves button in the toolbar.

Changes

  • nexuslims_annotate/ -- new Django app with views, templates, CSS, and URL config
  • xslt/detail_stylesheet.xsl -- renders <description> elements in gallery cards, activity tables, and metadata modals; color-codes datasets (green = annotated, orange = has file but no description, gray = no file)
  • nexuslims_overrides/ -- detail template wired up with offcanvas panel, inline edit JS, and help modal; datatables JS extended with pencil-icon inline edit; detail CSS extended for gallery/table/tooltip/modal/button styles; Monaco editor find-widget styling fixed
  • mdcs/urls.py -- annotate routes gated on both NX_ENABLE_ANNOTATOR and nexuslims_annotate in INSTALLED_APPS
  • nexuslims_overrides/settings.py -- NX_ENABLE_ANNOTATOR = True default added
  • nexuslims_overrides/CUSTOMIZATION.md -- full documentation for the feature and its feature flag

Feature flag

# config/settings/custom_settings.py
NX_ENABLE_ANNOTATOR = False  # hides all UI and removes /annotate/ routes

Requires a container restart to take effect.

Permissions

All annotator views enforce write access via check_can_write. Users without write permission receive a 403 on all endpoints (GET and POST). The Annotate Record button is only rendered for users with can_write on the record.

Test plan

  • Log in as a record owner -- Annotate Record button visible on detail page
  • Open offcanvas panel, edit descriptions, save with button and with Ctrl+Enter
  • Open inline edit via pencil icon in activity table, save with Ctrl+Enter and Escape to cancel
  • Open full-page editor via expand icon, save and verify redirect back to record
  • Drag a card to a different activity section -- card turns indigo, count badges update
  • Check multiple cards with shift-click, batch-move via toolbar dropdown
  • Check multiple cards, drag one -- all selected cards move together
  • Undo a move via the "Moved" badge; undo all moves via toolbar button
  • Log in as a user without write access -- button not visible; direct GET to /annotate/<id>/ returns 403
  • Set NX_ENABLE_ANNOTATOR = False, restart container -- button gone, /annotate/<id>/ returns 404
  • Remove nexuslims_annotate from INSTALLED_APPS -- /annotate/<id>/ returns 404
  • Verify descriptions appear in gallery cards, activity table rows, and metadata modals after saving

jat255 added 14 commits March 22, 2026 23:09
Adds a new Django app that allows authenticated users to add textual
descriptions to individual datasets within a NexusLIMS experiment record.
An offcanvas panel is accessible from the record detail page via an
"Annotate Record" button. Descriptions are saved directly into the XML
record's existing <description> elements and persist across page loads.

Controlled by the NX_ENABLE_ANNOTATOR feature flag (default True in dev).
- Move button into #top-button-div action bar via JS (window load, prepend
  so it appears rightmost in the flex-reversed container)
- Add Bootstrap tooltip "Annotate this record with dataset descriptions"
  initialized via JS to avoid data-bs-toggle conflict with offcanvas
- Add annotate.css in the app's static directory with responsive rules:
  font-size scaling matching other btn-top-group buttons, icon-only
  at ≤696px, and full-width offcanvas at narrow viewports
- Blur button after offcanvas closes (setTimeout to yield after
  Bootstrap's focus-return) to prevent the gray focus background
- Redesign dataset cards with color-coded left-border accents: green for
  saved annotations, gray for empty, yellow/amber for unsaved changes
- Add activity dividers with 1-based numbering and horizontal rule separator
- Track dirty state via data-original attribute on each textarea; update
  card colors live on input
- Warn before closing panel when unsaved changes exist (confirm dialog),
  with forceClose flag to bypass check after save or confirmed discard
- Fix sticky Save/Cancel footer by moving it outside the form element,
  using HTML5 form= attribute on the submit button, and applying flex
  column layout to the offcanvas body so the form scrolls independently
- Use monospace font for dataset filenames; reduce thumbnail size to 56px
…ssions

Full-width annotator page (annotate.html):
- Fix broken template: extend theme.html (not the non-existent core_main_app/user/main.html)
- Extract record title from XML via _get_title() helper rather than data.title
- Match panel UI: color-coded cards, activity dividers with 1-based numbering,
  data-original dirty tracking, monospace filenames, align-self-stretch images
- Style Back to Record button as btn-top-group with menu-fa icon spacing
- Save redirects to record detail; errors redirect back with ?error=1

Offcanvas panel improvements:
- Offset offcanvas top by 60px so header clears the fixed navbar
- Add expand button (fa-expand) in offcanvas header linking to full-width page;
  initialize its tooltip via JS with placement:left
- Reduce first-activity top margin to mt-1; subsequent activities keep mt-3
- Images use align-self-stretch + width:auto;max-width:80px;height:100% so
  they fill card height proportionally instead of being clipped at a fixed size

Permission fix:
- Gate Annotate button on data.can_write (workspace write access) rather than
  request.user.is_authenticated, matching the Edit/Open in text editor buttons

Save view:
- Detect AJAX via X-Requested-With header; return JSON for panel, redirect for
  full-width form POST
- Add X-Requested-With: XMLHttpRequest to panel's fetch call
Annotator features:
- Add inline pencil icon on dataset rows (hover-reveal) that opens a
  floating edit popup — saves via new annotate_save_one endpoint
- Add annotate_save_one view and URL for single-dataset saves
- Add help modal (? button) to offcanvas panel and full-page annotator
  explaining color coding, keyboard shortcuts, and inline editing
- Add Ctrl/Cmd+Enter keyboard shortcut to save in both offcanvas and
  inline popup; Escape to close inline popup

Full-page annotator UI:
- Modernize dataset cards: rounded corners, hover shadow, borderless
  textarea with subtle fill and green focus ring
- DRY CSS: extract shared .annotate-card and .annotate-textarea rules
  into annotate.css, removing duplicate inline styles from templates
- Fix CDCS theme interference (padding-top: 40px / margin-top: -40px
  on form children) that caused misaligned activity headers; use
  row-gap for vertical card spacing instead of Bootstrap gutters

Detail page / XSLT:
- Fix first gallery image description not shown on page load (setTimeout)
- Fix off-page DataTables rows not updating after save by storing
  descriptions in window.__nxAnnotateDescriptions and applying on draw.dt
- Fix dataset metadata modal description overflow (white-space, max-width)
- Add .nx-desc-row flex layout for pencil icon alignment in tables
- Refresh descriptions on save via refreshDisplayedDescriptions (exposed
  on window for cross-IIFE access)

CSS / styles:
- Add .nx-modal-desc-wrapper line-height and .dataset-meta-modal
  max-width/white-space fixes in modals.css
- Add .nx-desc-edit hover styles and #nx-inline-edit-popup in modals.css
- Fix Monaco editor find widget broken by MDCS global .button rule
  (scoped reset in main.css)
…INSTALLED_APPS

Setting NX_ENABLE_ANNOTATOR=False now removes both the annotator UI and the
/annotate/ URL routes, so the full-page editor is not directly accessible via URL.
Routes are also suppressed if nexuslims_annotate is removed from INSTALLED_APPS.
Add check_can_write checks to annotate_record and annotate_panel so
unauthorized users receive a 403 instead of seeing the form. Save views
now also catch AccessControlError explicitly and return 403 rather than
500 for permission failures.
Merge core and server optional extras into main dependencies so plain
`uv sync` installs everything without --extra flags. Add python-dotenv
as a dev dependency group. Update Dockerfile and dev-commands aliases
accordingly. Also add nexuslims_annotate to test INSTALLED_APPS and
document runtests.py usage in CLAUDE.md.
Add ability to reassign datasets between acquisition activities in the
annotator panel. New XML helpers handle activity parsing, setup param
injection/reconciliation, creation-time sorting, and applying moves.
The save endpoint now accepts a moves JSON payload alongside descriptions.

Also consolidate the XML and JSON download buttons in the detail view
into a single Download Record dropdown (XSLT, buttons.css, tour.js).

Includes 936-line test suite covering all new pure XML-processing helpers.
…elpers

Move StageX into activity 0 setup (matching real record structure), add
image_004.dm3 to activity 1, and add Unique_to_N meta fields to each dataset
to make the fixture more realistic.

Update all affected tests to match the new fixture, using orig_0/orig_1
class attributes for relative dataset-count assertions so tests stay correct
regardless of fixture size. Remove test_conflicting_dataset_value_prevents_
setup_param_promotion (tests an impossible state) and test_existing_meta_
not_overwritten (no longer applicable after StageX moved to setup level).

Add _dump_activity_el, _dump_activities, _dump_el, and _dump_xml helpers
for use when debugging tests.

Also update nexuslims-test-data.tar.gz with the latest example_record.xml.
…oating UI

- Add tut-aa-inline-edit tour step that highlights the pencil icon for
  inline dataset description editing (only shown when annotator is enabled
  and user has write access)
- Pin Shepherd.js to 11.2.0 (supports floatingUIOptions) and add Floating
  UI (utils 0.2.11, core 1.7.5, dom 1.7.6) as static assets
- Fix modal overlay leak on tour cancel by explicitly removing
  .shepherd-modal-overlay-container in cleanupOnExit
- Fix modal visibility check to also require display !== 'none'
- Add scrollDisabled body class during tour for consistent scroll management
- Refactor annotator inline styles to .nx-help-text and .nx-dataset-name
  CSS classes; fix offcanvas-backdrop top offset
- Add tut-annotate-panel step that programmatically opens the Bootstrap
  offcanvas via beforeShowPromise (waits for shown.bs.offcanvas), then
  attaches the tooltip to the panel header and closes the panel on hide
- Change tut-annotate-record buttons from end to next so the tour
  continues into the new panel step
- Close the offcanvas in clean_up_on_exit as a safety net for
  cancel/complete events that race with the step's when.hide hook
- Add createAnnotatorTour() with steps covering the welcome overview,
  dataset card color coding, checkbox/batch-move (with forced opacity to
  reveal the hidden checkbox), and the save button with keyboard shortcut
- Add NexusLIMSTours.startAnnotatorTour() public method and wire it into
  startTour() page detection via #annotate-page-form
- Remove stale detail/tour.js and detail/downloads.js script tags from
  theme_base.html override (files no longer exist; correct scripts are
  loaded by theme.html)
- Fix indentation regression in annotate.html Help button
@jat255 jat255 marked this pull request as ready for review March 26, 2026 14:46
jat255 added 11 commits March 28, 2026 09:16
…rror handling

- Replace all hardcoded `/annotate/...` paths with `reverse()` calls in views.py
  and `{% url %}` tags in detail.html; pass URLs via `data-url-*` attributes so
  JS never constructs paths itself
- Fix TypeError in `_sort_datasets_by_creation_time` when multiple datasets lack
  timestamps (use sentinel datetime.min instead of None comparison)
- Add proper 404/403 responses: `annotate_record` raises Http404 on missing
  records; `annotate_panel` returns JSON 404; `annotate_descriptions` checks
  write permission and returns 403
- Change `NX_ENABLE_ANNOTATOR` default from False to True in context processor
- Add SortableJS 1.15.6 vendored library
- Remove unused date-formatting code from detail/main.js
- Add tests for the sort fix and view-level integration tests
…t handling, popup layout

- Replace mark_safe(json.dumps(activities)) with json_script filter to prevent
  XSS via </script> injection in annotate.html
- Add DoesNotExist handler to annotate_descriptions view (was the only view
  missing it, causing 500 instead of 404 for deleted records)
- Validate dataset_index in annotate_save_one: return 400 if missing or negative
  instead of silently succeeding as a no-op
- Wrap popup offsetWidth read in requestAnimationFrame so position clamp uses
  actual rendered width rather than 0 before first layout pass
- Catch ModelError (raised for malformed/non-integer record IDs) alongside
  DoesNotExist in all five views, returning 404 instead of 500
- Add @require_GET to annotate_panel to reject POST with 405
- Add ET.ParseError handling in read-path views (annotate_record,
  annotate_panel, annotate_descriptions) returning 500 JSON instead of
  unhandled exception
- Fix form action attribute missing from _panel.html offcanvas form
- Fix XSS in offcanvas error handler: replace innerHTML string concat
  with createElement/textContent
- Add tests: DoesNotExist/ModelError in save views, malformed XML in
  read views, POST to panel returns 405, annotate_save 404 and success
Views:
- Move _get_title inside ET.ParseError guard in annotate_record (issue #1)
- annotate_record XML parse error now renders template with xml_error
  context instead of returning raw JSON in an HTML view (issue #10)
- annotate_panel returns JsonResponse 403 instead of HttpResponseForbidden
  to stay consistent with other AJAX endpoints (issue #4)
- annotate_save_one: catch ValueError separately to return 400 for
  non-integer dataset_index (issue #12); add idx bounds check -> 400
  (issue #11); log raw_idx instead of None on int() failure (issue #5)
- Both write-path views use @require_POST instead of manual method check
  to get correct Allow header on 405 responses (issue #15)
- Log warning when moves JSON is malformed and silently dropped (issue #3)
- 500 responses return generic 'Internal server error' instead of
  leaking str(e) (issue #14)
- _apply_moves validates move dict keys before access (issue #16)
- Add comments documenting write-path authorization via upsert

Tests:
- Add AnnotateRecordViewTest with 6 cases covering unauthenticated,
  404, 403, malformed XML (500), success render, and ?error=1 banner
  (issue #2)
- Add bounds-check test and 403 test for annotate_save_one (issues #11, #13)
- Fix test_non_integer_dataset_index to expect 400, not 500 (issue #12)
- Fix image_004.dm3 fixture location from image_003.dm3 (issue #8)
- Add CUSTOM_NAME to test_settings.py and core_main_app_data_detail
  stub URL so annotate.html renders cleanly in tests

Frontend:
- updateActivityCounts() now syncs window.__nxActivities and rebuilds
  the move dropdown so counts are not stale after moves (issue #7)
- Replace fragile CSRF cookie regex with {{ csrf_token }} in detail.html
  (issue #6)
- Remove dead .sortable-drag-over CSS rule (SortableJS never applies it;
  ghostClass is used instead) (issue #9)
- Move inline <style> blocks from annotate.html and detail.html into
  annotate.css
- Reset saving flag on pageshow to handle failed mid-flight POSTs
- Remove unused default_auto_field from apps.py (no models in this app)
Python negative indexing caused values like -1 to bypass the upper-bound
check and silently move the wrong dataset. Now validates that datasetIndex
is a non-negative integer before processing.
If the moves POST param decoded to a non-list JSON value (e.g. an integer
or object), _apply_moves would raise TypeError, triggering the outer
exception handler and returning a 500 while discarding the user's
description edits. Add an isinstance check to reset moves to [] and log
a warning instead.
- Shift-clicking a checkbox selects all cards between the last clicked
  and the current one, matching the clicked card's checked state
- Dragging a selected card now brings all other selected cards along to
  the same destination activity, then clears the selection
- Update help text and modal to document both new interactions
…g ghost

- Add mousedown handler that calls preventDefault when shift is held over
  a checkbox label, suppressing browser text selection without affecting
  the checkbox toggle
- Add onStart callback to SortableJS that applies nx-multi-drag class and
  data-multi-count attribute when dragging a selected card with others also
  selected; CSS renders stacked indigo box-shadows and a count badge
- Clean up nx-multi-drag class/attribute in onEnd regardless of drop target
…laceholder

Defer badge DOM injection to setTimeout(0) so it runs after SortableJS
clones the element for the ghost placeholder. The nx-multi-drag class is
still added synchronously so both the placeholder and dragged element get
the stacked box-shadow, but only the dragged element receives the
"N selected" badge span.
- Replace favicon.png with the updated diffraction-pattern icon
- Add icon.png and icon.svg as source assets
- Point admin base.html favicon at nexuslims/img/favicon.png to match
  the main theme (was referencing the old img/favicon.png path)
- Update XSL loading spinner from /static/img/logo_bare.png to
  /static/nexuslims/img/favicon.png
…erms of Use page

- Replace static PNG loading spinner in detail_stylesheet.xsl with an
  inline SVG using the NexusLIMS icon geometry (two counter-rotating
  hexagonal rings of circles). Use xsl:attribute for viewBox to avoid
  HTML serializer lowercasing.
- Update loading.css to animate the two SVG groups (#nx-spin-inner /
  #nx-spin-outer) with counter-rotating keyframe animations.
- Add setup_terms_of_use() to init_environment.py (Step 6) that inserts
  a default Terms of Use page into the WebPage model on first run,
  skipping if non-empty content already exists.
- Add template override at nexuslims_overrides/templates/core_website_app/
  user/terms-of-use.html with scoped CSS: centered 720px column, compact
  heading/paragraph spacing, lead style for first paragraph, and a proper
  page title h2.
- Document Terms of Use customization in CUSTOMIZATION.md.
@jat255 jat255 merged commit 9587a4f into main Mar 29, 2026
2 checks passed
@jat255 jat255 deleted the feat/annotator-app branch March 29, 2026 05:02
jat255 added a commit that referenced this pull request Mar 29, 2026
The Dockerfile was missing a COPY directive for the nexuslims_annotate
app added in #16, causing Django to fail on startup with
ModuleNotFoundError: No module named 'nexuslims_annotate'.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant