feat(tenancy): a project ceiling per plan, and copy for the backend's error codes - #55
Merged
Conversation
… organizations
Seats already had a plan ceiling. This adds projects, and stops there.
Every ceiling is scoped to an organization, because an organization is where a
subscription lives. That is the whole shape: anything a plan limits is
something an organization contains, so Organization.plan answers all of it and
nothing new has to hold a subscription.
How many organizations somebody creates is deliberately not limited. An empty
one holds no projects, no flags and no traffic, so metering them would be
metering nothing -- and the moment you do limit them, the model needs an owner
to count against, because "how many do you own" cannot be answered by an ADMIN
membership: being invited to administer somebody else's organization would
consume your own allowance. Flagsmith does not limit organizations either, and
the ownership question is the reason worth naming.
_SEAT_LIMITS becomes _LIMITS, a row per plan. Flags, environments, conditions
and rules have no entry, on purpose: a limit that does not exist is not written
down as None in twenty places, it simply has no check.
The project check shares a transaction with the insert and locks the
organization row first, the same shape as the seat check on invitation accept.
Without it two concurrent creates both observe "one below the limit" and both
commit. SQLite ignores select_for_update silently, so that protection is only
real on PostgreSQL -- which is what CI and any deployment run.
The plan is never accepted from the request. The serializer already marks it
read-only and the model defaults it, so upgrading cannot be a field in a POST
body.
DEFAULT_ORGANIZATION_PLAN is the whole difference between the editions:
unset COMMUNITY -- projects and seats unlimited
DEFAULT_ORGANIZATION_PLAN=FREE 1 project, 3 seats
Verified both, and that an unrecognised value raises ImproperlyConfigured
naming the four it accepts rather than falling through to something.
11 tests: the ceiling and the refusal, that it is per organization rather than
per user, COMMUNITY unaffected, organizations staying unlimited, administering
somebody else's costing nothing, the plan coming from the setting, the plan not
being choosable by the caller, an unrecognised default refused, and flags
staying unlimited.
603 passed on SQLite and 603 on PostgreSQL 18.
Endpoints that distinguish their failures answer with a machine code --
{"error": "seat_limit_reached"} -- and api.ts surfaces that code as the
ApiError's message, on purpose: copy should not be a backend deploy, and the
same failure reaches more than one screen.
The translation was optional and scattered. One switch in the invitation page
covered four codes; everywhere else showed the code itself. Nothing failed when
a code had no copy, so project_limit_reached shipped in the previous commit
with none, and a person hitting a plan ceiling would have read exactly that.
ERROR_COPY is one table, and errorCopy() reads it. An unmapped code is
returned as it arrived rather than replaced with something generic: a "please
try again" throws away the only piece of information anybody had, and the
person most likely to see a raw code is whoever just added it. A screen where
an unrecognised failure means something better of its own passes a fallback --
the invitation page still says the link is dead, because on that page it is.
The table is kept complete by a test that walks the backend's Python for
`{"error": "..."}` and fails naming any code with no copy, and any copy for a
code the backend cannot send. Verified it discriminates: removing one entry
fails the run and names it. Without that, this table drifts the same way the
switch did -- silently, because an unmapped code still looks like a message.
Wired into the four screens that receive these codes: project creation, member
removal, invitation accept, and password reset.
DEFAULT_ORGANIZATION_PLAN was readable from settings but not visible to anyone
reading a compose file, so the value a container actually runs on could only be
found by reading Python.
Declared with the same override shape compose.yml already uses for everything
configurable -- ${VAR:-default} -- so the file states the default and an
operator can change it without editing the file:
DEFAULT_ORGANIZATION_PLAN=${DEFAULT_ORGANIZATION_PLAN:-COMMUNITY}
COMMUNITY in both, including the production file. A hosted deployment sets FREE
in its own environment; somebody who clones this repository and runs it gets
what a self-hosted install should get, which is no ceilings at all. The
alternative -- a literal FREE in compose.dev.yml -- was tried and removed: it
would have limited every development environment to one project by default,
which is the opposite of what an open source install is for.
Verified through `docker compose config`, which resolves the interpolation:
COMMUNITY with nothing set, FREE with the variable exported, in both files.
ruff check failed on CI. makemigrations writes `import tenancy.models` above `from django.db import ...`, which is the opposite of this project's order -- Django generates the file and does not know the rule. Caught by CI rather than locally because the backend lint was never run here: the frontend one was, the backend one was not. `ruff check .` is what the CI job runs, and it is one command.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Seats already had a plan ceiling. This adds projects, stops there, and fixes the frontend gap that made the addition visible.
Every ceiling is scoped to an organization
That is the whole shape: an organization is where a subscription lives, so anything a plan limits is something an organization contains.
Organization.plananswers all of it and nothing new has to hold a subscription.How many organizations somebody creates is deliberately not limited. An empty one holds no projects, no flags and no traffic, so metering them would be metering nothing.
And the moment you do limit them, the model needs an owner to count against —
Organizationhas none, and an ADMIN membership is not a substitute: being invited to administer somebody else's organization would consume your own allowance. An earlier revision of this branch had anAccountmodel and that ceiling; it was removed once the ownership question surfaced. Flagsmith does not limit organizations either.Flags, environments, conditions and rules have no entry, on purpose. A limit that does not exist is not written down as
Nonein twenty places — it simply has no check.The check
Shares a transaction with the insert and locks the organization row first, the same shape as the seat check on invitation accept. Without it two concurrent creates both observe "one below the limit" and both commit.
SQLite ignores
select_for_updatesilently, so that protection is only real on PostgreSQL — which is what CI and any deployment run. Said in the code so nobody reads a green local suite as proof of it.The plan is never accepted from the request: the serializer already marks it read-only and the model defaults it, so upgrading cannot be a field in a POST body.
One setting is the whole difference between the editions
Declared in both compose files with the override shape
compose.ymlalready uses, so the default is visible without reading Python:DEFAULT_ORGANIZATION_PLAN=${DEFAULT_ORGANIZATION_PLAN:-COMMUNITY}A literal
FREEincompose.dev.ymlwas tried and removed: it would have limited every development environment to one project, which is the opposite of what a self-hosted install is for.An unrecognised value raises
ImproperlyConfigurednaming the four it accepts, rather than falling through to something.The frontend gap this exposed
Endpoints that distinguish their failures answer with a machine code, and
api.tssurfaces it as the error's message — on purpose, since copy should not be a backend deploy. But the translation was one switch in the invitation page; everywhere else showed the code itself, and nothing failed when a code had none.So
project_limit_reachedshipped in the first commit with no copy, and a person hitting the ceiling would have read exactly that.ERROR_COPYis now one table anderrorCopy()reads it. An unmapped code is returned as it arrived rather than replaced with something generic: a "please try again" throws away the only information anybody had, and the person most likely to see a raw code is whoever just added it. A screen with a better meaning of its own passes a fallback — the invitation page still says the link is dead, because there it is.Kept complete by a test that walks the backend's Python for
{"error": "..."}and fails naming any code with no copy, and any copy for a code the backend cannot send. Verified it discriminates: removing one entry fails the run and names it.Wired into the four screens that receive these codes: project creation, member removal, invitation accept, password reset.
Verified
docker compose configresolves toCOMMUNITYunset andFREEoverridden, in both files