Skip to content

Extend object-reference checks to all versions and add bulk-archive action#3749

Draft
SmittieC wants to merge 3 commits into
mainfrom
claude/issue-3197-20260702-0800
Draft

Extend object-reference checks to all versions and add bulk-archive action#3749
SmittieC wants to merge 3 commits into
mainfrom
claude/issue-3197-20260702-0800

Conversation

@SmittieC

@SmittieC SmittieC commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Product Description

When deleting an object (service provider, assistant, pipeline, collection), deletion is now blocked when any non-archived version — not just working/published — references it. The "Cannot delete" modal now offers a one-click bulk-archive button for non-working, non-published snapshot versions, while working and published versions must still be cleaned up manually.

Resolves #3197. Ports #3198 (branch claude/issue-3197-20260422-0552) onto current main. Closes #3198.

Technical Description

  • Added is_blocking_object / is_bulk_archiveable helpers in apps/utils/deletion.py.
  • get_related_experiments* querysets (assistants, documents) now return all non-archived versions.
  • Delete views categorise references into manual-only vs bulk-archiveable.
  • New experiments:bulk_archive_versions endpoint archives non-working, non-published versions atomically (team-scoped).
  • Extended render_referenced_objects_modal helper + referenced_objects.html for the bulk-archive section (threads request for CSRF).
  • Added 12 tests.

Migrations

  • The migrations are backwards compatible (no migrations needed — query-only changes)

Docs and Changelog

  • This PR requires docs/changelog update

Generated with Claude Code

… action

Working and published experiment versions already blocked deletion of referenced
service providers, assistants, pipelines, and collections. Non-published snapshot
versions (not working, not published, not archived) were silently ignored, letting
deletions corrupt those references.

Changes:
- Add is_blocking_object / is_bulk_archiveable helpers in apps/utils/deletion.py
- Update get_related_experiments(_with_pipeline)_queryset methods in assistants and
  documents models to return ALL non-archived versions, not just working/published
- Categorise blocking references in every delete view into 'manual-only' (working +
  published) and 'bulk-archiveable' (non-working, non-published, non-archived)
- Add bulk_archive_experiment_versions endpoint (experiments:bulk_archive_versions)
  that archives a supplied list of non-working, non-published versions atomically
- Extend the render_referenced_objects_modal helper (and referenced_objects.html) to
  render the bulk-archive section with a form button, threading request through for CSRF
- Add tests for deletion blocking and the bulk-archive endpoint

Ports the changes from PR #3198 (branch claude/issue-3197-20260422-0552) onto current
main, adapting to the refactored render_referenced_objects_modal helper.

Co-authored-by: Chris Smit <SmittieC@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
codescene-delta-analysis[bot]

This comment was marked as outdated.

- Lowercase form method value (POST -> post) in referenced_objects.html
- Collapse three-way conditional to experiment_objects or assistant_objects
  (equivalent, since those lists partition all blocking objects), which
  also clears the CodeScene complex-conditional advisory

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Fixed the CI failure and addressed the CodeScene advisory:

  • djLint H029 (CI failure): lowercased the form method value (POSTpost) in templates/generic/referenced_objects.html.
  • CodeScene complex-conditional advisory: collapsed if manual_experiments or bulk_archiveable_experiments or related_assistants to if experiment_objects or assistant_objects. These are equivalent since manual_experiments + bulk_archiveable_experiments partition all experiment_objects and related_assistants covers all assistant_objects.

Verified locally: ruff check/ruff format clean and all 34 tests in apps/service_providers/tests/test_views.py pass.

codescene-delta-analysis[bot]

This comment was marked as outdated.

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR broadens related-experiment queries to include all non-archived versions (not just default/published), introduces is_blocking_object/is_bulk_archiveable helper predicates, adds a new bulk-archive endpoint and URL route for experiment versions, extends render_referenced_objects_modal and its template to support bulk-archive actions, and updates the delete flows for assistants, collections, pipelines, and service providers to split related experiments into manual vs bulk-archiveable groups. Tests are added for service provider deletion checks and the new bulk-archive endpoint.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Possibly related issues

Possibly related PRs

  • dimagi/open-chat-studio#692: The switch to Experiment.objects.get_all().filter(...) depends on the get_all() manager method introduced there.
  • dimagi/open-chat-studio#728: Introduces is_archived/archive() for versioned objects which this PR leverages via is_bulk_archiveable and bulk-archive/delete flows.
  • dimagi/open-chat-studio#735: Both PRs modify DeletePipeline.delete in apps/pipelines/views.py, adding related but different archiving behavior.

