Identifier scheme field renders as free text; a valid ORCID is refused with 'Unknown identifier scheme.' #298

Closed
opened 2026-09-23 19:32:31 +00:00 by tiagoagueda · 1 comment
Owner

What happens

On the profile page (Your details), the identifiers' block renders the scheme input as a free-text box. Saving a valid ORCID - e.g. scheme ORCID, value 0000-0003-3947-1881 (the checksum passes) - is refused with:

Unknown identifier scheme.

The identifiers' block on a company page renders the same way.

Reproduced on current main (b47674329)

  • GET of the profile page renders the scheme field as <input type="text" name="identifiers-0-scheme" maxlength="20"> - not a select, despite the help text "Choose what kind of identifier this is."
  • Posting the formset with the exact internal key orcid and value 0000-0003-3947-1881 validates and saves fine. Only the bare lowercase key passes; nothing on the page tells the person what it is. Typing the scheme's own name - ORCID - reaches find() in core/identifiers.py, matches nothing, and raises the scheme ValidationError.

Root cause

fea7b090d ("Make one registry of identifiers, and say what each one identifies", #109; shipped in v0.3.0) moved the schemes out of the model field's choices and into the plugin registry. PersonIdentifier.scheme deliberately lost its choices (scheme_field() in core/identifiers.py: "Not choices, which is what it was"), and the form now sets

self.fields["scheme"].choices = [("", "—"), *identifiers.choices()]

in __init__ (accounts/forms.py:607, jobs/forms.py:364). Django picks the widget from choices when the field is constructed; assigning choices afterwards leaves the default TextInput in place, so the select the field had before the registry work never came back.

Why the tests did not catch it

The identifier tests (test_person_identifiers.py, test_name_if_other.py) exercise the forms from posted data only. No test asserts what the scheme field renders; a widget assertion - a select populated from the registry, scoped to the subject - would have caught this.

Affected

  • PersonIdentifierForm, accounts/forms.py:607 - Your details
  • CompanyIdentifierForm, jobs/forms.py:364 - company page

Expected

The scheme field renders as a select (the blank choice plus the registry's schemes for the subject), or at least accepts a scheme's display label case-insensitively. (Either way, a test that renders the page and checks the widget would keep it there.)

Environment

Postulo 0.3.0 / main b47674329, Windows, dev settings; reproduced against a fresh in-memory database.

**What happens** On the profile page (Your details), the identifiers' block renders the scheme input as a free-text box. Saving a valid ORCID - e.g. scheme `ORCID`, value `0000-0003-3947-1881` (the checksum passes) - is refused with: > Unknown identifier scheme. The identifiers' block on a company page renders the same way. **Reproduced on current main (b47674329)** - `GET` of the profile page renders the scheme field as `<input type="text" name="identifiers-0-scheme" maxlength="20">` - not a select, despite the help text "Choose what kind of identifier this is." - Posting the formset with the exact internal key `orcid` and value `0000-0003-3947-1881` validates and saves fine. Only the bare lowercase key passes; nothing on the page tells the person what it is. Typing the scheme's own name - `ORCID` - reaches `find()` in `core/identifiers.py`, matches nothing, and raises the `scheme` ValidationError. **Root cause** `fea7b090d` ("Make one registry of identifiers, and say what each one identifies", #109; shipped in v0.3.0) moved the schemes out of the model field's `choices` and into the plugin registry. `PersonIdentifier.scheme` deliberately lost its `choices` (`scheme_field()` in `core/identifiers.py`: "Not ``choices``, which is what it was"), and the form now sets ```python self.fields["scheme"].choices = [("", "—"), *identifiers.choices()] ``` in `__init__` (`accounts/forms.py:607`, `jobs/forms.py:364`). Django picks the widget from `choices` when the field is *constructed*; assigning `choices` afterwards leaves the default `TextInput` in place, so the select the field had before the registry work never came back. **Why the tests did not catch it** The identifier tests (`test_person_identifiers.py`, `test_name_if_other.py`) exercise the forms from posted data only. No test asserts what the scheme field *renders*; a widget assertion - a select populated from the registry, scoped to the subject - would have caught this. **Affected** - `PersonIdentifierForm`, `accounts/forms.py:607` - Your details - `CompanyIdentifierForm`, `jobs/forms.py:364` - company page **Expected** The scheme field renders as a select (the blank choice plus the registry's schemes for the subject), or at least accepts a scheme's display label case-insensitively. (Either way, a test that renders the page and checks the widget would keep it there.) **Environment** Postulo 0.3.0 / main `b47674329`, Windows, dev settings; reproduced against a fresh in-memory database.
tiagoagueda added this to the 0.5.0 milestone 2026-09-25 12:58:52 +00:00
Author
Owner

Fixed on bug/298 (d56463974), fast-forwarded to main and pushed.

The report's diagnosis was exactly right, with a second layer underneath it: assigning choices after the form is built does not change the widget Django picked from the model field, and this field is a plain CharField to boot — a CharField's choices attribute is read by nothing, not for validation and not for rendering. So the fix is not a select that inherits the choices, but a select handed them:

  • PersonIdentifierForm (Your details) and CompanyIdentifierForm (company page) now build the choices from the subject-scoped registry view and pass them to the widget: forms.Select(choices=...).
  • The select is scoped the way the model already refuses: a person's row offers ORCID, ResearcherID, Scopus, ISNI, Wikidata, LinkedIn and Other, and no lei; a company's row offers its own set, and no orcid.
  • This is what the stylesheet had been waiting for since #284: its :has(select option[value="other"]:checked) rule never matched a text box.

Keeping it there, the gap the report named:

  • test_the_kind_is_a_select_of_the_schemes_that_identify_people renders the profile page and asserts the control is a <select> carrying the registry's person schemes — and no company scheme.
  • test_the_company_form_offers_a_select_scoped_to_companies does the same on the company page, scoped the other way.
  • Both also assert the widget type on the form class directly, so a template change cannot hide the regression.

One housekeeping note: the branch's first full-suite run failed test_translations.py::test_the_catalogues_are_what_a_fresh_extraction_writes[postulo] — the seven lines inserted into each forms.py moved the source-line pointers of the strings below them. The catalogues were re-extracted in the same commit (pointers and dates only; extract --check clean) and recompiled.

Full suite after the re-extraction: 8615 passed, 63 skipped, 1 xfailed.

Fixed on `bug/298` (`d56463974`), fast-forwarded to `main` and pushed. The report's diagnosis was exactly right, with a second layer underneath it: assigning `choices` after the form is built does not change the widget Django picked from the model field, and this field is a plain `CharField` to boot — a `CharField`'s `choices` attribute is read by nothing, not for validation and not for rendering. So the fix is not a select that inherits the choices, but a select handed them: - `PersonIdentifierForm` (Your details) and `CompanyIdentifierForm` (company page) now build the choices from the subject-scoped registry view and pass them to the widget: `forms.Select(choices=...)`. - The select is scoped the way the model already refuses: a person's row offers ORCID, ResearcherID, Scopus, ISNI, Wikidata, LinkedIn and Other, and no `lei`; a company's row offers its own set, and no `orcid`. - This is what the stylesheet had been waiting for since #284: its `:has(select option[value="other"]:checked)` rule never matched a text box. Keeping it there, the gap the report named: - `test_the_kind_is_a_select_of_the_schemes_that_identify_people` renders the profile page and asserts the control is a `<select>` carrying the registry's person schemes — and no company scheme. - `test_the_company_form_offers_a_select_scoped_to_companies` does the same on the company page, scoped the other way. - Both also assert the widget type on the form class directly, so a template change cannot hide the regression. One housekeeping note: the branch's first full-suite run failed `test_translations.py::test_the_catalogues_are_what_a_fresh_extraction_writes[postulo]` — the seven lines inserted into each `forms.py` moved the source-line pointers of the strings below them. The catalogues were re-extracted in the same commit (pointers and dates only; `extract --check` clean) and recompiled. Full suite after the re-extraction: 8615 passed, 63 skipped, 1 xfailed.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Postulo/postulo#298
No description provided.