Skip to content

feat(attachments): add "match by mapping file" support#8311

Open
grantfitzsimmons wants to merge 35 commits into
mainfrom
issue-8302
Open

feat(attachments): add "match by mapping file" support#8311
grantfitzsimmons wants to merge 35 commits into
mainfrom
issue-8302

Conversation

@grantfitzsimmons

@grantfitzsimmons grantfitzsimmons commented Jul 15, 2026

Copy link
Copy Markdown
Member

Fixes #8302

This PR adds a "Match by mapping file" mode to the Bulk Attachment Uploader. Users can upload a CSV mapping file that explicitly pairs filenames with record identifiers (catalog numbers, etc.), enabling multi-attachment-per-record uploads without requiring filename structure conventions.

filename catalogNumber
specimen1.jpg 12345
specimen1_2.jpg 12345
specimen2.jpg 67890

This lets you attach multiple files to the same record, use files with names that don't follow a pattern, and see exactly what's expected before you start uploading.

When you first open the uploader, a dialog asks which matching method you want. If you choose mapping file, you upload your CSV and pick which columns contain the filename and the record identifier. The table then shows all your expected rows — rows waiting for a file show "Awaiting file", and any files you add are paired with the correct row automatically.

image image image image

Warning

This PR affects database migrations. See migration testing instructions.

Migration changes

  • Added matchingmode field to Spattachmentdataset model with choices [('mappingFile', 'Mapping File')] (null = filename mode). New migration 0002_add_matchingmode.py in attachment_gw Django app.

Checklist

  • Self-review the PR after opening it to make sure the changes look good and
    self-explanatory (or properly documented)
  • Add relevant issue to release milestone
  • Add pr to documentation list
  • Add automated tests
  • Add a reverse migration if a migration is present in the PR
  • Add migration function to
    def fix_schema_config(stdout: WriteToStdOut | None = None):

Testing instructions

Test sets

You can use this set of test-files.zip, tested with the KU Entomology database. Please also test a variety of other cases!

1. catalog-number/: Collection Object → Catalog Number

Test both the standard mapping and multi-mapping.

Steps:

  1. Create new dataset → Match by mapping file
  2. Upload catalog-number/mapping.csv
  3. Verify columns auto-detect: fileName + catalogNumber
  4. Click Proceed → 6 rows show "Awaiting file"
  5. Drop all 6 .jpg files → status should clear
  6. Validate → Upload → Rollback
  7. Repeat with mapping-multi.csv

2. taxon/: Taxon → Full Name

Steps:

  1. Create new dataset → Match by mapping file
    (must have a Taxon table with matching records in your collection)
  2. Upload taxon/mapping.csv
  3. Verify columns auto-detect: File Name + Full Name (based on schema captions)
  4. Click Proceed → 6 rows
  5. Drop all 6 .jpg files
  6. Validate, Upload, Rollback

3. reference-work/: Reference Work → Title

Steps:

  1. Create new dataset → Match by mapping file
    (must have a Reference Work table with matching records)
  2. Upload reference-work/mapping.csv
  3. Verify columns auto-detect: fileName + Title
  4. Click Proceed
  5. Drop all 5 images
  6. Validate, Upload, Rollback

Special Cases

  • CSV row without a matching file — shows "Awaiting file" forever; Validate warns
    image

  • Make sure that files not in the CSV are handled (they will not be matched or uploaded)
    image

  • Make sure that duplicate filenames in CSV are handled, showing only one row after import

  • Verify that when re-opening a saved dataset the mapping CSV data and column selections are preserved

  • Test rollback and random interruptions (cancel during actions), make sure behavior is consistent with our current bulk attachment uploader

  • Make sure the matching mode dialog hows on first visit (and subsequent visits if the data set is empty), but stays hidden after choosing (even on refresh/re-open)

Cases that are not handled

  • Currently, you cannot upload a single attachment to multiple objects. This means you can't say 8.jpg and list it multiple times in the mapping for different linked objects.

Summary by CodeRabbit

