3 Commits

Author SHA1 Message Date
poduck
ba2c656243 perf: Optimize slow tests with shared tenant fixtures
- Add session-scoped shared_tenant and second_shared_tenant fixtures to conftest.py
- Refactor test_models.py and test_user_model.py to use shared fixtures
- Avoid ~40s migration overhead per tenant by reusing fixtures across tests
- Add pytest-xdist to dev dependencies for future parallel test execution

Previously 4 tests each created their own tenant (~40s each = ~160s total).
Now they share session-scoped tenants, reducing overhead significantly.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 02:22:43 -05:00
poduck
485f86086b feat: Unified FeaturesPermissionsEditor component for plan and business permissions
- Create reusable FeaturesPermissionsEditor component with support for both
  subscription plan editing and individual business permission overrides
- Add can_use_contracts field to Tenant model for per-business contracts toggle
- Update PlatformSettings.tsx to use unified component for plan permissions
- Update BusinessEditModal.tsx to use unified component for business permissions
- Update PlatformBusinessUpdate API interface with all permission fields
- Add contracts permission mapping to tenant sync task

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 01:37:04 -05:00
poduck
2f6ea82114 fix: Update djstripe signal imports and fix test mocking
- Use correct WEBHOOK_SIGNALS dict access for payment intent signals
- Simplify webhook tests by removing complex djstripe module mocking
- Fix TimezoneSerializerMixin tests to expect dynamic field addition
- Update TenantViewSet tests to mock exclude() chain for public schema

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 00:24:37 -05:00
17 changed files with 775 additions and 602 deletions

View File

