Several telephone numbers per account: one primary, unique across the instance #90

Closed
opened 2026-09-07 14:42:55 +00:00 by tiagoagueda · 1 comment
Owner

Observation

allow a user having multiple phone numbers, but all of them have to be platform wise
unique and one have to be defined as primary, as it happens with e-mails

What exists

Profile.phone is a single CharField, stored in international form (+33612345678) by
the PhoneField in core/phone_field.py. One number, no uniqueness, no primary.

Email addresses already work the way this asks: allauth's EmailAddress gives an account
several, exactly one primary, each verified independently, and unique across the
instance. So there is a shape to copy, and its edges are already known.

What this asks for

A PhoneNumber model owned by a person, with:

  • several per account, added and removed like email addresses;
  • exactly one primary, and the invariant enforced where it cannot be dodged rather
    than in a form — a database constraint plus the service that sets it;
  • unique across the instance, on the normalised international form, so that two
    accounts cannot both claim +351912345678.

The three things to decide before writing any of it

1. Instance-wide uniqueness is a disclosure. It is worth being explicit, because the
project's first commitment is that one person's data never reaches another's. If a number
must be unique across the instance, then "this number is already in use" tells whoever
typed it that somebody else on this server has it -- which is a real fact about another
account, leaked to anybody who can add a number. Email has the same property and allauth
answers it in a specific way; whatever is done here should be a deliberate choice with the
same care, not an inherited default. Options: refuse with a vague message, refuse plainly,
or take it silently and let an administrator resolve the collision. This needs deciding
first because it shapes the constraint, the form and the tests.

2. Uniqueness of what, exactly. +351912345678 and 00351912345678 and
+351 912 345 678 are one number written three ways, and only the stored international
form is comparable. Postulo deliberately keeps an unparseable number exactly as typed
(phones.py says so, and means it: refusing to save it would be the worst outcome). So a
number that could not be parsed has no normalised form, and cannot participate in a
uniqueness constraint at all. The rule probably has to be: unique among numbers that
parsed, ignored for those that did not.

3. Verification, or the absence of it. An email address is unique and verified, and
the verification is what makes uniqueness meaningful -- otherwise anybody can squat any
address. Postulo cannot send an SMS and should not start; that is an outbound channel, a
cost and a dependency the project does not have. So these numbers will be unique but
unverified, which means the first account to type a number owns it. Worth stating in the
interface rather than discovering.

Scope

  • PhoneNumber model, owner-scoped like everything else, with the primary constraint.
  • A migration that moves the existing Profile.phone into the first row and makes it
    primary, losing nothing.
  • Settings -> Your details grows an add/remove/make-primary list, the way email addresses
    already have one.
  • The primary number is what a CV and a cover letter render, so nothing downstream needs
    to know there are several.
  • tests/security/ gets the ownership sweep, since this is a new table holding a personal
    detail.

Classification

Enhancement. Not breaking for anybody's data -- the migration carries the existing number
across -- but it changes a documented model field, so the wiki and the API schema move
with it.

## Observation > allow a user having multiple phone numbers, but all of them have to be platform wise > unique and one have to be defined as primary, as it happens with e-mails ## What exists `Profile.phone` is a single `CharField`, stored in international form (`+33612345678`) by the `PhoneField` in `core/phone_field.py`. One number, no uniqueness, no primary. Email addresses already work the way this asks: allauth's `EmailAddress` gives an account several, exactly one `primary`, each `verified` independently, and unique across the instance. So there is a shape to copy, and its edges are already known. ## What this asks for A `PhoneNumber` model owned by a person, with: - **several per account**, added and removed like email addresses; - **exactly one primary**, and the invariant enforced where it cannot be dodged rather than in a form — a database constraint plus the service that sets it; - **unique across the instance**, on the normalised international form, so that two accounts cannot both claim `+351912345678`. ## The three things to decide before writing any of it **1. Instance-wide uniqueness is a disclosure.** It is worth being explicit, because the project's first commitment is that one person's data never reaches another's. If a number must be unique across the instance, then "this number is already in use" tells whoever typed it that *somebody else on this server has it* -- which is a real fact about another account, leaked to anybody who can add a number. Email has the same property and allauth answers it in a specific way; whatever is done here should be a deliberate choice with the same care, not an inherited default. Options: refuse with a vague message, refuse plainly, or take it silently and let an administrator resolve the collision. This needs deciding first because it shapes the constraint, the form and the tests. **2. Uniqueness of what, exactly.** `+351912345678` and `00351912345678` and `+351 912 345 678` are one number written three ways, and only the stored international form is comparable. Postulo deliberately keeps an unparseable number exactly as typed (`phones.py` says so, and means it: refusing to save it would be the worst outcome). So a number that could not be parsed has no normalised form, and cannot participate in a uniqueness constraint at all. The rule probably has to be: unique among numbers that parsed, ignored for those that did not. **3. Verification, or the absence of it.** An email address is unique *and verified*, and the verification is what makes uniqueness meaningful -- otherwise anybody can squat any address. Postulo cannot send an SMS and should not start; that is an outbound channel, a cost and a dependency the project does not have. So these numbers will be unique but unverified, which means the first account to type a number owns it. Worth stating in the interface rather than discovering. ## Scope - `PhoneNumber` model, owner-scoped like everything else, with the primary constraint. - A migration that moves the existing `Profile.phone` into the first row and makes it primary, losing nothing. - Settings -> Your details grows an add/remove/make-primary list, the way email addresses already have one. - The primary number is what a CV and a cover letter render, so nothing downstream needs to know there are several. - `tests/security/` gets the ownership sweep, since this is a new table holding a personal detail. ## Classification Enhancement. Not breaking for anybody's data -- the migration carries the existing number across -- but it changes a documented model field, so the wiki and the API schema move with it.
tiagoagueda added this to the 0.3.0 milestone 2026-09-07 14:42:55 +00:00
Author
Owner

