From bc251bbd6cadf72cb21b2cecb4341d092704712e Mon Sep 17 00:00:00 2001 From: moneebullah25 Date: Sat, 10 Oct 2026 11:12:33 -0400 Subject: [PATCH 1/4] fix(leads): reactivate archived tags named in a CSV import --- backend/leads/csv_import.py | 9 ++++++--- backend/leads/tests/test_lead_import.py | 8 ++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/leads/csv_import.py b/backend/leads/csv_import.py index 4c70bf9c..47b509cc 100644 --- a/backend/leads/csv_import.py +++ b/backend/leads/csv_import.py @@ -397,7 +397,10 @@ def _get_or_create_tag(name: str, org, cache: dict[str, Tags]) -> Tags: # SELECT-then-INSERT race when two imports name the same new tag. slug = Tags.slug_for(name) if slug not in cache: - cache[slug], _ = Tags.objects.get_or_create( - slug=slug, org=org, defaults={"name": name} - ) + tag, _ = Tags.objects.get_or_create(slug=slug, org=org, defaults={"name": name}) + if not tag.is_active: + # Same as the tags API: naming an archived tag brings it back. + tag.is_active = True + tag.save(update_fields=["is_active"]) + cache[slug] = tag return cache[slug] diff --git a/backend/leads/tests/test_lead_import.py b/backend/leads/tests/test_lead_import.py index 182b50be..bdaed0d2 100644 --- a/backend/leads/tests/test_lead_import.py +++ b/backend/leads/tests/test_lead_import.py @@ -591,3 +591,11 @@ class TestTagSlugs: tag = Tags.objects.get(org=org_a) assert tag.slug == "trade-show" assert set(Lead.objects.values_list("tags", flat=True)) == {tag.id} + + def test_archived_tag_is_reactivated(self, admin_client, org_a): + Tags.objects.create(name="VIP", slug="vip", org=org_a, is_active=False) + response = _post(admin_client, COMMIT, self._tagged("a@example.com", "VIP")) + assert response.status_code == 200, response.json() + tag = Lead.objects.get().tags.get() + assert tag.is_active + assert Tags.objects.filter(org=org_a).count() == 1 From d6b602f628b3fb940bcc1470a1af9f296c342cd7 Mon Sep 17 00:00:00 2001 From: moneebullah25 Date: Sat, 10 Oct 2026 11:26:24 -0400 Subject: [PATCH 2/4] fix(leads): stamp audit fields when an import reactivates a tag --- backend/leads/csv_import.py | 2 +- backend/leads/tests/test_lead_import.py | 27 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/backend/leads/csv_import.py b/backend/leads/csv_import.py index 47b509cc..e67eb339 100644 --- a/backend/leads/csv_import.py +++ b/backend/leads/csv_import.py @@ -401,6 +401,6 @@ def _get_or_create_tag(name: str, org, cache: dict[str, Tags]) -> Tags: if not tag.is_active: # Same as the tags API: naming an archived tag brings it back. tag.is_active = True - tag.save(update_fields=["is_active"]) + tag.save() cache[slug] = tag return cache[slug] diff --git a/backend/leads/tests/test_lead_import.py b/backend/leads/tests/test_lead_import.py index bdaed0d2..f31f8dae 100644 --- a/backend/leads/tests/test_lead_import.py +++ b/backend/leads/tests/test_lead_import.py @@ -599,3 +599,30 @@ class TestTagSlugs: tag = Lead.objects.get().tags.get() assert tag.is_active assert Tags.objects.filter(org=org_a).count() == 1 + + def test_archived_tag_matched_by_slug_is_reactivated_once( + self, admin_client, org_a + ): + archived = Tags.objects.create( + name="VIP", slug="vip", org=org_a, is_active=False + ) + csv = _csv( + BASE + ["tags"], + [ + ["Ada", "Byte", "a@example.com", "Vip"], + ["Bo", "Cee", "b@example.com", "vip "], + ], + ) + response = _post(admin_client, COMMIT, csv) + assert response.status_code == 200, response.json() + archived.refresh_from_db() + assert archived.is_active + assert archived.name == "VIP" + assert archived.updated_by is not None + assert Tags.objects.filter(org=org_a).count() == 1 + + def test_preview_leaves_an_archived_tag_archived(self, admin_client, org_a): + Tags.objects.create(name="VIP", slug="vip", org=org_a, is_active=False) + response = _post(admin_client, PREVIEW, self._tagged("a@example.com", "VIP")) + assert response.status_code == 200, response.json() + assert not Tags.objects.get(org=org_a).is_active From c415d50c122bba244b8d9f54054db7cc5673d78c Mon Sep 17 00:00:00 2001 From: moneebullah25 Date: Wed, 14 Oct 2026 10:54:05 -0400 Subject: [PATCH 3/4] fix(leads): report archived tags as import row errors instead of restoring them --- backend/leads/csv_import.py | 21 ++++++--- backend/leads/tests/test_lead_import.py | 57 ++++++++++--------------- 2 files changed, 37 insertions(+), 41 deletions(-) diff --git a/backend/leads/csv_import.py b/backend/leads/csv_import.py index e67eb339..83250378 100644 --- a/backend/leads/csv_import.py +++ b/backend/leads/csv_import.py @@ -215,10 +215,15 @@ def parse_and_validate(file_bytes: bytes, request, legacy=False) -> ImportResult valid: list[ValidatedRow] = [] errors: list[RowError] = [] seen_emails: dict[str, int] = {} + # Restoring a tag is an org-admin action in the tags API, so an import + # (open to sales members) reports it instead of reactivating it. + archived = set( + Tags.objects.filter(org=org, is_active=False).values_list("slug", flat=True) + ) for idx, record in parsed: row_errors, validated = _validate_row( - idx, record, request, profiles, teams, seen_emails, legacy + idx, record, request, profiles, teams, seen_emails, legacy, archived ) if row_errors: errors.extend(row_errors) @@ -262,7 +267,7 @@ def _reference_maps(parsed, org) -> tuple[dict[str, str], dict[str, str]]: return profiles, teams -def _validate_row(idx, record, request, profiles, teams, seen_emails, legacy): +def _validate_row(idx, record, request, profiles, teams, seen_emails, legacy, archived): errors: list[RowError] = [] if legacy: @@ -335,6 +340,14 @@ def _validate_row(idx, record, request, profiles, teams, seen_emails, legacy): name_error = Tags.name_error(name) if name_error: errors.append(RowError(idx, "tags", f"Tag '{name}': {name_error}")) + elif Tags.slug_for(name) in archived: + errors.append( + RowError( + idx, + "tags", + f"Tag '{name}' is archived; restore it in Settings first", + ) + ) if errors: return errors, None @@ -398,9 +411,5 @@ def _get_or_create_tag(name: str, org, cache: dict[str, Tags]) -> Tags: slug = Tags.slug_for(name) if slug not in cache: tag, _ = Tags.objects.get_or_create(slug=slug, org=org, defaults={"name": name}) - if not tag.is_active: - # Same as the tags API: naming an archived tag brings it back. - tag.is_active = True - tag.save() cache[slug] = tag return cache[slug] diff --git a/backend/leads/tests/test_lead_import.py b/backend/leads/tests/test_lead_import.py index f31f8dae..41ee48ba 100644 --- a/backend/leads/tests/test_lead_import.py +++ b/backend/leads/tests/test_lead_import.py @@ -552,9 +552,7 @@ class TestTagSlugs: return _csv(BASE + ["tags"], [["Ada", "Byte", email, tag]]) def test_non_latin_tags_import_and_are_reused(self, admin_client, org_a): - first = _post( - admin_client, COMMIT, self._tagged("one@example.com", "日本;中国") - ) + first = _post(admin_client, COMMIT, self._tagged("one@example.com", "日本;中国")) second = _post(admin_client, COMMIT, self._tagged("two@example.com", "日本")) assert first.status_code == 200, first.json() assert second.status_code == 200, second.json() @@ -592,37 +590,26 @@ class TestTagSlugs: assert tag.slug == "trade-show" assert set(Lead.objects.values_list("tags", flat=True)) == {tag.id} - def test_archived_tag_is_reactivated(self, admin_client, org_a): + @pytest.mark.parametrize("url", [PREVIEW, COMMIT]) + def test_archived_tag_is_a_row_error_for_an_admin(self, admin_client, org_a, url): Tags.objects.create(name="VIP", slug="vip", org=org_a, is_active=False) - response = _post(admin_client, COMMIT, self._tagged("a@example.com", "VIP")) - assert response.status_code == 200, response.json() - tag = Lead.objects.get().tags.get() - assert tag.is_active - assert Tags.objects.filter(org=org_a).count() == 1 - - def test_archived_tag_matched_by_slug_is_reactivated_once( - self, admin_client, org_a - ): - archived = Tags.objects.create( - name="VIP", slug="vip", org=org_a, is_active=False - ) - csv = _csv( - BASE + ["tags"], - [ - ["Ada", "Byte", "a@example.com", "Vip"], - ["Bo", "Cee", "b@example.com", "vip "], - ], - ) - response = _post(admin_client, COMMIT, csv) - assert response.status_code == 200, response.json() - archived.refresh_from_db() - assert archived.is_active - assert archived.name == "VIP" - assert archived.updated_by is not None - assert Tags.objects.filter(org=org_a).count() == 1 - - def test_preview_leaves_an_archived_tag_archived(self, admin_client, org_a): - Tags.objects.create(name="VIP", slug="vip", org=org_a, is_active=False) - response = _post(admin_client, PREVIEW, self._tagged("a@example.com", "VIP")) - assert response.status_code == 200, response.json() + response = _post(admin_client, url, self._tagged("a@example.com", "vip ")) + error = response.json()["errors"][0] + assert (error["row"], error["field"]) == (1, "tags") + assert error["message"] == "Tag 'vip' is archived; restore it in Settings first" assert not Tags.objects.get(org=org_a).is_active + assert not Lead.objects.exists() + + @pytest.mark.parametrize("url", [PREVIEW, COMMIT]) + def test_archived_tag_is_a_row_error_for_sales_access( + self, user_client, user_profile, org_a, url + ): + user_profile.has_sales_access = True + user_profile.save(update_fields=["has_sales_access"]) + Tags.objects.create(name="VIP", slug="vip", org=org_a, is_active=False) + response = _post(user_client, url, self._tagged("a@example.com", "VIP")) + error = response.json()["errors"][0] + assert (error["row"], error["field"]) == (1, "tags") + assert "archived" in error["message"] + assert not Tags.objects.get(org=org_a).is_active + assert not Lead.objects.exists() From ea3ce094202688e816352f607c8d585bdbf54142 Mon Sep 17 00:00:00 2001 From: moneebullah25 Date: Wed, 14 Oct 2026 11:12:46 -0400 Subject: [PATCH 4/4] style: apply ruff format to lead import tests --- backend/leads/tests/test_lead_import.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/leads/tests/test_lead_import.py b/backend/leads/tests/test_lead_import.py index 41ee48ba..b6569761 100644 --- a/backend/leads/tests/test_lead_import.py +++ b/backend/leads/tests/test_lead_import.py @@ -552,7 +552,9 @@ class TestTagSlugs: return _csv(BASE + ["tags"], [["Ada", "Byte", email, tag]]) def test_non_latin_tags_import_and_are_reused(self, admin_client, org_a): - first = _post(admin_client, COMMIT, self._tagged("one@example.com", "日本;中国")) + first = _post( + admin_client, COMMIT, self._tagged("one@example.com", "日本;中国") + ) second = _post(admin_client, COMMIT, self._tagged("two@example.com", "日本")) assert first.status_code == 200, first.json() assert second.status_code == 200, second.json()