Suggested reviewers: snopoke

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: broader object-reference checks plus a bulk-archive action.
Description check ✅ Passed The description covers the required product, technical, migration, and changelog sections; the demo section is optional and can be omitted.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

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

Actionable comments posted: 3

Caution

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

⚠️ Outside diff range comments (1)
apps/pipelines/views.py (1)

218-231: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Split static-trigger experiments by bulk-archive eligibility too. get_static_trigger_experiment_ids() can return non-working, non-published versions, but they’re always passed as manual-only here, so they miss the bulk-archive path.

🤖 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 `@apps/pipelines/views.py` around lines 218 - 231, The pipeline
referenced-objects modal currently builds static-trigger experiments only into
the manual list, so versions returned by get_static_trigger_experiment_ids()
never reach the bulk-archive flow. Update the logic in views.py around the
pipeline modal assembly to split static-trigger experiments by
is_bulk_archiveable the same way other experiments are handled, using the
existing helpers like render_referenced_objects_modal,
bulk_archiveable_experiments, and bulk_archiveable_ids so eligible
static-trigger versions are included in the bulk-archive path.
🧹 Nitpick comments (7)
apps/utils/deletion.py (1)

198-204: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

is_bulk_archiveable silently mislabels non-Experiment objects.

is_default_version only exists on Experiment (per apps/experiments/models.py); OpenAiAssistant and Collection don't define it. Because of the getattr(..., False) default, calling this on any non-Experiment version object with a working_version_id set would incorrectly return True. All current call sites happen to pre-filter to Experiment instances, so this isn't triggered today, but the function's generic signature invites future misuse.

🛡️ Proposed defensive fix
 def is_bulk_archiveable(obj) -> bool:
     """Returns True if an object can be bulk-archived (non-working, non-published, non-archived version)."""
     return (
-        not getattr(obj, "is_archived", False)
+        isinstance(obj, Experiment)
+        and not getattr(obj, "is_archived", False)
         and getattr(obj, "working_version_id", None) is not None
         and not getattr(obj, "is_default_version", False)
     )
🤖 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 `@apps/utils/deletion.py` around lines 198 - 204, `is_bulk_archiveable`
currently assumes every object has `is_default_version`, which can wrongly
return True for non-`Experiment` objects that only have `working_version_id`.
Update the helper in `apps/utils/deletion.py` to defensively require an
`Experiment`-style object before checking `is_default_version`, or otherwise
gate the logic so it only applies to objects that actually define that
attribute. Keep the existing archive/working-version checks, but make the
function safe against `OpenAiAssistant` and `Collection` instances by using a
type/attribute guard tied to `is_bulk_archiveable` and the `is_default_version`
access.
apps/service_providers/tests/test_views.py (2)

273-320: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider parametrizing bulk-archive eligibility tests.

test_bulk_archive_non_published_versions, test_bulk_archive_ignores_published_version, and test_bulk_archive_ignores_working_version all exercise the same endpoint with different version states, again relying on inline comments to convey the intent.

As per path instructions, **/test_*.py: "Use pytest.mark.parametrize for tests with varying inputs over enumerated data, with readable IDs via pytest.param(..., id=\"...\") rather than inline comments".

🤖 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 `@apps/service_providers/tests/test_views.py` around lines 273 - 320, The
bulk-archive eligibility checks are split across three nearly identical tests,
so consolidate them into a single parametrized test using
pytest.mark.parametrize with readable ids. Update the test logic around
test_bulk_archive_non_published_versions,
test_bulk_archive_ignores_published_version, and
test_bulk_archive_ignores_working_version to cover each version state via
enumerated inputs, while keeping the shared setup and endpoint call in one
place.

Source: Path instructions


179-267: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider parametrizing the blocking-vs-allowed scenarios.

TestDeleteServiceProviderReferenceChecks has four tests (working / non-published / published / archived-only) that differ mainly in how the referencing experiment version is set up, using inline comments (e.g. "v1 auto-becomes published") to explain the setup instead of readable test IDs.

As per path instructions, **/test_*.py: "Use pytest.mark.parametrize for tests with varying inputs over enumerated data, with readable IDs via pytest.param(..., id=\"...\") rather than inline comments".

