feat: Implement staff invitation system with role-based permissions

- Add StaffInvitation model with token-based 7-day expiration
- Create invitation API endpoints (create, cancel, resend, accept, decline)
- Add permissions JSONField to User model for granular access control
- Implement frontend invite modal with role-specific permissions:
  - Manager: can_invite_staff, can_manage_resources, can_manage_services,
    can_view_reports, can_access_settings, can_refund_payments
  - Staff: can_view_all_schedules, can_manage_own_appointments
- Add edit staff modal with permissions management and deactivate option
- Create AcceptInvitePage for invitation acceptance flow
- Add active/inactive staff separation with collapsible section
- Auto-create bookable resource when configured at invite time
- Remove Quick Add Appointment from dashboard

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-11-28 02:03:48 -05:00
parent b10426fbdb
commit 83815fcb34
15 changed files with 2477 additions and 181 deletions

View File

@@ -12,7 +12,9 @@ from rest_framework.authtoken.views import obtain_auth_token
from smoothschedule.users.api_views import (
current_user_view, logout_view, send_verification_email, verify_email,
hijack_acquire_view, hijack_release_view
hijack_acquire_view, hijack_release_view,
staff_invitations_view, cancel_invitation_view, resend_invitation_view,
invitation_details_view, accept_invitation_view, decline_invitation_view
)
from schedule.api_views import current_business_view, update_business_view
@@ -47,6 +49,13 @@ urlpatterns += [
# Hijack (masquerade) API
path("api/auth/hijack/acquire/", hijack_acquire_view, name="hijack_acquire"),
path("api/auth/hijack/release/", hijack_release_view, name="hijack_release"),
# Staff Invitations API
path("api/staff/invitations/", staff_invitations_view, name="staff_invitations"),
path("api/staff/invitations/<int:invitation_id>/", cancel_invitation_view, name="cancel_invitation"),
path("api/staff/invitations/<int:invitation_id>/resend/", resend_invitation_view, name="resend_invitation"),
path("api/staff/invitations/token/<str:token>/", invitation_details_view, name="invitation_details"),
path("api/staff/invitations/token/<str:token>/accept/", accept_invitation_view, name="accept_invitation"),
path("api/staff/invitations/token/<str:token>/decline/", decline_invitation_view, name="decline_invitation"),
# Business API
path("api/business/current/", current_business_view, name="current_business"),
path("api/business/current/update/", update_business_view, name="update_business"),

View File

@@ -102,13 +102,15 @@ class StaffSerializer(serializers.ModelSerializer):
"""Serializer for Staff members (Users with staff roles)"""
name = serializers.SerializerMethodField()
role = serializers.SerializerMethodField()
can_invite_staff = serializers.SerializerMethodField()
class Meta:
model = User
fields = [
'id', 'username', 'name', 'email', 'phone', 'role',
'is_active', 'permissions', 'can_invite_staff',
]
read_only_fields = fields
read_only_fields = ['id', 'username', 'email', 'role', 'can_invite_staff']
def get_name(self, obj):
return obj.full_name
@@ -122,6 +124,9 @@ class StaffSerializer(serializers.ModelSerializer):
}
return role_mapping.get(obj.role, obj.role.lower())
def get_can_invite_staff(self, obj):
return obj.can_invite_staff()
class ServiceSerializer(serializers.ModelSerializer):
"""Serializer for Service model"""
@@ -139,19 +144,13 @@ class ServiceSerializer(serializers.ModelSerializer):
class ResourceSerializer(serializers.ModelSerializer):
"""Serializer for Resource model"""
capacity_description = serializers.SerializerMethodField()
user_id = serializers.IntegerField(source='user.id', read_only=True, allow_null=True)
user_id = serializers.IntegerField(required=False, allow_null=True)
user_name = serializers.CharField(source='user.full_name', read_only=True, allow_null=True)
user = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(),
required=False,
allow_null=True,
write_only=True
)
class Meta:
model = Resource
fields = [
'id', 'name', 'type', 'user', 'user_id', 'user_name',
'id', 'name', 'type', 'user_id', 'user_name',
'description', 'max_concurrent_events',
'buffer_duration', 'is_active', 'capacity_description',
'saved_lane_count', 'created_at', 'updated_at',
@@ -165,6 +164,35 @@ class ResourceSerializer(serializers.ModelSerializer):
return "Exclusive use (1 at a time)"
return f"Up to {obj.max_concurrent_events} concurrent events"
def to_representation(self, instance):
"""Add user_id to the output"""
ret = super().to_representation(instance)
ret['user_id'] = instance.user_id
return ret
def create(self, validated_data):
"""Handle user_id when creating a resource"""
user_id = validated_data.pop('user_id', None)
if user_id:
try:
validated_data['user'] = User.objects.get(id=user_id)
except User.DoesNotExist:
pass
return super().create(validated_data)
def update(self, instance, validated_data):
"""Handle user_id when updating a resource"""
user_id = validated_data.pop('user_id', None)
if user_id is not None:
if user_id:
try:
validated_data['user'] = User.objects.get(id=user_id)
except User.DoesNotExist:
pass
else:
validated_data['user'] = None
return super().update(instance, validated_data)
class ParticipantSerializer(serializers.ModelSerializer):
"""Serializer for Participant model"""

View File

@@ -282,12 +282,17 @@ class ServiceViewSet(viewsets.ModelViewSet):
return Response({'status': 'ok', 'updated': len(order)})
class StaffViewSet(viewsets.ReadOnlyModelViewSet):
class StaffViewSet(viewsets.ModelViewSet):
"""
API endpoint for listing staff members (Users who can be assigned to resources).
API endpoint for managing staff members (Users who can be assigned to resources).
Staff members are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF.
This endpoint is read-only for assigning staff to resources.
Supports:
- GET /api/staff/ - List staff members
- GET /api/staff/{id}/ - Get staff member details
- PATCH /api/staff/{id}/ - Update staff member (is_active, permissions)
- POST /api/staff/{id}/toggle_active/ - Toggle active status
"""
serializer_class = StaffSerializer
# TODO: Re-enable authentication for production
@@ -297,6 +302,10 @@ class StaffViewSet(viewsets.ReadOnlyModelViewSet):
ordering_fields = ['email', 'first_name', 'last_name']
ordering = ['first_name', 'last_name']
# Disable create and delete - staff are managed via invitations
# Note: 'post' is needed for custom actions like toggle_active
http_method_names = ['get', 'patch', 'post', 'head', 'options']
def get_queryset(self):
"""
Return staff members for the current tenant.
@@ -305,11 +314,17 @@ class StaffViewSet(viewsets.ReadOnlyModelViewSet):
"""
from django.db.models import Q
# Include inactive staff for listing (so admins can reactivate them)
show_inactive = self.request.query_params.get('show_inactive', 'true')
queryset = User.objects.filter(
Q(role=User.Role.TENANT_OWNER) |
Q(role=User.Role.TENANT_MANAGER) |
Q(role=User.Role.TENANT_STAFF)
).filter(is_active=True)
)
if show_inactive.lower() != 'true':
queryset = queryset.filter(is_active=True)
# Filter by tenant if user is authenticated and has a tenant
# TODO: Re-enable this when authentication is enabled
@@ -326,3 +341,55 @@ class StaffViewSet(viewsets.ReadOnlyModelViewSet):
)
return queryset
def partial_update(self, request, *args, **kwargs):
"""
Update staff member.
Allowed fields: is_active, permissions
Owners can edit any staff member.
Managers can only edit staff (not other managers or owners).
"""
instance = self.get_object()
# TODO: Add permission checks when authentication is enabled
# current_user = request.user
# if current_user.role == User.Role.TENANT_MANAGER:
# if instance.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]:
# return Response(
# {'error': 'Managers cannot edit owners or other managers.'},
# status=status.HTTP_403_FORBIDDEN
# )
# Only allow updating specific fields
allowed_fields = {'is_active', 'permissions'}
update_data = {k: v for k, v in request.data.items() if k in allowed_fields}
serializer = self.get_serializer(instance, data=update_data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
@action(detail=True, methods=['post'])
def toggle_active(self, request, pk=None):
"""Toggle the active status of a staff member."""
staff = self.get_object()
# Prevent deactivating yourself
# TODO: Enable this check when authentication is enabled
# if request.user.id == staff.id:
# return Response(
# {'error': 'You cannot deactivate your own account.'},
# status=status.HTTP_400_BAD_REQUEST
# )
staff.is_active = not staff.is_active
staff.save(update_fields=['is_active'])
return Response({
'id': staff.id,
'is_active': staff.is_active,
'message': f"Staff member {'activated' if staff.is_active else 'deactivated'} successfully."
})

View File

@@ -12,8 +12,10 @@ from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response
from rest_framework.authtoken.models import Token
from .models import User, EmailVerificationToken
from .models import User, EmailVerificationToken, StaffInvitation
from core.permissions import can_hijack
from rest_framework import serializers
from schedule.models import Resource, ResourceType
@api_view(['GET'])
@@ -61,9 +63,12 @@ def current_user_view(request):
'email_verified': user.email_verified,
'is_staff': user.is_staff,
'is_superuser': user.is_superuser,
'is_active': user.is_active,
'business': user.tenant_id,
'business_name': business_name,
'business_subdomain': business_subdomain,
'permissions': user.permissions,
'can_invite_staff': user.can_invite_staff(),
}
return Response(user_data, status=status.HTTP_200_OK)
@@ -201,9 +206,12 @@ def _get_user_data(user):
'email_verified': user.email_verified,
'is_staff': user.is_staff,
'is_superuser': user.is_superuser,
'is_active': user.is_active,
'business': user.tenant_id,
'business_name': business_name,
'business_subdomain': business_subdomain,
'permissions': user.permissions,
'can_invite_staff': user.can_invite_staff(),
}
@@ -329,3 +337,407 @@ def hijack_release_view(request):
'user': _get_user_data(original_user),
'masquerade_stack': masquerade_stack, # Return remaining stack (should be empty now)
}, status=status.HTTP_200_OK)
# ============================================================================
# Staff Invitation Endpoints
# ============================================================================
class StaffInvitationSerializer(serializers.ModelSerializer):
"""Serializer for staff invitations"""
invited_by_name = serializers.SerializerMethodField()
role_display = serializers.SerializerMethodField()
class Meta:
model = StaffInvitation
fields = [
'id', 'email', 'role', 'role_display', 'status',
'invited_by', 'invited_by_name',
'created_at', 'expires_at', 'accepted_at',
'create_bookable_resource', 'resource_name', 'permissions'
]
read_only_fields = ['id', 'status', 'invited_by', 'created_at', 'expires_at', 'accepted_at']
def get_invited_by_name(self, obj):
return obj.invited_by.full_name if obj.invited_by else None
def get_role_display(self, obj):
role_map = {
'TENANT_MANAGER': 'Manager',
'TENANT_STAFF': 'Staff',
}
return role_map.get(obj.role, obj.role)
@api_view(['GET', 'POST'])
@permission_classes([IsAuthenticated])
def staff_invitations_view(request):
"""
List pending invitations or create a new invitation.
GET /api/staff/invitations/ - List invitations for current tenant
POST /api/staff/invitations/ - Create new invitation
"""
user = request.user
# Check permission - only owners and managers with permission can manage invitations
if not user.can_invite_staff():
return Response(
{"error": "You do not have permission to invite staff members."},
status=status.HTTP_403_FORBIDDEN
)
# Must have a tenant
if not user.tenant:
return Response(
{"error": "No business associated with your account."},
status=status.HTTP_400_BAD_REQUEST
)
if request.method == 'GET':
# List invitations for this tenant
invitations = StaffInvitation.objects.filter(
tenant=user.tenant,
status=StaffInvitation.Status.PENDING
)
serializer = StaffInvitationSerializer(invitations, many=True)
return Response(serializer.data)
elif request.method == 'POST':
email = request.data.get('email', '').strip().lower()
role = request.data.get('role', User.Role.TENANT_STAFF)
create_bookable_resource = request.data.get('create_bookable_resource', False)
resource_name = request.data.get('resource_name', '').strip()
permissions = request.data.get('permissions', {})
# Validate email
if not email:
return Response(
{"error": "Email is required."},
status=status.HTTP_400_BAD_REQUEST
)
# Validate role - only allow manager and staff roles
if role not in [User.Role.TENANT_MANAGER, User.Role.TENANT_STAFF]:
return Response(
{"error": "Invalid role. Must be 'TENANT_MANAGER' or 'TENANT_STAFF'."},
status=status.HTTP_400_BAD_REQUEST
)
# Managers can only invite staff, not other managers
# TODO: Add owner control to allow/disallow managers inviting managers
if user.role == User.Role.TENANT_MANAGER and role == User.Role.TENANT_MANAGER:
return Response(
{"error": "Managers can only invite staff members, not other managers."},
status=status.HTTP_403_FORBIDDEN
)
# Check if user already exists in this tenant
existing_user = User.objects.filter(
email=email,
tenant=user.tenant
).first()
if existing_user:
return Response(
{"error": f"A user with email {email} already exists in your business."},
status=status.HTTP_400_BAD_REQUEST
)
# Validate permissions is a dict
if not isinstance(permissions, dict):
permissions = {}
# Create invitation with bookable resource settings
invitation = StaffInvitation.create_invitation(
email=email,
role=role,
tenant=user.tenant,
invited_by=user,
create_bookable_resource=create_bookable_resource,
resource_name=resource_name,
permissions=permissions
)
# Send invitation email
_send_invitation_email(invitation)
serializer = StaffInvitationSerializer(invitation)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
def cancel_invitation_view(request, invitation_id):
"""
Cancel a pending invitation.
DELETE /api/staff/invitations/<id>/
"""
user = request.user
if not user.can_manage_users():
return Response(
{"error": "You do not have permission to manage staff invitations."},
status=status.HTTP_403_FORBIDDEN
)
if not user.tenant:
return Response(
{"error": "No business associated with your account."},
status=status.HTTP_400_BAD_REQUEST
)
invitation = get_object_or_404(
StaffInvitation,
id=invitation_id,
tenant=user.tenant
)
if invitation.status != StaffInvitation.Status.PENDING:
return Response(
{"error": "Only pending invitations can be cancelled."},
status=status.HTTP_400_BAD_REQUEST
)
invitation.cancel()
return Response({"detail": "Invitation cancelled."}, status=status.HTTP_200_OK)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def resend_invitation_view(request, invitation_id):
"""
Resend an invitation email.
POST /api/staff/invitations/<id>/resend/
"""
user = request.user
if not user.can_manage_users():
return Response(
{"error": "You do not have permission to manage staff invitations."},
status=status.HTTP_403_FORBIDDEN
)
if not user.tenant:
return Response(
{"error": "No business associated with your account."},
status=status.HTTP_400_BAD_REQUEST
)
invitation = get_object_or_404(
StaffInvitation,
id=invitation_id,
tenant=user.tenant,
status=StaffInvitation.Status.PENDING
)
# Resend the email
_send_invitation_email(invitation)
return Response({"detail": "Invitation email resent."}, status=status.HTTP_200_OK)
@api_view(['GET'])
@permission_classes([AllowAny])
def invitation_details_view(request, token):
"""
Get invitation details by token (public endpoint for acceptance page).
GET /api/staff/invitations/token/<token>/
"""
invitation = get_object_or_404(StaffInvitation, token=token)
if not invitation.is_valid():
return Response(
{"error": "This invitation has expired or is no longer valid."},
status=status.HTTP_400_BAD_REQUEST
)
# Return limited info for the acceptance page
role_map = {
'TENANT_MANAGER': 'Manager',
'TENANT_STAFF': 'Staff',
}
return Response({
'email': invitation.email,
'role': invitation.role,
'role_display': role_map.get(invitation.role, invitation.role),
'business_name': invitation.tenant.name,
'invited_by': invitation.invited_by.full_name if invitation.invited_by else None,
'expires_at': invitation.expires_at,
'create_bookable_resource': invitation.create_bookable_resource,
'resource_name': invitation.resource_name,
})
@api_view(['POST'])
@permission_classes([AllowAny])
def accept_invitation_view(request, token):
"""
Accept an invitation and create user account.
POST /api/staff/invitations/token/<token>/accept/
Body: {
"first_name": "John",
"last_name": "Doe",
"password": "securepassword123"
}
"""
invitation = get_object_or_404(StaffInvitation, token=token)
if not invitation.is_valid():
return Response(
{"error": "This invitation has expired or is no longer valid."},
status=status.HTTP_400_BAD_REQUEST
)
first_name = request.data.get('first_name', '').strip()
last_name = request.data.get('last_name', '').strip()
password = request.data.get('password', '')
# Validate required fields
if not first_name:
return Response(
{"error": "First name is required."},
status=status.HTTP_400_BAD_REQUEST
)
if not password or len(password) < 8:
return Response(
{"error": "Password must be at least 8 characters."},
status=status.HTTP_400_BAD_REQUEST
)
# Check if email is already taken (in any tenant)
if User.objects.filter(email=invitation.email).exists():
return Response(
{"error": "An account with this email already exists. Please login instead."},
status=status.HTTP_400_BAD_REQUEST
)
# Create the user
username = invitation.email.split('@')[0]
# Ensure username is unique
base_username = username
counter = 1
while User.objects.filter(username=username).exists():
username = f"{base_username}{counter}"
counter += 1
user = User.objects.create_user(
username=username,
email=invitation.email,
password=password,
first_name=first_name,
last_name=last_name,
role=invitation.role,
tenant=invitation.tenant,
email_verified=True, # Email is verified since they received the invitation
permissions=invitation.permissions, # Copy permissions from invitation
)
# Mark invitation as accepted
invitation.accept(user)
# Create bookable resource if configured
resource_created = None
if invitation.create_bookable_resource:
# Get the resource name (use invitation setting or user's full name)
resource_name = invitation.resource_name or user.full_name
# Find or create the default STAFF resource type
staff_resource_type = ResourceType.objects.filter(
category=ResourceType.Category.STAFF,
is_default=True
).first()
# Create the resource
resource_created = Resource.objects.create(
name=resource_name,
type=Resource.Type.STAFF, # Legacy field
resource_type=staff_resource_type, # New field
user=user,
max_concurrent_events=1, # Default to exclusive booking
)
# Create auth token for immediate login
Token.objects.filter(user=user).delete()
auth_token = Token.objects.create(user=user)
response_data = {
'access': auth_token.key,
'refresh': auth_token.key,
'user': _get_user_data(user),
'detail': 'Account created successfully.',
}
if resource_created:
response_data['resource_created'] = {
'id': resource_created.id,
'name': resource_created.name,
}
return Response(response_data, status=status.HTTP_201_CREATED)
@api_view(['POST'])
@permission_classes([AllowAny])
def decline_invitation_view(request, token):
"""
Decline an invitation.
POST /api/staff/invitations/token/<token>/decline/
"""
invitation = get_object_or_404(StaffInvitation, token=token)
if invitation.status != StaffInvitation.Status.PENDING:
return Response(
{"error": "This invitation is no longer pending."},
status=status.HTTP_400_BAD_REQUEST
)
invitation.decline()
return Response({"detail": "Invitation declined."}, status=status.HTTP_200_OK)
def _send_invitation_email(invitation):
"""Send invitation email to the invitee."""
# Build invitation URL
port = ':5173' if settings.DEBUG else ''
# Get subdomain for the tenant
primary_domain = invitation.tenant.domains.filter(is_primary=True).first()
if primary_domain:
subdomain = primary_domain.domain.split('.')[0] + '.'
else:
subdomain = invitation.tenant.schema_name + '.'
invite_url = f"http://{subdomain}lvh.me{port}/accept-invite?token={invitation.token}"
role_map = {
'TENANT_MANAGER': 'Manager',
'TENANT_STAFF': 'Staff Member',
}
role_display = role_map.get(invitation.role, 'team member')
subject = f"You're invited to join {invitation.tenant.name} on Smooth Schedule"
message = f"""Hi there,
{invitation.invited_by.full_name if invitation.invited_by else 'Someone'} has invited you to join {invitation.tenant.name} as a {role_display} on Smooth Schedule.
Click the link below to accept this invitation and create your account:
{invite_url}
This invitation will expire in 7 days.
If you did not expect this invitation, you can safely ignore this email.
Thanks,
The Smooth Schedule Team
"""
send_mail(
subject,
message,
settings.DEFAULT_FROM_EMAIL if hasattr(settings, 'DEFAULT_FROM_EMAIL') else 'noreply@smoothschedule.com',
[invitation.email],
fail_silently=False,
)

