🏢 Teams & Organizations¶
Full multi-tenancy with role-based access control for Django Keel SaaS projects.
Overview¶
The Teams app provides:
- Multi-tenant data isolation - Each team has separate data
- Role-Based Access Control (RBAC) - Owner, Admin, Member roles
- Team Invitations - Email-based with secure tokens
- Per-Seat Billing - Automatic Stripe subscription quantity updates
Models¶
Team¶
class Team(models.Model):
name = models.CharField(max_length=255)
slug = models.SlugField(unique=True)
owner = models.ForeignKey(User, on_delete=models.PROTECT)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
Methods:
- get_member_count() - Active members count
- get_active_members() - Queryset of active memberships
- has_member(user) - Check membership
- add_user(user, role, added_by) - Add team member
TeamMember¶
class TeamMember(models.Model):
team = models.ForeignKey(Team, on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.CASCADE)
role = models.CharField(max_length=20, choices=ROLE_CHOICES)
is_active = models.BooleanField(default=True)
added_by = models.ForeignKey(User, on_delete=models.SET_NULL)
joined_at = models.DateTimeField(auto_now_add=True)
Roles:
- owner - Full control, can delete team, manage billing
- admin - Manage members, can't delete team or change billing
- member - Read/write access, can't manage team
Methods:
- is_owner() - Check if owner
- is_admin() - Check if admin or owner
- can_manage_members() - Check if can add/remove members
TeamInvitation¶
class TeamInvitation(models.Model):
team = models.ForeignKey(Team, on_delete=models.CASCADE)
email = models.EmailField()
role = models.CharField(max_length=20, choices=ROLE_CHOICES)
invited_by = models.ForeignKey(User, on_delete=models.CASCADE)
token = models.CharField(max_length=64, unique=True, editable=False)
status = models.CharField(max_length=20, default="pending")
expires_at = models.DateTimeField()
created_at = models.DateTimeField(auto_now_add=True)
The token is generated on save with secrets.token_urlsafe(32), and expires_at defaults to 7 days from creation.
Methods:
- is_valid() - Check if still pending and not expired
- accept(user) - Accept invitation and create membership
- decline() - Decline invitation
- send_invitation_email() - Send invite email
Usage¶
Create Team¶
from apps.teams.models import Team, TeamMember
# Create team
team = Team.objects.create(name="Acme Corp", slug="acme-corp", owner=request.user)
# Owner membership is auto-created via signal
# But you can also create manually:
TeamMember.objects.create(team=team, user=request.user, role="owner", added_by=request.user)
Invite Member¶
from apps.teams.models import TeamInvitation
from datetime import timedelta
from django.utils import timezone
invitation = TeamInvitation.objects.create(
team=team,
email="colleague@example.com",
role="member",
invited_by=request.user,
# token and expires_at (7 days) are set automatically on save
)
# Send invitation email
invitation.send_invitation_email()
Accept Invitation¶
# User clicks link with token
invitation = TeamInvitation.objects.get(token=token)
if invitation.is_valid():
invitation.accept(request.user)
# Now user is a team member!
Check Membership¶
if team.has_member(request.user):
# User is a member
pass
# Or get membership
membership = TeamMember.objects.filter(team=team, user=request.user, is_active=True).first()
if membership and membership.is_admin():
# User is admin or owner
pass
Views & Permissions¶
Require Team Membership¶
The mixins resolve the team from the team_slug URL kwarg and set self.team and self.team_member on the view:
from django.views.generic import ListView
from apps.teams.permissions import TeamMemberRequiredMixin
# URL pattern must include <slug:team_slug>
class ProjectListView(TeamMemberRequiredMixin, ListView):
model = Project
def get_queryset(self):
# Filtered to the team from the URL
return super().get_queryset().filter(team=self.team)
Require Admin Role¶
from apps.teams.permissions import TeamAdminRequiredMixin
class MemberManageView(TeamAdminRequiredMixin, UpdateView):
model = TeamMember
# Only admins and owners can access
Require Owner Role¶
from apps.teams.permissions import TeamOwnerRequiredMixin
class TeamDeleteView(TeamOwnerRequiredMixin, DeleteView):
model = Team
# Only owner can delete team
Signals¶
Django Keel includes automatic signal handlers:
On Team Creation: - Auto-create owner membership
On Member Addition (with Stripe advanced mode): - Update Stripe subscription quantity for per-seat billing
On Member Removal: - Update Stripe subscription quantity
URL Patterns¶
# Teams
/teams/ # List teams
/teams/create/ # Create team
/teams/<team_slug>/ # Team detail (includes member list)
/teams/<team_slug>/edit/ # Edit team
/teams/<team_slug>/delete/ # Delete team
# Members
/teams/<team_slug>/members/<member_id>/edit/ # Edit member role
/teams/<team_slug>/members/<member_id>/remove/ # Remove member
/teams/<team_slug>/leave/ # Leave team
# Invitations
/teams/<team_slug>/invite/ # Invite member
/teams/invitations/<token>/accept/ # Accept invitation
/teams/invitations/<token>/decline/ # Decline invitation
Templates¶
Django Keel provides base templates you can customize:
templates/teams/
├── team_list.html
├── team_form.html
├── team_detail.html
├── team_confirm_delete.html
├── invitation_form.html
├── member_form.html
└── emails/
├── invitation.txt
└── invitation.html
Testing¶
# tests/teams/test_models.py
def test_team_creation(user):
team = Team.objects.create(name="Test Team", slug="test-team", owner=user)
assert team.get_member_count() == 1 # Owner auto-added
def test_add_member(user, team):
new_user = User.objects.create_user(email="new@example.com", password="testpass123")
team.add_user(new_user, role="member", added_by=user)
assert team.has_member(new_user)
Best Practices¶
- Always check team membership before showing data
- Use mixins for permission checks
- Filter querysets by team
- Log team actions for audit trail
- Handle member limits based on subscription
- Send email notifications for invitations
- Clean up expired invitations periodically
Next Steps¶
- Stripe Integration - Per-seat billing
- Feature Gating - Access control by plan
- User Impersonation - Support tools