Skip to content

fix: prevent duplicate tags on leads#464

Open
davi713albano-coder wants to merge 1 commit into
Kuldeeep18:mainfrom
davi713albano-coder:fix/prevent-duplicate-lead-tags
Open

fix: prevent duplicate tags on leads#464
davi713albano-coder wants to merge 1 commit into
Kuldeeep18:mainfrom
davi713albano-coder:fix/prevent-duplicate-lead-tags

Conversation

@davi713albano-coder

@davi713albano-coder davi713albano-coder commented Jun 27, 2026

Copy link
Copy Markdown

Closes #436

What does this PR do?

Prevents the same tag from being assigned to a lead multiple times.

Changes:

  • Added unique constraint on LeadTag (lead, tag) in the model
  • Added duplicate check before tag creation in the view
  • Returns 400 Bad Request with clear message when duplicate is attempted
  • Updated frontend dropdown to disable already-assigned tags

How did you verify your code works?

Logic follows the implementation guide in #436. The constraint enforces uniqueness at the DB level, and the view provides a graceful error response.

Summary by CodeRabbit

  • Bug Fixes

    • Tag assignment now prevents duplicate tags from being added to the same lead and returns a clear validation error when duplicates are selected.
    • Existing tag chips already assigned to a lead are now shown as disabled, with a tooltip explaining why they can’t be selected again.
  • Data Integrity

    • Added stronger safeguards to keep each lead-and-tag combination unique.

@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a UniqueConstraint to the LeadTag model and a corresponding migration. The serializer and view now detect duplicate tag assignments and return 400 errors instead of silently skipping. The frontend disables tag chips that are already assigned to the selected lead.

Duplicate tag prevention

Layer / File(s) Summary
Model constraint and migration
backend/leads/models.py, backend/leads/migrations/0006_leadtag_unique_constraint.py
LeadTag.Meta adds a named UniqueConstraint on (lead, tag), and a new migration applies it to the database.
Duplicate validation in serializer and view
backend/leads/serializers.py, backend/leads/views.py
_set_tags raises ValidationError listing duplicate tag names; assign_tags returns a 400 with duplicate names instead of silently skipping creation.
Disabled chip state in frontend
frontend/leads.html
CSS dims .tag-filter-chip.disabled chips; assignedTagIds state drives chip rendering to mark already-assigned tags as disabled with a tooltip.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐇 A tag twice placed? Not on my watch!
The constraint says "once" — no duplicate blotch.
The chips go grey, the backend cries "Nope!"
With a 400 returned and a message of hope.
Clean data at last, the rabbit hops free~ 🏷️

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ 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 clearly and concisely describes the main change: preventing duplicate lead tags.
Linked Issues check ✅ Passed The PR enforces lead-tag uniqueness in the model and view, returns 400 on duplicates, and disables already-assigned tags in the UI.
Out of Scope Changes check ✅ Passed The changes stay within the issue scope and only cover model, backend validation, migration, and frontend duplicate-tag handling.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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: 2

Caution

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

⚠️ Outside diff range comments (1)
backend/leads/views.py (1)

140-158: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

assign_tags now rejects valid replace requests and can delete tags before erroring.

This action is documented as replacing the lead's tag set, but any request that includes an already-associated tag now returns 400. For example, if the lead already has A, a request for [A, C] hits Line 141 first, then Lines 148-155 reject A as a duplicate instead of keeping it and adding C. Treat existing rows as retained members of the replacement set, and only validate true duplicates in the incoming payload before any delete/create work.

🤖 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 `@backend/leads/views.py` around lines 140 - 158, The assign_tags flow in
backend/leads/views.py is treating already-associated tags as duplicates and can
delete existing LeadTag rows before rejecting the request. Update the
assign_tags logic so it first validates the incoming tag payload for true
duplicates within the request, then treats tags already on the lead as retained
members of the replacement set rather than errors, and only after validation
performs the delete/create replacement work. Use the assign_tags view’s LeadTag,
Tag, and existing_tag_ids handling to locate and adjust this behavior.
🧹 Nitpick comments (1)
backend/leads/models.py (1)

78-80: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Don't keep unique_together and UniqueConstraint for the same pair.

unique_together = ('lead', 'tag') already enforces this rule, so Lines 79-80 add a second uniqueness declaration on the same columns. If you want the named constraint, replace unique_together with it instead of stacking both, and generate the matching migration to drop the legacy rule.

🤖 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 `@backend/leads/models.py` around lines 78 - 80, Remove the duplicate
uniqueness declaration for the lead/tag pair in the model’s Meta definition:
`unique_together` and the `UniqueConstraint` in `models.py` are enforcing the
same rule twice. Keep only one approach in the model (prefer the named
`UniqueConstraint` if you need the explicit constraint name) and update the
related migration so the legacy `unique_together` rule is removed cleanly.
🤖 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 `@backend/leads/serializers.py`:
- Around line 39-51: The tag replacement flow in the lead serializer is mutating
state before validation and incorrectly rejecting valid full-replacement
payloads. Update the tag-handling logic in the serializer method that uses
LeadTag.objects so it first validates duplicates only within the incoming
tag_ids set, then applies the replace contract by deleting omitted LeadTag
relations and creating only truly new ones. Keep the existing tags that are
included in the request, and avoid raising ValidationError for tags already
assigned when they are part of the replacement payload.

