Add default automation flows for tenants
Features:
- Auto-provision 5 default email flows for each new tenant:
- Appointment Confirmation (on event created)
- Appointment Reminder (X hours before, per service settings)
- Thank You Email (on final payment)
- Deposit Payment Confirmation
- Final Payment Confirmation
- New SmoothSchedule piece triggers:
- payment_received: Polls for new payments (deposit/final)
- upcoming_events: Polls for events starting within X hours
- New SmoothSchedule piece action:
- list_customers: List customers with search, pagination
- Backend APIs:
- GET /api/v1/payments/ for payment trigger polling
- GET /api/v1/events/upcoming/ for reminder trigger
- Restore functionality:
- GET /api/activepieces/default-flows/ to list default flows
- POST /api/activepieces/default-flows/{type}/restore/ to restore one
- POST /api/activepieces/default-flows/restore-all/ to restore all
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,9 +5,10 @@ import { createEventAction, findEventsAction, updateEventAction, cancelEventActi
|
||||
import { listResourcesAction } from './lib/actions/list-resources';
|
||||
import { listServicesAction } from './lib/actions/list-services';
|
||||
import { listInactiveCustomersAction } from './lib/actions/list-inactive-customers';
|
||||
import { listCustomersAction } from './lib/actions/list-customers';
|
||||
import { sendEmailAction } from './lib/actions/send-email';
|
||||
import { listEmailTemplatesAction } from './lib/actions/list-email-templates';
|
||||
import { eventCreatedTrigger, eventUpdatedTrigger, eventCancelledTrigger, eventStatusChangedTrigger } from './lib/triggers';
|
||||
import { eventCreatedTrigger, eventUpdatedTrigger, eventCancelledTrigger, eventStatusChangedTrigger, paymentReceivedTrigger, upcomingEventsTrigger } from './lib/triggers';
|
||||
import { API_URL } from './lib/common';
|
||||
|
||||
/**
|
||||
@@ -75,6 +76,7 @@ export const smoothSchedule = createPiece({
|
||||
listResourcesAction,
|
||||
listServicesAction,
|
||||
listInactiveCustomersAction,
|
||||
listCustomersAction,
|
||||
sendEmailAction,
|
||||
listEmailTemplatesAction,
|
||||
createCustomApiCallAction({
|
||||
@@ -89,5 +91,5 @@ export const smoothSchedule = createPiece({
|
||||
},
|
||||
}),
|
||||
],
|
||||
triggers: [eventCreatedTrigger, eventUpdatedTrigger, eventCancelledTrigger, eventStatusChangedTrigger],
|
||||
triggers: [eventCreatedTrigger, eventUpdatedTrigger, eventCancelledTrigger, eventStatusChangedTrigger, paymentReceivedTrigger, upcomingEventsTrigger],
|
||||
});
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from './find-events';
|
||||
export * from './list-resources';
|
||||
export * from './list-services';
|
||||
export * from './list-inactive-customers';
|
||||
export * from './list-customers';
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Property, createAction } from '@activepieces/pieces-framework';
|
||||
import { HttpMethod } from '@activepieces/pieces-common';
|
||||
import { smoothScheduleAuth, SmoothScheduleAuth } from '../../index';
|
||||
import { makeRequest } from '../common';
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
results: T[];
|
||||
count: number;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
}
|
||||
|
||||
interface Customer {
|
||||
id: number;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
phone: string;
|
||||
notes: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const listCustomersAction = createAction({
|
||||
auth: smoothScheduleAuth,
|
||||
name: 'list_customers',
|
||||
displayName: 'List Customers',
|
||||
description: 'Get a list of customers from SmoothSchedule. Useful for customer lookup and bulk operations.',
|
||||
props: {
|
||||
search: Property.ShortText({
|
||||
displayName: 'Search',
|
||||
description: 'Search by name, email, or phone number',
|
||||
required: false,
|
||||
}),
|
||||
limit: Property.Number({
|
||||
displayName: 'Limit',
|
||||
description: 'Maximum number of customers to return (default: 50, max: 500)',
|
||||
required: false,
|
||||
defaultValue: 50,
|
||||
}),
|
||||
offset: Property.Number({
|
||||
displayName: 'Offset',
|
||||
description: 'Number of customers to skip (for pagination)',
|
||||
required: false,
|
||||
defaultValue: 0,
|
||||
}),
|
||||
orderBy: Property.StaticDropdown({
|
||||
displayName: 'Order By',
|
||||
description: 'Sort order for results',
|
||||
required: false,
|
||||
options: {
|
||||
options: [
|
||||
{ label: 'Newest First', value: '-created_at' },
|
||||
{ label: 'Oldest First', value: 'created_at' },
|
||||
{ label: 'Name (A-Z)', value: 'first_name' },
|
||||
{ label: 'Name (Z-A)', value: '-first_name' },
|
||||
{ label: 'Email (A-Z)', value: 'email' },
|
||||
{ label: 'Last Updated', value: '-updated_at' },
|
||||
],
|
||||
},
|
||||
defaultValue: '-created_at',
|
||||
}),
|
||||
},
|
||||
async run(context) {
|
||||
const auth = context.auth as SmoothScheduleAuth;
|
||||
const props = context.propsValue;
|
||||
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
if (props.search) {
|
||||
queryParams['search'] = props.search;
|
||||
}
|
||||
|
||||
// Clamp limit to reasonable range
|
||||
const limit = Math.min(Math.max(props.limit || 50, 1), 500);
|
||||
queryParams['limit'] = limit.toString();
|
||||
|
||||
if (props.offset && props.offset > 0) {
|
||||
queryParams['offset'] = props.offset.toString();
|
||||
}
|
||||
|
||||
if (props.orderBy) {
|
||||
queryParams['ordering'] = props.orderBy;
|
||||
}
|
||||
|
||||
const response = await makeRequest<PaginatedResponse<Customer>>(
|
||||
auth,
|
||||
HttpMethod.GET,
|
||||
'/customers/',
|
||||
undefined,
|
||||
queryParams
|
||||
);
|
||||
|
||||
return {
|
||||
customers: response.results || [],
|
||||
total_count: response.count || 0,
|
||||
has_more: response.next !== null,
|
||||
limit: limit,
|
||||
offset: props.offset || 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -2,3 +2,5 @@ export * from './event-created';
|
||||
export * from './event-updated';
|
||||
export * from './event-cancelled';
|
||||
export * from './event-status-changed';
|
||||
export * from './payment-received';
|
||||
export * from './upcoming-events';
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { createTrigger, TriggerStrategy, Property } from '@activepieces/pieces-framework';
|
||||
import { HttpMethod } from '@activepieces/pieces-common';
|
||||
import { smoothScheduleAuth, SmoothScheduleAuth } from '../../index';
|
||||
import { makeRequest } from '../common';
|
||||
|
||||
const TRIGGER_KEY = 'last_payment_check_timestamp';
|
||||
|
||||
interface PaymentData {
|
||||
id: number;
|
||||
payment_intent_id: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
type: 'deposit' | 'final';
|
||||
status: string;
|
||||
created_at: string;
|
||||
completed_at: string;
|
||||
event: {
|
||||
id: number;
|
||||
title: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
status: string;
|
||||
deposit_amount: string | null;
|
||||
final_price: string | null;
|
||||
remaining_balance: string | null;
|
||||
};
|
||||
service: {
|
||||
id: number;
|
||||
name: string;
|
||||
price: string;
|
||||
} | null;
|
||||
customer: {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export const paymentReceivedTrigger = createTrigger({
|
||||
auth: smoothScheduleAuth,
|
||||
name: 'payment_received',
|
||||
displayName: 'Payment Received',
|
||||
description: 'Triggers when a payment is successfully completed in SmoothSchedule.',
|
||||
props: {
|
||||
paymentType: Property.StaticDropdown({
|
||||
displayName: 'Payment Type',
|
||||
description: 'Only trigger for specific payment types',
|
||||
required: false,
|
||||
options: {
|
||||
options: [
|
||||
{ label: 'All Payments', value: 'all' },
|
||||
{ label: 'Deposit Payments', value: 'deposit' },
|
||||
{ label: 'Final Payments', value: 'final' },
|
||||
],
|
||||
},
|
||||
defaultValue: 'all',
|
||||
}),
|
||||
},
|
||||
type: TriggerStrategy.POLLING,
|
||||
async onEnable(context) {
|
||||
// Store current timestamp as starting point
|
||||
await context.store.put(TRIGGER_KEY, new Date().toISOString());
|
||||
},
|
||||
async onDisable(context) {
|
||||
await context.store.delete(TRIGGER_KEY);
|
||||
},
|
||||
async test(context) {
|
||||
const auth = context.auth as SmoothScheduleAuth;
|
||||
const { paymentType } = context.propsValue;
|
||||
|
||||
const queryParams: Record<string, string> = {
|
||||
limit: '5',
|
||||
};
|
||||
|
||||
if (paymentType && paymentType !== 'all') {
|
||||
queryParams['type'] = paymentType;
|
||||
}
|
||||
|
||||
const payments = await makeRequest<PaymentData[]>(
|
||||
auth,
|
||||
HttpMethod.GET,
|
||||
'/payments/',
|
||||
undefined,
|
||||
queryParams
|
||||
);
|
||||
|
||||
return payments;
|
||||
},
|
||||
async run(context) {
|
||||
const auth = context.auth as SmoothScheduleAuth;
|
||||
const { paymentType } = context.propsValue;
|
||||
|
||||
const lastCheck = await context.store.get<string>(TRIGGER_KEY) || new Date(0).toISOString();
|
||||
|
||||
const queryParams: Record<string, string> = {
|
||||
'created_at__gt': lastCheck,
|
||||
limit: '100',
|
||||
};
|
||||
|
||||
if (paymentType && paymentType !== 'all') {
|
||||
queryParams['type'] = paymentType;
|
||||
}
|
||||
|
||||
const payments = await makeRequest<PaymentData[]>(
|
||||
auth,
|
||||
HttpMethod.GET,
|
||||
'/payments/',
|
||||
undefined,
|
||||
queryParams
|
||||
);
|
||||
|
||||
if (payments.length > 0) {
|
||||
// Update the last check timestamp to the most recent payment
|
||||
const mostRecent = payments.reduce((latest, p) =>
|
||||
new Date(p.completed_at) > new Date(latest.completed_at) ? p : latest
|
||||
);
|
||||
await context.store.put(TRIGGER_KEY, mostRecent.completed_at);
|
||||
}
|
||||
|
||||
return payments;
|
||||
},
|
||||
sampleData: {
|
||||
id: 12345,
|
||||
payment_intent_id: 'pi_3QDEr5GvIfP3a7s90bcd1234',
|
||||
amount: '50.00',
|
||||
currency: 'usd',
|
||||
type: 'deposit',
|
||||
status: 'SUCCEEDED',
|
||||
created_at: '2024-12-01T10:00:00Z',
|
||||
completed_at: '2024-12-01T10:00:05Z',
|
||||
event: {
|
||||
id: 100,
|
||||
title: 'Consultation with John Doe',
|
||||
start_time: '2024-12-15T14:00:00Z',
|
||||
end_time: '2024-12-15T15:00:00Z',
|
||||
status: 'SCHEDULED',
|
||||
deposit_amount: '50.00',
|
||||
final_price: '200.00',
|
||||
remaining_balance: '150.00',
|
||||
},
|
||||
service: {
|
||||
id: 1,
|
||||
name: 'Consultation',
|
||||
price: '200.00',
|
||||
},
|
||||
customer: {
|
||||
id: 50,
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@example.com',
|
||||
phone: '+1-555-0100',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { createTrigger, TriggerStrategy, Property } from '@activepieces/pieces-framework';
|
||||
import { HttpMethod } from '@activepieces/pieces-common';
|
||||
import { smoothScheduleAuth, SmoothScheduleAuth } from '../../index';
|
||||
import { makeRequest } from '../common';
|
||||
|
||||
const TRIGGER_KEY_PREFIX = 'reminder_sent_event_ids';
|
||||
|
||||
interface UpcomingEventData {
|
||||
id: number;
|
||||
title: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
status: string;
|
||||
hours_until_start: number;
|
||||
reminder_hours_before: number;
|
||||
should_send_reminder: boolean;
|
||||
service: {
|
||||
id: number;
|
||||
name: string;
|
||||
duration: number;
|
||||
price: string;
|
||||
reminder_enabled: boolean;
|
||||
reminder_hours_before: number;
|
||||
} | null;
|
||||
customer: {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
} | null;
|
||||
resources: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
}>;
|
||||
notes: string | null;
|
||||
location: {
|
||||
id: number;
|
||||
name: string;
|
||||
address: string;
|
||||
} | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const upcomingEventsTrigger = createTrigger({
|
||||
auth: smoothScheduleAuth,
|
||||
name: 'upcoming_events',
|
||||
displayName: 'Upcoming Event (Reminder)',
|
||||
description: 'Triggers for events starting soon. Use for sending appointment reminders.',
|
||||
props: {
|
||||
hoursAhead: Property.Number({
|
||||
displayName: 'Hours Ahead',
|
||||
description: 'Trigger for events starting within this many hours (matches service reminder settings)',
|
||||
required: false,
|
||||
defaultValue: 24,
|
||||
}),
|
||||
onlyIfReminderEnabled: Property.Checkbox({
|
||||
displayName: 'Only if Reminder Enabled',
|
||||
description: 'Only trigger for events where the service has reminders enabled',
|
||||
required: false,
|
||||
defaultValue: true,
|
||||
}),
|
||||
},
|
||||
type: TriggerStrategy.POLLING,
|
||||
async onEnable(context) {
|
||||
// Initialize with empty set of processed event IDs
|
||||
await context.store.put(TRIGGER_KEY_PREFIX, JSON.stringify([]));
|
||||
},
|
||||
async onDisable(context) {
|
||||
await context.store.delete(TRIGGER_KEY_PREFIX);
|
||||
},
|
||||
async test(context) {
|
||||
const auth = context.auth as SmoothScheduleAuth;
|
||||
const { hoursAhead } = context.propsValue;
|
||||
|
||||
const queryParams: Record<string, string> = {
|
||||
hours_ahead: String(hoursAhead || 24),
|
||||
limit: '5',
|
||||
};
|
||||
|
||||
const events = await makeRequest<UpcomingEventData[]>(
|
||||
auth,
|
||||
HttpMethod.GET,
|
||||
'/events/upcoming/',
|
||||
undefined,
|
||||
queryParams
|
||||
);
|
||||
|
||||
return events;
|
||||
},
|
||||
async run(context) {
|
||||
const auth = context.auth as SmoothScheduleAuth;
|
||||
const { hoursAhead, onlyIfReminderEnabled } = context.propsValue;
|
||||
|
||||
// Get list of event IDs we've already processed
|
||||
const processedIdsJson = await context.store.get<string>(TRIGGER_KEY_PREFIX) || '[]';
|
||||
let processedIds: number[] = [];
|
||||
try {
|
||||
processedIds = JSON.parse(processedIdsJson);
|
||||
} catch {
|
||||
processedIds = [];
|
||||
}
|
||||
|
||||
const queryParams: Record<string, string> = {
|
||||
hours_ahead: String(hoursAhead || 24),
|
||||
limit: '100',
|
||||
};
|
||||
|
||||
const events = await makeRequest<UpcomingEventData[]>(
|
||||
auth,
|
||||
HttpMethod.GET,
|
||||
'/events/upcoming/',
|
||||
undefined,
|
||||
queryParams
|
||||
);
|
||||
|
||||
// Filter to only events that should trigger reminders
|
||||
let filteredEvents = events.filter((event) => {
|
||||
// Skip if already processed
|
||||
if (processedIds.includes(event.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if reminder is appropriate based on service settings
|
||||
if (!event.should_send_reminder) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if service has reminders enabled
|
||||
if (onlyIfReminderEnabled && event.service && !event.service.reminder_enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Update the processed IDs list
|
||||
if (filteredEvents.length > 0) {
|
||||
const newProcessedIds = [...processedIds, ...filteredEvents.map((e) => e.id)];
|
||||
// Keep only last 1000 IDs to prevent unbounded growth
|
||||
const trimmedIds = newProcessedIds.slice(-1000);
|
||||
await context.store.put(TRIGGER_KEY_PREFIX, JSON.stringify(trimmedIds));
|
||||
}
|
||||
|
||||
// Also clean up old IDs (events that have already passed)
|
||||
// This runs periodically to keep the list manageable
|
||||
if (Math.random() < 0.1) { // 10% of runs
|
||||
const currentIds = events.map((e) => e.id);
|
||||
const activeProcessedIds = processedIds.filter((id) => currentIds.includes(id));
|
||||
await context.store.put(TRIGGER_KEY_PREFIX, JSON.stringify(activeProcessedIds));
|
||||
}
|
||||
|
||||
return filteredEvents;
|
||||
},
|
||||
sampleData: {
|
||||
id: 12345,
|
||||
title: 'Consultation with John Doe',
|
||||
start_time: '2024-12-15T14:00:00Z',
|
||||
end_time: '2024-12-15T15:00:00Z',
|
||||
status: 'SCHEDULED',
|
||||
hours_until_start: 23.5,
|
||||
reminder_hours_before: 24,
|
||||
should_send_reminder: true,
|
||||
service: {
|
||||
id: 1,
|
||||
name: 'Consultation',
|
||||
duration: 60,
|
||||
price: '200.00',
|
||||
reminder_enabled: true,
|
||||
reminder_hours_before: 24,
|
||||
},
|
||||
customer: {
|
||||
id: 50,
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
email: 'john.doe@example.com',
|
||||
phone: '+1-555-0100',
|
||||
},
|
||||
resources: [
|
||||
{ id: 1, name: 'Dr. Smith' },
|
||||
],
|
||||
notes: 'First-time client',
|
||||
location: {
|
||||
id: 1,
|
||||
name: 'Main Office',
|
||||
address: '123 Business St',
|
||||
},
|
||||
created_at: '2024-12-01T10:00:00Z',
|
||||
},
|
||||
});
|
||||
@@ -247,3 +247,162 @@ def provision_activepieces_on_tenant_create(sender, instance, created, **kwargs)
|
||||
tenant_id = instance.id
|
||||
# Use a delay to ensure all other tenant setup is complete
|
||||
transaction.on_commit(lambda: _provision_activepieces_connection(tenant_id))
|
||||
|
||||
|
||||
def _provision_default_flows_for_tenant(tenant_id):
|
||||
"""
|
||||
Provision default automation flows in Activepieces for a tenant.
|
||||
Called after Activepieces connection is provisioned.
|
||||
"""
|
||||
from smoothschedule.identity.core.models import Tenant
|
||||
from django.conf import settings
|
||||
|
||||
# Only provision if Activepieces is configured
|
||||
if not getattr(settings, 'ACTIVEPIECES_JWT_SECRET', ''):
|
||||
logger.debug("Activepieces not configured, skipping default flows")
|
||||
return
|
||||
|
||||
try:
|
||||
tenant = Tenant.objects.get(id=tenant_id)
|
||||
|
||||
# Check if tenant has the automation feature
|
||||
if hasattr(tenant, 'has_feature') and not tenant.has_feature('can_use_plugins'):
|
||||
logger.debug(
|
||||
f"Tenant {tenant.schema_name} doesn't have automation feature, "
|
||||
"skipping default flows"
|
||||
)
|
||||
return
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from smoothschedule.integrations.activepieces.services import (
|
||||
get_activepieces_client,
|
||||
)
|
||||
from smoothschedule.integrations.activepieces.models import (
|
||||
TenantActivepiecesProject,
|
||||
TenantDefaultFlow,
|
||||
)
|
||||
from smoothschedule.integrations.activepieces.default_flows import (
|
||||
get_all_flow_definitions,
|
||||
FLOW_VERSION,
|
||||
)
|
||||
from django_tenants.utils import schema_context
|
||||
|
||||
# Get or create Activepieces project for this tenant
|
||||
try:
|
||||
project = TenantActivepiecesProject.objects.get(tenant=tenant)
|
||||
except TenantActivepiecesProject.DoesNotExist:
|
||||
logger.warning(
|
||||
f"No Activepieces project for tenant {tenant.schema_name}, "
|
||||
"skipping default flows"
|
||||
)
|
||||
return
|
||||
|
||||
client = get_activepieces_client()
|
||||
|
||||
# Get a session token for API calls
|
||||
provisioning_token = client._generate_trust_token(tenant)
|
||||
result = client._request(
|
||||
"POST",
|
||||
"/api/v1/authentication/django-trust",
|
||||
data={"token": provisioning_token},
|
||||
)
|
||||
session_token = result.get("token")
|
||||
project_id = result.get("projectId")
|
||||
|
||||
if not session_token:
|
||||
logger.error(
|
||||
f"Failed to get Activepieces session for tenant {tenant.schema_name}"
|
||||
)
|
||||
return
|
||||
|
||||
# Create each default flow
|
||||
flow_definitions = get_all_flow_definitions()
|
||||
created_count = 0
|
||||
|
||||
with schema_context(tenant.schema_name):
|
||||
for flow_type, flow_def in flow_definitions.items():
|
||||
# Check if flow already exists
|
||||
if TenantDefaultFlow.objects.filter(
|
||||
tenant=tenant, flow_type=flow_type
|
||||
).exists():
|
||||
logger.debug(
|
||||
f"Default flow {flow_type} already exists for tenant {tenant.schema_name}"
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
# Create the flow in Activepieces
|
||||
created_flow = client.create_flow(
|
||||
project_id=project_id,
|
||||
token=session_token,
|
||||
flow_data={
|
||||
"displayName": flow_def.get("displayName", flow_type),
|
||||
"trigger": flow_def.get("trigger"),
|
||||
},
|
||||
)
|
||||
|
||||
flow_id = created_flow.get("id")
|
||||
if not flow_id:
|
||||
logger.error(
|
||||
f"Failed to create flow {flow_type} for tenant {tenant.schema_name}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Enable the flow
|
||||
client.update_flow_status(flow_id, session_token, enabled=True)
|
||||
|
||||
# Store the flow record in Django
|
||||
TenantDefaultFlow.objects.create(
|
||||
tenant=tenant,
|
||||
flow_type=flow_type,
|
||||
activepieces_flow_id=flow_id,
|
||||
default_flow_json=flow_def,
|
||||
version=FLOW_VERSION,
|
||||
is_enabled=True,
|
||||
is_modified=False,
|
||||
)
|
||||
|
||||
created_count += 1
|
||||
logger.info(
|
||||
f"Created default flow {flow_type} for tenant {tenant.schema_name}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to create flow {flow_type} for tenant {tenant.schema_name}: {e}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Provisioned {created_count} default flows for tenant: {tenant.schema_name}"
|
||||
)
|
||||
|
||||
except Tenant.DoesNotExist:
|
||||
logger.error(f"Tenant {tenant_id} not found when provisioning default flows")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to provision default flows for tenant {tenant_id}: {e}")
|
||||
|
||||
|
||||
@receiver(post_save, sender='core.Tenant')
|
||||
def provision_default_flows_on_tenant_create(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Provision default automation flows when a new tenant is created.
|
||||
|
||||
This creates the standard email automation flows (confirmation, reminder,
|
||||
thank you, payment confirmations) so tenants have working automations
|
||||
out of the box.
|
||||
|
||||
Runs after Activepieces connection provisioning via on_commit with a delayed
|
||||
lambda to ensure the connection exists first.
|
||||
"""
|
||||
if not created:
|
||||
return
|
||||
|
||||
# Skip public schema
|
||||
if instance.schema_name == 'public':
|
||||
return
|
||||
|
||||
tenant_id = instance.id
|
||||
# Use a second on_commit to run after the connection provisioning
|
||||
# The ordering of on_commit callbacks is preserved, so this will run
|
||||
# after _provision_activepieces_connection
|
||||
transaction.on_commit(lambda: _provision_default_flows_for_tenant(tenant_id))
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
"""
|
||||
Default flow definitions for auto-provisioning.
|
||||
|
||||
Each flow uses SmoothSchedule piece triggers and actions to create
|
||||
standard email automation workflows for every tenant.
|
||||
|
||||
Flow structure follows Activepieces format:
|
||||
- trigger: The trigger configuration (polling or webhook)
|
||||
- Each action is nested in the previous step's "nextAction" field
|
||||
"""
|
||||
|
||||
from typing import Dict, Any
|
||||
|
||||
# Version for tracking upgrades
|
||||
FLOW_VERSION = "1.0.0"
|
||||
|
||||
# Template types for email actions
|
||||
EMAIL_TEMPLATES = {
|
||||
"appointment_confirmation": "APPOINTMENT_CONFIRMATION",
|
||||
"appointment_reminder": "APPOINTMENT_REMINDER",
|
||||
"thank_you": "THANK_YOU",
|
||||
"payment_receipt": "PAYMENT_RECEIPT",
|
||||
}
|
||||
|
||||
|
||||
def _create_send_email_action(
|
||||
template_type: str,
|
||||
step_name: str = "send_email",
|
||||
next_action: Dict[str, Any] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a send_email action step.
|
||||
|
||||
Args:
|
||||
template_type: The email template to use (e.g., "APPOINTMENT_CONFIRMATION")
|
||||
step_name: Unique step name
|
||||
next_action: Optional next action in the chain
|
||||
|
||||
Returns:
|
||||
Action definition dict
|
||||
"""
|
||||
action = {
|
||||
"name": step_name,
|
||||
"displayName": "Send Email",
|
||||
"type": "PIECE",
|
||||
"valid": True,
|
||||
"settings": {
|
||||
"pieceName": "@activepieces/piece-smoothschedule",
|
||||
"pieceVersion": "~0.0.1",
|
||||
"pieceType": "CUSTOM",
|
||||
"actionName": "send_email",
|
||||
"input": {
|
||||
# These use Activepieces interpolation syntax
|
||||
# {{trigger.customer.email}} references the trigger output
|
||||
"recipientEmail": "{{trigger.customer.email}}",
|
||||
"templateType": template_type,
|
||||
"context": {
|
||||
# Map trigger data to template context
|
||||
"customer_name": "{{trigger.customer.first_name}}",
|
||||
"customer_email": "{{trigger.customer.email}}",
|
||||
"event_title": "{{trigger.event.title}}",
|
||||
"event_date": "{{trigger.event.start_time}}",
|
||||
"service_name": "{{trigger.service.name}}",
|
||||
"amount": "{{trigger.amount}}",
|
||||
},
|
||||
},
|
||||
"inputUiInfo": {
|
||||
"customizedInputs": {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if next_action:
|
||||
action["nextAction"] = next_action
|
||||
|
||||
return action
|
||||
|
||||
|
||||
def get_appointment_confirmation_flow() -> Dict[str, Any]:
|
||||
"""
|
||||
Appointment Confirmation Flow
|
||||
|
||||
Trigger: When a new event is created with status SCHEDULED
|
||||
Action: Send appointment confirmation email
|
||||
"""
|
||||
return {
|
||||
"displayName": "Appointment Confirmation Email",
|
||||
"description": "Automatically send a confirmation email when an appointment is booked",
|
||||
"trigger": {
|
||||
"name": "trigger",
|
||||
"displayName": "Event Created",
|
||||
"type": "PIECE_TRIGGER",
|
||||
"valid": True,
|
||||
"settings": {
|
||||
"pieceName": "@activepieces/piece-smoothschedule",
|
||||
"pieceVersion": "~0.0.1",
|
||||
"pieceType": "CUSTOM",
|
||||
"triggerName": "event_created",
|
||||
"input": {},
|
||||
"inputUiInfo": {
|
||||
"customizedInputs": {},
|
||||
},
|
||||
},
|
||||
"nextAction": _create_send_email_action(
|
||||
template_type=EMAIL_TEMPLATES["appointment_confirmation"],
|
||||
step_name="send_confirmation_email",
|
||||
),
|
||||
},
|
||||
"schemaVersion": "1",
|
||||
}
|
||||
|
||||
|
||||
def get_appointment_reminder_flow() -> Dict[str, Any]:
|
||||
"""
|
||||
Appointment Reminder Flow
|
||||
|
||||
Trigger: Upcoming events (based on service reminder settings)
|
||||
Action: Send reminder email
|
||||
"""
|
||||
return {
|
||||
"displayName": "Appointment Reminder Email",
|
||||
"description": "Send reminder emails before appointments (based on service settings)",
|
||||
"trigger": {
|
||||
"name": "trigger",
|
||||
"displayName": "Upcoming Event",
|
||||
"type": "PIECE_TRIGGER",
|
||||
"valid": True,
|
||||
"settings": {
|
||||
"pieceName": "@activepieces/piece-smoothschedule",
|
||||
"pieceVersion": "~0.0.1",
|
||||
"pieceType": "CUSTOM",
|
||||
"triggerName": "upcoming_events",
|
||||
"input": {
|
||||
"hoursAhead": 24,
|
||||
"onlyIfReminderEnabled": True,
|
||||
},
|
||||
"inputUiInfo": {
|
||||
"customizedInputs": {},
|
||||
},
|
||||
},
|
||||
"nextAction": {
|
||||
"name": "send_reminder_email",
|
||||
"displayName": "Send Reminder Email",
|
||||
"type": "PIECE",
|
||||
"valid": True,
|
||||
"settings": {
|
||||
"pieceName": "@activepieces/piece-smoothschedule",
|
||||
"pieceVersion": "~0.0.1",
|
||||
"pieceType": "CUSTOM",
|
||||
"actionName": "send_email",
|
||||
"input": {
|
||||
"recipientEmail": "{{trigger.customer.email}}",
|
||||
"templateType": EMAIL_TEMPLATES["appointment_reminder"],
|
||||
"context": {
|
||||
"customer_name": "{{trigger.customer.first_name}}",
|
||||
"customer_email": "{{trigger.customer.email}}",
|
||||
"event_title": "{{trigger.title}}",
|
||||
"event_date": "{{trigger.start_time}}",
|
||||
"service_name": "{{trigger.service.name}}",
|
||||
"hours_until": "{{trigger.hours_until_start}}",
|
||||
},
|
||||
},
|
||||
"inputUiInfo": {
|
||||
"customizedInputs": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"schemaVersion": "1",
|
||||
}
|
||||
|
||||
|
||||
def get_thank_you_flow() -> Dict[str, Any]:
|
||||
"""
|
||||
Thank You Email Flow
|
||||
|
||||
Trigger: When a final payment is received
|
||||
Action: Send thank you email
|
||||
"""
|
||||
return {
|
||||
"displayName": "Thank You Email (After Payment)",
|
||||
"description": "Send a thank you email when final payment is completed",
|
||||
"trigger": {
|
||||
"name": "trigger",
|
||||
"displayName": "Payment Received",
|
||||
"type": "PIECE_TRIGGER",
|
||||
"valid": True,
|
||||
"settings": {
|
||||
"pieceName": "@activepieces/piece-smoothschedule",
|
||||
"pieceVersion": "~0.0.1",
|
||||
"pieceType": "CUSTOM",
|
||||
"triggerName": "payment_received",
|
||||
"input": {
|
||||
"paymentType": "final",
|
||||
},
|
||||
"inputUiInfo": {
|
||||
"customizedInputs": {},
|
||||
},
|
||||
},
|
||||
"nextAction": {
|
||||
"name": "send_thank_you_email",
|
||||
"displayName": "Send Thank You Email",
|
||||
"type": "PIECE",
|
||||
"valid": True,
|
||||
"settings": {
|
||||
"pieceName": "@activepieces/piece-smoothschedule",
|
||||
"pieceVersion": "~0.0.1",
|
||||
"pieceType": "CUSTOM",
|
||||
"actionName": "send_email",
|
||||
"input": {
|
||||
"recipientEmail": "{{trigger.customer.email}}",
|
||||
"templateType": EMAIL_TEMPLATES["thank_you"],
|
||||
"context": {
|
||||
"customer_name": "{{trigger.customer.first_name}}",
|
||||
"customer_email": "{{trigger.customer.email}}",
|
||||
"event_title": "{{trigger.event.title}}",
|
||||
"service_name": "{{trigger.service.name}}",
|
||||
"amount": "{{trigger.amount}}",
|
||||
},
|
||||
},
|
||||
"inputUiInfo": {
|
||||
"customizedInputs": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"schemaVersion": "1",
|
||||
}
|
||||
|
||||
|
||||
def get_deposit_payment_flow() -> Dict[str, Any]:
|
||||
"""
|
||||
Deposit Payment Confirmation Flow
|
||||
|
||||
Trigger: When a deposit payment is received
|
||||
Action: Send payment receipt email with deposit-specific subject
|
||||
"""
|
||||
return {
|
||||
"displayName": "Deposit Payment Confirmation",
|
||||
"description": "Send a confirmation when a deposit payment is received",
|
||||
"trigger": {
|
||||
"name": "trigger",
|
||||
"displayName": "Payment Received",
|
||||
"type": "PIECE_TRIGGER",
|
||||
"valid": True,
|
||||
"settings": {
|
||||
"pieceName": "@activepieces/piece-smoothschedule",
|
||||
"pieceVersion": "~0.0.1",
|
||||
"pieceType": "CUSTOM",
|
||||
"triggerName": "payment_received",
|
||||
"input": {
|
||||
"paymentType": "deposit",
|
||||
},
|
||||
"inputUiInfo": {
|
||||
"customizedInputs": {},
|
||||
},
|
||||
},
|
||||
"nextAction": {
|
||||
"name": "send_deposit_confirmation",
|
||||
"displayName": "Send Deposit Confirmation",
|
||||
"type": "PIECE",
|
||||
"valid": True,
|
||||
"settings": {
|
||||
"pieceName": "@activepieces/piece-smoothschedule",
|
||||
"pieceVersion": "~0.0.1",
|
||||
"pieceType": "CUSTOM",
|
||||
"actionName": "send_email",
|
||||
"input": {
|
||||
"recipientEmail": "{{trigger.customer.email}}",
|
||||
"templateType": EMAIL_TEMPLATES["payment_receipt"],
|
||||
"subjectOverride": "Deposit Received - {{trigger.service.name}}",
|
||||
"context": {
|
||||
"customer_name": "{{trigger.customer.first_name}}",
|
||||
"customer_email": "{{trigger.customer.email}}",
|
||||
"event_title": "{{trigger.event.title}}",
|
||||
"event_date": "{{trigger.event.start_time}}",
|
||||
"service_name": "{{trigger.service.name}}",
|
||||
"amount": "{{trigger.amount}}",
|
||||
"payment_type": "deposit",
|
||||
"remaining_balance": "{{trigger.event.remaining_balance}}",
|
||||
},
|
||||
},
|
||||
"inputUiInfo": {
|
||||
"customizedInputs": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"schemaVersion": "1",
|
||||
}
|
||||
|
||||
|
||||
def get_final_payment_flow() -> Dict[str, Any]:
|
||||
"""
|
||||
Final Payment Confirmation Flow
|
||||
|
||||
Trigger: When a final payment is received
|
||||
Action: Send payment receipt email
|
||||
"""
|
||||
return {
|
||||
"displayName": "Final Payment Confirmation",
|
||||
"description": "Send a confirmation when the final payment is received",
|
||||
"trigger": {
|
||||
"name": "trigger",
|
||||
"displayName": "Payment Received",
|
||||
"type": "PIECE_TRIGGER",
|
||||
"valid": True,
|
||||
"settings": {
|
||||
"pieceName": "@activepieces/piece-smoothschedule",
|
||||
"pieceVersion": "~0.0.1",
|
||||
"pieceType": "CUSTOM",
|
||||
"triggerName": "payment_received",
|
||||
"input": {
|
||||
"paymentType": "final",
|
||||
},
|
||||
"inputUiInfo": {
|
||||
"customizedInputs": {},
|
||||
},
|
||||
},
|
||||
"nextAction": {
|
||||
"name": "send_payment_confirmation",
|
||||
"displayName": "Send Payment Confirmation",
|
||||
"type": "PIECE",
|
||||
"valid": True,
|
||||
"settings": {
|
||||
"pieceName": "@activepieces/piece-smoothschedule",
|
||||
"pieceVersion": "~0.0.1",
|
||||
"pieceType": "CUSTOM",
|
||||
"actionName": "send_email",
|
||||
"input": {
|
||||
"recipientEmail": "{{trigger.customer.email}}",
|
||||
"templateType": EMAIL_TEMPLATES["payment_receipt"],
|
||||
"context": {
|
||||
"customer_name": "{{trigger.customer.first_name}}",
|
||||
"customer_email": "{{trigger.customer.email}}",
|
||||
"event_title": "{{trigger.event.title}}",
|
||||
"service_name": "{{trigger.service.name}}",
|
||||
"amount": "{{trigger.amount}}",
|
||||
"payment_type": "final",
|
||||
},
|
||||
},
|
||||
"inputUiInfo": {
|
||||
"customizedInputs": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"schemaVersion": "1",
|
||||
}
|
||||
|
||||
|
||||
# Mapping of flow types to their definition functions
|
||||
FLOW_TYPE_DEFINITIONS = {
|
||||
"appointment_confirmation": get_appointment_confirmation_flow,
|
||||
"appointment_reminder": get_appointment_reminder_flow,
|
||||
"thank_you": get_thank_you_flow,
|
||||
"payment_deposit": get_deposit_payment_flow,
|
||||
"payment_final": get_final_payment_flow,
|
||||
}
|
||||
|
||||
|
||||
def get_flow_definition(flow_type: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get the flow definition for a given flow type.
|
||||
|
||||
Args:
|
||||
flow_type: One of the FlowType choices from TenantDefaultFlow model
|
||||
|
||||
Returns:
|
||||
Flow definition dict ready for Activepieces API
|
||||
|
||||
Raises:
|
||||
ValueError: If flow_type is not recognized
|
||||
"""
|
||||
if flow_type not in FLOW_TYPE_DEFINITIONS:
|
||||
raise ValueError(f"Unknown flow type: {flow_type}")
|
||||
|
||||
return FLOW_TYPE_DEFINITIONS[flow_type]()
|
||||
|
||||
|
||||
def get_all_flow_definitions() -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Get all flow definitions.
|
||||
|
||||
Returns:
|
||||
Dict mapping flow_type to its definition
|
||||
"""
|
||||
return {
|
||||
flow_type: get_func()
|
||||
for flow_type, get_func in FLOW_TYPE_DEFINITIONS.items()
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-21 21:22
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('activepieces', '0001_initial'),
|
||||
('core', '0030_add_sidebar_text_color'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TenantDefaultFlow',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('flow_type', models.CharField(choices=[('appointment_confirmation', 'Appointment Confirmation Email'), ('appointment_reminder', 'Appointment Reminder'), ('thank_you', 'Thank You Email (After Payment)'), ('payment_deposit', 'Deposit Payment Confirmation'), ('payment_final', 'Final Payment Confirmation')], help_text='Type of default flow', max_length=50)),
|
||||
('activepieces_flow_id', models.CharField(help_text='The Activepieces flow ID', max_length=255)),
|
||||
('is_modified', models.BooleanField(default=False, help_text='Whether user has modified this flow from default')),
|
||||
('default_flow_json', models.JSONField(help_text='The original default flow definition for restore')),
|
||||
('version', models.CharField(default='1.0.0', help_text='Version of the default flow template', max_length=20)),
|
||||
('is_enabled', models.BooleanField(default=True, help_text='Whether this flow is enabled in Activepieces')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('tenant', models.ForeignKey(help_text='The tenant this default flow belongs to', on_delete=django.db.models.deletion.CASCADE, related_name='default_flows', to='core.tenant')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Tenant Default Flow',
|
||||
'verbose_name_plural': 'Tenant Default Flows',
|
||||
'indexes': [models.Index(fields=['tenant', 'flow_type'], name='activepiece_tenant__a23277_idx'), models.Index(fields=['activepieces_flow_id'], name='activepiece_activep_452ab8_idx')],
|
||||
'unique_together': {('tenant', 'flow_type')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -74,3 +74,68 @@ class TenantActivepiecesUser(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email} -> {self.activepieces_user_id}"
|
||||
|
||||
|
||||
class TenantDefaultFlow(models.Model):
|
||||
"""
|
||||
Tracks default automation flows provisioned for a tenant.
|
||||
|
||||
Used for:
|
||||
1. Identifying which flows are "default" vs user-created
|
||||
2. Enabling restore to default functionality
|
||||
3. Tracking if user has modified the flow
|
||||
"""
|
||||
|
||||
class FlowType(models.TextChoices):
|
||||
APPOINTMENT_CONFIRMATION = "appointment_confirmation", "Appointment Confirmation Email"
|
||||
APPOINTMENT_REMINDER = "appointment_reminder", "Appointment Reminder"
|
||||
THANK_YOU_EMAIL = "thank_you", "Thank You Email (After Payment)"
|
||||
PAYMENT_DEPOSIT = "payment_deposit", "Deposit Payment Confirmation"
|
||||
PAYMENT_FINAL = "payment_final", "Final Payment Confirmation"
|
||||
|
||||
tenant = models.ForeignKey(
|
||||
"core.Tenant",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="default_flows",
|
||||
help_text="The tenant this default flow belongs to",
|
||||
)
|
||||
flow_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=FlowType.choices,
|
||||
help_text="Type of default flow",
|
||||
)
|
||||
activepieces_flow_id = models.CharField(
|
||||
max_length=255,
|
||||
help_text="The Activepieces flow ID",
|
||||
)
|
||||
is_modified = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether user has modified this flow from default",
|
||||
)
|
||||
default_flow_json = models.JSONField(
|
||||
help_text="The original default flow definition for restore",
|
||||
)
|
||||
version = models.CharField(
|
||||
max_length=20,
|
||||
default="1.0.0",
|
||||
help_text="Version of the default flow template",
|
||||
)
|
||||
is_enabled = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether this flow is enabled in Activepieces",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
app_label = "activepieces"
|
||||
verbose_name = "Tenant Default Flow"
|
||||
verbose_name_plural = "Tenant Default Flows"
|
||||
unique_together = ["tenant", "flow_type"]
|
||||
indexes = [
|
||||
models.Index(fields=["tenant", "flow_type"]),
|
||||
models.Index(fields=["activepieces_flow_id"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.tenant.name} - {self.get_flow_type_display()}"
|
||||
|
||||
@@ -316,6 +316,128 @@ class ActivepiecesClient:
|
||||
)
|
||||
return result.get("data", [])
|
||||
|
||||
def create_flow(self, project_id: str, token: str, flow_data: dict) -> dict:
|
||||
"""
|
||||
Create a new flow in Activepieces.
|
||||
|
||||
Args:
|
||||
project_id: The Activepieces project ID
|
||||
token: Session token for API calls
|
||||
flow_data: Flow definition including displayName and trigger
|
||||
|
||||
Returns:
|
||||
Created flow object with id
|
||||
"""
|
||||
# Create the flow shell
|
||||
result = self._request(
|
||||
"POST",
|
||||
"/api/v1/flows",
|
||||
data={
|
||||
"displayName": flow_data.get("displayName", "Untitled"),
|
||||
"projectId": project_id,
|
||||
},
|
||||
token=token,
|
||||
)
|
||||
|
||||
flow_id = result.get("id")
|
||||
|
||||
# Apply the full flow definition with trigger and actions
|
||||
if flow_id and flow_data.get("trigger"):
|
||||
self.import_flow(
|
||||
flow_id=flow_id,
|
||||
token=token,
|
||||
display_name=flow_data.get("displayName", "Untitled"),
|
||||
trigger=flow_data["trigger"],
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def import_flow(
|
||||
self, flow_id: str, token: str, display_name: str, trigger: dict
|
||||
) -> dict:
|
||||
"""
|
||||
Import/update a flow with a trigger and actions structure.
|
||||
|
||||
Uses the IMPORT_FLOW operation to set the complete flow definition.
|
||||
|
||||
Args:
|
||||
flow_id: The Activepieces flow ID
|
||||
token: Session token for API calls
|
||||
display_name: Display name for the flow
|
||||
trigger: The trigger configuration with actions chain
|
||||
|
||||
Returns:
|
||||
Updated flow object
|
||||
"""
|
||||
return self._request(
|
||||
"POST",
|
||||
f"/api/v1/flows/{flow_id}",
|
||||
data={
|
||||
"type": "IMPORT_FLOW",
|
||||
"request": {
|
||||
"displayName": display_name,
|
||||
"trigger": trigger,
|
||||
"schemaVersion": "1",
|
||||
},
|
||||
},
|
||||
token=token,
|
||||
)
|
||||
|
||||
def update_flow_status(self, flow_id: str, token: str, enabled: bool) -> dict:
|
||||
"""
|
||||
Enable or disable a flow.
|
||||
|
||||
Args:
|
||||
flow_id: The Activepieces flow ID
|
||||
token: Session token for API calls
|
||||
enabled: True to enable, False to disable
|
||||
|
||||
Returns:
|
||||
Updated flow object
|
||||
"""
|
||||
return self._request(
|
||||
"POST",
|
||||
f"/api/v1/flows/{flow_id}",
|
||||
data={
|
||||
"type": "CHANGE_STATUS",
|
||||
"request": {
|
||||
"status": "ENABLED" if enabled else "DISABLED",
|
||||
},
|
||||
},
|
||||
token=token,
|
||||
)
|
||||
|
||||
def get_flow(self, flow_id: str, token: str) -> dict:
|
||||
"""
|
||||
Get a flow by ID.
|
||||
|
||||
Args:
|
||||
flow_id: The Activepieces flow ID
|
||||
token: Session token for API calls
|
||||
|
||||
Returns:
|
||||
Flow object
|
||||
"""
|
||||
return self._request(
|
||||
"GET",
|
||||
f"/api/v1/flows/{flow_id}",
|
||||
token=token,
|
||||
)
|
||||
|
||||
def delete_flow(self, flow_id: str, token: str) -> None:
|
||||
"""
|
||||
Delete a flow.
|
||||
|
||||
Args:
|
||||
flow_id: The Activepieces flow ID
|
||||
token: Session token for API calls
|
||||
"""
|
||||
self._request(
|
||||
"DELETE",
|
||||
f"/api/v1/flows/{flow_id}",
|
||||
token=token,
|
||||
)
|
||||
|
||||
def trigger_webhook(self, webhook_url: str, payload: dict) -> dict:
|
||||
"""
|
||||
Trigger a flow via its webhook URL.
|
||||
|
||||
@@ -9,6 +9,9 @@ from .views import (
|
||||
ActivepiecesFlowsView,
|
||||
ActivepiecesHealthView,
|
||||
ActivepiecesWebhookView,
|
||||
DefaultFlowsListView,
|
||||
DefaultFlowRestoreView,
|
||||
DefaultFlowsRestoreAllView,
|
||||
)
|
||||
|
||||
app_name = "activepieces"
|
||||
@@ -38,4 +41,20 @@ urlpatterns = [
|
||||
ActivepiecesHealthView.as_view(),
|
||||
name="health",
|
||||
),
|
||||
# Default flows management
|
||||
path(
|
||||
"default-flows/",
|
||||
DefaultFlowsListView.as_view(),
|
||||
name="default-flows-list",
|
||||
),
|
||||
path(
|
||||
"default-flows/<str:flow_type>/restore/",
|
||||
DefaultFlowRestoreView.as_view(),
|
||||
name="default-flow-restore",
|
||||
),
|
||||
path(
|
||||
"default-flows/restore-all/",
|
||||
DefaultFlowsRestoreAllView.as_view(),
|
||||
name="default-flows-restore-all",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -12,8 +12,9 @@ from rest_framework.views import APIView
|
||||
|
||||
from smoothschedule.identity.core.mixins import TenantRequiredAPIView
|
||||
|
||||
from .models import TenantActivepiecesProject
|
||||
from .models import TenantActivepiecesProject, TenantDefaultFlow
|
||||
from .services import ActivepiecesError, get_activepieces_client
|
||||
from .default_flows import get_flow_definition, FLOW_VERSION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -194,3 +195,230 @@ class ActivepiecesHealthView(APIView):
|
||||
{"status": "unhealthy", "error": str(e)},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
|
||||
class DefaultFlowsListView(TenantRequiredAPIView, APIView):
|
||||
"""
|
||||
List default automation flows for the current tenant.
|
||||
|
||||
GET /api/activepieces/default-flows/
|
||||
|
||||
Returns:
|
||||
{
|
||||
"flows": [
|
||||
{
|
||||
"flow_type": "appointment_confirmation",
|
||||
"display_name": "Appointment Confirmation Email",
|
||||
"activepieces_flow_id": "...",
|
||||
"is_modified": false,
|
||||
"is_enabled": true,
|
||||
"version": "1.0.0"
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
tenant = self.tenant
|
||||
|
||||
flows = TenantDefaultFlow.objects.filter(tenant=tenant)
|
||||
|
||||
flow_data = [
|
||||
{
|
||||
"flow_type": flow.flow_type,
|
||||
"display_name": flow.get_flow_type_display(),
|
||||
"activepieces_flow_id": flow.activepieces_flow_id,
|
||||
"is_modified": flow.is_modified,
|
||||
"is_enabled": flow.is_enabled,
|
||||
"version": flow.version,
|
||||
"created_at": flow.created_at.isoformat(),
|
||||
"updated_at": flow.updated_at.isoformat(),
|
||||
}
|
||||
for flow in flows
|
||||
]
|
||||
|
||||
return self.success_response({"flows": flow_data})
|
||||
|
||||
|
||||
class DefaultFlowRestoreView(TenantRequiredAPIView, APIView):
|
||||
"""
|
||||
Restore a default flow to its original definition.
|
||||
|
||||
POST /api/activepieces/default-flows/<flow_type>/restore/
|
||||
|
||||
This updates the existing flow in Activepieces with the original
|
||||
default definition, preserving the flow ID.
|
||||
|
||||
URL params:
|
||||
flow_type: One of appointment_confirmation, appointment_reminder,
|
||||
thank_you, payment_deposit, payment_final
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"flow_type": "appointment_confirmation",
|
||||
"message": "Flow restored to default"
|
||||
}
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request, flow_type):
|
||||
tenant = self.tenant
|
||||
|
||||
# Validate flow type
|
||||
valid_types = [choice[0] for choice in TenantDefaultFlow.FlowType.choices]
|
||||
if flow_type not in valid_types:
|
||||
return self.error_response(
|
||||
f"Invalid flow type. Must be one of: {', '.join(valid_types)}",
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the default flow record
|
||||
try:
|
||||
default_flow = TenantDefaultFlow.objects.get(
|
||||
tenant=tenant, flow_type=flow_type
|
||||
)
|
||||
except TenantDefaultFlow.DoesNotExist:
|
||||
return self.error_response(
|
||||
f"Default flow '{flow_type}' not found for this tenant",
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
client = get_activepieces_client()
|
||||
|
||||
try:
|
||||
# Get session for this tenant
|
||||
session_data = client.get_embed_session(tenant)
|
||||
token = session_data.get("token")
|
||||
|
||||
if not token:
|
||||
return self.error_response(
|
||||
"Failed to get Activepieces session",
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
# Get the original flow definition
|
||||
flow_def = get_flow_definition(flow_type)
|
||||
|
||||
# Update the flow in Activepieces using import_flow
|
||||
client.import_flow(
|
||||
flow_id=default_flow.activepieces_flow_id,
|
||||
token=token,
|
||||
display_name=flow_def.get("displayName", flow_type),
|
||||
trigger=flow_def.get("trigger"),
|
||||
)
|
||||
|
||||
# Update the Django record
|
||||
default_flow.is_modified = False
|
||||
default_flow.default_flow_json = flow_def
|
||||
default_flow.version = FLOW_VERSION
|
||||
default_flow.save()
|
||||
|
||||
logger.info(
|
||||
f"Restored default flow {flow_type} for tenant {tenant.schema_name}"
|
||||
)
|
||||
|
||||
return self.success_response({
|
||||
"success": True,
|
||||
"flow_type": flow_type,
|
||||
"message": "Flow restored to default",
|
||||
})
|
||||
|
||||
except ActivepiecesError as e:
|
||||
logger.error(f"Failed to restore flow: {e}")
|
||||
return self.error_response(
|
||||
"Failed to restore flow in Activepieces",
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
|
||||
class DefaultFlowsRestoreAllView(TenantRequiredAPIView, APIView):
|
||||
"""
|
||||
Restore all default flows to their original definitions.
|
||||
|
||||
POST /api/activepieces/default-flows/restore-all/
|
||||
|
||||
This restores all default flows for the tenant, preserving flow IDs.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"restored": ["appointment_confirmation", "appointment_reminder", ...],
|
||||
"failed": [],
|
||||
"message": "Restored 5 flows"
|
||||
}
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
tenant = self.tenant
|
||||
|
||||
client = get_activepieces_client()
|
||||
|
||||
try:
|
||||
# Get session for this tenant
|
||||
session_data = client.get_embed_session(tenant)
|
||||
token = session_data.get("token")
|
||||
|
||||
if not token:
|
||||
return self.error_response(
|
||||
"Failed to get Activepieces session",
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
restored = []
|
||||
failed = []
|
||||
|
||||
# Get all default flows for this tenant
|
||||
default_flows = TenantDefaultFlow.objects.filter(tenant=tenant)
|
||||
|
||||
for default_flow in default_flows:
|
||||
try:
|
||||
# Get the original flow definition
|
||||
flow_def = get_flow_definition(default_flow.flow_type)
|
||||
|
||||
# Update the flow in Activepieces
|
||||
client.import_flow(
|
||||
flow_id=default_flow.activepieces_flow_id,
|
||||
token=token,
|
||||
display_name=flow_def.get("displayName", default_flow.flow_type),
|
||||
trigger=flow_def.get("trigger"),
|
||||
)
|
||||
|
||||
# Update the Django record
|
||||
default_flow.is_modified = False
|
||||
default_flow.default_flow_json = flow_def
|
||||
default_flow.version = FLOW_VERSION
|
||||
default_flow.save()
|
||||
|
||||
restored.append(default_flow.flow_type)
|
||||
logger.info(
|
||||
f"Restored default flow {default_flow.flow_type} for tenant {tenant.schema_name}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
failed.append(default_flow.flow_type)
|
||||
logger.error(
|
||||
f"Failed to restore flow {default_flow.flow_type}: {e}"
|
||||
)
|
||||
|
||||
return self.success_response({
|
||||
"success": len(failed) == 0,
|
||||
"restored": restored,
|
||||
"failed": failed,
|
||||
"message": f"Restored {len(restored)} flows" + (
|
||||
f", {len(failed)} failed" if failed else ""
|
||||
),
|
||||
})
|
||||
|
||||
except ActivepiecesError as e:
|
||||
logger.error(f"Failed to restore flows: {e}")
|
||||
return self.error_response(
|
||||
"Failed to restore flows in Activepieces",
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
@@ -29,6 +29,8 @@ from .views import (
|
||||
WebhookViewSet,
|
||||
EmailTemplateListView,
|
||||
SendEmailView,
|
||||
PaymentListView,
|
||||
UpcomingEventsView,
|
||||
)
|
||||
|
||||
app_name = 'public_api'
|
||||
@@ -154,6 +156,12 @@ All errors follow this format:
|
||||
path('emails/templates/', EmailTemplateListView.as_view(), name='email-templates'),
|
||||
path('emails/send/', SendEmailView.as_view(), name='send-email'),
|
||||
|
||||
# Payment Endpoints (for Activepieces triggers)
|
||||
path('payments/', PaymentListView.as_view(), name='payments'),
|
||||
|
||||
# Events Endpoints (for Activepieces triggers)
|
||||
path('events/upcoming/', UpcomingEventsView.as_view(), name='upcoming-events'),
|
||||
|
||||
# ViewSet routes
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
|
||||
@@ -1900,6 +1900,326 @@ class WebhookViewSet(PublicAPIViewMixin, viewsets.ViewSet):
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Payment Endpoints (for Activepieces triggers)
|
||||
# =============================================================================
|
||||
|
||||
@extend_schema(
|
||||
summary="List recent payments",
|
||||
description=(
|
||||
"Get recent payment transactions for polling triggers (Activepieces integration). "
|
||||
"Returns completed payments with event and customer context."
|
||||
),
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name='created_at__gt',
|
||||
type=OpenApiTypes.DATETIME,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Only return payments created after this timestamp (ISO format)",
|
||||
required=False,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name='type',
|
||||
type=str,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter by payment type: 'deposit' or 'final'",
|
||||
required=False,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name='limit',
|
||||
type=int,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Maximum number of payments to return (default: 100, max: 500)",
|
||||
required=False,
|
||||
),
|
||||
],
|
||||
responses={200: OpenApiResponse(description="List of recent payments with event data")},
|
||||
tags=['Payments'],
|
||||
)
|
||||
class PaymentListView(PublicAPIViewMixin, APIView):
|
||||
"""
|
||||
List recent payments for Activepieces polling triggers.
|
||||
|
||||
Returns completed payments (status=SUCCEEDED) with full event and customer context.
|
||||
Use created_at__gt parameter for polling new payments since last check.
|
||||
|
||||
Payment types:
|
||||
- 'deposit': Payments where the event still has remaining balance
|
||||
- 'final': Payments where the event is fully paid
|
||||
|
||||
**Required scope:** `bookings:read`
|
||||
"""
|
||||
permission_classes = [HasAPIToken, CanReadBookings]
|
||||
|
||||
def get(self, request):
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from smoothschedule.commerce.payments.models import TransactionLink
|
||||
from smoothschedule.scheduling.schedule.models import Event
|
||||
|
||||
tenant = self.get_tenant()
|
||||
if not tenant:
|
||||
return Response(
|
||||
{'error': 'not_found', 'message': 'Business not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
with schema_context(tenant.schema_name):
|
||||
# Only get successful payments
|
||||
queryset = TransactionLink.objects.filter(
|
||||
status=TransactionLink.Status.SUCCEEDED
|
||||
).select_related('event', 'event__service').order_by('-completed_at', '-created_at')
|
||||
|
||||
# Filter by time
|
||||
created_after = request.query_params.get('created_at__gt')
|
||||
if created_after:
|
||||
dt = parse_datetime(created_after)
|
||||
if dt:
|
||||
queryset = queryset.filter(completed_at__gt=dt)
|
||||
|
||||
# Filter by payment type
|
||||
payment_type = request.query_params.get('type')
|
||||
# We'll filter by type after fetching (requires checking event data)
|
||||
|
||||
# Limit results
|
||||
try:
|
||||
limit = min(int(request.query_params.get('limit', 100)), 500)
|
||||
except ValueError:
|
||||
limit = 100
|
||||
|
||||
results = []
|
||||
for tx in queryset[:limit * 2]: # Fetch extra for type filtering
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
event = tx.event
|
||||
if not event:
|
||||
continue
|
||||
|
||||
# Determine payment type based on event state
|
||||
is_deposit = event.deposit_transaction_id == tx.payment_intent_id
|
||||
is_final = event.final_charge_transaction_id == tx.payment_intent_id
|
||||
|
||||
# Infer type if not explicitly set
|
||||
if not is_deposit and not is_final:
|
||||
# If deposit amount matches, it's likely a deposit
|
||||
if event.deposit_amount and tx.amount == event.deposit_amount:
|
||||
is_deposit = True
|
||||
else:
|
||||
is_final = True
|
||||
|
||||
tx_type = 'deposit' if is_deposit else 'final'
|
||||
|
||||
# Filter by type if requested
|
||||
if payment_type and tx_type != payment_type:
|
||||
continue
|
||||
|
||||
# Get customer info from participants
|
||||
customer = None
|
||||
participants = list(event.participants.all())
|
||||
for p in participants:
|
||||
obj = p.content_object
|
||||
if p.role == 'CUSTOMER' and obj:
|
||||
customer = {
|
||||
'id': getattr(obj, 'id', None),
|
||||
'first_name': getattr(obj, 'first_name', ''),
|
||||
'last_name': getattr(obj, 'last_name', ''),
|
||||
'email': getattr(obj, 'email', ''),
|
||||
'phone': getattr(obj, 'phone', ''),
|
||||
}
|
||||
break
|
||||
|
||||
# Get service info
|
||||
service_data = None
|
||||
if event.service:
|
||||
service_data = {
|
||||
'id': event.service.id,
|
||||
'name': event.service.name,
|
||||
'price': str(event.service.price),
|
||||
}
|
||||
|
||||
results.append({
|
||||
'id': tx.id,
|
||||
'payment_intent_id': tx.payment_intent_id,
|
||||
'amount': str(tx.amount),
|
||||
'currency': tx.currency,
|
||||
'type': tx_type,
|
||||
'status': tx.status,
|
||||
'created_at': tx.created_at.isoformat() if tx.created_at else None,
|
||||
'completed_at': tx.completed_at.isoformat() if tx.completed_at else None,
|
||||
'event': {
|
||||
'id': event.id,
|
||||
'title': event.title,
|
||||
'start_time': event.start_time.isoformat() if event.start_time else None,
|
||||
'end_time': event.end_time.isoformat() if event.end_time else None,
|
||||
'status': event.status,
|
||||
'deposit_amount': str(event.deposit_amount) if event.deposit_amount else None,
|
||||
'final_price': str(event.final_price) if event.final_price else None,
|
||||
'remaining_balance': str(event.remaining_balance) if event.remaining_balance else None,
|
||||
},
|
||||
'service': service_data,
|
||||
'customer': customer,
|
||||
})
|
||||
|
||||
return Response(results)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
summary="List upcoming events",
|
||||
description=(
|
||||
"Get events starting within the specified time window for reminder triggers. "
|
||||
"Used by Activepieces to trigger appointment reminder flows."
|
||||
),
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name='hours_ahead',
|
||||
type=int,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Return events starting within this many hours (default: 24, max: 168)",
|
||||
required=False,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name='status',
|
||||
type=str,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Filter by status (default: SCHEDULED)",
|
||||
required=False,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name='limit',
|
||||
type=int,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Maximum number of events to return (default: 100, max: 500)",
|
||||
required=False,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name='last_checked_at',
|
||||
type=OpenApiTypes.DATETIME,
|
||||
location=OpenApiParameter.QUERY,
|
||||
description="Return only events that haven't been fetched since this time (for deduplication)",
|
||||
required=False,
|
||||
),
|
||||
],
|
||||
responses={200: OpenApiResponse(description="List of upcoming events with service and customer data")},
|
||||
tags=['Events'],
|
||||
)
|
||||
class UpcomingEventsView(PublicAPIViewMixin, APIView):
|
||||
"""
|
||||
List upcoming events for reminder automation triggers.
|
||||
|
||||
Returns events starting within the specified time window with full
|
||||
service and customer context. Events are ordered by start time.
|
||||
|
||||
The hours_ahead parameter determines how far in the future to look.
|
||||
Each service has its own reminder_hours_before setting which should
|
||||
match the polling frequency.
|
||||
|
||||
**Required scope:** `bookings:read`
|
||||
"""
|
||||
permission_classes = [HasAPIToken, CanReadBookings]
|
||||
|
||||
def get(self, request):
|
||||
from django.utils import timezone as tz
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from datetime import timedelta
|
||||
from smoothschedule.scheduling.schedule.models import Event
|
||||
|
||||
tenant = self.get_tenant()
|
||||
if not tenant:
|
||||
return Response(
|
||||
{'error': 'not_found', 'message': 'Business not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# Parse parameters
|
||||
try:
|
||||
hours_ahead = int(request.query_params.get('hours_ahead', 24))
|
||||
hours_ahead = max(1, min(hours_ahead, 168)) # Clamp 1-168 hours
|
||||
except ValueError:
|
||||
hours_ahead = 24
|
||||
|
||||
try:
|
||||
limit = int(request.query_params.get('limit', 100))
|
||||
limit = max(1, min(limit, 500))
|
||||
except ValueError:
|
||||
limit = 100
|
||||
|
||||
status_filter = request.query_params.get('status', 'SCHEDULED')
|
||||
last_checked_at = request.query_params.get('last_checked_at')
|
||||
|
||||
now = tz.now()
|
||||
window_end = now + timedelta(hours=hours_ahead)
|
||||
|
||||
with schema_context(tenant.schema_name):
|
||||
queryset = Event.objects.filter(
|
||||
start_time__gte=now,
|
||||
start_time__lte=window_end,
|
||||
status=status_filter.upper(),
|
||||
).select_related('service').order_by('start_time')
|
||||
|
||||
results = []
|
||||
for event in queryset[:limit]:
|
||||
# Get participants
|
||||
participants = list(event.participants.all())
|
||||
|
||||
customer = None
|
||||
resources = []
|
||||
for p in participants:
|
||||
obj = p.content_object
|
||||
if p.role == 'CUSTOMER' and obj:
|
||||
customer = {
|
||||
'id': getattr(obj, 'id', None),
|
||||
'first_name': getattr(obj, 'first_name', ''),
|
||||
'last_name': getattr(obj, 'last_name', ''),
|
||||
'email': getattr(obj, 'email', ''),
|
||||
'phone': getattr(obj, 'phone', ''),
|
||||
}
|
||||
elif p.role == 'RESOURCE' and obj:
|
||||
resources.append({
|
||||
'id': getattr(obj, 'id', None),
|
||||
'name': getattr(obj, 'name', ''),
|
||||
})
|
||||
|
||||
# Service and reminder info
|
||||
service_data = None
|
||||
reminder_hours_before = 24 # Default
|
||||
if event.service:
|
||||
service_data = {
|
||||
'id': event.service.id,
|
||||
'name': event.service.name,
|
||||
'duration': event.service.duration,
|
||||
'price': str(event.service.price),
|
||||
'reminder_enabled': event.service.reminder_enabled,
|
||||
'reminder_hours_before': event.service.reminder_hours_before,
|
||||
}
|
||||
reminder_hours_before = event.service.reminder_hours_before
|
||||
|
||||
# Calculate hours until event
|
||||
hours_until = (event.start_time - now).total_seconds() / 3600
|
||||
|
||||
results.append({
|
||||
'id': event.id,
|
||||
'title': event.title,
|
||||
'start_time': event.start_time.isoformat(),
|
||||
'end_time': event.end_time.isoformat() if event.end_time else None,
|
||||
'status': event.status,
|
||||
'hours_until_start': round(hours_until, 2),
|
||||
'reminder_hours_before': reminder_hours_before,
|
||||
'should_send_reminder': hours_until <= reminder_hours_before,
|
||||
'service': service_data,
|
||||
'customer': customer,
|
||||
'resources': resources,
|
||||
'notes': getattr(event, 'notes', None),
|
||||
'location': {
|
||||
'id': event.location.id,
|
||||
'name': event.location.name,
|
||||
'address': event.location.address_line1,
|
||||
} if event.location else None,
|
||||
'created_at': event.created_at.isoformat() if event.created_at else None,
|
||||
})
|
||||
|
||||
return Response(results)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Email Endpoints
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user