A CV photo of its own, independent of the avatar, printed only where a CV says so #668

Closed
opened 2026-10-03 01:58:48 +00:00 by tiagoagueda · 1 comment
Owner

From the list of 2026-10-02: "A CV photo may not be the avatar photo, make them independent." No CV prints a photograph today, and the picture a person has on their profile is the wrong picture for one: it is a square crop made for a header tile, and it may be an illustration or a holiday snapshot. The wiki has already promised that a photo on a CV "will be a per-CV choice, off by default, when it arrives" (Accounts-and-invitations.md:42-43). This is that, with a photo of its own.

What exists today

  • No CV photo exists. contact_details hands a document the name, headline, email, phone, location, links, identifiers, form of address and pronouns, and no picture (src/postulo/documents/rendering.py:151-231). The two built-in themes and the shared base print none (templates/documents/themes/base_cv.html:67-80, plain/cv.html, classic/cv.html, base_portfolio.html); a search of src/postulo for "photo" finds comments only. The templates plugin records the same gap: "contact carries no picture, so no template can print one … a CV with a photograph is a core change" (postulo/postulo-templates#2).
  • The avatar cannot stand in. It is decoded and cropped to a square (accounts/avatars.py:67-80, core/pictures.py:115-131, square=True through ImageOps.fit), stored at most 1 MiB under avatars/<pk>/ (accounts/models.py:152-154, :410), served to the person and to administrators (accounts/views.py:297-315), and meant for the interface. A passport-style photograph is 35 x 45 mm, about 413 x 531 pixels at 300 dpi (accounts/avatars.py:40-45, #265), which is portrait, so a square crop has already thrown part of it away. The wiki says "Nothing here appears on a CV" (Accounts-and-invitations.md:42).
  • The Europass reader leaves a photograph alone. QUIET names "the photograph, which the old reader never took either" (plugins/europass/candidate.py:149-150). The candidate file leaves the picture out on purpose and says so (Your-career-record.md:529-534).
  • Where a CV's choices live. #308 gave each CV a column per detail it prints, and three yes-or-no switches in printing.SWITCHES (documents/printing.py:153-160): show_location, show_form_of_address and show_pronouns (documents/models.py:316-326). That one dictionary is read by the CV form (documents/forms.py:294), the API (api/routers/documents.py:78, :191), the archive (documents/printing.py:550, :636) and the importer (core/importer.py:955). printing.resolve computes what is printed (:340-385), and contact_details is "the only place they reach a document" (rendering.py:151-166), so the CV, the preview, the PDF and a theme nobody here has seen all receive the same answer. The two #309 added (show_form_of_address, show_pronouns) default to off, "until a CV says so".
  • What the renderers may fetch. The PDF renderer's fetcher opens data: addresses and nothing else (documents/pdf.py:231-236), and the page's policy allows images from 'self' and data: only (config/settings/base.py:681). A photo can only reach a document as a data: value.
  • Two exports never see a theme. The plain text and the Word file are written from the outline (documents/docx.py, documents/formats.py), not from a template.

Checked against main at f09b6010b.

Proposal

  1. A photo of its own. One CV photo per person, kept apart from the avatar and the Gravatar copy, with its own upload and removal on Your details beside Your picture, and a sentence on each saying they are separate. It is decoded, straightened and re-encoded, which strips what the file knew about where it was taken (core.pictures.as_stored), but not cropped: the person's own framing is kept, within the same 1 MiB budget and 5 MB input cap. Stored the way #662 decides for the avatar, and it follows the same rule: it goes with its owner.
  2. A switch on each CV. show_photo joins printing.SWITCHES, default off, so the form, the API, the archive and the importer carry it with no further code. It lives inside the contact block: a CV whose "include contact details" switch is off prints no photo, as it prints no name.
  3. A value for themes. When the CV says so and the person has a photo, contact_details carries photo, a data: address; otherwise an empty string. base_cv.html and base_portfolio.html print it in the header in a 35 x 45 mm frame (object-fit: cover, top-weighted, which WeasyPrint draws and a browser preview draws the same), with alt="" and beside the name in the document order. plain and classic style the frame. A theme that does not read contact.photo prints no photo and is not broken (keys a theme was written against keep their meaning, as rendering.py:186-199 already says).
  4. What does not get it. The plain text and the Word file leave the photo out, and the CV's page says so under the switch. The candidate file leaves it out for the reason it leaves the avatar out; the archive carries it.
  5. Wiki. CVs (the switch), Accounts and invitations (the second picture; the "will be" sentence becomes true), Writing a plugin (contact.photo, and that a theme may ignore it).

Decisions

  • One photo per person, or one per CV. Options: one on the profile with a switch per CV; a file per CV. Recommended: one on the profile, with the switch per CV. People mostly have one formal photograph, and a file per CV multiplies a lifecycle that #355 shows is easy to get wrong. A CV that wants a different photograph is a later extension (a pinned photo, as #308 pins a number).
  • Where it is chosen. Options: Your details; the CV's own settings. Recommended: the upload on Your details (it is a fact about the person, like the avatar), the switch on the CV (a decision about one document).
  • Crop. Options: crop on upload to 7:9; keep what was given and frame it in the theme. Recommended: keep, and frame in the theme. A server-side crop has no way to be corrected without script, and 35 x 45 mm is one country's frame; a theme may use a circle or a square.
  • Alt text. Recommended: empty. The photograph adds nothing a screen-reader user or an applicant-tracking system needs, and the name is beside it; the PDF's tag tree then marks it as decoration.
  • Word and text. A Word photograph needs an image part, a relationship and a drawing element in the hand-written package (documents/docx.py, which is deliberately small). Recommended: not in this issue; say so on the page.
  • Europass. A CV downloaded from Europass can carry a photograph as an attachment; Postulo's reader ignores it. Recommended: keep ignoring it until the real-download check, #318, has seen one; then it may offer to take it as the CV photo.
  • Personal data. A face is personal data and, in some jurisdictions, treated with more care. Recommended: nothing leaves the instance except in a document the person made, and off by default.
  • Conflict with an earlier decision. None: it carries out the wiki's promise and #309's rule that what is sensitive is printed only where a CV says so.

Builds on

#308 and #309 (closed), #265 (closed), #482 (the same re-encoding path), #662 (where the picture lives), postulo/postulo-templates#2.

Needed by

postulo/postulo-templates#2 (a Europass-style template that prints a photograph), the plugin's own theme issues.

Done when

  • A new tests/test_cv_photo.py: a photo is stored independently of the avatar (changing one leaves the other); metadata is stripped; a file that is not an image is refused; an uploaded photo is not cropped; deleting the account removes it and its file; a CV with show_photo off hands the renderer an empty photo, one with it on a data: address, and the master switch off removes it.
  • tests/test_cv_prints.py: the new switch is carried by the form, the API, the archive and the importer, and an older archive restores with it off; a changed switch makes a new version of what was sent (the existing rule), and a sent PDF is unchanged afterwards.
  • tests/test_document_themes.py and tests/test_cv_prints.py: both built-in themes print the frame when given a photo and are byte for byte what they were without one; the browser preview shows it under the real policy with no violation (tests/e2e/), axe-clean in both themes.
  • tests/security/test_cv_prints.py: nobody can name or read another person's photo through the CV form or the API.
  • Standing checks: the archive format goes up by one (core/export.py FORMAT_VERSION, 29 on f09b6010b; one more than whatever it is when the commit lands) with a round trip; candidate format (4) unchanged and the page that says the picture is left out still true; API: prints.photo on a CV (tests/test_api.py); search unaffected; CV print choice is this issue; processing record (core/gdpr.py, drawn from plugins and connections) needs nothing; tests/test_wiki_surface.py is for names on postulo.plugins.api, and none is added (contact.photo is a context key, documented in the wiki); new pages, if any, in tests/test_page_coverage.py and tests/security/test_isolation_sweep.py; strings in the working catalogues (en-GB, fr-FR, pt-PT as drafts) and Weblate (#349).

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

From the list of 2026-10-02: "A CV photo may not be the avatar photo, make them independent." No CV prints a photograph today, and the picture a person has on their profile is the wrong picture for one: it is a square crop made for a header tile, and it may be an illustration or a holiday snapshot. The wiki has already promised that a photo on a CV "will be a per-CV choice, off by default, when it arrives" (`Accounts-and-invitations.md:42-43`). This is that, with a photo of its own. ## What exists today - **No CV photo exists.** `contact_details` hands a document the name, headline, email, phone, location, links, identifiers, form of address and pronouns, and no picture (`src/postulo/documents/rendering.py:151-231`). The two built-in themes and the shared base print none (`templates/documents/themes/base_cv.html:67-80`, `plain/cv.html`, `classic/cv.html`, `base_portfolio.html`); a search of `src/postulo` for "photo" finds comments only. The templates plugin records the same gap: "`contact` carries no picture, so no template can print one … a CV with a photograph is a core change" (postulo/postulo-templates#2). - **The avatar cannot stand in.** It is decoded and cropped to a square (`accounts/avatars.py:67-80`, `core/pictures.py:115-131`, `square=True` through `ImageOps.fit`), stored at most 1 MiB under `avatars/<pk>/` (`accounts/models.py:152-154`, `:410`), served to the person and to administrators (`accounts/views.py:297-315`), and meant for the interface. A passport-style photograph is 35 x 45 mm, about 413 x 531 pixels at 300 dpi (`accounts/avatars.py:40-45`, #265), which is portrait, so a square crop has already thrown part of it away. The wiki says "Nothing here appears on a CV" (`Accounts-and-invitations.md:42`). - **The Europass reader leaves a photograph alone.** `QUIET` names "the photograph, which the old reader never took either" (`plugins/europass/candidate.py:149-150`). The candidate file leaves the picture out on purpose and says so (`Your-career-record.md:529-534`). - **Where a CV's choices live.** #308 gave each CV a column per detail it prints, and three yes-or-no switches in `printing.SWITCHES` (`documents/printing.py:153-160`): `show_location`, `show_form_of_address` and `show_pronouns` (`documents/models.py:316-326`). That one dictionary is read by the CV form (`documents/forms.py:294`), the API (`api/routers/documents.py:78`, `:191`), the archive (`documents/printing.py:550`, `:636`) and the importer (`core/importer.py:955`). `printing.resolve` computes what is printed (`:340-385`), and `contact_details` is "the only place they reach a document" (`rendering.py:151-166`), so the CV, the preview, the PDF and a theme nobody here has seen all receive the same answer. The two #309 added (`show_form_of_address`, `show_pronouns`) default to off, "until a CV says so". - **What the renderers may fetch.** The PDF renderer's fetcher opens `data:` addresses and nothing else (`documents/pdf.py:231-236`), and the page's policy allows images from `'self'` and `data:` only (`config/settings/base.py:681`). A photo can only reach a document as a `data:` value. - **Two exports never see a theme.** The plain text and the Word file are written from the outline (`documents/docx.py`, `documents/formats.py`), not from a template. Checked against `main` at `f09b6010b`. ## Proposal 1. **A photo of its own.** One *CV photo* per person, kept apart from the avatar and the Gravatar copy, with its own upload and removal on *Your details* beside *Your picture*, and a sentence on each saying they are separate. It is decoded, straightened and re-encoded, which strips what the file knew about where it was taken (`core.pictures.as_stored`), but **not cropped**: the person's own framing is kept, within the same 1 MiB budget and 5 MB input cap. Stored the way #662 decides for the avatar, and it follows the same rule: it goes with its owner. 2. **A switch on each CV.** `show_photo` joins `printing.SWITCHES`, default off, so the form, the API, the archive and the importer carry it with no further code. It lives inside the contact block: a CV whose "include contact details" switch is off prints no photo, as it prints no name. 3. **A value for themes.** When the CV says so and the person has a photo, `contact_details` carries `photo`, a `data:` address; otherwise an empty string. `base_cv.html` and `base_portfolio.html` print it in the header in a 35 x 45 mm frame (`object-fit: cover`, top-weighted, which WeasyPrint draws and a browser preview draws the same), with `alt=""` and beside the name in the document order. `plain` and `classic` style the frame. A theme that does not read `contact.photo` prints no photo and is not broken (keys a theme was written against keep their meaning, as `rendering.py:186-199` already says). 4. **What does not get it.** The plain text and the Word file leave the photo out, and the CV's page says so under the switch. The candidate file leaves it out for the reason it leaves the avatar out; the archive carries it. 5. **Wiki.** *CVs* (the switch), *Accounts and invitations* (the second picture; the "will be" sentence becomes true), *Writing a plugin* (`contact.photo`, and that a theme may ignore it). ## Decisions - **One photo per person, or one per CV.** Options: one on the profile with a switch per CV; a file per CV. **Recommended:** one on the profile, with the switch per CV. People mostly have one formal photograph, and a file per CV multiplies a lifecycle that #355 shows is easy to get wrong. A CV that wants a different photograph is a later extension (a pinned photo, as #308 pins a number). - **Where it is chosen.** Options: *Your details*; the CV's own settings. **Recommended:** the upload on *Your details* (it is a fact about the person, like the avatar), the switch on the CV (a decision about one document). - **Crop.** Options: crop on upload to 7:9; keep what was given and frame it in the theme. **Recommended:** keep, and frame in the theme. A server-side crop has no way to be corrected without script, and 35 x 45 mm is one country's frame; a theme may use a circle or a square. - **Alt text.** **Recommended:** empty. The photograph adds nothing a screen-reader user or an applicant-tracking system needs, and the name is beside it; the PDF's tag tree then marks it as decoration. - **Word and text.** A Word photograph needs an image part, a relationship and a drawing element in the hand-written package (`documents/docx.py`, which is deliberately small). **Recommended:** not in this issue; say so on the page. - **Europass.** A CV downloaded from Europass can carry a photograph as an attachment; Postulo's reader ignores it. **Recommended:** keep ignoring it until the real-download check, #318, has seen one; then it may offer to take it as the CV photo. - **Personal data.** A face is personal data and, in some jurisdictions, treated with more care. **Recommended:** nothing leaves the instance except in a document the person made, and off by default. - **Conflict with an earlier decision.** None: it carries out the wiki's promise and #309's rule that what is sensitive is printed only where a CV says so. ## Builds on #308 and #309 (closed), #265 (closed), #482 (the same re-encoding path), #662 (where the picture lives), postulo/postulo-templates#2. ## Needed by postulo/postulo-templates#2 (a Europass-style template that prints a photograph), the plugin's own theme issues. ## Done when - A new `tests/test_cv_photo.py`: a photo is stored independently of the avatar (changing one leaves the other); metadata is stripped; a file that is not an image is refused; an uploaded photo is not cropped; deleting the account removes it and its file; a CV with `show_photo` off hands the renderer an empty `photo`, one with it on a `data:` address, and the master switch off removes it. - `tests/test_cv_prints.py`: the new switch is carried by the form, the API, the archive and the importer, and an older archive restores with it off; a changed switch makes a new version of what was sent (the existing rule), and a sent PDF is unchanged afterwards. - `tests/test_document_themes.py` and `tests/test_cv_prints.py`: both built-in themes print the frame when given a photo and are byte for byte what they were without one; the browser preview shows it under the real policy with no violation (`tests/e2e/`), axe-clean in both themes. - `tests/security/test_cv_prints.py`: nobody can name or read another person's photo through the CV form or the API. - Standing checks: the archive format goes up by one (`core/export.py` `FORMAT_VERSION`, 29 on `f09b6010b`; one more than whatever it is when the commit lands) with a round trip; candidate format (4) unchanged and the page that says the picture is left out still true; API: `prints.photo` on a CV (`tests/test_api.py`); search unaffected; CV print choice is this issue; processing record (`core/gdpr.py`, drawn from plugins and connections) needs nothing; `tests/test_wiki_surface.py` is for names on `postulo.plugins.api`, and none is added (`contact.photo` is a context key, documented in the wiki); new pages, if any, in `tests/test_page_coverage.py` and `tests/security/test_isolation_sweep.py`; strings in the working catalogues (en-GB, fr-FR, pt-PT as drafts) and Weblate (#349). From the list of improvements of 2026-10-02.
tiagoagueda added this to the 0.6.0 milestone 2026-10-03 01:58:48 +00:00
Author
Owner

Landed on main with a green Forgejo CI run. Its entry in the CHANGELOG's Unreleased section ends in (#668).

Landed on main with a green Forgejo CI run. Its entry in the CHANGELOG's Unreleased section ends in (#668).
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#668
No description provided.