One LinkedIn, one repository, one website: a person's presence online is four single URL fields #189

Closed
opened 2026-09-12 09:55:27 +00:00 by tiagoagueda · 0 comments
Owner

Observation

Somebody has a GitHub and a GitLab, or a personal Mastodon and a professional one, or
three repositories worth showing. Postulo holds one of each:

accounts/models.py
  273:  website         = models.URLField(_("website"), blank=True)
  274:  linkedin_url    = models.URLField(_("LinkedIn"), blank=True)
  275:  source_repo_url = models.URLField(...)

jobs/models.py
  432:  linkedin_url    = models.URLField(_("LinkedIn"), blank=True)   # on a contact

Four single-valued URL columns, three of them on the person and one on every contact they
record. A second of anything goes in notes or nowhere.

The pattern to copy already exists, twice, two lines above the problem

Contact has this, immediately before its linkedin_url:

#: ...a generic foreign key alone has no referential integrity, so without this a
#: deleted contact would leave its telephone numbers behind, still holding their claim
#: on the instance-wide uniqueness rule.
phone_numbers    = GenericRelation("core.PhoneNumber", ...)
postal_addresses = GenericRelation("core.PostalAddress", ...)
linkedin_url     = models.URLField(_("LinkedIn"), blank=True)      # <- the odd one out

Profile has the same two relations. So telephone numbers and postal addresses are
already many-per-person, generic, attached to both the account holder and their contacts,
with the cascade written down — and a web presence, which is the same shape, is a column.

And the plugin half is equally settled. plugins/phone_numbers/__init__.py is the model:

Only the declaration lives here. The numbers themselves are a core model, because they
are a person's data and Postulo does the ownership scoping; what a plugin says is whether
this capability is offered
, and that is all this package holds.

So: a feature plugin holding a name and nothing else, a core. model behind a
GenericRelation, and plugins.policy deciding whether it is offered. Nothing new is being
invented — this is the third instance of a shape that has been through review twice.

1. resume.Link is multi-valued and already kinded:

PORTFOLIO, SITE ("Personal site"), CODE ("Code (GitHub, GitLab…)"),
DESIGN ("Design (Behance, Dribbble…)"), PUBLICATION, VIDEO, OTHER

It also checks links (LinkStatus: not checked / answered / did not answer), renders as a
CV Links section, and can be sent with an application. CODE is, word for word, "multiple
code repos".

2. accounts.PersonIdentifier is multi-valued and schemed: ORCID, ResearcherID, Scopus,
ISNI, other — the scholarly identities a name cannot carry.

3. The four URL columns above.

So the real question is not "what model" but "which of these owns a GitHub address"

There is a defensible line, and the issue turns on whether it is worth a fourth store:

  • The contact block is the header printed on every CV — how to find and reach you. The
    profile page says exactly that: "the contact block that will be printed on your CVs".
  • The Links section is CV content — work that already lives somewhere, listed among
    experience and education.

A GitHub URL is plausibly either, and means something different in each. That distinction is
real. Whether it justifies a fourth table is the decision this issue needs.

The cheaper path worth pricing first: give resume.Link a "show in the contact block"
flag. That yields multiple social profiles and multiple code repos immediately, reusing the
kinds, the link checking and the CV rendering that all already work — and it costs one field
and one migration rather than a model, a plugin, a page and sixty-eight catalogues. What it
does not give is links on a contact, since resume.Link belongs to the account holder;
Contact.linkedin_url would stay single.

If links on contacts matter, the generic model is the right answer and the flag is not. That
is the question to settle before any code.

If it is the new model

  • core.WebProfile (name to be argued), generic, GenericRelation from both Profile and
    Contact, with the cascade the phone-number comment explains — a generic FK alone has
    no referential integrity and deleting the holder must delete these.
  • A feature plugin under plugins/, declaration only, name fixed like phone-numbers
    because "changing it orphans every decision an administrator has recorded about it".
  • A vocabulary, and whether it is open. LinkedIn, GitHub, GitLab, Codeberg, Mastodon,
    Bluesky, personal site — and then somebody's Forgejo instance. resume.LinkKind solves this
    by describing the sort of thing rather than naming the host, which ages far better than a
    list of brand names and is the precedent to follow. accounts/identifiers.py shows the
    other shape, a scheme list with OTHER.
  • Ordering, because a contact block prints in an order somebody chose.
  • Migrating the four columns, keeping their values, and deciding whether they stay as
    read-only aliases for a release or go at once (the export format in core/export.py names
    them, so FORMAT_VERSION is involved either way).
  • Never fetched. resume.Link's docstring is the standard to hold: "Postulo never
    fetches a link on its own… the only thing worse is a job tracker quietly making requests
    nobody asked for."
    Whatever this becomes inherits that, including any favicon or avatar
    temptation.
  • #180 makes Your details the candidate's own record with a left nav; this is one of the
    things that would sit on it, and the two should agree on where contact data lives.
  • #187 — as an internal plugin its strings are core's, in core's catalogues, under the
    release rule.
