Every account owns its dashboard arrangement from the day it exists #123

Closed
opened 2026-09-09 10:10:45 +00:00 by tiagoagueda · 1 comment
Owner

Observation

the dashboard, even if inicilized for a new user, must be unique for each user.

First of three. The grid and the dragging depend on this one, because what a person drags a
widget into has to be stored somewhere that is theirs.

What exists

Profile.dashboard_widgets is a nullable JSON list of widget keys, and it already holds
three distinguishable answers:

Stored Means What the page shows
None never arranged the seven defaults, in default_order
[] deliberately cleared an empty dashboard, offering to bring something back
["a", "b"] this person's arrangement those, in that order

Nothing is shared between accounts today. widgets.keys_for(profile) reads one
profile; widgets.Sources is built per request from request.user; every widget computes
against for_user() querysets. Two accounts that have never arranged anything are not
looking at one dashboard — they are looking at the same default list of keys, each
computed against their own records. Worth saying plainly, because "unique for each user"
could be read as a leak and it is not one.

So the change this asks for is about ownership of the arrangement: giving every account
a stored arrangement of its own from the moment its profile exists, instead of an implicit
default computed from the registry.

What this asks for

A dashboard arrangement that belongs to the account from the day the account does.

Worth being careful about

None is load-bearing, and materialising every account destroys it. The rule it exists
for is written out in core/widgets.py: a widget added in a later release should appear
for somebody who has never arranged their dashboard, and stay away from somebody who has
built a page deliberately.
If every account has an explicit list, nobody is ever "never
arranged", and a widget added in 0.4.0 reaches nobody, for ever, unless something else
is built to carry it there.

That something has to be decided as part of this, not after it. The options, without
choosing here:

  • a stored generation marker — the arrangement records which release's default it was
    seeded from, and a later widget is offered to anybody still on an older generation;
  • a new-widgets tray on the arrange page — added widgets land in a visible "not shown
    yet" area rather than on the page, which makes the arrival explicit instead of silent;
  • a per-account seen set, which is the most precise and the most state.

Two things read has_arranged() and both change meaning. Back to the standard
arrangement
sets dashboard_widgets = None; the banner explaining that new widgets will
appear is shown when is_default is true. If nothing is ever None, reset has to mean
"seed me from today's default" and the banner has to say something else or go.

Existing accounts are the population this protects. A migration that materialises
everybody freezes today's seven defaults for every account that has never touched the
setting — which is exactly the group the None rule was written for. That is the trade;
it should be made knowingly rather than discovered in 0.4.0 when a new widget appears for
nobody.

Profiles are created lazily, in more than one place. Profile.objects.get_or_create
appears in the settings views, and accounts/signals.py fills language and time zone from
SiteSettings when a profile is made. Whatever seeds an arrangement has to sit where
profiles are actually created, or accounts arriving by another route — an archive import,
createsuperuser, a first SSO sign-in — get no arrangement and fall into whatever the code
does with a missing one.

The import path carries this field. core/importer.py copies profile attributes out of
an archive by name. An arrangement exported before this change is a list of keys; one
exported after may not be. The importer already reads every earlier format and has to keep
doing so.

Decide the key namespace now, while it is free. Built-in keys are bare — counters,
funnel, gone_quiet. The suggestion says widgets become extensible in a later release.
When they do, a third-party widget can register a key that collides with a built-in one,
and the collision lands inside stored arrangements rather than in the registry, where it
would at least raise. Choosing the shape now (postulo:counters against acme:counters,
or a registry that refuses an unnamespaced third-party key) costs a decision. Choosing it
after people have arranged their dashboards costs a data migration of every one of them.

A stored key that no longer exists is already handled — keys_for() passes over it
rather than breaking the page — and whatever replaces the list must keep doing that, since
uninstalling a plugin is a normal event and not an error.

