Employment services are NACE-coded companies, and only they are offered as the company a listing came through #671

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

The maintainer's list says: “Employment services are companies with correct NACE code associated. Are the only kind on companies that can be a reference a listing from another company.” Postulo already has two things called an employment service or an agency, and neither is defined by a NACE code, so this issue says which is which, reads the sentence from the code, and makes the one place where a company stands between a listing and its employer offer only the companies that qualify.

What exists today

  • “Employment service” is a company kind, and it means the public office. CompanyKind.EMPLOYMENT_SERVICE is “the public office a job seeker is registered with -- France Travail, IEFP, the Bundesagentur für Arbeit, Jobcentre Plus” (src/postulo/jobs/models.py:209-221). The docstring adds: “A recruitment agency is not a third kind”. It is picked from a registry of 37 offices by country (src/postulo/jobs/employment_services.py:50-90, src/postulo/jobs/forms.py:243-250, :376-390). What the kind changes: the report is addressed to it and the first one's adviser is named (src/postulo/applications/reports.py:511-520), it is left out of the industry tallies (:522-529), and the companies table can filter on it (src/postulo/jobs/tables.py:94-99). CompanyQuerySet.employers() (jobs/models.py:111-118) is the set without it, and nothing in src/ calls it; one test does (tests/test_employment_services.py:109).
  • The same words name an application channel. Channel.EMPLOYMENT_SERVICE is “the listings of the public employment service the person is registered with” (src/postulo/applications/models.py:153-164), counted separately in the report (reports.py:508-510).
  • The company a listing came through is Application.through_agency. An optional foreign key to jobs.Company, “the recruitment agency it went through”, with the comment that “the posting's company stays the employer: an agency is deliberately not a kind of company” (applications/models.py:388-400, #239). The picker offers every company of the owner except the employer (src/postulo/applications/forms.py:401-435, the agency half at :423-435); the API accepts any company the caller owns (src/postulo/api/routers/common.py:62-68); the importer matches the agency by name (src/postulo/core/importer.py:766, :899). Application.referred_by is a Contact, a person, and is not a company at all (applications/models.py:380-387).
  • A listing has no field for an intermediary. JobPosting.company is the employer; “found via” is free text (jobs/models.py:874-897).
  • A company has no NACE code of its own. Only Industry carries one: Industry.code is “the NACE division this name is”, set by save() from the name, so renaming an industry to a division's name gives it the code (jobs/models.py:55-81, jobs/industries.py code_for). A company has industries through a many-to-many (jobs/models.py:288-290). src/postulo/jobs/data/nace-2.1.json holds 22 sections and 87 divisions in 24 languages and no groups or classes (data/LICENCE.md: “Groups (three digits) and classes (four) were not taken”). Division 78 is “Employment activities” (section O in the file); the starter list of familiar industry names has nothing for recruitment or staffing (industries.py:64-97).
  • The wiki says the same as the code: Tracking applications → “The employment service” (the public office) and “Who referred you, and the agency it went through” (“recorded as an ordinary company”).

How the sentence reads against this. The code gives “employment service” the public-office meaning, and the maintainer's sentence gives it a NACE meaning. The only place one company stands between a listing and its employer is through_agency, so “the only kind of companies that can be a reference a listing from another company” is read here as: the picker for the company an application went through offers the companies whose NACE code is employment activities (division 78), and no others. The public office is also something that refers a person to another company's listing, which is the case the maintainer's wording fits best; it is kept in the qualifying set below. Checked against main at f09b6010b.

