Skip to content

Fix Deleting Attachments Via API#8073

Open
Iwantexpresso wants to merge 6 commits into
mainfrom
issue-6859
Open

Fix Deleting Attachments Via API#8073
Iwantexpresso wants to merge 6 commits into
mainfrom
issue-6859

Conversation

@Iwantexpresso
Copy link
Copy Markdown
Contributor

@Iwantexpresso Iwantexpresso commented May 11, 2026

Fixes #6859

Fixed an error that was caused by the delete_obj function attempting to audit an access obj.id's that were already deleted. This happened when attempting to delete an attachment or {table}attachment record via the API (both via direct API delete and using the "delete" button on the data entry form).

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

Testing instructions

GUI deletion:

  • Create a collection object with an attachment and save it.
  • Delete the attachment in a new tab through the form meta menu share link
  • Make sure the deletion is successful and no errors are raised through this deletion method

API deletion:

  • Create a collection object with an attachment and save it.
  • Create a query to retrieve the Collection object attachment ID.
    A query like this should help you get it:
image
  • Use the COA ID to Delete the attachment through the tables API page by using the DELETE request for Collectionobjectattachment( this page https://your specify instance/documentation/api/tables/)

  • Make sure the deletion is successful and no errors are raised for this deletion method

Summary by CodeRabbit

  • Bug Fixes
    • Pre-delete handlers (when present) are now invoked consistently before an item is removed.
    • Items are deleted immediately during cleanup to avoid stale state or access to removed targets.
    • Recursive deletions now cache dependent object identifiers before recursing, preventing attempts to access dependents after they’ve been removed.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 11, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9a6d75cc-0ed1-4456-a51d-29c2be737333

📥 Commits

Reviewing files that changed from the base of the PR and between e51d2b2 and 234b78d.

📒 Files selected for processing (1)
  • specifyweb/specify/api/crud.py

📝 Walkthrough

Walkthrough

delete_obj now invokes deleter(obj, parent_obj) only when provided, calls obj.delete() before processing dependents, and for dependent-to-one relations caches each dependent's id and only recurses when that cached id is not None; recursive calls forward version, parent_obj=obj, and clean_predelete.

Changes

Deletion Flow Safety

Layer / File(s) Summary
Deletion with ID caching
specifyweb/specify/api/crud.py
delete_obj invokes deleter(obj, parent_obj) only when present, then calls obj.delete() prior to handling dependents. For dependent-to-one deletions it caches each dependent's id and only recurses into delete_obj when the cached id is not None, forwarding version, parent_obj=obj, and clean_predelete.
🚥 Pre-merge checks | ✅ 4 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Automatic Tests ⚠️ Warning PR explicitly states automated tests are not yet added. The fix involves critical API deletion logic for attachments that should have automated tests to prevent regression of issue #6859. Add automated tests covering attachment/CollectionObjectAttachment deletion via API (both direct and form deletion workflows) to verify the fix resolves the null-id audit log and ProtectedError issues.
Testing Instructions ⚠️ Warning The PR testing instructions section is empty; only placeholder comments exist. PR objectives claim instructions were provided, but they are absent from the template. Add testing instructions covering: GUI & API deletion workflows, how to obtain COA ID, verification that attachment files delete, and test both CollectionObjectAttachment and Attachment deletions.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly relates to the main change: fixing API deletion of attachments by modifying the delete_obj function's handling of object deletion and recursion.
Linked Issues check ✅ Passed The code changes address the core requirements from issue #6859 by reordering deletion logic to delete the object before recursive dependent deletions, preventing null ID audit log errors.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing attachment deletion logic in delete_obj; no out-of-scope modifications detected.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-6859

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.

@Iwantexpresso Iwantexpresso requested review from a team May 20, 2026 18:03
Copy link
Copy Markdown
Collaborator

@emenslin emenslin left a comment

Choose a reason for hiding this comment

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

  • Make sure the deletion is successful and no errors are raised through this deletion method
  • Make sure the deletion is successful and no errors are raised for this deletion method

Looks good, I was able to delete attachments without any errors

@emenslin emenslin requested a review from a team May 20, 2026 19:58
Copy link
Copy Markdown
Collaborator

@bhumikaguptaa bhumikaguptaa left a comment

Choose a reason for hiding this comment

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

GUI deletion:

  • Make sure the deletion is successful and no errors are raised through this deletion method

API deletion:

  • Make sure the deletion is successful and no errors are raised for this deletion method

Works as expected, ran into no errors.

Image

Copy link
Copy Markdown
Contributor

@alesan99 alesan99 left a comment

Choose a reason for hiding this comment

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

  • Make sure the deletion is successful and no errors are raised through this deletion method
  • Make sure the deletion is successful and no errors are raised for this deletion method

The fix works, nice job! 👍

I just have some minor code comments

def delete_obj(obj, deleter: Callable[[Any, Any], None] | None=None, version=None, parent_obj=None, clean_predelete=None) -> None:
# need to delete dependent -to-one records
# e.g. delete CollectionObjectAttribute when CollectionObject is deleted
# but have to delete the referring record first
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.

I think these comments should be kept

Comment thread specifyweb/specify/api/crud.py Outdated
obj_id = obj.id

if deleter:
if deleter and (obj_id is not None):
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.

From my testing, I think the fix works without this line? (And without the obj_id definition earlier). I may be wrong though.

Comment thread specifyweb/specify/api/crud.py Outdated
delete_obj(dep, deleter, version, parent_obj=obj, clean_predelete=clean_predelete)
dep_id = dep.id
if dep_id is not None:
delete_obj(dep, deleter, version, parent_obj=obj, clean_predelete=clean_predelete)
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, but the indentation is off here and in the comment before.

@github-project-automation github-project-automation Bot moved this from 📋Back Log to Dev Attention Needed in General Tester Board May 21, 2026
… its not non as its redundants, Fixed indentaiton on delete
Copy link
Copy Markdown

@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

♻️ Duplicate comments (1)
specifyweb/specify/api/crud.py (1)

185-188: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't propagate the parent's version into recursive dependent deletions.

version belongs to the top-level object being deleted (used by bump_version for optimistic locking). Passing it down means each dependent runs bump_version(dep, parent_version), which compares parent_version against the dependent's own version column. Unless they happen to coincide, manager.filter(pk=dep.pk, version=parent_version).update(...) returns 0 and you'll get a StaleObjectException on otherwise valid cascades (e.g. Attachment when deleting a {Table}Attachment).

The previous behavior of recursing without version was correct — dependents should not participate in the caller-driven optimistic-lock check.

🛠️ Suggested fix
     for dep in dependents_to_delete:
         dep_id = dep.id
         if dep_id is not None:
-            delete_obj(dep, deleter, version, parent_obj=obj, clean_predelete=clean_predelete)
+            delete_obj(dep, deleter, parent_obj=obj, clean_predelete=clean_predelete)
🤖 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/specify/api/crud.py` around lines 185 - 188, The recursive
deletion is incorrectly passing the caller's top-level `version` into dependent
deletions, causing dependent `bump_version` checks to compare the wrong version;
update the call site in the loop over `dependents_to_delete` (where
`delete_obj(dep, deleter, version, parent_obj=obj,
clean_predelete=clean_predelete)` is invoked) to omit the `version` argument so
dependents are deleted with their own version checks (i.e., call `delete_obj`
without forwarding `version`), leaving the top-level `version` handling intact
for the originally requested object.
🧹 Nitpick comments (1)
specifyweb/specify/api/crud.py (1)

175-184: 💤 Low value

Clean up the debug-era commented code and stale "temporary" note before merge.

Once the deleter invocation is restored with the correct null-id guard (see the critical comment), the "temporary disabled to tests its necessity…" note and the commented-out lines become dead weight. Removing them now keeps the function readable and avoids future confusion about whether the disablement was intentional.

🤖 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/specify/api/crud.py` around lines 175 - 184, Remove the leftover
debug comment block and stale "temporary disabled..." note and restore the
original deleter invocation: capture obj.id into a local obj_id before calling
obj.delete(), then call deleter(obj, parent_obj) only when deleter is truthy and
obj_id is not None (e.g., use the guard that was commented out). Ensure the
preceding explanatory comment about storing dependent ids is updated or removed
so the block around obj.delete(), obj_id, deleter, and parent_obj is concise and
clear.
🤖 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.

Inline comments:
In `@specifyweb/specify/api/crud.py`:
- Around line 175-182: Re-enable the deleter call in delete_obj so permission
checks and audit logging are preserved: if a deleter (from make_default_deleter)
exists, invoke deleter(obj, parent_obj) guarded by obj.id is not None and do it
before calling obj.delete(); this ensures check_table_permissions(..., "delete")
and auditlog.remove(...) run with a live id and avoids the null-id assertion on
cascaded dependents.

---

Duplicate comments:
In `@specifyweb/specify/api/crud.py`:
- Around line 185-188: The recursive deletion is incorrectly passing the
caller's top-level `version` into dependent deletions, causing dependent
`bump_version` checks to compare the wrong version; update the call site in the
loop over `dependents_to_delete` (where `delete_obj(dep, deleter, version,
parent_obj=obj, clean_predelete=clean_predelete)` is invoked) to omit the
`version` argument so dependents are deleted with their own version checks
(i.e., call `delete_obj` without forwarding `version`), leaving the top-level
`version` handling intact for the originally requested object.

---

Nitpick comments:
In `@specifyweb/specify/api/crud.py`:
- Around line 175-184: Remove the leftover debug comment block and stale
"temporary disabled..." note and restore the original deleter invocation:
capture obj.id into a local obj_id before calling obj.delete(), then call
deleter(obj, parent_obj) only when deleter is truthy and obj_id is not None
(e.g., use the guard that was commented out). Ensure the preceding explanatory
comment about storing dependent ids is updated or removed so the block around
obj.delete(), obj_id, deleter, and parent_obj is concise and clear.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2cb0f7da-88c5-4576-b38c-cece8659cfb0

📥 Commits

Reviewing files that changed from the base of the PR and between 559c66c and e55ddcb.

📒 Files selected for processing (1)
  • specifyweb/specify/api/crud.py

Comment thread specifyweb/specify/api/crud.py Outdated
Copy link
Copy Markdown

@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.

Caution

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

⚠️ Outside diff range comments (1)
specifyweb/specify/api/crud.py (1)

157-182: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Guard the unguarded deleter(obj, parent_obj) call in delete_obj

delete_obj still accepts deleter: ... | None = None, but it calls deleter(obj, parent_obj) unconditionally. There’s an in-tree caller that omits deleter entirely—specifyweb/backend/merge/record_merging.py calls delete_obj(..., clean_predelete=...) only—so this will raise TypeError: 'NoneType' object is not callable.

🛡️ Suggested fix
-    deleter(obj, parent_obj)
+    if deleter is not None:
+        deleter(obj, parent_obj)
🤖 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/specify/api/crud.py` around lines 157 - 182, The delete_obj
function currently calls deleter(obj, parent_obj) unconditionally even though
deleter may be None; update delete_obj to check whether deleter is provided
before invoking it (e.g., if deleter: deleter(obj, parent_obj)) so callers that
omit the deleter (like record_merging.py) won’t trigger a TypeError; keep the
existing deleter parameter and only invoke the callable when it is not None.
🧹 Nitpick comments (2)
specifyweb/specify/api/crud.py (2)

184-188: 💤 Low value

Tighten the comment and tiny readability nit on the dependents loop.

The fix itself is correct — Django's collector sets pk=None on in-memory instances after cascade delete, so caching dep.id and skipping None cleanly avoids the "null id to audit log" assertion for already-cascaded dependents. Two small touch-ups would make this easier to follow:

  1. The comment says "before delete obj, store dep id" but obj.delete() has already run on line 182. Reword to reflect that obj.delete() may have cascaded the dependents to pk=None.
  2. Consider collapsing the cache/guard into the loop for readability.
♻️ Optional polish
-    # before delete obj, store dep id to avoid accessing deleted obj in recursive delete calls
-    for dep in dependents_to_delete:
-        dep_id = dep.id
-        if dep_id is not None:
-            delete_obj(dep, deleter, version, parent_obj=obj, clean_predelete=clean_predelete)
+    # obj.delete() above may have cascade-deleted dependents, which leaves their
+    # in-memory pk as None. Skip those to avoid the null-id audit assertion.
+    for dep in dependents_to_delete:
+        if dep.pk is None:
+            continue
+        delete_obj(dep, deleter, version, parent_obj=obj, clean_predelete=clean_predelete)
🤖 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/specify/api/crud.py` around lines 184 - 188, Comment and loop are
slightly misleading: obj.delete() may have already cascaded dependents and set
their pk to None, so we should update the comment to reflect that and inline the
id-cache/None-guard for clarity. Change the comment to indicate "after
obj.delete(), dependents may have been cascaded to pk=None" and rewrite the loop
using a single-step pattern that reads dep_id = dep.id; if dep_id is None:
continue; then call delete_obj(dep, deleter, version, parent_obj=obj,
clean_predelete=clean_predelete). Keep references to dependents_to_delete,
dep.id, and delete_obj to locate the change.

175-179: ⚡ Quick win

Please remove the exploratory commented-out code before merging.

Lines 175–179 carry stale debugging scaffolding — the commented obj_id = obj.id, the "temporary disabled to tests its necessity" note, and the commented if deleter and (obj_id is not None): guard. They no longer describe what the code does and will confuse future readers (especially since the now-active line 180 was previously the body of that commented-out if, so the indentation context is lost).

♻️ Suggested cleanup (combine with the None-guard fix above)
-    # store obj id and class before deletion to avoid accessing deleted obj
-    # obj_id = obj.id
-    # temporary disabled to tests its necessity since the cases in which its obj_id is missing are on the dependent objects
-
-    # if deleter and (obj_id is not None):
-    deleter(obj, parent_obj)
+    if deleter is not None:
+        deleter(obj, parent_obj)
🤖 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/specify/api/crud.py` around lines 175 - 179, Remove the
exploratory commented-out lines (the commented "obj_id = obj.id", the note about
"temporary disabled to tests its necessity", and the commented "if deleter and
(obj_id is not None):") from the deletion logic in specify/api/crud.py; ensure
the remaining code around the deletion continues to use a proper None-guard for
obj.id (reintroduce or keep a check like "if deleter and obj.id is not None:"
where appropriate) so the indentation/context of the now-active deletion body is
correct and unambiguous.
🤖 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.

Outside diff comments:
In `@specifyweb/specify/api/crud.py`:
- Around line 157-182: The delete_obj function currently calls deleter(obj,
parent_obj) unconditionally even though deleter may be None; update delete_obj
to check whether deleter is provided before invoking it (e.g., if deleter:
deleter(obj, parent_obj)) so callers that omit the deleter (like
record_merging.py) won’t trigger a TypeError; keep the existing deleter
parameter and only invoke the callable when it is not None.

---

Nitpick comments:
In `@specifyweb/specify/api/crud.py`:
- Around line 184-188: Comment and loop are slightly misleading: obj.delete()
may have already cascaded dependents and set their pk to None, so we should
update the comment to reflect that and inline the id-cache/None-guard for
clarity. Change the comment to indicate "after obj.delete(), dependents may have
been cascaded to pk=None" and rewrite the loop using a single-step pattern that
reads dep_id = dep.id; if dep_id is None: continue; then call delete_obj(dep,
deleter, version, parent_obj=obj, clean_predelete=clean_predelete). Keep
references to dependents_to_delete, dep.id, and delete_obj to locate the change.
- Around line 175-179: Remove the exploratory commented-out lines (the commented
"obj_id = obj.id", the note about "temporary disabled to tests its necessity",
and the commented "if deleter and (obj_id is not None):") from the deletion
logic in specify/api/crud.py; ensure the remaining code around the deletion
continues to use a proper None-guard for obj.id (reintroduce or keep a check
like "if deleter and obj.id is not None:" where appropriate) so the
indentation/context of the now-active deletion body is correct and unambiguous.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cf583a6e-a742-402b-b977-93c7dbce6a9d

📥 Commits

Reviewing files that changed from the base of the PR and between e55ddcb and e51d2b2.

📒 Files selected for processing (1)
  • specifyweb/specify/api/crud.py

Copy link
Copy Markdown
Collaborator

@emenslin emenslin left a comment

Choose a reason for hiding this comment

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

  • Make sure the deletion is successful and no errors are raised through this deletion method

  • Make sure the deletion is successful and no errors are raised for this deletion method

Looks good, I didn't run into any issues

@emenslin emenslin requested a review from a team May 21, 2026 17:29
Copy link
Copy Markdown
Contributor

@alesan99 alesan99 left a comment

Choose a reason for hiding this comment

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

  • Make sure the deletion is successful and no errors are raised through this deletion method
  • Make sure the deletion is successful and no errors are raised for this deletion method

Works!

@CarolineDenis CarolineDenis requested a review from a team May 22, 2026 07:42
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.

Error deleting attachments via API

5 participants