View File

@@ -0,0 +1,36 @@
# Generated by Django 5.2.8 on 2025-11-28 06:15
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0004_tenant_email_logo_alter_tenant_logo'),
('users', '0003_add_email_verification'),
]
operations = [
migrations.CreateModel(
name='StaffInvitation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(help_text='Email address to send invitation to', max_length=254)),
('role', models.CharField(choices=[('TENANT_MANAGER', 'Manager'), ('TENANT_STAFF', 'Staff')], default='TENANT_STAFF', help_text='Role the invited user will have', max_length=20)),
('token', models.CharField(max_length=64, unique=True)),
('status', models.CharField(choices=[('PENDING', 'Pending'), ('ACCEPTED', 'Accepted'), ('DECLINED', 'Declined'), ('EXPIRED', 'Expired'), ('CANCELLED', 'Cancelled')], default='PENDING', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField()),
('accepted_at', models.DateTimeField(blank=True, null=True)),
('accepted_user', models.ForeignKey(blank=True, help_text='User account created when invitation was accepted', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='accepted_invitation', to=settings.AUTH_USER_MODEL)),
('invited_by', models.ForeignKey(help_text='User who sent the invitation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_invitations', to=settings.AUTH_USER_MODEL)),
('tenant', models.ForeignKey(help_text='Business the user is being invited to', on_delete=django.db.models.deletion.CASCADE, related_name='staff_invitations', to='core.tenant')),
],
options={
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['token'], name='users_staff_token_139ef3_idx'), models.Index(fields=['email', 'tenant', 'status'], name='users_staff_email_eb223a_idx'), models.Index(fields=['status', 'expires_at'], name='users_staff_status_b43c24_idx')],
},
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.8 on 2025-11-28 06:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0004_add_staff_invitation'),
]
operations = [
migrations.AddField(
model_name='staffinvitation',
name='create_bookable_resource',
field=models.BooleanField(default=False, help_text='Whether to create a bookable resource for this staff member'),
),
migrations.AddField(
model_name='staffinvitation',
name='permissions',
field=models.JSONField(blank=True, default=dict, help_text='Permission settings for the invited user'),
),
migrations.AddField(
model_name='staffinvitation',
name='resource_name',
field=models.CharField(blank=True, help_text="Name for the bookable resource (defaults to user's name if empty)", max_length=200),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2025-11-28 06:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0005_add_bookable_and_permissions_to_invitation'),
]
operations = [
migrations.AddField(
model_name='user',
name='permissions',
field=models.JSONField(blank=True, default=dict, help_text='Role-specific permissions like can_invite_staff for managers'),
),
]

View File

@@ -64,7 +64,14 @@ class User(AbstractUser):
# Additional profile fields
phone = models.CharField(max_length=20, blank=True)
job_title = models.CharField(max_length=100, blank=True)
# Role-specific permissions (stored as JSON for flexibility)
permissions = models.JSONField(
default=dict,
blank=True,
help_text="Role-specific permissions like can_invite_staff for managers"
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -126,6 +133,16 @@ class User(AbstractUser):
self.Role.PLATFORM_MANAGER,
self.Role.TENANT_OWNER,
]
def can_invite_staff(self):
"""Check if user can invite new staff members"""
# Owners can always invite
if self.role == self.Role.TENANT_OWNER:
return True
# Managers can invite if they have the permission
if self.role == self.Role.TENANT_MANAGER:
return self.permissions.get('can_invite_staff', False)
return False
def get_accessible_tenants(self):
"""
@@ -195,3 +212,172 @@ class EmailVerificationToken(models.Model):
cls.objects.filter(user=user, used=False).update(used=True)
# Create new token
return cls.objects.create(user=user)
class StaffInvitation(models.Model):
"""
Invitation for new staff members to join a business.
Flow:
1. Owner/Manager creates invitation with email and role
2. System sends email with unique token link
3. Invitee clicks link, creates account, and is added to tenant
"""
class Status(models.TextChoices):
PENDING = 'PENDING', _('Pending')
ACCEPTED = 'ACCEPTED', _('Accepted')
DECLINED = 'DECLINED', _('Declined')
EXPIRED = 'EXPIRED', _('Expired')
CANCELLED = 'CANCELLED', _('Cancelled')
# Invitation target
email = models.EmailField(help_text="Email address to send invitation to")
role = models.CharField(
max_length=20,
choices=[
(User.Role.TENANT_MANAGER, _('Manager')),
(User.Role.TENANT_STAFF, _('Staff')),
],
default=User.Role.TENANT_STAFF,
help_text="Role the invited user will have"
)
# Tenant association
tenant = models.ForeignKey(
'core.Tenant',
on_delete=models.CASCADE,
related_name='staff_invitations',
help_text="Business the user is being invited to"
)
# Invitation metadata
invited_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
related_name='sent_invitations',
help_text="User who sent the invitation"
)
# Token for secure acceptance
token = models.CharField(max_length=64, unique=True)
# Status tracking
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.PENDING
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
accepted_at = models.DateTimeField(null=True, blank=True)
# Link to created user (after acceptance)
accepted_user = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='accepted_invitation',
help_text="User account created when invitation was accepted"
)
# Bookable resource configuration
create_bookable_resource = models.BooleanField(
default=False,
help_text="Whether to create a bookable resource for this staff member"
)
resource_name = models.CharField(
max_length=200,
blank=True,
help_text="Name for the bookable resource (defaults to user's name if empty)"
)
# Permissions configuration (stored as JSON for flexibility)
permissions = models.JSONField(
default=dict,
blank=True,
help_text="Permission settings for the invited user"
)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['token']),
models.Index(fields=['email', 'tenant', 'status']),
models.Index(fields=['status', 'expires_at']),
]
def __str__(self):
return f"Invitation for {self.email} to {self.tenant.name} ({self.get_status_display()})"
def save(self, *args, **kwargs):
if not self.token:
self.token = secrets.token_urlsafe(32)
if not self.expires_at:
# Default expiration: 7 days
self.expires_at = timezone.now() + timedelta(days=7)
super().save(*args, **kwargs)
def is_valid(self):
"""Check if invitation can still be accepted"""
if self.status != self.Status.PENDING:
return False
if timezone.now() > self.expires_at:
return False
return True
def accept(self, user):
"""Mark invitation as accepted and link to user"""
self.status = self.Status.ACCEPTED
self.accepted_at = timezone.now()
self.accepted_user = user
self.save()
def decline(self):
"""Mark invitation as declined"""
self.status = self.Status.DECLINED
self.save()
def cancel(self):
"""Cancel a pending invitation"""
if self.status == self.Status.PENDING:
self.status = self.Status.CANCELLED
self.save()
@classmethod
def create_invitation(cls, email, role, tenant, invited_by,
create_bookable_resource=False, resource_name='', permissions=None):
"""
Create a new invitation, cancelling any existing pending invitations
for the same email/tenant combination.
Args:
email: Email address to invite
role: Role for the invited user (TENANT_MANAGER or TENANT_STAFF)
tenant: Tenant/business the user is being invited to
invited_by: User sending the invitation
create_bookable_resource: Whether to create a bookable resource when accepted
resource_name: Name for the bookable resource (optional)
permissions: Dict of permission settings (optional)
"""
# Cancel existing pending invitations for this email/tenant
cls.objects.filter(
email=email,
tenant=tenant,
status=cls.Status.PENDING
).update(status=cls.Status.CANCELLED)
# Create new invitation
return cls.objects.create(
email=email,
role=role,
tenant=tenant,
invited_by=invited_by,
create_bookable_resource=create_bookable_resource,
resource_name=resource_name,
permissions=permissions or {}
)