An experience names its organisation as a company #683

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

The maintainer's list says, under Your career: “Experience organization is a Company.” Today the organisation of an experience is a line of free text, and that was chosen on purpose (#131, and the model's own comment); this issue changes the decision, keeps what it was protecting, and makes the entry point at a company of the person's own.

What exists today

  • The organisation is free text. Experience.organisation is a required CharField(200); the docstring gives the reason: “Where you have worked and where you are applying are unrelated lists, and joining them would put former employers into the company picker for new applications” (src/postulo/resume/models.py:65-74). The wiki tells the person the same (Your-career-record.md, “The organisations you have worked for are kept separately from the companies you are applying to”).
  • It is not translated, on purpose. #131 (closed, 0.3.0, 28551c13) left organisation and institution out of the career's translations: “Universidade de Lisboa stays that on an English CV; translating it invents an employer who never existed” (src/postulo/resume/translatable.py:16-27). Tests pin it: tests/test_career_languages.py:72-79 (the field is not translatable), :305-312 (the screen has no box for it) and tests/test_candidate_file.py:1302 (a file that translates it is refused). They pin a free-text field, not a company, and stay true while the text stays.
  • Where the text is read. The form (resume/forms.py:63-88); the CV's two themes (templates/documents/themes/base_cv.html:134, base_portfolio.html:139), which a plugin's theme replaces and reads as entry.item.organisation (documents/rendering.py:88-98 says a plugin's template written against entry.item.role “gets the French job title for free”, and the same holds here); the preview, the plain text and the Word file (documents/rendering.py:308, :379); search (core/search.py:468-481); the career preview (templates/resume/preview.html:25); the candidate file, where an experience is “its organisation, its title and its dates” (resume/candidate.py:178-199); the Europass import, which creates text only (resume/importing.py:224-235); seed_demo.
  • The archive and the candidate file share the block. RESUME_FIELDS["experience"] (core/export.py:343-354) is written by _resume_block into both documents, and tests/test_candidate_file.py:354-387 fingerprints it: a field added for the archive changes the candidate file too and fails until CANDIDATE_FORMAT is bumped (4 at f09b6010b; FORMAT_VERSION is 29).
  • The archive restores the career before the companies (core/importer.py:530-575, companies from :597), and hands every key of an entry straight to the constructor (model.objects.create(owner=user, **values), :556). A key the model does not take has to be popped first, as esco_uri is (:569). Where the archive already points at a company it does so by name, resolved in a pass after the companies exist (through_agency, :766-768, :888-899).
  • A company is the person's own and carries private opinion. Company is owner-scoped, unique by name per owner, with notes, kind, industries, identifiers and a logo (jobs/models.py:238-313). One entry point already matches a typed name to a company case-insensitively and adds it if none exists: get_or_create_company (applications/services.py:305-335). The capture form's datalist of company names comes from jobs/recall.py:44-65 (applications/forms.py:182).
  • Where companies are offered or listed, with no way to leave one out: the posting's and the contact's pickers (jobs/forms.py:638, :731), the application's agency picker (applications/forms.py:423), the suggestions above, the companies table, the map (jobs/views.py:166-176) and search (core/search.py:247). The one precedent for narrowing is a kind: CompanyQuerySet.employers() (jobs/models.py:111-118), “wherever employers are counted or offered, this is the set”.
  • A company merge and a company delete do not know about the career. merge_companies ends with _refuse_what_is_left (jobs/merging.py:230-250, :502), which asks Django what a delete would take along; a link set to null on delete is recorded by Django as an update, not a deletion, so a new link would be cleared silently unless the merge moves it. The delete dialog counts postings, applications and contacts only (jobs/views.py:574-610).
  • The API has no career endpoints, so nothing changes there (src/postulo/api/routers/).

Checked against main at f09b6010b.

Proposal

Three commits, in this order; each leaves main green.

  1. A link beside the text. Experience.company, a nullable foreign key to jobs.Company (SET_NULL, so deleting a company never deletes a career entry), added by a migration that invents nothing. organisation stays, stays required, and stays what every theme, the text, the Word file, search and the candidate file read. In the form it remains a text box with the person's companies offered (as the capture form offers them); on save the text is matched to one of the owner's companies by name, case-insensitively, and linked, and where none matches the company is added (reusing get_or_create_company), marked as from the career (decision below): a boolean column on Company, default false, added by the same migration, so it goes into COMPANY_FIELDS (core/export.py:164-176) and CompanyOut (api/schemas.py:87, read-only) in this commit, and the archive format goes up by one here for that alone. The form says so in one sentence. The page for an entry shows the company as a link; the company's page lists the entries that name it (“Your history here”).
  2. The company stays whole. A company merge moves the career entries to the kept company (a _moved line in the plan and the confirmation, with a test that proves the link survives); the delete dialog adds “N career entries keep their text and lose the link”. A company marked as from the career is left out of the posting, contact and agency pickers and the capture suggestions unless a row already names it, out of the map until it has a posting, and out of the companies table unless a filter asks for it; it stops being marked as soon as a posting, a contact or an application is attached, or by hand. A company merge keeps the mark only if both companies had it.
  3. Files. The archive's experience block gains company, the linked company's name or blank (core/export.py RESUME_FIELDS); the importer pops it and links in a pass after the companies are restored, by name, and an older archive without the key restores unlinked. The candidate file carries the same field (one spelling of the block), read back as a hint: on the review page an entry whose company exactly matches one of the account's companies is offered as linked; a file never creates a company (a stranger's file must not fill the list). The Europass import adds the same exact-name link and never adds a company. Both formats go up by one from whatever they are when this commit lands (FORMAT_VERSION, CANDIDATE_FORMAT, each with its history line and the new SHAPES fingerprint); the archive has already gone up once for the flag in the first commit.