## Observation > the dashboard, even if inicilized for a new user, must be unique for each user. First of three. The grid and the dragging depend on this one, because what a person drags a widget *into* has to be stored somewhere that is theirs. ## What exists `Profile.dashboard_widgets` is a nullable JSON list of widget keys, and it already holds three distinguishable answers: | Stored | Means | What the page shows | | --- | --- | --- | | `None` | never arranged | the seven defaults, in `default_order` | | `[]` | deliberately cleared | an empty dashboard, offering to bring something back | | `["a", "b"]` | this person's arrangement | those, in that order | **Nothing is shared between accounts today.** `widgets.keys_for(profile)` reads one profile; `widgets.Sources` is built per request from `request.user`; every widget computes against `for_user()` querysets. Two accounts that have never arranged anything are not looking at one dashboard — they are looking at the same *default list of keys*, each computed against their own records. Worth saying plainly, because "unique for each user" could be read as a leak and it is not one. So the change this asks for is about **ownership of the arrangement**: giving every account a stored arrangement of its own from the moment its profile exists, instead of an implicit default computed from the registry. ## What this asks for A dashboard arrangement that belongs to the account from the day the account does. ## Worth being careful about **`None` is load-bearing, and materialising every account destroys it.** The rule it exists for is written out in `core/widgets.py`: *a widget added in a later release should appear for somebody who has never arranged their dashboard, and stay away from somebody who has built a page deliberately.* If every account has an explicit list, nobody is ever "never arranged", and a widget added in 0.4.0 reaches **nobody**, for ever, unless something else is built to carry it there. That something has to be decided as part of this, not after it. The options, without choosing here: - a stored **generation marker** — the arrangement records which release's default it was seeded from, and a later widget is offered to anybody still on an older generation; - a **new-widgets tray** on the arrange page — added widgets land in a visible "not shown yet" area rather than on the page, which makes the arrival explicit instead of silent; - a per-account **seen set**, which is the most precise and the most state. **Two things read `has_arranged()` and both change meaning.** *Back to the standard arrangement* sets `dashboard_widgets = None`; the banner explaining that new widgets will appear is shown when `is_default` is true. If nothing is ever `None`, reset has to mean "seed me from today's default" and the banner has to say something else or go. **Existing accounts are the population this protects.** A migration that materialises everybody freezes today's seven defaults for every account that has never touched the setting — which is exactly the group the `None` rule was written for. That is the trade; it should be made knowingly rather than discovered in 0.4.0 when a new widget appears for nobody. **Profiles are created lazily, in more than one place.** `Profile.objects.get_or_create` appears in the settings views, and `accounts/signals.py` fills language and time zone from `SiteSettings` when a profile is made. Whatever seeds an arrangement has to sit where profiles are actually created, or accounts arriving by another route — an archive import, `createsuperuser`, a first SSO sign-in — get no arrangement and fall into whatever the code does with a missing one. **The import path carries this field.** `core/importer.py` copies profile attributes out of an archive by name. An arrangement exported before this change is a list of keys; one exported after may not be. The importer already reads every earlier format and has to keep doing so. **Decide the key namespace now, while it is free.** Built-in keys are bare — `counters`, `funnel`, `gone_quiet`. The suggestion says widgets become extensible in a later release. When they do, a third-party widget can register a key that collides with a built-in one, and the collision lands *inside* stored arrangements rather than in the registry, where it would at least raise. Choosing the shape now (`postulo:counters` against `acme:counters`, or a registry that refuses an unnamespaced third-party key) costs a decision. Choosing it after people have arranged their dashboards costs a data migration of every one of them. **A stored key that no longer exists is already handled** — `keys_for()` passes over it rather than breaking the page — and whatever replaces the list must keep doing that, since uninstalling a plugin is a normal event and not an error.
tiagoagueda added this to the 0.3.0 milestone 2026-09-09 10:10:45 +00:00
Author
Owner

Done in a507a3b4.

Ownership, which was the ask

