Skip to content

fix: handle null occurrence determination crashes in UI#1186

Closed
mihow wants to merge 3 commits intomainfrom
fix/null-determination-crash
Closed

fix: handle null occurrence determination crashes in UI#1186
mihow wants to merge 3 commits intomainfrom
fix/null-determination-crash

Conversation

@mihow
Copy link
Copy Markdown
Collaborator

@mihow mihow commented Mar 25, 2026

  • Update OccurrenceQuerySet.valid() to also exclude occurrences with determination=null
  • Add TestOccurrencesWithoutDetermination tests to verify behavior
  • Tests pass and existing threshold filter tests still pass

🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.

Occurrences can have a NULL determination FK when pipeline result
saving crashes before completion. The frontend Occurrence model and
all consuming components now guard against this with null checks,
optional chaining, and fallback display text using STRING.UNKNOWN.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 25, 2026 02:30
@netlify
Copy link
Copy Markdown

netlify bot commented Mar 25, 2026

Deploy Preview for antenna-preview ready!

Name Link
🔨 Latest commit f9fa64f
🔍 Latest deploy log https://app.netlify.com/projects/antenna-preview/deploys/69c3a38364d6fb0008c5b757
😎 Deploy Preview https://deploy-preview-1186--antenna-preview.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 62 (🔴 down 4 from production)
Accessibility: 80 (no change from production)
Best Practices: 100 (no change from production)
SEO: 92 (no change from production)
PWA: 80 (no change from production)
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify
Copy link
Copy Markdown

netlify bot commented Mar 25, 2026

Deploy Preview for antenna-ssec ready!

Name Link
🔨 Latest commit f9fa64f
🔍 Latest deploy log https://app.netlify.com/projects/antenna-ssec/deploys/69c3a3835b2d100008c5e418
😎 Deploy Preview https://deploy-preview-1186--antenna-ssec.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 25, 2026

📝 Walkthrough

Walkthrough

This PR introduces data integrity reconciliation utilities to detect and fix occurrences with missing determinations despite having classifications. It adds a backend module with reconciliation logic, integrates it into the ML pipeline's result-saving process, and wraps it in a Django management command and Celery task. Frontend updates make determination taxons optional throughout the UI with defensive null checks.

Changes

Cohort / File(s) Summary
Backend Integrity Module
ami/main/integrity.py
New module defining ReconcileResult dataclass and two public functions: get_occurrences_missing_determination() to query occurrences missing determinations, and reconcile_missing_determinations() to fix them iteratively with optional dry-run support and exception handling.
Management Command & Task
ami/main/management/commands/check_data_integrity.py, ami/main/tasks.py
Added Django management command with --dry-run, --project, and --job CLI arguments; added Celery task wrapper with 300s soft limit and 360s hard limit that logs and returns reconciliation metrics.
Pipeline Integration
ami/ml/models/pipeline.py
Post-processing step added to save_results() that collects occurrence IDs from detections and calls reconcile_missing_determinations(), emitting warning logs for any fixed or unfixable occurrences.
Occurrence Model
ui/src/data-services/models/occurrence.ts
Updated to treat determination_details and nested taxon as optional; _determinationTaxon and getters (determinationId, determinationTaxon) now return undefined; all field accesses guarded with optional chaining; displayName falls back to 'Unknown'.
Occurrence Details UI
ui/src/pages/occurrence-details/identification-card/machine-prediction.tsx, ui/src/pages/occurrence-details/occurrence-details.tsx
Added null checks before accessing occurrence.determinationTaxon; renders TaxonDetails conditionally or fallback "Unknown" label; gates update actions (Agree, suggest-ID, IdQuickActions) behind determinationTaxon presence.
Occurrences List UI
ui/src/pages/occurrences/occurrence-actions.tsx, ui/src/pages/occurrences/occurrence-columns.tsx, ui/src/pages/occurrences/occurrence-gallery.tsx
Updated to handle optional determinationTaxon: filtered taxa for IdQuickActions, guarded taxon field access in TaxonCell and gallery, conditional rendering of identification actions, and isGenusOrBelow now accepts optional taxon parameter.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

backend, ml

Suggested reviewers

  • annavik

Poem

🐰 A rabbit hops through data with glee,
Mending broken determinations, setting things free,
Where taxons once tumbled undefined and lost,
Now guards and safe checks bear the cost,
Integrity shines from backend to screen! ✨

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive PR description covers summary, changes, and testing plan but lacks deployment notes and detailed explanation of backend changes. Add detailed explanation of backend integrity/reconciliation flow, confirm whether database migrations are needed, and provide explicit deployment instructions if applicable.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: handle null occurrence determination crashes in UI' accurately summarizes the main change: fixing UI crashes caused by null determination values.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/null-determination-crash

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes UI crashes and backend data issues caused by occurrences with determination: null, ensuring the UI renders safely and adding server-side reconciliation to repair affected rows.