Proposal

  1. A registry of roles and the divisions that qualify for each, in src/postulo/jobs beside industries.py: a role has a key, a label and a set of division codes. This issue ships one role, the intermediary (78). The place of learning (#685: division 85), the awarding body (#686: 85 and 94) and the membership organisation (#693: 94) add theirs without a second mechanism; the organisation of an experience (#683) gets no role, since any company may be an employer.
  2. A question the models can ask: which of the owner's companies qualify for a role, answered from their industries' codes (Company.industries with a division in the role's set), plus the companies whose kind is the public employment service.
  3. The company picker in ApplicationForm._scope_referrer_and_agency offers the qualifying companies first and the rest behind an explicit “Show every company” control that works with scripts off. The employer stays excluded, and a company the row already names stays on the list, as the comment at applications/forms.py:425-427 already requires.
  4. Say what qualifies, in the field's help text: “Companies in Employment activities, and public employment services. Add that industry to a company to have it offered here.”
  5. Reconcile the words as decided below, in the interface, the wiki and the docstrings that now disagree (jobs/models.py:209-218, applications/models.py:388-392).
  6. Suggest the division. The industry datalist already lists the divisions in the reader's language; make the form's help point at “Employment activities” so a recruiter can be marked without knowing the code.

Decisions

  • The two meanings of “employment service”. (a) Keep the public-office kind and give the NACE role another name in the interface (“agency”); (b) rename the kind's label to “Public employment service” (the stored value employment_service is in the archive, the API (api/schemas.py:90, :193) and the CSV aliases (core/csv_import.py:395-409) and does not change) and let “employment service” in the interface mean the NACE role; (c) fold them, so the kind is derived from NACE. Recommended: (b) for the label only, and nothing in code or stored data renamed. The maintainer's sentence defines the term by NACE, the public office is still its own relation (the person is registered with it, the report is addressed to it), and (c) would make the report's addressee depend on an industry nobody has set. The channel's label is changed to match, so one word does not name two sets of companies.
  • Strict or suggested. Strict means the picker, the form, the API (422) and the importer refuse a company that does not qualify; suggested means qualifying companies first with “show every company” behind them. Recommended: suggested. No existing company carries an industry with code 78, so a strict rule would empty the picker for every instance on the day it ships, and rows that already name an agency (through_agency is stored, exported and merged, jobs/merging.py:342, :442) must not become invalid. Revisit strictness once companies carry a code from somewhere other than typing; this keeps the maintainer's intent as the default and does not lock anyone out.
  • Where the NACE code lives. (a) Derived from the company's industries, no new column; (b) a Company.nace field at class level; (c) from an identifier register. Recommended: (a). Industry.code is already the NACE division (#140), the whole of division 78 is what agencies are, and (a) changes no archive, API or candidate format. Classes are needed by #686 and are not in the vendored file; that issue decides whether to harvest them from the same Publications Office dataset (the licence of the classes is not checked here).
  • The public office and NACE. Whether Eurostat's explanatory notes place a public employment office in division 78 is not verified: the class 78.10 text found names placement agencies, online placement agencies and casting bureaux, and no public bodies. Recommended: the public employment service kind qualifies on its own, whatever industries it has, so the answer does not depend on the classification's wording.
  • Where a listing names its intermediary. (a) Only on the application, as #239 decided; (b) also on the listing, as a company that advertised it for the employer. Recommended: (a) here, because the listing is a fact about the world and the application records the route taken, and through_agency already travels with “apply” (tests/test_referrals.py:294). If the maintainer reads “a listing from another company” as the listing carrying it, (b) is a separate issue on JobPosting (archive bump, capture, API).
  • Conflict with #239 and #202. Both say an agency is not a kind and recorded the office as a kind. Neither is reversed: no kind is added, and agencies stay ordinary companies, qualified by what they do.
  • Aliases for familiar names. “Recruitment” or “Staffing” match no division name, so they get no code (industries.py code_for matches division names only). Recommended: no alias table in this issue; the division name is offered in every language Eurostat publishes, and the help says so.

Prior art

No new dependency. NACE Rev. 2.1 is already vendored (Eurostat, CC BY 4.0, src/postulo/jobs/data/LICENCE.md) and #140 chose it over a hand-made list; the rule that a classification is taken, not invented, is the one the maintainer's list states. Not verified: the Rev. 2.1 titles of the groups of division 78 (Rev. 2 has 78.1, 78.2 and 78.3).

Builds on

Issues #202 (the kind), #239 (the agency on the application), #140 and #266 (a classification is taken, not written), and #329 (companies get addresses and phones; the office is a company there too).

Needed by

#685, #686 and #693 add their roles to the registry; #695 reuses g3's. #683 shares the company link with them and uses no role.

Done when

  • A new test beside tests/test_nace.py covers the registry: the intermediary role is division 78, an unknown role is refused, a company with an industry named “Employment activities” (and its translations) qualifies, a company with none does not, and a public employment service always does.
  • tests/test_referrals.py changes with the picker: test_the_form_offers_only_the_owners_own (:121) and test_the_employer_is_not_offered_as_its_own_agency (:158) assert the qualifying-first order, and a new test asserts “show every company” lists the rest; test_an_agency_that_is_the_employer_already_stays_on_its_list (:239) still passes unchanged.
  • A test that one owner's qualifying set never contains another owner's company (tests/security/test_isolation_sweep.py if the picker gains a URL, otherwise the form test above).
  • No format change: the archive format (src/postulo/core/export.py FORMAT_VERSION, 29 at f09b6010b) and the candidate format (4) stay as they are, because nothing new is stored. If the maintainer chooses (b) under Where the NACE code lives, both bump (by one each, from whatever they are when the commit lands) and the standing checklist applies.
  • The label change is carried in the working catalogues (en-GB, fr-FR, pt-PT as drafts) and in Weblate (#349); the wiki's Tracking applications is corrected.

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

The maintainer's list says: “Employment services are companies with correct NACE code associated. Are the only kind on companies that can be a reference a listing from another company.” Postulo already has two things called an employment service or an agency, and neither is defined by a NACE code, so this issue says which is which, reads the sentence from the code, and makes the one place where a company stands between a listing and its employer offer only the companies that qualify. ## What exists today - **“Employment service” is a company kind, and it means the public office.** `CompanyKind.EMPLOYMENT_SERVICE` is “the public office a job seeker is registered with -- France Travail, IEFP, the Bundesagentur für Arbeit, Jobcentre Plus” (`src/postulo/jobs/models.py:209-221`). The docstring adds: “A recruitment agency is not a third kind”. It is picked from a registry of 37 offices by country (`src/postulo/jobs/employment_services.py:50-90`, `src/postulo/jobs/forms.py:243-250`, `:376-390`). What the kind changes: the report is addressed to it and the first one's adviser is named (`src/postulo/applications/reports.py:511-520`), it is left out of the industry tallies (`:522-529`), and the companies table can filter on it (`src/postulo/jobs/tables.py:94-99`). `CompanyQuerySet.employers()` (`jobs/models.py:111-118`) is the set without it, and nothing in `src/` calls it; one test does (`tests/test_employment_services.py:109`). - **The same words name an application channel.** `Channel.EMPLOYMENT_SERVICE` is “the listings of the public employment service the person is registered with” (`src/postulo/applications/models.py:153-164`), counted separately in the report (`reports.py:508-510`). - **The company a listing came through is `Application.through_agency`.** An optional foreign key to `jobs.Company`, “the recruitment agency it went through”, with the comment that “the posting's company stays the employer: an agency is deliberately not a kind of company” (`applications/models.py:388-400`, #239). The picker offers every company of the owner except the employer (`src/postulo/applications/forms.py:401-435`, the agency half at `:423-435`); the API accepts any company the caller owns (`src/postulo/api/routers/common.py:62-68`); the importer matches the agency by name (`src/postulo/core/importer.py:766`, `:899`). `Application.referred_by` is a `Contact`, a person, and is not a company at all (`applications/models.py:380-387`). - **A listing has no field for an intermediary.** `JobPosting.company` is the employer; “found via” is free text (`jobs/models.py:874-897`). - **A company has no NACE code of its own.** Only `Industry` carries one: `Industry.code` is “the NACE division this name is”, set by `save()` from the name, so renaming an industry to a division's name gives it the code (`jobs/models.py:55-81`, `jobs/industries.py` `code_for`). A company has industries through a many-to-many (`jobs/models.py:288-290`). `src/postulo/jobs/data/nace-2.1.json` holds 22 sections and 87 divisions in 24 languages and no groups or classes (`data/LICENCE.md`: “Groups (three digits) and classes (four) were not taken”). Division 78 is “Employment activities” (section O in the file); the starter list of familiar industry names has nothing for recruitment or staffing (`industries.py:64-97`). - The wiki says the same as the code: *Tracking applications* → “The employment service” (the public office) and “Who referred you, and the agency it went through” (“recorded as an ordinary company”). **How the sentence reads against this.** The code gives “employment service” the public-office meaning, and the maintainer's sentence gives it a NACE meaning. The only place one company stands between a listing and its employer is `through_agency`, so “the only kind of companies that can be a reference a listing from another company” is read here as: the picker for the company an application went through offers the companies whose NACE code is employment activities (division 78), and no others. The public office is also something that refers a person to another company's listing, which is the case the maintainer's wording fits best; it is kept in the qualifying set below. Checked against `main` at `f09b6010b`. ## Proposal 1. **A registry of roles and the divisions that qualify for each**, in `src/postulo/jobs` beside `industries.py`: a role has a key, a label and a set of division codes. This issue ships one role, the intermediary (`78`). The place of learning (#685: division 85), the awarding body (#686: 85 and 94) and the membership organisation (#693: 94) add theirs without a second mechanism; the organisation of an experience (#683) gets no role, since any company may be an employer. 2. **A question the models can ask**: which of the owner's companies qualify for a role, answered from their industries' codes (`Company.industries` with a division in the role's set), plus the companies whose kind is the public employment service. 3. **The company picker in `ApplicationForm._scope_referrer_and_agency`** offers the qualifying companies first and the rest behind an explicit “Show every company” control that works with scripts off. The employer stays excluded, and a company the row already names stays on the list, as the comment at `applications/forms.py:425-427` already requires. 4. **Say what qualifies**, in the field's help text: “Companies in Employment activities, and public employment services. Add that industry to a company to have it offered here.” 5. **Reconcile the words** as decided below, in the interface, the wiki and the docstrings that now disagree (`jobs/models.py:209-218`, `applications/models.py:388-392`). 6. **Suggest the division.** The industry datalist already lists the divisions in the reader's language; make the form's help point at “Employment activities” so a recruiter can be marked without knowing the code. ## Decisions - **The two meanings of “employment service”.** (a) Keep the public-office kind and give the NACE role another name in the interface (“agency”); (b) rename the kind's label to “Public employment service” (the stored value `employment_service` is in the archive, the API (`api/schemas.py:90`, `:193`) and the CSV aliases (`core/csv_import.py:395-409`) and does not change) and let “employment service” in the interface mean the NACE role; (c) fold them, so the kind is derived from NACE. **Recommended: (b) for the label only, and nothing in code or stored data renamed.** The maintainer's sentence defines the term by NACE, the public office is still its own relation (the person is registered with it, the report is addressed to it), and (c) would make the report's addressee depend on an industry nobody has set. The channel's label is changed to match, so one word does not name two sets of companies. - **Strict or suggested.** Strict means the picker, the form, the API (422) and the importer refuse a company that does not qualify; suggested means qualifying companies first with “show every company” behind them. **Recommended: suggested.** No existing company carries an industry with code 78, so a strict rule would empty the picker for every instance on the day it ships, and rows that already name an agency (`through_agency` is stored, exported and merged, `jobs/merging.py:342`, `:442`) must not become invalid. Revisit strictness once companies carry a code from somewhere other than typing; this keeps the maintainer's intent as the default and does not lock anyone out. - **Where the NACE code lives.** (a) Derived from the company's industries, no new column; (b) a `Company.nace` field at class level; (c) from an identifier register. **Recommended: (a).** `Industry.code` is already the NACE division (#140), the whole of division 78 is what agencies are, and (a) changes no archive, API or candidate format. Classes are needed by #686 and are not in the vendored file; that issue decides whether to harvest them from the same Publications Office dataset (the licence of the classes is not checked here). - **The public office and NACE.** Whether Eurostat's explanatory notes place a public employment office in division 78 is not verified: the class 78.10 text found names placement agencies, online placement agencies and casting bureaux, and no public bodies. **Recommended: the public employment service kind qualifies on its own, whatever industries it has**, so the answer does not depend on the classification's wording. - **Where a listing names its intermediary.** (a) Only on the application, as #239 decided; (b) also on the listing, as a company that advertised it for the employer. **Recommended: (a) here**, because the listing is a fact about the world and the application records the route taken, and `through_agency` already travels with “apply” (`tests/test_referrals.py:294`). If the maintainer reads “a listing from another company” as the listing carrying it, (b) is a separate issue on `JobPosting` (archive bump, capture, API). - **Conflict with #239 and #202.** Both say an agency is not a kind and recorded the office as a kind. Neither is reversed: no kind is added, and agencies stay ordinary companies, qualified by what they do. - **Aliases for familiar names.** “Recruitment” or “Staffing” match no division name, so they get no code (`industries.py` `code_for` matches division names only). **Recommended: no alias table in this issue**; the division name is offered in every language Eurostat publishes, and the help says so. ## Prior art No new dependency. NACE Rev. 2.1 is already vendored (Eurostat, CC BY 4.0, `src/postulo/jobs/data/LICENCE.md`) and #140 chose it over a hand-made list; the rule that a classification is taken, not invented, is the one the maintainer's list states. Not verified: the Rev. 2.1 titles of the groups of division 78 (Rev. 2 has 78.1, 78.2 and 78.3). ## Builds on Issues #202 (the kind), #239 (the agency on the application), #140 and #266 (a classification is taken, not written), and #329 (companies get addresses and phones; the office is a company there too). ## Needed by #685, #686 and #693 add their roles to the registry; #695 reuses g3's. #683 shares the company link with them and uses no role. ## Done when - A new test beside `tests/test_nace.py` covers the registry: the intermediary role is division 78, an unknown role is refused, a company with an industry named “Employment activities” (and its translations) qualifies, a company with none does not, and a public employment service always does. - `tests/test_referrals.py` changes with the picker: `test_the_form_offers_only_the_owners_own` (`:121`) and `test_the_employer_is_not_offered_as_its_own_agency` (`:158`) assert the qualifying-first order, and a new test asserts “show every company” lists the rest; `test_an_agency_that_is_the_employer_already_stays_on_its_list` (`:239`) still passes unchanged. - A test that one owner's qualifying set never contains another owner's company (`tests/security/test_isolation_sweep.py` if the picker gains a URL, otherwise the form test above). - No format change: the archive format (`src/postulo/core/export.py` `FORMAT_VERSION`, 29 at `f09b6010b`) and the candidate format (4) stay as they are, because nothing new is stored. If the maintainer chooses (b) under *Where the NACE code lives*, both bump (by one each, from whatever they are when the commit lands) and the standing checklist applies. - The label change is carried in the working catalogues (en-GB, fr-FR, pt-PT as drafts) and in Weblate (#349); the wiki's *Tracking applications* is corrected. From the list of improvements of 2026-10-02.
tiagoagueda added this to the 0.6.0 milestone 2026-10-03 01:58:50 +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#671
No description provided.