Tags and industries: an ASCII-only slug as identity drops non-Latin names, merges C# into C++ and answers 500 #356
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#356
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found on 2026-09-29 in a read-only review of postulo at
95348908d, following every path that makes a tag or an industry (the two pages, the application and company forms, the API, the spreadsheet import, the archive) and running each against the snapshot with the test client.Checked again on 2026-10-01 against
mainat0a5cad587: still present. Two files gained or lost lines above the code, which is otherwise unchanged: the spreadsheet import's tag loop is now atcore/csv_import.py:1047-1052and its transaction at:941; injobs/forms.pytheIndustry.namedcall is now at:334,IndustryForm.clean_name's lookup at:551andIndustryForm.save's slug at:566. Everything else is where it was.A tag and an industry are unique per
(owner, slug), and the slug comes fromdjango.utils.text.slugify()withoutallow_unicode, which deletes every character outside ASCII. That slug is computed in seven places that do not agree: some match an existing record by slug and some by name, one cuts at 60 characters where the others cut at 160, one falls back tootherand the rest to the empty string, and a renamed tag keeps the slug of its old name.C++,C#andCall becomec; any name written wholly in Greek, Cyrillic, Georgian, Armenian, Arabic, Hebrew, Ethiopic or CJK becomes"". Depending on the path, the person's label is silently dropped, attached to a different tag, duplicated, lost on an export and import, or refused by the database as anIntegrityError, a 500. Postulo ships catalogues for el, bg, uk, mk, sr, ka, hy, ar, am and ti, among others.core/models.py:195-197, theunique_tag_slug_per_ownerconstraint;jobs/models.py:67-69,unique_industry_slug_per_owner.core/models.py:202-205,Tag.save:self.slug = slugify(self.name)[:60], only while the slug is empty, with no fallback. A rename keeps the old slug; a non-Latin name is stored with"".core/models.py:232-258,Tag.named(the application forms' New tags box,applications/forms.py:289and:368): matches by slug, andif not name or not slug or slug in seen: continueskips a name whose slug is empty.applications/forms.py:615-624,TagForm.clean_name: looks for a clash withname__iexactonly. The slug is not a form field, so model validation never looks at the constraint.api/routers/common.py:71-82,tags_named(called atapi/routers/applications.py:104andapi/routers/listings.py:208):filter(name__iexact=name).first(), elseTag.objects.create(owner=owner, name=name), with no cut to the column's 60 characters.core/csv_import.py:1052-1057, inperform: the same by-name lookup, thenTag.objects.create(owner=user, name=name[:60]), looking up the uncut name and storing the cut one.jobs/models.py:74-76,Industry.save:slugify(self.name)[:160] or "other", only while the slug is empty.jobs/models.py:83-102,Industry.named(called atjobs/forms.py:333,api/routers/companies.py:67and:99, andcore/importer.py:585): cuts at 160 and skips an empty slug.jobs/forms.py:554,IndustryForm.clean_name: looks forslug=slugify(name)[:60];jobs/forms.py:569,IndustryForm.save:industry.slug = slugify(industry.name)[:60] or "other", on every save. The 60 is the length from before #140 widened the field to 160.core/export.py:703writes an application's tags as slugs;core/importer.py:404-413remakes a tag underentry.get("slug") or entry.get("name", "").lower()and keystags_by_slugon the result;core/importer.py:667-670looks the application's slugs up in it.applications/views.py:102filters bytags__slug;templates/applications/partials/filters.html:35uses the slug as the option's value.Tag.named(user, ["Κάτι", "日本語"])returns[], so a tag typed into New tags in Greek, Russian or Japanese is not made and nothing says so.Industry.named(user, ["Банки", "Τράπεζες", "Software"])returns only Software: the company form's Other industries box drops the name, including one picked from the suggestions the form itself offers in Bulgarian, andPOST /api/v1/companieswith"industries": ["Πληροφορική"]answers 201 withindustries: [].""; Κάτι then passesclean_nameand raisesIntegrityError: UNIQUE constraint failed: core_tag.owner_id, core_tag.slug. On the Industries page Ενέργεια is saved asother; Υγεία passesclean_name, which looked for the slug"", and fails the same way. So does the first non-Latin industry when one called Other already exists.C++present,C#(orC) on the Tags page is a 500, and typed into New tags it attaches theC++tag.Remote!besideremoteis a 500 too:name__iexactdoes not match and the slug does.remote. Typing Remote into New tags attaches the tag now called Hybrid; typing Hybrid makes a second tag named Hybrid; making Remote on the Tags page is a 500.remote,POST /api/v1/applicationswith"tags": ["Remote!"]answers 500; so does"tags": ["C++", "C#"], and a second Greek or Cyrillic tag.POST /api/v1/listings/{id}/applygoes through the same helper. A tag name longer than 60 characters is stored whole on SQLite; the column isvarchar(60), which PostgreSQL refuses (read, not run).C++; C#,Remote!; remoteorудалённо; мечтаraises the sameIntegrityError, on a single row. So does a 70-character tag on two rows: the second row looks up the full name, misses the cut tag the first row made, and makes it again.performruns in one transaction (core/csv_import.py:946), so the page answers 500 and nothing from the file is imported.Industry.named; adding the same name on the Industries page passesclean_name, which looks for the 60-character slug, and makes a second industry with the same name. Opening an industry with an 89-character slug on the Industries page and saving it unchanged rewrites the slug to 60 characters, and the nextIndustry.namedcall with that name makes a duplicate. Many of the NACE division names offered as suggestions (#140) are this long.""is exported as""on its applications; the importer remakes the tag undername.lower(), the reference to""matches nothing, and the tag falls off every application. A company's industries are exported by name and imported throughIndustry.named, so the one Cyrillic industry that did exist (asother) is dropped from its company.""renders as<option value="">, the same value as Any.C#besideC++or with a renamed tag's old name, an agent using the API gets a 500 for an ordinary tag, and a whole spreadsheet fails to import with no explanation. Duplicate industries split a company list in two: the?industry=filter and the counts on the Industries page each show half, until the person finds and merges them by hand.Tag.slug_for(name)andIndustry.slug_for(name), or one shared helper):slugify(name, allow_unicode=True), cut tocls._meta.get_field("slug").max_length, never empty (a fixed word such astagorindustrywhen nothing is left, as for++), and made unique for the owner with a numeric suffix (c,c-2). Declare both fieldsSlugField(allow_unicode=True).Tag.namedandIndustry.namedmatch an existing record by its name, cut to the column's length and compared case-insensitively with whitespace collapsed, and otherwise create one through the slug function. This is what letsC#andC++coexist.Industry.namedpromises that "capitals and accents do not breed duplicates"; if that is to hold, strip combining marks in the comparison as well.tags_namedand the loop incore/csv_import.py:1052-1057becomeTag.named(...);TagForm.clean_nameandIndustryForm.clean_namecheck the clash by the same comparison, so a clash is a form error and never anIntegrityError;IndustryForm.savestops setting the slug itself.savewhenever the name has changed, with the same uniqueness step.core/importer.py:404-413, make tags through the same helper and keytags_by_slugon the slug the archive gave (entry.get("slug", "")), not on the slug of the row that was made, so archives already written with""keep their tags."", isotherfor a name that is not Other, was cut at 60, or no longer matches its name, and merges industries left with identical names, moving their companies.Done when
tests/test_tags.py:C++thenC#,remotethenRemote!, and Επείγον then Κάτι each end as two distinct tags with no error, through the Tags page (or a form error where the names are equal) and through the New tags box;Tag.namedreturns a tag for a Greek, a Cyrillic and a Japanese name; after renaming Remote to Hybrid,Tag.namedwith Hybrid returns the renamed tag and with Remote a new one, and making Remote on the Tags page succeeds; a non-Latin tag can be chosen in the applications filter and is still on its application after an export and import. Each fails today.tests/test_industries.py: two Greek industries can be made on the Industries page;Industry.namedreturns a row for Банки and Τράπεζες; a company saved with Банки under Other industries has that industry, and still has it after an export and import; a name with more than 60 slug characters made throughIndustry.named, saved unchanged throughjobs:industry_updateand passed toIndustry.namedagain is one industry, and posting it tojobs:industry_creategives the "You already have that industry." error.tests/test_api.py:POST /api/v1/applicationswith["Remote!"]whileremoteexists, with["C++", "C#"]and with a 70-character tag answers 201;POST /api/v1/companieswith a Greek industry returns it.tests/test_csv_import.py: a sheet whose Tags cells holdC++; C#, two Cyrillic names, and a 70-character tag repeated on two rows imports with the expected tags.""andother, a tag renamed before the migration, and two industries with one name come out with distinct, non-empty slugs and one industry.