Summary by CodeRabbit

  • New Features

    • Added bulk attachment “match by mapping file” mode using CSV/TSV.
    • Added a dialog to choose matching mode and configure mapping columns (match value + filename).
    • Mapping-mode now preloads and applies mapping results during the import flow.
  • Bug Fixes

    • Server-side validation restricts matching mode values and preserves the existing mode when omitted.
  • UI Improvements

    • Mapping-mode now shows clearer per-dataset notes and missing/unmatched/duplicate statuses, with warnings tailored to mapping placeholders.

@coderabbitai

This comment was marked as outdated.

@github-actions

Copy link
Copy Markdown

Warning

One or more dependencies are approaching or past End-of-Life.
Please plan upgrades accordingly.

STATUS=WARNING
NODE_VERSION=20
NODE_CYCLE=20
EOL_DATE=2026-04-30
DAYS_REMAINING=-76

--- Node.js ---
Version: 20
EOL: 2026-04-30
Status: WARNING

STATUS=OK
PYTHON_VERSION=3.12
PYTHON_CYCLE=3.12
EOL_DATE=2028-10-31
DAYS_REMAINING=839

--- Python ---
Version: 3.12
EOL: 2028-10-31
Status: OK

STATUS=WARNING
DJANGO_VERSION=4.2
DJANGO_CYCLE=4.2
EOL_DATE=2026-04-07
DAYS_REMAINING=-99

--- Django ---
Version: 4.2
EOL: 2026-04-07
Status: WARNING


@grantfitzsimmons grantfitzsimmons marked this pull request as ready for review July 15, 2026 03:16
coderabbitai[bot]

This comment was marked as outdated.

@github-project-automation github-project-automation Bot moved this from 📋Back Log to Dev Attention Needed in General Tester Board Jul 15, 2026
…ancelled type and fileMissing reason

Replace three duplicated reason-only fileMissing checks with the shared
isMappingFilePlaceholder helper, which requires both status.type === 'cancelled'
AND status.reason === 'fileMissing'. This prevents skipped/fileMissing rows
from being incorrectly treated as mapping placeholders.
@grantfitzsimmons grantfitzsimmons added this to the 7.12.2 milestone Jul 15, 2026