Decisions

  • The earlier decision this reverses. #131, the model's docstring, translatable.py:16-27 and the wiki's two paragraphs say an employer is free text so that former employers stay out of the company list. The maintainer now wants the organisation to be a company. Recommended: supersede the list argument and keep the protection it stood for, with the from-the-career marker below; keep the translation rule unchanged for now (next decision). The docstrings and the wiki (Your-career-record.md: the “kept separately” paragraph and “Some things are not translated, on purpose”) are corrected in the same commit.
  • Link or copy. (a) The entry stores the company's name as its own text and links to the company; (b) the entry holds only the link and prints the company's current name; (c) a link plus an optional wording of its own. Recommended: (a), with the text editable. A CV states the name an employer had when the person worked there, and companies are renamed and merged; (b) would let a rename or a merge change a CV already sent. (a) also keeps plugin themes, the plain text, search and the candidate file working unchanged.
  • Does a former employer join the company list. (a) Yes, like any company; (b) yes, marked as from the career and left out of the places a company is picked for a new application, until it has a posting, a contact or an application; (c) never created from the career, link only to an existing company. Recommended: (b), as a boolean on Company (default false, so every existing company is what it was), set when the career form adds the company and cleared by the first posting, contact or application. (a) puts every school-holiday employer into the posting picker; (c) leaves the maintainer's sentence half true. A derived rule (“no postings and named by an entry”) needs no column but hides a company the person typed on purpose and also worked for. The flag is in COMPANY_FIELDS and in CompanyOut from the commit that adds it.
  • What a CV prints. Today the person's own text. The maintainer's own example elsewhere in the list (“Universidade de Aveiro” is “University of Aveiro” in English) contradicts #131's example, and #673 says this issue and #685 settle the career side. Recommended: no change to any CV here, so an unopened CV stays byte for byte what it was (#308's promise), and when #673 lands, a per-CV switch off by default, “Print organisations in the CV's language”, prints the company's name in that language where Wikidata or the person recorded one and the entry's own text otherwise. A name Postulo cannot vouch for stays opt-in; the switch is a follow-up of #673, not part of these three commits.
  • Existing entries. (a) Link at the next edit only; (b) a migration that links an entry where exactly one company of the same owner has its name, case-insensitively, and never adds a company; (c) a review page. Recommended: (b), the rule the archive importer already uses for an agency, reversible by unlinking. Companies are added only from the form.
  • NACE. The list asks for a NACE code on the learning institution and the certification issuer, and none for an employer. Recommended: none here: any company can be an employer. The shared company field this issue adds is what #685 and #686 narrow with #671's role registry.
  • Privacy. A company's notes are the person's opinion of an employer and sit one click from the career page. Nothing new is shown to anyone but the owner, the candidate file carries the company's name and no more, and no document prints anything of a company but the name. Recommended: keep it so, and a test says so.

Builds on

