Skip to content

Teams

Overview

SpeedPy includes a full multi-tenancy system based on teams. Teams can be enabled or disabled via the SPEEDPY_TEAMS_ENABLED setting.

SPEEDPY_TEAMS_ENABLED = env.bool("SPEEDPY_TEAMS_ENABLED", default=True)

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 invited
  • send_role_change_email — notifies a user when their role changes
  • expire_team_memberships — runs daily at 2:00 AM, deletes memberships past access_expires_at
  • expire_team_memberships_invitations — runs daily at 2:30 AM, deletes expired pending invitations
  • purge_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:

SPEEDPY_TEAM_DELETION_DELAY_HOURS = 24   # 0 = delete on the click

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:

SPEEDPY_TEAM_DELETION_CLEANUP_HOOKS = [
    "myapp.services.teardown.tear_down_team_storage",
]

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:

SUBSCRIPTION_PLANS = {
    'free': {
        'name': 'Free',
    }
}

Add your plans here and use team.get_plan_config() to check plan features and limits.