Add demo tenant reseed, staff roles, and fix masquerade redirect
Demo Tenant: - Add block_emails field to Tenant model for demo accounts - Add is_email_blocked() and wrapper functions in email_service - Create reseed_demo management command with salon/spa theme - Add Celery beat task for daily reseed at midnight UTC - Create 100 appointments, 20 customers, 13 services, 12 resources Staff Roles: - Add StaffRole model with permission toggles - Create default roles: Full Access, Front Desk, Limited Staff - Add StaffRolesSettings page and hooks - Integrate role assignment in Staff management Bug Fixes: - Fix masquerade redirect using wrong role names (tenant_owner vs owner) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
56
ESTIMATE.md
Normal file
56
ESTIMATE.md
Normal file
@@ -0,0 +1,56 @@
|
||||
### **Project Estimate: SmoothSchedule Platform Development**
|
||||
|
||||
**Date:** December 16, 2025
|
||||
**Prepared For:** Internal & External Stakeholders
|
||||
**Prepared By:** Gemini AI Software Engineering Agent
|
||||
|
||||
---
|
||||
|
||||
#### **1. Executive Summary**
|
||||
|
||||
This document provides a high-level cost and timeline estimate for the from-scratch development of the SmoothSchedule platform. Our analysis of the existing codebase reveals that SmoothSchedule is not merely a scheduling application, but a sophisticated, multi-tenant Software-as-a-Service (SaaS) platform with enterprise-grade features for e-commerce, extensive customization, and developer-level extensibility.
|
||||
|
||||
The architecture includes several high-complexity components, most notably a **sandboxed custom scripting engine** for user-defined automations and a **full data-isolation sandbox mode** for testing. These features place the project in a category of complexity comparable to building a platform-as-a-service (PaaS).
|
||||
|
||||
* **Total Estimated Project Cost:** **$3,500,000 - $5,500,000 USD**
|
||||
* **Estimated Timeline:** **18 - 24 months**
|
||||
|
||||
---
|
||||
|
||||
#### **2. Project Scope & Complexity Analysis**
|
||||
|
||||
This estimate is based on the implementation of the following key features and architectural pillars identified in the codebase:
|
||||
|
||||
* **Multi-Tenant SaaS Architecture:** The system is designed to serve multiple businesses (tenants) from a single infrastructure, with complete data separation.
|
||||
* **Custom Python Scripting Engine:** The platform's most complex feature is its ability to safely execute custom, user-written Python code in a sandboxed environment. This allows for limitless business automation, similar to Zapier or Salesforce Apex, and requires significant investment in security and resource management.
|
||||
* **Full Data-Isolation Sandbox Mode:** A complete "Test Mode" for each tenant, which uses a separate, isolated database schema. This allows users to safely test workflows and automations without affecting their live business data—a feature typical of mature, enterprise-grade platforms.
|
||||
* **Extensible Automation & Plugin Framework:** A comprehensive system for creating, installing, and managing "plugins" (automations). These can be triggered by schedules (e.g., cron jobs) or application events (e.g., when an appointment is completed), and are managed via a built-in marketplace.
|
||||
* **Visual Page Builder:** A drag-and-drop interface (identified as using `@measured/puck`) that allows tenants to build their own public-facing websites and booking pages.
|
||||
* **Advanced E-commerce Platform:** Integration with Stripe Connect, enabling tenants to bill their own customers, not just for the platform to bill its tenants. This requires complex logic for managed accounts, payment flows, and fee processing.
|
||||
* **Real-time Capabilities & Asynchronous Tasks:** The platform uses WebSockets (`django-channels`) for live updates and a Celery-based queue for handling background jobs like sending emails, generating reports, and running automations.
|
||||
* **Mobile Application:** A companion field application for iOS and Android that integrates with the full feature set of the backend.
|
||||
|
||||
---
|
||||
|
||||
#### **3. Cost Estimate Breakdown**
|
||||
|
||||
This estimate assumes a US-based software development agency model, including project management, design, development, and quality assurance.
|
||||
|
||||
| Category | Description | Estimated Cost Range |
|
||||
| :---------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------ |
|
||||
| **Backend Development** | (Python/Django) Includes multi-tenancy, database architecture, REST APIs, and the highly complex scripting engine & sandbox mode. | $1,500,000 - $2,200,000 |
|
||||
| **Frontend Development** | (React/TypeScript) Includes the primary web application, visual page builder, custom dashboards, and UIs for the automation/scripting engine. | $700,000 - $1,100,000 |
|
||||
| **Mobile App Development** | (iOS/Android) Native or cross-platform development of the companion field application. | $350,000 - $550,000 |
|
||||
| **UI/UX Design** | Wireframing, prototyping, and high-fidelity design for the web and mobile applications, ensuring a polished and intuitive user experience. | $250,000 - $400,000 |
|
||||
| **Project Management & QA** | Management oversight, sprint planning, manual and automated testing, and release coordination across all development tracks. | $550,000 - $900,000 |
|
||||
| **Third-Party Security Audit** | Essential for a platform that executes custom code. Includes penetration testing and code review by an external security firm. | $100,000 - $350,000 |
|
||||
| **Total Estimated Cost** | | **$3,500,000 - $5,500,000** |
|
||||
|
||||
---
|
||||
|
||||
#### **4. Assumptions & Disclaimer**
|
||||
|
||||
* This estimate is based on a feature set inferred from a comprehensive analysis of the provided codebase.
|
||||
* The costs are calculated using a blended agency rate typical for senior-level engineering talent in the United States. Rates and timelines may vary based on team location and composition.
|
||||
* This document is a high-level estimate intended for budgetary and planning purposes only. It is **not** a fixed-price quote. A formal proposal would require a detailed discovery and specification phase.
|
||||
* This estimate covers initial development and deployment. It does **not** include ongoing operational costs such as hosting, third-party service fees (e.g., Stripe, Twilio), marketing, or post-launch maintenance.
|
||||
@@ -133,6 +133,7 @@ const CommunicationSettings = React.lazy(() => import('./pages/settings/Communic
|
||||
const BillingSettings = React.lazy(() => import('./pages/settings/BillingSettings'));
|
||||
const QuotaSettings = React.lazy(() => import('./pages/settings/QuotaSettings'));
|
||||
const BusinessHoursSettings = React.lazy(() => import('./pages/settings/BusinessHoursSettings'));
|
||||
const StaffRolesSettings = React.lazy(() => import('./pages/settings/StaffRolesSettings'));
|
||||
|
||||
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
|
||||
|
||||
@@ -953,6 +954,7 @@ const AppContent: React.FC = () => {
|
||||
<Route path="email-templates" element={<SystemEmailTemplates />} />
|
||||
<Route path="custom-domains" element={<CustomDomainsSettings />} />
|
||||
<Route path="api" element={<ApiSettings />} />
|
||||
<Route path="staff-roles" element={<StaffRolesSettings />} />
|
||||
<Route path="authentication" element={<AuthenticationSettings />} />
|
||||
<Route path="email" element={<EmailSettings />} />
|
||||
<Route path="sms-calling" element={<CommunicationSettings />} />
|
||||
|
||||
@@ -45,12 +45,26 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
const logoutMutation = useLogout();
|
||||
const { canUse } = usePlanFeatures();
|
||||
|
||||
// Helper to check if user has a specific staff permission
|
||||
// Owners and managers always have all permissions
|
||||
// Staff members check their effective_permissions (role + user overrides)
|
||||
const hasPermission = (permissionKey: string): boolean => {
|
||||
if (role === 'owner' || role === 'manager') {
|
||||
return true;
|
||||
}
|
||||
if (role === 'staff') {
|
||||
// Check effective_permissions which combines user overrides and staff role
|
||||
return user.effective_permissions?.[permissionKey] === true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const canViewAdminPages = role === 'owner' || role === 'manager';
|
||||
const canViewManagementPages = role === 'owner' || role === 'manager';
|
||||
const isStaff = role === 'staff';
|
||||
const canViewSettings = role === 'owner';
|
||||
const canViewTickets = role === 'owner' || role === 'manager' || (role === 'staff' && user.can_access_tickets);
|
||||
const canSendMessages = user.can_send_messages === true;
|
||||
const canViewTickets = hasPermission('can_access_tickets');
|
||||
const canSendMessages = hasPermission('can_access_messages') || user.can_send_messages === true;
|
||||
|
||||
const handleSignOut = () => {
|
||||
logoutMutation.mutate();
|
||||
@@ -116,7 +130,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
isCollapsed={isCollapsed}
|
||||
exact
|
||||
/>
|
||||
{!isStaff && (
|
||||
{hasPermission('can_access_scheduler') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/scheduler"
|
||||
icon={CalendarDays}
|
||||
@@ -124,7 +138,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{!isStaff && (
|
||||
{hasPermission('can_access_tasks') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/tasks"
|
||||
icon={Clock}
|
||||
@@ -134,7 +148,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
)}
|
||||
{isStaff && (
|
||||
{(isStaff && hasPermission('can_access_my_schedule')) && (
|
||||
<SidebarItem
|
||||
to="/dashboard/my-schedule"
|
||||
icon={CalendarDays}
|
||||
@@ -142,7 +156,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{(role === 'staff' || role === 'resource') && (
|
||||
{(role === 'staff' || role === 'resource') && hasPermission('can_access_my_availability') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/my-availability"
|
||||
icon={CalendarOff}
|
||||
@@ -152,72 +166,94 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
)}
|
||||
</SidebarSection>
|
||||
|
||||
{/* Manage Section - Staff+ */}
|
||||
{canViewManagementPages && (
|
||||
{/* Manage Section - Show if user has any manage-related permission */}
|
||||
{(canViewManagementPages ||
|
||||
hasPermission('can_access_site_builder') ||
|
||||
hasPermission('can_access_gallery') ||
|
||||
hasPermission('can_access_customers') ||
|
||||
hasPermission('can_access_services') ||
|
||||
hasPermission('can_access_resources') ||
|
||||
hasPermission('can_access_staff') ||
|
||||
hasPermission('can_access_contracts') ||
|
||||
hasPermission('can_access_time_blocks') ||
|
||||
hasPermission('can_access_locations')
|
||||
) && (
|
||||
<SidebarSection title={t('nav.sections.manage', 'Manage')} isCollapsed={isCollapsed}>
|
||||
<SidebarItem
|
||||
to="/dashboard/site-editor"
|
||||
icon={LayoutTemplate}
|
||||
label={t('nav.siteBuilder', 'Site Builder')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/dashboard/gallery"
|
||||
icon={Image}
|
||||
label={t('nav.gallery', 'Media Gallery')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/dashboard/customers"
|
||||
icon={Users}
|
||||
label={t('nav.customers')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/dashboard/services"
|
||||
icon={Briefcase}
|
||||
label={t('nav.services', 'Services')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/dashboard/resources"
|
||||
icon={ClipboardList}
|
||||
label={t('nav.resources')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
{canViewAdminPages && (
|
||||
<>
|
||||
<SidebarItem
|
||||
to="/dashboard/staff"
|
||||
icon={Users}
|
||||
label={t('nav.staff')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
{canUse('contracts') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/contracts"
|
||||
icon={FileSignature}
|
||||
label={t('nav.contracts', 'Contracts')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
)}
|
||||
<SidebarItem
|
||||
to="/dashboard/time-blocks"
|
||||
icon={CalendarOff}
|
||||
label={t('nav.timeBlocks', 'Time Blocks')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SidebarItem
|
||||
to="/dashboard/locations"
|
||||
icon={MapPin}
|
||||
label={t('nav.locations', 'Locations')}
|
||||
isCollapsed={isCollapsed}
|
||||
locked={!canUse('multi_location')}
|
||||
/>
|
||||
</>
|
||||
{hasPermission('can_access_site_builder') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/site-editor"
|
||||
icon={LayoutTemplate}
|
||||
label={t('nav.siteBuilder', 'Site Builder')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{hasPermission('can_access_gallery') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/gallery"
|
||||
icon={Image}
|
||||
label={t('nav.gallery', 'Media Gallery')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{hasPermission('can_access_customers') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/customers"
|
||||
icon={Users}
|
||||
label={t('nav.customers')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
)}
|
||||
{hasPermission('can_access_services') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/services"
|
||||
icon={Briefcase}
|
||||
label={t('nav.services', 'Services')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{hasPermission('can_access_resources') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/resources"
|
||||
icon={ClipboardList}
|
||||
label={t('nav.resources')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{hasPermission('can_access_staff') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/staff"
|
||||
icon={Users}
|
||||
label={t('nav.staff')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
)}
|
||||
{hasPermission('can_access_contracts') && canUse('contracts') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/contracts"
|
||||
icon={FileSignature}
|
||||
label={t('nav.contracts', 'Contracts')}
|
||||
isCollapsed={isCollapsed}
|
||||
badgeElement={<UnfinishedBadge />}
|
||||
/>
|
||||
)}
|
||||
{hasPermission('can_access_time_blocks') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/time-blocks"
|
||||
icon={CalendarOff}
|
||||
label={t('nav.timeBlocks', 'Time Blocks')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{hasPermission('can_access_locations') && (
|
||||
<SidebarItem
|
||||
to="/dashboard/locations"
|
||||
icon={MapPin}
|
||||
label={t('nav.locations', 'Locations')}
|
||||
isCollapsed={isCollapsed}
|
||||
locked={!canUse('multi_location')}
|
||||
/>
|
||||
)}
|
||||
</SidebarSection>
|
||||
)}
|
||||
@@ -245,7 +281,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
)}
|
||||
|
||||
{/* Money Section - Payments */}
|
||||
{canViewAdminPages && (
|
||||
{hasPermission('can_access_payments') && (
|
||||
<SidebarSection title={t('nav.sections.money', 'Money')} isCollapsed={isCollapsed}>
|
||||
<SidebarItem
|
||||
to="/dashboard/payments"
|
||||
@@ -258,7 +294,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
)}
|
||||
|
||||
{/* Extend Section - Automations */}
|
||||
{canViewAdminPages && (
|
||||
{hasPermission('can_access_automations') && (
|
||||
<SidebarSection title={t('nav.sections.extend', 'Extend')} isCollapsed={isCollapsed}>
|
||||
<SidebarItem
|
||||
to="/dashboard/automations/my-automations"
|
||||
|
||||
@@ -122,9 +122,11 @@ export const useIsAuthenticated = (): boolean => {
|
||||
/**
|
||||
* Get the redirect path based on user role
|
||||
* Tenant users go to /dashboard/, platform users go to /
|
||||
* Note: Backend maps tenant_owner -> owner, tenant_manager -> manager, etc.
|
||||
*/
|
||||
const getRedirectPathForRole = (role: string): string => {
|
||||
const tenantRoles = ['tenant_owner', 'tenant_manager', 'tenant_staff'];
|
||||
// Tenant roles (as returned by backend after role mapping)
|
||||
const tenantRoles = ['owner', 'manager', 'staff', 'customer'];
|
||||
if (tenantRoles.includes(role)) {
|
||||
return '/dashboard/';
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface StaffInvitation {
|
||||
create_bookable_resource: boolean;
|
||||
resource_name: string;
|
||||
permissions: Record<string, boolean>;
|
||||
staff_role_id: number | null;
|
||||
staff_role_name: string | null;
|
||||
}
|
||||
|
||||
export interface InvitationDetails {
|
||||
@@ -52,6 +54,7 @@ export interface CreateInvitationData {
|
||||
create_bookable_resource?: boolean;
|
||||
resource_name?: string;
|
||||
permissions?: StaffPermissions;
|
||||
staff_role_id?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ import apiClient from '../api/client';
|
||||
|
||||
export interface StaffPermissions {
|
||||
can_invite_staff?: boolean;
|
||||
[key: string]: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface StaffMember {
|
||||
@@ -18,6 +19,9 @@ export interface StaffMember {
|
||||
is_active: boolean;
|
||||
permissions: StaffPermissions;
|
||||
can_invite_staff: boolean;
|
||||
staff_role_id: number | null;
|
||||
staff_role_name: string | null;
|
||||
effective_permissions: Record<string, boolean>;
|
||||
}
|
||||
|
||||
interface StaffFilters {
|
||||
@@ -48,6 +52,9 @@ export const useStaff = (filters?: StaffFilters) => {
|
||||
is_active: s.is_active ?? true,
|
||||
permissions: s.permissions || {},
|
||||
can_invite_staff: s.can_invite_staff ?? false,
|
||||
staff_role_id: s.staff_role_id ?? null,
|
||||
staff_role_name: s.staff_role_name ?? null,
|
||||
effective_permissions: s.effective_permissions || {},
|
||||
}));
|
||||
},
|
||||
retry: false,
|
||||
@@ -66,7 +73,7 @@ export const useUpdateStaff = () => {
|
||||
updates,
|
||||
}: {
|
||||
id: string;
|
||||
updates: { is_active?: boolean; permissions?: StaffPermissions };
|
||||
updates: { is_active?: boolean; permissions?: StaffPermissions; staff_role_id?: number | null };
|
||||
}) => {
|
||||
const { data } = await apiClient.patch(`/staff/${id}/`, updates);
|
||||
return data;
|
||||
|
||||
95
frontend/src/hooks/useStaffRoles.ts
Normal file
95
frontend/src/hooks/useStaffRoles.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import apiClient from '../api/client';
|
||||
import { StaffRole, AvailablePermissions } from '../types';
|
||||
|
||||
/**
|
||||
* Hook to fetch all staff roles for the current tenant
|
||||
*/
|
||||
export const useStaffRoles = () => {
|
||||
return useQuery<StaffRole[]>({
|
||||
queryKey: ['staffRoles'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/staff-roles/');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to fetch a single staff role by ID
|
||||
*/
|
||||
export const useStaffRole = (id: number | null) => {
|
||||
return useQuery<StaffRole>({
|
||||
queryKey: ['staffRoles', id],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get(`/staff-roles/${id}/`);
|
||||
return data;
|
||||
},
|
||||
enabled: id !== null,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to fetch available permission keys and their metadata
|
||||
*/
|
||||
export const useAvailablePermissions = () => {
|
||||
return useQuery<AvailablePermissions>({
|
||||
queryKey: ['staffRoles', 'availablePermissions'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/staff-roles/available_permissions/');
|
||||
return data;
|
||||
},
|
||||
staleTime: 1000 * 60 * 60, // Cache for 1 hour - permissions don't change often
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to create a new staff role
|
||||
*/
|
||||
export const useCreateStaffRole = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: Partial<StaffRole>) => {
|
||||
const response = await apiClient.post('/staff-roles/', data);
|
||||
return response.data as StaffRole;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['staffRoles'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to update an existing staff role
|
||||
*/
|
||||
export const useUpdateStaffRole = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, ...data }: { id: number } & Partial<StaffRole>) => {
|
||||
const response = await apiClient.patch(`/staff-roles/${id}/`, data);
|
||||
return response.data as StaffRole;
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['staffRoles'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['staffRoles', variables.id] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to delete a staff role
|
||||
*/
|
||||
export const useDeleteStaffRole = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiClient.delete(`/staff-roles/${id}/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['staffRoles'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -496,6 +496,10 @@
|
||||
"reactivateAccount": "Reactivate Account",
|
||||
"deactivateHint": "Prevent this user from logging in while keeping their data",
|
||||
"reactivateHint": "Allow this user to log in again",
|
||||
"staffRole": "Staff Role",
|
||||
"noRoleAssigned": "No role assigned",
|
||||
"selectRole": "Select a role...",
|
||||
"staffRoleSelectHint": "Assign a role to control which features this staff member can access",
|
||||
"canSendMessages": "Can send broadcast messages",
|
||||
"canSendMessagesHint": "Send messages to groups of staff and customers",
|
||||
"deactivate": "Deactivate",
|
||||
@@ -1316,6 +1320,97 @@
|
||||
"copiedToClipboard": "Copied to clipboard",
|
||||
"failedToSaveReturnUrl": "Failed to save return URL",
|
||||
"onlyOwnerCanAccess": "Only the business owner can access these settings."
|
||||
},
|
||||
"backToApp": "Back to App",
|
||||
"sections": {
|
||||
"business": "Business",
|
||||
"branding": "Branding",
|
||||
"integrations": "Integrations",
|
||||
"access": "Access",
|
||||
"communication": "Communication",
|
||||
"billing": "Billing"
|
||||
},
|
||||
"general": {
|
||||
"title": "General",
|
||||
"description": "Name, timezone, contact"
|
||||
},
|
||||
"resourceTypes": {
|
||||
"title": "Resource Types",
|
||||
"description": "Staff, rooms, equipment"
|
||||
},
|
||||
"businessHours": {
|
||||
"title": "Business Hours",
|
||||
"description": "Operating hours"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"description": "Logo, colors, theme"
|
||||
},
|
||||
"emailTemplates": {
|
||||
"title": "Email Templates",
|
||||
"description": "Customize automated emails"
|
||||
},
|
||||
"customDomains": {
|
||||
"title": "Custom Domains",
|
||||
"description": "Use your own domain"
|
||||
},
|
||||
"api": {
|
||||
"title": "API & Webhooks",
|
||||
"description": "API tokens, webhooks"
|
||||
},
|
||||
"staffRoles": {
|
||||
"title": "Staff Roles",
|
||||
"description": "Role permissions",
|
||||
"pageTitle": "Staff Roles",
|
||||
"pageDescription": "Create and manage roles with specific permissions for your staff members.",
|
||||
"createRole": "Create Role",
|
||||
"editRole": "Edit Role",
|
||||
"deleteRole": "Delete Role",
|
||||
"roleName": "Role Name",
|
||||
"roleDescription": "Description",
|
||||
"roleNamePlaceholder": "e.g., Front Desk",
|
||||
"roleDescriptionPlaceholder": "Brief description of this role's responsibilities",
|
||||
"permissions": "Permissions",
|
||||
"menuAccess": "Menu Access",
|
||||
"dangerousOperations": "Dangerous Operations",
|
||||
"staffAssigned": "{{count}} staff assigned",
|
||||
"noStaffAssigned": "No staff assigned",
|
||||
"defaultRole": "Default",
|
||||
"cannotDeleteDefault": "Cannot delete default roles",
|
||||
"cannotDeleteWithStaff": "Cannot delete roles with assigned staff",
|
||||
"confirmDelete": "Are you sure you want to delete this role?",
|
||||
"deleteWarning": "This action cannot be undone.",
|
||||
"noRolesFound": "No staff roles found",
|
||||
"createFirstRole": "Create your first custom staff role to control what your staff can access.",
|
||||
"saving": "Saving...",
|
||||
"save": "Save Role",
|
||||
"cancel": "Cancel",
|
||||
"loadingRoles": "Loading roles...",
|
||||
"loadingPermissions": "Loading permissions...",
|
||||
"errorLoadingRoles": "Failed to load staff roles",
|
||||
"errorLoadingPermissions": "Failed to load available permissions",
|
||||
"roleCreated": "Role created successfully",
|
||||
"roleUpdated": "Role updated successfully",
|
||||
"roleDeleted": "Role deleted successfully",
|
||||
"errorCreating": "Failed to create role",
|
||||
"errorUpdating": "Failed to update role",
|
||||
"errorDeleting": "Failed to delete role"
|
||||
},
|
||||
"authentication": {
|
||||
"title": "Authentication",
|
||||
"description": "OAuth, social login"
|
||||
},
|
||||
"email": {
|
||||
"title": "Email Setup",
|
||||
"description": "Email addresses for tickets"
|
||||
},
|
||||
"smsCalling": {
|
||||
"title": "SMS & Calling",
|
||||
"description": "Credits, phone numbers"
|
||||
},
|
||||
"billing": {
|
||||
"title": "Plan & Billing",
|
||||
"description": "Subscription, invoices"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
AlertTriangle,
|
||||
Calendar,
|
||||
Clock,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
SettingsSidebarSection,
|
||||
@@ -154,6 +155,12 @@ const SettingsLayout: React.FC = () => {
|
||||
|
||||
{/* Access Section */}
|
||||
<SettingsSidebarSection title={t('settings.sections.access', 'Access')}>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/staff-roles"
|
||||
icon={Users}
|
||||
label={t('settings.staffRoles.title', 'Staff Roles')}
|
||||
description={t('settings.staffRoles.description', 'Role permissions')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/dashboard/settings/authentication"
|
||||
icon={Lock}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
StaffInvitation,
|
||||
CreateInvitationData,
|
||||
} from '../hooks/useInvitations';
|
||||
import { useStaffRoles } from '../hooks/useStaffRoles';
|
||||
import {
|
||||
Plus,
|
||||
User as UserIcon,
|
||||
@@ -43,6 +44,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
const { data: staffMembers = [], isLoading, error } = useStaff();
|
||||
const { data: resources = [] } = useResources();
|
||||
const { data: invitations = [], isLoading: invitationsLoading } = useInvitations();
|
||||
const { data: staffRoles = [] } = useStaffRoles();
|
||||
const createResourceMutation = useCreateResource();
|
||||
const createInvitationMutation = useCreateInvitation();
|
||||
const cancelInvitationMutation = useCancelInvitation();
|
||||
@@ -53,6 +55,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteRole, setInviteRole] = useState<'TENANT_MANAGER' | 'TENANT_STAFF'>('TENANT_STAFF');
|
||||
const [inviteStaffRoleId, setInviteStaffRoleId] = useState<number | null>(null);
|
||||
const [createBookableResource, setCreateBookableResource] = useState(false);
|
||||
const [resourceName, setResourceName] = useState('');
|
||||
const [invitePermissions, setInvitePermissions] = useState<Record<string, boolean>>({});
|
||||
@@ -64,6 +67,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [editingStaff, setEditingStaff] = useState<StaffMember | null>(null);
|
||||
const [editPermissions, setEditPermissions] = useState<Record<string, boolean>>({});
|
||||
const [editStaffRoleId, setEditStaffRoleId] = useState<number | null>(null);
|
||||
const [editError, setEditError] = useState('');
|
||||
const [editSuccess, setEditSuccess] = useState('');
|
||||
|
||||
@@ -106,6 +110,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
create_bookable_resource: createBookableResource,
|
||||
resource_name: resourceName.trim(),
|
||||
permissions: invitePermissions,
|
||||
staff_role_id: inviteRole === 'TENANT_STAFF' ? inviteStaffRoleId : null,
|
||||
};
|
||||
|
||||
await createInvitationMutation.mutateAsync(invitationData);
|
||||
@@ -114,6 +119,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
setCreateBookableResource(false);
|
||||
setResourceName('');
|
||||
setInvitePermissions({});
|
||||
setInviteStaffRoleId(null);
|
||||
// Close modal after short delay
|
||||
setTimeout(() => {
|
||||
setIsInviteModalOpen(false);
|
||||
@@ -146,6 +152,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
const openInviteModal = () => {
|
||||
setInviteEmail('');
|
||||
setInviteRole('TENANT_STAFF');
|
||||
setInviteStaffRoleId(null);
|
||||
setCreateBookableResource(false);
|
||||
setResourceName('');
|
||||
setInvitePermissions({});
|
||||
@@ -190,6 +197,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
const openEditModal = (staff: StaffMember) => {
|
||||
setEditingStaff(staff);
|
||||
setEditPermissions(staff.permissions || {});
|
||||
setEditStaffRoleId(staff.staff_role_id);
|
||||
setEditError('');
|
||||
setEditSuccess('');
|
||||
setIsEditModalOpen(true);
|
||||
@@ -199,6 +207,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
setIsEditModalOpen(false);
|
||||
setEditingStaff(null);
|
||||
setEditPermissions({});
|
||||
setEditStaffRoleId(null);
|
||||
setEditError('');
|
||||
setEditSuccess('');
|
||||
};
|
||||
@@ -208,9 +217,16 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
|
||||
setEditError('');
|
||||
try {
|
||||
const updates: { permissions: Record<string, boolean>; staff_role_id?: number | null } = {
|
||||
permissions: editPermissions,
|
||||
};
|
||||
// Only include staff_role_id for staff users (not owners/managers)
|
||||
if (editingStaff.role === 'staff') {
|
||||
updates.staff_role_id = editStaffRoleId;
|
||||
}
|
||||
await updateStaffMutation.mutateAsync({
|
||||
id: editingStaff.id,
|
||||
updates: { permissions: editPermissions },
|
||||
updates,
|
||||
});
|
||||
setEditSuccess(t('staff.settingsSaved'));
|
||||
setTimeout(() => {
|
||||
@@ -272,7 +288,9 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-white text-sm">{invitation.email}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{invitation.role_display} • {t('staff.expires')}{' '}
|
||||
{invitation.role_display}
|
||||
{invitation.staff_role_name && ` (${invitation.staff_role_name})`}
|
||||
{' '}• {t('staff.expires')}{' '}
|
||||
{new Date(invitation.expires_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -309,6 +327,7 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
<tr>
|
||||
<th className="px-6 py-4 font-medium">{t('staff.name')}</th>
|
||||
<th className="px-6 py-4 font-medium">{t('staff.role')}</th>
|
||||
<th className="px-6 py-4 font-medium">{t('staff.staffRole')}</th>
|
||||
<th className="px-6 py-4 font-medium">{t('staff.bookableResource')}</th>
|
||||
<th className="px-6 py-4 font-medium text-right">{t('common.actions')}</th>
|
||||
</tr>
|
||||
@@ -350,6 +369,21 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{user.role === 'staff' ? (
|
||||
user.staff_role_name ? (
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300">
|
||||
{user.staff_role_name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 italic">
|
||||
{t('staff.noRoleAssigned')}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{linkedResource ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/20 px-2 py-1 rounded">
|
||||
@@ -533,6 +567,30 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Staff Role Selector (only for staff invitations) */}
|
||||
{inviteRole === 'TENANT_STAFF' && staffRoles.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('staff.staffRole')}
|
||||
</label>
|
||||
<select
|
||||
value={inviteStaffRoleId ?? ''}
|
||||
onChange={(e) => setInviteStaffRoleId(e.target.value ? Number(e.target.value) : null)}
|
||||
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"
|
||||
>
|
||||
<option value="">{t('staff.selectRole')}</option>
|
||||
{staffRoles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('staff.staffRoleSelectHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Permissions - Using shared component */}
|
||||
{inviteRole === 'TENANT_MANAGER' && (
|
||||
<StaffPermissions
|
||||
@@ -672,6 +730,30 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Staff Role Selector (only for staff users) */}
|
||||
{editingStaff.role === 'staff' && staffRoles.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('staff.staffRole')}
|
||||
</label>
|
||||
<select
|
||||
value={editStaffRoleId ?? ''}
|
||||
onChange={(e) => setEditStaffRoleId(e.target.value ? Number(e.target.value) : null)}
|
||||
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"
|
||||
>
|
||||
<option value="">{t('staff.selectRole')}</option>
|
||||
{staffRoles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('staff.staffRoleSelectHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Permissions - Using shared component */}
|
||||
{editingStaff.role === 'manager' && (
|
||||
<StaffPermissions
|
||||
|
||||
483
frontend/src/pages/settings/StaffRolesSettings.tsx
Normal file
483
frontend/src/pages/settings/StaffRolesSettings.tsx
Normal file
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* Staff Roles Settings Page
|
||||
*
|
||||
* Create and manage staff roles with granular permissions.
|
||||
* Roles control what menu items and features are accessible to staff members.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { Shield, Plus, X, Pencil, Trash2, Users, Lock, Check } from 'lucide-react';
|
||||
import { Business, User, StaffRole, PermissionDefinition } from '../../types';
|
||||
import {
|
||||
useStaffRoles,
|
||||
useAvailablePermissions,
|
||||
useCreateStaffRole,
|
||||
useUpdateStaffRole,
|
||||
useDeleteStaffRole,
|
||||
} from '../../hooks/useStaffRoles';
|
||||
|
||||
const StaffRolesSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useOutletContext<{
|
||||
business: Business;
|
||||
user: User;
|
||||
}>();
|
||||
|
||||
const { data: staffRoles = [], isLoading } = useStaffRoles();
|
||||
const { data: availablePermissions } = useAvailablePermissions();
|
||||
const createStaffRole = useCreateStaffRole();
|
||||
const updateStaffRole = useUpdateStaffRole();
|
||||
const deleteStaffRole = useDeleteStaffRole();
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingRole, setEditingRole] = useState<StaffRole | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
permissions: {} as Record<string, boolean>,
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
const isManager = user.role === 'manager';
|
||||
const canManageRoles = isOwner || isManager;
|
||||
|
||||
// Merge menu and dangerous permissions for display
|
||||
const allPermissions = useMemo(() => {
|
||||
if (!availablePermissions) return { menu: {}, dangerous: {} };
|
||||
return {
|
||||
menu: availablePermissions.menu_permissions || {},
|
||||
dangerous: availablePermissions.dangerous_permissions || {},
|
||||
};
|
||||
}, [availablePermissions]);
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingRole(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
permissions: {},
|
||||
});
|
||||
setError(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (role: StaffRole) => {
|
||||
setEditingRole(role);
|
||||
setFormData({
|
||||
name: role.name,
|
||||
description: role.description || '',
|
||||
permissions: { ...role.permissions },
|
||||
});
|
||||
setError(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingRole(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const togglePermission = (key: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
permissions: {
|
||||
...prev.permissions,
|
||||
[key]: !prev.permissions[key],
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleAllPermissions = (category: 'menu' | 'dangerous', enable: boolean) => {
|
||||
const permissions = category === 'menu' ? allPermissions.menu : allPermissions.dangerous;
|
||||
const updates: Record<string, boolean> = {};
|
||||
Object.keys(permissions).forEach((key) => {
|
||||
updates[key] = enable;
|
||||
});
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
permissions: {
|
||||
...prev.permissions,
|
||||
...updates,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (editingRole) {
|
||||
await updateStaffRole.mutateAsync({
|
||||
id: editingRole.id,
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
permissions: formData.permissions,
|
||||
});
|
||||
} else {
|
||||
await createStaffRole.mutateAsync({
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
permissions: formData.permissions,
|
||||
});
|
||||
}
|
||||
closeModal();
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.error || err.response?.data?.name?.[0] || 'Failed to save role';
|
||||
setError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (role: StaffRole) => {
|
||||
if (!role.can_delete) {
|
||||
alert(
|
||||
role.is_default
|
||||
? t('settings.staffRoles.cannotDeleteDefault', 'Default roles cannot be deleted.')
|
||||
: t('settings.staffRoles.cannotDeleteInUse', 'Remove all staff from this role before deleting.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.confirm(t('settings.staffRoles.confirmDelete', `Are you sure you want to delete the "${role.name}" role?`))) {
|
||||
try {
|
||||
await deleteStaffRole.mutateAsync(role.id);
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error || 'Failed to delete role');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const countEnabledPermissions = (permissions: Record<string, boolean>) => {
|
||||
return Object.values(permissions).filter(Boolean).length;
|
||||
};
|
||||
|
||||
if (!canManageRoles) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<Shield size={48} className="mx-auto mb-4 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{t('settings.staffRoles.noAccess', 'Only the business owner or manager can access these settings.')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<Shield className="text-indigo-500" />
|
||||
{t('settings.staffRoles.title', 'Staff Roles')}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('settings.staffRoles.subtitle', 'Create roles to control what staff members can access in your business.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Roles List */}
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('settings.staffRoles.yourRoles', 'Your Staff Roles')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('settings.staffRoles.rolesDescription', 'Assign staff members to roles to control their permissions.')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium text-sm"
|
||||
>
|
||||
<Plus size={16} />
|
||||
{t('settings.staffRoles.createRole', 'Create Role')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
|
||||
</div>
|
||||
) : staffRoles.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<Shield size={40} className="mx-auto mb-2 opacity-30" />
|
||||
<p>{t('settings.staffRoles.noRoles', 'No staff roles configured.')}</p>
|
||||
<p className="text-sm mt-1">{t('settings.staffRoles.createFirst', 'Create your first role to manage staff permissions.')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{staffRoles.map((role) => (
|
||||
<div
|
||||
key={role.id}
|
||||
className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3 flex-1 min-w-0">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${
|
||||
role.is_default
|
||||
? 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
<Shield size={20} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white flex items-center gap-2 flex-wrap">
|
||||
{role.name}
|
||||
{role.is_default && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 rounded">
|
||||
{t('common.default', 'Default')}
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-3 mt-0.5">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users size={14} />
|
||||
{t('settings.staffRoles.staffAssigned', '{{count}} staff', { count: role.staff_count })}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Check size={14} />
|
||||
{t('settings.staffRoles.permissionsEnabled', '{{count}} permissions', {
|
||||
count: countEnabledPermissions(role.permissions),
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
{role.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1 line-clamp-2">
|
||||
{role.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||
<button
|
||||
onClick={() => openEditModal(role)}
|
||||
className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
|
||||
title={t('common.edit', 'Edit')}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(role)}
|
||||
disabled={deleteStaffRole.isPending || !role.can_delete}
|
||||
className={`p-2 transition-colors disabled:opacity-50 ${
|
||||
role.can_delete
|
||||
? 'text-gray-400 hover:text-red-600 dark:hover:text-red-400'
|
||||
: 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
|
||||
}`}
|
||||
title={
|
||||
role.is_default
|
||||
? t('settings.staffRoles.cannotDeleteDefault', 'Default roles cannot be deleted')
|
||||
: role.staff_count > 0
|
||||
? t('settings.staffRoles.cannotDeleteInUse', 'Remove all staff first')
|
||||
: t('common.delete', 'Delete')
|
||||
}
|
||||
>
|
||||
{role.can_delete ? <Trash2 size={16} /> : <Lock size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-700 shrink-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{editingRole
|
||||
? t('settings.staffRoles.editRole', 'Edit Role')
|
||||
: t('settings.staffRoles.createRole', 'Create Role')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
|
||||
<div className="p-6 space-y-6">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('settings.staffRoles.roleName', 'Role Name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
disabled={editingRole?.is_default}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
placeholder={t('settings.staffRoles.roleNamePlaceholder', 'e.g., Front Desk, Senior Stylist')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('settings.staffRoles.roleDescription', 'Description')}
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 resize-none"
|
||||
placeholder={t('settings.staffRoles.roleDescriptionPlaceholder', 'Describe what this role can do...')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu Permissions */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t('settings.staffRoles.menuPermissions', 'Menu Access')}
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('settings.staffRoles.menuPermissionsDescription', 'Control which pages staff can see in the sidebar.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAllPermissions('menu', true)}
|
||||
className="text-xs text-brand-600 dark:text-brand-400 hover:underline"
|
||||
>
|
||||
{t('common.selectAll', 'Select All')}
|
||||
</button>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAllPermissions('menu', false)}
|
||||
className="text-xs text-gray-500 dark:text-gray-400 hover:underline"
|
||||
>
|
||||
{t('common.clearAll', 'Clear All')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(allPermissions.menu).map(([key, def]: [string, PermissionDefinition]) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions[key] || false}
|
||||
onChange={() => togglePermission(key)}
|
||||
className="w-4 h-4 text-brand-600 border-gray-300 dark:border-gray-600 rounded focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{def.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{def.description}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dangerous Permissions */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
{t('settings.staffRoles.dangerousPermissions', 'Dangerous Operations')}
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 rounded">
|
||||
{t('common.caution', 'Caution')}
|
||||
</span>
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('settings.staffRoles.dangerousPermissionsDescription', 'Allow staff to perform destructive or sensitive actions.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAllPermissions('dangerous', true)}
|
||||
className="text-xs text-brand-600 dark:text-brand-400 hover:underline"
|
||||
>
|
||||
{t('common.selectAll', 'Select All')}
|
||||
</button>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAllPermissions('dangerous', false)}
|
||||
className="text-xs text-gray-500 dark:text-gray-400 hover:underline"
|
||||
>
|
||||
{t('common.clearAll', 'Clear All')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 p-3 bg-red-50/50 dark:bg-red-900/10 rounded-lg border border-red-100 dark:border-red-900/30">
|
||||
{Object.entries(allPermissions.dangerous).map(([key, def]: [string, PermissionDefinition]) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-2 p-2 rounded-lg hover:bg-red-100/50 dark:hover:bg-red-900/20 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.permissions[key] || false}
|
||||
onChange={() => togglePermission(key)}
|
||||
className="w-4 h-4 text-red-600 border-gray-300 dark:border-gray-600 rounded focus:ring-red-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{def.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{def.description}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 p-6 border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createStaffRole.isPending || updateStaffRole.isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{editingRole ? t('common.save', 'Save') : t('common.create', 'Create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StaffRolesSettings;
|
||||
@@ -135,6 +135,34 @@ export interface User {
|
||||
linked_resource_name?: string;
|
||||
permissions?: Record<string, boolean>;
|
||||
quota_overages?: QuotaOverage[];
|
||||
// Staff role fields
|
||||
staff_role_id?: number | null;
|
||||
staff_role_name?: string | null;
|
||||
effective_permissions?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
// Staff Role Types
|
||||
export interface StaffRole {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
permissions: Record<string, boolean>;
|
||||
is_default: boolean;
|
||||
staff_count: number;
|
||||
can_delete: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PermissionDefinition {
|
||||
label: string;
|
||||
description: string;
|
||||
default: boolean;
|
||||
}
|
||||
|
||||
export interface AvailablePermissions {
|
||||
menu_permissions: Record<string, PermissionDefinition>;
|
||||
dangerous_permissions: Record<string, PermissionDefinition>;
|
||||
}
|
||||
|
||||
export type ResourceType = 'STAFF' | 'ROOM' | 'EQUIPMENT';
|
||||
|
||||
@@ -32,6 +32,26 @@ from .email_renderer import render_email
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_email_blocked() -> bool:
|
||||
"""
|
||||
Check if emails should be blocked for the current tenant.
|
||||
|
||||
Used for demo accounts where we don't want to send real emails.
|
||||
Checks the tenant's block_emails flag.
|
||||
|
||||
Returns:
|
||||
True if emails should be blocked, False otherwise
|
||||
"""
|
||||
try:
|
||||
from django.db import connection
|
||||
tenant = getattr(connection, 'tenant', None)
|
||||
if tenant and hasattr(tenant, 'block_emails'):
|
||||
return tenant.block_emails
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def send_system_email(
|
||||
email_type: EmailType,
|
||||
to_email: str,
|
||||
@@ -70,6 +90,11 @@ def send_system_email(
|
||||
logger.warning("Cannot send email: no recipient address")
|
||||
return False
|
||||
|
||||
# Check if emails are blocked for this tenant (demo accounts)
|
||||
if is_email_blocked():
|
||||
logger.info(f"Email blocked for demo tenant: {email_type.value} to {to_email}")
|
||||
return True # Return True to avoid error handling in callers
|
||||
|
||||
context = context or {}
|
||||
|
||||
try:
|
||||
@@ -187,3 +212,84 @@ def get_template_preview(
|
||||
"""
|
||||
template = PuckEmailTemplate.get_or_create_for_type(email_type)
|
||||
return render_email(template, context or {})
|
||||
|
||||
|
||||
def send_plain_email(
|
||||
subject: str,
|
||||
message: str,
|
||||
from_email: Optional[str],
|
||||
recipient_list: List[str],
|
||||
fail_silently: bool = False,
|
||||
) -> int:
|
||||
"""
|
||||
Send a plain text email, respecting the tenant's block_emails setting.
|
||||
|
||||
This is a wrapper around Django's send_mail that checks if emails are blocked
|
||||
for the current tenant (e.g., demo accounts).
|
||||
|
||||
Args:
|
||||
subject: Email subject line
|
||||
message: Email body text
|
||||
from_email: Sender email address
|
||||
recipient_list: List of recipient email addresses
|
||||
fail_silently: If True, suppress exceptions on send failure
|
||||
|
||||
Returns:
|
||||
Number of emails sent (0 if blocked, 1 if sent)
|
||||
"""
|
||||
from django.core.mail import send_mail
|
||||
|
||||
# Check if emails are blocked for this tenant
|
||||
if is_email_blocked():
|
||||
logger.info(f"Email blocked for demo tenant: {subject} to {recipient_list}")
|
||||
return 1 # Return 1 to indicate "success" to callers
|
||||
|
||||
return send_mail(
|
||||
subject=subject,
|
||||
message=message,
|
||||
from_email=from_email,
|
||||
recipient_list=recipient_list,
|
||||
fail_silently=fail_silently,
|
||||
)
|
||||
|
||||
|
||||
def send_html_email(
|
||||
subject: str,
|
||||
message: str,
|
||||
from_email: Optional[str],
|
||||
recipient_list: List[str],
|
||||
html_message: Optional[str] = None,
|
||||
fail_silently: bool = False,
|
||||
) -> int:
|
||||
"""
|
||||
Send an email with HTML support, respecting the tenant's block_emails setting.
|
||||
|
||||
This is a wrapper around Django's send_mail that checks if emails are blocked
|
||||
for the current tenant (e.g., demo accounts).
|
||||
|
||||
Args:
|
||||
subject: Email subject line
|
||||
message: Plain text email body
|
||||
from_email: Sender email address
|
||||
recipient_list: List of recipient email addresses
|
||||
html_message: Optional HTML version of the email
|
||||
fail_silently: If True, suppress exceptions on send failure
|
||||
|
||||
Returns:
|
||||
Number of emails sent (0 if blocked, 1 if sent)
|
||||
"""
|
||||
from django.core.mail import send_mail
|
||||
|
||||
# Check if emails are blocked for this tenant
|
||||
if is_email_blocked():
|
||||
logger.info(f"Email blocked for demo tenant: {subject} to {recipient_list}")
|
||||
return 1 # Return 1 to indicate "success" to callers
|
||||
|
||||
return send_mail(
|
||||
subject=subject,
|
||||
message=message,
|
||||
from_email=from_email,
|
||||
recipient_list=recipient_list,
|
||||
html_message=html_message,
|
||||
fail_silently=fail_silently,
|
||||
)
|
||||
|
||||
@@ -32,9 +32,8 @@ class TenantHeaderMiddleware(MiddlewareMixin):
|
||||
tenant = Tenant.objects.get(schema_name=subdomain)
|
||||
except Tenant.DoesNotExist:
|
||||
# Try looking up by domain (subdomain matching)
|
||||
from django_tenants.models import DomainMixin
|
||||
from django.apps import apps
|
||||
Domain = apps.get_model('tenants', 'Domain')
|
||||
from django_tenants.utils import get_tenant_domain_model
|
||||
Domain = get_tenant_domain_model()
|
||||
try:
|
||||
# Look for domain that starts with the subdomain
|
||||
domain_obj = Domain.objects.filter(domain__startswith=f"{subdomain}.").first()
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated migration for block_emails field
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0028_tenantstorageusage'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='block_emails',
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text='Block all outbound emails from this tenant (for demo accounts)'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -45,22 +45,38 @@ def safe_error_message(exception: Exception, default_message: str = "An error oc
|
||||
|
||||
def _staff_has_permission_override(user, permission_key):
|
||||
"""
|
||||
Check if a staff member has a per-user permission override.
|
||||
Check if a staff member has permission via their role or per-user override.
|
||||
|
||||
Staff members can be granted specific permissions via user.permissions JSONField.
|
||||
This allows owners/managers to grant individual staff access to normally restricted areas.
|
||||
Permission Resolution Order:
|
||||
1. User-level override (user.permissions JSONField) - highest priority
|
||||
2. Staff role permissions (user.staff_role.permissions)
|
||||
3. Default: False
|
||||
|
||||
This allows owners/managers to grant individual staff access to normally restricted areas
|
||||
either via their assigned role or per-user overrides.
|
||||
|
||||
Args:
|
||||
user: The user to check
|
||||
permission_key: The permission key to check (e.g., 'can_access_resources')
|
||||
|
||||
Returns:
|
||||
bool: True if user has the permission override
|
||||
bool: True if user has the permission
|
||||
"""
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
permissions = getattr(user, 'permissions', {}) or {}
|
||||
return permissions.get(permission_key, False)
|
||||
|
||||
# Check user-level override first (highest priority)
|
||||
user_permissions = getattr(user, 'permissions', {}) or {}
|
||||
if permission_key in user_permissions:
|
||||
return user_permissions[permission_key]
|
||||
|
||||
# Check staff role permissions
|
||||
staff_role = getattr(user, 'staff_role', None)
|
||||
if staff_role:
|
||||
role_permissions = getattr(staff_role, 'permissions', {}) or {}
|
||||
return role_permissions.get(permission_key, False)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class DenyStaffWritePermission(BasePermission):
|
||||
|
||||
@@ -267,6 +267,12 @@ class Tenant(TenantMixin):
|
||||
help_text="Whether sandbox/test mode is available for this business"
|
||||
)
|
||||
|
||||
# Demo/Sales Mode
|
||||
block_emails = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Block all outbound emails from this tenant (for demo accounts)"
|
||||
)
|
||||
|
||||
# Auto-created fields from TenantMixin:
|
||||
# - schema_name (unique, indexed)
|
||||
# - auto_create_schema
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
API views for user authentication
|
||||
"""
|
||||
import secrets
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -19,6 +18,7 @@ from smoothschedule.identity.core.permissions import can_hijack
|
||||
from rest_framework import serializers
|
||||
from smoothschedule.scheduling.schedule.models import Resource, ResourceType
|
||||
from smoothschedule.identity.core.models import Tenant, Domain
|
||||
from smoothschedule.communication.messaging.email_service import send_plain_email
|
||||
from django_tenants.utils import schema_context
|
||||
|
||||
|
||||
@@ -248,7 +248,7 @@ Thanks,
|
||||
The Smooth Schedule Team
|
||||
"""
|
||||
|
||||
send_mail(
|
||||
send_plain_email(
|
||||
subject,
|
||||
message,
|
||||
settings.DEFAULT_FROM_EMAIL if hasattr(settings, 'DEFAULT_FROM_EMAIL') else 'noreply@smoothschedule.com',
|
||||
@@ -889,7 +889,7 @@ Thanks,
|
||||
The Smooth Schedule Team
|
||||
"""
|
||||
|
||||
send_mail(
|
||||
send_plain_email(
|
||||
subject,
|
||||
message,
|
||||
settings.DEFAULT_FROM_EMAIL if hasattr(settings, 'DEFAULT_FROM_EMAIL') else 'noreply@smoothschedule.com',
|
||||
@@ -944,7 +944,7 @@ Thanks,
|
||||
The Smooth Schedule Team
|
||||
"""
|
||||
|
||||
send_mail(
|
||||
send_plain_email(
|
||||
subject,
|
||||
message,
|
||||
settings.DEFAULT_FROM_EMAIL if hasattr(settings, 'DEFAULT_FROM_EMAIL') else 'noreply@smoothschedule.com',
|
||||
@@ -1274,7 +1274,7 @@ The SmoothSchedule Team
|
||||
"""
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
send_plain_email(
|
||||
subject,
|
||||
message,
|
||||
settings.DEFAULT_FROM_EMAIL if hasattr(settings, 'DEFAULT_FROM_EMAIL') else 'noreply@smoothschedule.com',
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-16 17:56
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0028_tenantstorageusage'),
|
||||
('users', '0010_add_stripe_customer_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='StaffRole',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='Display name of the role', max_length=100)),
|
||||
('description', models.TextField(blank=True, default='', help_text='Description of what this role can do')),
|
||||
('permissions', models.JSONField(blank=True, default=dict, help_text='Permission keys and their boolean values')),
|
||||
('is_default', models.BooleanField(default=False, help_text='True for system-created default roles')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('tenant', models.ForeignKey(help_text='Tenant this role belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='staff_roles', to='core.tenant')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-is_default', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='staffinvitation',
|
||||
name='staff_role',
|
||||
field=models.ForeignKey(blank=True, help_text='Staff role to assign when invitation is accepted (for TENANT_STAFF only)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invitations', to='users.staffrole'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='staff_role',
|
||||
field=models.ForeignKey(blank=True, help_text='Assigned staff role (for TENANT_STAFF only)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff_members', to='users.staffrole'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='staffrole',
|
||||
index=models.Index(fields=['tenant', 'is_default'], name='users_staff_tenant__53464c_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='staffrole',
|
||||
unique_together={('tenant', 'name')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,120 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-16 17:56
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
# Default role configurations - duplicated here to avoid import issues during migrations
|
||||
DEFAULT_ROLES = {
|
||||
'Full Access Staff': {
|
||||
'description': 'Complete access to all features (similar to manager)',
|
||||
'permissions': {
|
||||
'can_access_dashboard': True,
|
||||
'can_access_scheduler': True,
|
||||
'can_access_tasks': True,
|
||||
'can_access_my_schedule': True,
|
||||
'can_access_my_availability': True,
|
||||
'can_access_site_builder': True,
|
||||
'can_access_gallery': True,
|
||||
'can_access_customers': True,
|
||||
'can_access_services': True,
|
||||
'can_access_resources': True,
|
||||
'can_access_staff': True,
|
||||
'can_access_contracts': True,
|
||||
'can_access_time_blocks': True,
|
||||
'can_access_locations': True,
|
||||
'can_access_messages': True,
|
||||
'can_access_tickets': True,
|
||||
'can_access_payments': True,
|
||||
'can_access_automations': True,
|
||||
'can_delete_customers': True,
|
||||
'can_cancel_appointments': True,
|
||||
'can_delete_appointments': True,
|
||||
'can_refund_payments': True,
|
||||
'can_delete_resources': True,
|
||||
'can_delete_services': True,
|
||||
'can_invite_staff': True,
|
||||
'can_self_approve_time_off': True,
|
||||
},
|
||||
},
|
||||
'Front Desk': {
|
||||
'description': 'Access to scheduling, customers, and basic operations',
|
||||
'permissions': {
|
||||
'can_access_dashboard': True,
|
||||
'can_access_scheduler': True,
|
||||
'can_access_my_schedule': True,
|
||||
'can_access_my_availability': True,
|
||||
'can_access_customers': True,
|
||||
'can_access_tickets': True,
|
||||
'can_cancel_appointments': True,
|
||||
},
|
||||
},
|
||||
'Limited Staff': {
|
||||
'description': 'Basic access to own schedule only',
|
||||
'permissions': {
|
||||
'can_access_dashboard': True,
|
||||
'can_access_my_schedule': True,
|
||||
'can_access_my_availability': True,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def create_default_roles_for_tenants(apps, schema_editor):
|
||||
"""Create default staff roles for all existing tenants"""
|
||||
Tenant = apps.get_model('core', 'Tenant')
|
||||
StaffRole = apps.get_model('users', 'StaffRole')
|
||||
User = apps.get_model('users', 'User')
|
||||
|
||||
for tenant in Tenant.objects.all():
|
||||
limited_role = None
|
||||
|
||||
# Create default roles for each tenant
|
||||
for role_name, role_config in DEFAULT_ROLES.items():
|
||||
role, created = StaffRole.objects.get_or_create(
|
||||
tenant=tenant,
|
||||
name=role_name,
|
||||
defaults={
|
||||
'description': role_config['description'],
|
||||
'permissions': role_config['permissions'],
|
||||
'is_default': True,
|
||||
}
|
||||
)
|
||||
if role_name == 'Limited Staff':
|
||||
limited_role = role
|
||||
|
||||
# Assign existing TENANT_STAFF users to 'Limited Staff' role
|
||||
if limited_role:
|
||||
User.objects.filter(
|
||||
tenant=tenant,
|
||||
role='TENANT_STAFF',
|
||||
staff_role__isnull=True
|
||||
).update(staff_role=limited_role)
|
||||
|
||||
|
||||
def reverse_migration(apps, schema_editor):
|
||||
"""
|
||||
Reverse: Remove staff_role from users and delete default roles.
|
||||
Note: This will not delete custom roles created by users.
|
||||
"""
|
||||
User = apps.get_model('users', 'User')
|
||||
StaffRole = apps.get_model('users', 'StaffRole')
|
||||
|
||||
# Clear staff_role from all users
|
||||
User.objects.filter(staff_role__isnull=False).update(staff_role=None)
|
||||
|
||||
# Delete default roles
|
||||
StaffRole.objects.filter(is_default=True).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0011_add_staff_role_model'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
create_default_roles_for_tenants,
|
||||
reverse_migration,
|
||||
),
|
||||
]
|
||||
@@ -143,6 +143,16 @@ class User(AbstractUser):
|
||||
help_text="Role-specific permissions like can_invite_staff for managers"
|
||||
)
|
||||
|
||||
# Staff role assignment (for TENANT_STAFF users)
|
||||
staff_role = models.ForeignKey(
|
||||
'StaffRole',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='staff_members',
|
||||
help_text="Assigned staff role (for TENANT_STAFF only)"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
@@ -302,6 +312,64 @@ class User(AbstractUser):
|
||||
# Staff and others cannot send messages
|
||||
return False
|
||||
|
||||
def has_staff_permission(self, permission_key):
|
||||
"""
|
||||
Check if staff member has a specific permission.
|
||||
|
||||
Permission Resolution Order:
|
||||
1. Owners and Managers always have all permissions (return True)
|
||||
2. For staff: User-level override takes priority
|
||||
3. Then check staff role permissions
|
||||
4. Default: False
|
||||
|
||||
Args:
|
||||
permission_key: The permission key to check (e.g., 'can_access_scheduler')
|
||||
|
||||
Returns:
|
||||
bool: Whether the user has the permission
|
||||
"""
|
||||
# Owners and managers have all permissions
|
||||
if self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]:
|
||||
return True
|
||||
|
||||
# For staff, check permissions
|
||||
if self.role == self.Role.TENANT_STAFF:
|
||||
# User-level override takes priority
|
||||
if self.permissions and permission_key in self.permissions:
|
||||
return self.permissions[permission_key]
|
||||
|
||||
# Then check staff role permissions
|
||||
if self.staff_role and self.staff_role.permissions:
|
||||
return self.staff_role.permissions.get(permission_key, False)
|
||||
|
||||
return False
|
||||
|
||||
def get_effective_permissions(self):
|
||||
"""
|
||||
Get the merged permissions for this user (role + user overrides).
|
||||
|
||||
Returns:
|
||||
dict: All effective permissions for this user
|
||||
"""
|
||||
if self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]:
|
||||
# Return all permissions as True for owner/manager
|
||||
from smoothschedule.identity.users.staff_permissions import ALL_PERMISSIONS
|
||||
return {k: True for k in ALL_PERMISSIONS.keys()}
|
||||
|
||||
if self.role == self.Role.TENANT_STAFF:
|
||||
# Start with role permissions
|
||||
perms = {}
|
||||
if self.staff_role and self.staff_role.permissions:
|
||||
perms = self.staff_role.permissions.copy()
|
||||
|
||||
# Override with user-level permissions
|
||||
if self.permissions:
|
||||
perms.update(self.permissions)
|
||||
|
||||
return perms
|
||||
|
||||
return {}
|
||||
|
||||
def get_accessible_tenants(self):
|
||||
"""
|
||||
Get list of tenants this user can access.
|
||||
@@ -501,6 +569,111 @@ class TrustedDevice(models.Model):
|
||||
return device
|
||||
|
||||
|
||||
class StaffRole(models.Model):
|
||||
"""
|
||||
Tenant-scoped role definitions for staff members.
|
||||
|
||||
This model is in the users app (SHARED_APP) with a tenant FK for scoping.
|
||||
It uses explicit tenant filtering instead of schema isolation because
|
||||
the User model (which references it) is also in a shared app.
|
||||
|
||||
Each role contains a set of permissions that control access to
|
||||
menu items and dangerous operations at the API level.
|
||||
|
||||
Permission Resolution Order:
|
||||
1. User-level override (user.permissions) - highest priority
|
||||
2. Staff role permissions (staff_role.permissions)
|
||||
3. Default: False
|
||||
"""
|
||||
|
||||
tenant = models.ForeignKey(
|
||||
'core.Tenant',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='staff_roles',
|
||||
help_text="Tenant this role belongs to"
|
||||
)
|
||||
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
help_text="Display name of the role"
|
||||
)
|
||||
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
default='',
|
||||
help_text="Description of what this role can do"
|
||||
)
|
||||
|
||||
# Permissions stored as JSON for flexibility
|
||||
permissions = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Permission keys and their boolean values"
|
||||
)
|
||||
|
||||
# System roles cannot be deleted
|
||||
is_default = models.BooleanField(
|
||||
default=False,
|
||||
help_text="True for system-created default roles"
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'users'
|
||||
ordering = ['-is_default', 'name']
|
||||
unique_together = [['tenant', 'name']]
|
||||
indexes = [
|
||||
models.Index(fields=['tenant', 'is_default']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.tenant.name})"
|
||||
|
||||
def get_staff_count(self):
|
||||
"""Returns the number of staff assigned to this role.
|
||||
|
||||
Note: In list views, prefer using the annotated `staff_count` field
|
||||
from the queryset for better performance (single query vs N+1).
|
||||
"""
|
||||
return self.staff_members.count()
|
||||
|
||||
def can_delete(self):
|
||||
"""Check if this role can be deleted"""
|
||||
if self.is_default:
|
||||
return False
|
||||
# Use annotated staff_count if available, otherwise query
|
||||
staff_count = getattr(self, 'staff_count', None)
|
||||
if staff_count is None:
|
||||
staff_count = self.get_staff_count()
|
||||
return staff_count == 0
|
||||
|
||||
@classmethod
|
||||
def create_default_roles_for_tenant(cls, tenant):
|
||||
"""
|
||||
Create default staff roles for a tenant.
|
||||
Called during tenant creation and in data migrations.
|
||||
"""
|
||||
from smoothschedule.identity.users.staff_permissions import DEFAULT_ROLES
|
||||
|
||||
created_roles = []
|
||||
for role_name, role_config in DEFAULT_ROLES.items():
|
||||
role, created = cls.objects.get_or_create(
|
||||
tenant=tenant,
|
||||
name=role_name,
|
||||
defaults={
|
||||
'description': role_config['description'],
|
||||
'permissions': role_config['permissions'],
|
||||
'is_default': True,
|
||||
}
|
||||
)
|
||||
if created:
|
||||
created_roles.append(role)
|
||||
return created_roles
|
||||
|
||||
|
||||
class StaffInvitation(models.Model):
|
||||
"""
|
||||
Invitation for new staff members to join a business.
|
||||
@@ -590,6 +763,16 @@ class StaffInvitation(models.Model):
|
||||
help_text="Permission settings for the invited user"
|
||||
)
|
||||
|
||||
# Staff role to assign when invitation is accepted
|
||||
staff_role = models.ForeignKey(
|
||||
'StaffRole',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='invitations',
|
||||
help_text="Staff role to assign when invitation is accepted (for TENANT_STAFF only)"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
app_label = 'users'
|
||||
ordering = ['-created_at']
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
Staff Role Permission Keys
|
||||
|
||||
These keys control access to menu items and dangerous operations for staff members.
|
||||
All permissions default to False for staff unless explicitly granted via their role
|
||||
or user-level override.
|
||||
|
||||
Permission Resolution Order:
|
||||
1. User-level override (user.permissions JSONField) - highest priority
|
||||
2. Staff role permissions (user.staff_role.permissions)
|
||||
3. Default: False
|
||||
"""
|
||||
|
||||
# Menu/Page Access Permissions
|
||||
# These control visibility of sidebar menu items and access to corresponding pages/APIs
|
||||
MENU_PERMISSIONS = {
|
||||
'can_access_dashboard': {
|
||||
'label': 'Dashboard',
|
||||
'description': 'Access the main dashboard',
|
||||
'default': True,
|
||||
},
|
||||
'can_access_scheduler': {
|
||||
'label': 'Scheduler',
|
||||
'description': 'View and manage the appointment calendar',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_tasks': {
|
||||
'label': 'Tasks',
|
||||
'description': 'View and manage scheduled tasks',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_my_schedule': {
|
||||
'label': 'My Schedule',
|
||||
'description': 'View own appointments and schedule',
|
||||
'default': True,
|
||||
},
|
||||
'can_access_my_availability': {
|
||||
'label': 'My Availability',
|
||||
'description': 'Manage own availability and time off',
|
||||
'default': True,
|
||||
},
|
||||
'can_access_site_builder': {
|
||||
'label': 'Site Builder',
|
||||
'description': 'Edit the booking site',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_gallery': {
|
||||
'label': 'Media Gallery',
|
||||
'description': 'Manage photos and media',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_customers': {
|
||||
'label': 'Customers',
|
||||
'description': 'View and manage customer list',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_services': {
|
||||
'label': 'Services',
|
||||
'description': 'View and manage services',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_resources': {
|
||||
'label': 'Resources',
|
||||
'description': 'View and manage resources',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_staff': {
|
||||
'label': 'Staff',
|
||||
'description': 'View and manage staff members',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_contracts': {
|
||||
'label': 'Contracts',
|
||||
'description': 'View and manage contracts',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_time_blocks': {
|
||||
'label': 'Time Blocks',
|
||||
'description': 'Manage business time blocks',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_locations': {
|
||||
'label': 'Locations',
|
||||
'description': 'Manage business locations',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_messages': {
|
||||
'label': 'Messages',
|
||||
'description': 'Send broadcast messages',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_tickets': {
|
||||
'label': 'Tickets',
|
||||
'description': 'View and manage support tickets',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_payments': {
|
||||
'label': 'Payments',
|
||||
'description': 'View payment information',
|
||||
'default': False,
|
||||
},
|
||||
'can_access_automations': {
|
||||
'label': 'Automations',
|
||||
'description': 'View and manage automations',
|
||||
'default': False,
|
||||
},
|
||||
}
|
||||
|
||||
# Dangerous Operation Permissions
|
||||
# These control specific destructive or sensitive operations at the API level
|
||||
DANGEROUS_PERMISSIONS = {
|
||||
'can_delete_customers': {
|
||||
'label': 'Delete Customers',
|
||||
'description': 'Permanently delete customer records',
|
||||
'default': False,
|
||||
},
|
||||
'can_cancel_appointments': {
|
||||
'label': 'Cancel Appointments',
|
||||
'description': 'Cancel appointments',
|
||||
'default': False,
|
||||
},
|
||||
'can_delete_appointments': {
|
||||
'label': 'Delete Appointments',
|
||||
'description': 'Permanently delete appointments',
|
||||
'default': False,
|
||||
},
|
||||
'can_refund_payments': {
|
||||
'label': 'Process Refunds',
|
||||
'description': 'Issue refunds to customers',
|
||||
'default': False,
|
||||
},
|
||||
'can_delete_resources': {
|
||||
'label': 'Delete Resources',
|
||||
'description': 'Delete bookable resources',
|
||||
'default': False,
|
||||
},
|
||||
'can_delete_services': {
|
||||
'label': 'Delete Services',
|
||||
'description': 'Delete service offerings',
|
||||
'default': False,
|
||||
},
|
||||
'can_invite_staff': {
|
||||
'label': 'Invite Staff',
|
||||
'description': 'Send invitations to new staff members',
|
||||
'default': False,
|
||||
},
|
||||
'can_self_approve_time_off': {
|
||||
'label': 'Self-Approve Time Off',
|
||||
'description': 'Approve own time off requests without manager approval',
|
||||
'default': False,
|
||||
},
|
||||
}
|
||||
|
||||
# All permissions combined for easy iteration
|
||||
ALL_PERMISSIONS = {**MENU_PERMISSIONS, **DANGEROUS_PERMISSIONS}
|
||||
|
||||
|
||||
def get_default_permissions_for_role(role_name: str) -> dict:
|
||||
"""
|
||||
Get the default permissions for a built-in role.
|
||||
|
||||
Args:
|
||||
role_name: One of 'Full Access Staff', 'Front Desk', 'Limited Staff'
|
||||
|
||||
Returns:
|
||||
Dict of permission keys to boolean values
|
||||
"""
|
||||
return DEFAULT_ROLES.get(role_name, {}).get('permissions', {})
|
||||
|
||||
|
||||
# Default role configurations
|
||||
# These are created for each tenant during migration and on new tenant creation
|
||||
DEFAULT_ROLES = {
|
||||
'Full Access Staff': {
|
||||
'description': 'Complete access to all features (similar to manager)',
|
||||
'permissions': {k: True for k in ALL_PERMISSIONS.keys()},
|
||||
},
|
||||
'Front Desk': {
|
||||
'description': 'Access to scheduling, customers, and basic operations',
|
||||
'permissions': {
|
||||
'can_access_dashboard': True,
|
||||
'can_access_scheduler': True,
|
||||
'can_access_my_schedule': True,
|
||||
'can_access_my_availability': True,
|
||||
'can_access_customers': True,
|
||||
'can_access_tickets': True,
|
||||
'can_cancel_appointments': True,
|
||||
},
|
||||
},
|
||||
'Limited Staff': {
|
||||
'description': 'Basic access to own schedule only',
|
||||
'permissions': {
|
||||
'can_access_dashboard': True,
|
||||
'can_access_my_schedule': True,
|
||||
'can_access_my_availability': True,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
"""
|
||||
Tests for Staff Role functionality.
|
||||
|
||||
Tests cover:
|
||||
- StaffRole model methods
|
||||
- Permission resolution (role + user overrides)
|
||||
- User.has_staff_permission method
|
||||
- Permission helper function
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
|
||||
|
||||
class TestStaffRoleModel:
|
||||
"""Unit tests for StaffRole model"""
|
||||
|
||||
def test_staff_count_returns_correct_count(self):
|
||||
"""staff_count property returns number of assigned staff"""
|
||||
mock_role = Mock()
|
||||
mock_role.staff_members.count.return_value = 5
|
||||
|
||||
# Import and patch the property
|
||||
from smoothschedule.identity.users.models import StaffRole
|
||||
role = Mock(spec=StaffRole)
|
||||
role.staff_members = mock_role.staff_members
|
||||
role.is_default = False
|
||||
|
||||
# Test via the actual property logic
|
||||
assert mock_role.staff_members.count() == 5
|
||||
|
||||
def test_can_delete_returns_false_for_default_roles(self):
|
||||
"""Default roles cannot be deleted"""
|
||||
mock_role = Mock()
|
||||
mock_role.is_default = True
|
||||
mock_role.staff_members.count.return_value = 0
|
||||
|
||||
# can_delete should return False for default roles
|
||||
result = not mock_role.is_default and mock_role.staff_members.count() == 0
|
||||
assert result is False
|
||||
|
||||
def test_can_delete_returns_false_when_staff_assigned(self):
|
||||
"""Roles with staff assigned cannot be deleted"""
|
||||
mock_role = Mock()
|
||||
mock_role.is_default = False
|
||||
mock_role.staff_members.count.return_value = 3
|
||||
|
||||
# can_delete should return False when staff are assigned
|
||||
result = not mock_role.is_default and mock_role.staff_members.count() == 0
|
||||
assert result is False
|
||||
|
||||
def test_can_delete_returns_true_when_empty_and_not_default(self):
|
||||
"""Non-default roles with no staff can be deleted"""
|
||||
mock_role = Mock()
|
||||
mock_role.is_default = False
|
||||
mock_role.staff_members.count.return_value = 0
|
||||
|
||||
# can_delete should return True
|
||||
result = not mock_role.is_default and mock_role.staff_members.count() == 0
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestUserHasStaffPermission:
|
||||
"""Unit tests for User.has_staff_permission method"""
|
||||
|
||||
def test_owner_always_has_permission(self):
|
||||
"""Owners have all permissions"""
|
||||
mock_user = Mock()
|
||||
mock_user.role = 'TENANT_OWNER'
|
||||
|
||||
# Simulate the has_staff_permission logic
|
||||
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
|
||||
result = True
|
||||
else:
|
||||
result = False
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_manager_always_has_permission(self):
|
||||
"""Managers have all permissions"""
|
||||
mock_user = Mock()
|
||||
mock_user.role = 'TENANT_MANAGER'
|
||||
|
||||
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
|
||||
result = True
|
||||
else:
|
||||
result = False
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_staff_user_override_takes_priority(self):
|
||||
"""User-level override takes priority over role permissions"""
|
||||
mock_user = Mock()
|
||||
mock_user.role = 'TENANT_STAFF'
|
||||
mock_user.permissions = {'can_access_scheduler': True}
|
||||
mock_user.staff_role = Mock()
|
||||
mock_user.staff_role.permissions = {'can_access_scheduler': False}
|
||||
|
||||
# Simulate permission resolution
|
||||
permission_key = 'can_access_scheduler'
|
||||
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
|
||||
result = True
|
||||
elif mock_user.role == 'TENANT_STAFF':
|
||||
if mock_user.permissions and permission_key in mock_user.permissions:
|
||||
result = mock_user.permissions[permission_key]
|
||||
elif mock_user.staff_role and mock_user.staff_role.permissions:
|
||||
result = mock_user.staff_role.permissions.get(permission_key, False)
|
||||
else:
|
||||
result = False
|
||||
else:
|
||||
result = False
|
||||
|
||||
assert result is True # User override wins
|
||||
|
||||
def test_staff_role_permission_used_when_no_override(self):
|
||||
"""Role permission used when no user-level override"""
|
||||
mock_user = Mock()
|
||||
mock_user.role = 'TENANT_STAFF'
|
||||
mock_user.permissions = {} # No user-level override
|
||||
mock_user.staff_role = Mock()
|
||||
mock_user.staff_role.permissions = {'can_access_scheduler': True}
|
||||
|
||||
permission_key = 'can_access_scheduler'
|
||||
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
|
||||
result = True
|
||||
elif mock_user.role == 'TENANT_STAFF':
|
||||
if mock_user.permissions and permission_key in mock_user.permissions:
|
||||
result = mock_user.permissions[permission_key]
|
||||
elif mock_user.staff_role and mock_user.staff_role.permissions:
|
||||
result = mock_user.staff_role.permissions.get(permission_key, False)
|
||||
else:
|
||||
result = False
|
||||
else:
|
||||
result = False
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_staff_without_role_defaults_to_false(self):
|
||||
"""Staff without a role defaults to no permissions"""
|
||||
mock_user = Mock()
|
||||
mock_user.role = 'TENANT_STAFF'
|
||||
mock_user.permissions = {}
|
||||
mock_user.staff_role = None
|
||||
|
||||
permission_key = 'can_access_scheduler'
|
||||
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
|
||||
result = True
|
||||
elif mock_user.role == 'TENANT_STAFF':
|
||||
if mock_user.permissions and permission_key in mock_user.permissions:
|
||||
result = mock_user.permissions[permission_key]
|
||||
elif mock_user.staff_role and mock_user.staff_role.permissions:
|
||||
result = mock_user.staff_role.permissions.get(permission_key, False)
|
||||
else:
|
||||
result = False
|
||||
else:
|
||||
result = False
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_customer_never_has_permission(self):
|
||||
"""Customers don't have staff permissions"""
|
||||
mock_user = Mock()
|
||||
mock_user.role = 'CUSTOMER'
|
||||
mock_user.permissions = {'can_access_scheduler': True} # Even if set
|
||||
|
||||
permission_key = 'can_access_scheduler'
|
||||
if mock_user.role in ['TENANT_OWNER', 'TENANT_MANAGER']:
|
||||
result = True
|
||||
elif mock_user.role == 'TENANT_STAFF':
|
||||
if mock_user.permissions and permission_key in mock_user.permissions:
|
||||
result = mock_user.permissions[permission_key]
|
||||
elif mock_user.staff_role and mock_user.staff_role.permissions:
|
||||
result = mock_user.staff_role.permissions.get(permission_key, False)
|
||||
else:
|
||||
result = False
|
||||
else:
|
||||
result = False
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestPermissionHelperFunction:
|
||||
"""Unit tests for _staff_has_permission_override function"""
|
||||
|
||||
def test_unauthenticated_user_returns_false(self):
|
||||
"""Unauthenticated users have no permissions"""
|
||||
from smoothschedule.identity.core.mixins import _staff_has_permission_override
|
||||
|
||||
mock_user = Mock()
|
||||
mock_user.is_authenticated = False
|
||||
|
||||
result = _staff_has_permission_override(mock_user, 'can_access_scheduler')
|
||||
assert result is False
|
||||
|
||||
def test_user_override_takes_priority(self):
|
||||
"""User-level override is checked first"""
|
||||
from smoothschedule.identity.core.mixins import _staff_has_permission_override
|
||||
|
||||
mock_user = Mock()
|
||||
mock_user.is_authenticated = True
|
||||
mock_user.permissions = {'can_access_scheduler': True}
|
||||
mock_user.staff_role = Mock()
|
||||
mock_user.staff_role.permissions = {'can_access_scheduler': False}
|
||||
|
||||
result = _staff_has_permission_override(mock_user, 'can_access_scheduler')
|
||||
assert result is True
|
||||
|
||||
def test_user_override_false_takes_priority(self):
|
||||
"""User-level override of False takes priority over role True"""
|
||||
from smoothschedule.identity.core.mixins import _staff_has_permission_override
|
||||
|
||||
mock_user = Mock()
|
||||
mock_user.is_authenticated = True
|
||||
mock_user.permissions = {'can_access_scheduler': False}
|
||||
mock_user.staff_role = Mock()
|
||||
mock_user.staff_role.permissions = {'can_access_scheduler': True}
|
||||
|
||||
result = _staff_has_permission_override(mock_user, 'can_access_scheduler')
|
||||
assert result is False
|
||||
|
||||
def test_role_permission_used_when_no_user_override(self):
|
||||
"""Role permission used when user.permissions doesn't have the key"""
|
||||
from smoothschedule.identity.core.mixins import _staff_has_permission_override
|
||||
|
||||
mock_user = Mock()
|
||||
mock_user.is_authenticated = True
|
||||
mock_user.permissions = {} # No override for this key
|
||||
mock_user.staff_role = Mock()
|
||||
mock_user.staff_role.permissions = {'can_access_scheduler': True}
|
||||
|
||||
result = _staff_has_permission_override(mock_user, 'can_access_scheduler')
|
||||
assert result is True
|
||||
|
||||
def test_no_role_no_override_returns_false(self):
|
||||
"""No role and no override returns False"""
|
||||
from smoothschedule.identity.core.mixins import _staff_has_permission_override
|
||||
|
||||
mock_user = Mock()
|
||||
mock_user.is_authenticated = True
|
||||
mock_user.permissions = {}
|
||||
mock_user.staff_role = None
|
||||
|
||||
result = _staff_has_permission_override(mock_user, 'can_access_scheduler')
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestStaffRoleSerializer:
|
||||
"""Unit tests for StaffRoleSerializer"""
|
||||
|
||||
def test_serializer_validates_permissions_type(self):
|
||||
"""Permissions must be a dictionary"""
|
||||
from smoothschedule.scheduling.schedule.serializers import StaffRoleSerializer
|
||||
|
||||
serializer = StaffRoleSerializer(data={
|
||||
'name': 'Test Role',
|
||||
'permissions': 'not a dict',
|
||||
})
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert 'permissions' in serializer.errors
|
||||
|
||||
def test_serializer_validates_permission_values_are_boolean(self):
|
||||
"""Permission values must be booleans"""
|
||||
from smoothschedule.scheduling.schedule.serializers import StaffRoleSerializer
|
||||
|
||||
serializer = StaffRoleSerializer(data={
|
||||
'name': 'Test Role',
|
||||
'permissions': {'can_access_scheduler': 'yes'},
|
||||
})
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert 'permissions' in serializer.errors
|
||||
|
||||
def test_serializer_accepts_valid_permissions(self):
|
||||
"""Valid permissions dict is accepted"""
|
||||
from smoothschedule.scheduling.schedule.serializers import StaffRoleSerializer
|
||||
|
||||
serializer = StaffRoleSerializer(data={
|
||||
'name': 'Test Role',
|
||||
'description': 'A test role',
|
||||
'permissions': {
|
||||
'can_access_scheduler': True,
|
||||
'can_access_customers': False,
|
||||
},
|
||||
})
|
||||
|
||||
# Note: is_valid() may fail due to missing tenant context,
|
||||
# but permissions validation should pass
|
||||
serializer.is_valid()
|
||||
# No permission errors means validation passed
|
||||
assert 'permissions' not in serializer.errors
|
||||
|
||||
|
||||
class TestDefaultRoles:
|
||||
"""Unit tests for default role configurations"""
|
||||
|
||||
def test_default_roles_exist(self):
|
||||
"""Default roles are defined"""
|
||||
from smoothschedule.identity.users.staff_permissions import DEFAULT_ROLES
|
||||
|
||||
assert 'Full Access Staff' in DEFAULT_ROLES
|
||||
assert 'Front Desk' in DEFAULT_ROLES
|
||||
assert 'Limited Staff' in DEFAULT_ROLES
|
||||
|
||||
def test_full_access_has_all_permissions(self):
|
||||
"""Full Access Staff has all permissions enabled"""
|
||||
from smoothschedule.identity.users.staff_permissions import DEFAULT_ROLES, ALL_PERMISSIONS
|
||||
|
||||
full_access = DEFAULT_ROLES['Full Access Staff']
|
||||
permissions = full_access['permissions']
|
||||
|
||||
for key in ALL_PERMISSIONS.keys():
|
||||
assert key in permissions, f"Missing permission: {key}"
|
||||
assert permissions[key] is True, f"Permission not enabled: {key}"
|
||||
|
||||
def test_limited_staff_has_basic_permissions(self):
|
||||
"""Limited Staff has only basic permissions"""
|
||||
from smoothschedule.identity.users.staff_permissions import DEFAULT_ROLES
|
||||
|
||||
limited = DEFAULT_ROLES['Limited Staff']
|
||||
permissions = limited['permissions']
|
||||
|
||||
# Should have basic permissions
|
||||
assert permissions.get('can_access_dashboard') is True
|
||||
assert permissions.get('can_access_my_schedule') is True
|
||||
assert permissions.get('can_access_my_availability') is True
|
||||
|
||||
# Should not have dangerous permissions
|
||||
assert permissions.get('can_delete_customers') is not True
|
||||
assert permissions.get('can_access_scheduler') is not True
|
||||
|
||||
def test_all_permissions_have_required_fields(self):
|
||||
"""All permission definitions have required fields"""
|
||||
from smoothschedule.identity.users.staff_permissions import ALL_PERMISSIONS
|
||||
|
||||
for key, config in ALL_PERMISSIONS.items():
|
||||
assert 'label' in config, f"Missing label for {key}"
|
||||
assert 'description' in config, f"Missing description for {key}"
|
||||
assert 'default' in config, f"Missing default for {key}"
|
||||
assert isinstance(config['default'], bool), f"Default must be bool for {key}"
|
||||
@@ -5,10 +5,10 @@ Handles email notifications, reminders, PDF generation, and expiration.
|
||||
import logging
|
||||
from celery import shared_task
|
||||
from django.utils import timezone
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.conf import settings
|
||||
from datetime import timedelta
|
||||
from smoothschedule.communication.messaging.email_service import send_html_email
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -64,7 +64,7 @@ def send_contract_email(self, contract_id):
|
||||
plain_message = render_to_string('contracts/emails/signing_request.txt', context)
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
send_html_email(
|
||||
subject=subject,
|
||||
message=plain_message,
|
||||
from_email=from_email,
|
||||
@@ -149,7 +149,7 @@ def send_contract_reminder(self, contract_id):
|
||||
plain_message = render_to_string('contracts/emails/reminder.txt', context)
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
send_html_email(
|
||||
subject=subject,
|
||||
message=plain_message,
|
||||
from_email=from_email,
|
||||
@@ -222,7 +222,7 @@ def send_contract_signed_emails(self, contract_id):
|
||||
html_message = render_to_string('contracts/emails/signed_customer.html', context)
|
||||
plain_message = render_to_string('contracts/emails/signed_customer.txt', context)
|
||||
|
||||
send_mail(
|
||||
send_html_email(
|
||||
subject=subject,
|
||||
message=plain_message,
|
||||
from_email=from_email,
|
||||
@@ -248,7 +248,7 @@ def send_contract_signed_emails(self, contract_id):
|
||||
html_message = render_to_string('contracts/emails/signed_business.html', context)
|
||||
plain_message = render_to_string('contracts/emails/signed_business.txt', context)
|
||||
|
||||
send_mail(
|
||||
send_html_email(
|
||||
subject=subject,
|
||||
message=plain_message,
|
||||
from_email=from_email,
|
||||
|
||||
@@ -0,0 +1,702 @@
|
||||
"""
|
||||
Daily Demo Tenant Reseed Command
|
||||
|
||||
Creates/reseeds a salon/spa themed demo tenant for sales demonstrations.
|
||||
Designed to run daily at midnight UTC via Celery beat to keep appointments fresh.
|
||||
|
||||
Features:
|
||||
- Salon/Spa themed business data (stylists, services, rooms)
|
||||
- Pro subscription with all features enabled
|
||||
- Email blocking enabled (no real emails sent)
|
||||
- Appointments spanning 2 weeks past to 3 weeks future
|
||||
- Sample automations installed
|
||||
|
||||
Usage:
|
||||
python manage.py reseed_demo
|
||||
python manage.py reseed_demo --quiet # Less output
|
||||
python manage.py reseed_demo --appointments 150 # More appointments
|
||||
"""
|
||||
import random
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
from django.utils import timezone
|
||||
from django_tenants.utils import schema_context, tenant_context
|
||||
|
||||
from smoothschedule.identity.core.models import Tenant, Domain
|
||||
from smoothschedule.identity.users.models import User, StaffRole
|
||||
from smoothschedule.scheduling.schedule.models import (
|
||||
Event,
|
||||
Participant,
|
||||
Resource,
|
||||
ResourceType,
|
||||
Service,
|
||||
ScheduledTask,
|
||||
PluginTemplate,
|
||||
PluginInstallation,
|
||||
GlobalEventPlugin,
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Reseed demo tenant with fresh salon/spa data for sales demonstrations"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--quiet",
|
||||
action="store_true",
|
||||
help="Reduce output verbosity",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--appointments",
|
||||
type=int,
|
||||
default=100,
|
||||
help="Number of appointments to create (default: 100)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.quiet = options.get("quiet", False)
|
||||
self.appointment_count = options.get("appointments", 100)
|
||||
|
||||
if not self.quiet:
|
||||
self.stdout.write("\n" + "=" * 70)
|
||||
self.stdout.write(self.style.SUCCESS(" SERENITY SALON & SPA - DEMO RESEED"))
|
||||
self.stdout.write("=" * 70 + "\n")
|
||||
|
||||
# Step 1: Get or create demo tenant
|
||||
demo_tenant = self.setup_tenant()
|
||||
|
||||
# Step 2: Assign Pro subscription
|
||||
self.assign_pro_subscription(demo_tenant)
|
||||
|
||||
# Step 3: Switch to tenant schema for tenant-specific data
|
||||
with tenant_context(demo_tenant):
|
||||
# Clear existing appointments
|
||||
self.clear_appointments()
|
||||
|
||||
# Create or update tenant users
|
||||
tenant_users = self.create_tenant_users(demo_tenant)
|
||||
|
||||
# Create or update resource types
|
||||
resource_types = self.create_resource_types()
|
||||
|
||||
# Create or update services
|
||||
services = self.create_services()
|
||||
|
||||
# Create or update resources
|
||||
resources = self.create_resources(tenant_users, resource_types)
|
||||
|
||||
# Create or update customers
|
||||
customers = self.create_customers(demo_tenant)
|
||||
|
||||
# Create fresh appointments
|
||||
self.create_appointments(
|
||||
resources=resources,
|
||||
services=services,
|
||||
customers=customers,
|
||||
)
|
||||
|
||||
# Setup automations
|
||||
self.setup_automations(tenant_users)
|
||||
|
||||
# Assign staff roles
|
||||
self.assign_staff_roles(tenant_users)
|
||||
|
||||
if not self.quiet:
|
||||
self.stdout.write("\n" + "=" * 70)
|
||||
self.stdout.write(self.style.SUCCESS(" DEMO RESEED COMPLETE!"))
|
||||
self.stdout.write("=" * 70)
|
||||
self.stdout.write("\nAccess URL: http://demo.lvh.me:5173")
|
||||
self.stdout.write("All passwords: test123\n")
|
||||
|
||||
def setup_tenant(self):
|
||||
"""Get or create demo tenant with proper settings."""
|
||||
if not self.quiet:
|
||||
self.stdout.write("\n[1/9] Setting up Demo Tenant...")
|
||||
|
||||
# Get or create the demo tenant
|
||||
# Note: Don't set branding colors yet - requires Pro subscription
|
||||
try:
|
||||
tenant = Tenant.objects.get(schema_name="demo")
|
||||
# Update basic settings (not branding - that requires Pro)
|
||||
tenant.name = "Serenity Salon & Spa"
|
||||
tenant.timezone = "America/New_York"
|
||||
tenant.block_emails = True
|
||||
tenant.initial_setup_complete = True
|
||||
tenant.save()
|
||||
if not self.quiet:
|
||||
self.stdout.write(f" {self.style.WARNING('UPDATED')} Demo tenant settings")
|
||||
except Tenant.DoesNotExist:
|
||||
tenant = Tenant.objects.create(
|
||||
schema_name="demo",
|
||||
name="Serenity Salon & Spa",
|
||||
timezone="America/New_York",
|
||||
block_emails=True,
|
||||
initial_setup_complete=True,
|
||||
)
|
||||
if not self.quiet:
|
||||
self.stdout.write(f" {self.style.SUCCESS('CREATED')} Demo tenant")
|
||||
|
||||
# Create domain
|
||||
domain, created = Domain.objects.get_or_create(
|
||||
domain="demo.lvh.me",
|
||||
defaults={"tenant": tenant, "is_primary": True},
|
||||
)
|
||||
if created and not self.quiet:
|
||||
self.stdout.write(f" {self.style.SUCCESS('CREATED')} Domain: demo.lvh.me")
|
||||
|
||||
return tenant
|
||||
|
||||
def assign_pro_subscription(self, tenant):
|
||||
"""Assign Pro subscription to demo tenant."""
|
||||
if not self.quiet:
|
||||
self.stdout.write("\n[2/9] Assigning Pro Subscription...")
|
||||
|
||||
subscription_created = False
|
||||
try:
|
||||
from smoothschedule.billing.models import Subscription, Plan, PlanVersion
|
||||
|
||||
# Get Pro plan
|
||||
try:
|
||||
pro_plan = Plan.objects.get(code='pro')
|
||||
pro_version = pro_plan.versions.filter(is_public=True).order_by('-created_at').first()
|
||||
|
||||
if pro_version:
|
||||
subscription, created = Subscription.objects.update_or_create(
|
||||
business=tenant,
|
||||
defaults={
|
||||
'plan_version': pro_version,
|
||||
'status': 'active',
|
||||
'current_period_start': timezone.now(),
|
||||
'current_period_end': timezone.now() + timedelta(days=365),
|
||||
}
|
||||
)
|
||||
subscription_created = True
|
||||
status_str = self.style.SUCCESS('CREATED') if created else self.style.WARNING('UPDATED')
|
||||
if not self.quiet:
|
||||
self.stdout.write(f" {status_str} Pro subscription")
|
||||
else:
|
||||
if not self.quiet:
|
||||
self.stdout.write(f" {self.style.WARNING('SKIPPED')} No Pro plan version found")
|
||||
except Plan.DoesNotExist:
|
||||
if not self.quiet:
|
||||
self.stdout.write(f" {self.style.WARNING('SKIPPED')} Pro plan not found - run billing_seed_catalog first")
|
||||
except ImportError:
|
||||
if not self.quiet:
|
||||
self.stdout.write(f" {self.style.WARNING('SKIPPED')} Billing models not available")
|
||||
|
||||
# Now that Pro subscription is assigned, we can set branding colors
|
||||
# (requires white_label feature from Pro plan)
|
||||
# Use direct update to bypass the save() permission check
|
||||
# (The save check was designed for API updates, not management commands)
|
||||
Tenant.objects.filter(pk=tenant.pk).update(
|
||||
primary_color="#ec4899", # Pink
|
||||
secondary_color="#f472b6", # Light pink
|
||||
)
|
||||
tenant.refresh_from_db()
|
||||
if not self.quiet:
|
||||
self.stdout.write(f" {self.style.SUCCESS('SET')} Branding colors (Pink theme)")
|
||||
|
||||
def create_tenant_users(self, tenant):
|
||||
"""Create owner, manager, and staff users."""
|
||||
if not self.quiet:
|
||||
self.stdout.write("\n[3/9] Creating Users...")
|
||||
|
||||
users = {}
|
||||
|
||||
# Owner
|
||||
owner_data = {
|
||||
"username": "owner@demo.com",
|
||||
"email": "owner@demo.com",
|
||||
"first_name": "Victoria",
|
||||
"last_name": "Stone",
|
||||
"role": User.Role.TENANT_OWNER,
|
||||
"tenant": tenant,
|
||||
"phone": "555-100-0001",
|
||||
}
|
||||
owner, created = User.objects.get_or_create(
|
||||
username=owner_data["username"],
|
||||
defaults=owner_data,
|
||||
)
|
||||
if created:
|
||||
owner.set_password("test123")
|
||||
owner.save()
|
||||
users["owner"] = owner
|
||||
if not self.quiet:
|
||||
status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
|
||||
self.stdout.write(f" {status} {owner.email} (Owner)")
|
||||
|
||||
# Manager
|
||||
manager_data = {
|
||||
"username": "manager@demo.com",
|
||||
"email": "manager@demo.com",
|
||||
"first_name": "Marcus",
|
||||
"last_name": "Chen",
|
||||
"role": User.Role.TENANT_MANAGER,
|
||||
"tenant": tenant,
|
||||
"phone": "555-100-0002",
|
||||
}
|
||||
manager, created = User.objects.get_or_create(
|
||||
username=manager_data["username"],
|
||||
defaults=manager_data,
|
||||
)
|
||||
if created:
|
||||
manager.set_password("test123")
|
||||
manager.save()
|
||||
users["manager"] = manager
|
||||
if not self.quiet:
|
||||
status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
|
||||
self.stdout.write(f" {status} {manager.email} (Manager)")
|
||||
|
||||
# Staff members (stylists and spa therapists)
|
||||
staff_data = [
|
||||
{"first_name": "Sophia", "last_name": "Martinez", "title": "Senior Stylist"},
|
||||
{"first_name": "Emma", "last_name": "Johnson", "title": "Stylist"},
|
||||
{"first_name": "Olivia", "last_name": "Chen", "title": "Junior Stylist"},
|
||||
{"first_name": "Isabella", "last_name": "Kim", "title": "Spa Therapist"},
|
||||
{"first_name": "Mia", "last_name": "Taylor", "title": "Esthetician"},
|
||||
]
|
||||
|
||||
staff_users = []
|
||||
for staff in staff_data:
|
||||
email = f"{staff['first_name'].lower()}.{staff['last_name'].lower()}@demo.com"
|
||||
user_data = {
|
||||
"username": email,
|
||||
"email": email,
|
||||
"first_name": staff["first_name"],
|
||||
"last_name": staff["last_name"],
|
||||
"role": User.Role.TENANT_STAFF,
|
||||
"tenant": tenant,
|
||||
"job_title": staff["title"],
|
||||
}
|
||||
user, created = User.objects.get_or_create(
|
||||
username=email,
|
||||
defaults=user_data,
|
||||
)
|
||||
if created:
|
||||
user.set_password("test123")
|
||||
user.save()
|
||||
staff_users.append(user)
|
||||
if not self.quiet:
|
||||
status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
|
||||
self.stdout.write(f" {status} {user.email} ({staff['title']})")
|
||||
|
||||
users["staff"] = staff_users
|
||||
return users
|
||||
|
||||
def create_resource_types(self):
|
||||
"""Create resource types for salon/spa."""
|
||||
if not self.quiet:
|
||||
self.stdout.write("\n[4/9] Creating Resource Types...")
|
||||
|
||||
types_data = [
|
||||
{"name": "Stylist", "category": ResourceType.Category.STAFF, "description": "Hair stylists", "is_default": True},
|
||||
{"name": "Spa Therapist", "category": ResourceType.Category.STAFF, "description": "Massage and spa specialists", "is_default": False},
|
||||
{"name": "Station", "category": ResourceType.Category.OTHER, "description": "Hair styling stations", "is_default": False},
|
||||
{"name": "Spa Room", "category": ResourceType.Category.OTHER, "description": "Private spa treatment rooms", "is_default": False},
|
||||
{"name": "Equipment", "category": ResourceType.Category.OTHER, "description": "Shared equipment", "is_default": False},
|
||||
]
|
||||
|
||||
resource_types = {}
|
||||
for rt_data in types_data:
|
||||
rt, created = ResourceType.objects.get_or_create(
|
||||
name=rt_data["name"],
|
||||
defaults=rt_data,
|
||||
)
|
||||
resource_types[rt_data["name"]] = rt
|
||||
if not self.quiet:
|
||||
status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
|
||||
self.stdout.write(f" {status} {rt.name}")
|
||||
|
||||
return resource_types
|
||||
|
||||
def create_services(self):
|
||||
"""Create salon/spa services."""
|
||||
if not self.quiet:
|
||||
self.stdout.write("\n[5/9] Creating Services...")
|
||||
|
||||
services_data = [
|
||||
# Hair services
|
||||
{"name": "Haircut & Style", "duration": 45, "price_cents": 6500, "description": "Precision cut with styling"},
|
||||
{"name": "Hair Color - Full", "duration": 120, "price_cents": 15000, "description": "Full head color transformation"},
|
||||
{"name": "Hair Color - Touch-up", "duration": 60, "price_cents": 8500, "description": "Root touch-up and refresh"},
|
||||
{"name": "Blowout", "duration": 30, "price_cents": 4500, "description": "Professional blow dry and styling"},
|
||||
{"name": "Deep Conditioning", "duration": 30, "price_cents": 3500, "description": "Intensive hair treatment"},
|
||||
# Spa services
|
||||
{"name": "Swedish Massage", "duration": 60, "price_cents": 9500, "description": "Relaxing full body massage"},
|
||||
{"name": "Hot Stone Massage", "duration": 90, "price_cents": 12500, "description": "Therapeutic hot stone treatment"},
|
||||
{"name": "Facial - Classic", "duration": 60, "price_cents": 8500, "description": "Deep cleansing facial"},
|
||||
{"name": "Facial - Anti-Aging", "duration": 75, "price_cents": 11000, "description": "Advanced anti-aging treatment"},
|
||||
# Nail services
|
||||
{"name": "Manicure", "duration": 30, "price_cents": 3000, "description": "Classic nail care"},
|
||||
{"name": "Pedicure", "duration": 45, "price_cents": 5000, "description": "Relaxing foot treatment"},
|
||||
{"name": "Mani-Pedi Combo", "duration": 75, "price_cents": 7500, "description": "Complete hand and foot care"},
|
||||
# Premium
|
||||
{"name": "Bridal Package", "duration": 180, "price_cents": 35000, "description": "Complete bridal preparation", "variable_pricing": True, "deposit_amount_cents": 10000},
|
||||
]
|
||||
|
||||
services = []
|
||||
for i, svc_data in enumerate(services_data, 1):
|
||||
svc_data["display_order"] = i
|
||||
name = svc_data.pop("name")
|
||||
service, created = Service.objects.get_or_create(
|
||||
name=name,
|
||||
defaults=svc_data,
|
||||
)
|
||||
services.append(service)
|
||||
if not self.quiet:
|
||||
price = (svc_data.get("price_cents", 0)) / 100
|
||||
status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
|
||||
self.stdout.write(f" {status} {name} ({svc_data.get('duration', 0)} min, ${price:.2f})")
|
||||
|
||||
return services
|
||||
|
||||
def create_resources(self, tenant_users, resource_types):
|
||||
"""Create staff-linked and standalone resources."""
|
||||
if not self.quiet:
|
||||
self.stdout.write("\n[6/9] Creating Resources...")
|
||||
|
||||
resources = []
|
||||
stylist_type = resource_types.get("Stylist")
|
||||
spa_type = resource_types.get("Spa Therapist")
|
||||
station_type = resource_types.get("Station")
|
||||
spa_room_type = resource_types.get("Spa Room")
|
||||
equipment_type = resource_types.get("Equipment")
|
||||
|
||||
# Staff-linked resources (stylists and spa therapists)
|
||||
staff_users = tenant_users.get("staff", [])
|
||||
for user in staff_users:
|
||||
is_spa = "Spa" in (user.job_title or "") or "Esthetician" in (user.job_title or "")
|
||||
resource_type = spa_type if is_spa else stylist_type
|
||||
|
||||
resource, created = Resource.objects.get_or_create(
|
||||
user=user,
|
||||
defaults={
|
||||
"name": user.get_full_name(),
|
||||
"description": user.job_title or "Staff member",
|
||||
"resource_type": resource_type,
|
||||
"type": Resource.Type.STAFF,
|
||||
"max_concurrent_events": 1,
|
||||
"user_can_edit_schedule": True,
|
||||
},
|
||||
)
|
||||
resources.append(resource)
|
||||
if not self.quiet:
|
||||
status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
|
||||
self.stdout.write(f" {status} {resource.name} (Staff)")
|
||||
|
||||
# Standalone resources - Hair Stations
|
||||
for i in range(1, 4):
|
||||
resource, created = Resource.objects.get_or_create(
|
||||
name=f"Hair Station {i}",
|
||||
defaults={
|
||||
"description": f"Hair styling station #{i}",
|
||||
"resource_type": station_type,
|
||||
"type": Resource.Type.ROOM,
|
||||
"max_concurrent_events": 1,
|
||||
},
|
||||
)
|
||||
resources.append(resource)
|
||||
if not self.quiet:
|
||||
status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
|
||||
self.stdout.write(f" {status} {resource.name} (Station)")
|
||||
|
||||
# Spa Rooms
|
||||
for letter in ["A", "B"]:
|
||||
resource, created = Resource.objects.get_or_create(
|
||||
name=f"Spa Room {letter}",
|
||||
defaults={
|
||||
"description": f"Private spa treatment room {letter}",
|
||||
"resource_type": spa_room_type,
|
||||
"type": Resource.Type.ROOM,
|
||||
"max_concurrent_events": 1,
|
||||
},
|
||||
)
|
||||
resources.append(resource)
|
||||
if not self.quiet:
|
||||
status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
|
||||
self.stdout.write(f" {status} {resource.name} (Spa Room)")
|
||||
|
||||
# Relaxation Lounge (multi-capacity)
|
||||
resource, created = Resource.objects.get_or_create(
|
||||
name="Relaxation Lounge",
|
||||
defaults={
|
||||
"description": "Shared relaxation area",
|
||||
"resource_type": spa_room_type,
|
||||
"type": Resource.Type.ROOM,
|
||||
"max_concurrent_events": 5,
|
||||
},
|
||||
)
|
||||
resources.append(resource)
|
||||
if not self.quiet:
|
||||
status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
|
||||
self.stdout.write(f" {status} {resource.name} (Lounge)")
|
||||
|
||||
# Equipment
|
||||
resource, created = Resource.objects.get_or_create(
|
||||
name="Massage Chair",
|
||||
defaults={
|
||||
"description": "Portable massage chair",
|
||||
"resource_type": equipment_type,
|
||||
"type": Resource.Type.EQUIPMENT,
|
||||
"max_concurrent_events": 1,
|
||||
},
|
||||
)
|
||||
resources.append(resource)
|
||||
if not self.quiet:
|
||||
status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
|
||||
self.stdout.write(f" {status} {resource.name} (Equipment)")
|
||||
|
||||
return resources
|
||||
|
||||
def create_customers(self, tenant):
|
||||
"""Create customer users."""
|
||||
if not self.quiet:
|
||||
self.stdout.write("\n[7/9] Creating Customers...")
|
||||
|
||||
# Quick login customer
|
||||
customer_demo, created = User.objects.get_or_create(
|
||||
username="customer@demo.com",
|
||||
defaults={
|
||||
"email": "customer@demo.com",
|
||||
"first_name": "Demo",
|
||||
"last_name": "Customer",
|
||||
"role": User.Role.CUSTOMER,
|
||||
"tenant": tenant,
|
||||
"phone": "555-200-0001",
|
||||
},
|
||||
)
|
||||
if created:
|
||||
customer_demo.set_password("test123")
|
||||
customer_demo.save()
|
||||
if not self.quiet:
|
||||
status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
|
||||
self.stdout.write(f" {status} {customer_demo.email} (Quick Login)")
|
||||
|
||||
customers = [customer_demo]
|
||||
|
||||
# Additional customers with salon-appropriate names
|
||||
customer_data = [
|
||||
("Jennifer", "Anderson", "jennifer.anderson@example.com"),
|
||||
("Michelle", "Brooks", "michelle.brooks@example.com"),
|
||||
("Amanda", "Clark", "amanda.clark@example.com"),
|
||||
("Stephanie", "Davis", "stephanie.davis@example.com"),
|
||||
("Nicole", "Evans", "nicole.evans@example.com"),
|
||||
("Rachel", "Foster", "rachel.foster@example.com"),
|
||||
("Lauren", "Garcia", "lauren.garcia@example.com"),
|
||||
("Heather", "Hill", "heather.hill@example.com"),
|
||||
("Kimberly", "Jackson", "kimberly.jackson@example.com"),
|
||||
("Ashley", "King", "ashley.king@example.com"),
|
||||
("Brittany", "Lewis", "brittany.lewis@example.com"),
|
||||
("Tiffany", "Martin", "tiffany.martin@example.com"),
|
||||
("Samantha", "Nelson", "samantha.nelson@example.com"),
|
||||
("Christina", "Owens", "christina.owens@example.com"),
|
||||
("Jessica", "Parker", "jessica.parker@example.com"),
|
||||
("Elizabeth", "Quinn", "elizabeth.quinn@example.com"),
|
||||
("Megan", "Roberts", "megan.roberts@example.com"),
|
||||
("Sarah", "Smith", "sarah.smith@example.com"),
|
||||
("Amber", "Thompson", "amber.thompson@example.com"),
|
||||
]
|
||||
|
||||
for first_name, last_name, email in customer_data:
|
||||
user, created = User.objects.get_or_create(
|
||||
username=email,
|
||||
defaults={
|
||||
"email": email,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
"role": User.Role.CUSTOMER,
|
||||
"tenant": tenant,
|
||||
},
|
||||
)
|
||||
if created:
|
||||
user.set_password("test123")
|
||||
user.save()
|
||||
customers.append(user)
|
||||
|
||||
if not self.quiet:
|
||||
self.stdout.write(f" {self.style.SUCCESS('READY')} {len(customers)} customers total")
|
||||
|
||||
return customers
|
||||
|
||||
def clear_appointments(self):
|
||||
"""Clear existing appointments to prepare for fresh data."""
|
||||
if not self.quiet:
|
||||
self.stdout.write("\n Clearing existing appointments...")
|
||||
|
||||
deleted_participants = Participant.objects.all().delete()[0]
|
||||
deleted_events = Event.objects.all().delete()[0]
|
||||
|
||||
if not self.quiet:
|
||||
self.stdout.write(f" Deleted {deleted_events} events, {deleted_participants} participants")
|
||||
|
||||
def create_appointments(self, resources, services, customers):
|
||||
"""Create fresh appointments spanning past and future dates."""
|
||||
if not self.quiet:
|
||||
self.stdout.write(f"\n[8/9] Creating {self.appointment_count} Appointments...")
|
||||
|
||||
# Filter to staff resources only for appointments
|
||||
staff_resources = [r for r in resources if r.type == Resource.Type.STAFF]
|
||||
if not staff_resources:
|
||||
staff_resources = resources[:3]
|
||||
|
||||
resource_ct = ContentType.objects.get_for_model(Resource)
|
||||
user_ct = ContentType.objects.get_for_model(User)
|
||||
|
||||
# Time range: 2 weeks ago to 3 weeks ahead
|
||||
now = timezone.now()
|
||||
start_date = now - timedelta(days=14)
|
||||
end_date = now + timedelta(days=21)
|
||||
days_range = (end_date - start_date).days
|
||||
|
||||
# Status weights: 60% scheduled, 25% completed, 10% canceled, 5% no-show
|
||||
statuses = (
|
||||
[Event.Status.SCHEDULED] * 60 +
|
||||
[Event.Status.COMPLETED] * 25 +
|
||||
[Event.Status.CANCELED] * 10 +
|
||||
[Event.Status.NOSHOW] * 5
|
||||
)
|
||||
|
||||
created_count = 0
|
||||
for _ in range(self.appointment_count):
|
||||
# Random date in range
|
||||
random_day = random.randint(0, days_range - 1)
|
||||
appointment_date = start_date + timedelta(days=random_day)
|
||||
|
||||
# Business hours: 9 AM - 7 PM
|
||||
hour = random.randint(9, 18)
|
||||
minute = random.choice([0, 15, 30, 45])
|
||||
start_time = appointment_date.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
|
||||
# Pick random service, resource, customer
|
||||
service = random.choice(services)
|
||||
resource = random.choice(staff_resources)
|
||||
customer = random.choice(customers)
|
||||
|
||||
# Determine status based on time
|
||||
chosen_status = random.choice(statuses)
|
||||
if start_time < now and chosen_status == Event.Status.SCHEDULED:
|
||||
chosen_status = Event.Status.COMPLETED
|
||||
elif start_time > now and chosen_status in [Event.Status.COMPLETED, Event.Status.NOSHOW]:
|
||||
chosen_status = Event.Status.SCHEDULED
|
||||
|
||||
# Calculate end time
|
||||
end_time = start_time + timedelta(minutes=service.duration)
|
||||
|
||||
# Create event
|
||||
event = Event.objects.create(
|
||||
title=f"{customer.get_full_name() or customer.email} - {service.name}",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
status=chosen_status,
|
||||
service=service,
|
||||
notes=f"Service: {service.name}",
|
||||
)
|
||||
|
||||
# Create resource participant
|
||||
Participant.objects.create(
|
||||
event=event,
|
||||
role=Participant.Role.RESOURCE,
|
||||
content_type=resource_ct,
|
||||
object_id=resource.id,
|
||||
)
|
||||
|
||||
# Create customer participant
|
||||
Participant.objects.create(
|
||||
event=event,
|
||||
role=Participant.Role.CUSTOMER,
|
||||
content_type=user_ct,
|
||||
object_id=customer.id,
|
||||
)
|
||||
|
||||
created_count += 1
|
||||
|
||||
if not self.quiet:
|
||||
self.stdout.write(f" {self.style.SUCCESS('CREATED')} {created_count} appointments")
|
||||
|
||||
# Summary
|
||||
scheduled = Event.objects.filter(status=Event.Status.SCHEDULED).count()
|
||||
completed = Event.objects.filter(status=Event.Status.COMPLETED).count()
|
||||
canceled = Event.objects.filter(status=Event.Status.CANCELED).count()
|
||||
noshow = Event.objects.filter(status=Event.Status.NOSHOW).count()
|
||||
|
||||
self.stdout.write(f" Scheduled: {scheduled}")
|
||||
self.stdout.write(f" Completed: {completed}")
|
||||
self.stdout.write(f" Canceled: {canceled}")
|
||||
self.stdout.write(f" No-show: {noshow}")
|
||||
|
||||
def setup_automations(self, tenant_users):
|
||||
"""Setup sample automations and scheduled tasks."""
|
||||
if not self.quiet:
|
||||
self.stdout.write("\n[9/9] Setting up Automations...")
|
||||
|
||||
owner = tenant_users.get("owner")
|
||||
|
||||
try:
|
||||
# Create scheduled tasks for demo (if the automation system is available)
|
||||
# Daily Report Task
|
||||
task, created = ScheduledTask.objects.get_or_create(
|
||||
name="Daily Business Report",
|
||||
defaults={
|
||||
"description": "Send daily summary to owner",
|
||||
"plugin_name": "daily_report",
|
||||
"plugin_config": {"recipients": ["owner@demo.com"], "include_upcoming": True},
|
||||
"schedule_type": ScheduledTask.ScheduleType.CRON,
|
||||
"cron_expression": "0 8 * * *",
|
||||
"status": ScheduledTask.Status.ACTIVE,
|
||||
},
|
||||
)
|
||||
if not self.quiet:
|
||||
status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
|
||||
self.stdout.write(f" {status} Daily Report task")
|
||||
|
||||
# Weekly Cleanup Task
|
||||
task, created = ScheduledTask.objects.get_or_create(
|
||||
name="Weekly Cleanup",
|
||||
defaults={
|
||||
"description": "Clean up old completed appointments",
|
||||
"plugin_name": "cleanup_old_events",
|
||||
"plugin_config": {"days_old": 90, "dry_run": False},
|
||||
"schedule_type": ScheduledTask.ScheduleType.CRON,
|
||||
"cron_expression": "0 2 * * 0",
|
||||
"status": ScheduledTask.Status.ACTIVE,
|
||||
},
|
||||
)
|
||||
if not self.quiet:
|
||||
status = self.style.SUCCESS("CREATED") if created else self.style.WARNING("EXISTS")
|
||||
self.stdout.write(f" {status} Weekly Cleanup task")
|
||||
|
||||
except Exception as e:
|
||||
if not self.quiet:
|
||||
self.stdout.write(f" {self.style.WARNING('SKIPPED')} Automations setup: {e}")
|
||||
|
||||
def assign_staff_roles(self, tenant_users):
|
||||
"""Assign staff roles to demo staff members."""
|
||||
staff_users = tenant_users.get("staff", [])
|
||||
|
||||
# Role assignments: first gets Full Access, some get Front Desk, rest get Limited
|
||||
role_assignments = {
|
||||
0: "Full Access Staff", # Sophia
|
||||
1: "Front Desk", # Emma
|
||||
2: "Limited Staff", # Olivia
|
||||
3: "Front Desk", # Isabella
|
||||
4: "Limited Staff", # Mia
|
||||
}
|
||||
|
||||
for i, user in enumerate(staff_users):
|
||||
role_name = role_assignments.get(i, "Limited Staff")
|
||||
try:
|
||||
# Get tenant from user
|
||||
if user.tenant:
|
||||
role = StaffRole.objects.filter(
|
||||
tenant=user.tenant,
|
||||
name=role_name
|
||||
).first()
|
||||
if role:
|
||||
user.staff_role = role
|
||||
user.save(update_fields=["staff_role"])
|
||||
except Exception:
|
||||
pass # Staff roles may not be set up
|
||||
@@ -0,0 +1,54 @@
|
||||
# Generated migration for demo reseed periodic task
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def create_demo_reseed_task(apps, schema_editor):
|
||||
"""Create the periodic task for daily demo reseed."""
|
||||
try:
|
||||
CrontabSchedule = apps.get_model('django_celery_beat', 'CrontabSchedule')
|
||||
PeriodicTask = apps.get_model('django_celery_beat', 'PeriodicTask')
|
||||
|
||||
# Create crontab schedule for midnight UTC
|
||||
schedule, _ = CrontabSchedule.objects.get_or_create(
|
||||
minute='0',
|
||||
hour='0',
|
||||
day_of_week='*',
|
||||
day_of_month='*',
|
||||
month_of_year='*',
|
||||
defaults={'timezone': 'UTC'}
|
||||
)
|
||||
|
||||
# Create periodic task
|
||||
PeriodicTask.objects.update_or_create(
|
||||
name='reseed-demo-tenant-daily',
|
||||
defaults={
|
||||
'task': 'smoothschedule.scheduling.schedule.tasks.reseed_demo_tenant',
|
||||
'crontab': schedule,
|
||||
'enabled': True,
|
||||
'description': 'Daily reseed of demo tenant for sales demonstrations',
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
# django_celery_beat may not be installed/migrated yet
|
||||
pass
|
||||
|
||||
|
||||
def remove_demo_reseed_task(apps, schema_editor):
|
||||
"""Remove the periodic task."""
|
||||
try:
|
||||
PeriodicTask = apps.get_model('django_celery_beat', 'PeriodicTask')
|
||||
PeriodicTask.objects.filter(name='reseed-demo-tenant-daily').delete()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('schedule', '0039_remove_emailtemplate'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_demo_reseed_task, remove_demo_reseed_task),
|
||||
]
|
||||
@@ -6,7 +6,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from .models import Resource, Event, Participant, Service, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin, Holiday, TimeBlock, Location, Album, MediaFile
|
||||
from .services import AvailabilityService
|
||||
from smoothschedule.identity.users.models import User
|
||||
from smoothschedule.identity.users.models import User, StaffRole
|
||||
from smoothschedule.identity.core.mixins import TimezoneSerializerMixin
|
||||
|
||||
|
||||
@@ -36,6 +36,55 @@ class ResourceTypeSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
|
||||
|
||||
class StaffRoleSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for StaffRole model.
|
||||
|
||||
Provides CRUD operations for tenant-scoped staff roles with
|
||||
permission management.
|
||||
"""
|
||||
staff_count = serializers.IntegerField(read_only=True)
|
||||
can_delete = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = StaffRole
|
||||
fields = [
|
||||
'id', 'name', 'description', 'permissions', 'is_default',
|
||||
'staff_count', 'can_delete', 'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['id', 'is_default', 'staff_count', 'can_delete', 'created_at', 'updated_at']
|
||||
|
||||
def get_can_delete(self, obj):
|
||||
"""Check if this role can be deleted"""
|
||||
return obj.can_delete()
|
||||
|
||||
def validate_name(self, value):
|
||||
"""Ensure role name is unique within tenant"""
|
||||
request = self.context.get('request')
|
||||
tenant = getattr(request, 'tenant', None) if request else None
|
||||
|
||||
if not tenant:
|
||||
return value
|
||||
|
||||
existing = StaffRole.objects.filter(tenant=tenant, name=value)
|
||||
if self.instance:
|
||||
existing = existing.exclude(pk=self.instance.pk)
|
||||
if existing.exists():
|
||||
raise serializers.ValidationError("A role with this name already exists.")
|
||||
return value
|
||||
|
||||
def validate_permissions(self, value):
|
||||
"""Validate that permissions is a dict with boolean values"""
|
||||
if not isinstance(value, dict):
|
||||
raise serializers.ValidationError("Permissions must be a dictionary.")
|
||||
for key, val in value.items():
|
||||
if not isinstance(key, str):
|
||||
raise serializers.ValidationError("Permission keys must be strings.")
|
||||
if not isinstance(val, bool):
|
||||
raise serializers.ValidationError(f"Permission '{key}' must be a boolean value.")
|
||||
return value
|
||||
|
||||
|
||||
class LocationSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for Location model.
|
||||
@@ -185,13 +234,24 @@ class StaffSerializer(serializers.ModelSerializer):
|
||||
role = serializers.SerializerMethodField()
|
||||
can_invite_staff = serializers.SerializerMethodField()
|
||||
|
||||
# Staff role fields
|
||||
staff_role_id = serializers.PrimaryKeyRelatedField(
|
||||
source='staff_role',
|
||||
queryset=StaffRole.objects.all(),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
staff_role_name = serializers.CharField(source='staff_role.name', read_only=True)
|
||||
effective_permissions = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'id', 'username', 'name', 'email', 'phone', 'role',
|
||||
'is_active', 'permissions', 'can_invite_staff',
|
||||
'staff_role_id', 'staff_role_name', 'effective_permissions',
|
||||
]
|
||||
read_only_fields = ['id', 'username', 'email', 'role', 'can_invite_staff']
|
||||
read_only_fields = ['id', 'username', 'email', 'role', 'can_invite_staff', 'effective_permissions']
|
||||
|
||||
def get_name(self, obj):
|
||||
return obj.full_name
|
||||
@@ -208,6 +268,23 @@ class StaffSerializer(serializers.ModelSerializer):
|
||||
def get_can_invite_staff(self, obj):
|
||||
return obj.can_invite_staff()
|
||||
|
||||
def get_effective_permissions(self, obj):
|
||||
"""Get merged permissions (role + user overrides)"""
|
||||
return obj.get_effective_permissions()
|
||||
|
||||
def validate_staff_role_id(self, value):
|
||||
"""Validate that the staff role belongs to the same tenant"""
|
||||
if value is None:
|
||||
return value
|
||||
|
||||
request = self.context.get('request')
|
||||
tenant = getattr(request, 'tenant', None) if request else None
|
||||
|
||||
if tenant and value.tenant_id != tenant.id:
|
||||
raise serializers.ValidationError("Staff role must belong to the same business.")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class ServiceSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Service model"""
|
||||
|
||||
@@ -388,3 +388,22 @@ def cancel_event_tasks(event_id: int):
|
||||
|
||||
logger.info(f"Cancelled {cancelled_count} Celery tasks for event {event_id}")
|
||||
return cancelled_count
|
||||
|
||||
|
||||
@shared_task
|
||||
def reseed_demo_tenant():
|
||||
"""
|
||||
Daily reseed of demo tenant for sales demonstrations.
|
||||
|
||||
Runs at midnight UTC via Celery beat to keep demo appointments fresh.
|
||||
"""
|
||||
from django.core.management import call_command
|
||||
|
||||
logger.info("Starting daily demo tenant reseed...")
|
||||
try:
|
||||
call_command('reseed_demo', '--quiet')
|
||||
logger.info("Demo tenant reseed completed successfully")
|
||||
return {'success': True}
|
||||
except Exception as e:
|
||||
logger.error(f"Demo tenant reseed failed: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
@@ -12,12 +12,14 @@ from .views import (
|
||||
ScheduledTaskViewSet, TaskExecutionLogViewSet,
|
||||
HolidayViewSet, TimeBlockViewSet, LocationViewSet,
|
||||
AlbumViewSet, MediaFileViewSet, StorageUsageView,
|
||||
StaffRoleViewSet,
|
||||
)
|
||||
from .export_views import ExportViewSet
|
||||
|
||||
# Create router and register viewsets
|
||||
router = DefaultRouter()
|
||||
router.register(r'resource-types', ResourceTypeViewSet, basename='resourcetype')
|
||||
router.register(r'staff-roles', StaffRoleViewSet, basename='staffrole')
|
||||
router.register(r'resources', ResourceViewSet, basename='resource')
|
||||
router.register(r'appointments', EventViewSet, basename='appointment') # Alias for frontend
|
||||
router.register(r'events', EventViewSet, basename='event')
|
||||
|
||||
@@ -20,7 +20,7 @@ from .serializers import (
|
||||
EventPluginSerializer, GlobalEventPluginSerializer,
|
||||
HolidaySerializer, HolidayListSerializer,
|
||||
TimeBlockSerializer, TimeBlockListSerializer, BlockedDateSerializer, CheckConflictsSerializer,
|
||||
LocationSerializer,
|
||||
LocationSerializer, StaffRoleSerializer,
|
||||
)
|
||||
from .services import LocationService
|
||||
from .models import Service
|
||||
@@ -35,7 +35,7 @@ from smoothschedule.identity.core.mixins import (
|
||||
AutomationFeatureRequiredMixin,
|
||||
TaskFeatureRequiredMixin,
|
||||
)
|
||||
from smoothschedule.identity.users.models import User
|
||||
from smoothschedule.identity.users.models import User, StaffRole
|
||||
|
||||
|
||||
class ResourceTypeViewSet(viewsets.ModelViewSet):
|
||||
@@ -80,6 +80,92 @@ class ResourceTypeViewSet(viewsets.ModelViewSet):
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
class StaffRoleViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for managing Staff Roles.
|
||||
|
||||
Permissions:
|
||||
- Must be authenticated
|
||||
- Only owners/managers can access (staff denied via DenyStaffAllAccessPermission)
|
||||
|
||||
Functionality:
|
||||
- GET /staff-roles/ - List all roles for tenant
|
||||
- POST /staff-roles/ - Create new role
|
||||
- GET /staff-roles/{id}/ - Get role details
|
||||
- PATCH /staff-roles/{id}/ - Update role
|
||||
- DELETE /staff-roles/{id}/ - Delete role (if no staff assigned and not default)
|
||||
- GET /staff-roles/available_permissions/ - Get all available permission keys
|
||||
|
||||
NOTE: StaffRole is in the users app (SHARED_APP) with a tenant FK for scoping.
|
||||
We must explicitly filter by tenant since it doesn't use schema isolation.
|
||||
"""
|
||||
queryset = StaffRole.objects.all()
|
||||
serializer_class = StaffRoleSerializer
|
||||
permission_classes = [IsAuthenticated, DenyStaffAllAccessPermission]
|
||||
ordering = ['-is_default', 'name']
|
||||
|
||||
def filter_queryset_for_tenant(self, queryset):
|
||||
"""
|
||||
Filter StaffRole by tenant FK since it's in a shared schema app.
|
||||
"""
|
||||
request_tenant = getattr(self.request, 'tenant', None)
|
||||
if request_tenant:
|
||||
queryset = queryset.filter(tenant=request_tenant)
|
||||
else:
|
||||
# No tenant on request - return empty for safety
|
||||
return queryset.none()
|
||||
return queryset
|
||||
|
||||
def get_queryset(self):
|
||||
"""Filter by tenant and annotate with staff count"""
|
||||
queryset = super().get_queryset()
|
||||
from django.db.models import Count
|
||||
return queryset.annotate(staff_count=Count('staff_members'))
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set tenant on create"""
|
||||
tenant = getattr(self.request, 'tenant', None)
|
||||
if not tenant:
|
||||
raise PermissionDenied("Tenant context required to create staff roles.")
|
||||
serializer.save(tenant=tenant)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Override destroy to add validation"""
|
||||
instance = self.get_object()
|
||||
|
||||
# Check if default
|
||||
if instance.is_default:
|
||||
return Response(
|
||||
{'error': 'Cannot delete default roles.'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Check if staff assigned
|
||||
staff_count = instance.staff_members.count()
|
||||
if staff_count > 0:
|
||||
return Response(
|
||||
{'error': f'Cannot delete role "{instance.name}" because {staff_count} staff member(s) are assigned to it.'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def available_permissions(self, request):
|
||||
"""
|
||||
Return all available permission keys with their metadata.
|
||||
|
||||
This endpoint provides the frontend with the full list of permission
|
||||
keys that can be configured on a staff role.
|
||||
"""
|
||||
from smoothschedule.identity.users.staff_permissions import MENU_PERMISSIONS, DANGEROUS_PERMISSIONS
|
||||
|
||||
return Response({
|
||||
'menu_permissions': MENU_PERMISSIONS,
|
||||
'dangerous_permissions': DANGEROUS_PERMISSIONS,
|
||||
})
|
||||
|
||||
|
||||
class ResourceViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for managing Resources.
|
||||
|
||||
Reference in New Issue
Block a user