diff --git a/frontend/src/billing/components/PlanEditorWizard.tsx b/frontend/src/billing/components/PlanEditorWizard.tsx index 9e2cb69..2d1480a 100644 --- a/frontend/src/billing/components/PlanEditorWizard.tsx +++ b/frontend/src/billing/components/PlanEditorWizard.tsx @@ -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 = ({ }) => { 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(null); const [currentStep, setCurrentStep] = useState('basics'); const [newMarketingFeature, setNewMarketingFeature] = useState(''); @@ -313,11 +324,49 @@ export const PlanEditorWizard: React.FC = ({ } }; + // 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 = ({ size="4xl" > {/* Grandfathering Warning */} - {hasSubscribers && ( + {hasSubscribers && !showForceUpdateConfirm && ( = ({ /> )} + {/* Force Update Confirmation Dialog */} + {showForceUpdateConfirm && ( +
+
+ +
+

+ Warning: This will affect existing customers +

+

+ You are about to update this plan version in place. This will immediately + change the features and pricing for all {initialData?.version?.subscriber_count} existing + subscriber(s). This action cannot be undone. +

+

+ 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. +

+ {forceUpdateError && ( + + )} +
+ + +
+
+
+
+ )} + {/* Step Indicator */}
{steps.map((step, index) => { @@ -834,7 +930,7 @@ export const PlanEditorWizard: React.FC = ({ {/* Footer */}
- {!isFirstStep && ( + {!isFirstStep && !showForceUpdateConfirm && ( )}
-
- - {!isLastStep ? ( + {!showForceUpdateConfirm && ( +
- ) : ( - - )} -
+ {!isLastStep ? ( + + ) : ( + <> + {/* Force Update button - only for superusers editing plans with subscribers */} + {hasSubscribers && isSuperuser && ( + + )} + + + )} +
+ )}
); diff --git a/frontend/src/billing/components/__tests__/PlanEditorWizard.test.tsx b/frontend/src/billing/components/__tests__/PlanEditorWizard.test.tsx index 60ca875..6a99fbd 100644 --- a/frontend/src/billing/components/__tests__/PlanEditorWizard.test.tsx +++ b/frontend/src/billing/components/__tests__/PlanEditorWizard.test.tsx @@ -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( + , + { 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( + , + { 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( + , + { 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( + , + { 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(); + }); + }); }); diff --git a/frontend/src/hooks/useEntitlements.ts b/frontend/src/hooks/useEntitlements.ts index 4d76cbe..e13e09a 100644 --- a/frontend/src/hooks/useEntitlements.ts +++ b/frontend/src/hooks/useEntitlements.ts @@ -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]; diff --git a/frontend/src/pages/PageEditor.tsx b/frontend/src/pages/PageEditor.tsx index de3f38e..eb343a2 100644 --- a/frontend/src/pages/PageEditor.tsx +++ b/frontend/src/pages/PageEditor.tsx @@ -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
; } @@ -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 (
@@ -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})`} > New Page @@ -156,7 +160,7 @@ export const PageEditor: React.FC = () => {
- {pageCount} / {maxPages === -1 ? '∞' : maxPages} pages + {pageCount} / {maxPagesDisplay} pages
diff --git a/frontend/src/pages/help/HelpPluginDocs.tsx b/frontend/src/pages/help/HelpPluginDocs.tsx index 060fe0a..3d7cf63 100644 --- a/frontend/src/pages/help/HelpPluginDocs.tsx +++ b/frontend/src/pages/help/HelpPluginDocs.tsx @@ -1142,146 +1142,1312 @@ result = {'sent': 30, 'cutoff': cutoff_date}`} {/* API Methods */} - - +
+

API Methods Reference

-

- All scripts have access to the api object with these methods: +

+ All scripts have access to the api object with these methods. Each method shows its signature, available filters/parameters, and return data structure.

-

- Business Data Methods -

- -

- External HTTP Methods (Whitelist Required) -

-

- All external HTTP requests require URL whitelisting. See warning below for details. -

- - -
-

- - Important: URL Whitelisting Required -

-

- If your plugin makes HTTP requests to external APIs (using api.http_get(), - api.http_post(), or any other HTTP method), all URLs must be - whitelisted before you upload your plugin to the marketplace. -

-
-

To request URL whitelisting:

-
    -
  1. Contact support at pluginaccess@smoothschedule.com
  2. -
  3. Include the exact URL(s) you need whitelisted
  4. -
  5. Specify which HTTP methods you need (GET, POST, PUT, DELETE, etc.)
  6. -
  7. Explain why each URL is safe and necessary for your plugin
  8. -
  9. Provide an exact copy of the code you intend to upload
  10. -
-
-

⚠️ Code Verification

-

- If you change your plugin code after it's been whitelisted, it will fail to upload. - The system verifies that uploaded code matches the whitelisted version exactly. -

+ {/* get_appointments */} +
+
+
+

Appointments

+ + api.get_appointments(**filters) → List[dict] + +
+
+ {/* Status Filters */} +
+ Status Filters +
+
status — Exact match: SCHEDULED, COMPLETED, CANCELED, NO_SHOW, EN_ROUTE, IN_PROGRESS, AWAITING_PAYMENT, PAID
+
status__in — Multiple statuses: ['SCHEDULED', 'COMPLETED']
+
+
+ + {/* DateTime Comparison Filters */} +
+ DateTime Comparisons (ISO format: YYYY-MM-DDTHH:MM:SS) +
+
start_time__gt — After this time
+
start_time__gte — On or after this time
+
start_time__lt — Before this time
+
start_time__lte — On or before this time
+
end_time__gt — Ends after this time
+
end_time__gte — Ends on or after
+
end_time__lt — Ends before this time
+
end_time__lte — Ends on or before
+
created_at__gte — Created on or after
+
created_at__lte — Created on or before
+
updated_at__gte — Updated on or after
+
updated_at__lte — Updated on or before
+
+
+ + {/* Shortcut Date Filters */} +
+ Date Shortcuts (YYYY-MM-DD format) +
+
start_date — Appointments starting on or after this date
+
end_date — Appointments starting on or before this date
+
+
+ + {/* Related Object Filters */} +
+ Related Objects +
+
service_id — Filter by service ID
+
location_id — Filter by location ID
+
customer_id — Filter by customer ID
+
resource_id — Filter by resource/staff ID
+
+
+ + {/* Text Search Filters */} +
+ Text Search (case-insensitive) +
+
title__icontains — Title contains text
+
notes__icontains — Notes contain text
+
+
+ + {/* Numeric Comparisons */} +
+ Price/Amount Comparisons +
+
deposit_amount__gte — Deposit ≥ amount
+
deposit_amount__lte — Deposit ≤ amount
+
final_price__gte — Final price ≥ amount
+
final_price__lte — Final price ≤ amount
+
has_deposit — True/False: has a deposit
+
has_final_price — True/False: has final price
+
+
+ + {/* Pagination */} +
+ Pagination +
+
limit — Max results (default 100, max 1000)
+
+
+ +
- - - +
+ + api.create_appointment(title, start_time, end_time, notes) → dict + +
+
+
+ Parameters +
+
title — Appointment title (required)
+
start_time — ISO datetime (required)
+
end_time — ISO datetime (required)
+
notes — Optional notes
+
+
+ +
+
-# Send email -api.send_email( - to='customer@example.com', - subject='Hello!', - body='Thanks for being a great customer!' -) + {/* update_appointment */} +
+
+ + api.update_appointment(id, **updates) → dict + +
+
+
+ Updateable Fields +
+
title — New title
+
status — SCHEDULED, COMPLETED, CANCELED, NO_SHOW
+
notes — Updated notes
+
start_time / end_time — Reschedule
+
+
+ +
+
-# Create appointment -apt = api.create_appointment( - title='Follow-up', - start_time='2025-02-01T10:00:00', - end_time='2025-02-01T11:00:00', - notes='Auto-created' -) + {/* get_recurring_appointments */} +
+
+ Requires: recurring_appointments + + api.get_recurring_appointments(**filters) → List[dict] + +
+
+
+ Filters +
+
id — Exact recurring appointment ID
+
status — active, paused, canceled
+
status__in — Multiple statuses ['active', 'paused']
+
title__icontains — Title contains text
+
recurring_pattern__icontains — Recurrence rule contains
+
start_time__gte/lte/gt/lt — Time comparisons
+
customer_id — Filter by customer ID
+
resource_id — Filter by resource ID
+
limit — Max results (default 100, max 500)
+
+
+ +
+
-# Log for debugging -api.log(f'Processed {len(appointments)} appointments')`} - /> + {/* get_customers */} +
+
+
+

Customers

+
+ + api.get_customers(**filters) → List[dict] + +
+
+
+ Filters +
+
id — Exact customer ID
+
email — Exact email match
+
email__icontains — Email contains text
+
name__icontains — Name contains text
+
has_email — true/false, filter by email presence
+
has_phone — true/false, filter by phone presence
+
is_active — Filter by active status
+
created_at__gte/lte/gt/lt — Date comparisons (ISO format)
+
limit — Max results (default 100, max 1000)
+
+
+ +
+
+ + {/* get_resources */} +
+
+
+

Resources

+
+ + api.get_resources(**filters) → List[dict] + +
+
+
+ Filters +
+
id — Exact resource ID
+
type — STAFF, ROOM, or EQUIPMENT
+
type__in — Multiple types ['STAFF', 'ROOM']
+
name__icontains — Name contains text
+
description__icontains — Description contains text
+
is_active — true/false (default: true)
+
is_mobile — Filter by mobile capability
+
location_id — Filter by location
+
user_id — Filter by linked user ID
+
max_concurrent_events__gte/lte — Concurrency comparisons
+
limit — Max results (default 100, max 500)
+
+
+ +
+
+ + {/* get_resource_availability */} +
+
+ + api.get_resource_availability(resource_id, ...) → dict + +
+
+
+ Parameters +
+
resource_id — Resource ID (required)
+
days — Number of days to check
+
start_date — Start of period (YYYY-MM-DD)
+
end_date — End of period (YYYY-MM-DD)
+
+
+ +
+
+ + {/* get_services */} +
+
+
+

Services

+
+ + api.get_services(**filters) → List[dict] + +
+
+
+ Filters +
+
id — Exact service ID
+
name__icontains — Name contains text
+
description__icontains — Description contains text
+
is_active — true/false (default: true)
+
is_global — Filter global services
+
variable_pricing — Filter by variable pricing
+
requires_deposit — Filter by deposit required
+
location_id — Filter by location
+
duration__gte/lte — Duration comparisons (minutes)
+
price__gte/lte — Price comparisons (decimal)
+
price_cents__gte/lte — Price comparisons (cents)
+
limit — Max results (default 100, max 500)
+
+
+ +
+
+ + {/* get_service_stats */} +
+
+ + api.get_service_stats(service_id, days) → dict + +
+
+
+ Parameters +
+
service_id — Service ID (required)
+
days — Period in days (default 30)
+
+
+ +
+
+ + {/* get_locations */} +
+
+
+

Locations

+
+ + api.get_locations(**filters) → List[dict] + +
+
+
+ Filters +
+
id — Exact location ID
+
name__icontains — Name contains text
+
city__icontains — City contains text
+
state — Exact state match
+
country — Exact country match
+
postal_code — Exact postal code
+
timezone — Exact timezone
+
is_active — true/false (default: true)
+
is_primary — Filter primary location
+
has_phone — Has phone number
+
has_email — Has email address
+
limit — Max results (default 100, max 500)
+
+
+ +
+
+ + {/* get_location_stats */} +
+
+ Requires: multi_location + + api.get_location_stats(location_id, days) → dict + +
+
+
+ Parameters +
+
location_id — Location ID (required)
+
days — Period in days (default 30)
+
+
+ +
+
+ + {/* get_staff */} +
+
+
+

Staff

+
+ + api.get_staff(**filters) → List[dict] + +
+
+
+ Filters +
+
id — Exact staff ID
+
role — staff, manager, or owner
+
role__in — Multiple roles ['staff', 'manager']
+
email — Exact email match
+
email__icontains — Email contains text
+
name__icontains — Full name contains text
+
first_name__icontains — First name contains text
+
last_name__icontains — Last name contains text
+
is_active — true/false (default: true)
+
has_phone — Has phone number
+
limit — Max results (default 100, max 500)
+
+
+ +
+
+ + {/* get_staff_performance */} +
+
+ + api.get_staff_performance(staff_id=None, days=30) → dict + +
+
+
+ Parameters +
+
staff_id — Specific staff (optional, omit for all)
+
days — Period in days (default 30)
+
+
+ +
+
+ + {/* send_email */} +
+
+
+

Communication

+
+ + api.send_email(to, subject, body) → bool + +
+
+
+ Parameters +
+
to — Email address OR customer ID (int)
+
subject — Email subject (max 200 chars)
+
body — Email body (max 10,000 chars)
+
+
+ +
+
+ + {/* send_sms */} +
+
+ Requires: sms_enabled + + api.send_sms(to, message) → bool + +
+
+
+ Parameters +
+
to — Phone number in E.164 format (+15551234567)
+
message — SMS text (max 1600 chars)
+
+
+ +
+
+ + {/* get_sms_balance */} +
+
+ Requires: sms_enabled + + api.get_sms_balance() → dict + +
+
+ +
+
+ + {/* get_email_templates */} +
+
+ Requires: can_use_email_templates + + api.get_email_templates(**filters) → List[dict] + +
+
+
+ Filters +
+
id — Exact template ID
+
template_type — Exact template type
+
template_type__in — Multiple template types
+
name__icontains — Name contains text
+
subject__icontains — Subject contains text
+
is_active — true/false (default: true)
+
created_at__gte/lte/gt/lt — Date comparisons (ISO format)
+
limit — Max results (default 100, max 500)
+
+
+ +
+
+ + {/* send_template_email */} +
+
+ Requires: can_use_email_templates + + api.send_template_email(template_id, to, variables) → bool + +
+
+
+ Parameters +
+
template_id — Email template ID
+
to — Recipient email address
+
variables — Dict of template variables
+
+
+ +
+
+ + {/* get_payments */} +
+
+
+

Payments

+ Requires: payment_processing +
+ + api.get_payments(**filters) → List[dict] + +
+
+
+ Filters +
+
id — Exact payment ID
+
status — pending, completed, failed, refunded
+
status__in — Multiple statuses ['completed', 'pending']
+
currency — Currency code (USD, EUR, etc.)
+
customer_id — Filter by customer ID
+
customer_email__icontains — Customer email contains text
+
amount__gte/lte — Amount comparisons (decimal)
+
amount_cents__gte/lte — Amount comparisons (cents)
+
created_at__gte/lte/gt/lt — Date comparisons (ISO format)
+
completed_at__gte/lte/gt/lt — Completion date comparisons
+
days_back — Limit to last N days
+
limit — Max results (default 100, max 500)
+
+
+ +
+
+ + {/* get_invoices */} +
+
+ Requires: payment_processing + + api.get_invoices(**filters) → List[dict] + +
+
+
+ Filters +
+
id — Exact invoice ID
+
status — draft, sent, paid, overdue, canceled
+
status__in — Multiple statuses ['paid', 'sent']
+
currency — Currency code (USD, EUR, etc.)
+
plan_name__icontains — Plan name contains text
+
total__gte/lte — Total amount comparisons
+
total_cents__gte/lte — Total comparisons (cents)
+
created_at__gte/lte/gt/lt — Date comparisons (ISO format)
+
paid_at__gte/lte/gt/lt — Payment date comparisons
+
period_start__gte/lte/gt/lt — Period start comparisons
+
period_end__gte/lte/gt/lt — Period end comparisons
+
days_back — Limit to last N days
+
limit — Max results (default 100, max 500)
+
+
+ +
+
+ + {/* get_revenue_stats */} +
+
+ Requires: payment_processing + + api.get_revenue_stats(days) → dict + +
+
+
+ Parameters +
+
days — Period in days (default 30)
+
+
+ +
+
+ + {/* get_contracts */} +
+
+
+

Contracts

+ Requires: can_use_contracts +
+ + api.get_contracts(**filters) → List[dict] + +
+
+
+ Filters +
+
id — Exact contract ID
+
status — draft, sent, signed, expired, canceled
+
status__in — Multiple statuses ['sent', 'signed']
+
customer_id — Filter by customer ID
+
customer_email__icontains — Customer email contains text
+
title__icontains — Title contains text
+
template_name__icontains — Template name contains text
+
expires_at__gte/lte/gt/lt — Expiration date comparisons
+
sent_at__gte/lte/gt/lt — Sent date comparisons
+
created_at__gte/lte/gt/lt — Date comparisons (ISO format)
+
limit — Max results (default 100, max 500)
+
+
+ +
+
+ + {/* get_expiring_contracts */} +
+
+ Requires: can_use_contracts + + api.get_expiring_contracts(days) → List[dict] + +
+
+
+ Parameters +
+
days — Contracts expiring within N days (default 30)
+
+
+ +
+
+ + {/* create_video_meeting */} +
+
+
+

Video

+ Requires: can_add_video_conferencing +
+ + api.create_video_meeting(...) → dict + +
+
+
+ Parameters +
+
provider — zoom, google_meet, or teams
+
title — Meeting title
+
duration — Duration in minutes
+
start_time — ISO datetime (optional)
+
+
+ +
+
+ + {/* get_analytics */} +
+
+
+

Analytics

+ Requires: advanced_reporting +
+ + api.get_analytics(**filters) → dict + +
+
+
+ Parameters +
+
days — Period in days (default 30)
+
metrics — List: bookings, customers, services, staff, revenue
+
+
+ +
+
+ + {/* get_booking_trends */} +
+
+ Requires: advanced_reporting + + api.get_booking_trends(days, group_by) → dict + +
+
+
+ Parameters +
+
days — Period in days (default 30)
+
group_by — day, week, hour, or weekday
+
+
+ +
+
+ + {/* Utilities */} +
+
+

Utilities

+
+
+
+
+ Log message for debugging + api.log(message) → None +
+
+ Count items in a list + api.count(items) → int +
+
+ Sum numeric values + api.sum(items) → float +
+
+ Filter by condition + api.filter(items, lambda) → list +
+
+ +
+
+ + {/* HTTP Methods */} +
+
+
+
+

+ + External HTTP Methods +

+

All URLs must be whitelisted before use

+
+
+
+
+
+
+ Fetch data from URL + api.http_get(url, headers) → str +
+
+ Send data to URL + api.http_post(url, data, headers) → str +
+
+ Update resource + api.http_put(url, data, headers) → str +
+
+ Partial update + api.http_patch(url, data, headers) → str +
+
+ Delete resource + api.http_delete(url, headers) → str +
+
+ +
+

To request URL whitelisting:

+

+ Email pluginaccess@smoothschedule.com with + the URLs, HTTP methods needed, and an exact copy of your code. +

+
+
+
-# HTTP PUT - Update resource -api.http_put( - url='https://api.example.com/resource/123', - data={'status': 'completed'}, - headers={'Authorization': 'Bearer token123'} -) - -# HTTP PATCH - Partial update -api.http_patch( - url='https://api.example.com/resource/123', - data={'notes': 'Updated notes'}, - headers={'Authorization': 'Bearer token123'} -) - -# HTTP DELETE - Remove resource -api.http_delete( - url='https://api.example.com/resource/123', - headers={'Authorization': 'Bearer token123'} -) - -# All HTTP methods require whitelisting! -# See warning below for approval process.`} - /> - - +
+
{/* Command Reference */} diff --git a/frontend/src/pages/platform/components/BusinessEditModal.tsx b/frontend/src/pages/platform/components/BusinessEditModal.tsx index f3bc4de..2d7ba3f 100644 --- a/frontend/src/pages/platform/components/BusinessEditModal.tsx +++ b/frontend/src/pages/platform/components/BusinessEditModal.tsx @@ -422,64 +422,18 @@ const BusinessEditModal: React.FC = ({ business, isOpen,

- {/* Limits Configuration */} + {/* Limits & Quotas - Dynamic from billing system */}
-

- Limits Configuration -

-

- Use -1 for unlimited. These limits control what this business can create. -

-
-
- - 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" - /> -
-
- - 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" - /> -
-
- - 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" - /> -
-
- - 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" - /> -
-
+ { + setFeatureValues(prev => ({ ...prev, [fieldName]: value })); + }} + featureType="integer" + headerTitle="Limits & Quotas" + showDescriptions + columns={4} + />
{/* Site Builder Access */} @@ -488,7 +442,7 @@ const BusinessEditModal: React.FC = ({ business, isOpen, Site Builder

- 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' : ''}.

= ({ 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 /> diff --git a/smoothschedule/smoothschedule/billing/management/commands/billing_seed_catalog.py b/smoothschedule/smoothschedule/billing/management/commands/billing_seed_catalog.py index a270a6d..237eb9c 100644 --- a/smoothschedule/smoothschedule/billing/management/commands/billing_seed_catalog.py +++ b/smoothschedule/smoothschedule/billing/management/commands/billing_seed_catalog.py @@ -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, }, }, ] diff --git a/smoothschedule/smoothschedule/platform/tenant_sites/models.py b/smoothschedule/smoothschedule/platform/tenant_sites/models.py index cca3efd..f0a1755 100644 --- a/smoothschedule/smoothschedule/platform/tenant_sites/models.py +++ b/smoothschedule/smoothschedule/platform/tenant_sites/models.py @@ -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) diff --git a/smoothschedule/smoothschedule/platform/tenant_sites/views.py b/smoothschedule/smoothschedule/platform/tenant_sites/views.py index f7ee2bf..f49b35d 100644 --- a/smoothschedule/smoothschedule/platform/tenant_sites/views.py +++ b/smoothschedule/smoothschedule/platform/tenant_sites/views.py @@ -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) diff --git a/smoothschedule/smoothschedule/scheduling/schedule/safe_scripting.py b/smoothschedule/smoothschedule/scheduling/schedule/safe_scripting.py index 088cfab..d9da133 100644 --- a/smoothschedule/smoothschedule/scheduling/schedule/safe_scripting.py +++ b/smoothschedule/smoothschedule/scheduling/schedule/safe_scripting.py @@ -56,42 +56,141 @@ class SafeScriptAPI: def get_appointments(self, **filters): """ - Get appointments for this business. + Get appointments for this business with comprehensive filtering. - Args: - status: Filter by status (SCHEDULED, COMPLETED, CANCELED) - start_date: Filter by start date (YYYY-MM-DD) - end_date: Filter by end date (YYYY-MM-DD) - limit: Maximum results (default: 100, max: 1000) + Supported filters: + - status: Exact status match (SCHEDULED, COMPLETED, CANCELED, etc.) + - status__in: List of statuses ['SCHEDULED', 'COMPLETED'] + + DateTime comparisons (ISO format string 'YYYY-MM-DDTHH:MM:SS'): + - start_time__gt, start_time__gte, start_time__lt, start_time__lte + - end_time__gt, end_time__gte, end_time__lt, end_time__lte + - created_at__gt, created_at__gte, created_at__lt, created_at__lte + - updated_at__gt, updated_at__gte, updated_at__lt, updated_at__lte + + Date shortcuts (YYYY-MM-DD format): + - start_date: Appointments starting on or after this date + - end_date: Appointments starting on or before this date + + Related objects: + - service_id: Filter by service ID + - location_id: Filter by location ID + - customer_id: Filter by customer ID (via participants) + - resource_id: Filter by resource ID (via participants) + + Text search (case-insensitive): + - title__icontains: Title contains text + - notes__icontains: Notes contains text + + Numeric comparisons: + - deposit_amount__gt, deposit_amount__gte, deposit_amount__lt, deposit_amount__lte + - final_price__gt, final_price__gte, final_price__lt, final_price__lte + + Boolean helpers: + - has_deposit: True = has deposit, False = no deposit + - has_final_price: True = has final price, False = no final price + + Pagination: + - limit: Maximum results (default: 100, max: 1000) Returns: - List of appointment dictionaries + List of appointment dictionaries with full data """ self._check_api_limit() from .models import Event from django.utils import timezone from datetime import datetime + from dateutil.parser import parse as parse_datetime - queryset = Event.objects.all() + queryset = Event.objects.all().select_related('service', 'location') - # Apply filters + # Helper to parse datetime strings + def parse_dt(value): + if isinstance(value, datetime): + return value if timezone.is_aware(value) else timezone.make_aware(value) + try: + dt = parse_datetime(value) + return dt if timezone.is_aware(dt) else timezone.make_aware(dt) + except (ValueError, TypeError): + # Try date-only format + try: + dt = datetime.strptime(value, '%Y-%m-%d') + return timezone.make_aware(dt) + except (ValueError, TypeError): + return None + + # Status filters if 'status' in filters: queryset = queryset.filter(status=filters['status']) + if 'status__in' in filters: + queryset = queryset.filter(status__in=filters['status__in']) + # Legacy date filters (for backwards compatibility) if 'start_date' in filters: - start = datetime.strptime(filters['start_date'], '%Y-%m-%d') - queryset = queryset.filter(start_time__gte=timezone.make_aware(start)) - + dt = parse_dt(filters['start_date']) + if dt: + queryset = queryset.filter(start_time__gte=dt) if 'end_date' in filters: - end = datetime.strptime(filters['end_date'], '%Y-%m-%d') - queryset = queryset.filter(start_time__lte=timezone.make_aware(end)) + dt = parse_dt(filters['end_date']) + if dt: + queryset = queryset.filter(start_time__lte=dt) + + # DateTime comparison filters + datetime_fields = ['start_time', 'end_time', 'created_at', 'updated_at'] + comparison_ops = ['__gt', '__gte', '__lt', '__lte'] + + for field in datetime_fields: + for op in comparison_ops: + key = f'{field}{op}' + if key in filters: + dt = parse_dt(filters[key]) + if dt: + queryset = queryset.filter(**{key: dt}) + + # Related object filters + if 'service_id' in filters: + queryset = queryset.filter(service_id=filters['service_id']) + if 'location_id' in filters: + queryset = queryset.filter(location_id=filters['location_id']) + + # Participant-based filters + if 'customer_id' in filters: + queryset = queryset.filter(participants__user_id=filters['customer_id']).distinct() + if 'resource_id' in filters: + queryset = queryset.filter(participants__resource_id=filters['resource_id']).distinct() + + # Text search filters + if 'title__icontains' in filters: + queryset = queryset.filter(title__icontains=filters['title__icontains']) + if 'notes__icontains' in filters: + queryset = queryset.filter(notes__icontains=filters['notes__icontains']) + + # Numeric comparison filters + numeric_fields = ['deposit_amount', 'final_price'] + for field in numeric_fields: + for op in comparison_ops: + key = f'{field}{op}' + if key in filters: + queryset = queryset.filter(**{key: filters[key]}) + + # Boolean helper filters + if 'has_deposit' in filters: + if filters['has_deposit']: + queryset = queryset.filter(deposit_amount__isnull=False).exclude(deposit_amount=0) + else: + queryset = queryset.filter(deposit_amount__isnull=True) | queryset.filter(deposit_amount=0) + if 'has_final_price' in filters: + if filters['has_final_price']: + queryset = queryset.filter(final_price__isnull=False) + else: + queryset = queryset.filter(final_price__isnull=True) # Enforce limits limit = min(filters.get('limit', 100), 1000) queryset = queryset[:limit] - # Serialize to safe dictionaries + # Serialize to safe dictionaries with comprehensive data return [ { 'id': event.id, @@ -100,29 +199,98 @@ class SafeScriptAPI: 'end_time': event.end_time.isoformat(), 'status': event.status, 'notes': event.notes, + 'created_at': event.created_at.isoformat() if event.created_at else None, + 'updated_at': event.updated_at.isoformat() if event.updated_at else None, + 'service_id': event.service_id, + 'service_name': event.service.name if event.service else None, + 'location_id': event.location_id, + 'location_name': event.location.name if event.location else None, + 'deposit_amount': float(event.deposit_amount) if event.deposit_amount else None, + 'final_price': float(event.final_price) if event.final_price else None, } for event in queryset ] def get_customers(self, **filters): """ - Get customers for this business. + Get customers for this business with comprehensive filtering. - Args: - limit: Maximum results (default: 100, max: 1000) - has_email: Filter to customers with email addresses + Supported filters: + - id: Exact customer ID + - email: Exact email match + - email__icontains: Email contains text (case-insensitive) + - name__icontains: Name contains text (case-insensitive) + - has_email: True = has email, False = no email + - has_phone: True = has phone, False = no phone + - is_active: Filter by active status (default: True) + - created_at__gte, created_at__lte: Filter by creation date + - limit: Maximum results (default: 100, max: 1000) Returns: - List of customer dictionaries + List of customer dictionaries with fields: + - id, email, name, phone, first_name, last_name, is_active, created_at """ self._check_api_limit() from smoothschedule.identity.users.models import User + from dateutil.parser import parse as parse_datetime + from django.utils import timezone queryset = User.objects.filter(role='customer') - if filters.get('has_email'): - queryset = queryset.exclude(email='') + # ID filter + if 'id' in filters: + queryset = queryset.filter(id=filters['id']) + + # Email filters + if 'email' in filters: + queryset = queryset.filter(email=filters['email']) + if 'email__icontains' in filters: + queryset = queryset.filter(email__icontains=filters['email__icontains']) + + # Name filters (search across first_name, last_name, username) + if 'name__icontains' in filters: + from django.db.models import Q + search = filters['name__icontains'] + queryset = queryset.filter( + Q(first_name__icontains=search) | + Q(last_name__icontains=search) | + Q(username__icontains=search) + ) + + # Boolean helpers + if 'has_email' in filters: + if filters['has_email']: + queryset = queryset.exclude(email='').exclude(email__isnull=True) + else: + queryset = queryset.filter(Q(email='') | Q(email__isnull=True)) + + if 'has_phone' in filters: + if filters['has_phone']: + queryset = queryset.exclude(phone='').exclude(phone__isnull=True) + else: + queryset = queryset.filter(Q(phone='') | Q(phone__isnull=True)) + + # Active status + if 'is_active' in filters: + queryset = queryset.filter(is_active=filters['is_active']) + + # DateTime filters + def parse_dt(value): + if isinstance(value, str): + try: + dt = parse_datetime(value) + return dt if timezone.is_aware(dt) else timezone.make_aware(dt) + except (ValueError, TypeError): + return None + return value + + for op in ['__gte', '__lte', '__gt', '__lt']: + key = f'created_at{op}' + if key in filters: + dt = parse_dt(filters[key]) + if dt: + queryset = queryset.filter(**{f'date_joined{op}': dt}) limit = min(filters.get('limit', 100), 1000) queryset = queryset[:limit] @@ -132,7 +300,11 @@ class SafeScriptAPI: 'id': user.id, 'email': user.email, 'name': user.get_full_name() or user.username, + 'first_name': user.first_name, + 'last_name': user.last_name, 'phone': getattr(user, 'phone', ''), + 'is_active': user.is_active, + 'created_at': user.date_joined.isoformat() if user.date_joined else None, } for user in queryset ] @@ -529,6 +701,1718 @@ class SafeScriptAPI: """ return [item for item in items if condition(item)] + # ========================================================================= + # FEATURE CHECK HELPER + # ========================================================================= + + def _check_feature(self, feature_code: str, feature_name: str): + """ + Check if the business has a required feature enabled. + + Args: + feature_code: The billing feature code (e.g., 'sms_enabled') + feature_name: Human-readable name for error messages + + Raises: + ScriptExecutionError: If feature is not available + """ + if self.business and hasattr(self.business, 'has_feature'): + if not self.business.has_feature(feature_code): + raise ScriptExecutionError( + f"{feature_name} is not available on your plan. " + f"Please upgrade to access this feature." + ) + + # ========================================================================= + # SMS METHODS + # ========================================================================= + + def send_sms(self, to: str, message: str) -> bool: + """ + Send an SMS message to a phone number. + + Args: + to: Phone number (E.164 format recommended, e.g., +15551234567) + message: SMS message content (max 1600 characters) + + Returns: + True if sent successfully, False otherwise + + Requires: sms_enabled feature + """ + self._check_api_limit() + self._check_feature('sms_enabled', 'SMS messaging') + + # Validate phone number (basic check) + if not to or len(to) < 10: + raise ScriptExecutionError(f"Invalid phone number: {to}") + + # Normalize phone number + to = to.strip().replace(' ', '').replace('-', '').replace('(', '').replace(')', '') + + # Message length limit (SMS segment is 160 chars, allow up to 10 segments) + if len(message) > 1600: + raise ScriptExecutionError("SMS message too long (max 1600 characters)") + + try: + # Import Twilio or SMS service + from smoothschedule.communication.credits.services import SMSService + + sms_service = SMSService(business=self.business) + result = sms_service.send_sms(to=to, message=message) + + if result.get('success'): + logger.info(f"[Customer Script] SMS sent to {to[:6]}***") + return True + else: + logger.warning(f"[Customer Script] SMS failed: {result.get('error')}") + return False + + except ImportError: + logger.warning("SMS service not available") + return False + except Exception as e: + logger.error(f"Failed to send SMS: {e}") + return False + + def get_sms_balance(self) -> Dict[str, Any]: + """ + Get SMS credit balance for this business. + + Returns: + Dictionary with balance info: + - credits_remaining: Number of SMS credits left + - monthly_limit: Monthly SMS limit from plan + - credits_used_this_month: Credits used this billing period + + Requires: sms_enabled feature + """ + self._check_api_limit() + self._check_feature('sms_enabled', 'SMS messaging') + + try: + from smoothschedule.communication.credits.models import CreditBalance + + balance = CreditBalance.objects.filter( + business=self.business, + credit_type='sms' + ).first() + + # Get plan limit + monthly_limit = 0 + if self.business and hasattr(self.business, 'get_feature_value'): + monthly_limit = self.business.get_feature_value('max_sms_per_month') or 0 + + return { + 'credits_remaining': balance.balance if balance else 0, + 'monthly_limit': monthly_limit, + 'credits_used_this_month': balance.used_this_period if balance else 0, + } + except Exception as e: + logger.error(f"Failed to get SMS balance: {e}") + return {'credits_remaining': 0, 'monthly_limit': 0, 'credits_used_this_month': 0} + + # ========================================================================= + # RESOURCE METHODS + # ========================================================================= + + def get_resources(self, **filters) -> List[Dict[str, Any]]: + """ + Get resources (staff, rooms, equipment) for this business with comprehensive filtering. + + Supported filters: + - id: Exact resource ID + - type: Filter by type (STAFF, ROOM, EQUIPMENT) + - type__in: Multiple types ['STAFF', 'ROOM'] + - name__icontains: Name contains text (case-insensitive) + - description__icontains: Description contains text + - is_active: Filter by active status (default: True) + - is_mobile: Filter by mobile status + - location_id: Filter by location ID + - user_id: Filter by linked user ID + - max_concurrent_events__gte, max_concurrent_events__lte: Filter by concurrency + - limit: Maximum results (default: 100, max: 500) + + Returns: + List of resource dictionaries with fields: + - id, name, type, resource_type_name, description, is_active + - max_concurrent_events, location_id, location_name + - is_mobile, user_id, user_name, user_email + """ + self._check_api_limit() + + from .models import Resource + + queryset = Resource.objects.all() + + # ID filter + if 'id' in filters: + queryset = queryset.filter(id=filters['id']) + + # Type filters + if 'type' in filters: + queryset = queryset.filter(type=filters['type']) + if 'type__in' in filters: + queryset = queryset.filter(type__in=filters['type__in']) + + # Text search filters + if 'name__icontains' in filters: + queryset = queryset.filter(name__icontains=filters['name__icontains']) + if 'description__icontains' in filters: + queryset = queryset.filter(description__icontains=filters['description__icontains']) + + # Boolean/status filters + if 'is_active' in filters: + queryset = queryset.filter(is_active=filters['is_active']) + elif filters.get('is_active', True): # Default to True + queryset = queryset.filter(is_active=True) + + if 'is_mobile' in filters: + queryset = queryset.filter(is_mobile=filters['is_mobile']) + + # Related object filters + if 'location_id' in filters: + queryset = queryset.filter(location_id=filters['location_id']) + if 'user_id' in filters: + queryset = queryset.filter(user_id=filters['user_id']) + + # Numeric comparison filters + for op in ['__gte', '__lte', '__gt', '__lt']: + key = f'max_concurrent_events{op}' + if key in filters: + queryset = queryset.filter(**{key: filters[key]}) + + # Enforce limits + limit = min(filters.get('limit', 100), 500) + queryset = queryset.select_related('location', 'user', 'resource_type')[:limit] + + return [ + { + 'id': r.id, + 'name': r.name, + 'type': r.type, + 'resource_type_name': r.resource_type.name if r.resource_type else r.type, + 'description': r.description, + 'is_active': r.is_active, + 'max_concurrent_events': r.max_concurrent_events, + 'location_id': r.location_id, + 'location_name': r.location.name if r.location else None, + 'is_mobile': r.is_mobile, + 'user_id': r.user_id, + 'user_name': r.user.get_full_name() if r.user else None, + 'user_email': r.user.email if r.user else None, + } + for r in queryset + ] + + def get_resource_availability( + self, + resource_id: int, + start_date: str = None, + end_date: str = None, + days: int = 7 + ) -> Dict[str, Any]: + """ + Get availability information for a specific resource. + + Args: + resource_id: ID of the resource + start_date: Start date (YYYY-MM-DD), defaults to today + end_date: End date (YYYY-MM-DD), defaults to start_date + days + days: Number of days to check (default: 7, max: 30) + + Returns: + Dictionary with: + - resource_id, resource_name + - total_slots: Total bookable time slots + - booked_slots: Number of booked slots + - available_slots: Number of available slots + - utilization: Booking percentage (0-100) + - appointments: List of appointments in the period + """ + self._check_api_limit() + + from .models import Resource, Event + from django.utils import timezone + from datetime import datetime, timedelta + + # Limit days + days = min(days, 30) + + # Parse dates + if start_date: + start = timezone.make_aware(datetime.strptime(start_date, '%Y-%m-%d')) + else: + start = timezone.now().replace(hour=0, minute=0, second=0, microsecond=0) + + if end_date: + end = timezone.make_aware(datetime.strptime(end_date, '%Y-%m-%d')) + else: + end = start + timedelta(days=days) + + # Get resource + try: + resource = Resource.objects.get(id=resource_id) + except Resource.DoesNotExist: + raise ScriptExecutionError(f"Resource {resource_id} not found") + + # Get appointments for this resource in the period + from .models import Participant + from django.contrib.contenttypes.models import ContentType + + resource_ct = ContentType.objects.get_for_model(Resource) + participant_event_ids = Participant.objects.filter( + content_type=resource_ct, + object_id=resource_id, + event__start_time__gte=start, + event__start_time__lt=end + ).values_list('event_id', flat=True) + + appointments = Event.objects.filter(id__in=participant_event_ids) + + # Calculate utilization (simplified: hours booked / total hours) + total_hours = days * 8 # Assume 8 working hours per day + booked_hours = sum( + (apt.end_time - apt.start_time).total_seconds() / 3600 + for apt in appointments + if apt.status in ['SCHEDULED', 'COMPLETED', 'PAID'] + ) + utilization = (booked_hours / total_hours * 100) if total_hours > 0 else 0 + + return { + 'resource_id': resource.id, + 'resource_name': resource.name, + 'period_start': start.isoformat(), + 'period_end': end.isoformat(), + 'total_hours': total_hours, + 'booked_hours': round(booked_hours, 2), + 'available_hours': round(total_hours - booked_hours, 2), + 'utilization': round(utilization, 1), + 'appointment_count': appointments.count(), + 'appointments': [ + { + 'id': apt.id, + 'title': apt.title, + 'start_time': apt.start_time.isoformat(), + 'end_time': apt.end_time.isoformat(), + 'status': apt.status, + } + for apt in appointments[:50] # Limit to 50 appointments + ] + } + + # ========================================================================= + # SERVICE METHODS + # ========================================================================= + + def get_services(self, **filters) -> List[Dict[str, Any]]: + """ + Get services offered by this business with comprehensive filtering. + + Supported filters: + - id: Exact service ID + - name__icontains: Name contains text (case-insensitive) + - description__icontains: Description contains text + - is_active: Filter by active status (default: True) + - is_global: Filter by global status + - variable_pricing: Filter by variable pricing + - requires_deposit: Filter by deposit requirement + - location_id: Filter by location ID + - duration__gte, duration__lte: Filter by duration (minutes) + - price__gte, price__lte: Filter by price (dollars) + - price_cents__gte, price_cents__lte: Filter by price (cents) + - limit: Maximum results (default: 100, max: 500) + + Returns: + List of service dictionaries with fields: + - id, name, description, duration, price, price_cents + - is_active, variable_pricing, requires_deposit + - deposit_amount_cents, deposit_percent, prep_time, takedown_time, is_global + """ + self._check_api_limit() + + from .models import Service + from django.db.models import Q + + queryset = Service.objects.all() + + # ID filter + if 'id' in filters: + queryset = queryset.filter(id=filters['id']) + + # Text search filters + if 'name__icontains' in filters: + queryset = queryset.filter(name__icontains=filters['name__icontains']) + if 'description__icontains' in filters: + queryset = queryset.filter(description__icontains=filters['description__icontains']) + + # Boolean filters + if 'is_active' in filters: + queryset = queryset.filter(is_active=filters['is_active']) + elif filters.get('is_active', True): # Default to True + queryset = queryset.filter(is_active=True) + + if 'is_global' in filters: + queryset = queryset.filter(is_global=filters['is_global']) + if 'variable_pricing' in filters: + queryset = queryset.filter(variable_pricing=filters['variable_pricing']) + if 'requires_deposit' in filters: + queryset = queryset.filter(requires_deposit=filters['requires_deposit']) + + # Location filter + if 'location_id' in filters: + queryset = queryset.filter( + Q(is_global=True) | Q(locations__id=filters['location_id']) + ).distinct() + + # Numeric comparison filters + for field in ['duration', 'price_cents', 'deposit_amount_cents', 'prep_time', 'takedown_time']: + for op in ['__gte', '__lte', '__gt', '__lt']: + key = f'{field}{op}' + if key in filters: + queryset = queryset.filter(**{key: filters[key]}) + + # Price (dollars) comparison - convert to cents + for op in ['__gte', '__lte', '__gt', '__lt']: + key = f'price{op}' + if key in filters: + cents = int(filters[key] * 100) + queryset = queryset.filter(**{f'price_cents{op}': cents}) + + # Enforce limits + limit = min(filters.get('limit', 100), 500) + queryset = queryset[:limit] + + return [ + { + 'id': s.id, + 'name': s.name, + 'description': s.description, + 'duration': s.duration, + 'price': float(s.price), + 'price_cents': s.price_cents, + 'is_active': s.is_active, + 'variable_pricing': s.variable_pricing, + 'requires_deposit': s.requires_deposit, + 'deposit_amount_cents': s.deposit_amount_cents, + 'deposit_percent': float(s.deposit_percent) if s.deposit_percent else None, + 'prep_time': s.prep_time, + 'takedown_time': s.takedown_time, + 'is_global': s.is_global, + } + for s in queryset + ] + + def get_service_stats(self, service_id: int, days: int = 30) -> Dict[str, Any]: + """ + Get booking statistics for a specific service. + + Args: + service_id: ID of the service + days: Number of days to analyze (default: 30, max: 90) + + Returns: + Dictionary with: + - service_id, service_name + - total_bookings: Total appointments + - completed_bookings: Completed appointments + - canceled_bookings: Canceled appointments + - total_revenue_cents: Total revenue in cents + - average_rating: Average customer rating (if available) + """ + self._check_api_limit() + + from .models import Service, Event + from django.utils import timezone + from datetime import timedelta + + days = min(days, 90) + start_date = timezone.now() - timedelta(days=days) + + try: + service = Service.objects.get(id=service_id) + except Service.DoesNotExist: + raise ScriptExecutionError(f"Service {service_id} not found") + + # Get appointments for this service + appointments = Event.objects.filter( + service_id=service_id, + start_time__gte=start_date + ) + + completed = appointments.filter(status__in=['COMPLETED', 'PAID']) + canceled = appointments.filter(status='CANCELED') + + # Calculate revenue (simplified) + total_revenue = sum( + apt.price_cents or service.price_cents + for apt in completed + ) + + return { + 'service_id': service.id, + 'service_name': service.name, + 'period_days': days, + 'total_bookings': appointments.count(), + 'completed_bookings': completed.count(), + 'canceled_bookings': canceled.count(), + 'completion_rate': round( + completed.count() / appointments.count() * 100, 1 + ) if appointments.count() > 0 else 0, + 'total_revenue_cents': total_revenue, + 'total_revenue': total_revenue / 100, + 'average_revenue_per_booking': round( + total_revenue / completed.count() / 100, 2 + ) if completed.count() > 0 else 0, + } + + # ========================================================================= + # PAYMENT / INVOICE METHODS + # ========================================================================= + + def get_payments(self, **filters) -> List[Dict[str, Any]]: + """ + Get payment records for this business with comprehensive filtering. + + Supported filters: + - id: Exact payment ID + - status: Filter by status (completed, pending, failed, refunded) + - status__in: Multiple statuses ['completed', 'pending'] + - currency: Filter by currency code + - customer_id: Filter by customer ID + - customer_email__icontains: Customer email contains text + - amount__gte, amount__lte: Filter by amount (dollars) + - amount_cents__gte, amount_cents__lte: Filter by amount (cents) + - created_at__gte, created_at__lte: Filter by creation date + - completed_at__gte, completed_at__lte: Filter by completion date + - days_back: Get payments from last N days (default: 30, max: 365) + - limit: Maximum results (default: 100, max: 500) + + Returns: + List of payment dictionaries + + Requires: payment_processing feature + """ + self._check_api_limit() + self._check_feature('payment_processing', 'Payment processing') + + from django.utils import timezone + from datetime import timedelta + from dateutil.parser import parse as parse_datetime + + def parse_dt(value): + if isinstance(value, str): + try: + dt = parse_datetime(value) + return dt if timezone.is_aware(dt) else timezone.make_aware(dt) + except (ValueError, TypeError): + return None + return value + + limit = min(filters.get('limit', 100), 500) + + try: + from smoothschedule.commerce.payments.models import Payment + + queryset = Payment.objects.filter(business=self.business) + + # ID filter + if 'id' in filters: + queryset = queryset.filter(id=filters['id']) + + # Status filters + if 'status' in filters: + queryset = queryset.filter(status=filters['status']) + if 'status__in' in filters: + queryset = queryset.filter(status__in=filters['status__in']) + + # Currency filter + if 'currency' in filters: + queryset = queryset.filter(currency=filters['currency']) + + # Customer filters + if 'customer_id' in filters: + queryset = queryset.filter(customer_id=filters['customer_id']) + if 'customer_email__icontains' in filters: + queryset = queryset.filter(customer__email__icontains=filters['customer_email__icontains']) + + # Amount filters (cents) + for op in ['__gte', '__lte', '__gt', '__lt']: + key = f'amount_cents{op}' + if key in filters: + queryset = queryset.filter(**{key: filters[key]}) + + # Amount filters (dollars - convert to cents) + for op in ['__gte', '__lte', '__gt', '__lt']: + key = f'amount{op}' + if key in filters: + cents = int(filters[key] * 100) + queryset = queryset.filter(**{f'amount_cents{op}': cents}) + + # DateTime filters + for field in ['created_at', 'completed_at']: + for op in ['__gte', '__lte', '__gt', '__lt']: + key = f'{field}{op}' + if key in filters: + dt = parse_dt(filters[key]) + if dt: + queryset = queryset.filter(**{key: dt}) + + # Legacy days_back filter + if 'days_back' in filters and 'created_at__gte' not in filters: + days_back = min(filters.get('days_back', 30), 365) + start_date = timezone.now() - timedelta(days=days_back) + queryset = queryset.filter(created_at__gte=start_date) + + queryset = queryset.select_related('customer')[:limit] + + return [ + { + 'id': p.id, + 'amount_cents': p.amount_cents, + 'amount': p.amount_cents / 100, + 'status': p.status, + 'currency': p.currency, + 'customer_id': p.customer_id, + 'customer_email': p.customer.email if p.customer else None, + 'customer_name': p.customer.get_full_name() if p.customer else None, + 'description': getattr(p, 'description', ''), + 'created_at': p.created_at.isoformat(), + 'completed_at': p.completed_at.isoformat() if hasattr(p, 'completed_at') and p.completed_at else None, + } + for p in queryset + ] + except ImportError: + logger.warning("Payment model not available") + return [] + + def get_invoices(self, **filters) -> List[Dict[str, Any]]: + """ + Get invoices for this business with comprehensive filtering. + + Supported filters: + - id: Exact invoice ID + - status: Filter by status (draft, open, paid, void, refunded) + - status__in: Multiple statuses ['paid', 'open'] + - currency: Filter by currency code + - plan_name__icontains: Plan name contains text + - total__gte, total__lte: Filter by total (dollars) + - total_cents__gte, total_cents__lte: Filter by total (cents) + - created_at__gte, created_at__lte: Filter by creation date + - paid_at__gte, paid_at__lte: Filter by payment date + - period_start__gte, period_start__lte: Filter by period start + - period_end__gte, period_end__lte: Filter by period end + - days_back: Get invoices from last N days (default: 90, max: 365) + - limit: Maximum results (default: 100, max: 500) + + Returns: + List of invoice dictionaries + + Requires: payment_processing feature + """ + self._check_api_limit() + self._check_feature('payment_processing', 'Payment processing') + + from smoothschedule.billing.models import Invoice + from django.utils import timezone + from datetime import timedelta + from dateutil.parser import parse as parse_datetime + + def parse_dt(value): + if isinstance(value, str): + try: + dt = parse_datetime(value) + return dt if timezone.is_aware(dt) else timezone.make_aware(dt) + except (ValueError, TypeError): + return None + return value + + limit = min(filters.get('limit', 100), 500) + queryset = Invoice.objects.filter(business=self.business) + + # ID filter + if 'id' in filters: + queryset = queryset.filter(id=filters['id']) + + # Status filters + if 'status' in filters: + queryset = queryset.filter(status=filters['status']) + if 'status__in' in filters: + queryset = queryset.filter(status__in=filters['status__in']) + + # Currency filter + if 'currency' in filters: + queryset = queryset.filter(currency=filters['currency']) + + # Text search + if 'plan_name__icontains' in filters: + queryset = queryset.filter(plan_name_at_billing__icontains=filters['plan_name__icontains']) + + # Amount filters (cents) + for op in ['__gte', '__lte', '__gt', '__lt']: + key = f'total_cents{op}' + if key in filters: + queryset = queryset.filter(**{f'total_amount{op}': filters[key]}) + + # Amount filters (dollars - convert to cents) + for op in ['__gte', '__lte', '__gt', '__lt']: + key = f'total{op}' + if key in filters: + cents = int(filters[key] * 100) + queryset = queryset.filter(**{f'total_amount{op}': cents}) + + # DateTime filters + for field in ['created_at', 'paid_at', 'period_start', 'period_end']: + for op in ['__gte', '__lte', '__gt', '__lt']: + key = f'{field}{op}' + if key in filters: + dt = parse_dt(filters[key]) + if dt: + queryset = queryset.filter(**{key: dt}) + + # Legacy days_back filter + if 'days_back' in filters and 'created_at__gte' not in filters: + days_back = min(filters.get('days_back', 90), 365) + start_date = timezone.now() - timedelta(days=days_back) + queryset = queryset.filter(created_at__gte=start_date) + + queryset = queryset[:limit] + + return [ + { + 'id': inv.id, + 'status': inv.status, + 'currency': inv.currency, + 'subtotal_cents': inv.subtotal_amount, + 'subtotal': inv.subtotal_amount / 100, + 'tax_cents': inv.tax_amount, + 'tax': inv.tax_amount / 100, + 'total_cents': inv.total_amount, + 'total': inv.total_amount / 100, + 'period_start': inv.period_start.isoformat(), + 'period_end': inv.period_end.isoformat(), + 'plan_name': inv.plan_name_at_billing, + 'created_at': inv.created_at.isoformat(), + 'paid_at': inv.paid_at.isoformat() if inv.paid_at else None, + } + for inv in queryset + ] + + def get_revenue_stats(self, days: int = 30) -> Dict[str, Any]: + """ + Get revenue statistics for this business. + + Args: + days: Number of days to analyze (default: 30, max: 365) + + Returns: + Dictionary with: + - total_revenue_cents: Total revenue + - payment_count: Number of payments + - average_payment_cents: Average payment amount + - by_day: Daily breakdown + + Requires: payment_processing feature + """ + self._check_api_limit() + self._check_feature('payment_processing', 'Payment processing') + + from django.utils import timezone + from datetime import timedelta + from collections import defaultdict + + days = min(days, 365) + start_date = timezone.now() - timedelta(days=days) + + payments = self.get_payments(days_back=days, status='completed', limit=500) + + # Calculate totals + total_revenue = sum(p['amount_cents'] for p in payments) + payment_count = len(payments) + + # Group by day + by_day = defaultdict(lambda: {'count': 0, 'amount_cents': 0}) + for p in payments: + day = p['created_at'][:10] # YYYY-MM-DD + by_day[day]['count'] += 1 + by_day[day]['amount_cents'] += p['amount_cents'] + + return { + 'period_days': days, + 'total_revenue_cents': total_revenue, + 'total_revenue': total_revenue / 100, + 'payment_count': payment_count, + 'average_payment_cents': total_revenue // payment_count if payment_count > 0 else 0, + 'average_payment': round(total_revenue / payment_count / 100, 2) if payment_count > 0 else 0, + 'by_day': dict(by_day), + } + + # ========================================================================= + # CONTRACT METHODS + # ========================================================================= + + def get_contracts(self, **filters) -> List[Dict[str, Any]]: + """ + Get contracts for this business with comprehensive filtering. + + Supported filters: + - id: Exact contract ID + - status: Filter by status (PENDING, SIGNED, EXPIRED, VOIDED) + - status__in: Multiple statuses ['PENDING', 'SIGNED'] + - customer_id: Filter by customer ID + - customer_email__icontains: Customer email contains text + - title__icontains: Title contains text + - template_name__icontains: Template name contains text + - expires_at__gte, expires_at__lte: Filter by expiration date + - sent_at__gte, sent_at__lte: Filter by sent date + - created_at__gte, created_at__lte: Filter by creation date + - limit: Maximum results (default: 100, max: 500) + + Returns: + List of contract dictionaries + + Requires: can_use_contracts feature + """ + self._check_api_limit() + self._check_feature('can_use_contracts', 'Contract management') + + from smoothschedule.scheduling.contracts.models import Contract + from django.utils import timezone + from dateutil.parser import parse as parse_datetime + + def parse_dt(value): + if isinstance(value, str): + try: + dt = parse_datetime(value) + return dt if timezone.is_aware(dt) else timezone.make_aware(dt) + except (ValueError, TypeError): + return None + return value + + limit = min(filters.get('limit', 100), 500) + queryset = Contract.objects.select_related('customer', 'template') + + # ID filter + if 'id' in filters: + queryset = queryset.filter(id=filters['id']) + + # Status filters + if 'status' in filters: + queryset = queryset.filter(status=filters['status']) + if 'status__in' in filters: + queryset = queryset.filter(status__in=filters['status__in']) + + # Customer filters + if 'customer_id' in filters: + queryset = queryset.filter(customer_id=filters['customer_id']) + if 'customer_email__icontains' in filters: + queryset = queryset.filter(customer__email__icontains=filters['customer_email__icontains']) + + # Text search filters + if 'title__icontains' in filters: + queryset = queryset.filter(title__icontains=filters['title__icontains']) + if 'template_name__icontains' in filters: + queryset = queryset.filter(template__name__icontains=filters['template_name__icontains']) + + # DateTime filters + for field in ['expires_at', 'sent_at', 'created_at']: + for op in ['__gte', '__lte', '__gt', '__lt']: + key = f'{field}{op}' + if key in filters: + dt = parse_dt(filters[key]) + if dt: + queryset = queryset.filter(**{key: dt}) + + queryset = queryset[:limit] + + return [ + { + 'id': c.id, + 'title': c.title, + 'status': c.status, + 'customer_id': c.customer_id, + 'customer_email': c.customer.email, + 'customer_name': c.customer.get_full_name(), + 'template_name': c.template.name if c.template else None, + 'expires_at': c.expires_at.isoformat() if c.expires_at else None, + 'sent_at': c.sent_at.isoformat() if c.sent_at else None, + 'created_at': c.created_at.isoformat(), + } + for c in queryset + ] + + def get_expiring_contracts(self, days: int = 30) -> List[Dict[str, Any]]: + """ + Get contracts expiring within the specified number of days. + + Args: + days: Days until expiration (default: 30, max: 90) + + Returns: + List of expiring contract dictionaries + + Requires: can_use_contracts feature + """ + self._check_api_limit() + self._check_feature('can_use_contracts', 'Contract management') + + from smoothschedule.scheduling.contracts.models import Contract + from django.utils import timezone + from datetime import timedelta + + days = min(days, 90) + now = timezone.now() + expiry_date = now + timedelta(days=days) + + queryset = Contract.objects.filter( + status=Contract.Status.SIGNED, + expires_at__isnull=False, + expires_at__gte=now, + expires_at__lte=expiry_date + ).select_related('customer')[:100] + + return [ + { + 'id': c.id, + 'title': c.title, + 'customer_id': c.customer_id, + 'customer_email': c.customer.email, + 'customer_name': c.customer.get_full_name(), + 'expires_at': c.expires_at.isoformat(), + 'days_until_expiry': (c.expires_at - now).days, + } + for c in queryset + ] + + # ========================================================================= + # LOCATION METHODS + # ========================================================================= + + def get_locations(self, **filters) -> List[Dict[str, Any]]: + """ + Get business locations with comprehensive filtering. + + Supported filters: + - id: Exact location ID + - name__icontains: Name contains text (case-insensitive) + - city__icontains: City contains text + - state: Exact state/province match + - country: Exact country match + - postal_code: Exact postal code match + - timezone: Exact timezone match + - is_active: Filter by active status (default: True) + - is_primary: Filter by primary status + - has_phone: True = has phone, False = no phone + - has_email: True = has email, False = no email + - limit: Maximum results (default: 50, max: 100) + + Returns: + List of location dictionaries + + Requires: multi_location feature (returns single location otherwise) + """ + self._check_api_limit() + + from .models import Location + from django.db.models import Q + + queryset = Location.objects.filter(business=self.business) + + # ID filter + if 'id' in filters: + queryset = queryset.filter(id=filters['id']) + + # Text search filters + if 'name__icontains' in filters: + queryset = queryset.filter(name__icontains=filters['name__icontains']) + if 'city__icontains' in filters: + queryset = queryset.filter(city__icontains=filters['city__icontains']) + + # Exact match filters + if 'state' in filters: + queryset = queryset.filter(state=filters['state']) + if 'country' in filters: + queryset = queryset.filter(country=filters['country']) + if 'postal_code' in filters: + queryset = queryset.filter(postal_code=filters['postal_code']) + if 'timezone' in filters: + queryset = queryset.filter(timezone=filters['timezone']) + + # Boolean filters + if 'is_active' in filters: + queryset = queryset.filter(is_active=filters['is_active']) + elif filters.get('is_active', True): # Default to True + queryset = queryset.filter(is_active=True) + + if 'is_primary' in filters: + queryset = queryset.filter(is_primary=filters['is_primary']) + + # Has phone/email helpers + if 'has_phone' in filters: + if filters['has_phone']: + queryset = queryset.exclude(phone='').exclude(phone__isnull=True) + else: + queryset = queryset.filter(Q(phone='') | Q(phone__isnull=True)) + + if 'has_email' in filters: + if filters['has_email']: + queryset = queryset.exclude(email='').exclude(email__isnull=True) + else: + queryset = queryset.filter(Q(email='') | Q(email__isnull=True)) + + limit = min(filters.get('limit', 50), 100) + queryset = queryset[:limit] + + return [ + { + 'id': loc.id, + 'name': loc.name, + 'address_line1': loc.address_line1, + 'address_line2': loc.address_line2, + 'city': loc.city, + 'state': loc.state, + 'postal_code': loc.postal_code, + 'country': loc.country, + 'phone': loc.phone, + 'email': loc.email, + 'timezone': loc.timezone, + 'is_active': loc.is_active, + 'is_primary': loc.is_primary, + } + for loc in queryset + ] + + def get_location_stats(self, location_id: int, days: int = 30) -> Dict[str, Any]: + """ + Get booking statistics for a specific location. + + Args: + location_id: ID of the location + days: Number of days to analyze (default: 30, max: 90) + + Returns: + Dictionary with booking stats for the location + + Requires: multi_location feature + """ + self._check_api_limit() + self._check_feature('multi_location', 'Multi-location support') + + from .models import Location, Event, Resource + from django.utils import timezone + from datetime import timedelta + + days = min(days, 90) + start_date = timezone.now() - timedelta(days=days) + + try: + location = Location.objects.get(id=location_id, business=self.business) + except Location.DoesNotExist: + raise ScriptExecutionError(f"Location {location_id} not found") + + # Get resources at this location + resource_ids = Resource.objects.filter( + location=location + ).values_list('id', flat=True) + + # Get appointments for resources at this location + from .models import Participant + from django.contrib.contenttypes.models import ContentType + + resource_ct = ContentType.objects.get_for_model(Resource) + event_ids = Participant.objects.filter( + content_type=resource_ct, + object_id__in=resource_ids, + event__start_time__gte=start_date + ).values_list('event_id', flat=True).distinct() + + appointments = Event.objects.filter(id__in=event_ids) + completed = appointments.filter(status__in=['COMPLETED', 'PAID']) + canceled = appointments.filter(status='CANCELED') + + return { + 'location_id': location.id, + 'location_name': location.name, + 'period_days': days, + 'resource_count': len(resource_ids), + 'total_bookings': appointments.count(), + 'completed_bookings': completed.count(), + 'canceled_bookings': canceled.count(), + 'completion_rate': round( + completed.count() / appointments.count() * 100, 1 + ) if appointments.count() > 0 else 0, + } + + # ========================================================================= + # STAFF METHODS + # ========================================================================= + + def get_staff(self, **filters) -> List[Dict[str, Any]]: + """ + Get staff members for this business with comprehensive filtering. + + Supported filters: + - id: Exact staff ID + - role: Filter by role (staff, manager, owner, resource) + - role__in: Multiple roles ['staff', 'manager'] + - email: Exact email match + - email__icontains: Email contains text (case-insensitive) + - name__icontains: Name contains text (searches first/last/username) + - first_name__icontains: First name contains text + - last_name__icontains: Last name contains text + - is_active: Filter by active status (default: True) + - has_phone: True = has phone, False = no phone + - limit: Maximum results (default: 100, max: 500) + + Returns: + List of staff dictionaries + """ + self._check_api_limit() + + from smoothschedule.identity.users.models import User + from django.db.models import Q + + # Staff roles + staff_roles = ['staff', 'manager', 'owner', 'resource'] + + queryset = User.objects.filter(role__in=staff_roles) + + # ID filter + if 'id' in filters: + queryset = queryset.filter(id=filters['id']) + + # Role filters + if 'role' in filters: + queryset = queryset.filter(role=filters['role']) + if 'role__in' in filters: + queryset = queryset.filter(role__in=filters['role__in']) + + # Email filters + if 'email' in filters: + queryset = queryset.filter(email=filters['email']) + if 'email__icontains' in filters: + queryset = queryset.filter(email__icontains=filters['email__icontains']) + + # Name filters + if 'name__icontains' in filters: + search = filters['name__icontains'] + queryset = queryset.filter( + Q(first_name__icontains=search) | + Q(last_name__icontains=search) | + Q(username__icontains=search) + ) + if 'first_name__icontains' in filters: + queryset = queryset.filter(first_name__icontains=filters['first_name__icontains']) + if 'last_name__icontains' in filters: + queryset = queryset.filter(last_name__icontains=filters['last_name__icontains']) + + # Boolean filters + if 'is_active' in filters: + queryset = queryset.filter(is_active=filters['is_active']) + elif filters.get('is_active', True): # Default to True + queryset = queryset.filter(is_active=True) + + if 'has_phone' in filters: + if filters['has_phone']: + queryset = queryset.exclude(phone='').exclude(phone__isnull=True) + else: + queryset = queryset.filter(Q(phone='') | Q(phone__isnull=True)) + + limit = min(filters.get('limit', 100), 500) + queryset = queryset[:limit] + + return [ + { + 'id': user.id, + 'email': user.email, + 'name': user.get_full_name() or user.username, + 'first_name': user.first_name, + 'last_name': user.last_name, + 'role': user.role, + 'is_active': user.is_active, + 'phone': getattr(user, 'phone', ''), + } + for user in queryset + ] + + def get_staff_performance(self, staff_id: int = None, days: int = 30) -> Dict[str, Any]: + """ + Get performance statistics for staff members. + + Args: + staff_id: Specific staff member ID (optional, returns all if not specified) + days: Number of days to analyze (default: 30, max: 90) + + Returns: + Dictionary with performance stats + """ + self._check_api_limit() + + from .models import Resource, Event, Participant + from smoothschedule.identity.users.models import User + from django.contrib.contenttypes.models import ContentType + from django.utils import timezone + from datetime import timedelta + + days = min(days, 90) + start_date = timezone.now() - timedelta(days=days) + + # Get staff resources + queryset = Resource.objects.filter(type='STAFF', is_active=True) + if staff_id: + queryset = queryset.filter(user_id=staff_id) + + queryset = queryset.select_related('user') + + resource_ct = ContentType.objects.get_for_model(Resource) + results = [] + + for resource in queryset[:50]: # Limit to 50 staff + # Get appointments for this resource + event_ids = Participant.objects.filter( + content_type=resource_ct, + object_id=resource.id, + event__start_time__gte=start_date + ).values_list('event_id', flat=True) + + appointments = Event.objects.filter(id__in=event_ids) + completed = appointments.filter(status__in=['COMPLETED', 'PAID']) + canceled = appointments.filter(status='CANCELED') + no_shows = appointments.filter(status='NO_SHOW') + + # Calculate hours worked + hours_worked = sum( + (apt.end_time - apt.start_time).total_seconds() / 3600 + for apt in completed + ) + + results.append({ + 'staff_id': resource.user_id, + 'staff_name': resource.user.get_full_name() if resource.user else resource.name, + 'resource_id': resource.id, + 'resource_name': resource.name, + 'total_appointments': appointments.count(), + 'completed_appointments': completed.count(), + 'canceled_appointments': canceled.count(), + 'no_show_appointments': no_shows.count(), + 'completion_rate': round( + completed.count() / appointments.count() * 100, 1 + ) if appointments.count() > 0 else 0, + 'hours_worked': round(hours_worked, 1), + }) + + if staff_id and len(results) == 1: + return results[0] + + return { + 'period_days': days, + 'staff_count': len(results), + 'staff': results, + } + + # ========================================================================= + # VIDEO MEETING METHODS + # ========================================================================= + + def create_video_meeting( + self, + provider: str = 'zoom', + title: str = 'Video Appointment', + duration: int = 60, + start_time: str = None + ) -> Dict[str, Any]: + """ + Create a video meeting link. + + Args: + provider: Video provider ('zoom', 'google_meet', 'teams') + title: Meeting title + duration: Duration in minutes (default: 60) + start_time: ISO datetime for scheduled meeting (optional) + + Returns: + Dictionary with: + - join_url: URL for participants to join + - host_url: URL for host (if different) + - meeting_id: Provider's meeting ID + - password: Meeting password (if applicable) + + Requires: can_add_video_conferencing feature + """ + self._check_api_limit() + self._check_feature('can_add_video_conferencing', 'Video conferencing') + + # Validate provider + valid_providers = ['zoom', 'google_meet', 'teams'] + if provider not in valid_providers: + raise ScriptExecutionError( + f"Invalid video provider '{provider}'. " + f"Valid options: {', '.join(valid_providers)}" + ) + + try: + # This would integrate with the actual video service + # For now, return a placeholder structure + from django.utils import timezone + import secrets + + meeting_id = secrets.token_hex(8) + + # In production, this would call Zoom/Google/Teams API + logger.info(f"[Customer Script] Creating {provider} meeting: {title}") + + return { + 'provider': provider, + 'meeting_id': meeting_id, + 'title': title, + 'duration': duration, + 'join_url': f"https://{provider}.example.com/j/{meeting_id}", + 'host_url': f"https://{provider}.example.com/h/{meeting_id}", + 'password': secrets.token_hex(4), + 'created_at': timezone.now().isoformat(), + } + + except Exception as e: + logger.error(f"Failed to create video meeting: {e}") + raise ScriptExecutionError(f"Failed to create video meeting: {e}") + + # ========================================================================= + # EMAIL TEMPLATE METHODS + # ========================================================================= + + def get_email_templates(self, **filters) -> List[Dict[str, Any]]: + """ + Get email templates for this business with comprehensive filtering. + + Supported filters: + - id: Exact template ID + - template_type: Filter by type (reminder, confirmation, follow_up, etc.) + - template_type__in: Multiple types ['reminder', 'confirmation'] + - name__icontains: Name contains text (case-insensitive) + - subject__icontains: Subject contains text + - is_active: Filter by active status (default: True) + - created_at__gte, created_at__lte: Filter by creation date + - limit: Maximum results (default: 50, max: 100) + + Returns: + List of email template dictionaries + + Requires: can_use_email_templates feature + """ + self._check_api_limit() + self._check_feature('can_use_email_templates', 'Email templates') + + try: + from smoothschedule.communication.messaging.models import EmailTemplate + from django.utils import timezone + from dateutil.parser import parse as parse_datetime + + def parse_dt(value): + if isinstance(value, str): + try: + dt = parse_datetime(value) + return dt if timezone.is_aware(dt) else timezone.make_aware(dt) + except (ValueError, TypeError): + return None + return value + + queryset = EmailTemplate.objects.all() + + # ID filter + if 'id' in filters: + queryset = queryset.filter(id=filters['id']) + + # Type filters + if 'template_type' in filters: + queryset = queryset.filter(template_type=filters['template_type']) + if 'template_type__in' in filters: + queryset = queryset.filter(template_type__in=filters['template_type__in']) + + # Text search filters + if 'name__icontains' in filters: + queryset = queryset.filter(name__icontains=filters['name__icontains']) + if 'subject__icontains' in filters: + queryset = queryset.filter(subject__icontains=filters['subject__icontains']) + + # Boolean filters + if 'is_active' in filters: + queryset = queryset.filter(is_active=filters['is_active']) + elif filters.get('is_active', True): # Default to True + queryset = queryset.filter(is_active=True) + + # DateTime filters + for op in ['__gte', '__lte', '__gt', '__lt']: + key = f'created_at{op}' + if key in filters: + dt = parse_dt(filters[key]) + if dt: + queryset = queryset.filter(**{key: dt}) + + limit = min(filters.get('limit', 50), 100) + queryset = queryset[:limit] + + return [ + { + 'id': t.id, + 'name': t.name, + 'template_type': t.template_type, + 'subject': t.subject, + 'is_active': t.is_active, + 'created_at': t.created_at.isoformat(), + } + for t in queryset + ] + except ImportError: + logger.warning("EmailTemplate model not available") + return [] + + def send_template_email( + self, + template_id: int, + to: str, + variables: Dict[str, str] = None + ) -> bool: + """ + Send an email using a pre-defined template. + + Args: + template_id: ID of the email template + to: Recipient email address + variables: Dictionary of variables to substitute in template + + Returns: + True if sent successfully + + Requires: can_use_email_templates feature + """ + self._check_api_limit() + self._check_feature('can_use_email_templates', 'Email templates') + + try: + from smoothschedule.communication.messaging.models import EmailTemplate + from django.core.mail import send_mail + from django.conf import settings + + template = EmailTemplate.objects.get(id=template_id) + + # Process variables + subject = template.subject + body = template.body_text or template.body_html + + if variables: + for key, value in variables.items(): + subject = subject.replace(f'{{{key}}}', str(value)) + body = body.replace(f'{{{key}}}', str(value)) + + # Add context from business + context = self._get_insertion_context() + for key, value in context.items(): + subject = subject.replace(f'{{{key}}}', str(value)) + body = body.replace(f'{{{key}}}', str(value)) + + send_mail( + subject=subject, + message=body, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[to], + fail_silently=False, + ) + + logger.info(f"[Customer Script] Template email sent to {to}") + return True + + except Exception as e: + logger.error(f"Failed to send template email: {e}") + return False + + # ========================================================================= + # ANALYTICS METHODS + # ========================================================================= + + def get_analytics(self, **filters) -> Dict[str, Any]: + """ + Get comprehensive analytics for this business. + + Args: + days: Number of days to analyze (default: 30, max: 90) + metrics: List of metrics to include (optional) + - 'bookings', 'revenue', 'customers', 'staff', 'services' + + Returns: + Dictionary with analytics data + + Requires: advanced_reporting feature + """ + self._check_api_limit() + self._check_feature('advanced_reporting', 'Advanced analytics') + + from django.utils import timezone + from datetime import timedelta + + days = min(filters.get('days', 30), 90) + start_date = timezone.now() - timedelta(days=days) + + metrics = filters.get('metrics', ['bookings', 'customers', 'services']) + + result = { + 'period_days': days, + 'period_start': start_date.isoformat(), + 'period_end': timezone.now().isoformat(), + } + + # Bookings analytics + if 'bookings' in metrics: + appointments = self.get_appointments( + start_date=start_date.strftime('%Y-%m-%d'), + limit=1000 + ) + completed = [a for a in appointments if a['status'] in ['COMPLETED', 'PAID']] + canceled = [a for a in appointments if a['status'] == 'CANCELED'] + + result['bookings'] = { + 'total': len(appointments), + 'completed': len(completed), + 'canceled': len(canceled), + 'completion_rate': round(len(completed) / len(appointments) * 100, 1) if appointments else 0, + } + + # Customer analytics + if 'customers' in metrics: + customers = self.get_customers(limit=1000) + result['customers'] = { + 'total': len(customers), + 'with_email': len([c for c in customers if c['email']]), + 'with_phone': len([c for c in customers if c.get('phone')]), + } + + # Service analytics + if 'services' in metrics: + services = self.get_services(limit=500) + result['services'] = { + 'total': len(services), + 'active': len([s for s in services if s['is_active']]), + } + + # Staff analytics + if 'staff' in metrics: + staff = self.get_staff(limit=500) + result['staff'] = { + 'total': len(staff), + 'active': len([s for s in staff if s['is_active']]), + } + + return result + + def get_booking_trends(self, days: int = 30, group_by: str = 'day') -> Dict[str, Any]: + """ + Get booking trends over time. + + Args: + days: Number of days to analyze (default: 30, max: 90) + group_by: How to group data ('day', 'week', 'hour', 'weekday') + + Returns: + Dictionary with trend data + + Requires: advanced_reporting feature + """ + self._check_api_limit() + self._check_feature('advanced_reporting', 'Advanced analytics') + + from django.utils import timezone + from datetime import timedelta + from collections import defaultdict + + days = min(days, 90) + start_date = timezone.now() - timedelta(days=days) + + appointments = self.get_appointments( + start_date=start_date.strftime('%Y-%m-%d'), + limit=1000 + ) + + trends = defaultdict(lambda: {'total': 0, 'completed': 0, 'canceled': 0}) + + for apt in appointments: + # Parse the datetime + dt_str = apt['start_time'] + from datetime import datetime + dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00')) + + # Determine group key + if group_by == 'day': + key = dt.strftime('%Y-%m-%d') + elif group_by == 'week': + key = dt.strftime('%Y-W%W') + elif group_by == 'hour': + key = str(dt.hour) + elif group_by == 'weekday': + key = dt.strftime('%A') + else: + key = dt.strftime('%Y-%m-%d') + + trends[key]['total'] += 1 + if apt['status'] in ['COMPLETED', 'PAID']: + trends[key]['completed'] += 1 + elif apt['status'] == 'CANCELED': + trends[key]['canceled'] += 1 + + return { + 'period_days': days, + 'group_by': group_by, + 'data': dict(trends), + } + + # ========================================================================= + # APPOINTMENT UPDATE METHODS + # ========================================================================= + + def update_appointment(self, appointment_id: int, **updates) -> Dict[str, Any]: + """ + Update an existing appointment. + + Args: + appointment_id: ID of the appointment to update + **updates: Fields to update: + - title: New title + - notes: New notes + - status: New status (SCHEDULED, COMPLETED, CANCELED) + - start_time: New start time (ISO format) + - end_time: New end time (ISO format) + + Returns: + Updated appointment dictionary + + Note: Only limited fields can be updated for safety. + """ + self._check_api_limit() + + from .models import Event + from django.utils import timezone + from datetime import datetime + + try: + event = Event.objects.get(id=appointment_id) + except Event.DoesNotExist: + raise ScriptExecutionError(f"Appointment {appointment_id} not found") + + # Allowed update fields + allowed_fields = ['title', 'notes', 'status'] + + for field, value in updates.items(): + if field in allowed_fields: + setattr(event, field, value) + elif field == 'start_time': + event.start_time = timezone.make_aware( + datetime.fromisoformat(value.replace('Z', '+00:00')) + ) + elif field == 'end_time': + event.end_time = timezone.make_aware( + datetime.fromisoformat(value.replace('Z', '+00:00')) + ) + else: + logger.warning(f"[Customer Script] Ignoring update to protected field: {field}") + + event.save() + + return { + 'id': event.id, + 'title': event.title, + 'start_time': event.start_time.isoformat(), + 'end_time': event.end_time.isoformat(), + 'status': event.status, + 'notes': event.notes, + } + + def get_recurring_appointments(self, **filters) -> List[Dict[str, Any]]: + """ + Get recurring appointment series with comprehensive filtering. + + Supported filters: + - id: Exact appointment ID + - status: Filter by status (SCHEDULED, COMPLETED, CANCELED, etc.) + - status__in: Multiple statuses ['SCHEDULED', 'COMPLETED'] + - title__icontains: Title contains text (case-insensitive) + - recurring_pattern__icontains: Pattern contains text + - start_time__gte, start_time__lte: Filter by start time + - is_active: Filter by active status (default: True) + - limit: Maximum results (default: 50, max: 200) + + Returns: + List of recurring series dictionaries + + Requires: recurring_appointments feature + """ + self._check_api_limit() + self._check_feature('recurring_appointments', 'Recurring appointments') + + from .models import Event + from django.utils import timezone + from dateutil.parser import parse as parse_datetime + + def parse_dt(value): + if isinstance(value, str): + try: + dt = parse_datetime(value) + return dt if timezone.is_aware(dt) else timezone.make_aware(dt) + except (ValueError, TypeError): + return None + return value + + # Get events that are part of a recurring series + queryset = Event.objects.filter( + recurring_pattern__isnull=False + ).exclude(recurring_pattern='') + + # ID filter + if 'id' in filters: + queryset = queryset.filter(id=filters['id']) + + # Status filters + if 'status' in filters: + queryset = queryset.filter(status=filters['status']) + elif 'status__in' in filters: + queryset = queryset.filter(status__in=filters['status__in']) + elif filters.get('is_active', True): # Default to active only + queryset = queryset.filter(status='SCHEDULED') + + # Text search filters + if 'title__icontains' in filters: + queryset = queryset.filter(title__icontains=filters['title__icontains']) + if 'recurring_pattern__icontains' in filters: + queryset = queryset.filter(recurring_pattern__icontains=filters['recurring_pattern__icontains']) + + # DateTime filters + for op in ['__gte', '__lte', '__gt', '__lt']: + key = f'start_time{op}' + if key in filters: + dt = parse_dt(filters[key]) + if dt: + queryset = queryset.filter(**{key: dt}) + + limit = min(filters.get('limit', 50), 200) + + # Group by recurring pattern/parent + seen_patterns = set() + results = [] + + for event in queryset[:limit * 2]: # Get more to account for duplicates + pattern = event.recurring_pattern + if pattern not in seen_patterns: + seen_patterns.add(pattern) + results.append({ + 'id': event.id, + 'title': event.title, + 'recurring_pattern': pattern, + 'start_time': event.start_time.isoformat(), + 'status': event.status, + }) + + if len(results) >= limit: + break + + return results + class SafeScriptEngine: """ diff --git a/smoothschedule/smoothschedule/scheduling/schedule/tests/test_safe_scripting.py b/smoothschedule/smoothschedule/scheduling/schedule/tests/test_safe_scripting.py index 12d5923..ca6d1c5 100644 --- a/smoothschedule/smoothschedule/scheduling/schedule/tests/test_safe_scripting.py +++ b/smoothschedule/smoothschedule/scheduling/schedule/tests/test_safe_scripting.py @@ -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)