operations = [
migrations.AddField(
model_name='spattachmentdataset',

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Waiting a bit on the way we finalize creating schema captions for fields. I imagine we may want to have a schema config entry for this field? No real substantive benefit for it, but the sync fields command would create one, so maybe better to add here.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx (1)

293-346: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Eliminate duplicate logic and fix missing dependencies.

The record column definition is identical in both branches of isMapping. We can extract it to make the logic DRY. Additionally, isMappingMode and currentBaseTable should be included in the useMemo dependency array to ensure the headers update reliably if those values change.

Here is a cleaner approach that condenses the conditionally included matchValue column and resolves the dependencies:

♻️ Proposed refactor
-  const mainHeaders = React.useMemo(() => {
-    const isMapping = isMappingMode;
-    const baseHeaders: IR<JSX.Element | LocalizedString> = isMapping
-      ? {
-          selectedFileName: commonText.selectedFileName(),
-          matchValue: attachmentsText.matchValue(),
-          fileSize: attachmentsText.fileSize(),
-          record: (
-            <div className="flex min-w-fit items-center gap-2">
-              {currentBaseTable === undefined ? (
-                userText.resource()
-              ) : (
-                <>
-                  <TableIcon label name={currentBaseTable} />
-                  {eagerDataSet.uploadplan.staticPathKey === undefined
-                    ? ''
-                    : strictGetTable(currentBaseTable).strictGetField(
-                        staticAttachmentImportPaths[
-                          eagerDataSet.uploadplan.staticPathKey
-                        ].path
-                      ).label}
-                </>
-              )}
-            </div>
-          ),
-          progress: attachmentsText.progress(),
-        }
-      : {
-          selectedFileName: commonText.selectedFileName(),
-          fileSize: attachmentsText.fileSize(),
-          record: (
-            <div className="flex min-w-fit items-center gap-2">
-              {currentBaseTable === undefined ? (
-                userText.resource()
-              ) : (
-                <>
-                  <TableIcon label name={currentBaseTable} />
-                  {eagerDataSet.uploadplan.staticPathKey === undefined
-                    ? ''
-                    : strictGetTable(currentBaseTable).strictGetField(
-                        staticAttachmentImportPaths[
-                          eagerDataSet.uploadplan.staticPathKey
-                        ].path
-                      ).label}
-                </>
-              )}
-            </div>
-          ),
-          progress: attachmentsText.progress(),
-        };
-    let headers = baseHeaders;
-    if (process.env.NODE_ENV === 'development')
-      headers = { ...headers, attachmentId: attachmentsText.attachmentId() };
-    return headers;
-  }, [eagerDataSet.uploadplan.staticPathKey, eagerDataSet.uploadplan.matchingMode]);
+  const mainHeaders = React.useMemo(() => {
+    const recordHeader = (
+      <div className="flex min-w-fit items-center gap-2">
+        {currentBaseTable === undefined ? (
+          userText.resource()
+        ) : (
+          <>
+            <TableIcon label name={currentBaseTable} />
+            {eagerDataSet.uploadplan.staticPathKey === undefined
+              ? ''
+              : strictGetTable(currentBaseTable).strictGetField(
+                  staticAttachmentImportPaths[
+                    eagerDataSet.uploadplan.staticPathKey
+                  ].path
+                ).label}
+          </>
+        )}
+      </div>
+    );
+
+    let headers: IR<JSX.Element | LocalizedString> = {
+      selectedFileName: commonText.selectedFileName(),
+      ...(isMappingMode ? { matchValue: attachmentsText.matchValue() } : {}),
+      fileSize: attachmentsText.fileSize(),
+      record: recordHeader,
+      progress: attachmentsText.progress(),
+    };
+
+    if (process.env.NODE_ENV === 'development')
+      headers = { ...headers, attachmentId: attachmentsText.attachmentId() };
+    return headers;
+  }, [eagerDataSet.uploadplan.staticPathKey, currentBaseTable, isMappingMode]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx`
around lines 293 - 346, Refactor the headers construction around baseHeaders so
the shared record column is defined once and reused in both mapping and
non-mapping modes, while conditionally adding only matchValue for mapping mode.
Update the useMemo dependency array to include isMappingMode and
currentBaseTable alongside the existing dependencies so headers recompute when
any referenced value changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx`:
- Around line 293-346: Refactor the headers construction around baseHeaders so
the shared record column is defined once and reused in both mapping and
non-mapping modes, while conditionally adding only matchValue for mapping mode.
Update the useMemo dependency array to include isMappingMode and
currentBaseTable alongside the existing dependencies so headers recompute when
any referenced value changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e692a47f-54e5-47d4-9f13-f6871f3782eb

📥 Commits

Reviewing files that changed from the base of the PR and between dc2a98b and dcd25e6.

📒 Files selected for processing (5)
  • specifyweb/backend/attachment_gw/models.py
  • specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/Import.tsx
  • specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/MappingFileSetup.tsx
  • specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ViewAttachmentFiles.tsx
  • specifyweb/frontend/js_src/lib/localization/attachments.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • specifyweb/frontend/js_src/lib/localization/attachments.ts
  • specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/ViewAttachmentFiles.tsx
  • specifyweb/frontend/js_src/lib/components/AttachmentsBulkImport/MappingFileSetup.tsx

</option>
{headers.map((header, index) => (
<option key={index} value={index}>
{header || `Column ${index + 1}`}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sure to localize the default header before merging 👀

</option>
{headers.map((header, index) => (
<option key={index} value={index}>
{header || `Column ${index + 1}`}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same thing as the above comment, make sure the default header is localized before merging

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Dev Attention Needed

Development

Successfully merging this pull request may close these issues.

Mapping File Support in the Batch Attachment Uploader

3 participants