Profile.dashboard_widgets is non-nullable now and seeded when the profile is made — in accounts/signals.py, where every creation path passes, and again on the way past in the arrange view for a profile that somehow has none. Both, because "from the day the account exists" should not depend on every future creation path having remembered.

Your reading was right and the issue said so plainly: nothing was ever shared. What changed is that the arrangement is a stored fact rather than a computation, which is what a grid needs before a widget can be dragged into anything.

The null was load-bearing, and here is what replaced it

Of the three you listed, the seen set is the only one that survives the next thing coming:

  • a generation marker ties the answer to a release, and a widget from a plugin installed on a Tuesday does not have one;
  • a tray is a way of showing an answer rather than being one — it is now how the seen set is presented, which is the right relationship between them;
  • a seen set is precise, and the extra state is one JSON column.

Profile.dashboard_known holds every key an account has already decided about. A key in neither that nor the arrangement is new to that account, whether it arrived in a release or with a plugin.

An empty seen set means nothing recorded, not nothing decided. The set only ever grows and starts as every key there is, so it is never legitimately empty — while an archive from before this, or a row made by a path that missed the seeding, gives exactly that. Announcing every widget in Postulo as new to somebody who has been reading their own dashboard for a year is the worse of the two mistakes.

The trade, made knowingly

A widget added in 0.4.0 no longer appears by itself for somebody who never arranged their dashboard. It waits on the arrange page under New since you last arranged this, with two buttons — Add and Not this — and the dashboard names what is waiting.

Strictly this is one fewer thing happening without being asked: the old behaviour changed somebody's page during an upgrade. And Not this is an answer that is remembered, so nothing is offered twice.

The two things that read has_arranged

Both changed, as you said they would.

  • Reset writes this account's own copy of the standard arrangement rather than clearing the field. "The standard page" is a thing to be given, not an absence to fall back into.
  • The banner now says something true in both states: the arrangement is yours, it starts out standard, and nothing arrives without being asked. has_arranged is gone; is_standard(profile) asks the value instead of its absence, which is the same question with one fewer state.

Existing accounts

The migration fills in everybody who had a null, and records that they have been offered everything that exists. That freezes today's seven defaults for accounts that never touched the setting — the group the null protected — which is exactly the cost you named, paid here rather than discovered when a new widget reaches nobody. Those accounts get the seven they were already seeing, plus a seen set that will announce the eighth.

An empty list is left alone: it means deliberately cleared, which is a decision and not an absence. The reverse restores a null only for an arrangement identical to the standard one.

The keys are written out in the migration rather than read from core.widgets — a migration that imported the registry would do something different the day a widget is renamed. It records what was true when it was written, which is all a migration can honestly record. There is a test for that.

The key namespace, decided now

A bare key is Postulo's; anybody else's is provider:key. Widget.provider says who, and register() refuses the wrong shape at start-up: a plugin's widget with a bare key, or a namespaced key with no provider to own it. counters and acme:counters are two widgets and can coexist.

You were right about the cost of deferring: a key lands inside every stored arrangement, so a collision found afterwards is a data migration of every one of them rather than an error somebody sees once.

The import path

The arrangement had never travelled in the archive at all — PROFILE_FIELDS did not carry it. It does now, with the seen set beside it, at format 12. A stored key that no longer exists is still passed over rather than breaking the page, which uninstalling a plugin makes an ordinary event.

Also

  • tests/test_dashboard_ownership.py, 16 tests. Suite 4369 passed, 29 skipped; browser suite 54 passed.
  • Six new strings, filled in all 39 European catalogues. The dashboard notice names the waiting widgets rather than counting them — it says the same thing, tells you whether you care, and avoids a plural form in thirty-nine languages.

Next in the chain is #124: the control for placing a widget in two dimensions, which has to exist before the grid.

