+ {t('acceptInvite.expiredTitle', 'Invitation Expired or Invalid')}
+
+
+ {t(
+ 'acceptInvite.expiredDescription',
+ 'This invitation has expired or is no longer valid. Please contact the person who sent the invitation to request a new one.'
+ )}
+
+
+
+ );
+ }
+
+ // Accepted state
+ if (accepted) {
+ return (
+
+
+
+
+ {t('acceptInvite.welcomeTitle', 'Welcome to the Team!')}
+
+
+ {t('acceptInvite.redirecting', 'Your account has been created. Redirecting to dashboard...')}
+
+
+
+
+ );
+ }
+
+ // Declined state
+ if (declined) {
+ return (
+
+ {editingStaff.is_active
+ ? t('staff.deactivateHint', 'Prevent this user from logging in while keeping their data')
+ : t('staff.reactivateHint', 'Allow this user to log in again')}
+
+
+
+
+
+
+ )}
+
+ {/* Action Buttons */}
+
+
+ {editingStaff.role !== 'owner' && (
+
+ )}
+
+
+
+
+
+ )}
+
+ );
};
export default Staff;
diff --git a/smoothschedule/config/urls.py b/smoothschedule/config/urls.py
index 62d2d03..cbb760c 100644
--- a/smoothschedule/config/urls.py
+++ b/smoothschedule/config/urls.py
@@ -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//", cancel_invitation_view, name="cancel_invitation"),
+ path("api/staff/invitations//resend/", resend_invitation_view, name="resend_invitation"),
+ path("api/staff/invitations/token//", invitation_details_view, name="invitation_details"),
+ path("api/staff/invitations/token//accept/", accept_invitation_view, name="accept_invitation"),
+ path("api/staff/invitations/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"),
diff --git a/smoothschedule/schedule/serializers.py b/smoothschedule/schedule/serializers.py
index 3836c5e..cd380d5 100644
--- a/smoothschedule/schedule/serializers.py
+++ b/smoothschedule/schedule/serializers.py
@@ -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"""
diff --git a/smoothschedule/schedule/views.py b/smoothschedule/schedule/views.py
index 540f14d..32ed461 100644
--- a/smoothschedule/schedule/views.py
+++ b/smoothschedule/schedule/views.py
@@ -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."
+ })
diff --git a/smoothschedule/smoothschedule/users/api_views.py b/smoothschedule/smoothschedule/users/api_views.py
index 583e8f9..24e75a1 100644
--- a/smoothschedule/smoothschedule/users/api_views.py
+++ b/smoothschedule/smoothschedule/users/api_views.py
@@ -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//
+ """
+ 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//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//
+ """
+ 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//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//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,
+ )
diff --git a/smoothschedule/smoothschedule/users/migrations/0004_add_staff_invitation.py b/smoothschedule/smoothschedule/users/migrations/0004_add_staff_invitation.py
new file mode 100644
index 0000000..d1975e1
--- /dev/null
+++ b/smoothschedule/smoothschedule/users/migrations/0004_add_staff_invitation.py
@@ -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')],
+ },
+ ),
+ ]
diff --git a/smoothschedule/smoothschedule/users/migrations/0005_add_bookable_and_permissions_to_invitation.py b/smoothschedule/smoothschedule/users/migrations/0005_add_bookable_and_permissions_to_invitation.py
new file mode 100644
index 0000000..0956505
--- /dev/null
+++ b/smoothschedule/smoothschedule/users/migrations/0005_add_bookable_and_permissions_to_invitation.py
@@ -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),
+ ),
+ ]
diff --git a/smoothschedule/smoothschedule/users/migrations/0006_add_permissions_to_user.py b/smoothschedule/smoothschedule/users/migrations/0006_add_permissions_to_user.py
new file mode 100644
index 0000000..679f78b
--- /dev/null
+++ b/smoothschedule/smoothschedule/users/migrations/0006_add_permissions_to_user.py
@@ -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'),
+ ),
+ ]
diff --git a/smoothschedule/smoothschedule/users/models.py b/smoothschedule/smoothschedule/users/models.py
index e0cff5e..8b4700c 100644
--- a/smoothschedule/smoothschedule/users/models.py
+++ b/smoothschedule/smoothschedule/users/models.py
@@ -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 {}
+ )