## Observation Somebody has a GitHub *and* a GitLab, or a personal Mastodon *and* a professional one, or three repositories worth showing. Postulo holds one of each: accounts/models.py 273: website = models.URLField(_("website"), blank=True) 274: linkedin_url = models.URLField(_("LinkedIn"), blank=True) 275: source_repo_url = models.URLField(...) jobs/models.py 432: linkedin_url = models.URLField(_("LinkedIn"), blank=True) # on a contact Four single-valued URL columns, three of them on the person and one on every contact they record. A second of anything goes in *notes* or nowhere. ## The pattern to copy already exists, twice, two lines above the problem `Contact` has this, immediately before its `linkedin_url`: #: ...a generic foreign key alone has no referential integrity, so without this a #: deleted contact would leave its telephone numbers behind, still holding their claim #: on the instance-wide uniqueness rule. phone_numbers = GenericRelation("core.PhoneNumber", ...) postal_addresses = GenericRelation("core.PostalAddress", ...) linkedin_url = models.URLField(_("LinkedIn"), blank=True) # <- the odd one out `Profile` has the same two relations. So *telephone numbers* and *postal addresses* are already many-per-person, generic, attached to both the account holder and their contacts, with the cascade written down — and a web presence, which is the same shape, is a column. And the plugin half is equally settled. `plugins/phone_numbers/__init__.py` is the model: > **Only the declaration lives here.** The numbers themselves are a core model, because they > are a person's data and Postulo does the ownership scoping; what a plugin says is *whether > this capability is offered*, and that is all this package holds. So: a `feature` plugin holding a name and nothing else, a `core.` model behind a `GenericRelation`, and `plugins.policy` deciding whether it is offered. Nothing new is being invented — this is the third instance of a shape that has been through review twice. ## Before building it: three things already store links, and they overlap **1. `resume.Link`** is multi-valued and already kinded: PORTFOLIO, SITE ("Personal site"), CODE ("Code (GitHub, GitLab…)"), DESIGN ("Design (Behance, Dribbble…)"), PUBLICATION, VIDEO, OTHER It also *checks* links (`LinkStatus`: not checked / answered / did not answer), renders as a CV **Links** section, and can be sent with an application. `CODE` is, word for word, "multiple code repos". **2. `accounts.PersonIdentifier`** is multi-valued and schemed: ORCID, ResearcherID, Scopus, ISNI, other — the scholarly identities a name cannot carry. **3. The four URL columns above.** ### So the real question is not "what model" but "which of these owns a GitHub address" There is a defensible line, and the issue turns on whether it is worth a fourth store: - **The contact block** is the header printed on every CV — *how to find and reach you*. The profile page says exactly that: "the contact block that will be printed on your CVs". - **The Links section** is CV *content* — *work that already lives somewhere*, listed among experience and education. A GitHub URL is plausibly either, and means something different in each. That distinction is real. Whether it justifies a fourth table is the decision this issue needs. **The cheaper path worth pricing first:** give `resume.Link` a "show in the contact block" flag. That yields multiple social profiles and multiple code repos immediately, reusing the kinds, the link checking and the CV rendering that all already work — and it costs one field and one migration rather than a model, a plugin, a page and sixty-eight catalogues. What it does *not* give is links on a **contact**, since `resume.Link` belongs to the account holder; `Contact.linkedin_url` would stay single. If links on contacts matter, the generic model is the right answer and the flag is not. That is the question to settle before any code. ## If it is the new model - `core.WebProfile` (name to be argued), generic, `GenericRelation` from both `Profile` and `Contact`, **with the cascade** the phone-number comment explains — a generic FK alone has no referential integrity and deleting the holder must delete these. - A `feature` plugin under `plugins/`, declaration only, name fixed like `phone-numbers` because "changing it orphans every decision an administrator has recorded about it". - **A vocabulary, and whether it is open.** LinkedIn, GitHub, GitLab, Codeberg, Mastodon, Bluesky, personal site — and then somebody's Forgejo instance. `resume.LinkKind` solves this by describing the *sort* of thing rather than naming the host, which ages far better than a list of brand names and is the precedent to follow. `accounts/identifiers.py` shows the other shape, a scheme list with `OTHER`. - **Ordering**, because a contact block prints in an order somebody chose. - Migrating the four columns, keeping their values, and deciding whether they stay as read-only aliases for a release or go at once (the export format in `core/export.py` names them, so `FORMAT_VERSION` is involved either way). - **Never fetched.** `resume.Link`'s docstring is the standard to hold: *"Postulo never fetches a link on its own… the only thing worse is a job tracker quietly making requests nobody asked for."* Whatever this becomes inherits that, including any favicon or avatar temptation. ## Related - #180 makes *Your details* the candidate's own record with a left nav; this is one of the things that would sit on it, and the two should agree on where contact data lives. - #187 — as an internal plugin its strings are core's, in core's catalogues, under the release rule.
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#189
No description provided.