Your details: nationalities, one or several, or only whether you are a citizen of the EU, the EEA or Switzerland #680

Closed
opened 2026-10-03 01:58:56 +00:00 by tiagoagueda · 0 comments
Owner

Your details has no nationality. The maintainer's list asks for it, with more than one allowed, and with a way to say less: "Add nationality option, including multiple nationality. Add the possibility to just list as European/non-European citizen." It is optional, never worked out from a name, an address, a language or a phone number, and printed on a CV only where a CV is set to print it (#308, #309).

What exists today

  • No nationality, citizenship or work-authorisation field exists on Profile or anywhere else in src (src/postulo/accounts/models.py:265-295 holds the name's companions, headline and location only).
  • The country list is core/phones.py COUNTRIES: 241 rows of ISO 3166-1 alpha-2 code, dialling prefix and English name, including territories (Aruba, Guam, Åland, Kosovo, Taiwan), and phones.BY_CODE, country_choices() and country_name() read it (core/phones.py:89, :333, :399-419). The address form's country menu and the flag tag use it (core/postal.py:355; core/flags.py, {% flag %}). Names are in English by design and are not translated.
  • The same precedent as #679 for everything around a new profile column: the form and the sidebar (accounts/forms.py:380, accounts/views.py _section_nav), PROFILE_FIELDS and CANDIDATE_PROFILE_FIELDS (core/export.py:111, :143), FORMAT_VERSION (:80; 29 at f09b6010b) and CANDIDATE_FORMAT (:104; 4), ProfileOut and ProfilePatch (api/schemas.py:891, :940), printing.SWITCHES and the show_* columns of CV (documents/printing.py:156, documents/models.py:316-326), and the contact dict handed to every renderer (documents/rendering.py:218-229).
  • A list on a profile is not new: hidden_nav_items, nav_order and dashboard_widgets are JSON lists of keys, each read back through a function that believes only what it can check (core/navigation.py known_keys). A person's rows that need their own table are the phone numbers, addresses, links and identifiers, each with a label, a kind and a primary, which a nationality has none of.
  • The candidate file's review reads a detail as one string through the column's form field (resume/candidate.py:750-790, _details), and a value that is not text is a refused row (_not_text()); a list needs its own reading.
  • The archive importer writes a profile's fields as given (core/importer.py:478-482); see #679 for the consequence.
  • The Europass Candidate XML the importer is tested with has one NationalityCode per nationality directly under Person, ISO 3166-1 alpha-2, “multiple entries of this code list are allowed” (BR-COM-43; tests/data/europass-candidate-sample.xml:90-94), which is the shape proposed here; the reader takes none (plugins/europass/candidate.py:345-398). vCard 4 has no nationality property, so the person's own card (#660) has nothing to write it to.
  • No set of the EU's, the EEA's or Switzerland's countries exists anywhere in the code (searched for member lists and for EEA); “European” appears only for languages (core/languages.py, #118) and for NACE (jobs/industries.py).

Checked against main at f09b6010b.

Proposal

  1. A list of nationalities on Profile: a JSON list of two-letter codes from phones.COUNTRIES, in the order the person gave them, each once, up to ten; a code that is not on the list is refused by the form and the API, and dropped with a note by a file. The form draws a menu for each nationality held and one empty one, as the row blocks draw extra=1, each a Basecoat select with the flag beside the country (#301), the native select being the scripts-off path (saving adds the next empty one); it sits in the card #679 makes.
  2. A scope for someone who would rather say less: a second column, nationality_scope, with three values: not said (blank), “Citizen of an EU or EEA country, or of Switzerland”, and “Citizen of another country”. It is the answer only while the list is empty; with countries listed the scope is derived from them and the stored value is cleared on save, so the two can never disagree. The EU, EEA and Switzerland set is one constant in a module of its own (27 EU members, Iceland, Liechtenstein and Norway, and Switzerland), with where each part comes from in the comment, and a test that every code in it is in phones.COUNTRIES.
  3. Printing, off by default: a show_nationality switch on CV in printing.SWITCHES, so the form, the API and the importer pick it up; the contact dict gains nationalities (names) and nationality_scope; the base themes, the plain text and the Word file print one line, “Nationality: Portugal, Brazil”, or the scope's wording when no country is listed.
  4. Archive, candidate file, API. PROFILE_FIELDS and CANDIDATE_PROFILE_FIELDS gain both columns; each bumps its format by one from whatever it is when the commit lands, with its line in the history comment. A candidate file's list is read code by code: each is validated, each becomes a review row (add, or already there), and nothing a person has is replaced. ProfileOut and ProfilePatch take nationalities (a list of codes) and nationality_scope, with the page's rules and a 422 naming the field; CVPrintsOut/CVPrintsIn gain the boolean.
  5. Not in the first commit: reading nationalities from a Europass file, and writing them anywhere another format has no place for them.

Decisions

  • What “European” means. The list says “European/non-European”. Geographically it covers Norway, Switzerland, the United Kingdom, Ukraine and the Western Balkans; what an employer asks and what a right to work in the EU turns on is citizenship of the EU, the EEA or Switzerland, which excludes the United Kingdom since 2020. Recommended: the narrow, checkable meaning, worded exactly so (as in the Proposal), derived from a set that is one constant. If the maintainer wants the wider sense it is a different constant and a different sentence, and the issue becomes a question of where Europe stops. The CV prints the countries or the wording, never the word “European” on its own.
  • A list, a scope, or both. The list's way of saying less is a scope, not an empty list: someone with no nationality listed is not a statement of anything. Recommended: both, with the list winning, as above. The alternative, two modes with a switch between them, hides the countries a person already entered when they change their mind.
  • Why not rows. A nationality has no label, no kind and no primary, so a table of OwnedModel rows would be a model, a migration, a formset, a removal dialog (#303) and an isolation test for a list of codes. Recommended: a JSON list on the profile, validated at every door by one function, as the navigation lists are.
  • Which countries. phones.COUNTRIES was made for dialling prefixes. It has territories, which are not nationalities, and no code for stateless people or refugees (ISO 3166 has none; ICAO's travel-document list uses XXA, XXB, XXC for these, not verified here). Recommended: reuse it; it already drives the address menu and the flags, and one list is one thing to keep. A stateless person leaves the list empty and may choose the scope; if the maintainer wants the case named it is a later, separate row in the menu with its own wording.
  • How a country prints. Citizens are named by an adjective (“Portuguese”) in a CV in most languages, and no source Postulo has gives adjectives per language: ISO 3166 has none and the CLDR-based libraries in #689 carry country names, not nationalities. Recommended: print the country, in English as location_line already does (#309 recorded “München, Germany” on a French CV as the same limitation), under a translated label; revisit with #689.
  • Sensitivity. Nationality is personal data and can reveal ethnic origin; it is not on the Article 9(1) list in itself (to be confirmed by the maintainer for any instance that answers to a regulator). Recommended: the treatment #679 sets: optional, never inferred, off on every CV, never in a list, a log, a webhook or a search, removed with the account, and named in docs/THREAT-MODEL.md.
  • A limit of ten. Nobody holds more; a bound keeps a hand-written request from storing a thousand. Recommended: ten, in the column's validator.
  • Work authorisation. Whether somebody may work somewhere is a different question from what passport they hold, and not asked here. Recommended: out of scope.

Builds on

#679 (the card, the switches, the format bumps, the validator pattern), #309 and #308 (optional, never inferred, off by default), #181 (the candidate file), #301 (Basecoat's select with a flag beside each country), #306 (postal addresses and the country list). #689 for names in the document's language.

Done when

  • A new test file tests/test_nationalities.py: the validator (known codes, case folded, duplicates removed, order kept, an unknown code and more than ten refused, a non-list refused); the scope is cleared when countries are listed and derived as “EU, EEA or Switzerland” for PT, NO and CH and not for GB or BR; every code of the set is in phones.COUNTRIES; the form saves, adds a second and clears; nothing prints by default and the switch prints the line in the preview, the plain text and the Word file.
  • tests/test_cv_prints.py and tests/security/test_cv_prints.py cover the switch; the archive round trip and tests/test_export.py carry both columns at the new format; tests/test_candidate_file.py holds the new fingerprint and a review row per listed code, and tests/security/test_candidate_file.py a list that is the wrong type, too long, or full of unknown codes; the archive importer drops a bad list with a report line (tests/test_importers.py).
  • tests/test_api.py covers the two fields on GET and PATCH /profile, including the 422 for an unknown code; tests/test_profile_sections.py is unchanged because the card is #679's. Search is unaffected: nothing here is indexed (core/search.py has no profile group), and a test says so for the new fields.
  • The account-deletion test and the isolation sweep show the fields gone with the account (tests/test_account_deletion.py, tests/security/test_isolation_sweep.py). The record of processing needs no line (core/gdpr.py draws it from plugins and connections), as in #679.
  • The strings (labels, the scope's two wordings, “Nationality:”) are in the working catalogues (en-GB, fr-FR, pt-PT as drafts) and in Weblate (#349); the wiki's Your career record, CVs and The capture API and docs/THREAT-MODEL.md change in the same commit.

From the list of improvements of 2026-10-02.

Your details has no nationality. The maintainer's list asks for it, with more than one allowed, and with a way to say less: "Add nationality option, including multiple nationality. Add the possibility to just list as European/non-European citizen." It is optional, never worked out from a name, an address, a language or a phone number, and printed on a CV only where a CV is set to print it (#308, #309). ## What exists today - No nationality, citizenship or work-authorisation field exists on `Profile` or anywhere else in `src` (`src/postulo/accounts/models.py:265-295` holds the name's companions, headline and location only). - The country list is `core/phones.py` `COUNTRIES`: 241 rows of ISO 3166-1 alpha-2 code, dialling prefix and English name, including territories (Aruba, Guam, Åland, Kosovo, Taiwan), and `phones.BY_CODE`, `country_choices()` and `country_name()` read it (`core/phones.py:89`, `:333`, `:399-419`). The address form's country menu and the flag tag use it (`core/postal.py:355`; `core/flags.py`, `{% flag %}`). Names are in English by design and are not translated. - The same precedent as #679 for everything around a new profile column: the form and the sidebar (`accounts/forms.py:380`, `accounts/views.py` `_section_nav`), `PROFILE_FIELDS` and `CANDIDATE_PROFILE_FIELDS` (`core/export.py:111`, `:143`), `FORMAT_VERSION` (`:80`; 29 at `f09b6010b`) and `CANDIDATE_FORMAT` (`:104`; 4), `ProfileOut` and `ProfilePatch` (`api/schemas.py:891`, `:940`), `printing.SWITCHES` and the `show_*` columns of `CV` (`documents/printing.py:156`, `documents/models.py:316-326`), and the `contact` dict handed to every renderer (`documents/rendering.py:218-229`). - A list on a profile is not new: `hidden_nav_items`, `nav_order` and `dashboard_widgets` are JSON lists of keys, each read back through a function that believes only what it can check (`core/navigation.py` `known_keys`). A person's rows that need their own table are the phone numbers, addresses, links and identifiers, each with a label, a kind and a primary, which a nationality has none of. - The candidate file's review reads a detail as one string through the column's form field (`resume/candidate.py:750-790`, `_details`), and a value that is not text is a refused row (`_not_text()`); a list needs its own reading. - The archive importer writes a profile's fields as given (`core/importer.py:478-482`); see #679 for the consequence. - The Europass Candidate XML the importer is tested with has one `NationalityCode` per nationality directly under `Person`, ISO 3166-1 alpha-2, “multiple entries of this code list are allowed” (BR-COM-43; `tests/data/europass-candidate-sample.xml:90-94`), which is the shape proposed here; the reader takes none (`plugins/europass/candidate.py:345-398`). vCard 4 has no nationality property, so the person's own card (#660) has nothing to write it to. - No set of the EU's, the EEA's or Switzerland's countries exists anywhere in the code (searched for member lists and for EEA); “European” appears only for languages (`core/languages.py`, #118) and for NACE (`jobs/industries.py`). Checked against `main` at `f09b6010b`. ## Proposal 1. **A list of nationalities on `Profile`**: a JSON list of two-letter codes from `phones.COUNTRIES`, in the order the person gave them, each once, up to ten; a code that is not on the list is refused by the form and the API, and dropped with a note by a file. The form draws a menu for each nationality held and one empty one, as the row blocks draw `extra=1`, each a Basecoat select with the flag beside the country (#301), the native select being the scripts-off path (saving adds the next empty one); it sits in the card #679 makes. 2. **A scope for someone who would rather say less**: a second column, `nationality_scope`, with three values: not said (blank), “Citizen of an EU or EEA country, or of Switzerland”, and “Citizen of another country”. It is the answer only while the list is empty; with countries listed the scope is derived from them and the stored value is cleared on save, so the two can never disagree. The EU, EEA and Switzerland set is one constant in a module of its own (27 EU members, Iceland, Liechtenstein and Norway, and Switzerland), with where each part comes from in the comment, and a test that every code in it is in `phones.COUNTRIES`. 3. **Printing, off by default**: a `show_nationality` switch on `CV` in `printing.SWITCHES`, so the form, the API and the importer pick it up; the `contact` dict gains `nationalities` (names) and `nationality_scope`; the base themes, the plain text and the Word file print one line, “Nationality: Portugal, Brazil”, or the scope's wording when no country is listed. 4. **Archive, candidate file, API.** `PROFILE_FIELDS` and `CANDIDATE_PROFILE_FIELDS` gain both columns; each bumps its format by one from whatever it is when the commit lands, with its line in the history comment. A candidate file's list is read code by code: each is validated, each becomes a review row (add, or already there), and nothing a person has is replaced. `ProfileOut` and `ProfilePatch` take `nationalities` (a list of codes) and `nationality_scope`, with the page's rules and a 422 naming the field; `CVPrintsOut`/`CVPrintsIn` gain the boolean. 5. **Not in the first commit:** reading nationalities from a Europass file, and writing them anywhere another format has no place for them. ## Decisions - **What “European” means.** The list says “European/non-European”. Geographically it covers Norway, Switzerland, the United Kingdom, Ukraine and the Western Balkans; what an employer asks and what a right to work in the EU turns on is citizenship of the EU, the EEA or Switzerland, which excludes the United Kingdom since 2020. **Recommended:** the narrow, checkable meaning, worded exactly so (as in the Proposal), derived from a set that is one constant. If the maintainer wants the wider sense it is a different constant and a different sentence, and the issue becomes a question of where Europe stops. The CV prints the countries or the wording, never the word “European” on its own. - **A list, a scope, or both.** The list's way of saying less is a scope, not an empty list: someone with no nationality listed is not a statement of anything. **Recommended:** both, with the list winning, as above. The alternative, two modes with a switch between them, hides the countries a person already entered when they change their mind. - **Why not rows.** A nationality has no label, no kind and no primary, so a table of `OwnedModel` rows would be a model, a migration, a formset, a removal dialog (#303) and an isolation test for a list of codes. **Recommended:** a JSON list on the profile, validated at every door by one function, as the navigation lists are. - **Which countries.** `phones.COUNTRIES` was made for dialling prefixes. It has territories, which are not nationalities, and no code for stateless people or refugees (ISO 3166 has none; ICAO's travel-document list uses `XXA`, `XXB`, `XXC` for these, not verified here). **Recommended:** reuse it; it already drives the address menu and the flags, and one list is one thing to keep. A stateless person leaves the list empty and may choose the scope; if the maintainer wants the case named it is a later, separate row in the menu with its own wording. - **How a country prints.** Citizens are named by an adjective (“Portuguese”) in a CV in most languages, and no source Postulo has gives adjectives per language: ISO 3166 has none and the CLDR-based libraries in #689 carry country names, not nationalities. **Recommended:** print the country, in English as `location_line` already does (#309 recorded “München, Germany” on a French CV as the same limitation), under a translated label; revisit with #689. - **Sensitivity.** Nationality is personal data and can reveal ethnic origin; it is not on the Article 9(1) list in itself (to be confirmed by the maintainer for any instance that answers to a regulator). **Recommended:** the treatment #679 sets: optional, never inferred, off on every CV, never in a list, a log, a webhook or a search, removed with the account, and named in `docs/THREAT-MODEL.md`. - **A limit of ten.** Nobody holds more; a bound keeps a hand-written request from storing a thousand. **Recommended:** ten, in the column's validator. - **Work authorisation.** Whether somebody may work somewhere is a different question from what passport they hold, and not asked here. **Recommended:** out of scope. ## Builds on #679 (the card, the switches, the format bumps, the validator pattern), #309 and #308 (optional, never inferred, off by default), #181 (the candidate file), #301 (Basecoat's select with a flag beside each country), #306 (postal addresses and the country list). #689 for names in the document's language. ## Done when - A new test file `tests/test_nationalities.py`: the validator (known codes, case folded, duplicates removed, order kept, an unknown code and more than ten refused, a non-list refused); the scope is cleared when countries are listed and derived as “EU, EEA or Switzerland” for `PT`, `NO` and `CH` and not for `GB` or `BR`; every code of the set is in `phones.COUNTRIES`; the form saves, adds a second and clears; nothing prints by default and the switch prints the line in the preview, the plain text and the Word file. - `tests/test_cv_prints.py` and `tests/security/test_cv_prints.py` cover the switch; the archive round trip and `tests/test_export.py` carry both columns at the new format; `tests/test_candidate_file.py` holds the new fingerprint and a review row per listed code, and `tests/security/test_candidate_file.py` a list that is the wrong type, too long, or full of unknown codes; the archive importer drops a bad list with a report line (`tests/test_importers.py`). - `tests/test_api.py` covers the two fields on `GET` and `PATCH /profile`, including the 422 for an unknown code; `tests/test_profile_sections.py` is unchanged because the card is #679's. Search is unaffected: nothing here is indexed (`core/search.py` has no profile group), and a test says so for the new fields. - The account-deletion test and the isolation sweep show the fields gone with the account (`tests/test_account_deletion.py`, `tests/security/test_isolation_sweep.py`). The record of processing needs no line (`core/gdpr.py` draws it from plugins and connections), as in #679. - The strings (labels, the scope's two wordings, “Nationality:”) are in the working catalogues (en-GB, fr-FR, pt-PT as drafts) and in Weblate (#349); the wiki's *Your career record*, *CVs* and *The capture API* and `docs/THREAT-MODEL.md` change in the same commit. From the list of improvements of 2026-10-02.
tiagoagueda added this to the 0.6.0 milestone 2026-10-03 01:58:56 +00:00
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#680
No description provided.