Give companies telephone numbers, postal addresses and an email address, and let two people record the same switchboard #329

Closed
opened 2026-09-30 20:30:05 +00:00 by tiagoagueda · 3 comments
Owner

A company's card on a CardDAV server carries what it answers on: a switchboard (TEL), a general mailbox (EMAIL), an office (ADR). Postulo keeps none of them for a company. PhoneNumber, PostalAddress and WebLink are generic rows for "a person or a contact", as their docstrings say (src/postulo/core/models.py), and only Profile and Contact declare relations to them. Company has a website, a careers page, a location and notes, but no number, email or address; Contact has one email field; the only email rows are allauth's, for accounts. The DAV improvements ask to import a card "as a new company (or update existing one)", to pair a card with a company, and to export to CardDAV. A plugin cannot add a table of its own (#228), so a company card's TEL, EMAIL and ADR can only be dropped or pushed into notes.

What stands in the way

Numbers are unique across the instance. phone_number_unique_across_the_instance (src/postulo/core/models.py) constrains the normalised number, first come first served since #90, and taken_elsewhere (src/postulo/core/phone_numbers.py) refuses a number any other row holds, the same account's included. For a person's own number the rule is load-bearing: a number that gets somebody back into an account has to be unique (#144), and how often anyone hears the refusal is limited (#142). Once companies hold numbers, it gets in the way: two people on one instance registered with one employment office (#202) could not both record its line, and one person could not record a recruiter whose direct line is also the company's. Contacts already meet it (test_two_accounts_cannot_both_hold_one_number in tests/test_phone_numbers.py uses two contacts). Tracking applications names the difference: "a recruiter's switchboard is a number you recorded, never a number you are."

Addresses are unique per owner, across every holder. address_unique_per_owner is on (owner, comparable), while the form only catches a repeat within one holder (BasePostalAddressFormSet.clean in src/postulo/core/postal.py). A company and a recruiter working in its building, or a parent and its subsidiary at one address, would hit the database constraint, not a message.

Proposal

  • Company gains phone_numbers and postal_addresses, as Contact has, and an email field: shown on the company page, edited on its form with the partials the profile uses, following the Several telephone numbers switch, and carried in CompanyOut, CompanyIn and CompanyPatch, the personal export (a new archive format, src/postulo/core/export.py) and its import.
  • Merging companies (#239) moves their numbers and addresses as a contact merge does; otherwise _refuse_what_is_left (src/postulo/jobs/merging.py) refuses the merge.
  • website and careers_url stay the company's links; no WebLink rows.
  • Nothing shows or edits a contact's postal addresses (partials/postal_addresses.html is included only by the profile page); the contact form gains them, as a person card's ADR lands there.
  • Uniqueness as decided below. taken_elsewhere, which the forms, the API, the importers and the surface's phone_number_is_taken all call, answers the chosen rule for the holder being written to; the docstrings, the collision message and the wiki (Tracking applications, Your career record) follow.

Decisions

  • Which numbers are unique across the instance. Recommended: numbers held by a profile, a person's own, verified or not. That keeps #90 for what it asked ("a user having multiple phone numbers, but all of them have to be platform wise unique"), keeps #144's reason, and frees every number a person merely records. It needs a flag save writes, as it writes normalised: content type ids differ per database. Alternatives: verified numbers only (nothing calls PhoneNumber.record_verified yet, so today no rule at all), or companies alone exempt (two contacts sharing a switchboard, or two people recording one recruiter, would still collide). Unique per holder either way.
  • Addresses: unique per owner or per holder. Recommended: per holder, as WebLink is. One list naming an address twice is still the mistake #92 meant; a company and the people who work in its building are not.
  • One email or several. Recommended: one field, as Contact has; several, for both, is a separate question.
  • Departments. Recommended: not now. Department is deliberately only a name, so a department card's TEL, EMAIL and ADR still have nowhere to go; the alternative is the same rows on Department.

Needed by

postulo/postulo-dav#8, postulo/postulo-dav#9 and postulo/postulo-dav#10, for a company card's TEL, EMAIL and ADR. #328 is how a plugin writes these rows.

Done when

  • A company's numbers, addresses and email are shown on its page, edited on its form, and carried by the API and an export and import round trip; a contact's addresses are shown and edited on its form.
  • Two accounts record one switchboard on their companies; two profiles still cannot hold one number, and the refusal is still limited; a test each.
  • A company and one of its contacts at one address both save, and merging companies moves their rows.
  • One account's company numbers and addresses never appear on another's; the company pages stay in the isolation sweep and the page-coverage walk; the new strings are in the working catalogues (en, fr-fr, pt-pt, pt-br as drafts); the migration grants no verification to any row.

From the list of DAV plugin improvements of 2026-09-30.

A company's card on a CardDAV server carries what it answers on: a switchboard (`TEL`), a general mailbox (`EMAIL`), an office (`ADR`). Postulo keeps none of them for a company. `PhoneNumber`, `PostalAddress` and `WebLink` are generic rows for "a person or a contact", as their docstrings say (`src/postulo/core/models.py`), and only `Profile` and `Contact` declare relations to them. `Company` has a website, a careers page, a location and notes, but no number, email or address; `Contact` has one `email` field; the only email rows are allauth's, for accounts. The DAV improvements ask to import a card "as a new company (or update existing one)", to pair a card with a company, and to export to CardDAV. A plugin cannot add a table of its own (#228), so a company card's `TEL`, `EMAIL` and `ADR` can only be dropped or pushed into notes. ## What stands in the way **Numbers are unique across the instance.** `phone_number_unique_across_the_instance` (`src/postulo/core/models.py`) constrains the normalised number, first come first served since #90, and `taken_elsewhere` (`src/postulo/core/phone_numbers.py`) refuses a number any other row holds, the same account's included. For a person's own number the rule is load-bearing: a number that gets somebody back into an account has to be unique (#144), and how often anyone hears the refusal is limited (#142). Once companies hold numbers, it gets in the way: two people on one instance registered with one employment office (#202) could not both record its line, and one person could not record a recruiter whose direct line is also the company's. Contacts already meet it (`test_two_accounts_cannot_both_hold_one_number` in `tests/test_phone_numbers.py` uses two contacts). *Tracking applications* names the difference: "a recruiter's switchboard is a number you recorded, never a number you are." **Addresses are unique per owner, across every holder.** `address_unique_per_owner` is on `(owner, comparable)`, while the form only catches a repeat within one holder (`BasePostalAddressFormSet.clean` in `src/postulo/core/postal.py`). A company and a recruiter working in its building, or a parent and its subsidiary at one address, would hit the database constraint, not a message. ## Proposal - `Company` gains `phone_numbers` and `postal_addresses`, as `Contact` has, and an `email` field: shown on the company page, edited on its form with the partials the profile uses, following the *Several telephone numbers* switch, and carried in `CompanyOut`, `CompanyIn` and `CompanyPatch`, the personal export (a new archive format, `src/postulo/core/export.py`) and its import. - Merging companies (#239) moves their numbers and addresses as a contact merge does; otherwise `_refuse_what_is_left` (`src/postulo/jobs/merging.py`) refuses the merge. - `website` and `careers_url` stay the company's links; no `WebLink` rows. - Nothing shows or edits a contact's postal addresses (`partials/postal_addresses.html` is included only by the profile page); the contact form gains them, as a person card's `ADR` lands there. - Uniqueness as decided below. `taken_elsewhere`, which the forms, the API, the importers and the surface's `phone_number_is_taken` all call, answers the chosen rule for the holder being written to; the docstrings, the collision message and the wiki (*Tracking applications*, *Your career record*) follow. ## Decisions - **Which numbers are unique across the instance.** Recommended: numbers held by a profile, a person's own, verified or not. That keeps #90 for what it asked ("a user having multiple phone numbers, but all of them have to be platform wise unique"), keeps #144's reason, and frees every number a person merely records. It needs a flag `save` writes, as it writes `normalised`: content type ids differ per database. Alternatives: verified numbers only (nothing calls `PhoneNumber.record_verified` yet, so today no rule at all), or companies alone exempt (two contacts sharing a switchboard, or two people recording one recruiter, would still collide). Unique per holder either way. - **Addresses: unique per owner or per holder.** Recommended: per holder, as `WebLink` is. One list naming an address twice is still the mistake #92 meant; a company and the people who work in its building are not. - **One email or several.** Recommended: one field, as `Contact` has; several, for both, is a separate question. - **Departments.** Recommended: not now. `Department` is deliberately only a name, so a department card's `TEL`, `EMAIL` and `ADR` still have nowhere to go; the alternative is the same rows on `Department`. ## Needed by postulo/postulo-dav#8, postulo/postulo-dav#9 and postulo/postulo-dav#10, for a company card's `TEL`, `EMAIL` and `ADR`. #328 is how a plugin writes these rows. ## Done when - A company's numbers, addresses and email are shown on its page, edited on its form, and carried by the API and an export and import round trip; a contact's addresses are shown and edited on its form. - Two accounts record one switchboard on their companies; two profiles still cannot hold one number, and the refusal is still limited; a test each. - A company and one of its contacts at one address both save, and merging companies moves their rows. - One account's company numbers and addresses never appear on another's; the company pages stay in the isolation sweep and the page-coverage walk; the new strings are in the working catalogues (en, fr-fr, pt-pt, pt-br as drafts); the migration grants no verification to any row. From the list of DAV plugin improvements of 2026-09-30.
Author
Owner

From the read-only review of 2026-09-29 (postulo at 95348908d, checked again at 0a5cad587). A reviewer reached the same conclusion about the instance-wide constraint from the code; it was not filed again. What it adds to the above:

  • A contact entry can block the number's real holder. Whoever records a stranger's mobile as a contact makes it impossible for that stranger, on the same instance, to add the number to their own profile, and with it to use it as a way back into their account (#144). The refusal that protects profile numbers is being triggered by a row that identifies nobody.
  • The refusal costs an allowance and says the wrong thing. A person recording the same switchboard on two of their own contacts is told "it may belong to somebody else's records" (ALREADY_IN_USE, core/phone_numbers.py), and each attempt spends one of the hourly collision answers (collision_message). #333 is the same allowance seen from the plugin surface.
  • A shape for the fix: store on the row whether it identifies its owner (true only when the holder is a profile; a partial index on a content-type id would not be portable between databases), condition the instance-wide constraint on that, and keep a per-holder constraint on (content_type, object_id, normalised) so one holder cannot list a number twice. taken_elsewhere then looks only at profile numbers.
**From the read-only review of 2026-09-29** (postulo at `95348908d`, checked again at `0a5cad587`). A reviewer reached the same conclusion about the instance-wide constraint from the code; it was not filed again. What it adds to the above: - **A contact entry can block the number's real holder.** Whoever records a stranger's mobile as a contact makes it impossible for that stranger, on the same instance, to add the number to their own profile, and with it to use it as a way back into their account (#144). The refusal that protects profile numbers is being triggered by a row that identifies nobody. - **The refusal costs an allowance and says the wrong thing.** A person recording the same switchboard on two of their own contacts is told "it may belong to somebody else's records" (`ALREADY_IN_USE`, `core/phone_numbers.py`), and each attempt spends one of the hourly collision answers (`collision_message`). #333 is the same allowance seen from the plugin surface. - **A shape for the fix:** store on the row whether it identifies its owner (true only when the holder is a profile; a partial index on a content-type id would not be portable between databases), condition the instance-wide constraint on that, and keep a per-holder constraint on `(content_type, object_id, normalised)` so one holder cannot list a number twice. `taken_elsewhere` then looks only at profile numbers.
Author
Owner

The maintainer's list of 2026-10-02 asks for map pins for companies and contacts with a full address, and two new issues depend on this one.

#702 (pins from a full address) cannot pin a company until it can hold an address. On main at f09b6010b only Profile and Contact carry postal_addresses (src/postulo/accounts/models.py:282, src/postulo/jobs/models.py:635); Company has none. #671 (employment services as NACE-coded companies) does not need an address, but it names the public employment office as a company whose number and address belong here.

This issue has no milestone, so company pins follow whenever it gets one.

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

The maintainer's list of 2026-10-02 asks for map pins for companies and contacts with a full address, and two new issues depend on this one. #702 (pins from a full address) cannot pin a company until it can hold an address. On `main` at `f09b6010b` only `Profile` and `Contact` carry `postal_addresses` (`src/postulo/accounts/models.py:282`, `src/postulo/jobs/models.py:635`); `Company` has none. #671 (employment services as NACE-coded companies) does not need an address, but it names the public employment office as a company whose number and address belong here. This issue has no milestone, so company pins follow whenever it gets one. From the list of improvements of 2026-10-02.
Author
Owner

From the maintainer's improvements list of 2026-10-07, for the company edit view: replace Location with addresses, possibly several, using the built-in postal plugin. Entering just a city and a country replaces today's Location; the exact place is inferred from a full address.

Today a company has a single free-text location (templates/jobs/company_form.html:40) and a correction field, placed offline from GeoNames at city level (jobs/models.py:432-456). This issue gives companies postal addresses; #702 places an address (town, then postcode centroid, never online); #703 makes several addresses several pins. So the request is these three together, plus: the location field retired once addresses exist, with each company's location carried into a city-and-country address by a migration. Checked against main at 8c3d03570.

From the maintainer's improvements list of 2026-10-07, for the company edit view: **replace *Location* with addresses**, possibly several, using the built-in postal plugin. Entering just a city and a country replaces today's *Location*; the exact place is inferred from a full address. Today a company has a single free-text `location` (`templates/jobs/company_form.html:40`) and a correction field, placed offline from GeoNames at city level (`jobs/models.py:432-456`). This issue gives companies postal addresses; #702 places an address (town, then postcode centroid, never online); #703 makes several addresses several pins. So the request is these three together, plus: the `location` field retired once addresses exist, with each company's `location` carried into a city-and-country address by a migration. Checked against `main` at `8c3d03570`.
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#329
No description provided.