Done in `a507a3b4`. ## Ownership, which was the ask `Profile.dashboard_widgets` is non-nullable now and seeded when the profile is made — in `accounts/signals.py`, where every creation path passes, and again on the way past in the arrange view for a profile that somehow has none. Both, because "from the day the account exists" should not depend on every future creation path having remembered. Your reading was right and the issue said so plainly: nothing was ever shared. What changed is that the arrangement is a stored fact rather than a computation, which is what a grid needs before a widget can be dragged into anything. ## The null was load-bearing, and here is what replaced it Of the three you listed, the **seen set** is the only one that survives the next thing coming: - a **generation marker** ties the answer to a release, and a widget from a plugin installed on a Tuesday does not have one; - a **tray** is a way of *showing* an answer rather than being one — it is now how the seen set is presented, which is the right relationship between them; - a **seen set** is precise, and the extra state is one JSON column. `Profile.dashboard_known` holds every key an account has already decided about. A key in neither that nor the arrangement is new **to that account**, whether it arrived in a release or with a plugin. **An empty seen set means *nothing recorded*, not *nothing decided*.** The set only ever grows and starts as every key there is, so it is never legitimately empty — while an archive from before this, or a row made by a path that missed the seeding, gives exactly that. Announcing every widget in Postulo as new to somebody who has been reading their own dashboard for a year is the worse of the two mistakes. ## The trade, made knowingly A widget added in 0.4.0 **no longer appears by itself** for somebody who never arranged their dashboard. It waits on the arrange page under *New since you last arranged this*, with two buttons — *Add* and *Not this* — and the dashboard names what is waiting. Strictly this is one fewer thing happening without being asked: the old behaviour changed somebody's page during an upgrade. And *Not this* is an answer that is **remembered**, so nothing is offered twice. ## The two things that read `has_arranged` Both changed, as you said they would. - **Reset** writes this account's own copy of the standard arrangement rather than clearing the field. "The standard page" is a thing to be given, not an absence to fall back into. - **The banner** now says something true in both states: the arrangement is yours, it starts out standard, and nothing arrives without being asked. `has_arranged` is gone; `is_standard(profile)` asks the value instead of its absence, which is the same question with one fewer state. ## Existing accounts The migration fills in everybody who had a null, and records that they have been offered everything that exists. That freezes today's seven defaults for accounts that never touched the setting — the group the null protected — which is exactly the cost you named, paid here rather than discovered when a new widget reaches nobody. Those accounts get the seven they were already seeing, plus a seen set that will announce the eighth. An empty list is left alone: it means *deliberately cleared*, which is a decision and not an absence. The reverse restores a null only for an arrangement identical to the standard one. **The keys are written out in the migration rather than read from `core.widgets`** — a migration that imported the registry would do something different the day a widget is renamed. It records what was true when it was written, which is all a migration can honestly record. There is a test for that. ## The key namespace, decided now A **bare key is Postulo's**; anybody else's is `provider:key`. `Widget.provider` says who, and `register()` refuses the wrong shape at start-up: a plugin's widget with a bare key, or a namespaced key with no provider to own it. `counters` and `acme:counters` are two widgets and can coexist. You were right about the cost of deferring: a key lands *inside* every stored arrangement, so a collision found afterwards is a data migration of every one of them rather than an error somebody sees once. ## The import path The arrangement had never travelled in the archive at all — `PROFILE_FIELDS` did not carry it. It does now, with the seen set beside it, at format 12. A stored key that no longer exists is still passed over rather than breaking the page, which uninstalling a plugin makes an ordinary event. ## Also - `tests/test_dashboard_ownership.py`, 16 tests. Suite 4369 passed, 29 skipped; browser suite 54 passed. - Six new strings, filled in all 39 European catalogues. The dashboard notice names the waiting widgets rather than counting them — it says the same thing, tells you whether you care, and avoids a plural form in thirty-nine languages. Next in the chain is #124: the control for placing a widget in two dimensions, which has to exist before the grid.
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#123
No description provided.