@@ -41,11 +41,38 @@ export interface PlatformBusinessUpdate {
subscription_tier?: string;
max_users?: number;
max_resources?: number;
// Platform permissions
can_manage_oauth_credentials?: boolean;
can_accept_payments?: boolean;
can_use_custom_domain?: boolean;
can_white_label?: boolean;
can_api_access?: boolean;
// Feature permissions
can_add_video_conferencing?: boolean;
can_connect_to_api?: boolean;
can_book_repeated_events?: boolean;
can_require_2fa?: boolean;
can_download_logs?: boolean;
can_delete_data?: boolean;
can_use_sms_reminders?: boolean;
can_use_masked_phone_numbers?: boolean;
can_use_pos?: boolean;
can_use_mobile_app?: boolean;
can_export_data?: boolean;
can_use_plugins?: boolean;
can_use_tasks?: boolean;
can_create_plugins?: boolean;
can_use_webhooks?: boolean;
can_use_calendar_sync?: boolean;
can_use_contracts?: boolean;
can_process_refunds?: boolean;
can_create_packages?: boolean;
can_use_email_templates?: boolean;
can_customize_booking_page?: boolean;
advanced_reporting?: boolean;
priority_support?: boolean;
dedicated_support?: boolean;
sso_enabled?: boolean;
}
export interface PlatformBusinessCreate {

View File

@@ -0,0 +1,531 @@
/**
* FeaturesPermissionsEditor
*
* A unified component for editing features and permissions.
* Used by both subscription plan editing (PlatformSettings) and
* individual business editing (BusinessEditModal).
*
* Supports two modes:
* - 'plan': For editing subscription plan permissions (uses plan-style keys)
* - 'business': For editing individual business permissions (uses tenant-style keys)
*/
import React from 'react';
import { Key } from 'lucide-react';
/**
* Permission definition with metadata
*/
interface PermissionDefinition {
key: string;
planKey?: string; // Key used in subscription plan permissions JSON
businessKey?: string; // Key used in tenant/business model fields
label: string;
description?: string;
category: PermissionCategory;
dependsOn?: string; // Key of permission this depends on
}
type PermissionCategory =
| 'payments'
| 'communication'
| 'customization'
| 'plugins'
| 'advanced'
| 'enterprise'
| 'scheduling';
/**
* All available permissions with their mappings
*/
export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
// Payments & Revenue
{
key: 'can_accept_payments',
planKey: 'can_accept_payments',
businessKey: 'can_accept_payments',
label: 'Online Payments',
description: 'Accept payments via Stripe Connect',
category: 'payments',
},
{
key: 'can_process_refunds',
planKey: 'can_process_refunds',
businessKey: 'can_process_refunds',
label: 'Process Refunds',
description: 'Issue refunds for payments',
category: 'payments',
},
{
key: 'can_create_packages',
planKey: 'can_create_packages',
businessKey: 'can_create_packages',
label: 'Service Packages',
description: 'Create and sell service packages',
category: 'payments',
},
{
key: 'can_use_pos',
planKey: 'can_use_pos',
businessKey: 'can_use_pos',
label: 'POS System',
description: 'Point of sale for in-person payments',
category: 'payments',
},
// Communication
{
key: 'sms_reminders',
planKey: 'sms_reminders',
businessKey: 'can_use_sms_reminders',
label: 'SMS Reminders',
description: 'Send SMS appointment reminders',
category: 'communication',
},
{
key: 'masked_calling',
planKey: 'can_use_masked_phone_numbers',
businessKey: 'can_use_masked_phone_numbers',
label: 'Masked Calling',
description: 'Use masked phone numbers for privacy',
category: 'communication',
},
{
key: 'email_templates',
planKey: 'can_use_email_templates',
businessKey: 'can_use_email_templates',
label: 'Email Templates',
description: 'Custom email templates for communications',
category: 'communication',
},
// Customization
{
key: 'custom_booking_page',
planKey: 'can_customize_booking_page',
businessKey: 'can_customize_booking_page',
label: 'Custom Booking Page',
description: 'Customize the public booking page',
category: 'customization',
},
{
key: 'custom_domain',
planKey: 'can_use_custom_domain',
businessKey: 'can_use_custom_domain',
label: 'Custom Domains',
description: 'Use your own domain for booking',
category: 'customization',
},
{
key: 'white_label',
planKey: 'can_white_label',
businessKey: 'can_white_label',
label: 'White Labelling',
description: 'Remove SmoothSchedule branding',
category: 'customization',
},
// Plugins & Automation
{
key: 'plugins',
planKey: 'can_use_plugins',
businessKey: 'can_use_plugins',
label: 'Use Plugins',
description: 'Install and use marketplace plugins',
category: 'plugins',
},
{
key: 'tasks',
planKey: 'can_use_tasks',
businessKey: 'can_use_tasks',
label: 'Scheduled Tasks',
description: 'Create automated scheduled tasks',
category: 'plugins',
dependsOn: 'plugins',
},
{
key: 'create_plugins',
planKey: 'can_create_plugins',
businessKey: 'can_create_plugins',
label: 'Create Plugins',
description: 'Build custom plugins',
category: 'plugins',
dependsOn: 'plugins',
},
// Advanced Features
{
key: 'api_access',
planKey: 'can_api_access',
businessKey: 'can_api_access',
label: 'API Access',
description: 'Access REST API for integrations',
category: 'advanced',
},
{
key: 'webhooks',
planKey: 'can_use_webhooks',
businessKey: 'can_use_webhooks',
label: 'Webhooks',
description: 'Receive webhook notifications',
category: 'advanced',
},
{
key: 'calendar_sync',
planKey: 'calendar_sync',
businessKey: 'can_use_calendar_sync',
label: 'Calendar Sync',
description: 'Sync with Google Calendar, etc.',
category: 'advanced',
},
{
key: 'export_data',
planKey: 'can_export_data',
businessKey: 'can_export_data',
label: 'Data Export',
description: 'Export data to CSV/Excel',
category: 'advanced',
},
{
key: 'video_conferencing',
planKey: 'video_conferencing',
businessKey: 'can_add_video_conferencing',
label: 'Video Conferencing',
description: 'Add video links to appointments',
category: 'advanced',
},
{
key: 'advanced_reporting',
planKey: 'advanced_reporting',
businessKey: 'advanced_reporting',
label: 'Advanced Analytics',
description: 'Detailed reporting and analytics',
category: 'advanced',
},
{
key: 'contracts',
planKey: 'contracts_enabled',
businessKey: 'can_use_contracts',
label: 'Contracts',
description: 'Create and manage e-signature contracts',
category: 'advanced',
},
{
key: 'mobile_app',
planKey: 'can_use_mobile_app',
businessKey: 'can_use_mobile_app',
label: 'Mobile App',
description: 'Access via mobile application',
category: 'advanced',
},
// Enterprise & Security
{
key: 'manage_oauth',
planKey: 'can_manage_oauth_credentials',
businessKey: 'can_manage_oauth_credentials',
label: 'Manage OAuth',
description: 'Configure custom OAuth credentials',
category: 'enterprise',
},
{
key: 'require_2fa',
planKey: 'can_require_2fa',
businessKey: 'can_require_2fa',
label: 'Require 2FA',
description: 'Enforce two-factor authentication',
category: 'enterprise',
},
{
key: 'sso_enabled',
planKey: 'sso_enabled',
businessKey: 'sso_enabled',
label: 'SSO / SAML',
description: 'Single sign-on integration',
category: 'enterprise',
},
{
key: 'priority_support',
planKey: 'priority_support',
businessKey: 'priority_support',
label: 'Priority Support',
description: 'Faster response times',
category: 'enterprise',
},
{
key: 'dedicated_support',
planKey: 'dedicated_support',
businessKey: 'dedicated_support',
label: 'Dedicated Support',
description: 'Dedicated account manager',
category: 'enterprise',
},
// Scheduling
{
key: 'repeated_events',
planKey: 'can_book_repeated_events',
businessKey: 'can_book_repeated_events',
label: 'Recurring Events',
description: 'Schedule recurring appointments',
category: 'scheduling',
},
];
/**
* Category metadata for display
*/
const CATEGORY_META: Record<PermissionCategory, { label: string; order: number }> = {
payments: { label: 'Payments & Revenue', order: 1 },
communication: { label: 'Communication', order: 2 },
customization: { label: 'Customization', order: 3 },
plugins: { label: 'Plugins & Automation', order: 4 },
advanced: { label: 'Advanced Features', order: 5 },
scheduling: { label: 'Scheduling', order: 6 },
enterprise: { label: 'Enterprise & Security', order: 7 },
};
export type EditorMode = 'plan' | 'business';
export interface FeaturesPermissionsEditorProps {
/**
* Mode determines which keys are used and which permissions are shown
*/
mode: EditorMode;
/**
* Current permission values
* For 'plan' mode: the permissions object from subscription plan
* For 'business' mode: flat object with tenant field names
*/
values: Record<string, boolean>;
/**
* Callback when a permission changes
*/
onChange: (key: string, value: boolean) => void;
/**
* Optional: Limit which categories to show
*/
categories?: PermissionCategory[];
/**
* Optional: Limit which permissions to show by key
*/
includeOnly?: string[];
/**
* Optional: Hide specific permissions
*/
exclude?: string[];
/**
* Number of columns in the grid (default: 3)
*/
columns?: 2 | 3 | 4;
/**
* Show section header
*/
showHeader?: boolean;
/**
* Custom header title
*/
headerTitle?: string;
/**
* Show descriptions under labels
*/
showDescriptions?: boolean;
}
/**
* Get the appropriate key for a permission based on mode
*/
export function getPermissionKey(def: PermissionDefinition, mode: EditorMode): string {
if (mode === 'plan') {
return def.planKey || def.key;
}
return def.businessKey || def.key;
}
/**
* Convert permissions from one mode to another
*/
export function convertPermissions(
values: Record<string, boolean>,
fromMode: EditorMode,
toMode: EditorMode
): Record<string, boolean> {
const result: Record<string, boolean> = {};
for (const def of PERMISSION_DEFINITIONS) {
const fromKey = getPermissionKey(def, fromMode);
const toKey = getPermissionKey(def, toMode);
if (fromKey in values) {
result[toKey] = values[fromKey];
}
}
return result;
}
/**
* Get permission value from values object
*/
function getPermissionValue(
values: Record<string, boolean>,
def: PermissionDefinition,
mode: EditorMode
): boolean {
const key = getPermissionKey(def, mode);
return values[key] ?? false;
}
/**
* Check if a dependent permission should be disabled
*/
function isDependencyDisabled(
values: Record<string, boolean>,
def: PermissionDefinition,
mode: EditorMode
): boolean {
if (!def.dependsOn) return false;
const parentDef = PERMISSION_DEFINITIONS.find(d => d.key === def.dependsOn);
if (!parentDef) return false;
return !getPermissionValue(values, parentDef, mode);
}
const FeaturesPermissionsEditor: React.FC<FeaturesPermissionsEditorProps> = ({
mode,
values,
onChange,
categories,
includeOnly,
exclude = [],
columns = 3,
showHeader = true,
headerTitle = 'Features & Permissions',
showDescriptions = false,
}) => {
// Filter permissions based on props
const filteredPermissions = PERMISSION_DEFINITIONS.filter(def => {
if (exclude.includes(def.key)) return false;
if (includeOnly && !includeOnly.includes(def.key)) return false;
if (categories && !categories.includes(def.category)) return false;
return true;
});
// Group by category
const groupedPermissions = filteredPermissions.reduce((acc, def) => {
if (!acc[def.category]) {
acc[def.category] = [];
}
acc[def.category].push(def);
return acc;
}, {} as Record<PermissionCategory, PermissionDefinition[]>);
// Sort categories by order
const sortedCategories = Object.keys(groupedPermissions).sort(
(a, b) => CATEGORY_META[a as PermissionCategory].order - CATEGORY_META[b as PermissionCategory].order
) as PermissionCategory[];
const handleChange = (def: PermissionDefinition, checked: boolean) => {
const key = getPermissionKey(def, mode);
onChange(key, checked);
// If disabling a parent permission, also disable dependents
if (!checked) {
const dependents = PERMISSION_DEFINITIONS.filter(d => d.dependsOn === def.key);
for (const dep of dependents) {
const depKey = getPermissionKey(dep, mode);
if (values[depKey]) {
onChange(depKey, false);
}
}
}
};
const gridCols = {
2: 'grid-cols-2',
3: 'grid-cols-3',
4: 'grid-cols-4',
};
return (
<div className="space-y-4">
{showHeader && (
<>
<h3 className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
<Key size={16} className="text-purple-500" />
{headerTitle}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
Control which features are available.
</p>
</>
)}
{sortedCategories.map(category => (
<div key={category}>
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
{CATEGORY_META[category].label}
</h4>
<div className={`grid ${gridCols[columns]} gap-3`}>
{groupedPermissions[category].map(def => {
const isChecked = getPermissionValue(values, def, mode);
const isDisabled = isDependencyDisabled(values, def, mode);
const key = getPermissionKey(def, mode);
return (
<label
key={def.key}
className={`flex items-start gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg ${
isDisabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer'
}`}
>
<input
type="checkbox"
checked={isChecked}
onChange={(e) => handleChange(def, e.target.checked)}
disabled={isDisabled}
className="mt-0.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 disabled:opacity-50"
/>
<div className="flex-1 min-w-0">
<span className="text-sm text-gray-700 dark:text-gray-300 block">
{def.label}
</span>
{showDescriptions && def.description && (
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
{def.description}
</span>
)}
</div>
</label>
);
})}
</div>
{/* Show dependency hint for plugins category */}
{category === 'plugins' && !getPermissionValue(
values,
PERMISSION_DEFINITIONS.find(d => d.key === 'plugins')!,
mode
) && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
Enable "Use Plugins" to allow Scheduled Tasks and Create Plugins
</p>
)}
</div>
))}
</div>
);
};
export default FeaturesPermissionsEditor;