In `@frontend/leads.html`:
- Around line 74-78: The disabled tag filter chip is preventing the hover
explanation from being reachable because `.tag-filter-chip.disabled` blocks
pointer events and the `title` is on the disabled element itself. Update the
`tag-filter-chip`/disabled chip markup and styling so the message is shown on a
wrapper or nearby element instead of the disabled button, keeping the
explanatory text accessible without relying on hover over the disabled control.

---

Outside diff comments:
In `@backend/leads/views.py`:
- Around line 140-158: The assign_tags flow in backend/leads/views.py is
treating already-associated tags as duplicates and can delete existing LeadTag
rows before rejecting the request. Update the assign_tags logic so it first
validates the incoming tag payload for true duplicates within the request, then
treats tags already on the lead as retained members of the replacement set
rather than errors, and only after validation performs the delete/create
replacement work. Use the assign_tags view’s LeadTag, Tag, and existing_tag_ids
handling to locate and adjust this behavior.

---

Nitpick comments:
In `@backend/leads/models.py`:
- Around line 78-80: Remove the duplicate uniqueness declaration for the
lead/tag pair in the model’s Meta definition: `unique_together` and the
`UniqueConstraint` in `models.py` are enforcing the same rule twice. Keep only
one approach in the model (prefer the named `UniqueConstraint` if you need the
explicit constraint name) and update the related migration so the legacy
`unique_together` rule is removed cleanly.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0c9c6c45-3650-41b7-9d6e-330f23d69613

📥 Commits

Reviewing files that changed from the base of the PR and between 4a33158 and 95682b9.

📒 Files selected for processing (5)
  • backend/leads/migrations/0006_leadtag_unique_constraint.py
  • backend/leads/models.py
  • backend/leads/serializers.py
  • backend/leads/views.py
  • frontend/leads.html

Comment on lines 39 to +51
LeadTag.objects.filter(lead=lead).exclude(tag__in=tags).delete()
# Add new tags that are not already assigned
existing_tag_ids = set(
LeadTag.objects.filter(lead=lead).values_list('tag_id', flat=True)
)
for tag in tags:
if tag.id not in existing_tag_ids:
LeadTag.objects.create(lead=lead, tag=tag, organization=org)
new_tags = [tag for tag in tags if tag.id not in existing_tag_ids]
duplicate_tags = [tag for tag in tags if tag.id in existing_tag_ids]
if duplicate_tags:
duplicate_names = [tag.name for tag in duplicate_tags]
raise serializers.ValidationError(
{"tag_ids": f"Tags already assigned to this lead: {', '.join(duplicate_names)}"}
)
for tag in new_tags:
LeadTag.objects.create(lead=lead, tag=tag, organization=org)

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 | ⚡ Quick win

This breaks the "replace tags" contract and can mutate state before returning 400.

tag_ids is documented as the full replacement set, so a request like [existing_tag, new_tag] should keep the existing tag and add the new one. Instead, Line 39 deletes omitted tags first, then Lines 45-49 reject the still-present tag as a duplicate. That turns a valid replace into an error and leaves the lead partially updated. Validate only repeated IDs in the incoming payload before mutating, then delete missing relations and create only the genuinely new ones.

🤖 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 `@backend/leads/serializers.py` around lines 39 - 51, The tag replacement flow
in the lead serializer is mutating state before validation and incorrectly
rejecting valid full-replacement payloads. Update the tag-handling logic in the
serializer method that uses LeadTag.objects so it first validates duplicates
only within the incoming tag_ids set, then applies the replace contract by
deleting omitted LeadTag relations and creating only truly new ones. Keep the
existing tags that are included in the request, and avoid raising
ValidationError for tags already assigned when they are part of the replacement
payload.

Comment thread frontend/leads.html
Comment on lines +74 to +78
.tag-filter-chip.disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: 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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the surrounding template and any related markup behavior in the same file.
ast-grep outline frontend/leads.html --view expanded

Repository: Kuldeeep18/LeadOrbit

Length of output: 192


🏁 Script executed:

#!/bin/bash
set -euo pipefail

wc -l frontend/leads.html
sed -n '60,90p' frontend/leads.html
printf '\n---\n'
sed -n '660,690p' frontend/leads.html
printf '\n---\n'
rg -n "tag-filter-chip|Already assigned to this lead|pointer-events|disabled" frontend/leads.html

Repository: Kuldeeep18/LeadOrbit

Length of output: 3521


Move the explanation off the disabled button

title="Already assigned to this lead" won’t be reachable on the disabled chip, and .disabled { pointer-events: none; } blocks hover entirely. If that message matters, show it on a wrapper/adjacent element instead of the disabled button.

🤖 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 `@frontend/leads.html` around lines 74 - 78, The disabled tag filter chip is
preventing the hover explanation from being reachable because
`.tag-filter-chip.disabled` blocks pointer events and the `title` is on the
disabled element itself. Update the `tag-filter-chip`/disabled chip markup and
styling so the message is shown on a wrapper or nearby element instead of the
disabled button, keeping the explanatory text accessible without relying on
hover over the disabled control.

@Kuldeeep18

Copy link
Copy Markdown
Owner

Hi @davi713albano-coder 👋

LeadOrbit Bot here 🤖

We noticed you've opened a Pull Request but haven't starred the repository yet.

Starring the repository is mandatory for PR review and merge.

Please:

  1. Star the repository.
  2. Reply to this comment with "Done".

Once you've done that, the bot will continue processing your PR.

Note: PRs from contributors who haven't starred the repository will remain pending until this requirement is completed.

Thanks for contributing to LeadOrbit! 🚀

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.

LO-090 [Easy]: Prevent Duplicate Tags on Leads

2 participants