Your details: date and place of birth, optional, and printed on a CV only when a CV says so #679

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

Your details has no date of birth and no place of birth. The maintainer's list asks for both ("Add place and date of birth", under Your details). They are the most identifying things a person can add to a profile, so they are optional, never worked out from anything, and kept off every document until a CV is set to print them, as the form of address and the pronouns were (#309, #308).

What exists today

  • Nothing stores either. Profile holds the name's companions form_of_address and pronouns, a headline and a free-text location (src/postulo/accounts/models.py:265-295); grep finds no birth, nationality or gender field anywhere in src.
  • How a personal detail is added here (the precedent, #309 and #308). A column on Profile; the form on Your details, in the Your name card (templates/accounts/profile.html:87); PROFILE_FIELDS in the archive (core/export.py:111) and, if it is what a CV may print, CANDIDATE_PROFILE_FIELDS (:143); ProfileOut and ProfilePatch in the API (api/schemas.py:891, :940, profile_out :961); a show_* switch per detail on CV (documents/models.py:316-326), listed in printing.SWITCHES (documents/printing.py:156) which the form, the API and the importer all loop over (documents/forms.py:294, api/routers/documents.py:78,191, core/importer.py:955); and a key on the contact dict that every theme, the preview, the plain text and the Word file are handed (documents/rendering.py:218-229). Off by default: "neither is printed anywhere unless a CV says so".
  • The archive importer writes a profile's fields without checking them: for name, value in profile_data.items(): if hasattr(profile, name) and value: setattr(profile, name, value) (core/importer.py:478-482). A new field with a format would be stored as the file wrote it unless its own reader is put in front of it.
  • The candidate file reads text details through the column's own form field (resume/candidate.py:750-790, _details), so a validator on the column is the one place a file's value is held to the page's rule.
  • Archive format and candidate format are numbers a commit bumps: FORMAT_VERSION (core/export.py:80, 29 at f09b6010b) and CANDIDATE_FORMAT (:104, 4). The candidate fingerprint is held by tests/test_candidate_file.py:354-387.
  • Places elsewhere are text or a code: PostalAddress.municipality and .country (a two-letter code, core/models.py PostalAddress), the country list is core/phones.py COUNTRIES (241 rows, English names), used by the address form (core/postal.py:355); jobs/places.py matches a company's free text against GeoNames' cities of more than a thousand inhabitants, offline, as a guess, for the map (#108).
  • docs/THREAT-MODEL.md names a home address as "the sharpest thing this application holds" and gives the rules that follow from it. A date and a place of birth are not on that list, and are exactly what identity checks ask for.
  • The processing record is not a list of data kinds: core/gdpr.py record_of_processing() is drawn from the installed plugins and the connections, so a field of the account holder's own profile needs no entry there. What core/gdpr.py does carry per data kind is the document and the erasure of a contact (contact_document, erase_contact, retention_dry_run): it does not apply to the account holder's own profile, which the archive and account deletion (accounts/deletion.py) answer.
  • vCard 4 has the two: BDAY, which takes a date with reduced accuracy (19960415, --0415) or text, and, from RFC 6474, BIRTHPLACE, a text or a URI (RFC 6350 section 6.2.5, RFC 6474). The Europass Candidate XML the importer is tested with carries the date of birth as hr:BirthDate directly under Person, in the same reduced forms (YYYY-MM-DD, YYYY-MM or YYYY, rule BR-COM-06 in the sample's own comments; tests/data/europass-candidate-sample.xml:95-97) and has no place of birth; plugins/europass/candidate.py _read_person (:345-398) reads no birth date.

Checked against main at f09b6010b.

Proposal

  1. Columns on Profile, all optional: a date of birth that may be a year, a year and month, or a whole date; a place of birth as the text typed (a town or city) and a country as a two-letter code, from the list the address form already offers. Blank means nothing, and nothing is worked out from anything (an age is never computed, stored or shown).
  2. A date field that holds what the person knows. One validator on the column, used by the form, the API, the candidate file and the importer alike: the value is a real date, in ISO 8601's reduced forms (1990, 1990-03, 1990-03-12), not in the future, not before 1900. The form is three boxes in a fieldset with a legend (day, month, year, each a text input with inputmode="numeric" and the bday-day, bday-month and bday-year autocomplete tokens, SC 1.3.5), in the order the interface language writes dates; a year alone is allowed.
  3. On Your details: a new card after Your name, “Personal details”, with an entry in the sidebar (section-personal, accounts/views.py _section_nav); #680 and #681 put their fields in the same card. A sentence says what the card is for and that none of it is printed until a CV is set to print it.
  4. A switch per CV for each, off, joined to printing.SWITCHES: Print your date of birth and Print your place of birth, with the CV form, the API and the importer picking them up from that one table; printing.is_default and Printed carry them; documents/rendering.contact_details adds birth_date and birth_place keys, which a theme prints or ignores, and Postulo's own base themes print one “Born …” line when they are set, in the document's language and date style. The plain text and the Word file print the same line.
  5. Archive, candidate file, API. The archive gains the three fields in PROFILE_FIELDS (one more than the format it is on when the commit lands, with its line in the history comment of core/export.py); the candidate file carries them too (CANDIDATE_PROFILE_FIELDS, format one more than now), read back "filled where blank, kept where not", each through the column's validator, so a malformed date in a file is a refused row on the review page and nothing stored. The API's ProfileOut and ProfilePatch gain them, held to the same rule (a 422 naming the field), and CVPrintsOut/CVPrintsIn gain the two booleans.
  6. The importer reads them through the same validator and drops one that fails with a line in the report, instead of storing it. (The importer's blind setattr is wider than this issue; this closes it for these fields and names the rest in the report of the commit.)
  7. Threat model and wiki. docs/THREAT-MODEL.md names a date and place of birth beside the home address in What is most worth defending, with the same rules: owner-scoped like every row, never in a list, a log, a search result, a webhook or an error message, and printed only where a CV says so. The wiki's Your career record (the candidate file's contents), CVs (the new switches) and The capture API (the new fields) are updated in the same commit.

Decisions

  • Partial dates: how to store one. (a) Three nullable integers; (b) a date plus a precision; (c) one text column holding the ISO 8601 reduced form. Recommended: (c). It is the form the archive, the candidate file, the API and vCard already speak, the validator is the single rule, and nothing here sorts or filters by birth date. Django's SelectDateWidget needs all three parts and offers only the next ten years unless given a range, so it is not the form; the GOV.UK Design System's three-box date input is the pattern followed.
  • Place of birth: free text and country, or GeoNames. jobs/places.py places a company at city level as a guess and refuses the street on purpose (#108, the threat model). A place of birth is a fact someone states, often a village GeoNames' cities-above-1,000 table lacks, and must never be pinned on a map. Recommended: the text typed and a country code, no lookup, no coordinates, never offered to the map (#702).
  • Country names. The list is core/phones.py's, in English, as the address form does it. Recommended: reuse it. A printed country in the document's language needs names Postulo does not have (#309 left "München, Germany" on a French CV for the same reason); #689 is where a general source lands.
  • On the CV: yes or no, and how much. Default off, like the form of address (#308, #309). A switch for the date and one for the place, a plain yes or no. A CV that should print only a year is served by storing only a year. Recommended: two switches now; a three-state choice (none, year, full) only if somebody asks.
  • In the candidate file. The file is "the part of an account that is the person" and its stated rule is "what a CV prints"; these are what a CV may print. Recommended: carry them. The file is the person's to hand to another Postulo; the review page (#181) already says what each part would do before anything is written.
  • Who may read it through the API. GET /profile is under the read scope and PATCH under write, as the name is; a browser extension's token holds captures and sees neither. Recommended: the same scopes, and nothing about these fields in any list endpoint, webhook or log.
  • Legal status. Date and place of birth are personal data; neither is on the list of special categories in Article 9(1) of the GDPR, and the maintainer should confirm that reading for any instance that must answer to a regulator. Nothing in this issue depends on it: the fields are optional, off on every CV, and removed with the account.

Prior art

  • Standards, not libraries. ISO 8601's reduced-precision forms; vCard 4 BDAY and RFC 6474's BIRTHPLACE for the day #660 writes the person's own card (a stored value maps one to one; a year-only date is BDAY:1990).
  • edtf (Library of Congress EDTF, MIT, 5.0.2 released 2026-09-17, no advisories on OSV, not a dependency): parses uncertain, approximate and interval dates. Rejected: what is needed is three shapes and a range check, about twenty lines; a dependency for the part of EDTF nobody asked for is more to keep current than the code it replaces. The maintainer may overrule: it is MIT, so compatible with AGPL-3.0.
  • django-partial-date 1.3.2 (MIT, released 2022-01-20, no advisory on OSV, depends on six, not a dependency): a Django field for a date that may be only a year or a month. Rejected: not released since 2022, and it adds a field type where one validated text column is enough (decision (c)).
  • Hand-written precedent: applications/ical.py and documents/docx.py are written by hand on purpose; this is smaller than either.

Builds on

#309 (form of address and pronouns: optional, never inferred), #308 (a CV chooses what it prints), #181 (the candidate file), #306 (postal addresses and countries), #337 (the language of a date's wording).

Needed by

#680 (nationalities) and #681 (gender) add to the same card, the same switches and the same format bumps. #660 (contacts as vCard 4) writes the two on the person's own card, as BDAY and BIRTHPLACE; it exports that card and never reads it back.

Done when

  • A new test file tests/test_birth_details.py: the validator (a year, a year and month, a date, refused: an impossible day, a future date, 1899, text, a NUL); the form saves and clears each; the sidebar lists the card; nothing is printed on a CV by default and the two switches print one “Born …” line in the document's language, in the preview, the plain text and the Word file.
  • tests/test_cv_prints.py and tests/security/test_cv_prints.py cover the two switches (default, chosen, the archive carrying them); tests/test_export.py and the archive round trip carry the fields at the new format; tests/test_candidate_file.py holds the new candidate fingerprint (SHAPES) and a review row for a good value and a refused one; tests/security/test_candidate_file.py a file with a malformed date; the archive importer drops one (tests/test_importers.py).
  • tests/test_api.py: GET /profile and PATCH /profile carry the fields with the page's bounds; tests/test_profile_sections.py expects the new anchor. Search is unaffected: nothing here is indexed (core/search.py has no profile group), and a test says so for the new fields.
  • tests/security/test_isolation_sweep.py and the account-deletion test (tests/test_account_deletion.py) show the fields gone with the account; tests/test_gdpr.py needs no change (the record of processing is drawn from plugins), which a one-line comment in the commit says.
  • The new strings (card title, labels, help, “Born …”) are in the working catalogues (en-GB, fr-FR, pt-PT as drafts) and in Weblate (#349). tests/test_wiki_surface.py is unaffected unless a new name reaches postulo.plugins.api.
  • The wiki pages and docs/THREAT-MODEL.md named above are changed in the same commit; the CHANGELOG line ends (#N).

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

Your details has no date of birth and no place of birth. The maintainer's list asks for both ("Add place and date of birth", under Your details). They are the most identifying things a person can add to a profile, so they are optional, never worked out from anything, and kept off every document until a CV is set to print them, as the form of address and the pronouns were (#309, #308). ## What exists today - Nothing stores either. `Profile` holds the name's companions `form_of_address` and `pronouns`, a headline and a free-text `location` (`src/postulo/accounts/models.py:265-295`); `grep` finds no birth, nationality or gender field anywhere in `src`. - **How a personal detail is added here (the precedent, #309 and #308).** A column on `Profile`; the form on *Your details*, in the *Your name* card (`templates/accounts/profile.html:87`); `PROFILE_FIELDS` in the archive (`core/export.py:111`) and, if it is what a CV may print, `CANDIDATE_PROFILE_FIELDS` (`:143`); `ProfileOut` and `ProfilePatch` in the API (`api/schemas.py:891`, `:940`, `profile_out` `:961`); a `show_*` switch per detail on `CV` (`documents/models.py:316-326`), listed in `printing.SWITCHES` (`documents/printing.py:156`) which the form, the API and the importer all loop over (`documents/forms.py:294`, `api/routers/documents.py:78,191`, `core/importer.py:955`); and a key on the `contact` dict that every theme, the preview, the plain text and the Word file are handed (`documents/rendering.py:218-229`). Off by default: "neither is printed anywhere unless a CV says so". - **The archive importer writes a profile's fields without checking them**: `for name, value in profile_data.items(): if hasattr(profile, name) and value: setattr(profile, name, value)` (`core/importer.py:478-482`). A new field with a format would be stored as the file wrote it unless its own reader is put in front of it. - The candidate file reads text details through the column's own form field (`resume/candidate.py:750-790`, `_details`), so a validator on the column is the one place a file's value is held to the page's rule. - Archive format and candidate format are numbers a commit bumps: `FORMAT_VERSION` (`core/export.py:80`, 29 at `f09b6010b`) and `CANDIDATE_FORMAT` (`:104`, 4). The candidate fingerprint is held by `tests/test_candidate_file.py:354-387`. - Places elsewhere are text or a code: `PostalAddress.municipality` and `.country` (a two-letter code, `core/models.py` `PostalAddress`), the country list is `core/phones.py` `COUNTRIES` (241 rows, English names), used by the address form (`core/postal.py:355`); `jobs/places.py` matches a company's free text against GeoNames' cities of more than a thousand inhabitants, offline, as a guess, for the map (#108). - `docs/THREAT-MODEL.md` names a home address as "the sharpest thing this application holds" and gives the rules that follow from it. A date and a place of birth are not on that list, and are exactly what identity checks ask for. - The processing record is not a list of data kinds: `core/gdpr.py` `record_of_processing()` is drawn from the installed plugins and the connections, so a field of the account holder's own profile needs no entry there. What `core/gdpr.py` does carry per data kind is the document and the erasure of a *contact* (`contact_document`, `erase_contact`, `retention_dry_run`): it does not apply to the account holder's own profile, which the archive and account deletion (`accounts/deletion.py`) answer. - vCard 4 has the two: `BDAY`, which takes a date with reduced accuracy (`19960415`, `--0415`) or text, and, from RFC 6474, `BIRTHPLACE`, a text or a URI (RFC 6350 section 6.2.5, RFC 6474). The Europass Candidate XML the importer is tested with carries the date of birth as `hr:BirthDate` directly under `Person`, in the same reduced forms (`YYYY-MM-DD`, `YYYY-MM` or `YYYY`, rule BR-COM-06 in the sample's own comments; `tests/data/europass-candidate-sample.xml:95-97`) and has no place of birth; `plugins/europass/candidate.py` `_read_person` (`:345-398`) reads no birth date. Checked against `main` at `f09b6010b`. ## Proposal 1. **Columns on `Profile`**, all optional: a date of birth that may be a year, a year and month, or a whole date; a place of birth as the text typed (a town or city) and a country as a two-letter code, from the list the address form already offers. Blank means nothing, and nothing is worked out from anything (an age is never computed, stored or shown). 2. **A date field that holds what the person knows.** One validator on the column, used by the form, the API, the candidate file and the importer alike: the value is a real date, in ISO 8601's reduced forms (`1990`, `1990-03`, `1990-03-12`), not in the future, not before 1900. The form is three boxes in a `fieldset` with a legend (day, month, year, each a text input with `inputmode="numeric"` and the `bday-day`, `bday-month` and `bday-year` autocomplete tokens, SC 1.3.5), in the order the interface language writes dates; a year alone is allowed. 3. **On *Your details*:** a new card after *Your name*, “Personal details”, with an entry in the sidebar (`section-personal`, `accounts/views.py` `_section_nav`); #680 and #681 put their fields in the same card. A sentence says what the card is for and that none of it is printed until a CV is set to print it. 4. **A switch per CV for each**, off, joined to `printing.SWITCHES`: *Print your date of birth* and *Print your place of birth*, with the CV form, the API and the importer picking them up from that one table; `printing.is_default` and `Printed` carry them; `documents/rendering.contact_details` adds `birth_date` and `birth_place` keys, which a theme prints or ignores, and Postulo's own base themes print one “Born …” line when they are set, in the document's language and date style. The plain text and the Word file print the same line. 5. **Archive, candidate file, API.** The archive gains the three fields in `PROFILE_FIELDS` (one more than the format it is on when the commit lands, with its line in the history comment of `core/export.py`); the candidate file carries them too (`CANDIDATE_PROFILE_FIELDS`, format one more than now), read back "filled where blank, kept where not", each through the column's validator, so a malformed date in a file is a refused row on the review page and nothing stored. The API's `ProfileOut` and `ProfilePatch` gain them, held to the same rule (a 422 naming the field), and `CVPrintsOut`/`CVPrintsIn` gain the two booleans. 6. **The importer reads them through the same validator** and drops one that fails with a line in the report, instead of storing it. (The importer's blind `setattr` is wider than this issue; this closes it for these fields and names the rest in the report of the commit.) 7. **Threat model and wiki.** `docs/THREAT-MODEL.md` names a date and place of birth beside the home address in *What is most worth defending*, with the same rules: owner-scoped like every row, never in a list, a log, a search result, a webhook or an error message, and printed only where a CV says so. The wiki's *Your career record* (the candidate file's contents), *CVs* (the new switches) and *The capture API* (the new fields) are updated in the same commit. ## Decisions - **Partial dates: how to store one.** (a) Three nullable integers; (b) a date plus a precision; (c) one text column holding the ISO 8601 reduced form. **Recommended:** (c). It is the form the archive, the candidate file, the API and vCard already speak, the validator is the single rule, and nothing here sorts or filters by birth date. Django's `SelectDateWidget` needs all three parts and offers only the next ten years unless given a range, so it is not the form; the GOV.UK Design System's three-box date input is the pattern followed. - **Place of birth: free text and country, or GeoNames.** `jobs/places.py` places a company at city level as a guess and refuses the street on purpose (#108, the threat model). A place of birth is a fact someone states, often a village GeoNames' cities-above-1,000 table lacks, and must never be pinned on a map. **Recommended:** the text typed and a country code, no lookup, no coordinates, never offered to the map (#702). - **Country names.** The list is `core/phones.py`'s, in English, as the address form does it. **Recommended:** reuse it. A printed country in the document's language needs names Postulo does not have (#309 left "München, Germany" on a French CV for the same reason); #689 is where a general source lands. - **On the CV: yes or no, and how much.** Default off, like the form of address (#308, #309). A switch for the date and one for the place, a plain yes or no. A CV that should print only a year is served by storing only a year. **Recommended:** two switches now; a three-state choice (none, year, full) only if somebody asks. - **In the candidate file.** The file is "the part of an account that is the person" and its stated rule is "what a CV prints"; these are what a CV may print. **Recommended:** carry them. The file is the person's to hand to another Postulo; the review page (#181) already says what each part would do before anything is written. - **Who may read it through the API.** `GET /profile` is under the `read` scope and `PATCH` under `write`, as the name is; a browser extension's token holds `captures` and sees neither. **Recommended:** the same scopes, and nothing about these fields in any list endpoint, webhook or log. - **Legal status.** Date and place of birth are personal data; neither is on the list of special categories in Article 9(1) of the GDPR, and the maintainer should confirm that reading for any instance that must answer to a regulator. Nothing in this issue depends on it: the fields are optional, off on every CV, and removed with the account. ## Prior art - **Standards, not libraries.** ISO 8601's reduced-precision forms; vCard 4 `BDAY` and RFC 6474's `BIRTHPLACE` for the day #660 writes the person's own card (a stored value maps one to one; a year-only date is `BDAY:1990`). - **`edtf`** (Library of Congress EDTF, MIT, 5.0.2 released 2026-09-17, no advisories on OSV, not a dependency): parses uncertain, approximate and interval dates. **Rejected**: what is needed is three shapes and a range check, about twenty lines; a dependency for the part of EDTF nobody asked for is more to keep current than the code it replaces. The maintainer may overrule: it is MIT, so compatible with AGPL-3.0. - **`django-partial-date`** 1.3.2 (MIT, released 2022-01-20, no advisory on OSV, depends on `six`, not a dependency): a Django field for a date that may be only a year or a month. **Rejected**: not released since 2022, and it adds a field type where one validated text column is enough (decision (c)). - Hand-written precedent: `applications/ical.py` and `documents/docx.py` are written by hand on purpose; this is smaller than either. ## Builds on #309 (form of address and pronouns: optional, never inferred), #308 (a CV chooses what it prints), #181 (the candidate file), #306 (postal addresses and countries), #337 (the language of a date's wording). ## Needed by #680 (nationalities) and #681 (gender) add to the same card, the same switches and the same format bumps. #660 (contacts as vCard 4) writes the two on the person's own card, as `BDAY` and `BIRTHPLACE`; it exports that card and never reads it back. ## Done when - A new test file `tests/test_birth_details.py`: the validator (a year, a year and month, a date, refused: an impossible day, a future date, 1899, text, a NUL); the form saves and clears each; the sidebar lists the card; nothing is printed on a CV by default and the two switches print one “Born …” line in the document's language, in the preview, the plain text and the Word file. - `tests/test_cv_prints.py` and `tests/security/test_cv_prints.py` cover the two switches (default, chosen, the archive carrying them); `tests/test_export.py` and the archive round trip carry the fields at the new format; `tests/test_candidate_file.py` holds the new candidate fingerprint (`SHAPES`) and a review row for a good value and a refused one; `tests/security/test_candidate_file.py` a file with a malformed date; the archive importer drops one (`tests/test_importers.py`). - `tests/test_api.py`: `GET /profile` and `PATCH /profile` carry the fields with the page's bounds; `tests/test_profile_sections.py` expects the new anchor. Search is unaffected: nothing here is indexed (`core/search.py` has no profile group), and a test says so for the new fields. - `tests/security/test_isolation_sweep.py` and the account-deletion test (`tests/test_account_deletion.py`) show the fields gone with the account; `tests/test_gdpr.py` needs no change (the record of processing is drawn from plugins), which a one-line comment in the commit says. - The new strings (card title, labels, help, “Born …”) are in the working catalogues (en-GB, fr-FR, pt-PT as drafts) and in Weblate (#349). `tests/test_wiki_surface.py` is unaffected unless a new name reaches `postulo.plugins.api`. - The wiki pages and `docs/THREAT-MODEL.md` named above are changed in the same commit; the CHANGELOG line ends `(#N)`. 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#679
No description provided.