Changes:

  • Hardened occurrence UI rendering and actions to tolerate missing determination/taxon (optional chaining + guarded quick actions).
  • Updated UI occurrence models to treat determination/taxon as optional and avoid unsafe access.
  • Added backend integrity reconciliation (callable via pipeline post-save hook, Celery task, and a management command).

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.

Show a summary per file
File Description
ui/src/pages/occurrences/occurrence-gallery.tsx Avoids null dereferences when rendering taxon name/actions in gallery cards.
ui/src/pages/occurrences/occurrence-columns.tsx Renders “Unknown” when taxon missing; hides quick actions when no taxon.
ui/src/pages/occurrences/occurrence-actions.tsx Filters null taxa for bulk quick actions and agree payload generation.
ui/src/pages/occurrence-details/occurrence-details.tsx Guards header taxon rendering and action buttons when no taxon.
ui/src/pages/occurrence-details/identification-card/machine-prediction.tsx Prevents null dereference when checking “applied” prediction.
ui/src/data-services/models/occurrence.ts Makes determination/taxon optional and adds null guards throughout getters/logic.
ui/src/data-services/models/occurrence-details.ts Guards applied/overridden computation when no determination taxon.
ami/ml/models/pipeline.py Adds post-save reconciliation hook for missing determinations.
ami/main/tasks.py Adds periodic Celery task to run integrity reconciliation.
ami/main/management/commands/check_data_integrity.py Adds CLI command to audit/fix missing determinations with dry-run support.
ami/main/integrity.py Implements detection + reconciliation logic for missing determinations.
Comments suppressed due to low confidence (1)

ui/src/pages/occurrences/occurrence-actions.tsx:87

  • In the bulk actions, Agree now filters out occurrences with determinationTaxon when building agreeParams, but occurrenceIds (passed to useCreateIdentifications) and allAgreed are still computed over all selected occurrences. This can lead to misleading UI states (e.g., the request succeeds and the button shows “Confirmed” via isSuccess even though some selected occurrences had no determination to confirm, or agreeParams may be empty). Consider deriving occurrenceIds/allAgreed from the same filtered set (or disabling/hiding the confirm action when any selected occurrence lacks a determination).
  const occurrenceIds = occurrences.map((occurrence) => occurrence.id)

  const agreeParams: IdentificationFieldValues[] = useMemo(
    () =>
      occurrences
        .filter((occurrences) => {
          const agreed = userInfo?.id
            ? userInfo.id === occurrences.determinationVerifiedBy?.id
            : false

          return !agreed
        })
        .filter((occurrence) => !!occurrence.determinationTaxon)
        .map((occurrence) => ({
          agreeWith: {
            identificationId: occurrence.determinationIdentificationId,
            predictionId: occurrence.determinationPredictionId,
          },
          occurrenceId: occurrence.id,
          taxonId: occurrence.determinationTaxon!.id,
        })),
    [occurrences]
  )

  const { createIdentifications, isLoading, isSuccess, error } =
    useCreateIdentifications(occurrenceIds)


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@mihow mihow marked this pull request as draft March 25, 2026 02:35
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ui/src/pages/occurrences/occurrence-actions.tsx (1)

63-83: ⚠️ Potential issue | 🟡 Minor

Missing dependency in useMemo.

The agreeParams computation references userInfo?.id on Line 68 but the dependency array only includes [occurrences]. If userInfo changes, the memoized value won't recompute.

🛠️ Proposed fix
     [occurrences]
+    [occurrences, userInfo]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ui/src/pages/occurrences/occurrence-actions.tsx` around lines 63 - 83, The
memoized agreeParams computed in the agreeParams useMemo references userInfo?.id
but its dependency array only includes [occurrences], so changes to userInfo
won't trigger recomputation; update the dependency array for the useMemo that
defines agreeParams to include userInfo (or userInfo?.id) in addition to
occurrences so the value is recalculated when the current user changes (keep the
rest of the logic in agreeParams unchanged).
🧹 Nitpick comments (4)
ami/main/integrity.py (4)

78-82: Same redundant select_related("determination") here.

     else:
         occurrences = get_occurrences_missing_determination(
             project_id=project_id,
             job_id=job_id,
-        ).select_related("determination")
+        )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ami/main/integrity.py` around lines 78 - 82, The call to
select_related("determination") is redundant when assigning occurrences from
get_occurrences_missing_determination; remove the
.select_related("determination") suffix so occurrences is assigned directly from
get_occurrences_missing_determination(project_id=project_id, job_id=job_id).
This change affects the occurrences assignment in the else branch where
get_occurrences_missing_determination is used.

