Skip to content

feat(teams): team-based multi-tenancy - #317

Closed
github-actions[bot] wants to merge 1 commit into
mainfrom
backport-312-to-main
Closed

feat(teams): team-based multi-tenancy#317
github-actions[bot] wants to merge 1 commit into
mainfrom
backport-312-to-main

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

Automated backport of #312 to main.

* feat(teams): add Buzz Team and Buzz Team Membership
Multi-tenancy foundation for v2: two doctypes, their controllers, and a patch
so existing installs come out of migrate owning a team rather than none.
Ownership is the membership row — there is deliberately no owner field on Buzz
Team. An earlier draft had one and it drifted immediately: changing it moved
nothing and left the field disagreeing with the rows it claimed to describe.
after_insert takes the owner from flags instead, since the patch creates teams
for users other than the session user.
sync_frappe_roles recomputes Event Manager / Frontdesk Manager across all of a
user's enabled memberships rather than adding and removing per row, so someone
holding Manager in two teams keeps the role when one membership is disabled.
Buzz User is not in that map: utils.add_buzz_user_role already grants it, and
two writers for one role would fight. Only on_update is wired, not
after_insert — Document._action is "save" for both, so the latter was a second
recompute on every insert.
Membership uniqueness is enforced in validate rather than a unique index, so
the invitation upsert in a later step gets a readable message instead of an
integrity error. enabled is a Check named exactly that: frappe's link search
filters on the fieldname and delete_doc offers "disable instead of delete" for
free.
Slugs dedupe on generation because two users called Sam both want sams-team and
the column is unique; an explicitly set duplicate still fails. Naming uses
BTEAM.#### rather than format:, which frappe now warns against.
Ownership cannot be transferred yet — the Owner row is locked outright.
Relaxing that to "one enabled Owner must remain" is the change to make when
there is a UI that needs it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(teams): add a team field to the team-direct doctypes
Buzz Event, Event Venue, Event Host, Event Template and Buzz Campaign get a
reqd, indexed team Link. Nothing else does: the 16 doctypes that carry an event
link can reach their team through it, so a denormalized column on each of them
would be a perf optimization for load nobody has measured. Add one per doctype
if a query ever proves slow.
reqd would break the 49 places tests construct these documents, so
set_team_from_sole_membership fills an empty team from the user's only enabled
membership, and setup_test_records seeds a Test Team owned by Administrator.
Zero or several memberships leave the field empty and let reqd raise rather than
picking a team on the user's behalf. Ordering holds because
run_before_save_methods runs validate before _validate runs the mandatory check.
Server-side inserts never consult frappe.defaults, so the handler is the
mechanism; user defaults would only have covered the Desk client.
assign_default_team backfills existing rows from the site's first owned team,
creating one if the site has none. It carries its own frozen doctype tuple: a
patch that follows a moving constant changes behaviour retroactively.
Nothing is enforced yet. A site on this commit behaves as it did before, except
that a user in two or more teams must pick one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(teams): give fresh sites and the e2e user a team
install_app defaults to set_as_patched=True (frappe/installer.py:271, :330), so
patches are recorded as applied and never run on a new site. create_default_teams
therefore never fires there, leaving the site with no team at all — and the first
Event Host insert dies on the reqd team field. E2E hit this first because its site
is built from scratch every run.
after_install now seeds Administrator a team, and setup_test_records reuses it
instead of making a second one, which would have given Administrator two
memberships and broken the resolver for every fixture.
The e2e user is created after install with System Manager only, so it has no
membership of its own. auth.setup.ts gives it one; every other setup project
already depends on that project, and this keeps a local playwright run working
too, which a CI step would not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(teams): drop the install-time team seed
after_install seeded Administrator a team so a fresh site was never teamless.
auth.setup.ts now covers the e2e site and setup_test_records covers the python
suite, so the only thing left for it to do on a real install was put an
Administrator's Team record on every new site that no organiser would use.
A new install starts with no team, and the first user creates one from the Team
field. That is the model: a user with no team picks one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(teams): enforce row-level team isolation
Adds buzz/permissions.py: query conditions and deny-only has_permission hooks
for every team-owned doctype. Doctypes with a team column are scoped on it; the
rest reach their team through their event link, since step 02 deliberately left
them without a denormalized column.
has_team_access holds the whole role matrix — any enabled member reads,
Owner/Admin/Manager write, Owner/Admin delete — and the four hook pairs are thin
wrappers over it plus their carve-out. Rows with no team stay readable so a
half-migrated site does not hard-break.
Non-member carve-outs keep the attendee-facing dashboard working: owner matches
for Event Booking, Event Ticket, Sponsorship Enquiry and Event Talk; published
events widen Buzz Event and Sponsorship Tier for logged-in non-members. Guest
reads are never narrowed. Event Payment and Ticket Cancellation Request get no
carve-out — attendees reach those through APIs that already ignore permissions,
so adding one would widen access past today's behaviour.
Talk Proposal composes the team scope inside its existing two functions rather
than re-registering, or speakers would lose sight of their own proposals. Its
is_proposal_manager carve-out is gone: with teams, Event Manager is held by every
team manager on the site, so a blanket role check would defeat isolation on the
doctype most exposed to it.
CheckinService gains the membership check in its constructor, the one place both
check-in endpoints route through, and the e2e front-desk fixture now gets a
membership — the role alone is no longer enough.
hooks.py spells the 23 registrations out as literal data and imports nothing;
test_every_tenant_doctype_is_wired_to_both_hooks derives the tenant inventory
from the schema and fails if a doctype is missing from either dict.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(teams): return qb criterions from permission query hooks
The permission_query_conditions builders spliced namespaced SQL by hand:
backticked table names, embedded subqueries and manual frappe.db.escape
calls. db_query renders a non-string hook return through pypika instead
(frappe/model/db_query.py:1180-1184), inlining values with the driver's
own escaping, so the fragments can be composable expressions.
Every builder now returns a Criterion, or None when the user is
unrestricted, replacing the "" sentinel. Talk Proposal's speaker
conditions move with them, since it composes the derived builder's
output and would otherwise concatenate a Criterion into an f-string.
Adds two tests for Buzz Team and Buzz Team Membership list scoping. The
suite never read either doctype, so the builders behind them had no
coverage before this.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(teams): create team memberships from accepted invitations
Invitations reuse frappe's User Invitation. The team and the team role
ride on two custom fields — extra invite params are dropped on insert
without them, and the accept flow re-reads the invitation from the
database, so the hook would otherwise see nothing.
on_invitation_accepted is the only place every accepted invitation
routes through, whoever created it, so the inviter's authority is
checked there rather than at invite time: invited_by must hold Owner or
Admin on the team, and no invitation may grant ownership. Membership is
upserted, so a lapsed member is re-enabled instead of duplicated and an
enabled one is left alone.
sync_frappe_roles now saves the User with ignore_permissions. The accept
endpoint is allow_guest, so the invitee is Guest while the hook runs and
could not write to User — every accept would have raised PermissionError,
as would a team admin adding a member from the dashboard.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(teams): give every team its own settings
Tenant-specific configuration moves out of the site-wide Buzz Settings
single into a per-team Buzz Team Settings doctype, one row per team,
named after it. Buzz Settings keeps the genuinely global config (login
banner, event-proposal funnel) and becomes the seed source for the rest.
A team's row is created with its owner membership in Buzz Team.after_insert,
copying the current globals; create_team_settings_for_existing_teams does
the same for teams that predate the doctype. Copy-on-create, no runtime
fallback chain: changing a global later never mutates existing teams.
Rewired readers: ticket transfer / add-on / cancellation windows, ticket
and booking confirmation email templates, support_email, sponsor-deck
defaults and the Zoom webinar template. Event-level fields keep precedence
where they already had it. ticket.html no longer fetches Buzz Settings
itself — support_email arrives in args, as booking_confirmation.html
already did.
Two things the spec assumed turned out otherwise. Int columns are NOT NULL
DEFAULT 0, so an unset cutoff can never be None and a Python-side default
is unreachable: the 7-day fallback is the doctype default and
DEFAULT_CUTOFF_DAYS is gone. And the write rule for settings (members
read, Owner/Admin write) already existed as membership_has_permission,
now renamed team_admin_has_permission and shared by both doctypes.
default_webinar_template stays a custom field, mirrored onto Buzz Team
Settings from ZOOM_INTEGRATION_CUSTOM_FIELDS, so sites without
zoom_integration get neither a broken picker nor a failing seed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(teams): stub sendmail when inviting in team membership tests
The invitation mails itself out on insert, so the tests failed on any
runner without a default outgoing Email Account.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(teams): migrate existing sites into one shared team
Before teams, every enabled Event Manager worked on every event. The old
create_default_teams patch gave each of them a personal team, and
assign_default_team then backfilled every row to one arbitrary team, so
every other manager lost the events they had created.
create_default_teams now builds a single team: owner is the manager behind
the most Buzz Events (ties, and a site with no events, go to the oldest
account), everyone else joins as Admin. Administrator is excluded — it
bypasses team permissions and owns no one's work.
assign_default_team early-returns when no row is team-less, so a site that
needs no backfill no longer mints a "Default Team", and only the doctypes
with orphans are updated.
Both patches are bumped to #2 so already-migrated dev and CI sites pick up
the new semantics.
Patch tests move to buzz/test_default_team_patches.py. The patches read the
whole site, so setUp retires every enabled Event Manager first — the dev
database carries real ones, and rollback is per class, so the previous
test's managers are still around too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(teams): stub sendmail for every invitation, not just invite_by_email
df61297 put the frappe.sendmail stub inside the invite helper, but
test_accept_is_refused_when_the_invitation_names_no_team builds its
User Invitation directly — it has no team, so invite_by_email cannot
produce it. after_insert mails the invitee with now=True, which needs a
default outgoing Email Account, so the test errored on any site without
one. Local sites have an account configured; CI does not.
The stub moves to setUp, where every insert in the class routes through it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(teams): refuse to delete an owner membership
validate_owner_is_locked guards the save path only. The doctype grants
System Manager delete and team_admin_has_permission allows delete for any
team Owner or Admin, so the row that cannot be disabled or demoted could
still be removed outright, leaving the team with no owner.
on_trash closes the same invariant from the delete side. Nothing is
foreclosed by it: a Buzz Team with memberships already raises
LinkExistsError on delete, and the app has no team-deletion feature.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(teams): add team members from the Buzz Team form
A team had no way to take on an existing user. Only an accepted User
Invitation created a membership, and that path is for people who have no
account yet.
Adds an "Add Team Members" button: pick users as pills, give each one a
role, then create every membership in one call and mail the people who were
actually added. Both whitelisted methods are gated by can_manage_members,
the rule team_admin_has_permission already applied to membership writes;
onload publishes the same flag so the button stays hidden for a Manager or
Viewer. Owner is not offered — it is granted at team creation and locked.
Search is the app's own rather than frappe.db.get_link_options: the standard
query for User hides website users whenever filters are passed, and never
matches a full name. MultiSelectPills means to hide already-picked rows but
discards its own filter() result, so that is done here too.
upsert_membership now reports whether the user is newly on the team, so a
no-op re-add sends no mail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 2e3eef2)
@github-actionsgithub-actionsBot added the backport This PR / Issue is a backport for a branch other than develop label Aug 5, 2026
@harshtandiya
harshtandiya marked this pull request as draft August 18, 2026 13:48
@harshtandiyaharshtandiya added the Do Not Review PRs that are partial or need work before review from maintainer label Aug 18, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backportThis PR / Issue is a backport for a branch other than developDo Not ReviewPRs that are partial or need work before review from maintainer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@harshtandiya