View File

@@ -46,6 +46,7 @@ import {
useUpdatePlatformOAuthSettings,
} from '../../hooks/usePlatformOAuth';
import { Link } from 'react-router-dom';
import FeaturesPermissionsEditor, { getPermissionKey, PERMISSION_DEFINITIONS } from '../../components/platform/FeaturesPermissionsEditor';
type TabType = 'general' | 'stripe' | 'tiers' | 'oauth';
@@ -241,6 +242,7 @@ const GeneralSettingsTab: React.FC = () => {
};
const StripeSettingsTab: React.FC = () => {
const { t } = useTranslation();
const { data: settings, isLoading, error } = usePlatformSettings();
const updateKeysMutation = useUpdateStripeKeys();
const validateKeysMutation = useValidateStripeKeys();
@@ -1254,25 +1256,6 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
)}
</div>
{/* Contracts Feature */}
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white">Contracts</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">Allow tenants to create and manage contracts with customers</p>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.contracts_enabled || false}
onChange={(e) => setFormData((prev) => ({ ...prev, contracts_enabled: e.target.checked }))}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Enabled</span>
</label>
</div>
</div>
{/* Default Credit Settings */}
<div className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">Default Credit Settings</h4>
@@ -1422,238 +1405,25 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
</div>
</div>
{/* Permissions Configuration */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-white border-b pb-2 dark:border-gray-700">
Features & Permissions
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
Control which features are available to businesses on this plan.
</p>
{/* Payments & Revenue */}
<div>
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Payments & Revenue</h4>
<div className="grid grid-cols-3 gap-3">
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_accept_payments || false}
onChange={(e) => handlePermissionChange('can_accept_payments', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Online Payments</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_process_refunds || false}
onChange={(e) => handlePermissionChange('can_process_refunds', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Process Refunds</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_create_packages || false}
onChange={(e) => handlePermissionChange('can_create_packages', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Service Packages</span>
</label>
</div>
</div>
{/* Communication */}
<div>
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Communication</h4>
<div className="grid grid-cols-3 gap-3">
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.sms_reminders || false}
onChange={(e) => handlePermissionChange('sms_reminders', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">SMS Reminders</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_use_masked_phone_numbers || false}
onChange={(e) => handlePermissionChange('can_use_masked_phone_numbers', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Masked Calling</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_use_email_templates || false}
onChange={(e) => handlePermissionChange('can_use_email_templates', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Email Templates</span>
</label>
</div>
</div>
{/* Customization */}
<div>
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Customization</h4>
<div className="grid grid-cols-3 gap-3">
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_customize_booking_page || false}
onChange={(e) => handlePermissionChange('can_customize_booking_page', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Custom Booking Page</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_use_custom_domain || false}
onChange={(e) => handlePermissionChange('can_use_custom_domain', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Custom Domains</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_white_label || false}
onChange={(e) => handlePermissionChange('can_white_label', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">White Labelling</span>
</label>
</div>
</div>
{/* Advanced Features */}
<div>
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Advanced Features</h4>
<div className="grid grid-cols-3 gap-3">
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.advanced_reporting || false}
onChange={(e) => handlePermissionChange('advanced_reporting', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Advanced Analytics</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_api_access || false}
onChange={(e) => handlePermissionChange('can_api_access', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">API Access</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_use_plugins || false}
onChange={(e) => {
handlePermissionChange('can_use_plugins', e.target.checked);
// If disabling plugins, also disable dependent permissions
if (!e.target.checked) {
handlePermissionChange('can_use_tasks', false);
handlePermissionChange('can_create_plugins', false);
}
}}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Use Plugins</span>
</label>
<label className={`flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer ${formData.permissions?.can_use_plugins ? 'hover:bg-gray-50 dark:hover:bg-gray-700/50' : 'opacity-50 cursor-not-allowed'}`}>
<input
type="checkbox"
checked={formData.permissions?.can_use_tasks || false}
onChange={(e) => handlePermissionChange('can_use_tasks', e.target.checked)}
disabled={!formData.permissions?.can_use_plugins}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Scheduled Tasks</span>
</label>
<label className={`flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer ${formData.permissions?.can_use_plugins ? 'hover:bg-gray-50 dark:hover:bg-gray-700/50' : 'opacity-50 cursor-not-allowed'}`}>
<input
type="checkbox"
checked={formData.permissions?.can_create_plugins || false}
onChange={(e) => handlePermissionChange('can_create_plugins', e.target.checked)}
disabled={!formData.permissions?.can_use_plugins}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Create Plugins</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_export_data || false}
onChange={(e) => handlePermissionChange('can_export_data', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Data Export</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.can_use_webhooks || false}
onChange={(e) => handlePermissionChange('can_use_webhooks', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Webhooks</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.calendar_sync || false}
onChange={(e) => handlePermissionChange('calendar_sync', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Calendar Sync</span>
</label>
</div>
</div>
{/* Support & Enterprise */}
<div>
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Support & Enterprise</h4>
<div className="grid grid-cols-3 gap-3">
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.priority_support || false}
onChange={(e) => handlePermissionChange('priority_support', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Priority Support</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.dedicated_support || false}
onChange={(e) => handlePermissionChange('dedicated_support', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Dedicated Support</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={formData.permissions?.sso_enabled || false}
onChange={(e) => handlePermissionChange('sso_enabled', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">SSO / SAML</span>
</label>
</div>
</div>
{/* Permissions Configuration - Using unified FeaturesPermissionsEditor */}
<div className="space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<FeaturesPermissionsEditor
mode="plan"
values={{
...formData.permissions,
// Map contracts_enabled to the permission key used by the component
contracts_enabled: formData.contracts_enabled || false,
}}
onChange={(key, value) => {
// Handle contracts_enabled specially since it's a top-level plan field
if (key === 'contracts_enabled') {
setFormData((prev) => ({ ...prev, contracts_enabled: value }));
} else {
handlePermissionChange(key, value);
}
}}
headerTitle="Features & Permissions"
/>
</div>
{/* Display Features (List of strings) */}

View File

@@ -1,8 +1,9 @@
import React, { useState, useEffect } from 'react';
import { X, Save, Key, RefreshCw } from 'lucide-react';
import { X, Save, RefreshCw } from 'lucide-react';
import { useUpdateBusiness } from '../../../hooks/usePlatform';
import { useSubscriptionPlans } from '../../../hooks/usePlatformSettings';
import { PlatformBusiness } from '../../../api/platform';
import FeaturesPermissionsEditor, { PERMISSION_DEFINITIONS, getPermissionKey } from '../../../components/platform/FeaturesPermissionsEditor';
// Default tier settings - used when no subscription plans are loaded
const TIER_DEFAULTS: Record<string, {
@@ -92,6 +93,15 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
can_create_plugins: false,
can_use_webhooks: false,
can_use_calendar_sync: false,
can_use_contracts: false,
can_process_refunds: false,
can_create_packages: false,
can_use_email_templates: false,
can_customize_booking_page: false,
advanced_reporting: false,
priority_support: false,
dedicated_support: false,
sso_enabled: false,
});
// Get tier defaults from subscription plans or fallback to static defaults
@@ -215,6 +225,15 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
can_create_plugins: b.can_create_plugins || false,
can_use_webhooks: b.can_use_webhooks || false,
can_use_calendar_sync: b.can_use_calendar_sync || false,
can_use_contracts: b.can_use_contracts || false,
can_process_refunds: b.can_process_refunds || false,
can_create_packages: b.can_create_packages || false,
can_use_email_templates: b.can_use_email_templates || false,
can_customize_booking_page: b.can_customize_booking_page || false,
advanced_reporting: b.advanced_reporting || false,
priority_support: b.priority_support || false,
dedicated_support: b.dedicated_support || false,
sso_enabled: b.sso_enabled || false,
});
}
}, [business]);
@@ -355,207 +374,18 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
</div>
</div>
{/* Features & Permissions */}
{/* Features & Permissions - Using unified FeaturesPermissionsEditor */}
<div className="space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-2">
<Key size={16} className="text-purple-500" />
Features & Permissions
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
Control which features are available to this business.
</p>
{/* Payments & Revenue */}
<div>
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Payments & Revenue</h4>
<div className="grid grid-cols-3 gap-3">
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={editForm.can_accept_payments}
onChange={(e) => setEditForm({ ...editForm, can_accept_payments: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Online Payments</span>
</label>
</div>
</div>
{/* Communication */}
<div>
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Communication</h4>
<div className="grid grid-cols-3 gap-3">
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={editForm.can_use_sms_reminders}
onChange={(e) => setEditForm({ ...editForm, can_use_sms_reminders: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">SMS Reminders</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={editForm.can_use_masked_phone_numbers}
onChange={(e) => setEditForm({ ...editForm, can_use_masked_phone_numbers: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Masked Calling</span>
</label>
</div>
</div>
{/* Customization */}
<div>
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Customization</h4>
<div className="grid grid-cols-3 gap-3">
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={editForm.can_use_custom_domain}
onChange={(e) => setEditForm({ ...editForm, can_use_custom_domain: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Custom Domains</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={editForm.can_white_label}
onChange={(e) => setEditForm({ ...editForm, can_white_label: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">White Labelling</span>
</label>
</div>
</div>
{/* Plugins & Automation */}
<div>
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Plugins & Automation</h4>
<div className="grid grid-cols-3 gap-3">
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={editForm.can_use_plugins}
onChange={(e) => {
const checked = e.target.checked;
setEditForm(prev => ({
...prev,
can_use_plugins: checked,
// If disabling plugins, also disable tasks and create plugins
...(checked ? {} : { can_use_tasks: false, can_create_plugins: false })
}));
}}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Use Plugins</span>
</label>
<label className={`flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg ${editForm.can_use_plugins ? 'hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer' : 'opacity-50 cursor-not-allowed'}`}>
<input
type="checkbox"
checked={editForm.can_use_tasks}
onChange={(e) => setEditForm({ ...editForm, can_use_tasks: e.target.checked })}
disabled={!editForm.can_use_plugins}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 disabled:opacity-50"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Scheduled Tasks</span>
</label>
<label className={`flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg ${editForm.can_use_plugins ? 'hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer' : 'opacity-50 cursor-not-allowed'}`}>
<input
type="checkbox"
checked={editForm.can_create_plugins}
onChange={(e) => setEditForm({ ...editForm, can_create_plugins: e.target.checked })}
disabled={!editForm.can_use_plugins}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 disabled:opacity-50"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Create Plugins</span>
</label>
</div>
{!editForm.can_use_plugins && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
Enable "Use Plugins" to allow Scheduled Tasks and Create Plugins
</p>
)}
</div>
{/* Advanced Features */}
<div>
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Advanced Features</h4>
<div className="grid grid-cols-3 gap-3">
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={editForm.can_api_access}
onChange={(e) => setEditForm({ ...editForm, can_api_access: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">API Access</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={editForm.can_use_webhooks}
onChange={(e) => setEditForm({ ...editForm, can_use_webhooks: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Webhooks</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={editForm.can_use_calendar_sync}
onChange={(e) => setEditForm({ ...editForm, can_use_calendar_sync: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Calendar Sync</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={editForm.can_export_data}
onChange={(e) => setEditForm({ ...editForm, can_export_data: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Data Export</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={editForm.can_add_video_conferencing}
onChange={(e) => setEditForm({ ...editForm, can_add_video_conferencing: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Video Conferencing</span>
</label>
</div>
</div>
{/* Enterprise */}
<div>
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Enterprise</h4>
<div className="grid grid-cols-3 gap-3">
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={editForm.can_manage_oauth_credentials}
onChange={(e) => setEditForm({ ...editForm, can_manage_oauth_credentials: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Manage OAuth</span>
</label>
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
<input
type="checkbox"
checked={editForm.can_require_2fa}
onChange={(e) => setEditForm({ ...editForm, can_require_2fa: e.target.checked })}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Require 2FA</span>
</label>
</div>
</div>
<FeaturesPermissionsEditor
mode="business"
values={Object.fromEntries(
Object.entries(editForm).filter(([_, v]) => typeof v === 'boolean')
) as Record<string, boolean>}
onChange={(key, value) => {
setEditForm(prev => ({ ...prev, [key]: value }));
}}
headerTitle="Features & Permissions"
/>
</div>
</div>

View File

@@ -146,6 +146,7 @@ dev = [
"pytest==9.0.1",
"pytest-django==4.11.1",
"pytest-sugar==1.1.1",
"pytest-xdist>=3.5.0",
"ruff==0.14.6",
"sphinx==8.2.3",
"sphinx-autobuild==2025.8.25",

View File

@@ -4,38 +4,13 @@ Unit tests for Stripe webhook signal handlers.
Tests webhook signal handling logic with mocks to avoid database calls.
Follows CLAUDE.md guidelines: prefer mocks, avoid @pytest.mark.django_db.
Note: The webhooks.py module uses incorrect signal names (signals.payment_intent_succeeded
instead of signals.WEBHOOK_SIGNALS['payment_intent.succeeded']). These tests work around
this by mocking the signals module before import.
Note: The webhooks.py module uses djstripe signals. These tests mock the
handler functions' dependencies to test their logic in isolation.
"""
from unittest.mock import Mock, patch, MagicMock
import pytest
from decimal import Decimal
import sys
# Create a complete mock of djstripe.signals that matches what webhooks.py expects
class MockSignals:
"""Mock djstripe signals module with attribute-style signal access."""
webhook_processing_error = MagicMock()
payment_intent_succeeded = MagicMock()
payment_intent_payment_failed = MagicMock()
payment_intent_canceled = MagicMock()
WEBHOOK_SIGNALS = {
'payment_intent.succeeded': payment_intent_succeeded,
'payment_intent.payment_failed': payment_intent_payment_failed,
'payment_intent.canceled': payment_intent_canceled,
}
# Mock the djstripe module before any imports
mock_djstripe = MagicMock()
mock_djstripe.signals = MockSignals()
sys.modules['djstripe'] = mock_djstripe
# Now we can safely import the webhooks module
from smoothschedule.commerce.payments import webhooks
from smoothschedule.commerce.payments.models import TransactionLink

View File

@@ -4,7 +4,7 @@ Stripe Webhook Signal Handlers
Listens to dj-stripe signals to update TransactionLink and Event status.
"""
from django.dispatch import receiver
from djstripe import signals
from djstripe.signals import WEBHOOK_SIGNALS, webhook_processing_error
from django.utils import timezone
from .models import TransactionLink
from smoothschedule.scheduling.schedule.models import Event
@@ -13,11 +13,11 @@ import logging
logger = logging.getLogger(__name__)
@receiver(signals.webhook_processing_error)
@receiver(webhook_processing_error)
def handle_webhook_error(sender, exception, event_type, **kwargs):
"""
Log webhook processing errors for debugging.
This helps identify issues with Stripe webhook delivery or processing.
"""
logger.error(
@@ -31,7 +31,7 @@ def handle_webhook_error(sender, exception, event_type, **kwargs):
)
@receiver(signals.payment_intent_succeeded)
@receiver(WEBHOOK_SIGNALS['payment_intent.succeeded'])
def handle_payment_succeeded(sender, event, **kwargs):
"""
Handle successful payment and update Event status to PAID.
@@ -80,7 +80,7 @@ def handle_payment_succeeded(sender, event, **kwargs):
)
@receiver(signals.payment_intent_payment_failed)
@receiver(WEBHOOK_SIGNALS['payment_intent.payment_failed'])
def handle_payment_failed(sender, event, **kwargs):
"""Handle failed payments"""
payment_intent = event.data.object
@@ -109,7 +109,7 @@ def handle_payment_failed(sender, event, **kwargs):
logger.error(f"Error processing payment_failed: {str(e)}", exc_info=e)
@receiver(signals.payment_intent_canceled)
@receiver(WEBHOOK_SIGNALS['payment_intent.canceled'])
def handle_payment_canceled(sender, event, **kwargs):
"""Handle canceled payments"""
payment_intent = event.data.object

View File

@@ -18,3 +18,56 @@ def user(db) -> User:
For unit tests, use create_mock_user() from factories.py instead.
"""
return UserFactory()
# =============================================================================
# Shared Tenant Fixtures (Session-scoped for performance)
# =============================================================================
# Creating a tenant in django-tenants runs all migrations (~40 seconds).
# These fixtures are session-scoped to avoid recreating tenants for each test.
_shared_tenant_cache = {}
@pytest.fixture(scope="session")
def shared_tenant(django_db_setup, django_db_blocker):
"""
Session-scoped tenant fixture for tests that need a real tenant.
This tenant is created ONCE per test session and reused across all tests.
Use this instead of creating tenants in individual tests to avoid the
~40 second migration overhead per tenant.
Usage:
@pytest.mark.django_db
def test_something(shared_tenant):
user = User(tenant=shared_tenant, ...)
"""
from smoothschedule.identity.core.models import Tenant
with django_db_blocker.unblock():
# Check if tenant already exists from a previous run (--reuse-db)
tenant = Tenant.objects.filter(schema_name="sharedtest").first()
if not tenant:
tenant = Tenant.objects.create(
name="Shared Test Business",
schema_name="sharedtest"
)
return tenant
@pytest.fixture(scope="session")
def second_shared_tenant(django_db_setup, django_db_blocker):
"""
Second session-scoped tenant for tests that need multiple tenants.
"""
from smoothschedule.identity.core.models import Tenant
with django_db_blocker.unblock():
tenant = Tenant.objects.filter(schema_name="sharedtest2").first()
if not tenant:
tenant = Tenant.objects.create(
name="Shared Test Business 2",
schema_name="sharedtest2"
)
return tenant

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2025-12-10 06:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0022_add_can_use_tasks'),
]
operations = [
migrations.AddField(
model_name='tenant',
name='can_use_contracts',
field=models.BooleanField(default=False, help_text='Whether this business can create and manage e-signature contracts'),
),
]

View File

@@ -227,6 +227,10 @@ class Tenant(TenantMixin):
default=False,
help_text="Whether this business can sync Google Calendar and other calendar providers"
)
can_use_contracts = models.BooleanField(
default=False,
help_text="Whether this business can create and manage e-signature contracts"
)
# Stripe Payment Configuration
payment_mode = models.CharField(

View File

@@ -15,18 +15,23 @@ class TestTimezoneSerializerMixin:
"""Test TimezoneSerializerMixin class."""
def test_adds_business_timezone_field_to_serializer(self):
"""Should add business_timezone as a SerializerMethodField."""
"""Should add business_timezone as a SerializerMethodField when in Meta.fields."""
from smoothschedule.identity.core.mixins import TimezoneSerializerMixin
class TestSerializer(TimezoneSerializerMixin, serializers.Serializer):
name = serializers.CharField()
class Meta:
fields = ['name', 'business_timezone']
# Need to instantiate with context to bind the serializer
serializer = TestSerializer(context={})
# Check that the mixin defines the business_timezone attribute
assert hasattr(TimezoneSerializerMixin, 'business_timezone')
assert isinstance(TimezoneSerializerMixin.business_timezone, serializers.SerializerMethodField)
# Check that the mixin provides the get_business_timezone method
assert hasattr(TimezoneSerializerMixin, 'get_business_timezone')
# Check that business_timezone field is dynamically added when in Meta.fields
assert 'business_timezone' in serializer.fields
assert isinstance(serializer.fields['business_timezone'], serializers.SerializerMethodField)
def test_get_business_timezone_from_context_tenant(self):
"""Should get timezone from tenant in context."""
@@ -258,6 +263,9 @@ class TestTimezoneSerializerMixin:
class TestSerializer(TimezoneSerializerMixin, serializers.Serializer):
name = serializers.CharField()
class Meta:
fields = ['name', 'business_timezone']
# Attempt to create with business_timezone
data = {
'name': 'Test Event',
@@ -270,7 +278,9 @@ class TestTimezoneSerializerMixin:
assert serializer.is_valid()
# The business_timezone field is a SerializerMethodField which is always read-only
assert isinstance(TimezoneSerializerMixin.business_timezone, serializers.SerializerMethodField)
assert isinstance(serializer.fields['business_timezone'], serializers.SerializerMethodField)
# Validated data should not include business_timezone (it's read-only)
assert 'business_timezone' not in serializer.validated_data
class TestTimezoneContextMixin:

View File

@@ -341,45 +341,32 @@ class TestGetAccessibleTenants:
"""
Test get_accessible_tenants() method.
Note: These tests use database access because the method accesses
ForeignKey relationships which trigger database queries even with mocking.
Uses shared tenant fixtures (session-scoped) to avoid ~40s migration overhead
per tenant creation. See conftest.py for fixture definitions.
"""
@pytest.mark.django_db
def test_returns_all_tenants_for_platform_user(self):
# Arrange
from smoothschedule.identity.core.models import Tenant
import uuid
# Create a couple of tenants
unique_id1 = str(uuid.uuid4())[:8]
Tenant.objects.create(name=f"Tenant1 {unique_id1}", schema_name=f"tenant1{unique_id1}")
unique_id2 = str(uuid.uuid4())[:8]
Tenant.objects.create(name=f"Tenant2 {unique_id2}", schema_name=f"tenant2{unique_id2}")
def test_returns_all_tenants_for_platform_user(self, shared_tenant, second_shared_tenant):
# Arrange - use shared fixtures instead of creating new tenants
user = create_user_instance(User.Role.PLATFORM_MANAGER)
# Act
result = user.get_accessible_tenants()
# Assert
assert result.count() >= 2 # At least the two we created
# Assert - at least our two shared tenants exist
assert result.count() >= 2
@pytest.mark.django_db
def test_returns_single_tenant_for_tenant_user(self):
# Arrange
from smoothschedule.identity.core.models import Tenant
def test_returns_single_tenant_for_tenant_user(self, shared_tenant):
# Arrange - use shared fixture
import uuid
unique_id = str(uuid.uuid4())[:8]
tenant = Tenant.objects.create(name=f"My Business {unique_id}", schema_name=f"mybiz{unique_id}")
user = User(
username=f"owner{unique_id}",
email=f"owner{unique_id}@test.com",
role=User.Role.TENANT_OWNER,
tenant=tenant
tenant=shared_tenant
)
user.save()
@@ -388,7 +375,7 @@ class TestGetAccessibleTenants:
# Assert
assert result.count() == 1
assert result.first() == tenant
assert result.first() == shared_tenant
@pytest.mark.django_db
def test_returns_empty_queryset_for_tenant_user_without_tenant(self):
@@ -482,23 +469,16 @@ class TestSaveMethodValidation:
assert user.is_superuser is True
@pytest.mark.django_db
def test_clears_tenant_for_platform_users(self):
# Arrange
from smoothschedule.identity.core.models import Tenant
def test_clears_tenant_for_platform_users(self, shared_tenant):
# Arrange - use shared fixture to avoid ~40s migration overhead
import uuid
# Create a tenant first with unique schema_name
unique_id = str(uuid.uuid4())[:8]
tenant = Tenant.objects.create(
name=f"Test Business {unique_id}",
schema_name=f"testbiz{unique_id}"
)
user = User(
username=f"platformuser{unique_id}",
email=f"platform{unique_id}@example.com",
role=User.Role.PLATFORM_MANAGER,
tenant=tenant # Should be cleared
tenant=shared_tenant # Should be cleared
)
# Act
@@ -527,55 +507,43 @@ class TestSaveMethodValidation:
assert "must be assigned to a tenant" in str(exc_info.value)
@pytest.mark.django_db
def test_allows_tenant_user_with_tenant(self):
# Arrange
from smoothschedule.identity.core.models import Tenant
def test_allows_tenant_user_with_tenant(self, shared_tenant):
# Arrange - use shared fixture to avoid ~40s migration overhead
import uuid
unique_id = str(uuid.uuid4())[:8]
tenant = Tenant.objects.create(
name=f"Test Business {unique_id}",
schema_name=f"testbiz{unique_id}"
)
user = User(
username=f"owner{unique_id}",
email=f"owner{unique_id}@testbiz.com",
role=User.Role.TENANT_OWNER,
tenant=tenant
tenant=shared_tenant
)
# Act
user.save()
# Assert
assert user.tenant == tenant
assert user.tenant == shared_tenant
assert user.id is not None
@pytest.mark.django_db
def test_allows_customer_with_tenant(self):
# Arrange
from smoothschedule.identity.core.models import Tenant
def test_allows_customer_with_tenant(self, shared_tenant):
# Arrange - use shared fixture to avoid ~40s migration overhead
import uuid
unique_id = str(uuid.uuid4())[:8]
tenant = Tenant.objects.create(
name=f"Test Business {unique_id}",
schema_name=f"testbiz{unique_id}"
)
user = User(
username=f"customer{unique_id}",
email=f"customer{unique_id}@example.com",
role=User.Role.CUSTOMER,
tenant=tenant
tenant=shared_tenant
)
# Act
user.save()
# Assert
assert user.tenant == tenant
assert user.tenant == shared_tenant
# =============================================================================

View File

@@ -494,29 +494,18 @@ class TestGetAccessibleTenants:
assert result == mock_queryset
@pytest.mark.django_db
def test_returns_single_tenant_for_tenant_user(self):
# This test requires DB to create a real Tenant instance
# because Django's ForeignKey and ORM make mocking too complex
from smoothschedule.identity.core.models import Tenant
import uuid
# Create a real tenant
unique_id = str(uuid.uuid4())[:8]
tenant = Tenant.objects.create(
name=f'Test Business {unique_id}',
schema_name=f'testbiz{unique_id}'
)
def test_returns_single_tenant_for_tenant_user(self, shared_tenant):
# Use shared fixture to avoid ~40s migration overhead per tenant
# Create user with that tenant
user = create_user_instance(User.Role.TENANT_OWNER)
user.tenant = tenant
user.tenant = shared_tenant
# Act
result = user.get_accessible_tenants()
# Assert
assert result.count() == 1
assert list(result)[0] == tenant
assert list(result)[0] == shared_tenant
def test_returns_empty_queryset_for_tenant_user_without_tenant(self):
# Arrange
@@ -633,22 +622,16 @@ class TestSaveMethodValidation:
assert user.is_superuser is True
@pytest.mark.django_db
def test_clears_tenant_for_platform_users(self):
from smoothschedule.identity.core.models import Tenant
def test_clears_tenant_for_platform_users(self, shared_tenant):
# Use shared fixture to avoid ~40s migration overhead per tenant
import uuid
# Create unique schema name to avoid collisions
unique_id = str(uuid.uuid4())[:8]
tenant = Tenant.objects.create(
name=f'Test Business {unique_id}',
schema_name=f'testbiz{unique_id}'
)
user = User(
username='platformuser',
email='platform@example.com',
username=f'platformuser{unique_id}',
email=f'platform{unique_id}@example.com',
role=User.Role.PLATFORM_MANAGER,
tenant=tenant # Should be cleared
tenant=shared_tenant # Should be cleared
)
user.save()
@@ -669,49 +652,37 @@ class TestSaveMethodValidation:
assert 'must be assigned to a tenant' in str(exc_info.value)
@pytest.mark.django_db
def test_allows_tenant_user_with_tenant(self):
from smoothschedule.identity.core.models import Tenant
def test_allows_tenant_user_with_tenant(self, shared_tenant):
# Use shared fixture to avoid ~40s migration overhead per tenant
import uuid
# Create unique schema name to avoid collisions
unique_id = str(uuid.uuid4())[:8]
tenant = Tenant.objects.create(
name=f'Test Business {unique_id}',
schema_name=f'testbiz{unique_id}'
)
user = User(
username='owner',
email='owner@testbiz.com',
username=f'owner{unique_id}',
email=f'owner{unique_id}@testbiz.com',
role=User.Role.TENANT_OWNER,
tenant=tenant
tenant=shared_tenant
)
user.save()
assert user.tenant == tenant
assert user.tenant == shared_tenant
assert user.id is not None
@pytest.mark.django_db
def test_allows_customer_with_tenant(self):
from smoothschedule.identity.core.models import Tenant
def test_allows_customer_with_tenant(self, shared_tenant):
# Use shared fixture to avoid ~40s migration overhead per tenant
import uuid
# Create unique schema name to avoid collisions
unique_id = str(uuid.uuid4())[:8]
tenant = Tenant.objects.create(
name=f'Test Business {unique_id}',
schema_name=f'testbiz{unique_id}'
)
user = User(
username='customer',
email='customer@example.com',
username=f'customer{unique_id}',
email=f'customer{unique_id}@example.com',
role=User.Role.CUSTOMER,
tenant=tenant
tenant=shared_tenant
)
user.save()
assert user.tenant == tenant
assert user.tenant == shared_tenant
# =============================================================================

View File

@@ -227,6 +227,7 @@ class TenantSerializer(serializers.ModelSerializer):
'can_create_plugins',
'can_use_webhooks',
'can_use_calendar_sync',
'can_use_contracts',
]
read_only_fields = fields
@@ -298,6 +299,7 @@ class TenantUpdateSerializer(serializers.ModelSerializer):
'can_create_plugins',
'can_use_webhooks',
'can_use_calendar_sync',
'can_use_contracts',
]
read_only_fields = ['id']

View File

@@ -274,6 +274,9 @@ def sync_subscription_plan_to_tenants(self, plan_id: int):
'can_use_webhooks': 'can_use_webhooks',
'calendar_sync': 'can_use_calendar_sync',
'can_use_calendar_sync': 'can_use_calendar_sync',
'contracts': 'can_use_contracts',
'contracts_enabled': 'can_use_contracts',
'can_use_contracts': 'can_use_contracts',
}
# Limit field mappings from plan.limits JSON to Tenant fields

View File

@@ -1361,15 +1361,19 @@ class TestTenantViewSet:
request.query_params = {'is_active': 'true'}
mock_queryset = Mock()
excluded_queryset = Mock()
filtered_queryset = Mock()
mock_queryset.filter.return_value = filtered_queryset
# Chain: queryset.exclude().filter()
mock_queryset.exclude.return_value = excluded_queryset
excluded_queryset.filter.return_value = filtered_queryset
with patch.object(self.viewset, 'queryset', mock_queryset):
view = self.viewset()
view.request = request
result = view.get_queryset()
mock_queryset.filter.assert_called_once_with(is_active=True)
mock_queryset.exclude.assert_called_once_with(schema_name='public')
excluded_queryset.filter.assert_called_once_with(is_active=True)
def test_destroy_requires_superuser(self):
"""Test destroy requires superuser role"""
@@ -1424,17 +1428,23 @@ class TestTenantViewSet:
role=User.Role.SUPERUSER
)
with patch('smoothschedule.identity.core.models.Tenant.objects.count', return_value=10):
with patch('smoothschedule.identity.core.models.Tenant.objects.filter') as mock_filter:
mock_filter.return_value.count.return_value = 8
with patch('smoothschedule.identity.users.models.User.objects.count', return_value=100):
view = self.viewset.as_view({'get': 'metrics'})
response = view(request)
# Mock the Tenant.objects.exclude().count() and .filter().count() chains
with patch('smoothschedule.identity.core.models.Tenant.objects.exclude') as mock_exclude:
mock_excluded = Mock()
mock_exclude.return_value = mock_excluded
mock_excluded.count.return_value = 10 # total_tenants
mock_excluded.filter.return_value.count.return_value = 8 # active_tenants
with patch('smoothschedule.identity.users.models.User.objects.count', return_value=100):
view = self.viewset.as_view({'get': 'metrics'})
response = view(request)
assert response.status_code == status.HTTP_200_OK
assert 'total_tenants' in response.data
assert 'active_tenants' in response.data
assert 'total_users' in response.data
assert response.data['total_tenants'] == 10
assert response.data['active_tenants'] == 8
assert response.data['total_users'] == 100
# ============================================================================

View File

@@ -185,7 +185,7 @@ def current_business_view(request):
'masked_calling': tenant.can_use_masked_phone_numbers or plan_permissions.get('masked_calling', False),
'pos_system': tenant.can_use_pos or plan_permissions.get('pos_system', False),
'mobile_app': tenant.can_use_mobile_app or plan_permissions.get('mobile_app', False),
'contracts': getattr(tenant.subscription_plan, 'contracts_enabled', False) if tenant.subscription_plan else False,
'contracts': tenant.can_use_contracts or (getattr(tenant.subscription_plan, 'contracts_enabled', False) if tenant.subscription_plan else False),
}
business_data = {