Landed on 0.3.0 as 1a0f690.

The design changed from what this issue proposed, on the maintainer's direction, in three
ways worth recording here.

It is a plugin, not a schema change. Several telephone numbers is an internal plugin
of a new kind, feature — the first kind that governs a capability of Postulo itself
rather than a conversation with something outside it. Switched off, Postulo shows and uses
the primary number only, which is exactly what the single phone field did.

Off deletes nothing. The proposal in conversation was that disabling should discard
everything but the primary. It does not: the interface promises, on two pages and in every
language Postulo speaks, that switching a plugin off deletes nothing, and the first kind
whose subject is Postulo's own tables is not where that promise gets an exception. The
rows stay, the page says how many are being kept back, an export carries all of them either
way, and switching it on again finds them unchanged.

Contacts too. Not just an account holder: a contact at a company holds numbers on the
same terms, which is why the rows are a generic relation rather than a foreign key.

The three questions this issue said to decide first were decided as follows.

  1. Instance-wide uniqueness is a disclosure. Kept instance-wide, extended to contacts,
    on the maintainer's decision. The message says plainly that the number is already
    recorded on this instance and may belong to somebody else's records, because a vaguer
    message discloses exactly as much while leaving the person guessing. wiki/Tracking-applications.md
    says what else is not disclosed: not whose, not where, not when.
  2. Uniqueness of what. The normalised international form, and only that. A number nobody
    could parse is kept as typed and takes part in no comparison — the rule phones.py
    already sets. normalise() also reads 00 as the international prefix, so one number
    written three ways is one number.
  3. Verification. None, and said so in the wiki: unique but unverified, and the first
    account to type a number holds it.

One thing this issue did not anticipate: existing data was never held to the rule, and two
contacts sharing a switchboard is not a mistake. The data migration grandfathers such pairs
rather than picking a winner, and the form tells you the first time one is edited.

Landed on `0.3.0` as 1a0f690. The design changed from what this issue proposed, on the maintainer's direction, in three ways worth recording here. **It is a plugin, not a schema change.** *Several telephone numbers* is an internal plugin of a new kind, `feature` — the first kind that governs a capability of Postulo itself rather than a conversation with something outside it. Switched off, Postulo shows and uses the primary number only, which is exactly what the single `phone` field did. **Off deletes nothing.** The proposal in conversation was that disabling should discard everything but the primary. It does not: the interface promises, on two pages and in every language Postulo speaks, that switching a plugin off deletes nothing, and the first kind whose subject is Postulo's own tables is not where that promise gets an exception. The rows stay, the page says how many are being kept back, an export carries all of them either way, and switching it on again finds them unchanged. **Contacts too.** Not just an account holder: a contact at a company holds numbers on the same terms, which is why the rows are a generic relation rather than a foreign key. The three questions this issue said to decide first were decided as follows. 1. *Instance-wide uniqueness is a disclosure.* Kept instance-wide, extended to contacts, on the maintainer's decision. The message says plainly that the number is already recorded on this instance and may belong to somebody else's records, because a vaguer message discloses exactly as much while leaving the person guessing. `wiki/Tracking-applications.md` says what else is not disclosed: not whose, not where, not when. 2. *Uniqueness of what.* The normalised international form, and only that. A number nobody could parse is kept as typed and takes part in no comparison — the rule `phones.py` already sets. `normalise()` also reads `00` as the international prefix, so one number written three ways is one number. 3. *Verification.* None, and said so in the wiki: unique but unverified, and the first account to type a number holds it. One thing this issue did not anticipate: existing data was never held to the rule, and two contacts sharing a switchboard is not a mistake. The data migration grandfathers such pairs rather than picking a winner, and the form tells you the first time one is edited.
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.

Reference
Postulo/postulo#90
No description provided.