Add max_public_pages feature and site builder access control

- Add max_public_pages billing feature (Free=0, Starter=1, Growth=5, Pro=10)
- Gate site builder access based on max_public_pages entitlement
- Auto-create Site with default booking page for new tenants
- Update PageEditor to use useEntitlements hook for permission checks
- Replace hardcoded limits in BusinessEditModal with DynamicFeaturesEditor
- Add force update functionality for superusers in PlanEditorWizard
- Add comprehensive filters to all safe scripting get_* methods
- Update plugin documentation with full filter reference

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-13 00:27:15 -05:00
parent aa9d920612
commit 41caccd31a
11 changed files with 4413 additions and 255 deletions

View File

@@ -19,6 +19,7 @@ import {
Star,
Loader2,
ChevronLeft,
AlertTriangle,
} from 'lucide-react';
import { Modal, Alert } from '../../components/ui';
import { FeaturePicker } from './FeaturePicker';
@@ -29,8 +30,11 @@ import {
useCreatePlanVersion,
useUpdatePlan,
useUpdatePlanVersion,
useForceUpdatePlanVersion,
isForceUpdateConfirmRequired,
type PlanFeatureWrite,
} from '../../hooks/useBillingAdmin';
import { useCurrentUser } from '../../hooks/useAuth';
// =============================================================================
// Types
@@ -110,13 +114,20 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
}) => {
const { data: features, isLoading: featuresLoading } = useFeatures();
const { data: addons } = useAddOnProducts();
const { data: currentUser } = useCurrentUser();
const createPlanMutation = useCreatePlan();
const createVersionMutation = useCreatePlanVersion();
const updatePlanMutation = useUpdatePlan();
const updateVersionMutation = useUpdatePlanVersion();
const forceUpdateMutation = useForceUpdatePlanVersion();
const isNewPlan = mode === 'create';
const hasSubscribers = (initialData?.version?.subscriber_count ?? 0) > 0;
const isSuperuser = currentUser?.role === 'superuser';
// Force update state (for updating without creating new version)
const [showForceUpdateConfirm, setShowForceUpdateConfirm] = useState(false);
const [forceUpdateError, setForceUpdateError] = useState<string | null>(null);
const [currentStep, setCurrentStep] = useState<WizardStep>('basics');
const [newMarketingFeature, setNewMarketingFeature] = useState('');
@@ -313,11 +324,49 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
}
};
// Force update handler (updates existing version without creating new one)
const handleForceUpdate = async () => {
if (!initialData?.version?.id) return;
try {
setForceUpdateError(null);
// First call without confirm to get affected subscriber count
const response = await forceUpdateMutation.mutateAsync({
id: initialData.version.id,
name: formData.version_name,
is_public: formData.is_public,
price_monthly_cents: formData.price_monthly_cents,
price_yearly_cents: formData.price_yearly_cents,
transaction_fee_percent: formData.transaction_fee_percent,
transaction_fee_fixed_cents: formData.transaction_fee_fixed_cents,
trial_days: formData.trial_days,
is_most_popular: formData.is_most_popular,
show_price: formData.show_price,
marketing_features: formData.marketing_features,
stripe_product_id: formData.stripe_product_id,
stripe_price_id_monthly: formData.stripe_price_id_monthly,
stripe_price_id_yearly: formData.stripe_price_id_yearly,
features: formData.selectedFeatures,
confirm: true, // Confirm immediately since user already acknowledged
});
// If successful, close the modal
if (!isForceUpdateConfirmRequired(response)) {
onClose();
}
} catch (error) {
console.error('Failed to force update plan:', error);
setForceUpdateError('Failed to update plan. Please try again.');
}
};
const isLoading =
createPlanMutation.isPending ||
createVersionMutation.isPending ||
updatePlanMutation.isPending ||
updateVersionMutation.isPending;
updateVersionMutation.isPending ||
forceUpdateMutation.isPending;
// Derived values for display
const monthlyEquivalent = formData.price_yearly_cents > 0
@@ -345,7 +394,7 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
size="4xl"
>
{/* Grandfathering Warning */}
{hasSubscribers && (
{hasSubscribers && !showForceUpdateConfirm && (
<Alert
variant="warning"
className="mb-4"
@@ -359,6 +408,53 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
/>
)}
{/* Force Update Confirmation Dialog */}
{showForceUpdateConfirm && (
<div className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-start gap-3">
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-base font-semibold text-red-800 dark:text-red-200 mb-2">
Warning: This will affect existing customers
</h4>
<p className="text-sm text-red-700 dark:text-red-300 mb-3">
You are about to update this plan version <strong>in place</strong>. This will immediately
change the features and pricing for all <strong>{initialData?.version?.subscriber_count}</strong> existing
subscriber(s). This action cannot be undone.
</p>
<p className="text-sm text-red-700 dark:text-red-300 mb-4">
Only use this for correcting errors or minor adjustments. For significant changes,
use the standard save which creates a new version and grandfathers existing subscribers.
</p>
{forceUpdateError && (
<Alert variant="error" message={forceUpdateError} className="mb-3" />
)}
<div className="flex gap-3">
<button
type="button"
onClick={() => {
setShowForceUpdateConfirm(false);
setForceUpdateError(null);
}}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
Cancel
</button>
<button
type="button"
onClick={handleForceUpdate}
disabled={isLoading}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{forceUpdateMutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Yes, Update All Subscribers
</button>
</div>
</div>
</div>
</div>
)}
{/* Step Indicator */}
<div className="flex items-center justify-center gap-2 mb-6 pb-4 border-b border-gray-200 dark:border-gray-700">
{steps.map((step, index) => {
@@ -834,7 +930,7 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
{/* Footer */}
<div className="flex justify-between items-center pt-4 mt-4 border-t border-gray-200 dark:border-gray-700">
<div>
{!isFirstStep && (
{!isFirstStep && !showForceUpdateConfirm && (
<button
type="button"
onClick={goPrev}
@@ -845,35 +941,51 @@ export const PlanEditorWizard: React.FC<PlanEditorWizardProps> = ({
</button>
)}
</div>
<div className="flex gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Cancel
</button>
{!isLastStep ? (
{!showForceUpdateConfirm && (
<div className="flex gap-3">
<button
type="button"
onClick={goNext}
disabled={!canProceed}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
Next
Cancel
</button>
) : (
<button
type="button"
onClick={handleSubmit}
disabled={isLoading || !canProceed}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
{hasSubscribers ? 'Create New Version' : isNewPlan ? 'Create Plan' : 'Save Changes'}
</button>
)}
</div>
{!isLastStep ? (
<button
type="button"
onClick={goNext}
disabled={!canProceed}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
) : (
<>
{/* Force Update button - only for superusers editing plans with subscribers */}
{hasSubscribers && isSuperuser && (
<button
type="button"
onClick={() => setShowForceUpdateConfirm(true)}
disabled={isLoading || !canProceed}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-red-600 dark:text-red-400 border border-red-300 dark:border-red-700 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
<AlertTriangle className="w-4 h-4" />
Update Without Versioning
</button>
)}
<button
type="button"
onClick={handleSubmit}
disabled={isLoading || !canProceed}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
{hasSubscribers ? 'Create New Version' : isNewPlan ? 'Create Plan' : 'Save Changes'}
</button>
</>
)}
</div>
)}
</div>
</Modal>
);

View File

@@ -57,6 +57,23 @@ vi.mock('../../../hooks/useBillingAdmin', () => ({
mutateAsync: vi.fn().mockResolvedValue({ id: 1 }),
isPending: false,
}),
useForceUpdatePlanVersion: () => ({
mutateAsync: vi.fn().mockResolvedValue({ version: { id: 1 }, affected_count: 5 }),
isPending: false,
}),
isForceUpdateConfirmRequired: (response: unknown) =>
response !== null &&
typeof response === 'object' &&
'requires_confirm' in response &&
(response as { requires_confirm: boolean }).requires_confirm === true,
}));
// Mock useCurrentUser from useAuth
vi.mock('../../../hooks/useAuth', () => ({
useCurrentUser: () => ({
data: { id: 1, role: 'superuser', email: 'admin@test.com' },
isLoading: false,
}),
}));
describe('PlanEditorWizard', () => {
@@ -409,4 +426,135 @@ describe('PlanEditorWizard', () => {
});
});
});
describe('Force Update (Superuser)', () => {
it('shows "Update Without Versioning" button for superuser editing plan with subscribers', async () => {
const user = userEvent.setup();
render(
<PlanEditorWizard
{...defaultProps}
mode="edit"
initialData={{
id: 1,
code: 'pro',
name: 'Pro',
version: {
id: 1,
subscriber_count: 5,
name: 'Pro v1',
},
}}
/>,
{ wrapper: createWrapper() }
);
// Navigate to last step
await user.click(screen.getByRole('button', { name: /pricing/i }));
await user.click(screen.getByRole('button', { name: /features/i }));
await user.click(screen.getByRole('button', { name: /display/i }));
// Should show "Update Without Versioning" button
expect(screen.getByRole('button', { name: /update without versioning/i })).toBeInTheDocument();
});
it('shows confirmation dialog when clicking "Update Without Versioning"', async () => {
const user = userEvent.setup();
render(
<PlanEditorWizard
{...defaultProps}
mode="edit"
initialData={{
id: 1,
code: 'pro',
name: 'Pro',
version: {
id: 1,
subscriber_count: 5,
name: 'Pro v1',
},
}}
/>,
{ wrapper: createWrapper() }
);
// Navigate to last step
await user.click(screen.getByRole('button', { name: /pricing/i }));
await user.click(screen.getByRole('button', { name: /features/i }));
await user.click(screen.getByRole('button', { name: /display/i }));
// Click the force update button
await user.click(screen.getByRole('button', { name: /update without versioning/i }));
// Should show confirmation dialog with warning
expect(screen.getByText(/warning: this will affect existing customers/i)).toBeInTheDocument();
expect(screen.getByText(/5/)).toBeInTheDocument(); // subscriber count
expect(screen.getByRole('button', { name: /yes, update all subscribers/i })).toBeInTheDocument();
});
it('can cancel force update confirmation', async () => {
const user = userEvent.setup();
render(
<PlanEditorWizard
{...defaultProps}
mode="edit"
initialData={{
id: 1,
code: 'pro',
name: 'Pro',
version: {
id: 1,
subscriber_count: 5,
name: 'Pro v1',
},
}}
/>,
{ wrapper: createWrapper() }
);
// Navigate to last step
await user.click(screen.getByRole('button', { name: /pricing/i }));
await user.click(screen.getByRole('button', { name: /features/i }));
await user.click(screen.getByRole('button', { name: /display/i }));
// Click the force update button
await user.click(screen.getByRole('button', { name: /update without versioning/i }));
// Click Cancel
const cancelButtons = screen.getAllByRole('button', { name: /cancel/i });
await user.click(cancelButtons[0]); // First cancel is in the confirmation dialog
// Confirmation dialog should be hidden, back to normal footer
expect(screen.queryByText(/warning: this will affect existing customers/i)).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /update without versioning/i })).toBeInTheDocument();
});
it('does not show "Update Without Versioning" for plans without subscribers', async () => {
const user = userEvent.setup();
render(
<PlanEditorWizard
{...defaultProps}
mode="edit"
initialData={{
id: 1,
code: 'pro',
name: 'Pro',
version: {
id: 1,
subscriber_count: 0, // No subscribers
name: 'Pro v1',
},
}}
/>,
{ wrapper: createWrapper() }
);
// Navigate to last step
await user.click(screen.getByRole('button', { name: /pricing/i }));
await user.click(screen.getByRole('button', { name: /features/i }));
await user.click(screen.getByRole('button', { name: /display/i }));
// Should NOT show "Update Without Versioning" button
expect(screen.queryByRole('button', { name: /update without versioning/i })).not.toBeInTheDocument();
});
});
});

