= ({ onMasquerade, effectiveUser }) => {
{user.role}
+ |
+ {user.role === 'staff' ? (
+ user.staff_role_name ? (
+
+ {user.staff_role_name}
+
+ ) : (
+
+ {t('staff.noRoleAssigned')}
+
+ )
+ ) : (
+ —
+ )}
+ |
{linkedResource ? (
@@ -533,6 +567,30 @@ const Staff: React.FC = ({ onMasquerade, effectiveUser }) => {
+ {/* Staff Role Selector (only for staff invitations) */}
+ {inviteRole === 'TENANT_STAFF' && staffRoles.length > 0 && (
+
+
+
+
+ {t('staff.staffRoleSelectHint')}
+
+
+ )}
+
{/* Permissions - Using shared component */}
{inviteRole === 'TENANT_MANAGER' && (
= ({ onMasquerade, effectiveUser }) => {
+ {/* Staff Role Selector (only for staff users) */}
+ {editingStaff.role === 'staff' && staffRoles.length > 0 && (
+
+
+
+
+ {t('staff.staffRoleSelectHint')}
+
+
+ )}
+
{/* Permissions - Using shared component */}
{editingStaff.role === 'manager' && (
{
+ 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(null);
+ const [formData, setFormData] = useState({
+ name: '',
+ description: '',
+ permissions: {} as Record,
+ });
+ const [error, setError] = useState(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 = {};
+ 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) => {
+ return Object.values(permissions).filter(Boolean).length;
+ };
+
+ if (!canManageRoles) {
+ return (
+
+
+
+ {t('settings.staffRoles.noAccess', 'Only the business owner or manager can access these settings.')}
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ {t('settings.staffRoles.title', 'Staff Roles')}
+
+
+ {t('settings.staffRoles.subtitle', 'Create roles to control what staff members can access in your business.')}
+
+
+
+ {/* Roles List */}
+
+
+
+
+ {t('settings.staffRoles.yourRoles', 'Your Staff Roles')}
+
+
+ {t('settings.staffRoles.rolesDescription', 'Assign staff members to roles to control their permissions.')}
+
+
+
+
+
+ {isLoading ? (
+
+ ) : staffRoles.length === 0 ? (
+
+
+ {t('settings.staffRoles.noRoles', 'No staff roles configured.')}
+ {t('settings.staffRoles.createFirst', 'Create your first role to manage staff permissions.')}
+
+ ) : (
+
+ {staffRoles.map((role) => (
+
+
+
+
+
+
+
+
+ {role.name}
+ {role.is_default && (
+
+ {t('common.default', 'Default')}
+
+ )}
+
+
+
+
+ {t('settings.staffRoles.staffAssigned', '{{count}} staff', { count: role.staff_count })}
+
+
+
+ {t('settings.staffRoles.permissionsEnabled', '{{count}} permissions', {
+ count: countEnabledPermissions(role.permissions),
+ })}
+
+
+ {role.description && (
+
+ {role.description}
+
+ )}
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ {/* Create/Edit Modal */}
+ {isModalOpen && (
+
+
+
+
+ {editingRole
+ ? t('settings.staffRoles.editRole', 'Edit Role')
+ : t('settings.staffRoles.createRole', 'Create Role')}
+
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default StaffRolesSettings;
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index fe045cd3..b4e73534 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -135,6 +135,34 @@ export interface User {
linked_resource_name?: string;
permissions?: Record;
quota_overages?: QuotaOverage[];
+ // Staff role fields
+ staff_role_id?: number | null;
+ staff_role_name?: string | null;
+ effective_permissions?: Record;
+}
+
+// Staff Role Types
+export interface StaffRole {
+ id: number;
+ name: string;
+ description: string;
+ permissions: Record;
+ 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;
+ dangerous_permissions: Record;
}
export type ResourceType = 'STAFF' | 'ROOM' | 'EQUIPMENT';
diff --git a/smoothschedule/smoothschedule/communication/messaging/email_service.py b/smoothschedule/smoothschedule/communication/messaging/email_service.py
index 50a33662..a4d17692 100644
--- a/smoothschedule/smoothschedule/communication/messaging/email_service.py
+++ b/smoothschedule/smoothschedule/communication/messaging/email_service.py
@@ -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,
+ )
diff --git a/smoothschedule/smoothschedule/identity/core/middleware.py b/smoothschedule/smoothschedule/identity/core/middleware.py
index 1a6c4fa8..0bbcafd0 100644
--- a/smoothschedule/smoothschedule/identity/core/middleware.py
+++ b/smoothschedule/smoothschedule/identity/core/middleware.py
@@ -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()
diff --git a/smoothschedule/smoothschedule/identity/core/migrations/0029_tenant_block_emails.py b/smoothschedule/smoothschedule/identity/core/migrations/0029_tenant_block_emails.py
new file mode 100644
index 00000000..7da3a9aa
--- /dev/null
+++ b/smoothschedule/smoothschedule/identity/core/migrations/0029_tenant_block_emails.py
@@ -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)'
+ ),
+ ),
+ ]
diff --git a/smoothschedule/smoothschedule/identity/core/mixins.py b/smoothschedule/smoothschedule/identity/core/mixins.py
index 3325cc1a..150410ba 100644
--- a/smoothschedule/smoothschedule/identity/core/mixins.py
+++ b/smoothschedule/smoothschedule/identity/core/mixins.py
@@ -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):
diff --git a/smoothschedule/smoothschedule/identity/core/models.py b/smoothschedule/smoothschedule/identity/core/models.py
index 8b60c2ee..4e77f7f6 100644
--- a/smoothschedule/smoothschedule/identity/core/models.py
+++ b/smoothschedule/smoothschedule/identity/core/models.py
@@ -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
diff --git a/smoothschedule/smoothschedule/identity/users/api_views.py b/smoothschedule/smoothschedule/identity/users/api_views.py
index 76c49c5d..b3054dbe 100644
--- a/smoothschedule/smoothschedule/identity/users/api_views.py
+++ b/smoothschedule/smoothschedule/identity/users/api_views.py
@@ -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',
diff --git a/smoothschedule/smoothschedule/identity/users/migrations/0011_add_staff_role_model.py b/smoothschedule/smoothschedule/identity/users/migrations/0011_add_staff_role_model.py
new file mode 100644
index 00000000..aed29fc5
--- /dev/null
+++ b/smoothschedule/smoothschedule/identity/users/migrations/0011_add_staff_role_model.py
@@ -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')},
+ ),
+ ]
diff --git a/smoothschedule/smoothschedule/identity/users/migrations/0012_create_default_staff_roles.py b/smoothschedule/smoothschedule/identity/users/migrations/0012_create_default_staff_roles.py
new file mode 100644
index 00000000..723b2208
--- /dev/null
+++ b/smoothschedule/smoothschedule/identity/users/migrations/0012_create_default_staff_roles.py
@@ -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,
+ ),
+ ]
diff --git a/smoothschedule/smoothschedule/identity/users/models.py b/smoothschedule/smoothschedule/identity/users/models.py
index 434c4819..d702db37 100644
--- a/smoothschedule/smoothschedule/identity/users/models.py
+++ b/smoothschedule/smoothschedule/identity/users/models.py
@@ -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']
diff --git a/smoothschedule/smoothschedule/identity/users/staff_permissions.py b/smoothschedule/smoothschedule/identity/users/staff_permissions.py
new file mode 100644
index 00000000..7c19ee7b
--- /dev/null
+++ b/smoothschedule/smoothschedule/identity/users/staff_permissions.py
@@ -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,
+ },
+ },
+}
diff --git a/smoothschedule/smoothschedule/identity/users/tests/test_staff_roles.py b/smoothschedule/smoothschedule/identity/users/tests/test_staff_roles.py
new file mode 100644
index 00000000..a5e69b2d
--- /dev/null
+++ b/smoothschedule/smoothschedule/identity/users/tests/test_staff_roles.py
@@ -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}"
diff --git a/smoothschedule/smoothschedule/scheduling/contracts/tasks.py b/smoothschedule/smoothschedule/scheduling/contracts/tasks.py
index 097184f4..4ae7b8c0 100644
--- a/smoothschedule/smoothschedule/scheduling/contracts/tasks.py
+++ b/smoothschedule/smoothschedule/scheduling/contracts/tasks.py
@@ -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,
diff --git a/smoothschedule/smoothschedule/scheduling/schedule/management/commands/reseed_demo.py b/smoothschedule/smoothschedule/scheduling/schedule/management/commands/reseed_demo.py
new file mode 100644
index 00000000..c8af09c1
--- /dev/null
+++ b/smoothschedule/smoothschedule/scheduling/schedule/management/commands/reseed_demo.py
@@ -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
diff --git a/smoothschedule/smoothschedule/scheduling/schedule/migrations/0040_add_demo_reseed_periodic_task.py b/smoothschedule/smoothschedule/scheduling/schedule/migrations/0040_add_demo_reseed_periodic_task.py
new file mode 100644
index 00000000..30a6baf5
--- /dev/null
+++ b/smoothschedule/smoothschedule/scheduling/schedule/migrations/0040_add_demo_reseed_periodic_task.py
@@ -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),
+ ]
diff --git a/smoothschedule/smoothschedule/scheduling/schedule/serializers.py b/smoothschedule/smoothschedule/scheduling/schedule/serializers.py
index 919ebe23..fb02418f 100644
--- a/smoothschedule/smoothschedule/scheduling/schedule/serializers.py
+++ b/smoothschedule/smoothschedule/scheduling/schedule/serializers.py
@@ -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"""
diff --git a/smoothschedule/smoothschedule/scheduling/schedule/tasks.py b/smoothschedule/smoothschedule/scheduling/schedule/tasks.py
index 8c0aa33f..93b7c4f6 100644
--- a/smoothschedule/smoothschedule/scheduling/schedule/tasks.py
+++ b/smoothschedule/smoothschedule/scheduling/schedule/tasks.py
@@ -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)}
diff --git a/smoothschedule/smoothschedule/scheduling/schedule/urls.py b/smoothschedule/smoothschedule/scheduling/schedule/urls.py
index d3ae9184..ef0677d2 100644
--- a/smoothschedule/smoothschedule/scheduling/schedule/urls.py
+++ b/smoothschedule/smoothschedule/scheduling/schedule/urls.py
@@ -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')
diff --git a/smoothschedule/smoothschedule/scheduling/schedule/views.py b/smoothschedule/smoothschedule/scheduling/schedule/views.py
index 4181313a..d138bfc7 100644
--- a/smoothschedule/smoothschedule/scheduling/schedule/views.py
+++ b/smoothschedule/smoothschedule/scheduling/schedule/views.py
@@ -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.
|