🤖 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 `@apps/service_providers/tests/test_views.py` around lines 179 - 267, The
`TestDeleteServiceProviderReferenceChecks` class has several near-duplicate
delete-behavior tests that only vary by how the referencing experiment version
is configured. Consolidate the blocking/allowed scenarios into a single
`pytest.mark.parametrize`-driven test using readable `pytest.param(...,
id="...")` values, and move the setup differences into per-case fixtures or
inline setup branches tied to the parametrized case. Keep the assertions against
`authed_client.delete`, `VoiceProvider`, and the modal response the same, but
eliminate the separate working/non-published/published/archived-only test
methods and the explanatory inline comments.

Source: Path instructions

apps/service_providers/views.py (1)

122-150: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Redundant is_bulk_archiveable calls.

is_bulk_archiveable(e) is invoked once per experiment in three separate list comprehensions (manual_experiments, bulk_archiveable_experiments, and bulk_archiveable_ids). Partition experiment_objects once and derive all three downstream values from that single pass.

♻️ Proposed refactor
-        manual_experiments = [
-            Chip(
-                label=(
-                    f"{e.name} [{e.get_version_name()}]"
-                    if e.is_working_version
-                    else f"{e.name} {e.get_version_name()} [published]"
-                ),
-                url=e.get_absolute_url(),
-            )
-            for e in experiment_objects
-            if not is_bulk_archiveable(e)
-        ]
-        bulk_archiveable_experiments = [
-            Chip(label=f"{e.name} {e.get_version_name()}", url=e.get_absolute_url())
-            for e in experiment_objects
-            if is_bulk_archiveable(e)
-        ]
+        archiveable, manual = [], []
+        for e in experiment_objects:
+            (archiveable if is_bulk_archiveable(e) else manual).append(e)
+
+        manual_experiments = [
+            Chip(
+                label=(
+                    f"{e.name} [{e.get_version_name()}]"
+                    if e.is_working_version
+                    else f"{e.name} {e.get_version_name()} [published]"
+                ),
+                url=e.get_absolute_url(),
+            )
+            for e in manual
+        ]
+        bulk_archiveable_experiments = [
+            Chip(label=f"{e.name} {e.get_version_name()}", url=e.get_absolute_url()) for e in archiveable
+        ]
         related_assistants = [
             Chip(label=assistant.name, url=assistant.get_absolute_url()) for assistant in assistant_objects
         ]
         if manual_experiments or bulk_archiveable_experiments or related_assistants:
             return render_referenced_objects_modal(
                 "service provider",
                 request=request,
                 experiments=manual_experiments,
                 assistants=related_assistants,
                 bulk_archiveable_experiments=bulk_archiveable_experiments,
-                bulk_archiveable_ids=[e.id for e in experiment_objects if is_bulk_archiveable(e)],
+                bulk_archiveable_ids=[e.id for e in archiveable],
                 bulk_archive_url=reverse("experiments:bulk_archive_versions", args=[team_slug]),
             )
🤖 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 `@apps/service_providers/views.py` around lines 122 - 150, Remove the repeated
is_bulk_archiveable(e) checks in the referenced view by partitioning
experiment_objects once in the service provider modal logic, then derive
manual_experiments, bulk_archiveable_experiments, and bulk_archiveable_ids from
that single split. Update the code near the Chip list comprehensions and the
render_referenced_objects_modal call so the same categorization result is reused
instead of recalculating it three times.
apps/experiments/views/experiment.py (2)

890-898: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Bulk-archive eligibility is re-implemented inline instead of reusing the is_bulk_archiveable predicate.

Per the PR stack, apps/utils/deletion.py introduces is_bulk_archiveable/is_blocking_object helpers specifically to define this eligibility. This view instead hardcodes the same conditions (working_version_id__isnull=False, is_default_version=False, is_archived=False) directly in a queryset filter. If the predicate's definition changes later, this endpoint's behavior can silently diverge from what the modal displays as "bulk-archiveable," letting the UI and the actual archive action disagree.

Consider deriving this filter from (or validating against) the shared helper to keep a single source of truth.

🤖 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 `@apps/experiments/views/experiment.py` around lines 890 - 898, The
bulk-archive eligibility logic in the experiment view is duplicated inline
instead of using the shared predicate. Update the queryset in experiment.py to
reuse or validate against is_bulk_archiveable from apps/utils/deletion.py (and
its related is_blocking_object helper if needed) so the archive action matches
the same single source of truth as the modal. Keep the filtering behavior
centralized around Experiment.objects.get_all() and the bulk-archiveable check
rather than hardcoding the conditions in the view.