View File

@@ -127,6 +127,7 @@ export const FEATURE_CODES = {
MAX_RESOURCES: 'max_resources',
MAX_EVENT_TYPES: 'max_event_types',
MAX_CALENDARS_CONNECTED: 'max_calendars_connected',
MAX_PUBLIC_PAGES: 'max_public_pages',
} as const;
export type FeatureCode = (typeof FEATURE_CODES)[keyof typeof FEATURE_CODES];

View File

@@ -5,11 +5,11 @@ import { config } from "../puckConfig";
import { usePages, useUpdatePage, useCreatePage, useDeletePage } from "../hooks/useSites";
import { Loader2, Plus, Trash2, FileText } from "lucide-react";
import toast from 'react-hot-toast';
import { useAuth } from '../hooks/useAuth';
import { useEntitlements, FEATURE_CODES } from '../hooks/useEntitlements';
export const PageEditor: React.FC = () => {
const { data: pages, isLoading } = usePages();
const { user } = useAuth();
const { getLimit, isLoading: entitlementsLoading } = useEntitlements();
const updatePage = useUpdatePage();
const createPage = useCreatePage();
const deletePage = useDeletePage();
@@ -18,6 +18,13 @@ export const PageEditor: React.FC = () => {
const [showNewPageModal, setShowNewPageModal] = useState(false);
const [newPageTitle, setNewPageTitle] = useState('');
// Get max_public_pages from billing entitlements
// null = unlimited, 0 = no access, >0 = limited pages
const maxPagesLimit = getLimit(FEATURE_CODES.MAX_PUBLIC_PAGES);
const canCustomize = maxPagesLimit === null || maxPagesLimit > 0;
const pageCount = pages?.length || 0;
const canCreateMore = canCustomize && (maxPagesLimit === null || pageCount < maxPagesLimit);
const currentPage = pages?.find((p: any) => p.id === currentPageId) || pages?.find((p: any) => p.is_home) || pages?.[0];
useEffect(() => {
@@ -35,9 +42,8 @@ export const PageEditor: React.FC = () => {
const handlePublish = async (newData: any) => {
if (!currentPage) return;
// Check if user has permission to customize
const hasPermission = (user as any)?.tenant?.can_customize_booking_page || false;
if (!hasPermission) {
// Check if user has permission to customize (based on max_public_pages entitlement)
if (!canCustomize) {
toast.error("Your plan does not include site customization. Please upgrade to edit pages.");
return;
}
@@ -84,7 +90,7 @@ export const PageEditor: React.FC = () => {
}
};
if (isLoading) {
if (isLoading || entitlementsLoading) {
return <div className="flex justify-center p-10"><Loader2 className="animate-spin" /></div>;
}
@@ -94,10 +100,8 @@ export const PageEditor: React.FC = () => {
if (!data) return null;
const maxPages = (user as any)?.tenant?.max_pages || 1;
const pageCount = pages?.length || 0;
const canCustomize = (user as any)?.tenant?.can_customize_booking_page || false;
const canCreateMore = canCustomize && (maxPages === -1 || pageCount < maxPages);
// Display max pages as string for UI (null = unlimited shown as ∞)
const maxPagesDisplay = maxPagesLimit === null ? '∞' : maxPagesLimit;
return (
<div className="h-screen flex flex-col">
@@ -136,7 +140,7 @@ export const PageEditor: React.FC = () => {
onClick={() => setShowNewPageModal(true)}
disabled={!canCreateMore}
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
title={canCreateMore ? "Create new page" : `Page limit reached (${pageCount}/${maxPages})`}
title={canCreateMore ? "Create new page" : `Page limit reached (${pageCount}/${maxPagesDisplay})`}
>
<Plus size={16} />
New Page
@@ -156,7 +160,7 @@ export const PageEditor: React.FC = () => {
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{pageCount} / {maxPages === -1 ? '∞' : maxPages} pages
{pageCount} / {maxPagesDisplay} pages
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -422,64 +422,18 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
</p>
</div>
{/* Limits Configuration */}
{/* Limits & Quotas - Dynamic from billing system */}
<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">
Limits Configuration
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
Use -1 for unlimited. These limits control what this business can create.
</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Users
</label>
<input
type="number"
min="-1"
value={featureValues.max_users ?? 5}
onChange={(e) => setFeatureValues(prev => ({ ...prev, max_users: parseInt(e.target.value) || 0 }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Resources
</label>
<input
type="number"
min="-1"
value={featureValues.max_resources ?? 10}
onChange={(e) => setFeatureValues(prev => ({ ...prev, max_resources: parseInt(e.target.value) || 0 }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Pages
</label>
<input
type="number"
min="-1"
value={featureValues.max_pages ?? 1}
onChange={(e) => setFeatureValues(prev => ({ ...prev, max_pages: parseInt(e.target.value) || 0 }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max Locations
</label>
<input
type="number"
min="-1"
value={featureValues.max_locations ?? 1}
onChange={(e) => setFeatureValues(prev => ({ ...prev, max_locations: parseInt(e.target.value) || 0 }))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
</div>
<DynamicFeaturesEditor
values={featureValues}
onChange={(fieldName, value) => {
setFeatureValues(prev => ({ ...prev, [fieldName]: value }));
}}
featureType="integer"
headerTitle="Limits & Quotas"
showDescriptions
columns={4}
/>
</div>
{/* Site Builder Access */}
@@ -488,7 +442,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
Site Builder
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
Access the public-facing website builder for this business. Current limit: {featureValues.max_pages === -1 ? 'unlimited' : (featureValues.max_pages ?? 1)} page{(featureValues.max_pages ?? 1) !== 1 ? 's' : ''}.
Access the public-facing website builder for this business. Current limit: {(featureValues.max_public_pages === null || featureValues.max_public_pages === -1) ? 'unlimited' : (featureValues.max_public_pages ?? 0)} page{(featureValues.max_public_pages ?? 0) !== 1 ? 's' : ''}.
</p>
<a
href={`http://${business.subdomain}.lvh.me:5173/site-editor`}
@@ -510,14 +464,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
onChange={(fieldName, value) => {
setFeatureValues(prev => ({ ...prev, [fieldName]: value }));
}}
// Show only boolean features (limits are in the grid above)
featureType="boolean"
// Skip limits category since they're shown in the dedicated grid
excludeCodes={[
'max_users', 'max_resources', 'max_locations', 'max_services',
'max_customers', 'max_appointments_per_month', 'max_sms_per_month',
'max_email_per_month', 'max_storage_mb', 'max_api_calls_per_day',
]}
headerTitle="Features & Permissions"
showDescriptions
/>

View File

@@ -69,6 +69,7 @@ FEATURES = [
{"code": "custom_domain", "name": "Custom Domain", "description": "Use your own domain for booking pages", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_use_custom_domain", "display_order": 30},
{"code": "remove_branding", "name": "Remove Branding", "description": "Remove SmoothSchedule branding from customer-facing pages", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_white_label", "display_order": 40},
{"code": "white_label", "name": "White Label", "description": "Remove all SmoothSchedule branding completely", "feature_type": "boolean", "category": "customization", "tenant_field_name": "can_white_label", "display_order": 50},
{"code": "max_public_pages", "name": "Public Web Pages", "description": "Maximum number of public-facing web pages", "feature_type": "integer", "category": "customization", "tenant_field_name": "max_public_pages", "display_order": 55},
# --- Plugins & Automation ---
{"code": "can_use_plugins", "name": "Use Plugins", "description": "Install and use marketplace plugins", "feature_type": "boolean", "category": "plugins", "tenant_field_name": "can_use_plugins", "display_order": 10},
@@ -134,6 +135,7 @@ PLANS = [
"max_storage_mb": 250,
"max_api_calls_per_day": 0,
"max_sms_per_month": 0,
"max_public_pages": 0,
},
},
{
@@ -171,6 +173,7 @@ PLANS = [
"max_storage_mb": 2000,
"max_api_calls_per_day": 0,
"max_sms_per_month": 0,
"max_public_pages": 1,
},
},
{
@@ -216,6 +219,7 @@ PLANS = [
"max_sms_per_month": 2000,
"max_storage_mb": 10000,
"max_api_calls_per_day": 10000,
"max_public_pages": 5,
},
},
{
@@ -270,6 +274,7 @@ PLANS = [
"max_sms_per_month": 10000,
"max_storage_mb": 50000,
"max_api_calls_per_day": 50000,
"max_public_pages": 10,
},
},
{
@@ -332,6 +337,7 @@ PLANS = [
"max_sms_per_month": None,
"max_storage_mb": None,
"max_api_calls_per_day": None,
"max_public_pages": None,
},
},
]

View File

@@ -100,3 +100,14 @@ def create_default_page_for_site(sender, instance, created, **kwargs):
"""Automatically create a default home page when a new Site is created."""
if created:
instance.create_default_page()
@receiver(post_save, sender=Tenant)
def create_site_for_tenant(sender, instance, created, **kwargs):
"""Automatically create a Site with default page for new tenants.
This ensures ALL tenants (including Free tier) get a default booking page.
The Site post_save signal will then create the default home page.
"""
if created:
Site.objects.get_or_create(tenant=instance)

View File

@@ -8,6 +8,7 @@ from .models import Site, Page, Domain
from .serializers import SiteSerializer, PageSerializer, DomainSerializer, PublicPageSerializer, PublicServiceSerializer
from smoothschedule.identity.core.models import Tenant
from smoothschedule.scheduling.schedule.models import Service
from smoothschedule.billing.services.entitlements import EntitlementService
class SiteViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin, mixins.UpdateModelMixin):
serializer_class = SiteSerializer
@@ -53,17 +54,20 @@ class SiteViewSet(viewsets.GenericViewSet, mixins.RetrieveModelMixin, mixins.Upd
return Response(serializer.data)
elif request.method == 'POST':
# Check if tenant has permission to customize booking page
if not tenant.can_customize_booking_page:
# Check max_public_pages from billing system
max_pages = EntitlementService.get_limit(tenant, 'max_public_pages')
# None means unlimited, 0 means no access
can_customize = max_pages is None or max_pages > 0
if not can_customize:
return Response({
"error": "Your plan does not include site customization. Please upgrade to a paid plan to create and edit pages."
}, status=status.HTTP_403_FORBIDDEN)
# Check page limit
# Check page limit (None = unlimited)
current_page_count = Page.objects.filter(site=site).count()
max_pages = tenant.max_pages
if max_pages != -1 and current_page_count >= max_pages:
if max_pages is not None and current_page_count >= max_pages:
return Response({
"error": f"Page limit reached. Your plan allows {max_pages} page(s). Please upgrade to create more pages."
}, status=status.HTTP_403_FORBIDDEN)
@@ -81,25 +85,31 @@ class PageViewSet(viewsets.ModelViewSet):
def get_queryset(self):
return Page.objects.filter(site__tenant=self.request.tenant)
def _check_site_permission(self, tenant):
"""Check if tenant can edit site based on max_public_pages entitlement."""
max_pages = EntitlementService.get_limit(tenant, 'max_public_pages')
# None means unlimited, 0 means no access
return max_pages is None or max_pages > 0
def update(self, request, *args, **kwargs):
# Check if tenant has permission to customize booking page
if not request.tenant.can_customize_booking_page:
# Check max_public_pages from billing system
if not self._check_site_permission(request.tenant):
return Response({
"error": "Your plan does not include site customization. Please upgrade to a paid plan to edit pages."
}, status=status.HTTP_403_FORBIDDEN)
return super().update(request, *args, **kwargs)
def partial_update(self, request, *args, **kwargs):
# Check if tenant has permission to customize booking page
if not request.tenant.can_customize_booking_page:
# Check max_public_pages from billing system
if not self._check_site_permission(request.tenant):
return Response({
"error": "Your plan does not include site customization. Please upgrade to a paid plan to edit pages."
}, status=status.HTTP_403_FORBIDDEN)
return super().partial_update(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs):
# Check if tenant has permission to customize booking page
if not request.tenant.can_customize_booking_page:
# Check max_public_pages from billing system
if not self._check_site_permission(request.tenant):
return Response({
"error": "Your plan does not include site customization. Please upgrade to a paid plan to delete pages."
}, status=status.HTTP_403_FORBIDDEN)

View File

@@ -847,3 +847,872 @@ class TestValidatePluginWhitelistValidation:
assert len(result['warnings']) > 0
assert any('dynamic URL' in w for w in result['warnings'])
# =========================================================================
# TESTS FOR NEW API METHODS
# =========================================================================
class TestSafeScriptAPIFeatureCheck:
"""Tests for SafeScriptAPI._check_feature method."""
def test_has_check_feature_method(self):
"""Should have _check_feature method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, '_check_feature')
assert callable(api._check_feature)
def test_raises_when_feature_not_available(self):
"""Should raise ScriptExecutionError when feature not available."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
mock_business = Mock()
mock_business.has_feature = Mock(return_value=False)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
with pytest.raises(ScriptExecutionError) as exc_info:
api._check_feature('sms_enabled', 'SMS messaging')
assert 'not available on your plan' in str(exc_info.value)
def test_passes_when_feature_available(self):
"""Should not raise when feature is available."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_business = Mock()
mock_business.has_feature = Mock(return_value=True)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
# Should not raise
api._check_feature('sms_enabled', 'SMS messaging')
mock_business.has_feature.assert_called_with('sms_enabled')
class TestSafeScriptAPISendSMS:
"""Tests for SafeScriptAPI.send_sms method."""
def test_has_send_sms_method(self):
"""Should have send_sms method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'send_sms')
assert callable(api.send_sms)
def test_validates_phone_number(self):
"""Should validate phone number."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
mock_business = Mock()
mock_business.has_feature = Mock(return_value=True)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
with pytest.raises(ScriptExecutionError) as exc_info:
api.send_sms('123', 'Test message')
assert 'Invalid phone number' in str(exc_info.value)
def test_validates_message_length(self):
"""Should validate message length."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
mock_business = Mock()
mock_business.has_feature = Mock(return_value=True)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
with pytest.raises(ScriptExecutionError) as exc_info:
api.send_sms('+15551234567', 'x' * 2000)
assert 'too long' in str(exc_info.value)
class TestSafeScriptAPIGetSMSBalance:
"""Tests for SafeScriptAPI.get_sms_balance method."""
def test_has_get_sms_balance_method(self):
"""Should have get_sms_balance method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_sms_balance')
assert callable(api.get_sms_balance)
class TestSafeScriptAPIGetResources:
"""Tests for SafeScriptAPI.get_resources method."""
def test_has_get_resources_method(self):
"""Should have get_resources method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_resources')
assert callable(api.get_resources)
@patch('smoothschedule.scheduling.schedule.models.Resource')
def test_returns_list(self, mock_resource_class):
"""Should return a list of resources."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_resource = Mock()
mock_resource.id = 1
mock_resource.name = 'Test Resource'
mock_resource.type = 'STAFF'
mock_resource.resource_type = None
mock_resource.description = 'Description'
mock_resource.is_active = True
mock_resource.max_concurrent_events = 1
mock_resource.location_id = None
mock_resource.location = None
mock_resource.is_mobile = False
mock_resource.user_id = None
mock_resource.user = None
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[mock_resource])
mock_resource_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
result = api.get_resources()
assert isinstance(result, list)
class TestSafeScriptAPIGetResourceAvailability:
"""Tests for SafeScriptAPI.get_resource_availability method."""
def test_has_get_resource_availability_method(self):
"""Should have get_resource_availability method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_resource_availability')
assert callable(api.get_resource_availability)
class TestSafeScriptAPIGetServices:
"""Tests for SafeScriptAPI.get_services method."""
def test_has_get_services_method(self):
"""Should have get_services method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_services')
assert callable(api.get_services)
class TestSafeScriptAPIGetServiceStats:
"""Tests for SafeScriptAPI.get_service_stats method."""
def test_has_get_service_stats_method(self):
"""Should have get_service_stats method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_service_stats')
assert callable(api.get_service_stats)
class TestSafeScriptAPIGetPayments:
"""Tests for SafeScriptAPI.get_payments method."""
def test_has_get_payments_method(self):
"""Should have get_payments method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_payments')
assert callable(api.get_payments)
def test_requires_payment_processing_feature(self):
"""Should require payment_processing feature."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
mock_business = Mock()
mock_business.has_feature = Mock(return_value=False)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
with pytest.raises(ScriptExecutionError) as exc_info:
api.get_payments()
assert 'not available on your plan' in str(exc_info.value)
class TestSafeScriptAPIGetInvoices:
"""Tests for SafeScriptAPI.get_invoices method."""
def test_has_get_invoices_method(self):
"""Should have get_invoices method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_invoices')
assert callable(api.get_invoices)
class TestSafeScriptAPIGetRevenueStats:
"""Tests for SafeScriptAPI.get_revenue_stats method."""
def test_has_get_revenue_stats_method(self):
"""Should have get_revenue_stats method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_revenue_stats')
assert callable(api.get_revenue_stats)
class TestSafeScriptAPIGetContracts:
"""Tests for SafeScriptAPI.get_contracts method."""
def test_has_get_contracts_method(self):
"""Should have get_contracts method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_contracts')
assert callable(api.get_contracts)
def test_requires_contracts_feature(self):
"""Should require can_use_contracts feature."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
mock_business = Mock()
mock_business.has_feature = Mock(return_value=False)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
with pytest.raises(ScriptExecutionError) as exc_info:
api.get_contracts()
assert 'not available on your plan' in str(exc_info.value)
class TestSafeScriptAPIGetExpiringContracts:
"""Tests for SafeScriptAPI.get_expiring_contracts method."""
def test_has_get_expiring_contracts_method(self):
"""Should have get_expiring_contracts method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_expiring_contracts')
assert callable(api.get_expiring_contracts)
class TestSafeScriptAPIGetLocations:
"""Tests for SafeScriptAPI.get_locations method."""
def test_has_get_locations_method(self):
"""Should have get_locations method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_locations')
assert callable(api.get_locations)
class TestSafeScriptAPIGetLocationStats:
"""Tests for SafeScriptAPI.get_location_stats method."""
def test_has_get_location_stats_method(self):
"""Should have get_location_stats method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_location_stats')
assert callable(api.get_location_stats)
def test_requires_multi_location_feature(self):
"""Should require multi_location feature."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
mock_business = Mock()
mock_business.has_feature = Mock(return_value=False)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
with pytest.raises(ScriptExecutionError) as exc_info:
api.get_location_stats(1)
assert 'not available on your plan' in str(exc_info.value)
class TestSafeScriptAPIGetStaff:
"""Tests for SafeScriptAPI.get_staff method."""
def test_has_get_staff_method(self):
"""Should have get_staff method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_staff')
assert callable(api.get_staff)
class TestSafeScriptAPIGetStaffPerformance:
"""Tests for SafeScriptAPI.get_staff_performance method."""
def test_has_get_staff_performance_method(self):
"""Should have get_staff_performance method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_staff_performance')
assert callable(api.get_staff_performance)
class TestSafeScriptAPICreateVideoMeeting:
"""Tests for SafeScriptAPI.create_video_meeting method."""
def test_has_create_video_meeting_method(self):
"""Should have create_video_meeting method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'create_video_meeting')
assert callable(api.create_video_meeting)
def test_requires_video_conferencing_feature(self):
"""Should require can_add_video_conferencing feature."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
mock_business = Mock()
mock_business.has_feature = Mock(return_value=False)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
with pytest.raises(ScriptExecutionError) as exc_info:
api.create_video_meeting()
assert 'not available on your plan' in str(exc_info.value)
def test_validates_provider(self):
"""Should validate video provider."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
mock_business = Mock()
mock_business.has_feature = Mock(return_value=True)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
with pytest.raises(ScriptExecutionError) as exc_info:
api.create_video_meeting(provider='invalid_provider')
assert 'Invalid video provider' in str(exc_info.value)
def test_returns_meeting_data(self):
"""Should return meeting data."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_business = Mock()
mock_business.has_feature = Mock(return_value=True)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
result = api.create_video_meeting(provider='zoom', title='Test Meeting')
assert 'meeting_id' in result
assert 'join_url' in result
assert 'host_url' in result
assert result['provider'] == 'zoom'
assert result['title'] == 'Test Meeting'
class TestSafeScriptAPIGetEmailTemplates:
"""Tests for SafeScriptAPI.get_email_templates method."""
def test_has_get_email_templates_method(self):
"""Should have get_email_templates method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_email_templates')
assert callable(api.get_email_templates)
def test_requires_email_templates_feature(self):
"""Should require can_use_email_templates feature."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
mock_business = Mock()
mock_business.has_feature = Mock(return_value=False)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
with pytest.raises(ScriptExecutionError) as exc_info:
api.get_email_templates()
assert 'not available on your plan' in str(exc_info.value)
class TestSafeScriptAPISendTemplateEmail:
"""Tests for SafeScriptAPI.send_template_email method."""
def test_has_send_template_email_method(self):
"""Should have send_template_email method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'send_template_email')
assert callable(api.send_template_email)
class TestSafeScriptAPIGetAnalytics:
"""Tests for SafeScriptAPI.get_analytics method."""
def test_has_get_analytics_method(self):
"""Should have get_analytics method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_analytics')
assert callable(api.get_analytics)
def test_requires_advanced_reporting_feature(self):
"""Should require advanced_reporting feature."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
mock_business = Mock()
mock_business.has_feature = Mock(return_value=False)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
with pytest.raises(ScriptExecutionError) as exc_info:
api.get_analytics()
assert 'not available on your plan' in str(exc_info.value)
class TestSafeScriptAPIGetBookingTrends:
"""Tests for SafeScriptAPI.get_booking_trends method."""
def test_has_get_booking_trends_method(self):
"""Should have get_booking_trends method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_booking_trends')
assert callable(api.get_booking_trends)
class TestSafeScriptAPIUpdateAppointment:
"""Tests for SafeScriptAPI.update_appointment method."""
def test_has_update_appointment_method(self):
"""Should have update_appointment method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'update_appointment')
assert callable(api.update_appointment)
class TestSafeScriptAPIGetRecurringAppointments:
"""Tests for SafeScriptAPI.get_recurring_appointments method."""
def test_has_get_recurring_appointments_method(self):
"""Should have get_recurring_appointments method."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
assert hasattr(api, 'get_recurring_appointments')
assert callable(api.get_recurring_appointments)
class TestSafeScriptAPIGetAppointmentsFilters:
"""Tests for comprehensive filtering in get_appointments method."""
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_start_time_gt(self, mock_event_class):
"""Should filter by start_time greater than."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(start_time__gt='2024-01-01T10:00:00')
# Should have called filter with start_time__gt
filter_calls = [str(c) for c in mock_queryset.filter.call_args_list]
assert any('start_time__gt' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_start_time_lt(self, mock_event_class):
"""Should filter by start_time less than."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(start_time__lt='2024-01-01T10:00:00')
assert any('start_time__lt' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_start_time_gte(self, mock_event_class):
"""Should filter by start_time greater than or equal."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(start_time__gte='2024-01-01T10:00:00')
assert any('start_time__gte' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_start_time_lte(self, mock_event_class):
"""Should filter by start_time less than or equal."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(start_time__lte='2024-01-01T10:00:00')
assert any('start_time__lte' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_end_time_gt(self, mock_event_class):
"""Should filter by end_time greater than."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(end_time__gt='2024-01-01T10:00:00')
assert any('end_time__gt' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_end_time_lt(self, mock_event_class):
"""Should filter by end_time less than."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(end_time__lt='2024-01-01T10:00:00')
assert any('end_time__lt' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_created_at_gte(self, mock_event_class):
"""Should filter by created_at greater than or equal."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(created_at__gte='2024-01-01T10:00:00')
assert any('created_at__gte' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_updated_at_gte(self, mock_event_class):
"""Should filter by updated_at greater than or equal."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(updated_at__gte='2024-01-01T10:00:00')
assert any('updated_at__gte' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_service_id(self, mock_event_class):
"""Should filter by service_id."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(service_id=123)
assert any('service_id' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_location_id(self, mock_event_class):
"""Should filter by location_id."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(location_id=456)
assert any('location_id' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_title_contains(self, mock_event_class):
"""Should filter by title contains (case-insensitive)."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(title__icontains='meeting')
assert any('title__icontains' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_notes_contains(self, mock_event_class):
"""Should filter by notes contains (case-insensitive)."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(notes__icontains='important')
assert any('notes__icontains' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_customer_id(self, mock_event_class):
"""Should filter by customer_id (via participants)."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.distinct.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(customer_id=789)
assert any('participants__user_id' in str(c) or 'customer' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_resource_id(self, mock_event_class):
"""Should filter by resource_id (via participants)."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.distinct.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(resource_id=321)
assert any('participants__resource_id' in str(c) or 'resource' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_deposit_amount_gte(self, mock_event_class):
"""Should filter by deposit_amount greater than or equal."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(deposit_amount__gte=50.00)
assert any('deposit_amount__gte' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_final_price_lte(self, mock_event_class):
"""Should filter by final_price less than or equal."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(final_price__lte=100.00)
assert any('final_price__lte' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_has_deposit(self, mock_event_class):
"""Should filter appointments that have a deposit."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.exclude.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(has_deposit=True)
# Should use exclude(deposit_amount__isnull=True) or filter(deposit_amount__isnull=False)
call_args_str = str(mock_queryset.filter.call_args_list) + str(mock_queryset.exclude.call_args_list)
assert 'deposit_amount' in call_args_str
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_by_multiple_statuses(self, mock_event_class):
"""Should filter by multiple statuses using __in."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(status__in=['SCHEDULED', 'COMPLETED'])
assert any('status__in' in str(c) for c in mock_queryset.filter.call_args_list)
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_filter_combination(self, mock_event_class):
"""Should support combining multiple filters."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
api.get_appointments(
status='SCHEDULED',
start_time__gte='2024-01-01T00:00:00',
start_time__lt='2024-02-01T00:00:00',
service_id=123
)
# Multiple filter calls should have been made
assert mock_queryset.filter.call_count >= 1
@patch('smoothschedule.scheduling.schedule.models.Event')
def test_returns_comprehensive_data(self, mock_event_class):
"""Should return comprehensive appointment data including related objects."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI
from datetime import datetime
mock_service = Mock()
mock_service.id = 1
mock_service.name = 'Haircut'
mock_location = Mock()
mock_location.id = 2
mock_location.name = 'Main Office'
mock_event = Mock()
mock_event.id = 100
mock_event.title = 'Test Appointment'
mock_event.start_time = datetime(2024, 1, 15, 10, 0, 0)
mock_event.end_time = datetime(2024, 1, 15, 11, 0, 0)
mock_event.status = 'SCHEDULED'
mock_event.notes = 'Some notes'
mock_event.created_at = datetime(2024, 1, 1, 9, 0, 0)
mock_event.updated_at = datetime(2024, 1, 1, 9, 0, 0)
mock_event.service = mock_service
mock_event.service_id = 1
mock_event.location = mock_location
mock_event.location_id = 2
mock_event.deposit_amount = 25.00
mock_event.final_price = None
mock_queryset = MagicMock()
mock_queryset.filter.return_value = mock_queryset
mock_queryset.select_related.return_value = mock_queryset
mock_queryset.__getitem__ = Mock(return_value=[mock_event])
mock_event_class.objects.all.return_value = mock_queryset
api = SafeScriptAPI(business=Mock(), user=Mock(), execution_context={})
result = api.get_appointments()
assert len(result) == 1
appt = result[0]
assert appt['id'] == 100
assert appt['title'] == 'Test Appointment'
assert 'service_id' in appt
assert 'service_name' in appt
assert 'location_id' in appt
assert 'location_name' in appt
assert 'created_at' in appt
assert 'updated_at' in appt
assert 'deposit_amount' in appt
assert 'final_price' in appt
def test_requires_recurring_appointments_feature(self):
"""Should require recurring_appointments feature."""
from smoothschedule.scheduling.schedule.safe_scripting import SafeScriptAPI, ScriptExecutionError
mock_business = Mock()
mock_business.has_feature = Mock(return_value=False)
api = SafeScriptAPI(business=mock_business, user=Mock(), execution_context={})
with pytest.raises(ScriptExecutionError) as exc_info:
api.get_recurring_appointments()
assert 'not available on your plan' in str(exc_info.value)