Files
rengine/shared/services/tag.py
T
Yogesh OjhaandClaude Opus 5 416453d4f3 fix(api): two names that reduce to the same slug returned 500
Creating the tag "Web Servers" and then "web-servers" was a 500. The
name-to-slug mapping is many-to-one, but uniqueness was checked on the
name while the database constrains the slug, so any pair of names
differing only in case, spaces or punctuation collided on insert.

generate_slug also dropped every character outside a-z0-9, so a name in
any non-Latin script reduced to the empty string. The first such tag
was created with an empty slug, which made it unaddressable — the row
could never be read, updated or deleted through /tags/{project}/{slug}
— and the second one collided with it and failed.

Three routes and two services each derived slugs their own way; two had
a uniqueness loop and three did not, and the loop was a check followed
by an insert, so eight concurrent requests produced four 500s.

There is now one helper. generate_slug keeps its exact behaviour for
Latin names and falls back to Unicode word characters when that yields
nothing, so 東京支社 and Москва get real slugs. add_with_unique_slug
picks a free suffix, counting rows pending in the session as taken so a
CSV import carrying "Ops Team" and "ops-team" in one request lands two
tags, then inserts inside a savepoint and retries when the database
reports a slug conflict — re-raising anything else so an unrelated
constraint still surfaces its own error. Tag creation gained the
duplicate-name guard organizations already had.

Verified: eight concurrent creates over colliding names across tags,
organizations and projects, three rounds, no 5xx and one row per name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6LcH8uUddjJbKrahf34ys
2026-09-07 07:04:45 +05:30

38 lines
919 B
Python

import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models import Tag
from shared.utils.slug import add_with_unique_slug
from shared.utils.validation import clean_name
async def get_or_create_tag(
name: str,
project_id: uuid.UUID,
user_id: uuid.UUID,
session: AsyncSession,
color: str = "#6B7280",
) -> Tag:
normalized_name = clean_name(name, max_len=50).lower()
result = await session.execute(
select(Tag).where(
Tag.name == normalized_name,
Tag.project_id == project_id,
)
)
tag = result.scalar_one_or_none()
if not tag:
tag = Tag(
name=normalized_name,
color=color,
project_id=project_id,
created_by=user_id,
)
await add_with_unique_slug(session, tag, normalized_name, project_id=project_id)
return tag