Issues #131 (career entries per language, the decision above), #138 (an employer is a tree), #202 and #239 (company kinds, the agency), #308 (what a CV prints), #181 (the candidate file), #671 (the picker's shape, not its rule).

Needed by

#685 and #686 reuse the field and the marker. #693 and #695 (organisations and providers) take the same field. #673 is where a company's name in a language comes from.

Done when

  • A new tests/test_career_companies.py: saving an experience links an existing company by name, adds one marked as from the career where there is none, and never links another owner's company (a name that exists only under another account adds a new one); the marker is cleared by a posting, a contact or an application; the pickers, the suggestions, the map and the table leave it out and a filter brings it back.
  • tests/test_merging.py: a merge moves the entries and the confirmation says so; tests/test_jobs_isolation.py and tests/security/test_isolation_sweep.py show another account's company is neither offered nor linked; a delete keeps the entry, its text and its translations.
  • tests/test_career_languages.py keeps passing unchanged (the organisation is still not translatable); tests/test_documents.py and tests/test_document_formats.py show a CV, the text and the Word file print the same line as before; tests/test_help_text.py follows the field's help.
  • tests/test_export.py and the archive round trip carry the link at the new format and an older archive restores unlinked; tests/test_candidate_file.py holds the new SHAPES line, the review row offers an exact match and a file never adds a company (tests/security/test_candidate_file.py with a hostile company); tests/test_importers.py for the importer's pass; tests/test_europass_candidate.py for the exact-name link.
  • No career API exists and none is added; the companies API shows the flag (tests/test_api.py). No search change, no new page. The processing record (core/gdpr.py) is drawn from plugins and connections and needs no line. The standing checklist otherwise applies: the archive format is one more than whatever it is when each commit lands (29 at f09b6010b; the flag and the link are two bumps), the candidate format likewise for the link (4), strings in the working catalogues (en-GB, fr-FR, pt-PT as drafts) and in Weblate (#349); Your-career-record.md and the docstrings are changed in the same commit.

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

The maintainer's list says, under Your career: “Experience organization is a Company.” Today the organisation of an experience is a line of free text, and that was chosen on purpose (#131, and the model's own comment); this issue changes the decision, keeps what it was protecting, and makes the entry point at a company of the person's own. ## What exists today - **The organisation is free text.** `Experience.organisation` is a required `CharField(200)`; the docstring gives the reason: “Where you have worked and where you are applying are unrelated lists, and joining them would put former employers into the company picker for new applications” (`src/postulo/resume/models.py:65-74`). The wiki tells the person the same (`Your-career-record.md`, “The organisations you have worked for are kept separately from the companies you are applying to”). - **It is not translated, on purpose.** #131 (closed, 0.3.0, `28551c13`) left `organisation` and `institution` out of the career's translations: “*Universidade de Lisboa* stays that on an English CV; translating it invents an employer who never existed” (`src/postulo/resume/translatable.py:16-27`). Tests pin it: `tests/test_career_languages.py:72-79` (the field is not translatable), `:305-312` (the screen has no box for it) and `tests/test_candidate_file.py:1302` (a file that translates it is refused). They pin a free-text field, not a company, and stay true while the text stays. - **Where the text is read.** The form (`resume/forms.py:63-88`); the CV's two themes (`templates/documents/themes/base_cv.html:134`, `base_portfolio.html:139`), which a plugin's theme replaces and reads as `entry.item.organisation` (`documents/rendering.py:88-98` says a plugin's template written against `entry.item.role` “gets the French job title for free”, and the same holds here); the preview, the plain text and the Word file (`documents/rendering.py:308`, `:379`); search (`core/search.py:468-481`); the career preview (`templates/resume/preview.html:25`); the candidate file, where an experience is “its organisation, its title and its dates” (`resume/candidate.py:178-199`); the Europass import, which creates text only (`resume/importing.py:224-235`); `seed_demo`. - **The archive and the candidate file share the block.** `RESUME_FIELDS["experience"]` (`core/export.py:343-354`) is written by `_resume_block` into both documents, and `tests/test_candidate_file.py:354-387` fingerprints it: a field added for the archive changes the candidate file too and fails until `CANDIDATE_FORMAT` is bumped (4 at `f09b6010b`; `FORMAT_VERSION` is 29). - **The archive restores the career before the companies** (`core/importer.py:530-575`, companies from `:597`), and hands every key of an entry straight to the constructor (`model.objects.create(owner=user, **values)`, `:556`). A key the model does not take has to be popped first, as `esco_uri` is (`:569`). Where the archive already points at a company it does so by name, resolved in a pass after the companies exist (`through_agency`, `:766-768`, `:888-899`). - **A company is the person's own and carries private opinion.** `Company` is owner-scoped, unique by name per owner, with notes, kind, industries, identifiers and a logo (`jobs/models.py:238-313`). One entry point already matches a typed name to a company case-insensitively and adds it if none exists: `get_or_create_company` (`applications/services.py:305-335`). The capture form's datalist of company names comes from `jobs/recall.py:44-65` (`applications/forms.py:182`). - **Where companies are offered or listed, with no way to leave one out:** the posting's and the contact's pickers (`jobs/forms.py:638`, `:731`), the application's agency picker (`applications/forms.py:423`), the suggestions above, the companies table, the map (`jobs/views.py:166-176`) and search (`core/search.py:247`). The one precedent for narrowing is a kind: `CompanyQuerySet.employers()` (`jobs/models.py:111-118`), “wherever employers are counted or offered, this is the set”. - **A company merge and a company delete do not know about the career.** `merge_companies` ends with `_refuse_what_is_left` (`jobs/merging.py:230-250`, `:502`), which asks Django what a delete would take along; a link set to null on delete is recorded by Django as an update, not a deletion, so a new link would be cleared silently unless the merge moves it. The delete dialog counts postings, applications and contacts only (`jobs/views.py:574-610`). - **The API has no career endpoints**, so nothing changes there (`src/postulo/api/routers/`). Checked against `main` at `f09b6010b`. ## Proposal Three commits, in this order; each leaves `main` green. 1. **A link beside the text.** `Experience.company`, a nullable foreign key to `jobs.Company` (`SET_NULL`, so deleting a company never deletes a career entry), added by a migration that invents nothing. `organisation` stays, stays required, and stays what every theme, the text, the Word file, search and the candidate file read. In the form it remains a text box with the person's companies offered (as the capture form offers them); on save the text is matched to one of the owner's companies by name, case-insensitively, and linked, and where none matches the company is added (reusing `get_or_create_company`), marked as from the career (decision below): a boolean column on `Company`, default false, added by the same migration, so it goes into `COMPANY_FIELDS` (`core/export.py:164-176`) and `CompanyOut` (`api/schemas.py:87`, read-only) in this commit, and the archive format goes up by one here for that alone. The form says so in one sentence. The page for an entry shows the company as a link; the company's page lists the entries that name it (“Your history here”). 2. **The company stays whole.** A company merge moves the career entries to the kept company (a `_moved` line in the plan and the confirmation, with a test that proves the link survives); the delete dialog adds “N career entries keep their text and lose the link”. A company marked as from the career is left out of the posting, contact and agency pickers and the capture suggestions unless a row already names it, out of the map until it has a posting, and out of the companies table unless a filter asks for it; it stops being marked as soon as a posting, a contact or an application is attached, or by hand. A company merge keeps the mark only if both companies had it. 3. **Files.** The archive's experience block gains `company`, the linked company's name or blank (`core/export.py` `RESUME_FIELDS`); the importer pops it and links in a pass after the companies are restored, by name, and an older archive without the key restores unlinked. The candidate file carries the same field (one spelling of the block), read back as a hint: on the review page an entry whose `company` exactly matches one of the account's companies is offered as linked; **a file never creates a company** (a stranger's file must not fill the list). The Europass import adds the same exact-name link and never adds a company. Both formats go up by one from whatever they are when this commit lands (`FORMAT_VERSION`, `CANDIDATE_FORMAT`, each with its history line and the new `SHAPES` fingerprint); the archive has already gone up once for the flag in the first commit. ## Decisions - **The earlier decision this reverses.** #131, the model's docstring, `translatable.py:16-27` and the wiki's two paragraphs say an employer is free text so that former employers stay out of the company list. The maintainer now wants the organisation to be a company. **Recommended:** supersede the *list* argument and keep the *protection* it stood for, with the from-the-career marker below; keep the translation rule unchanged for now (next decision). The docstrings and the wiki (`Your-career-record.md`: the “kept separately” paragraph and “Some things are not translated, on purpose”) are corrected in the same commit. - **Link or copy.** (a) The entry stores the company's name as its own text and links to the company; (b) the entry holds only the link and prints the company's current name; (c) a link plus an optional wording of its own. **Recommended: (a), with the text editable.** A CV states the name an employer had when the person worked there, and companies are renamed and merged; (b) would let a rename or a merge change a CV already sent. (a) also keeps plugin themes, the plain text, search and the candidate file working unchanged. - **Does a former employer join the company list.** (a) Yes, like any company; (b) yes, marked as from the career and left out of the places a company is picked for a new application, until it has a posting, a contact or an application; (c) never created from the career, link only to an existing company. **Recommended: (b)**, as a boolean on `Company` (default false, so every existing company is what it was), set when the career form adds the company and cleared by the first posting, contact or application. (a) puts every school-holiday employer into the posting picker; (c) leaves the maintainer's sentence half true. A derived rule (“no postings and named by an entry”) needs no column but hides a company the person typed on purpose and also worked for. The flag is in `COMPANY_FIELDS` and in `CompanyOut` from the commit that adds it. - **What a CV prints.** Today the person's own text. The maintainer's own example elsewhere in the list (“Universidade de Aveiro” is “University of Aveiro” in English) contradicts #131's example, and #673 says this issue and #685 settle the career side. **Recommended:** no change to any CV here, so an unopened CV stays byte for byte what it was (#308's promise), and when #673 lands, a per-CV switch off by default, “Print organisations in the CV's language”, prints the company's name in that language where Wikidata or the person recorded one and the entry's own text otherwise. A name Postulo cannot vouch for stays opt-in; the switch is a follow-up of #673, not part of these three commits. - **Existing entries.** (a) Link at the next edit only; (b) a migration that links an entry where exactly one company of the same owner has its name, case-insensitively, and never adds a company; (c) a review page. **Recommended: (b)**, the rule the archive importer already uses for an agency, reversible by unlinking. Companies are added only from the form. - **NACE.** The list asks for a NACE code on the learning institution and the certification issuer, and none for an employer. **Recommended:** none here: any company can be an employer. The shared company field this issue adds is what #685 and #686 narrow with #671's role registry. - **Privacy.** A company's notes are the person's opinion of an employer and sit one click from the career page. Nothing new is shown to anyone but the owner, the candidate file carries the company's name and no more, and no document prints anything of a company but the name. **Recommended:** keep it so, and a test says so. ## Builds on Issues #131 (career entries per language, the decision above), #138 (an employer is a tree), #202 and #239 (company kinds, the agency), #308 (what a CV prints), #181 (the candidate file), #671 (the picker's shape, not its rule). ## Needed by #685 and #686 reuse the field and the marker. #693 and #695 (organisations and providers) take the same field. #673 is where a company's name in a language comes from. ## Done when - A new `tests/test_career_companies.py`: saving an experience links an existing company by name, adds one marked as from the career where there is none, and never links another owner's company (a name that exists only under another account adds a new one); the marker is cleared by a posting, a contact or an application; the pickers, the suggestions, the map and the table leave it out and a filter brings it back. - `tests/test_merging.py`: a merge moves the entries and the confirmation says so; `tests/test_jobs_isolation.py` and `tests/security/test_isolation_sweep.py` show another account's company is neither offered nor linked; a delete keeps the entry, its text and its translations. - `tests/test_career_languages.py` keeps passing unchanged (the organisation is still not translatable); `tests/test_documents.py` and `tests/test_document_formats.py` show a CV, the text and the Word file print the same line as before; `tests/test_help_text.py` follows the field's help. - `tests/test_export.py` and the archive round trip carry the link at the new format and an older archive restores unlinked; `tests/test_candidate_file.py` holds the new `SHAPES` line, the review row offers an exact match and a file never adds a company (`tests/security/test_candidate_file.py` with a hostile `company`); `tests/test_importers.py` for the importer's pass; `tests/test_europass_candidate.py` for the exact-name link. - No career API exists and none is added; the companies API shows the flag (`tests/test_api.py`). No search change, no new page. The processing record (`core/gdpr.py`) is drawn from plugins and connections and needs no line. The standing checklist otherwise applies: the archive format is one more than whatever it is when each commit lands (29 at `f09b6010b`; the flag and the link are two bumps), the candidate format likewise for the link (4), strings in the working catalogues (en-GB, fr-FR, pt-PT as drafts) and in Weblate (#349); `Your-career-record.md` and the docstrings are changed 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:58 +00:00
Author
Owner

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

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