879-881: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Decorator order opens a DB transaction before the auth check.

Decorators apply bottom-up, so login_and_team_required (innermost) runs inside the transaction.atomic block, which itself runs inside require_POST. This means a transaction is opened for every POST before authentication/team-membership is verified — wasted transaction overhead (and a stray BEGIN/ROLLBACK) for every unauthenticated/unauthorized request.

♻️ Proposed reorder
-@require_POST
-@transaction.atomic
-@login_and_team_required
+@login_and_team_required
+@require_POST
+@transaction.atomic
 def bulk_archive_experiment_versions(request, team_slug: str):
🤖 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 `@apps/experiments/views/experiment.py` around lines 879 - 881, The decorator
order on the view opens a database transaction before the authentication/team
check because decorators run bottom-up. Reorder the decorators on the affected
view so `login_and_team_required` is applied outermost, `require_POST` next, and
`transaction.atomic` innermost only around the actual view body; use the view’s
unique identifier in this diff to update the decorator stack without changing
its behavior.
apps/generics/referenced_objects.py (1)

8-29: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Contract between request and bulk_archive_url isn't enforced at runtime.

The docstring correctly notes request is required for CSRF rendering when bulk_archive_url is set, but nothing enforces this. If a future caller passes bulk_archive_url without request, {% csrf_token %} silently renders empty (no context processors run), and the archive form will fail CSRF validation — a hard-to-diagnose failure.