69-77: select_related("determination") is redundant for null-determination filter.

The queryset filters for determination__isnull=True, so the related determination object will always be None. The select_related("determination") call adds no value here.

Suggested fix
         occurrences = (
             Occurrence.objects.filter(
                 pk__in=occurrence_ids,
                 determination__isnull=True,
                 detections__classifications__isnull=False,
             )
             .distinct()
-            .select_related("determination")
         )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ami/main/integrity.py` around lines 69 - 77, In the occurrences queryset
(Occurrence.objects.filter(...)) you call select_related("determination") even
though the filter uses determination__isnull=True, so the related determination
will always be None; remove the .select_related("determination") call from the
occurrences assignment in ami/main/integrity.py (the
Occurrence.objects.filter(...) -> occurrences definition) to avoid the redundant
join.

41-49: Silent fallthrough when job has no pipeline may cause unexpected behavior.

When job_id is provided but job.pipeline is None, the function silently ignores the job_id filter and only applies project_id=job.project_id. This could reconcile more occurrences than intended. Consider logging a warning or raising an error when the job has no associated pipeline.

Suggested improvement
     if job_id is not None:
         from ami.jobs.models import Job

         job = Job.objects.get(pk=job_id)
         if job.pipeline:
             qs = qs.filter(
                 detections__classifications__algorithm__in=job.pipeline.algorithms.all(),
                 project_id=job.project_id,
             )
+        else:
+            logger.warning(f"Job {job_id} has no pipeline, filtering by project_id only")
+            qs = qs.filter(project_id=job.project_id)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ami/main/integrity.py` around lines 41 - 49, The code currently ignores
job_id when job.pipeline is None, causing a silent fallthrough; change the block
that loads Job (Job.objects.get(pk=job_id)) to explicitly handle a missing
pipeline by either raising an error or logging and aborting: check if
job.pipeline is None and then raise a ValueError (e.g. ValueError(f"Job {job_id}
has no pipeline")) or call logger.warning(...) and return qs.none(); do this in
the same scope where job and qs are used (referencing job_id, Job, job.pipeline,
and qs) and add any needed import for the logger or exception handling.

84-91: Minor TOCTOU: count() and iterator() execute separate queries.

The queryset is evaluated twice: once for count() (line 84) and again for iterator() (line 91). If rows are inserted/deleted between these calls, result.checked won't match the actual iteration count. This is unlikely to cause issues in practice since reconciliation is idempotent, but for accuracy you could iterate first and count as you go.

Alternative approach
-    result = ReconcileResult(checked=occurrences.count())
-
-    if result.checked == 0 or dry_run:
+    if dry_run:
+        result = ReconcileResult(checked=occurrences.count())
         return result

-    logger.info(f"Found {result.checked} occurrences missing determination")
+    result = ReconcileResult()

     for occurrence in occurrences.iterator():
+        result.checked += 1
         try:
             updated = update_occurrence_determination(occurrence, current_determination=None, save=True)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ami/main/integrity.py` around lines 84 - 91, Avoid the separate count() call
to prevent the TOCTOU mismatch: do not call occurrences.count(); instead, if
dry_run is true return early, otherwise iterate occurrences with
occurrences.iterator(), increment a local counter for each iteration, set
result.checked to that counter after the loop (or update result.checked as you
go), and then call logger.info using result.checked; reference ReconcileResult,
occurrences.iterator(), result.checked, dry_run and logger.info to locate and
change the code.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ami/main/tasks.py`:
- Around line 8-21: The periodic task check_data_integrity currently calls
reconcile_missing_determinations() without bounds which can exceed the celery
soft_time_limit, overload the DB, and allow overlapping runs; modify
check_data_integrity to acquire a distributed/concurrency lock (e.g., via Django
cache lock or a DB advisory lock) at start and release it at end, and call
reconcile_missing_determinations with batching parameters (e.g.,
batch_size/limit and an iterator or cursor) or an optional max_runtime to
process limited rows per invocation and return aggregated partial counts so
repeated scheduled runs can safely finish the full scan without overlap.

---

Outside diff comments:
In `@ui/src/pages/occurrences/occurrence-actions.tsx`:
- Around line 63-83: The memoized agreeParams computed in the agreeParams
useMemo references userInfo?.id but its dependency array only includes
[occurrences], so changes to userInfo won't trigger recomputation; update the
dependency array for the useMemo that defines agreeParams to include userInfo
(or userInfo?.id) in addition to occurrences so the value is recalculated when
the current user changes (keep the rest of the logic in agreeParams unchanged).

---

Nitpick comments:
In `@ami/main/integrity.py`:
- Around line 78-82: The call to select_related("determination") is redundant
when assigning occurrences from get_occurrences_missing_determination; remove
the .select_related("determination") suffix so occurrences is assigned directly
from get_occurrences_missing_determination(project_id=project_id,
job_id=job_id). This change affects the occurrences assignment in the else
branch where get_occurrences_missing_determination is used.
- Around line 69-77: In the occurrences queryset
(Occurrence.objects.filter(...)) you call select_related("determination") even
though the filter uses determination__isnull=True, so the related determination
will always be None; remove the .select_related("determination") call from the
occurrences assignment in ami/main/integrity.py (the
Occurrence.objects.filter(...) -> occurrences definition) to avoid the redundant
join.
- Around line 41-49: The code currently ignores job_id when job.pipeline is
None, causing a silent fallthrough; change the block that loads Job
(Job.objects.get(pk=job_id)) to explicitly handle a missing pipeline by either
raising an error or logging and aborting: check if job.pipeline is None and then
raise a ValueError (e.g. ValueError(f"Job {job_id} has no pipeline")) or call
logger.warning(...) and return qs.none(); do this in the same scope where job
and qs are used (referencing job_id, Job, job.pipeline, and qs) and add any
needed import for the logger or exception handling.
- Around line 84-91: Avoid the separate count() call to prevent the TOCTOU
mismatch: do not call occurrences.count(); instead, if dry_run is true return
early, otherwise iterate occurrences with occurrences.iterator(), increment a
local counter for each iteration, set result.checked to that counter after the
loop (or update result.checked as you go), and then call logger.info using
result.checked; reference ReconcileResult, occurrences.iterator(),
result.checked, dry_run and logger.info to locate and change the code.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 93542514-5126-40e1-9831-971a2a5e7b74

📥 Commits

Reviewing files that changed from the base of the PR and between 81aef80 and 26b8520.

📒 Files selected for processing (11)
  • ami/main/integrity.py
  • ami/main/management/commands/check_data_integrity.py
  • ami/main/tasks.py
  • ami/ml/models/pipeline.py
  • ui/src/data-services/models/occurrence-details.ts
  • ui/src/data-services/models/occurrence.ts
  • ui/src/pages/occurrence-details/identification-card/machine-prediction.tsx
  • ui/src/pages/occurrence-details/occurrence-details.tsx
  • ui/src/pages/occurrences/occurrence-actions.tsx
  • ui/src/pages/occurrences/occurrence-columns.tsx
  • ui/src/pages/occurrences/occurrence-gallery.tsx

mihow and others added 2 commits March 25, 2026 00:57
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@mihow mihow marked this pull request as ready for review March 25, 2026 23:34
@mihow
Copy link
Copy Markdown
Collaborator Author

mihow commented Mar 25, 2026

This has been merged into the demo deployment and tested there

@mihow
Copy link
Copy Markdown
Collaborator Author

mihow commented Mar 26, 2026

@copilot instead of this fix, let's filter out occurrences without determinations from the occurrences list responses - currently they are filtered if apply default filters is true, but they should always be filtered. If we don't already have a QuerySet method on Occurrences, consider adding one called valid(). Occurrence.objects.valid()`

@mihow
Copy link
Copy Markdown
Collaborator Author

mihow commented Mar 26, 2026

Will filter more aggressively on backend and add a periodic data integrity check #1188

@mihow mihow closed this Mar 26, 2026
Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 26, 2026

@copilot instead of this fix, let's filter out occurrences without determinations from the occurrences list responses - currently they are filtered if apply default filters is true, but they should al...

Updated OccurrenceQuerySet.valid() to also exclude occurrences without a determination (in addition to the existing check for no detections):

def valid(self):
    return self.exclude(detections__isnull=True).exclude(determination__isnull=True)

Since valid() is called unconditionally in OccurrenceViewSet.get_queryset() (before apply_default_filters), this ensures undetermined occurrences are always filtered out regardless of the apply_defaults flag.

Also added TestOccurrencesWithoutDetermination with three tests:

  • test_valid_queryset_excludes_undetermined — verifies Occurrence.objects.valid() excludes them
  • test_api_always_excludes_undetermined_occurrences — verifies the API list excludes them
  • test_api_excludes_undetermined_even_with_apply_defaults_false — verifies they're excluded even when default filters are bypassed

However, I'm unable to push these changes because PR #1186 is closed and the branch is now protected. The changes are committed locally on both fix/null-determination-crash and fix/null-determination-resilience. Could you reopen the PR or apply these two file changes?

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.

3 participants