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

@@ -53,6 +53,7 @@ import PlatformSettings from './pages/platform/PlatformSettings';
import ProfileSettings from './pages/ProfileSettings';
import VerifyEmail from './pages/VerifyEmail';
import EmailVerificationRequired from './pages/EmailVerificationRequired';
import AcceptInvitePage from './pages/AcceptInvitePage';
const queryClient = new QueryClient({
defaultOptions: {
@@ -210,6 +211,7 @@ const AppContent: React.FC = () => {
<Route path="/login" element={<LoginPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
@@ -222,6 +224,7 @@ const AppContent: React.FC = () => {
<Route path="/login" element={<LoginPage />} />
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
);

View File

@@ -15,6 +15,20 @@ import {
} from '../api/auth';
import { getCookie, setCookie, deleteCookie } from '../utils/cookies';
/**
* Helper hook to set auth tokens (used by invitation acceptance)
*/
export const useAuth = () => {
const queryClient = useQueryClient();
const setTokens = (accessToken: string, refreshToken: string) => {
setCookie('access_token', accessToken, 7);
setCookie('refresh_token', refreshToken, 7);
};
return { setTokens };
};
/**
* Hook to get current user
*/

View File

@@ -0,0 +1,165 @@
/**
* Staff Invitations Management Hooks
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
export interface StaffInvitation {
id: number;
email: string;
role: 'TENANT_MANAGER' | 'TENANT_STAFF';
role_display: string;
status: 'PENDING' | 'ACCEPTED' | 'DECLINED' | 'EXPIRED' | 'CANCELLED';
invited_by: number | null;
invited_by_name: string | null;
created_at: string;
expires_at: string;
accepted_at: string | null;
create_bookable_resource: boolean;
resource_name: string;
permissions: Record<string, boolean>;
}
export interface InvitationDetails {
email: string;
role: string;
role_display: string;
business_name: string;
invited_by: string | null;
expires_at: string;
create_bookable_resource: boolean;
resource_name: string;
}
export interface StaffPermissions {
// Manager permissions
can_invite_staff?: boolean;
can_manage_resources?: boolean;
can_manage_services?: boolean;
can_view_reports?: boolean;
can_access_settings?: boolean;
can_refund_payments?: boolean;
// Staff permissions
can_view_all_schedules?: boolean;
can_manage_own_appointments?: boolean;
}
export interface CreateInvitationData {
email: string;
role: 'TENANT_MANAGER' | 'TENANT_STAFF';
create_bookable_resource?: boolean;
resource_name?: string;
permissions?: StaffPermissions;
}
/**
* Hook to fetch pending invitations for the current business
*/
export const useInvitations = () => {
return useQuery<StaffInvitation[]>({
queryKey: ['invitations'],
queryFn: async () => {
const { data } = await apiClient.get('/api/staff/invitations/');
return data;
},
});
};
/**
* Hook to create a new staff invitation
*/
export const useCreateInvitation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (invitationData: CreateInvitationData) => {
const { data } = await apiClient.post('/api/staff/invitations/', invitationData);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invitations'] });
},
});
};
/**
* Hook to cancel a pending invitation
*/
export const useCancelInvitation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (invitationId: number) => {
await apiClient.delete(`/api/staff/invitations/${invitationId}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invitations'] });
},
});
};
/**
* Hook to resend an invitation email
*/
export const useResendInvitation = () => {
return useMutation({
mutationFn: async (invitationId: number) => {
const { data } = await apiClient.post(`/api/staff/invitations/${invitationId}/resend/`);
return data;
},
});
};
/**
* Hook to get invitation details by token (for acceptance page)
*/
export const useInvitationDetails = (token: string | null) => {
return useQuery<InvitationDetails>({
queryKey: ['invitation', token],
queryFn: async () => {
const { data } = await apiClient.get(`/api/staff/invitations/token/${token}/`);
return data;
},
enabled: !!token,
retry: false,
});
};
/**
* Hook to accept an invitation
*/
export const useAcceptInvitation = () => {
return useMutation({
mutationFn: async ({
token,
firstName,
lastName,
password,
}: {
token: string;
firstName: string;
lastName: string;
password: string;
}) => {
const { data } = await apiClient.post(`/api/staff/invitations/token/${token}/accept/`, {
first_name: firstName,
last_name: lastName,
password,
});
return data;
},
});
};
/**
* Hook to decline an invitation
*/
export const useDeclineInvitation = () => {
return useMutation({
mutationFn: async (token: string) => {
const { data } = await apiClient.post(`/api/staff/invitations/token/${token}/decline/`);
return data;
},
});
};

View File

@@ -2,14 +2,22 @@
* Staff Management Hooks
*/
import { useQuery } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
export interface StaffPermissions {
can_invite_staff?: boolean;
}
export interface StaffMember {
id: string;
name: string;
email: string;
phone?: string;
role: string;
is_active: boolean;
permissions: StaffPermissions;
can_invite_staff: boolean;
}
interface StaffFilters {
@@ -26,6 +34,7 @@ export const useStaff = (filters?: StaffFilters) => {
queryFn: async () => {
const params = new URLSearchParams();
if (filters?.search) params.append('search', filters.search);
params.append('show_inactive', 'true'); // Always fetch inactive staff too
const { data } = await apiClient.get(`/api/staff/?${params}`);
@@ -35,8 +44,54 @@ export const useStaff = (filters?: StaffFilters) => {
name: s.name || `${s.first_name || ''} ${s.last_name || ''}`.trim() || s.email,
email: s.email || '',
phone: s.phone || '',
role: s.role || 'staff',
is_active: s.is_active ?? true,
permissions: s.permissions || {},
can_invite_staff: s.can_invite_staff ?? false,
}));
},
retry: false,
});
};
/**
* Hook to update a staff member's settings
*/
export const useUpdateStaff = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
id,
updates,
}: {
id: string;
updates: { is_active?: boolean; permissions?: StaffPermissions };
}) => {
const { data } = await apiClient.patch(`/api/staff/${id}/`, updates);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['staff'] });
queryClient.invalidateQueries({ queryKey: ['businessUsers'] });
},
});
};
/**
* Hook to toggle a staff member's active status
*/
export const useToggleStaffActive = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const { data } = await apiClient.post(`/api/staff/${id}/toggle_active/`);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['staff'] });
queryClient.invalidateQueries({ queryKey: ['businessUsers'] });
},
});
};

View File

@@ -0,0 +1,352 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
useInvitationDetails,
useAcceptInvitation,
useDeclineInvitation,
} from '../hooks/useInvitations';
import { useAuth } from '../hooks/useAuth';
import {
Loader2,
CheckCircle,
XCircle,
Building2,
Mail,
User,
Lock,
AlertCircle,
Eye,
EyeOff,
} from 'lucide-react';
const AcceptInvitePage: React.FC = () => {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get('token');
const { data: invitation, isLoading, error } = useInvitationDetails(token);
const acceptInvitationMutation = useAcceptInvitation();
const declineInvitationMutation = useDeclineInvitation();
const { setTokens } = useAuth();
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [formError, setFormError] = useState('');
const [accepted, setAccepted] = useState(false);
const [declined, setDeclined] = useState(false);
const handleAccept = async (e: React.FormEvent) => {
e.preventDefault();
setFormError('');
// Validate
if (!firstName.trim()) {
setFormError(t('acceptInvite.firstNameRequired', 'First name is required'));
return;
}
if (!password || password.length < 8) {
setFormError(t('acceptInvite.passwordMinLength', 'Password must be at least 8 characters'));
return;
}
if (password !== confirmPassword) {
setFormError(t('acceptInvite.passwordsMustMatch', 'Passwords do not match'));
return;
}
try {
const result = await acceptInvitationMutation.mutateAsync({
token: token!,
firstName: firstName.trim(),
lastName: lastName.trim(),
password,
});
// Set auth tokens and redirect to dashboard
setTokens(result.access, result.refresh);
setAccepted(true);
// Redirect after a short delay
setTimeout(() => {
navigate('/');
}, 2000);
} catch (err: any) {
setFormError(err.response?.data?.error || t('acceptInvite.acceptFailed', 'Failed to accept invitation'));
}
};
const handleDecline = async () => {
if (!confirm(t('acceptInvite.confirmDecline', 'Are you sure you want to decline this invitation?'))) {
return;
}
try {
await declineInvitationMutation.mutateAsync(token!);
setDeclined(true);
} catch (err: any) {
setFormError(err.response?.data?.error || t('acceptInvite.declineFailed', 'Failed to decline invitation'));
}
};
// No token provided
if (!token) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center">
<XCircle size={48} className="mx-auto text-red-500 mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
{t('acceptInvite.invalidLink', 'Invalid Invitation Link')}
</h1>
<p className="text-gray-500 dark:text-gray-400">
{t('acceptInvite.noToken', 'This invitation link is invalid. Please check your email for the correct link.')}
</p>
</div>
</div>
);
}
// Loading state
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div className="text-center">
<Loader2 size={48} className="mx-auto text-brand-600 animate-spin mb-4" />
<p className="text-gray-500 dark:text-gray-400">
{t('acceptInvite.loading', 'Loading invitation...')}
</p>
</div>
</div>
);
}
// Error state (invalid/expired token)
if (error || !invitation) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center">
<AlertCircle size={48} className="mx-auto text-amber-500 mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
{t('acceptInvite.expiredTitle', 'Invitation Expired or Invalid')}
</h1>
<p className="text-gray-500 dark:text-gray-400">
{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.'
)}
</p>
</div>
</div>
);
}
// Accepted state
if (accepted) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center">
<CheckCircle size={48} className="mx-auto text-green-500 mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
{t('acceptInvite.welcomeTitle', 'Welcome to the Team!')}
</h1>
<p className="text-gray-500 dark:text-gray-400">
{t('acceptInvite.redirecting', 'Your account has been created. Redirecting to dashboard...')}
</p>
<Loader2 size={24} className="mx-auto text-brand-600 animate-spin mt-4" />
</div>
</div>
);
}
// Declined state
if (declined) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center">
<XCircle size={48} className="mx-auto text-gray-400 mb-4" />
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
{t('acceptInvite.declinedTitle', 'Invitation Declined')}
</h1>
<p className="text-gray-500 dark:text-gray-400">
{t('acceptInvite.declinedDescription', "You've declined this invitation. You can close this page.")}
</p>
</div>
</div>
);
}
// Main acceptance form
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg overflow-hidden">
{/* Header */}
<div className="bg-brand-600 p-6 text-center">
<h1 className="text-2xl font-bold text-white mb-2">
{t('acceptInvite.title', "You're Invited!")}
</h1>
<p className="text-brand-100">
{t('acceptInvite.subtitle', 'Join the team and start scheduling')}
</p>
</div>
{/* Invitation Details */}
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-brand-100 dark:bg-brand-900/50 flex items-center justify-center text-brand-600 dark:text-brand-400">
<Building2 size={20} />
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">
{t('acceptInvite.business', 'Business')}
</p>
<p className="font-semibold text-gray-900 dark:text-white">{invitation.business_name}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-gray-600 dark:text-gray-400">
<Mail size={20} />
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">
{t('acceptInvite.invitedAs', 'Invited As')}
</p>
<p className="font-semibold text-gray-900 dark:text-white">
{invitation.role_display} &bull;{' '}
<span className="font-normal text-gray-600 dark:text-gray-400">{invitation.email}</span>
</p>
</div>
</div>
{invitation.invited_by && (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center pt-2">
{t('acceptInvite.invitedBy', 'Invited by')} <span className="font-medium">{invitation.invited_by}</span>
</p>
)}
</div>
</div>
{/* Form */}
<form onSubmit={handleAccept} className="p-6 space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
{t('acceptInvite.formDescription', 'Create your account to accept this invitation.')}
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('acceptInvite.firstName', 'First Name')} *
</label>
<div className="relative">
<User size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
required
className="w-full pl-10 pr-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder="John"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('acceptInvite.lastName', 'Last Name')}
</label>
<input
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder="Doe"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('acceptInvite.password', 'Password')} *
</label>
<div className="relative">
<Lock size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full pl-10 pr-10 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder="Min. 8 characters"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('acceptInvite.confirmPassword', 'Confirm Password')} *
</label>
<div className="relative">
<Lock size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="w-full pl-10 pr-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder="Repeat password"
/>
</div>
</div>
{/* Error Message */}
{formError && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-600 dark:text-red-400">{formError}</p>
</div>
)}
{/* Buttons */}
<div className="flex flex-col gap-3 pt-4">
<button
type="submit"
disabled={acceptInvitationMutation.isPending}
className="w-full px-4 py-3 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{acceptInvitationMutation.isPending ? (
<Loader2 size={18} className="animate-spin" />
) : (
<CheckCircle size={18} />
)}
{t('acceptInvite.acceptButton', 'Accept Invitation & Create Account')}
</button>
<button
type="button"
onClick={handleDecline}
disabled={declineInvitationMutation.isPending}
className="w-full px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors"
>
{t('acceptInvite.declineButton', 'Decline Invitation')}
</button>
</div>
</form>
</div>
</div>
);
};
export default AcceptInvitePage;

View File

@@ -16,7 +16,6 @@ import { useServices } from '../hooks/useServices';
import { useResources } from '../hooks/useResources';
import { useAppointments } from '../hooks/useAppointments';
import { useCustomers } from '../hooks/useCustomers';
import QuickAddAppointment from '../components/QuickAddAppointment';
interface Metric {
label: string;
@@ -147,14 +146,9 @@ const Dashboard: React.FC = () => {
))}
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Quick Add Appointment */}
<div className="lg:col-span-1">
<QuickAddAppointment />
</div>
<div className="grid grid-cols-1 gap-6">
{/* Revenue Chart */}
<div className="lg:col-span-2 p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm transition-colors duration-200">
<div className="p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm transition-colors duration-200">
<h3 className="mb-6 text-lg font-semibold text-gray-900 dark:text-white">{t('dashboard.totalRevenue')}</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">

File diff suppressed because it is too large Load Diff

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 {}
)