🛡️ Proposed guard
     ``request`` must be supplied when ``bulk_archive_url`` is set so the archive
     form can render a valid CSRF token.
     """
+    if bulk_archive_url and request is None:
+        raise ValueError("`request` is required when `bulk_archive_url` is set")
     html = render_to_string(
🤖 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 `@apps/generics/referenced_objects.py` around lines 8 - 29, Add a runtime guard
in the referenced_objects modal-rendering function so the
request/bulk_archive_url contract is enforced, since the CSRF form depends on
request being present. In the function that calls render_to_string for
referenced_objects_modal.html, check for bulk_archive_url without request and
fail fast with a clear error before rendering. Keep the validation close to the
existing request=request usage so future callers of this helper cannot
accidentally generate an archive form without CSRF context.
🤖 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 `@apps/assistants/views.py`:
- Around line 232-259: The manual/bulk archive split for experiments is
duplicated here and in the other delete views, and it re-evaluates
is_bulk_archiveable(e) multiple times. Extract the classification and
Chip-labeling logic into a shared helper (for example, a utility used by
DeleteCollection.delete, DeletePipeline.delete, delete_service_provider, and
this assistant view) that takes all_experiments and returns manual chips, bulk
chips, and bulk archiveable ids in one pass. Keep the existing label formatting
and use the helper’s results directly in render_referenced_objects_modal so all
four call sites stay consistent.

In `@apps/experiments/views/experiment.py`:
- Around line 887-892: The `version_ids` handling in `experiment.py` should
validate POSTed values before they reach the
`Experiment.objects.get_all().filter(id__in=version_ids)` query. Add
parsing/validation in the request handler that only accepts numeric IDs (or
safely coerces them) and return `HttpResponse(status=400)` for any invalid input
instead of letting the ORM raise `ValueError`. Keep the fix near the
`version_ids` retrieval and the `Experiment` lookup so the existing early-return
pattern still applies.

In `@apps/service_providers/views.py`:
- Around line 117-142: The delete flow in the related-objects handling should
not proceed when any live relation exists. In the blocking_objects branch of the
service_providers view, either add the remaining related object types to the
modal content or change the condition so service_config.delete() is only reached
when blocking_objects is empty. Use the existing blocking_objects,
manual_experiments, bulk_archiveable_experiments, and related_assistants logic
to locate and update the guard.

---

Outside diff comments:
In `@apps/pipelines/views.py`:
- Around line 218-231: The pipeline referenced-objects modal currently builds
static-trigger experiments only into the manual list, so versions returned by
get_static_trigger_experiment_ids() never reach the bulk-archive flow. Update
the logic in views.py around the pipeline modal assembly to split static-trigger
experiments by is_bulk_archiveable the same way other experiments are handled,
using the existing helpers like render_referenced_objects_modal,
bulk_archiveable_experiments, and bulk_archiveable_ids so eligible
static-trigger versions are included in the bulk-archive path.

---

Nitpick comments:
In `@apps/experiments/views/experiment.py`:
- Around line 890-898: The bulk-archive eligibility logic in the experiment view
is duplicated inline instead of using the shared predicate. Update the queryset
in experiment.py to reuse or validate against is_bulk_archiveable from
apps/utils/deletion.py (and its related is_blocking_object helper if needed) so
the archive action matches the same single source of truth as the modal. Keep
the filtering behavior centralized around Experiment.objects.get_all() and the
bulk-archiveable check rather than hardcoding the conditions in the view.
- Around line 879-881: The decorator order on the view opens a database
transaction before the authentication/team check because decorators run
bottom-up. Reorder the decorators on the affected view so
`login_and_team_required` is applied outermost, `require_POST` next, and
`transaction.atomic` innermost only around the actual view body; use the view’s
unique identifier in this diff to update the decorator stack without changing
its behavior.

In `@apps/generics/referenced_objects.py`:
- Around line 8-29: Add a runtime guard in the referenced_objects
modal-rendering function so the request/bulk_archive_url contract is enforced,
since the CSRF form depends on request being present. In the function that calls
render_to_string for referenced_objects_modal.html, check for bulk_archive_url
without request and fail fast with a clear error before rendering. Keep the
validation close to the existing request=request usage so future callers of this
helper cannot accidentally generate an archive form without CSRF context.

In `@apps/service_providers/tests/test_views.py`:
- Around line 273-320: The bulk-archive eligibility checks are split across
three nearly identical tests, so consolidate them into a single parametrized
test using pytest.mark.parametrize with readable ids. Update the test logic
around test_bulk_archive_non_published_versions,
test_bulk_archive_ignores_published_version, and
test_bulk_archive_ignores_working_version to cover each version state via
enumerated inputs, while keeping the shared setup and endpoint call in one
place.
- Around line 179-267: The `TestDeleteServiceProviderReferenceChecks` class has
several near-duplicate delete-behavior tests that only vary by how the
referencing experiment version is configured. Consolidate the blocking/allowed
scenarios into a single `pytest.mark.parametrize`-driven test using readable
`pytest.param(..., id="...")` values, and move the setup differences into
per-case fixtures or inline setup branches tied to the parametrized case. Keep
the assertions against `authed_client.delete`, `VoiceProvider`, and the modal
response the same, but eliminate the separate
working/non-published/published/archived-only test methods and the explanatory
inline comments.

In `@apps/service_providers/views.py`:
- Around line 122-150: Remove the repeated is_bulk_archiveable(e) checks in the
referenced view by partitioning experiment_objects once in the service provider
modal logic, then derive manual_experiments, bulk_archiveable_experiments, and
bulk_archiveable_ids from that single split. Update the code near the Chip list
comprehensions and the render_referenced_objects_modal call so the same
categorization result is reused instead of recalculating it three times.

In `@apps/utils/deletion.py`:
- Around line 198-204: `is_bulk_archiveable` currently assumes every object has
`is_default_version`, which can wrongly return True for non-`Experiment` objects
that only have `working_version_id`. Update the helper in
`apps/utils/deletion.py` to defensively require an `Experiment`-style object
before checking `is_default_version`, or otherwise gate the logic so it only
applies to objects that actually define that attribute. Keep the existing
archive/working-version checks, but make the function safe against
`OpenAiAssistant` and `Collection` instances by using a type/attribute guard
tied to `is_bulk_archiveable` and the `is_default_version` access.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: bcf2b181-8f8b-45b2-b4f1-a5dabe281b65

📥 Commits

Reviewing files that changed from the base of the PR and between b2a4b49 and 9225f6d.

📒 Files selected for processing (13)
  • apps/assistants/models.py
  • apps/assistants/views.py
  • apps/documents/models.py
  • apps/documents/views.py
  • apps/experiments/urls.py
  • apps/experiments/views/__init__.py
  • apps/experiments/views/experiment.py
  • apps/generics/referenced_objects.py
  • apps/pipelines/views.py
  • apps/service_providers/tests/test_views.py
  • apps/service_providers/views.py
  • apps/utils/deletion.py
  • templates/generic/referenced_objects.html

Comment thread apps/assistants/views.py
Comment on lines +232 to +259
all_experiments = list(
assistant.get_related_experiments_with_pipeline_queryset(assistant_ids=version_query)
)
manual_experiments = [
Chip(
label=f"{experiment.name} {experiment.get_version_name()} [published]",
url=experiment.get_absolute_url(),
label=(
f"{e.name} [{e.get_version_name()}]"
if e.is_working_version
else f"{e.name} {e.get_version_name()} [published]"
),
url=e.get_absolute_url(),
)
for experiment in assistant.get_related_experiments_with_pipeline_queryset(assistant_ids=version_query)
for e in all_experiments
if not is_bulk_archiveable(e)
]
bulk_archiveable_experiments = [
Chip(label=f"{e.name} {e.get_version_name()}", url=e.get_absolute_url())
for e in all_experiments
if is_bulk_archiveable(e)
]
return render_referenced_objects_modal(
"assistant",
request=request,
pipeline_nodes=pipeline_nodes,
experiments_with_pipeline_nodes=experiments_with_pipeline_nodes,
experiments_with_pipeline_nodes=manual_experiments,
bulk_archiveable_experiments=bulk_archiveable_experiments,
bulk_archiveable_ids=[e.id for e in all_experiments if is_bulk_archiveable(e)],
bulk_archive_url=reverse("experiments:bulk_archive_versions", args=[team_slug]),

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Extract the manual/bulk-archiveable split into a shared helper.

This exact block — all_experiments split into manual_experiments/bulk_archiveable_experiments Chips plus a re-filtered bulk_archiveable_ids list, with identical label formatting — is duplicated verbatim in apps/documents/views.py (DeleteCollection.delete), apps/pipelines/views.py (DeletePipeline.delete), and apps/service_providers/views.py (delete_service_provider). is_bulk_archiveable(e) is also evaluated redundantly (once for the manual filter, once for the bulk chip list, once again for bulk_archiveable_ids).

Extracting a shared helper (e.g. in apps/utils/deletion.py) that classifies all_experiments once and returns (manual_chips, bulk_chips, bulk_ids) would eliminate the duplication across all four call sites and prevent future divergence when the labeling logic changes.

♻️ Proposed helper extraction
# apps/utils/deletion.py
def split_experiments_for_deletion_modal(experiments) -> tuple[list[Chip], list[Chip], list[int]]:
    manual, bulk, bulk_ids = [], [], []
    for e in experiments:
        label = f"{e.name} {e.get_version_name()}"
        if is_bulk_archiveable(e):
            bulk.append(Chip(label=label, url=e.get_absolute_url()))
            bulk_ids.append(e.id)
        else:
            label = f"{e.name} [{e.get_version_name()}]" if e.is_working_version else f"{label} [published]"
            manual.append(Chip(label=label, url=e.get_absolute_url()))
    return manual, bulk, bulk_ids
-            all_experiments = list(
-                assistant.get_related_experiments_with_pipeline_queryset(assistant_ids=version_query)
-            )
-            manual_experiments = [
-                Chip(
-                    label=(
-                        f"{e.name} [{e.get_version_name()}]"
-                        if e.is_working_version
-                        else f"{e.name} {e.get_version_name()} [published]"
-                    ),
-                    url=e.get_absolute_url(),
-                )
-                for e in all_experiments
-                if not is_bulk_archiveable(e)
-            ]
-            bulk_archiveable_experiments = [
-                Chip(label=f"{e.name} {e.get_version_name()}", url=e.get_absolute_url())
-                for e in all_experiments
-                if is_bulk_archiveable(e)
-            ]
+            all_experiments = list(
+                assistant.get_related_experiments_with_pipeline_queryset(assistant_ids=version_query)
+            )
+            manual_experiments, bulk_archiveable_experiments, bulk_archiveable_ids = (
+                split_experiments_for_deletion_modal(all_experiments)
+            )
             return render_referenced_objects_modal(
                 "assistant",
                 request=request,
                 pipeline_nodes=pipeline_nodes,
                 experiments_with_pipeline_nodes=manual_experiments,
                 bulk_archiveable_experiments=bulk_archiveable_experiments,
-                bulk_archiveable_ids=[e.id for e in all_experiments if is_bulk_archiveable(e)],
+                bulk_archiveable_ids=bulk_archiveable_ids,
                 bulk_archive_url=reverse("experiments:bulk_archive_versions", args=[team_slug]),
             )
🤖 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 `@apps/assistants/views.py` around lines 232 - 259, The manual/bulk archive
split for experiments is duplicated here and in the other delete views, and it
re-evaluates is_bulk_archiveable(e) multiple times. Extract the classification
and Chip-labeling logic into a shared helper (for example, a utility used by
DeleteCollection.delete, DeletePipeline.delete, delete_service_provider, and
this assistant view) that takes all_experiments and returns manual chips, bulk
chips, and bulk archiveable ids in one pass. Keep the existing label formatting
and use the helper’s results directly in render_referenced_objects_modal so all
four call sites stay consistent.

Comment on lines +887 to +892
version_ids = request.POST.getlist("version_ids")
if not version_ids:
return HttpResponse(status=400)
experiments = list(
Experiment.objects.get_all().filter(
id__in=version_ids,

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Unvalidated version_ids can raise an unhandled ValueError (500) instead of a clean 400.

request.POST.getlist("version_ids") returns raw strings passed straight into id__in=version_ids on an integer PK field. A malformed (non-numeric) value will raise ValueError inside the ORM during query construction, resulting in an unhandled 500 rather than a graceful error response.

🛡️ Proposed validation
     version_ids = request.POST.getlist("version_ids")
     if not version_ids:
         return HttpResponse(status=400)
+    try:
+        version_ids = [int(v) for v in version_ids]
+    except (TypeError, ValueError):
+        return HttpResponse(status=400)
     experiments = list(
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
version_ids = request.POST.getlist("version_ids")
if not version_ids:
return HttpResponse(status=400)
experiments = list(
Experiment.objects.get_all().filter(
id__in=version_ids,
version_ids = request.POST.getlist("version_ids")
if not version_ids:
return HttpResponse(status=400)
try:
version_ids = [int(v) for v in version_ids]
except (TypeError, ValueError):
return HttpResponse(status=400)
experiments = list(
Experiment.objects.get_all().filter(
id__in=version_ids,
🧰 Tools
🪛 ast-grep (0.44.0)

[error] 888-888: Lack of sanitization of user data
Context: HttpResponse(status=400)
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').

(http-response-from-request)

🤖 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 `@apps/experiments/views/experiment.py` around lines 887 - 892, The
`version_ids` handling in `experiment.py` should validate POSTed values before
they reach the `Experiment.objects.get_all().filter(id__in=version_ids)` query.
Add parsing/validation in the request handler that only accepts numeric IDs (or
safely coerces them) and return `HttpResponse(status=400)` for any invalid input
instead of letting the ORM raise `ValueError`. Keep the fix near the
`version_ids` retrieval and the `Experiment` lookup so the existing early-return
pattern still applies.

Comment thread apps/service_providers/views.py Outdated
Comment on lines +117 to +142
blocking_objects = [obj for obj in related_objects if is_blocking_object(obj)]
if blocking_objects:
experiment_objects = [obj for obj in blocking_objects if isinstance(obj, Experiment)]
assistant_objects = [obj for obj in blocking_objects if isinstance(obj, OpenAiAssistant)]

manual_experiments = [
Chip(
label=(
f"{experiment.name} [{experiment.get_version_name()}]"
if experiment.is_working_version
else f"{experiment.name} {experiment.get_version_name()} [published]"
f"{e.name} [{e.get_version_name()}]"
if e.is_working_version
else f"{e.name} {e.get_version_name()} [published]"
),
url=experiment.get_absolute_url(),
url=e.get_absolute_url(),
)
for experiment in [obj for obj in filtered_objects if isinstance(obj, Experiment)]
for e in experiment_objects
if not is_bulk_archiveable(e)
]
bulk_archiveable_experiments = [
Chip(label=f"{e.name} {e.get_version_name()}", url=e.get_absolute_url())
for e in experiment_objects
if is_bulk_archiveable(e)
]
related_assistants = [
Chip(label=assistant.name, url=assistant.get_absolute_url())
for assistant in [obj for obj in filtered_objects if isinstance(obj, OpenAiAssistant)]
Chip(label=assistant.name, url=assistant.get_absolute_url()) for assistant in assistant_objects
]
if related_experiments or related_assistants:
if manual_experiments or bulk_archiveable_experiments or related_assistants:

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect what object types get_related_objects can surface for service providers
rg -n -A 30 "_get_related_objects_querysets" apps/utils/deletion.py

Repository: dimagi/open-chat-studio

Length of output: 2292


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== apps/service_providers/views.py around the reported lines =="
sed -n '90,170p' apps/service_providers/views.py | cat -n

echo
echo "== is_blocking_object and related deletion helpers =="
rg -n -A 20 -B 10 "def is_blocking_object|class Node|get_related_objects\(" apps/utils/deletion.py apps -g '*.py'

echo
echo "== service provider delete view / delete call sites =="
rg -n -A 30 -B 15 "service_config\.delete\(|blocking_objects|related_objects" apps/service_providers/views.py apps -g '*.py'

Repository: dimagi/open-chat-studio

Length of output: 50380


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== provider model classes =="
rg -n -A 120 -B 20 "class .*ProviderModel|class .*ProviderType|class ServiceProvider" apps/service_providers/models.py apps/service_providers/utils.py -g '*.py'

echo
echo "== reverse relations into provider models =="
rg -n -A 3 -B 3 "ForeignKey\(.*service_providers|related_name=.*(llm|voice|embedding)|on_delete=models\.SET_NULL|on_delete=models\.CASCADE" apps -g '*.py' | sed -n '1,220p'

echo
echo "== delete methods and has_related_objects usage on provider models =="
rg -n -A 15 -B 10 "def delete\(|has_related_objects\(" apps/service_providers/models.py apps -g '*.py' | sed -n '1,220p'

Repository: dimagi/open-chat-studio

Length of output: 50379


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Foreign keys / reverse relations to provider models =="
rg -n -A 4 -B 4 "ForeignKey\(.*(LlmProvider|VoiceProvider|MessagingProvider|AuthProvider|TraceProvider)|related_name=.*(provider|providers|voice|llm|messaging|auth|trace)|OneToOneField\(.*(LlmProvider|VoiceProvider|MessagingProvider|AuthProvider|TraceProvider)" apps -g '*.py' | sed -n '1,260p'

echo
echo "== get_related_objects consumers for provider deletes =="
rg -n -A 20 -B 10 "delete_service_provider|has_related_objects\(self|get_related_objects\(self|get_related_objects\(service_config" apps/service_providers apps -g '*.py' | sed -n '1,260p'

echo
echo "== provider delete overrides =="
rg -n -A 20 -B 5 "class (LlmProvider|VoiceProvider|MessagingProvider|AuthProvider|TraceProvider)|def delete\(" apps/service_providers/models.py -g '*.py' | sed -n '1,260p'

Repository: dimagi/open-chat-studio

Length of output: 1969


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== provider reverse FKs =="
rg -n -A 3 -B 3 "models\.ForeignKey\(.*(VoiceProvider|LlmProvider|MessagingProvider|AuthProvider|TraceProvider)|models\.OneToOneField\(.*(VoiceProvider|LlmProvider|MessagingProvider|AuthProvider|TraceProvider)" apps -g '*.py' | sed -n '1,220p'

echo
echo "== SyntheticVoice model section =="
rg -n -A 80 -B 20 "class SyntheticVoice" apps -g '*.py' | sed -n '1,220p'

echo
echo "== experiment assistant/provider references =="
rg -n -A 4 -B 4 "OpenAiAssistant|Experiment.*ForeignKey|related_name=.*assistant|related_name=.*experiment" apps -g '*.py' | sed -n '1,220p'

Repository: dimagi/open-chat-studio

Length of output: 1969


Gate deletion on all live related objects. blocking_objects already contains every non-archived related record, but the modal only handles Experiment and OpenAiAssistant. Any other related type will fall through to service_config.delete(), so extend the modal to cover the remaining relations or return early whenever blocking_objects is non-empty.

🤖 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 `@apps/service_providers/views.py` around lines 117 - 142, The delete flow in
the related-objects handling should not proceed when any live relation exists.
In the blocking_objects branch of the service_providers view, either add the
remaining related object types to the modal content or change the condition so
service_config.delete() is only reached when blocking_objects is empty. Use the
existing blocking_objects, manual_experiments, bulk_archiveable_experiments, and
related_assistants logic to locate and update the guard.

@codescene-delta-analysis codescene-delta-analysis Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Health Improved (1 files improve in Code Health)

Gates Failed
Enforce advisory code health rules (1 file with Complex Method)

Our agent can fix these. Install it.

Gates Passed
3 Quality Gates Passed

Reason for failure
Enforce advisory code health rules Violations Code Health Impact
views.py 1 advisory rule 9.39 → 9.34 Suppress

See analysis details in CodeScene

View Improvements
File Code Health Impact Categories Improved
deletion.py 8.53 → 9.08 Overall Code Complexity

Quality Gate Profile: Clean Code Collective
Install CodeScene MCP: safeguard and uplift AI-generated code. Catch issues early with our IDE extension and CLI tool.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Extend object-reference checks to non-published/non-working versions and add bulk-archive action

1 participant