Teams
Overview¶
SpeedPy includes a full multi-tenancy system based on teams. Teams can be enabled or disabled via the SPEEDPY_TEAMS_ENABLED setting.
Models¶
All team models live in mainapp/models/teams.py.
Team¶
The core Team model stores team info, subscription plan, and limits:
class Team(BaseModel):
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=100, unique=True)
logo = models.ImageField(upload_to="team_logos/", blank=True, null=True)
plan = models.CharField(max_length=50, default="free", choices=SUBSCRIPTION_PLANS_CHOICES)
is_active = models.BooleanField(default=True)
limits_max_team_members = models.PositiveIntegerField(blank=True, null=True)
Inherits id (UUID), created_at, and updated_at from BaseModel.
TeamMembership¶
Connects users to teams with roles:
class TeamMembership(TeamModel):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
role = models.CharField(choices=[
("owner", "Owner"), # Full control, billing, delete team
("admin", "Admin"), # Manage team, invite members
("member", "Member"), # Create/edit, view data
("viewer", "Viewer"), # Read-only access
], default="member")
invited_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True)
invite_accepted_at = models.DateTimeField(null=True, blank=True)
access_expires_at = models.DateTimeField(null=True, blank=True)
Permission rules: - Owner can manage anyone - Admin can manage members and viewers (not owners or other admins) - Members and Viewers cannot manage anyone
TeamInvitation¶
Handles inviting users to teams:
class TeamInvitation(TeamModel):
invited_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
email = models.EmailField()
role = models.CharField(choices=[("admin", "Admin"), ("member", "Member"), ("viewer", "Viewer")])
token = models.CharField(max_length=64, unique=True)
status = models.CharField(choices=[
("pending", "Pending"), ("accepted", "Accepted"),
("declined", "Declined"), ("expired", "Expired"), ("revoked", "Revoked"),
], default="pending")
expires_at = models.DateTimeField(null=True, blank=True)
Invitations auto-generate a secure token and expire after 7 days by default.
TeamModel (Abstract)¶
For your own multi-tenant models, inherit from TeamModel:
from mainapp.models.teams import TeamModel
class Project(TeamModel):
name = models.CharField(max_length=255)
# Gets: id, created_at, updated_at, team (FK to Team)
URL Routes¶
Team URLs are conditionally included based on SPEEDPY_TEAMS_ENABLED:
| URL | View | Purpose |
|---|---|---|
/teams/create/ |
TeamCreateView |
Create a new team |
/teams/<team_id>/dashboard/ |
TeamDashboardView |
Team dashboard |
/teams/<team_id>/settings/ |
TeamSettingsView |
Team settings |
/teams/<team_id>/members/ |
TeamMembersListView |
List members |
/teams/<team_id>/members/invite/ |
InviteMemberView |
Invite a member |
/teams/<team_id>/members/<id>/update-role/ |
UpdateMemberRoleView |
Change member role |
/teams/<team_id>/members/<id>/remove/ |
RemoveMemberView |
Remove a member |
/teams/invitations/<token>/accept/ |
AcceptInvitationView |
Accept invitation |
/teams/invitations/<token>/decline/ |
DeclineInvitationView |
Decline invitation |
/teams/<team_id>/invitations/<id>/revoke/ |
RevokeInvitationView |
Revoke invitation |
/teams/<team_id>/delete/ |
TeamDeleteView |
Delete the team, or schedule it (owner only, POST) |
/teams/<team_id>/delete/cancel/ |
TeamDeleteCancelView |
Undo a scheduled deletion (owner only, POST) |
Background Tasks¶
Team-related Celery tasks are in mainapp/tasks/teams.py:
send_team_invitation_email— sends the invitation email when a member is invitedsend_role_change_email— notifies a user when their role changesexpire_team_memberships— runs daily at 2:00 AM, deletes memberships pastaccess_expires_atexpire_team_memberships_invitations— runs daily at 2:30 AM, deletes expired pending invitationspurge_scheduled_team_deletions— runs hourly at :15, deletes teams whose undo window has run out
Deleting a team¶
Deletion is owner only and POST only, and it is paced by a setting:
With 0 the button deletes the team there and then. With any other value the
click schedules the deletion that many hours out, and any owner can undo it
until the hour arrives. The danger zone on the team settings page shows one of
two blocks — "delete" or "undo scheduled deletion" — and its copy follows the
setting, so the number is never written twice.
Four rules are worth knowing before you change any of this.
A scheduled team stays active. TeamViewMixin resolves is_active teams
only, so setting is_active=False when scheduling would hide the undo button
behind a 404 from the one person allowed to press it. The team is not deleted
yet, so it keeps working until it is.
A live subscription blocks the deletion. active, past_due and paused
all block — past_due can still retry a card, and paused can resume.
canceled does not block, even inside its paid period, because nobody is
charged again. The check does not depend on SPEEDPY_BILLING_ENABLED: that
flag says whether you sell, not whether a provider is charging. It runs in the
view, again in the purge task after the window, and finally inside
Team.delete(), so admin and a shell cannot walk around it. Checkout is refused
for a scheduled team for the same reason — otherwise the two rules deadlock each
other.
Object storage is your job, not the boilerplate's. Deleting a team cascades every tenant row and touches no files. Logos, uploads, transcoded video and CDN copies would outlive their rows: unreachable, still paid for, sometimes still publicly readable. Register your own teardown:
Each hook is called with the team before its rows go. A hook must be idempotent, and it must raise on failure — the team then stays scheduled and the next hourly run retries it. That is deliberate: the rows are the only record of which objects still need removing, so deleting them first would throw the list away.
Two things worth cleaning up in a hook that are easy to miss: an ImageField
row never deletes its own file, and BillingCustomer/BillingSubscription are
joined to the team by a billable_type/billable_id string pair rather than
a foreign key, so the cascade cannot see them at all.
The purge deletes one team per transaction, each locked with
select_for_update. A bulk queryset delete would bypass Team.delete() — and
with it the subscription rule — and would race with an undo.
Billing is closed while a deletion is pending¶
The mark comes first and the delete second, even with a delay of 0. That matters for two reasons:
- It shuts the billing doors. Checkout and the provider's customer portal both refuse a team that is scheduled — the portal is a second door, since both Stripe's and Paddle's let somebody start or resume a subscription outside your UI, and you would only learn about it from a webhook. The billing page says so and drops the buttons.
- It makes the state durable. If the last step cannot finish — a cleanup hook failed, or a subscription reappeared mid-flight — the team stays marked and the hourly task completes the job. The owner is told the team is being deleted, which is true, rather than shown an error for a decision that has already been acted on.
The admin is not a way round any of it¶
TeamAdmin.delete_model and delete_queryset both route through the deletion
service. The bulk action needed its own override: Django's collector performs a
QuerySet.delete() without ever calling Model.delete(), so without it staff
could delete a team the provider was still charging.
Subscription Plans¶
Plans are defined in mainapp/subscription_plans.py:
Add your plans here and use team.get_plan_config() to check plan features and limits.