+ {t('helpTimeBlocks.subtitle', 'Learn how to block off time for closures, holidays, and unavailability')}
+
+
+
+
+
+ {/* Overview Section */}
+
+
+
+ {t('helpTimeBlocks.overview.title', 'What are Time Blocks?')}
+
+
+
+ Time blocks allow you to mark specific dates, times, or recurring periods as unavailable for bookings.
+ Use them to manage holidays, business closures, staff vacations, maintenance windows, and more.
+
+
+
+
+
+
Business Blocks
+
+ Apply to all resources. Perfect for company holidays, office closures, and maintenance.
+
+
+
+
+
+
+
Resource Blocks
+
+ Apply to specific resources. Use for individual vacations, appointments, or training.
+
+
+
+
+
+
+
Hard Blocks
+
+ Completely prevent bookings during the blocked period. Cannot be overridden.
+
+
+
+
+
+
+
Soft Blocks
+
+ Show a warning but still allow bookings with confirmation.
+
+ Holidays, office closures, company events, maintenance
+
+
+
+
+
+
+ Resource
+
+
+
+ A specific resource (staff member, room, etc.)
+
+
+ Vacation, personal appointments, lunch breaks, training
+
+
+
+
+
+
+
+
+
+
Blocks are Additive
+
+ Both business-level and resource-level blocks apply. If the business is closed on a holiday,
+ individual resource blocks don't matter for that day.
+
+
+
+
+
+
+ {/* Block Types Section */}
+
+
+
+ {t('helpTimeBlocks.types.title', 'Block Types: Hard vs Soft')}
+
+
+
+
+
+
+
+
+
Hard Block
+
+ Completely prevents any bookings during the blocked period. Customers cannot book,
+ and staff cannot override. The scheduler shows a striped red overlay.
+
+
+ Cannot be overridden
+ Shows in customer booking
+ Red striped overlay
+
+
+
+
+
+
+
+
+
+
+
Soft Block
+
+ Shows a warning but allows bookings with confirmation. Useful for indicating
+ preferred-off times that can be overridden if necessary.
+
+
+ Can be overridden
+ Shows warning only
+ Yellow dashed overlay
+
+ A specific date or date range that occurs once
+
+
+ Dec 24-26 (Christmas break), Feb 15 (President's Day)
+
+
+
+
+ Weekly
+
+
+ Repeats on specific days of the week
+
+
+ Every Saturday and Sunday, Every Monday lunch
+
+
+
+
+ Monthly
+
+
+ Repeats on specific days of the month
+
+
+ 1st of every month (inventory), 15th (payroll)
+
+
+
+
+ Yearly
+
+
+ Repeats on a specific month and day each year
+
+
+ July 4th, December 25th, January 1st
+
+
+
+
+ Holiday
+
+
+ Select from popular US holidays. Multi-select supported - each holiday creates its own block.
+
+
+ Christmas, Thanksgiving, Memorial Day, Independence Day
+
+
+
+
+
+
+
+ {/* Visualization Section */}
+
+
+
+ {t('helpTimeBlocks.visualization.title', 'Viewing Time Blocks')}
+
+
+
+ Time blocks appear in multiple views throughout the application with color-coded indicators:
+
+
+ {/* Color Legend */}
+
+
Color Legend
+
+
+
+ Business Hard Block
+ B
+
+
+
+ Business Soft Block
+ B
+
+
+
+ Resource Hard Block
+ R
+
+
+
+ Resource Soft Block
+ R
+
+
+
+
+
+
+
+
+
Scheduler Overlay
+
+ Blocked times appear directly on the scheduler calendar with visual indicators.
+ Business blocks use red/yellow colors, resource blocks use purple/cyan.
+ Click on any blocked area in week view to navigate to that day.
+
+
+
+
+
+
+
Month View
+
+ Blocked dates show with colored backgrounds and badge indicators.
+ Multiple block types on the same day show all applicable badges.
+
+
+
+
+
+
+
List View
+
+ Manage all time blocks in a tabular format with filtering options.
+ Edit, activate/deactivate, or delete blocks from here.
+
+ Staff members can manage their own time blocks through the "My Availability" page.
+ This allows them to block off time for personal appointments, vacations, or other commitments.
+
+
+
+
+ View business-level blocks (read-only)
+
+
+
+ Create and manage personal time blocks
+
+
+
+ See yearly calendar of their availability
+
+
+
+
+
+
+
Hard Block Permission
+
+ By default, staff can only create soft blocks. To allow a staff member to create hard blocks,
+ enable the "Can create hard blocks" permission in their staff settings.
+
+ The Contracts feature enables electronic document signing for your business. Create reusable
+ templates, send contracts to customers, and maintain legally compliant audit trails with automatic
+ PDF generation.
+
+
+
Contract Templates
+
+ Templates are reusable contract documents with placeholder variables that get filled in when sent:
+
+
+
+
Template Properties
+
+
• Name: Internal template identifier
+
• Content: HTML document with variables
+
• Scope: Customer-level or per-appointment
+
• Expiration: Days until contract expires
+
+
+
+
Available Variables
+
+
• {'{{CUSTOMER_NAME}}'}
+
• {'{{CUSTOMER_EMAIL}}'}
+
• {'{{BUSINESS_NAME}}'}
+
• {'{{DATE}}'} and {'{{YEAR}}'}
+
+
+
+
+
Contract Workflow
+
+
+ 1
+
+ Create Contract
+
Select a template and customer. Variables are automatically filled in.
+
+
+
+ 2
+
+ Send for Signing
+
Customer receives an email with a secure signing link.
+
+
+
+ 3
+
+ Customer Signs
+
Customer agrees via checkbox consent with full audit trail capture.
+
+
+
+ 4
+
+ PDF Generated
+
Signed PDF with audit trail is generated and stored automatically.
+
+
+
+
+
Contract Statuses
+
+
+ Pending
+
Awaiting signature
+
+
+ Signed
+
Successfully completed
+
+
+ Expired
+
Past expiration date
+
+
+ Voided
+
Manually cancelled
+
+
+
+
Legal Compliance
+
+
+
+
+
ESIGN & UETA Compliant
+
+ All signatures capture: timestamp, IP address, user agent, document hash, consent checkbox states,
+ and exact consent language. This creates a legally defensible audit trail.
+
+
+
+
+
+
Key Features
+
+
+
+ Email Delivery: Contracts are sent directly to customer email with signing link
+
+
+
+ Shareable Links: Copy signing link to share via other channels
+
+
+
+ PDF Download: Download signed contracts with full audit trail
+
+
+
+ Status Tracking: Monitor which contracts are pending, signed, or expired
+
+
+
+
+
+
+
+
Contracts Documentation
+
Complete guide to templates, signing, and compliance features
Create and manage digital contracts with e-signatures
+
+
+
+
+
+
+ Overview
+
+
+
+ The Contracts system allows you to create reusable contract templates, send them to customers for digital signature, and maintain legally compliant records with full audit trails.
+
+
+ All signatures are captured with ESIGN Act and UETA compliance, including IP address, timestamp, browser information, and optional geolocation for maximum legal protection.
+
+
+
+
+
+
+ Contract Templates
+
+
+
+ Templates are reusable contract documents that can be personalized with variable placeholders.
+
+
+
Template Variables
+
+ Use these placeholders in your templates - they'll be automatically replaced when the contract is created:
+
+
+
+
{"{{CUSTOMER_NAME}}"} - Full name
+
{"{{CUSTOMER_FIRST_NAME}}"} - First name
+
{"{{CUSTOMER_EMAIL}}"} - Email address
+
{"{{CUSTOMER_PHONE}}"} - Phone number
+
{"{{BUSINESS_NAME}}"} - Your business name
+
{"{{BUSINESS_EMAIL}}"} - Contact email
+
{"{{BUSINESS_PHONE}}"} - Business phone
+
{"{{DATE}}"} - Current date
+
{"{{YEAR}}"} - Current year
+
+
+
+
Template Scopes
+
+
+
+
+ Customer-Level
+
One-time contracts per customer (e.g., privacy policy, terms of service)
+
+
+
+
+
+ Appointment-Level
+
Signed for each booking (e.g., liability waivers, service agreements)
+
+
+
+
+
+
+
+
+ Sending Contracts
+
+
+
+
+
+ 1
+
+
+
Select a Template
+
Choose from your active contract templates
+
+
+
+
+ 2
+
+
+
Choose a Customer
+
Variables are automatically filled with customer data
+
+
+
+
+ 3
+
+
+
Send for Signature
+
Customer receives an email with a secure signing link
+
+
+
+
+ 4
+
+
+
Track Status
+
Monitor pending, signed, expired, or voided contracts
+
+
+
+
+
+
+
+
+ Contract Status
+
+
+
+
+
+ Pending
+ - Awaiting customer signature
+
+
+
+ Signed
+ - Customer has signed the contract
+
+
+
+ Expired
+ - Contract expired before signing
+
+
+
+ Voided
+ - Contract was cancelled by business
+
+
+
+
+
+
+
+ Legal Compliance
+
+
+
+
+
+
+ ESIGN Act & UETA Compliant: All signatures include comprehensive audit trails that meet federal and state requirements for electronic signatures.
+
+
+
+
+
Captured Audit Data
+
+
+
+ Document hash (SHA-256)
+
+
+
+ Signature timestamp (ISO)
+
+
+
+ Signer's IP address
+
+
+
+ Browser/device information
+
+
+
+ Consent checkbox states
+
+
+
+ Geolocation (if permitted)
+
+
+
+
+
+
+
+ PDF Generation
+
+
+
+ Once a contract is signed, a PDF is automatically generated that includes:
+
+
+
+
+ The full contract content with substituted variables
+
+
+
+ Signature section with signer's name and consent confirmations
+
+
+
+ Complete audit trail footer with all verification data
+
+
+
+ Your business branding and logo
+
+
+
+
+
+
+
+ Best Practices
+
+
+
+
+
+ Use Clear Language: Write contracts in plain language that customers can easily understand
+
+
+
+ Set Expiration Dates: Use the expiration feature to ensure contracts are signed in a timely manner
+
+
+
+ Link to Services: Associate contracts with specific services for automatic requirement checking
+
+
+
+ Version Control: Create new versions rather than editing existing active templates
+
+
+
+ Download PDFs: Keep copies of signed contracts for your records
+
+ );
+};
+
+export default HelpContracts;
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 5a389e5..009da10 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -448,4 +448,197 @@ export interface EmailTemplateVariable {
export interface EmailTemplateVariableGroup {
category: string;
items: EmailTemplateVariable[];
+}
+
+// --- Contract Types ---
+
+export type ContractScope = 'CUSTOMER' | 'APPOINTMENT';
+export type ContractStatus = 'PENDING' | 'SIGNED' | 'EXPIRED' | 'VOIDED';
+export type ContractTemplateStatus = 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
+
+export interface ContractTemplate {
+ id: string;
+ name: string;
+ description: string;
+ content: string;
+ scope: ContractScope;
+ status: ContractTemplateStatus;
+ expires_after_days: number | null;
+ version: number;
+ version_notes: string;
+ services: { id: string; name: string }[];
+ created_by: string | null;
+ created_by_name: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface Contract {
+ id: string;
+ template: string;
+ template_name: string;
+ template_version: number;
+ scope: ContractScope;
+ status: ContractStatus;
+ content: string;
+ customer?: string;
+ customer_name?: string;
+ customer_email?: string;
+ appointment?: string;
+ appointment_service_name?: string;
+ appointment_start_time?: string;
+ service?: string;
+ service_name?: string;
+ sent_at: string | null;
+ signed_at: string | null;
+ expires_at: string | null;
+ voided_at: string | null;
+ voided_reason: string | null;
+ public_token: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface ContractSignature {
+ id: string;
+ contract: string;
+ signer_name: string;
+ signer_email: string;
+ signature_data: string;
+ ip_address: string;
+ user_agent: string;
+ signed_at: string;
+}
+
+export interface ContractPublicView {
+ contract: Contract;
+ template: {
+ name: string;
+ content: string;
+ };
+ business: {
+ name: string;
+ logo_url?: string;
+ };
+ customer?: {
+ name: string;
+ email: string;
+ };
+ appointment?: {
+ service_name: string;
+ start_time: string;
+ };
+ is_expired: boolean;
+ can_sign: boolean;
+ signature?: ContractSignature;
+}
+
+// --- Time Blocking Types ---
+
+export type BlockType = 'HARD' | 'SOFT';
+export type RecurrenceType = 'NONE' | 'WEEKLY' | 'MONTHLY' | 'YEARLY' | 'HOLIDAY';
+export type TimeBlockLevel = 'business' | 'resource';
+
+export type HolidayType = 'FIXED' | 'FLOATING' | 'CALCULATED';
+
+export interface Holiday {
+ code: string;
+ name: string;
+ country: string;
+ holiday_type?: HolidayType;
+ month?: number;
+ day?: number;
+ week_of_month?: number;
+ day_of_week?: number;
+ calculation_rule?: string;
+ is_active?: boolean;
+ next_occurrence?: string; // ISO date string
+}
+
+export interface RecurrencePattern {
+ days_of_week?: number[]; // 0=Mon, 6=Sun (for WEEKLY)
+ days_of_month?: number[]; // 1-31 (for MONTHLY)
+ month?: number; // 1-12 (for YEARLY)
+ day?: number; // 1-31 (for YEARLY)
+ holiday_code?: string; // holiday code (for HOLIDAY)
+}
+
+export interface TimeBlock {
+ id: string;
+ title: string;
+ description?: string;
+ resource?: string | null; // Resource ID or null for business-level
+ resource_name?: string;
+ level: TimeBlockLevel;
+ block_type: BlockType;
+ recurrence_type: RecurrenceType;
+ start_date?: string; // ISO date string (for NONE type)
+ end_date?: string; // ISO date string (for NONE type)
+ all_day: boolean;
+ start_time?: string; // HH:MM:SS (if not all_day)
+ end_time?: string; // HH:MM:SS (if not all_day)
+ recurrence_pattern?: RecurrencePattern;
+ pattern_display?: string; // Human-readable pattern description
+ holiday_name?: string; // Holiday name if HOLIDAY type
+ recurrence_start?: string; // ISO date string
+ recurrence_end?: string; // ISO date string
+ is_active: boolean;
+ created_by?: string;
+ created_by_name?: string;
+ conflict_count?: number;
+ created_at: string;
+ updated_at?: string;
+}
+
+export interface TimeBlockListItem {
+ id: string;
+ title: string;
+ description?: string;
+ resource?: string | null;
+ resource_name?: string;
+ level: TimeBlockLevel;
+ block_type: BlockType;
+ recurrence_type: RecurrenceType;
+ start_date?: string;
+ end_date?: string;
+ all_day?: boolean;
+ start_time?: string;
+ end_time?: string;
+ recurrence_pattern?: RecurrencePattern;
+ recurrence_start?: string;
+ recurrence_end?: string;
+ pattern_display?: string;
+ is_active: boolean;
+ created_at: string;
+}
+
+export interface BlockedDate {
+ date: string; // ISO date string
+ block_type: BlockType;
+ title: string;
+ resource_id: string | null;
+ all_day: boolean;
+ start_time: string | null;
+ end_time: string | null;
+ time_block_id: string;
+}
+
+export interface TimeBlockConflict {
+ event_id: string;
+ title: string;
+ start_time: string;
+ end_time: string;
+}
+
+export interface TimeBlockConflictCheck {
+ has_conflicts: boolean;
+ conflict_count: number;
+ conflicts: TimeBlockConflict[];
+}
+
+export interface MyBlocksResponse {
+ business_blocks: TimeBlockListItem[];
+ my_blocks: TimeBlockListItem[];
+ resource_id: string;
+ resource_name: string;
}
\ No newline at end of file
diff --git a/smoothschedule/compose/local/django/Dockerfile b/smoothschedule/compose/local/django/Dockerfile
index 6a75323..74d7374 100644
--- a/smoothschedule/compose/local/django/Dockerfile
+++ b/smoothschedule/compose/local/django/Dockerfile
@@ -18,7 +18,14 @@ RUN apt-get update && apt-get install --no-install-recommends -y \
# psycopg dependencies
libpq-dev \
gettext \
- wait-for-it
+ wait-for-it \
+ # WeasyPrint dependencies for PDF generation
+ libpango-1.0-0 \
+ libpangoft2-1.0-0 \
+ libharfbuzz-subset0 \
+ libffi-dev \
+ libcairo2 \
+ libgdk-pixbuf-2.0-0
# Requirements are installed here to ensure they will be cached.
RUN --mount=type=cache,target=/root/.cache/uv \
diff --git a/smoothschedule/compose/production/django/Dockerfile b/smoothschedule/compose/production/django/Dockerfile
index 3d6ae81..efff52e 100644
--- a/smoothschedule/compose/production/django/Dockerfile
+++ b/smoothschedule/compose/production/django/Dockerfile
@@ -49,6 +49,13 @@ RUN apt-get update && apt-get install --no-install-recommends -y \
wait-for-it \
# SSH client for mail server management
openssh-client \
+ # WeasyPrint dependencies for PDF generation
+ libpango-1.0-0 \
+ libpangoft2-1.0-0 \
+ libharfbuzz-subset0 \
+ libffi-dev \
+ libcairo2 \
+ libgdk-pixbuf-2.0-0 \
# cleaning up unused files
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
&& rm -rf /var/lib/apt/lists/*
diff --git a/smoothschedule/config/settings/multitenancy.py b/smoothschedule/config/settings/multitenancy.py
index 19bf9f5..60765c4 100644
--- a/smoothschedule/config/settings/multitenancy.py
+++ b/smoothschedule/config/settings/multitenancy.py
@@ -56,6 +56,7 @@ TENANT_APPS = [
'django.contrib.contenttypes', # Needed for tenant schemas
'schedule', # Resource scheduling with configurable concurrency
'payments', # Stripe Connect payments bridge
+ 'contracts', # Contract/e-signature system
# Add your tenant-scoped business logic apps here:
# 'appointments',
# 'customers',
diff --git a/smoothschedule/config/urls.py b/smoothschedule/config/urls.py
index 3c0c65b..fd47a8d 100644
--- a/smoothschedule/config/urls.py
+++ b/smoothschedule/config/urls.py
@@ -78,6 +78,8 @@ urlpatterns += [
path("", include("analytics.urls")),
# Payments API
path("payments/", include("payments.urls")),
+ # Contracts API
+ path("contracts/", include("contracts.urls")),
# Communication Credits API
path("communication-credits/", include("smoothschedule.comms_credits.urls", namespace="comms_credits")),
# Tickets API
diff --git a/smoothschedule/contracts/README_PDF.md b/smoothschedule/contracts/README_PDF.md
new file mode 100644
index 0000000..1493c1f
--- /dev/null
+++ b/smoothschedule/contracts/README_PDF.md
@@ -0,0 +1,118 @@
+# Contract PDF Generation Setup
+
+The contracts app includes PDF generation capabilities using WeasyPrint. However, WeasyPrint requires system-level dependencies that need to be installed in the Docker container.
+
+## Current Status
+
+✅ **PDF Service Code**: Installed and ready
+✅ **Celery Tasks**: Installed and functional
+✅ **HTML Templates**: Installed and ready
+❌ **WeasyPrint Dependencies**: **NOT INSTALLED**
+
+## To Enable PDF Generation
+
+### 1. Update Docker Image
+
+Edit `compose/local/django/Dockerfile` (or production equivalent) and add the following before pip installs:
+
+```dockerfile
+# Install WeasyPrint dependencies
+RUN apt-get update && apt-get install -y \
+ libpango-1.0-0 \
+ libpangoft2-1.0-0 \
+ libgobject-2.0-0 \
+ libcairo2 \
+ libpangocairo-1.0-0 \
+ fonts-liberation \
+ && rm -rf /var/lib/apt/lists/*
+```
+
+### 2. Add to Requirements
+
+Add to your requirements file (e.g., `requirements/base.txt` or `requirements/local.txt`):
+
+```
+weasyprint>=67.0
+```
+
+### 3. Rebuild Container
+
+```bash
+cd /home/poduck/Desktop/smoothschedule2/smoothschedule
+docker compose -f docker-compose.local.yml up -d --build django
+```
+
+### 4. Verify Installation
+
+```bash
+docker compose -f docker-compose.local.yml exec django python -c "from contracts.pdf_service import ContractPDFService; print('Available:', ContractPDFService.is_available())"
+```
+
+You should see: `Available: True`
+
+## What Works Without WeasyPrint
+
+Even without WeasyPrint installed, the following functionality works:
+
+- ✅ Contract creation and management
+- ✅ Contract signing workflow
+- ✅ Email notifications (signing requests, reminders, confirmations)
+- ✅ Signature audit trail
+- ✅ All Celery tasks (except PDF generation)
+
+The only limitation is that `generate_contract_pdf` task will fail with a clear error message indicating WeasyPrint is not available.
+
+## Files Created
+
+- `/app/contracts/pdf_service.py` - PDF generation service
+- `/app/contracts/tasks.py` - Celery tasks for contracts
+- `/app/templates/contracts/pdf_template.html` - PDF template
+- `/app/templates/contracts/emails/` - Email templates (8 files)
+
+## Testing PDF Generation
+
+Once WeasyPrint is installed, you can test PDF generation:
+
+```python
+from contracts.models import Contract
+from contracts.pdf_service import ContractPDFService
+
+# Get a signed contract
+contract = Contract.objects.filter(status='SIGNED').first()
+
+# Generate PDF
+pdf_bytes = ContractPDFService.generate_pdf(contract)
+
+# Or save to storage
+pdf_path = ContractPDFService.save_contract_pdf(contract)
+print(f"Saved to: {pdf_path}")
+```
+
+## Celery Tasks
+
+All tasks are registered and will be autodiscovered by Celery:
+
+1. `send_contract_email(contract_id)` - Send signing request
+2. `send_contract_reminder(contract_id)` - Send reminder
+3. `send_contract_signed_emails(contract_id)` - Send confirmation emails
+4. `generate_contract_pdf(contract_id)` - Generate PDF (requires WeasyPrint)
+5. `check_expired_contracts()` - Periodic task to mark expired contracts
+6. `send_pending_reminders()` - Periodic task to send reminders 3 days before expiry
+
+## Periodic Tasks
+
+Add to your Celery beat schedule:
+
+```python
+# In config/settings/base.py or local.py
+CELERY_BEAT_SCHEDULE = {
+ 'check-expired-contracts': {
+ 'task': 'contracts.tasks.check_expired_contracts',
+ 'schedule': crontab(hour=2, minute=0), # Daily at 2 AM
+ },
+ 'send-contract-reminders': {
+ 'task': 'contracts.tasks.send_pending_reminders',
+ 'schedule': crontab(hour=10, minute=0), # Daily at 10 AM
+ },
+}
+```
diff --git a/smoothschedule/contracts/__init__.py b/smoothschedule/contracts/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/smoothschedule/contracts/admin.py b/smoothschedule/contracts/admin.py
new file mode 100644
index 0000000..8c38f3f
--- /dev/null
+++ b/smoothschedule/contracts/admin.py
@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.
diff --git a/smoothschedule/contracts/apps.py b/smoothschedule/contracts/apps.py
new file mode 100644
index 0000000..82de8d1
--- /dev/null
+++ b/smoothschedule/contracts/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ContractsConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'contracts'
diff --git a/smoothschedule/contracts/migrations/0001_initial.py b/smoothschedule/contracts/migrations/0001_initial.py
new file mode 100644
index 0000000..4594254
--- /dev/null
+++ b/smoothschedule/contracts/migrations/0001_initial.py
@@ -0,0 +1,115 @@
+# Generated by Django 5.2.8 on 2025-12-04 19:15
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('schedule', '0027_add_deposit_percent_back'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ContractTemplate',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(max_length=200)),
+ ('description', models.TextField(blank=True)),
+ ('content', models.TextField(help_text='Contract body with {{VARIABLE}} placeholders')),
+ ('scope', models.CharField(choices=[('CUSTOMER', 'Customer-Level'), ('APPOINTMENT', 'Appointment-Level')], default='APPOINTMENT', max_length=20)),
+ ('status', models.CharField(choices=[('DRAFT', 'Draft'), ('ACTIVE', 'Active'), ('ARCHIVED', 'Archived')], default='DRAFT', max_length=20)),
+ ('expires_after_days', models.PositiveIntegerField(blank=True, help_text='Days after sending before contract expires (null=never)', null=True)),
+ ('version', models.PositiveIntegerField(default=1)),
+ ('version_notes', models.TextField(blank=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_contract_templates', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'ordering': ['name'],
+ },
+ ),
+ migrations.CreateModel(
+ name='Contract',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('template_version', models.PositiveIntegerField(help_text='Version of template when contract was created')),
+ ('title', models.CharField(max_length=200)),
+ ('content_html', models.TextField(help_text='Rendered HTML content at time of creation')),
+ ('content_hash', models.CharField(help_text='SHA-256 hash of content for tamper detection', max_length=64)),
+ ('status', models.CharField(choices=[('PENDING', 'Pending Signature'), ('SIGNED', 'Signed'), ('EXPIRED', 'Expired'), ('VOIDED', 'Voided')], default='PENDING', max_length=20)),
+ ('signing_token', models.CharField(help_text='Token for public signing URL', max_length=64, unique=True)),
+ ('expires_at', models.DateTimeField(blank=True, null=True)),
+ ('sent_at', models.DateTimeField(blank=True, null=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('pdf_path', models.CharField(blank=True, max_length=500)),
+ ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contracts', to=settings.AUTH_USER_MODEL)),
+ ('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contracts', to='schedule.event')),
+ ('sent_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_contracts', to=settings.AUTH_USER_MODEL)),
+ ('template', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contracts', to='contracts.contracttemplate')),
+ ],
+ options={
+ 'ordering': ['-created_at'],
+ },
+ ),
+ migrations.CreateModel(
+ name='ServiceContractRequirement',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('display_order', models.PositiveIntegerField(default=0)),
+ ('is_required', models.BooleanField(default=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('service', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contract_requirements', to='schedule.service')),
+ ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='service_requirements', to='contracts.contracttemplate')),
+ ],
+ options={
+ 'ordering': ['display_order'],
+ },
+ ),
+ migrations.CreateModel(
+ name='ContractSignature',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('consent_checkbox_checked', models.BooleanField(default=False)),
+ ('consent_text', models.TextField(help_text='Exact consent language shown to signer')),
+ ('electronic_consent_given', models.BooleanField(default=False)),
+ ('electronic_consent_text', models.TextField(help_text='ESIGN Act consent text')),
+ ('signer_name', models.CharField(max_length=200)),
+ ('signer_email', models.EmailField(max_length=254)),
+ ('signed_at', models.DateTimeField()),
+ ('ip_address', models.GenericIPAddressField()),
+ ('user_agent', models.TextField()),
+ ('browser_fingerprint', models.CharField(blank=True, max_length=64)),
+ ('document_hash_at_signing', models.CharField(help_text='SHA-256 hash of contract content at moment of signing', max_length=64)),
+ ('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
+ ('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
+ ('contract', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='signature', to='contracts.contract')),
+ ],
+ options={
+ 'indexes': [models.Index(fields=['signed_at'], name='contracts_c_signed__e562eb_idx')],
+ },
+ ),
+ migrations.AddIndex(
+ model_name='contract',
+ index=models.Index(fields=['signing_token'], name='contracts_c_signing_4e91ca_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='contract',
+ index=models.Index(fields=['customer', 'status'], name='contracts_c_custome_791003_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='contract',
+ index=models.Index(fields=['template', 'customer'], name='contracts_c_templat_e832ba_idx'),
+ ),
+ migrations.AlterUniqueTogether(
+ name='servicecontractrequirement',
+ unique_together={('service', 'template')},
+ ),
+ ]
diff --git a/smoothschedule/contracts/migrations/__init__.py b/smoothschedule/contracts/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/smoothschedule/contracts/models.py b/smoothschedule/contracts/models.py
new file mode 100644
index 0000000..f74f7cf
--- /dev/null
+++ b/smoothschedule/contracts/models.py
@@ -0,0 +1,265 @@
+"""
+Contract/E-Signature models for SmoothSchedule.
+Provides ESIGN Act/UETA compliant digital signatures.
+"""
+import hashlib
+import secrets
+from django.db import models
+from django.utils import timezone
+
+
+class ContractTemplate(models.Model):
+ """
+ Reusable contract template with variable substitution.
+ Business-specific templates stored in tenant schema.
+ """
+ class Scope(models.TextChoices):
+ CUSTOMER = "CUSTOMER", "Customer-Level" # One-time per customer
+ APPOINTMENT = "APPOINTMENT", "Appointment-Level" # Per booking
+
+ class Status(models.TextChoices):
+ DRAFT = "DRAFT", "Draft"
+ ACTIVE = "ACTIVE", "Active"
+ ARCHIVED = "ARCHIVED", "Archived"
+
+ # Basic info
+ name = models.CharField(max_length=200)
+ description = models.TextField(blank=True)
+
+ # Contract content (HTML with {{variables}})
+ content = models.TextField(help_text="Contract body with {{VARIABLE}} placeholders")
+
+ # Scope and status
+ scope = models.CharField(
+ max_length=20,
+ choices=Scope.choices,
+ default=Scope.APPOINTMENT
+ )
+ status = models.CharField(
+ max_length=20,
+ choices=Status.choices,
+ default=Status.DRAFT
+ )
+
+ # Expiration settings
+ expires_after_days = models.PositiveIntegerField(
+ null=True,
+ blank=True,
+ help_text="Days after sending before contract expires (null=never)"
+ )
+
+ # Version tracking for legal compliance
+ version = models.PositiveIntegerField(default=1)
+ version_notes = models.TextField(blank=True)
+
+ # Metadata
+ created_by = models.ForeignKey(
+ "users.User",
+ on_delete=models.SET_NULL,
+ null=True,
+ related_name="created_contract_templates"
+ )
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ ordering = ["name"]
+
+ def __str__(self):
+ return f"{self.name} (v{self.version})"
+
+
+class ServiceContractRequirement(models.Model):
+ """
+ Links services to required contract templates.
+ When booking a service, these contracts must be signed.
+ """
+ service = models.ForeignKey(
+ "schedule.Service",
+ on_delete=models.CASCADE,
+ related_name="contract_requirements"
+ )
+ template = models.ForeignKey(
+ ContractTemplate,
+ on_delete=models.CASCADE,
+ related_name="service_requirements"
+ )
+
+ # Ordering for multiple contracts
+ display_order = models.PositiveIntegerField(default=0)
+
+ # Whether this is required or optional
+ is_required = models.BooleanField(default=True)
+
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ ordering = ["display_order"]
+ unique_together = ["service", "template"]
+
+ def __str__(self):
+ return f"{self.service.name} requires {self.template.name}"
+
+
+class Contract(models.Model):
+ """
+ Instance of a contract sent to a customer.
+ Contains the rendered content at time of sending (snapshot).
+ """
+ class Status(models.TextChoices):
+ PENDING = "PENDING", "Pending Signature"
+ SIGNED = "SIGNED", "Signed"
+ EXPIRED = "EXPIRED", "Expired"
+ VOIDED = "VOIDED", "Voided"
+
+ # Source template (for reference, content is snapshotted)
+ template = models.ForeignKey(
+ ContractTemplate,
+ on_delete=models.SET_NULL,
+ null=True,
+ related_name="contracts"
+ )
+ template_version = models.PositiveIntegerField(
+ help_text="Version of template when contract was created"
+ )
+
+ # Content snapshot (frozen at creation)
+ title = models.CharField(max_length=200)
+ content_html = models.TextField(help_text="Rendered HTML content at time of creation")
+ content_hash = models.CharField(
+ max_length=64,
+ help_text="SHA-256 hash of content for tamper detection"
+ )
+
+ # Customer
+ customer = models.ForeignKey(
+ "users.User",
+ on_delete=models.CASCADE,
+ related_name="contracts"
+ )
+
+ # Optional appointment link (null for customer-level contracts)
+ event = models.ForeignKey(
+ "schedule.Event",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="contracts"
+ )
+
+ # Status and token
+ status = models.CharField(
+ max_length=20,
+ choices=Status.choices,
+ default=Status.PENDING
+ )
+ signing_token = models.CharField(
+ max_length=64,
+ unique=True,
+ help_text="Token for public signing URL"
+ )
+
+ # Expiration
+ expires_at = models.DateTimeField(null=True, blank=True)
+
+ # Metadata
+ sent_by = models.ForeignKey(
+ "users.User",
+ on_delete=models.SET_NULL,
+ null=True,
+ related_name="sent_contracts"
+ )
+ sent_at = models.DateTimeField(null=True, blank=True)
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ # PDF storage path (generated after signing)
+ pdf_path = models.CharField(max_length=500, blank=True)
+
+ class Meta:
+ ordering = ["-created_at"]
+ indexes = [
+ models.Index(fields=["signing_token"]),
+ models.Index(fields=["customer", "status"]),
+ models.Index(fields=["template", "customer"]),
+ ]
+
+ def save(self, *args, **kwargs):
+ if not self.signing_token:
+ self.signing_token = secrets.token_urlsafe(48)
+ if not self.content_hash and self.content_html:
+ self.content_hash = hashlib.sha256(self.content_html.encode()).hexdigest()
+ super().save(*args, **kwargs)
+
+ def get_signing_url(self, request=None):
+ """Generate the public signing URL."""
+ from django.conf import settings
+ base_url = getattr(settings, "FRONTEND_URL", "")
+ if not base_url and request:
+ base_url = f"{request.scheme}://{request.get_host()}"
+ return f"{base_url}/sign/{self.signing_token}"
+
+ def __str__(self):
+ return f"{self.title} - {self.customer.email} ({self.status})"
+
+
+class ContractSignature(models.Model):
+ """
+ Audit trail for contract signature.
+ Contains all legally required data for ESIGN Act/UETA compliance.
+ """
+ contract = models.OneToOneField(
+ Contract,
+ on_delete=models.CASCADE,
+ related_name="signature"
+ )
+
+ # Signature method
+ consent_checkbox_checked = models.BooleanField(default=False)
+ consent_text = models.TextField(
+ help_text="Exact consent language shown to signer"
+ )
+
+ # Electronic consent to conduct business electronically
+ electronic_consent_given = models.BooleanField(default=False)
+ electronic_consent_text = models.TextField(
+ help_text="ESIGN Act consent text"
+ )
+
+ # Signer identification
+ signer_name = models.CharField(max_length=200)
+ signer_email = models.EmailField()
+
+ # Audit trail (ESIGN/UETA compliance)
+ signed_at = models.DateTimeField()
+ ip_address = models.GenericIPAddressField()
+ user_agent = models.TextField()
+ browser_fingerprint = models.CharField(max_length=64, blank=True)
+
+ # Document integrity
+ document_hash_at_signing = models.CharField(
+ max_length=64,
+ help_text="SHA-256 hash of contract content at moment of signing"
+ )
+
+ # Geolocation (optional, if available)
+ latitude = models.DecimalField(
+ max_digits=9,
+ decimal_places=6,
+ null=True,
+ blank=True
+ )
+ longitude = models.DecimalField(
+ max_digits=9,
+ decimal_places=6,
+ null=True,
+ blank=True
+ )
+
+ class Meta:
+ indexes = [
+ models.Index(fields=["signed_at"]),
+ ]
+
+ def __str__(self):
+ return f"Signature by {self.signer_name} on {self.signed_at}"
diff --git a/smoothschedule/contracts/pdf_service.py b/smoothschedule/contracts/pdf_service.py
new file mode 100644
index 0000000..e0aa19f
--- /dev/null
+++ b/smoothschedule/contracts/pdf_service.py
@@ -0,0 +1,299 @@
+"""
+PDF generation service for contract documents using WeasyPrint.
+Generates legally compliant contract PDFs with audit trails.
+
+NOTE: WeasyPrint requires system dependencies (Pango, GObject, Cairo).
+To enable PDF generation, add these to the Docker image:
+ apt-get install -y libpango-1.0-0 libpangoft2-1.0-0 libgobject-2.0-0 libcairo2
+ pip install weasyprint
+"""
+import logging
+from io import BytesIO
+from django.template.loader import render_to_string
+from django.conf import settings
+
+logger = logging.getLogger(__name__)
+
+try:
+ from weasyprint import HTML, CSS
+ from weasyprint.text.fonts import FontConfiguration
+ WEASYPRINT_AVAILABLE = True
+except (ImportError, OSError) as e:
+ logger.warning(f"WeasyPrint not available: {e}")
+ WEASYPRINT_AVAILABLE = False
+ HTML = None
+ CSS = None
+ FontConfiguration = None
+
+
+class ContractPDFService:
+ """
+ Service for generating contract PDFs with audit trail and legal compliance.
+ """
+
+ @staticmethod
+ def generate_pdf(contract):
+ """
+ Generate a PDF from a signed contract.
+
+ Args:
+ contract: Contract instance (must have signature)
+
+ Returns:
+ BytesIO: PDF file as bytes
+
+ Raises:
+ ValueError: If contract is not signed or signature data is missing
+ RuntimeError: If WeasyPrint is not available
+ """
+ if not WEASYPRINT_AVAILABLE:
+ raise RuntimeError(
+ "WeasyPrint is not available. Please install system dependencies: "
+ "libpango-1.0-0 libpangoft2-1.0-0 libgobject-2.0-0 libcairo2, "
+ "then pip install weasyprint"
+ )
+
+ if contract.status != 'SIGNED':
+ raise ValueError("Contract must be signed before generating PDF")
+
+ if not hasattr(contract, 'signature') or not contract.signature:
+ raise ValueError("Contract signature data is missing")
+
+ signature = contract.signature
+
+ # Get tenant/business info
+ from django.db import connection
+ from core.models import Tenant
+
+ tenant = None
+ if hasattr(connection, 'tenant'):
+ tenant = connection.tenant
+ else:
+ # Fallback - try to get tenant from schema
+ try:
+ tenant = Tenant.objects.get(schema_name=connection.schema_name)
+ except Tenant.DoesNotExist:
+ logger.warning(f"Could not find tenant for schema: {connection.schema_name}")
+
+ # Prepare context for template
+ context = {
+ 'contract': contract,
+ 'signature': signature,
+ 'tenant': tenant,
+ 'business_name': tenant.name if tenant else 'SmoothSchedule',
+ 'business_logo_url': tenant.logo.url if tenant and tenant.logo else None,
+ 'customer': contract.customer,
+ 'event': contract.event,
+
+ # Geolocation display
+ 'geolocation': None,
+
+ # Legal compliance text
+ 'esign_notice': (
+ "This document is a legally binding contract executed under the "
+ "U.S. Electronic Signatures in Global and National Commerce Act (ESIGN Act) "
+ "and the Uniform Electronic Transactions Act (UETA). "
+ "By electronically signing this document, all parties agree that such signature "
+ "is the legal equivalent of their manual signature."
+ ),
+ }
+
+ # Format geolocation if available
+ if signature.latitude and signature.longitude:
+ context['geolocation'] = f"{signature.latitude}, {signature.longitude}"
+
+ # Render HTML from template
+ html_string = render_to_string('contracts/pdf_template.html', context)
+
+ # Configure fonts
+ font_config = FontConfiguration()
+
+ # Generate PDF
+ html = HTML(string=html_string, base_url=settings.STATIC_URL or '/')
+
+ # Custom CSS for better PDF rendering
+ css_string = """
+ @page {
+ size: letter;
+ margin: 1in 0.75in;
+ @bottom-right {
+ content: "Page " counter(page) " of " counter(pages);
+ font-size: 9pt;
+ color: #666;
+ }
+ }
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ line-height: 1.6;
+ color: #1f2937;
+ }
+ table {
+ page-break-inside: avoid;
+ }
+ """
+ css = CSS(string=css_string, font_config=font_config)
+
+ # Render to PDF
+ pdf_bytes = BytesIO()
+ html.write_pdf(pdf_bytes, stylesheets=[css], font_config=font_config)
+ pdf_bytes.seek(0)
+
+ logger.info(f"Generated PDF for contract {contract.id} ({contract.title})")
+
+ return pdf_bytes
+
+ @staticmethod
+ def save_contract_pdf(contract, storage_path=None):
+ """
+ Generate and save contract PDF to storage.
+
+ Args:
+ contract: Contract instance
+ storage_path: Optional custom storage path
+
+ Returns:
+ str: Path where PDF was saved
+
+ Raises:
+ ValueError: If contract validation fails
+ RuntimeError: If WeasyPrint is not available
+ """
+ from django.core.files.base import ContentFile
+ from django.core.files.storage import default_storage
+ import os
+
+ # Generate PDF
+ pdf_bytes = ContractPDFService.generate_pdf(contract)
+
+ # Determine storage path
+ if not storage_path:
+ # Use contract signing token as filename (unique)
+ filename = f"contract_{contract.signing_token}.pdf"
+ storage_path = os.path.join('contracts', str(contract.customer.id), filename)
+
+ # Save to storage
+ saved_path = default_storage.save(storage_path, ContentFile(pdf_bytes.read()))
+
+ # Update contract model
+ contract.pdf_path = saved_path
+ contract.save(update_fields=['pdf_path'])
+
+ logger.info(f"Saved contract PDF to: {saved_path}")
+
+ return saved_path
+
+ @staticmethod
+ def is_available():
+ """Check if PDF generation is available."""
+ return WEASYPRINT_AVAILABLE
+
+ @staticmethod
+ def generate_template_preview(template, user=None):
+ """
+ Generate a PDF preview from a contract template with sample data.
+
+ Args:
+ template: ContractTemplate instance
+ user: Optional user requesting the preview
+
+ Returns:
+ BytesIO: PDF file as bytes
+
+ Raises:
+ RuntimeError: If WeasyPrint is not available
+ """
+ if not WEASYPRINT_AVAILABLE:
+ raise RuntimeError(
+ "WeasyPrint is not available. Please install system dependencies."
+ )
+
+ from django.db import connection
+ from django.utils import timezone
+ from core.models import Tenant
+
+ # Get tenant info
+ tenant = None
+ try:
+ tenant = Tenant.objects.get(schema_name=connection.schema_name)
+ except Tenant.DoesNotExist:
+ logger.warning(f"Could not find tenant for schema: {connection.schema_name}")
+
+ # Sample data for variable substitution
+ sample_context = {
+ "CUSTOMER_NAME": "John Smith",
+ "CUSTOMER_FIRST_NAME": "John",
+ "CUSTOMER_LAST_NAME": "Smith",
+ "CUSTOMER_EMAIL": "john.smith@example.com",
+ "CUSTOMER_PHONE": "(555) 123-4567",
+ "BUSINESS_NAME": tenant.name if tenant else "Your Business",
+ "BUSINESS_EMAIL": tenant.contact_email if tenant else "contact@example.com",
+ "BUSINESS_PHONE": tenant.phone if tenant else "(555) 000-0000",
+ "DATE": timezone.now().strftime("%B %d, %Y"),
+ "YEAR": timezone.now().strftime("%Y"),
+ "APPOINTMENT_DATE": timezone.now().strftime("%B %d, %Y"),
+ "APPOINTMENT_TIME": "10:00 AM",
+ "SERVICE_NAME": "Sample Service",
+ }
+
+ # Substitute variables in content
+ content = template.content
+ for key, value in sample_context.items():
+ content = content.replace(f"{{{{{key}}}}}", str(value or ""))
+
+ # Prepare context for template
+ context = {
+ 'template': template,
+ 'content_html': content,
+ 'tenant': tenant,
+ 'business_name': tenant.name if tenant else 'SmoothSchedule',
+ 'business_logo_url': tenant.logo.url if tenant and tenant.logo else None,
+ 'is_preview': True,
+ 'preview_notice': 'PREVIEW - This is a sample preview with placeholder data',
+ }
+
+ # Render HTML from template
+ html_string = render_to_string('contracts/pdf_preview_template.html', context)
+
+ # Configure fonts
+ font_config = FontConfiguration()
+
+ # Generate PDF
+ html = HTML(string=html_string, base_url=settings.STATIC_URL or '/')
+
+ # Custom CSS for PDF rendering
+ css_string = """
+ @page {
+ size: letter;
+ margin: 1in 0.75in;
+ @bottom-right {
+ content: "Page " counter(page) " of " counter(pages);
+ font-size: 9pt;
+ color: #666;
+ }
+ }
+ body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ line-height: 1.6;
+ color: #1f2937;
+ }
+ .preview-banner {
+ background: #fef3c7;
+ border: 2px solid #f59e0b;
+ color: #92400e;
+ padding: 12px;
+ text-align: center;
+ font-weight: bold;
+ margin-bottom: 24px;
+ border-radius: 4px;
+ }
+ """
+ css = CSS(string=css_string, font_config=font_config)
+
+ # Render to PDF
+ pdf_bytes = BytesIO()
+ html.write_pdf(pdf_bytes, stylesheets=[css], font_config=font_config)
+ pdf_bytes.seek(0)
+
+ logger.info(f"Generated preview PDF for template {template.id} ({template.name})")
+
+ return pdf_bytes
diff --git a/smoothschedule/contracts/serializers.py b/smoothschedule/contracts/serializers.py
new file mode 100644
index 0000000..7f2543c
--- /dev/null
+++ b/smoothschedule/contracts/serializers.py
@@ -0,0 +1,195 @@
+"""
+Serializers for Contract/E-Signature system.
+"""
+from rest_framework import serializers
+from .models import ContractTemplate, ServiceContractRequirement, Contract, ContractSignature
+
+
+class ContractTemplateSerializer(serializers.ModelSerializer):
+ """Full serializer for template CRUD"""
+ services = serializers.SerializerMethodField()
+ created_by_name = serializers.SerializerMethodField()
+
+ class Meta:
+ model = ContractTemplate
+ fields = [
+ "id", "name", "description", "content", "scope", "status",
+ "expires_after_days", "version", "version_notes", "services",
+ "created_by", "created_by_name", "created_at", "updated_at"
+ ]
+ read_only_fields = ["version", "created_by", "created_at", "updated_at"]
+
+ def get_services(self, obj):
+ requirements = obj.service_requirements.select_related("service")
+ return [{"id": r.service.id, "name": r.service.name} for r in requirements]
+
+ def get_created_by_name(self, obj):
+ if obj.created_by:
+ return obj.created_by.get_full_name() or obj.created_by.email
+ return None
+
+
+class ContractTemplateListSerializer(serializers.ModelSerializer):
+ """Lightweight serializer for dropdowns/lists"""
+ class Meta:
+ model = ContractTemplate
+ fields = ["id", "name", "scope", "status", "version"]
+
+
+class ServiceContractRequirementSerializer(serializers.ModelSerializer):
+ template_name = serializers.CharField(source="template.name", read_only=True)
+ template_scope = serializers.CharField(source="template.scope", read_only=True)
+ service_name = serializers.CharField(source="service.name", read_only=True)
+
+ class Meta:
+ model = ServiceContractRequirement
+ fields = [
+ "id", "service", "service_name", "template", "template_name",
+ "template_scope", "display_order", "is_required", "created_at"
+ ]
+
+
+class ContractSignatureSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = ContractSignature
+ fields = [
+ "signed_at", "signer_name", "signer_email", "ip_address",
+ "consent_checkbox_checked", "electronic_consent_given"
+ ]
+
+
+class ContractSerializer(serializers.ModelSerializer):
+ customer_name = serializers.SerializerMethodField()
+ customer_email = serializers.CharField(source="customer.email", read_only=True)
+ template_name = serializers.SerializerMethodField()
+ is_signed = serializers.SerializerMethodField()
+ signature_details = ContractSignatureSerializer(source="signature", read_only=True)
+ signing_url = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Contract
+ fields = [
+ "id", "template", "template_name", "template_version", "title",
+ "content_html", "customer", "customer_name", "customer_email",
+ "event", "status", "expires_at", "is_signed", "signature_details",
+ "signing_url", "pdf_path", "sent_at", "created_at", "updated_at"
+ ]
+ read_only_fields = [
+ "template_version", "content_html", "content_hash",
+ "signing_token", "sent_at", "created_at", "updated_at"
+ ]
+
+ def get_customer_name(self, obj):
+ return obj.customer.get_full_name() or obj.customer.email
+
+ def get_template_name(self, obj):
+ return obj.template.name if obj.template else obj.title
+
+ def get_is_signed(self, obj):
+ return obj.status == Contract.Status.SIGNED
+
+ def get_signing_url(self, obj):
+ request = self.context.get("request")
+ return obj.get_signing_url(request)
+
+
+class ContractListSerializer(serializers.ModelSerializer):
+ """Lightweight serializer for contract lists"""
+ customer_name = serializers.SerializerMethodField()
+ customer_email = serializers.CharField(source="customer.email", read_only=True)
+ template_name = serializers.SerializerMethodField()
+ is_signed = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Contract
+ fields = [
+ "id", "title", "customer", "customer_name", "customer_email",
+ "status", "is_signed", "template_name", "expires_at",
+ "sent_at", "created_at"
+ ]
+
+ def get_customer_name(self, obj):
+ return obj.customer.get_full_name() or obj.customer.email
+
+ def get_template_name(self, obj):
+ return obj.template.name if obj.template else obj.title
+
+ def get_is_signed(self, obj):
+ return obj.status == Contract.Status.SIGNED
+
+
+class PublicContractSerializer(serializers.ModelSerializer):
+ """Serializer for public signing endpoint (no auth required)"""
+ business_name = serializers.SerializerMethodField()
+ business_logo = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Contract
+ fields = [
+ "id", "title", "content_html", "status", "expires_at",
+ "business_name", "business_logo"
+ ]
+
+ def get_business_name(self, obj):
+ from django.db import connection
+ from core.models import Tenant
+ try:
+ tenant = Tenant.objects.get(schema_name=connection.schema_name)
+ return tenant.name
+ except Tenant.DoesNotExist:
+ return "Business"
+
+ def get_business_logo(self, obj):
+ from django.db import connection
+ from core.models import Tenant
+ try:
+ tenant = Tenant.objects.get(schema_name=connection.schema_name)
+ return tenant.logo.url if tenant.logo else None
+ except (Tenant.DoesNotExist, ValueError):
+ return None
+
+
+class ContractSignatureInputSerializer(serializers.Serializer):
+ """Input for signing a contract"""
+ consent_checkbox_checked = serializers.BooleanField()
+ electronic_consent_given = serializers.BooleanField()
+ signer_name = serializers.CharField(max_length=200)
+ latitude = serializers.DecimalField(
+ max_digits=9, decimal_places=6, required=False, allow_null=True
+ )
+ longitude = serializers.DecimalField(
+ max_digits=9, decimal_places=6, required=False, allow_null=True
+ )
+
+
+class CreateContractSerializer(serializers.Serializer):
+ """Input for creating a contract from template"""
+ template = serializers.PrimaryKeyRelatedField(
+ queryset=ContractTemplate.objects.filter(status=ContractTemplate.Status.ACTIVE)
+ )
+ customer_id = serializers.IntegerField()
+ event_id = serializers.IntegerField(required=False, allow_null=True)
+ send_email = serializers.BooleanField(default=True)
+
+ def validate_customer_id(self, value):
+ from smoothschedule.users.models import User
+ try:
+ customer = User.objects.get(id=value, role=User.Role.CUSTOMER)
+ return customer
+ except User.DoesNotExist:
+ raise serializers.ValidationError("Customer not found")
+
+ def validate_event_id(self, value):
+ if value is None:
+ return None
+ from schedule.models import Event
+ try:
+ return Event.objects.get(id=value)
+ except Event.DoesNotExist:
+ raise serializers.ValidationError("Event not found")
+
+ def validate(self, attrs):
+ # Transform the validated objects into proper fields
+ attrs['customer'] = attrs.pop('customer_id')
+ attrs['event'] = attrs.pop('event_id', None)
+ return attrs
diff --git a/smoothschedule/contracts/tasks.py b/smoothschedule/contracts/tasks.py
new file mode 100644
index 0000000..9a2dfec
--- /dev/null
+++ b/smoothschedule/contracts/tasks.py
@@ -0,0 +1,376 @@
+"""
+Celery tasks for contract management.
+Handles email notifications, reminders, PDF generation, and expiration.
+"""
+import logging
+from celery import shared_task
+from django.utils import timezone
+from django.core.mail import send_mail
+from django.template.loader import render_to_string
+from django.conf import settings
+from datetime import timedelta
+
+logger = logging.getLogger(__name__)
+
+
+@shared_task(bind=True, max_retries=3)
+def send_contract_email(self, contract_id):
+ """
+ Send initial contract signing request email to customer.
+
+ Args:
+ contract_id: ID of the Contract to send
+
+ Returns:
+ dict: Result with success status and details
+ """
+ from .models import Contract
+ from django.db import connection
+ from core.models import Tenant
+
+ try:
+ contract = Contract.objects.select_related('customer', 'template').get(id=contract_id)
+ except Contract.DoesNotExist:
+ logger.error(f"Contract {contract_id} not found")
+ return {'success': False, 'error': 'Contract not found'}
+
+ # Get tenant info
+ tenant = None
+ if hasattr(connection, 'tenant'):
+ tenant = connection.tenant
+ else:
+ try:
+ tenant = Tenant.objects.get(schema_name=connection.schema_name)
+ except Tenant.DoesNotExist:
+ logger.warning(f"Could not find tenant for contract {contract_id}")
+
+ business_name = tenant.name if tenant else 'SmoothSchedule'
+ from_email = tenant.contact_email if tenant and tenant.contact_email else settings.DEFAULT_FROM_EMAIL
+
+ # Get signing URL
+ signing_url = contract.get_signing_url()
+
+ # Render email content
+ context = {
+ 'contract': contract,
+ 'customer': contract.customer,
+ 'business_name': business_name,
+ 'signing_url': signing_url,
+ 'expires_at': contract.expires_at,
+ }
+
+ subject = f"Please Sign: {contract.title}"
+ html_message = render_to_string('contracts/emails/signing_request.html', context)
+ plain_message = render_to_string('contracts/emails/signing_request.txt', context)
+
+ try:
+ send_mail(
+ subject=subject,
+ message=plain_message,
+ from_email=from_email,
+ recipient_list=[contract.customer.email],
+ html_message=html_message,
+ fail_silently=False,
+ )
+
+ # Update contract
+ contract.sent_at = timezone.now()
+ contract.save(update_fields=['sent_at'])
+
+ logger.info(f"Sent contract signing email for contract {contract_id} to {contract.customer.email}")
+
+ return {
+ 'success': True,
+ 'contract_id': contract_id,
+ 'recipient': contract.customer.email,
+ }
+
+ except Exception as e:
+ logger.error(f"Failed to send contract email for {contract_id}: {str(e)}", exc_info=True)
+ # Retry with exponential backoff
+ raise self.retry(exc=e, countdown=60 * (2 ** self.request.retries))
+
+
+@shared_task(bind=True, max_retries=3)
+def send_contract_reminder(self, contract_id):
+ """
+ Send reminder email for pending contract.
+
+ Args:
+ contract_id: ID of the Contract to remind about
+
+ Returns:
+ dict: Result with success status
+ """
+ from .models import Contract
+ from django.db import connection
+ from core.models import Tenant
+
+ try:
+ contract = Contract.objects.select_related('customer').get(id=contract_id)
+ except Contract.DoesNotExist:
+ logger.error(f"Contract {contract_id} not found")
+ return {'success': False, 'error': 'Contract not found'}
+
+ # Only send reminder if contract is still pending
+ if contract.status != 'PENDING':
+ logger.info(f"Skipping reminder for contract {contract_id} - status is {contract.status}")
+ return {'success': False, 'skipped': True, 'reason': f'Contract status is {contract.status}'}
+
+ # Get tenant info
+ tenant = None
+ if hasattr(connection, 'tenant'):
+ tenant = connection.tenant
+ else:
+ try:
+ tenant = Tenant.objects.get(schema_name=connection.schema_name)
+ except Tenant.DoesNotExist:
+ pass
+
+ business_name = tenant.name if tenant else 'SmoothSchedule'
+ from_email = tenant.contact_email if tenant and tenant.contact_email else settings.DEFAULT_FROM_EMAIL
+
+ # Calculate days until expiration
+ days_until_expiry = None
+ if contract.expires_at:
+ days_until_expiry = (contract.expires_at - timezone.now()).days
+
+ context = {
+ 'contract': contract,
+ 'customer': contract.customer,
+ 'business_name': business_name,
+ 'signing_url': contract.get_signing_url(),
+ 'expires_at': contract.expires_at,
+ 'days_until_expiry': days_until_expiry,
+ }
+
+ subject = f"Reminder: Please Sign {contract.title}"
+ html_message = render_to_string('contracts/emails/reminder.html', context)
+ plain_message = render_to_string('contracts/emails/reminder.txt', context)
+
+ try:
+ send_mail(
+ subject=subject,
+ message=plain_message,
+ from_email=from_email,
+ recipient_list=[contract.customer.email],
+ html_message=html_message,
+ fail_silently=False,
+ )
+
+ logger.info(f"Sent contract reminder for {contract_id} to {contract.customer.email}")
+
+ return {'success': True, 'contract_id': contract_id}
+
+ except Exception as e:
+ logger.error(f"Failed to send reminder for {contract_id}: {str(e)}", exc_info=True)
+ raise self.retry(exc=e, countdown=60 * (2 ** self.request.retries))
+
+
+@shared_task(bind=True, max_retries=3)
+def send_contract_signed_emails(self, contract_id):
+ """
+ Send confirmation emails after contract is signed.
+ Sends to both customer and business owner.
+
+ Args:
+ contract_id: ID of the signed Contract
+
+ Returns:
+ dict: Result with success status
+ """
+ from .models import Contract
+ from django.db import connection
+ from core.models import Tenant
+ from smoothschedule.users.models import User
+
+ try:
+ contract = Contract.objects.select_related('customer', 'signature').get(id=contract_id)
+ except Contract.DoesNotExist:
+ logger.error(f"Contract {contract_id} not found")
+ return {'success': False, 'error': 'Contract not found'}
+
+ if contract.status != 'SIGNED':
+ logger.warning(f"Contract {contract_id} is not signed, skipping confirmation emails")
+ return {'success': False, 'skipped': True}
+
+ # Get tenant info
+ tenant = None
+ if hasattr(connection, 'tenant'):
+ tenant = connection.tenant
+ else:
+ try:
+ tenant = Tenant.objects.get(schema_name=connection.schema_name)
+ except Tenant.DoesNotExist:
+ pass
+
+ business_name = tenant.name if tenant else 'SmoothSchedule'
+ from_email = tenant.contact_email if tenant and tenant.contact_email else settings.DEFAULT_FROM_EMAIL
+
+ context = {
+ 'contract': contract,
+ 'signature': contract.signature,
+ 'customer': contract.customer,
+ 'business_name': business_name,
+ }
+
+ results = {'customer_sent': False, 'business_sent': False}
+
+ # Send to customer
+ try:
+ subject = f"Contract Signed: {contract.title}"
+ html_message = render_to_string('contracts/emails/signed_customer.html', context)
+ plain_message = render_to_string('contracts/emails/signed_customer.txt', context)
+
+ send_mail(
+ subject=subject,
+ message=plain_message,
+ from_email=from_email,
+ recipient_list=[contract.customer.email],
+ html_message=html_message,
+ fail_silently=False,
+ )
+
+ results['customer_sent'] = True
+ logger.info(f"Sent signed confirmation to customer {contract.customer.email}")
+
+ except Exception as e:
+ logger.error(f"Failed to send customer confirmation for {contract_id}: {str(e)}")
+
+ # Send to business owner(s)
+ try:
+ # Get business owners
+ owners = User.objects.filter(role='owner')
+ owner_emails = [owner.email for owner in owners if owner.email]
+
+ if owner_emails:
+ subject = f"Contract Signed by {contract.customer.get_full_name() or contract.customer.email}"
+ html_message = render_to_string('contracts/emails/signed_business.html', context)
+ plain_message = render_to_string('contracts/emails/signed_business.txt', context)
+
+ send_mail(
+ subject=subject,
+ message=plain_message,
+ from_email=from_email,
+ recipient_list=owner_emails,
+ html_message=html_message,
+ fail_silently=False,
+ )
+
+ results['business_sent'] = True
+ logger.info(f"Sent signed notification to business owners: {owner_emails}")
+
+ except Exception as e:
+ logger.error(f"Failed to send business notification for {contract_id}: {str(e)}")
+
+ return {'success': True, 'results': results}
+
+
+@shared_task(bind=True, max_retries=3)
+def generate_contract_pdf(self, contract_id):
+ """
+ Generate PDF for a signed contract and save to storage.
+
+ Args:
+ contract_id: ID of the signed Contract
+
+ Returns:
+ dict: Result with PDF path
+ """
+ from .models import Contract
+ from .pdf_service import ContractPDFService
+
+ try:
+ contract = Contract.objects.select_related('customer', 'signature').get(id=contract_id)
+ except Contract.DoesNotExist:
+ logger.error(f"Contract {contract_id} not found")
+ return {'success': False, 'error': 'Contract not found'}
+
+ if contract.status != 'SIGNED':
+ logger.warning(f"Contract {contract_id} is not signed, cannot generate PDF")
+ return {'success': False, 'error': 'Contract must be signed'}
+
+ try:
+ pdf_path = ContractPDFService.save_contract_pdf(contract)
+
+ logger.info(f"Generated PDF for contract {contract_id}: {pdf_path}")
+
+ return {
+ 'success': True,
+ 'contract_id': contract_id,
+ 'pdf_path': pdf_path,
+ }
+
+ except Exception as e:
+ logger.error(f"Failed to generate PDF for {contract_id}: {str(e)}", exc_info=True)
+ raise self.retry(exc=e, countdown=60 * (2 ** self.request.retries))
+
+
+@shared_task
+def check_expired_contracts():
+ """
+ Periodic task to check for expired contracts and mark them as EXPIRED.
+
+ Should run daily.
+
+ Returns:
+ dict: Number of contracts marked as expired
+ """
+ from .models import Contract
+
+ now = timezone.now()
+
+ # Find pending contracts that have expired
+ expired_contracts = Contract.objects.filter(
+ status='PENDING',
+ expires_at__lte=now,
+ expires_at__isnull=False,
+ )
+
+ count = expired_contracts.count()
+
+ # Mark them as expired
+ expired_contracts.update(status='EXPIRED')
+
+ logger.info(f"Marked {count} contracts as expired")
+
+ return {'expired_count': count}
+
+
+@shared_task
+def send_pending_reminders():
+ """
+ Periodic task to send reminders for pending contracts.
+ Sends reminder 3 days before expiration.
+
+ Should run daily.
+
+ Returns:
+ dict: Number of reminders sent
+ """
+ from .models import Contract
+
+ # Calculate the reminder window (contracts expiring in 3 days)
+ now = timezone.now()
+ reminder_date = now + timedelta(days=3)
+
+ # Find pending contracts expiring in approximately 3 days
+ # Use a 12-hour window around the 3-day mark
+ contracts_to_remind = Contract.objects.filter(
+ status='PENDING',
+ expires_at__gte=reminder_date - timedelta(hours=12),
+ expires_at__lte=reminder_date + timedelta(hours=12),
+ expires_at__isnull=False,
+ ).select_related('customer')
+
+ count = 0
+ for contract in contracts_to_remind:
+ try:
+ send_contract_reminder.delay(contract.id)
+ count += 1
+ except Exception as e:
+ logger.error(f"Failed to queue reminder for contract {contract.id}: {str(e)}")
+
+ logger.info(f"Queued {count} contract reminders")
+
+ return {'reminders_queued': count}
diff --git a/smoothschedule/contracts/templates/contracts/pdf_preview_template.html b/smoothschedule/contracts/templates/contracts/pdf_preview_template.html
new file mode 100644
index 0000000..567761f
--- /dev/null
+++ b/smoothschedule/contracts/templates/contracts/pdf_preview_template.html
@@ -0,0 +1,230 @@
+
+
+
+
+
+ {{ template.name }} - Preview
+
+
+
+ {% if is_preview %}
+
+ {{ preview_notice }}
+
+ {% endif %}
+
+
+
+ {% if business_logo_url %}
+
+ {% else %}
+
{{ business_name }}
+ {% endif %}
+
+
+
+
Template: {{ template.name }}
+
Version: {{ template.version }}
+
Scope: {{ template.get_scope_display }}
+
+
+
+
+
{{ template.name }}
+
+
+
+ Customer:
+ John Smith (sample)
+
+
+ Customer Email:
+ john.smith@example.com
+
+ {% if template.scope == 'APPOINTMENT' %}
+
+ Appointment:
+ Sample Service - (date will be filled in)
+
+ {% endif %}
+
+ Contract Created:
+ (will be filled when sent)
+
+
+
+
+ {{ content_html|safe }}
+
+
+
+
Signature Section
+
+ This section will contain:
+ • Electronic consent checkboxes
+ • Signer name input
+ • Full audit trail with IP address, timestamp, and document hash
+
+ This document has been cryptographically signed and verified.
+ The document hash (SHA-256) ensures the content has not been tampered with since signing.
+ Any modification to the content would result in a different hash value.
+
+
+
+
+
+
diff --git a/smoothschedule/contracts/tests.py b/smoothschedule/contracts/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/smoothschedule/contracts/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/smoothschedule/contracts/urls.py b/smoothschedule/contracts/urls.py
new file mode 100644
index 0000000..c5523e0
--- /dev/null
+++ b/smoothschedule/contracts/urls.py
@@ -0,0 +1,16 @@
+"""
+URL routing for Contract/E-Signature system.
+"""
+from django.urls import path, include
+from rest_framework.routers import DefaultRouter
+from . import views
+
+router = DefaultRouter()
+router.register(r"templates", views.ContractTemplateViewSet, basename="contract-template")
+router.register(r"requirements", views.ServiceContractRequirementViewSet, basename="contract-requirement")
+router.register(r"", views.ContractViewSet, basename="contract")
+
+urlpatterns = [
+ path("sign//", views.PublicContractSigningView.as_view(), name="contract-sign"),
+ path("", include(router.urls)),
+]
diff --git a/smoothschedule/contracts/views.py b/smoothschedule/contracts/views.py
new file mode 100644
index 0000000..437b62f
--- /dev/null
+++ b/smoothschedule/contracts/views.py
@@ -0,0 +1,430 @@
+"""
+Views for Contract/E-Signature system.
+"""
+import hashlib
+from django.shortcuts import get_object_or_404
+from django.utils import timezone
+from django.db import connection
+from rest_framework import viewsets, status
+from rest_framework.decorators import action
+from rest_framework.views import APIView
+from rest_framework.response import Response
+from rest_framework.permissions import IsAuthenticated, AllowAny
+
+from .models import ContractTemplate, ServiceContractRequirement, Contract, ContractSignature
+from .serializers import (
+ ContractTemplateSerializer, ContractTemplateListSerializer,
+ ServiceContractRequirementSerializer, ContractSerializer, ContractListSerializer,
+ PublicContractSerializer, ContractSignatureInputSerializer, CreateContractSerializer
+)
+
+
+def get_client_ip(request):
+ """Extract client IP from request"""
+ x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
+ if x_forwarded_for:
+ ip = x_forwarded_for.split(",")[0].strip()
+ else:
+ ip = request.META.get("REMOTE_ADDR")
+ return ip
+
+
+class ContractTemplateViewSet(viewsets.ModelViewSet):
+ """
+ CRUD for contract templates.
+ Permissions: owner/manager only
+ """
+ queryset = ContractTemplate.objects.all()
+ permission_classes = [IsAuthenticated]
+
+ def get_serializer_class(self):
+ if self.action == "list":
+ return ContractTemplateListSerializer
+ return ContractTemplateSerializer
+
+ def get_queryset(self):
+ qs = super().get_queryset()
+ status_filter = self.request.query_params.get("status")
+ if status_filter:
+ qs = qs.filter(status=status_filter)
+ return qs.order_by("name")
+
+ def perform_create(self, serializer):
+ serializer.save(created_by=self.request.user)
+
+ @action(detail=True, methods=["post"])
+ def duplicate(self, request, pk=None):
+ """Create a copy of an existing template"""
+ template = self.get_object()
+ new_template = ContractTemplate.objects.create(
+ name=f"{template.name} (Copy)",
+ description=template.description,
+ content=template.content,
+ scope=template.scope,
+ status=ContractTemplate.Status.DRAFT,
+ expires_after_days=template.expires_after_days,
+ created_by=request.user,
+ )
+ serializer = ContractTemplateSerializer(new_template)
+ return Response(serializer.data, status=status.HTTP_201_CREATED)
+
+ @action(detail=True, methods=["post"])
+ def new_version(self, request, pk=None):
+ """Create a new version of the template"""
+ template = self.get_object()
+ template.version += 1
+ template.version_notes = request.data.get("version_notes", "")
+ if "content" in request.data:
+ template.content = request.data["content"]
+ if "name" in request.data:
+ template.name = request.data["name"]
+ if "description" in request.data:
+ template.description = request.data["description"]
+ template.save()
+ serializer = ContractTemplateSerializer(template)
+ return Response(serializer.data)
+
+ @action(detail=True, methods=["post"])
+ def activate(self, request, pk=None):
+ """Activate a draft template"""
+ template = self.get_object()
+ template.status = ContractTemplate.Status.ACTIVE
+ template.save(update_fields=["status", "updated_at"])
+ return Response({"success": True})
+
+ @action(detail=True, methods=["post"])
+ def archive(self, request, pk=None):
+ """Archive a template"""
+ template = self.get_object()
+ template.status = ContractTemplate.Status.ARCHIVED
+ template.save(update_fields=["status", "updated_at"])
+ return Response({"success": True})
+
+ @action(detail=True, methods=["get"])
+ def preview_pdf(self, request, pk=None):
+ """Generate a PDF preview of the template"""
+ import logging
+ logger = logging.getLogger(__name__)
+
+ from django.http import HttpResponse
+ from .pdf_service import ContractPDFService, WEASYPRINT_AVAILABLE
+
+ if not WEASYPRINT_AVAILABLE:
+ return Response(
+ {"error": "PDF generation not available"},
+ status=status.HTTP_503_SERVICE_UNAVAILABLE
+ )
+
+ template = self.get_object()
+
+ try:
+ pdf_bytes = ContractPDFService.generate_template_preview(template, request.user)
+ response = HttpResponse(pdf_bytes, content_type="application/pdf")
+ response["Content-Disposition"] = f'inline; filename="{template.name}_preview.pdf"'
+ return response
+ except Exception as e:
+ import traceback
+ logger.error(f"PDF preview error: {e}")
+ logger.error(traceback.format_exc())
+ return Response(
+ {"error": str(e)},
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR
+ )
+
+
+class ServiceContractRequirementViewSet(viewsets.ModelViewSet):
+ """Manage which contracts are required for which services"""
+ queryset = ServiceContractRequirement.objects.all()
+ serializer_class = ServiceContractRequirementSerializer
+ permission_classes = [IsAuthenticated]
+
+ def get_queryset(self):
+ qs = super().get_queryset().select_related("service", "template")
+ service_id = self.request.query_params.get("service")
+ template_id = self.request.query_params.get("template")
+ if service_id:
+ qs = qs.filter(service_id=service_id)
+ if template_id:
+ qs = qs.filter(template_id=template_id)
+ return qs
+
+
+class ContractViewSet(viewsets.ModelViewSet):
+ """
+ CRUD for contract instances.
+ Includes sending, viewing, and PDF download.
+ """
+ queryset = Contract.objects.all()
+ permission_classes = [IsAuthenticated]
+
+ def get_serializer_class(self):
+ if self.action == "list":
+ return ContractListSerializer
+ if self.action == "create":
+ return CreateContractSerializer
+ return ContractSerializer
+
+ def get_queryset(self):
+ qs = super().get_queryset().select_related(
+ "customer", "template", "signature", "event"
+ )
+ customer_id = self.request.query_params.get("customer")
+ status_filter = self.request.query_params.get("status")
+ template_id = self.request.query_params.get("template")
+
+ if customer_id:
+ qs = qs.filter(customer_id=customer_id)
+ if status_filter:
+ qs = qs.filter(status=status_filter)
+ if template_id:
+ qs = qs.filter(template_id=template_id)
+
+ return qs.order_by("-created_at")
+
+ def create(self, request, *args, **kwargs):
+ """Create a contract from a template"""
+ serializer = CreateContractSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ template = serializer.validated_data["template"]
+ customer = serializer.validated_data["customer"]
+ event = serializer.validated_data.get("event")
+ send_email = serializer.validated_data.get("send_email", True)
+
+ # Render content with variables
+ content_html = self._render_template(template, customer, event)
+ content_hash = hashlib.sha256(content_html.encode()).hexdigest()
+
+ # Calculate expiration
+ expires_at = None
+ if template.expires_after_days:
+ expires_at = timezone.now() + timezone.timedelta(days=template.expires_after_days)
+
+ contract = Contract.objects.create(
+ template=template,
+ template_version=template.version,
+ title=template.name,
+ content_html=content_html,
+ content_hash=content_hash,
+ customer=customer,
+ event=event if template.scope == ContractTemplate.Scope.APPOINTMENT else None,
+ expires_at=expires_at,
+ sent_by=request.user,
+ )
+
+ if send_email:
+ from .tasks import send_contract_email
+ send_contract_email.delay(contract.id)
+ contract.sent_at = timezone.now()
+ contract.save(update_fields=["sent_at"])
+
+ response_serializer = ContractSerializer(contract, context={"request": request})
+ return Response(response_serializer.data, status=status.HTTP_201_CREATED)
+
+ def _render_template(self, template, customer, event=None):
+ """Render template with variable substitution"""
+ from core.models import Tenant
+
+ tenant = Tenant.objects.get(schema_name=connection.schema_name)
+
+ context = {
+ "CUSTOMER_NAME": customer.get_full_name() or customer.email,
+ "CUSTOMER_FIRST_NAME": customer.first_name or customer.email.split("@")[0],
+ "CUSTOMER_LAST_NAME": customer.last_name or "",
+ "CUSTOMER_EMAIL": customer.email,
+ "CUSTOMER_PHONE": getattr(customer, "phone", "") or "",
+ "BUSINESS_NAME": tenant.name,
+ "BUSINESS_EMAIL": tenant.contact_email or "",
+ "BUSINESS_PHONE": tenant.phone or "",
+ "DATE": timezone.now().strftime("%B %d, %Y"),
+ "YEAR": timezone.now().strftime("%Y"),
+ }
+
+ # Add event-specific variables if available
+ if event:
+ context["APPOINTMENT_DATE"] = event.start_time.strftime("%B %d, %Y")
+ context["APPOINTMENT_TIME"] = event.start_time.strftime("%I:%M %p")
+ if event.service:
+ context["SERVICE_NAME"] = event.service.name
+
+ content = template.content
+ for key, value in context.items():
+ content = content.replace(f"{{{{{key}}}}}", str(value or ""))
+
+ return content
+
+ @action(detail=True, methods=["post"])
+ def send(self, request, pk=None):
+ """Send contract to customer via email"""
+ contract = self.get_object()
+ if contract.status != Contract.Status.PENDING:
+ return Response(
+ {"error": "Contract is not pending"},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ from .tasks import send_contract_email
+ send_contract_email.delay(contract.id)
+
+ contract.sent_at = timezone.now()
+ contract.save(update_fields=["sent_at"])
+
+ return Response({"success": True, "message": "Contract sent"})
+
+ @action(detail=True, methods=["post"])
+ def resend(self, request, pk=None):
+ """Resend contract email"""
+ contract = self.get_object()
+ if contract.status != Contract.Status.PENDING:
+ return Response(
+ {"error": "Contract is not pending"},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ from .tasks import send_contract_email
+ send_contract_email.delay(contract.id)
+
+ return Response({"success": True, "message": "Contract resent"})
+
+ @action(detail=True, methods=["post"])
+ def void(self, request, pk=None):
+ """Void a pending contract"""
+ contract = self.get_object()
+ if contract.status != Contract.Status.PENDING:
+ return Response(
+ {"error": "Only pending contracts can be voided"},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ contract.status = Contract.Status.VOIDED
+ contract.save(update_fields=["status", "updated_at"])
+
+ return Response({"success": True})
+
+ @action(detail=True, methods=["get"])
+ def download_pdf(self, request, pk=None):
+ """Download signed contract PDF"""
+ from django.http import FileResponse
+ from django.core.files.storage import default_storage
+
+ contract = self.get_object()
+ if not contract.pdf_path:
+ return Response({"error": "PDF not available"}, status=status.HTTP_404_NOT_FOUND)
+
+ try:
+ file = default_storage.open(contract.pdf_path, "rb")
+ return FileResponse(
+ file,
+ as_attachment=True,
+ filename=f"{contract.title}.pdf"
+ )
+ except Exception:
+ return Response({"error": "PDF not found"}, status=status.HTTP_404_NOT_FOUND)
+
+
+class PublicContractSigningView(APIView):
+ """
+ Public endpoint for signing contracts (no auth required).
+ Uses token-based access.
+ """
+ permission_classes = [AllowAny]
+
+ def get(self, request, token):
+ """Get contract details for signing page"""
+ contract = get_object_or_404(Contract, signing_token=token)
+
+ if contract.status == Contract.Status.SIGNED:
+ return Response(
+ {"error": "Contract already signed", "status": "signed"},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+ if contract.status == Contract.Status.VOIDED:
+ return Response(
+ {"error": "Contract has been voided", "status": "voided"},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+ if contract.expires_at and timezone.now() > contract.expires_at:
+ contract.status = Contract.Status.EXPIRED
+ contract.save(update_fields=["status"])
+ return Response(
+ {"error": "Contract has expired", "status": "expired"},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ serializer = PublicContractSerializer(contract)
+ return Response(serializer.data)
+
+ def post(self, request, token):
+ """Sign the contract"""
+ contract = get_object_or_404(Contract, signing_token=token)
+
+ # Validate status
+ if contract.status != Contract.Status.PENDING:
+ return Response(
+ {"error": "Contract cannot be signed", "status": contract.status},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ # Validate expiration
+ if contract.expires_at and timezone.now() > contract.expires_at:
+ contract.status = Contract.Status.EXPIRED
+ contract.save(update_fields=["status"])
+ return Response({"error": "Contract has expired"}, status=status.HTTP_400_BAD_REQUEST)
+
+ serializer = ContractSignatureInputSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ # Validate consent
+ if not serializer.validated_data["consent_checkbox_checked"]:
+ return Response(
+ {"error": "You must check the consent box"},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+ if not serializer.validated_data["electronic_consent_given"]:
+ return Response(
+ {"error": "You must consent to electronic records"},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ # Standard consent text
+ consent_text = (
+ "I have read and agree to the terms and conditions outlined in this document. "
+ "By checking this box, I understand that this constitutes a legal electronic signature "
+ "under the ESIGN Act (15 U.S.C. section 7001 et seq.) and UETA."
+ )
+ electronic_consent_text = (
+ "I consent to conduct business electronically. I understand that: "
+ "1. I am agreeing to use electronic records and signatures in place of paper. "
+ "2. I have the right to receive documents in paper form upon request. "
+ "3. I can withdraw this consent at any time. "
+ "4. I need internet access to access these documents. "
+ "5. I can request a paper copy at any time."
+ )
+
+ # Create signature with audit trail
+ signature = ContractSignature.objects.create(
+ contract=contract,
+ consent_checkbox_checked=True,
+ consent_text=consent_text,
+ electronic_consent_given=True,
+ electronic_consent_text=electronic_consent_text,
+ signer_name=serializer.validated_data["signer_name"],
+ signer_email=contract.customer.email,
+ signed_at=timezone.now(),
+ ip_address=get_client_ip(request),
+ user_agent=request.META.get("HTTP_USER_AGENT", "")[:500],
+ document_hash_at_signing=contract.content_hash,
+ latitude=serializer.validated_data.get("latitude"),
+ longitude=serializer.validated_data.get("longitude"),
+ )
+
+ # Update contract status
+ contract.status = Contract.Status.SIGNED
+ contract.save(update_fields=["status", "updated_at"])
+
+ # Generate PDF and send confirmation emails asynchronously
+ from .tasks import generate_contract_pdf, send_contract_signed_emails
+ generate_contract_pdf.delay(contract.id)
+ send_contract_signed_emails.delay(contract.id)
+
+ return Response({"success": True, "message": "Contract signed successfully"})
diff --git a/smoothschedule/schedule/migrations/0028_add_timeblock_and_holiday.py b/smoothschedule/schedule/migrations/0028_add_timeblock_and_holiday.py
new file mode 100644
index 0000000..fd21a45
--- /dev/null
+++ b/smoothschedule/schedule/migrations/0028_add_timeblock_and_holiday.py
@@ -0,0 +1,63 @@
+# Generated by Django 5.2.8 on 2025-12-04 19:27
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('schedule', '0027_add_deposit_percent_back'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Holiday',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('code', models.CharField(help_text="Unique identifier (e.g., 'thanksgiving_us', 'christmas')", max_length=50, unique=True)),
+ ('name', models.CharField(max_length=100)),
+ ('country', models.CharField(db_index=True, default='US', help_text='ISO 3166-1 alpha-2 country code', max_length=2)),
+ ('holiday_type', models.CharField(choices=[('FIXED', 'Fixed date'), ('FLOATING', 'Floating (Nth weekday of month)'), ('CALCULATED', 'Calculated (algorithm-based)')], default='FIXED', max_length=20)),
+ ('month', models.PositiveSmallIntegerField(blank=True, help_text='Month (1-12)', null=True)),
+ ('day', models.PositiveSmallIntegerField(blank=True, help_text='Day of month (1-31)', null=True)),
+ ('week_of_month', models.PositiveSmallIntegerField(blank=True, help_text="Week of month (1-4, or 5 for 'last')", null=True)),
+ ('day_of_week', models.PositiveSmallIntegerField(blank=True, help_text='Day of week (0=Monday, 6=Sunday)', null=True)),
+ ('calculation_rule', models.CharField(blank=True, help_text="Calculation rule (e.g., 'easter', 'easter-2' for Good Friday)", max_length=50)),
+ ('is_active', models.BooleanField(default=True)),
+ ],
+ options={
+ 'ordering': ['country', 'name'],
+ 'indexes': [models.Index(fields=['country', 'is_active'], name='schedule_ho_country_b41340_idx')],
+ },
+ ),
+ migrations.CreateModel(
+ name='TimeBlock',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('title', models.CharField(help_text="Display title (e.g., 'Christmas Day', 'Lunch Break')", max_length=200)),
+ ('description', models.TextField(blank=True, help_text='Optional description or reason for block')),
+ ('block_type', models.CharField(choices=[('HARD', 'Hard Block (prevents booking)'), ('SOFT', 'Soft Block (warning only, override allowed)')], default='HARD', help_text='HARD prevents booking; SOFT shows warning but allows override', max_length=10)),
+ ('recurrence_type', models.CharField(choices=[('NONE', 'No recurrence (specific date/range)'), ('WEEKLY', 'Weekly (specific days of week)'), ('MONTHLY', 'Monthly (specific days of month)'), ('YEARLY', 'Yearly (specific days of year)'), ('HOLIDAY', 'Holiday (floating dates)')], db_index=True, default='NONE', max_length=20)),
+ ('start_date', models.DateField(blank=True, help_text='Start date for one-time blocks', null=True)),
+ ('end_date', models.DateField(blank=True, help_text='End date for one-time blocks (same as start for single day)', null=True)),
+ ('all_day', models.BooleanField(default=True, help_text='If true, blocks entire day; if false, uses start/end time')),
+ ('start_time', models.TimeField(blank=True, help_text='Start time (if not all-day)', null=True)),
+ ('end_time', models.TimeField(blank=True, help_text='End time (if not all-day)', null=True)),
+ ('recurrence_pattern', models.JSONField(blank=True, default=dict, help_text='\n Recurrence configuration:\n - WEEKLY: {"days_of_week": [0,1,2]} (0=Mon, 6=Sun)\n - MONTHLY: {"days_of_month": [1, 15]}\n - YEARLY: {"month": 7, "day": 4} or {"month": 12, "day": 25}\n - HOLIDAY: {"holiday_code": "thanksgiving_us"}\n ')),
+ ('recurrence_start', models.DateField(blank=True, help_text='When this recurring block becomes active', null=True)),
+ ('recurrence_end', models.DateField(blank=True, help_text='When this recurring block ends (null = forever)', null=True)),
+ ('is_active', models.BooleanField(db_index=True, default=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_time_blocks', to=settings.AUTH_USER_MODEL)),
+ ('resource', models.ForeignKey(blank=True, help_text='Specific resource (null = business-level block)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='time_blocks', to='schedule.resource')),
+ ],
+ options={
+ 'ordering': ['-created_at'],
+ 'indexes': [models.Index(fields=['resource', 'is_active'], name='schedule_ti_resourc_1e9a5d_idx'), models.Index(fields=['recurrence_type', 'is_active'], name='schedule_ti_recurre_b7b096_idx'), models.Index(fields=['start_date', 'end_date'], name='schedule_ti_start_d_bba9d9_idx')],
+ },
+ ),
+ ]
diff --git a/smoothschedule/schedule/models.py b/smoothschedule/schedule/models.py
index e60cf26..804bdd0 100644
--- a/smoothschedule/schedule/models.py
+++ b/smoothschedule/schedule/models.py
@@ -1478,4 +1478,428 @@ class EmailTemplate(models.Model):
def _append_text_footer(self, text: str) -> str:
"""Append Powered by Smooth Schedule footer to plain text"""
footer = "\n\n---\nPowered by SmoothSchedule - https://smoothschedule.com"
- return text + footer
\ No newline at end of file
+ return text + footer
+
+
+class Holiday(models.Model):
+ """
+ Predefined holiday definitions for the holiday picker.
+
+ Supports three types:
+ - FIXED: Same date every year (e.g., Christmas on Dec 25)
+ - FLOATING: Nth weekday of a month (e.g., Thanksgiving = 4th Thursday of November)
+ - CALCULATED: Algorithm-based (e.g., Easter)
+ """
+
+ class Type(models.TextChoices):
+ FIXED = 'FIXED', 'Fixed date'
+ FLOATING = 'FLOATING', 'Floating (Nth weekday of month)'
+ CALCULATED = 'CALCULATED', 'Calculated (algorithm-based)'
+
+ code = models.CharField(
+ max_length=50,
+ unique=True,
+ help_text="Unique identifier (e.g., 'thanksgiving_us', 'christmas')"
+ )
+ name = models.CharField(max_length=100)
+ country = models.CharField(
+ max_length=2,
+ default='US',
+ db_index=True,
+ help_text="ISO 3166-1 alpha-2 country code"
+ )
+
+ holiday_type = models.CharField(
+ max_length=20,
+ choices=Type.choices,
+ default=Type.FIXED
+ )
+
+ # For FIXED holidays (e.g., Dec 25)
+ month = models.PositiveSmallIntegerField(
+ null=True,
+ blank=True,
+ help_text="Month (1-12)"
+ )
+ day = models.PositiveSmallIntegerField(
+ null=True,
+ blank=True,
+ help_text="Day of month (1-31)"
+ )
+
+ # For FLOATING holidays (e.g., "4th Thursday of November")
+ week_of_month = models.PositiveSmallIntegerField(
+ null=True,
+ blank=True,
+ help_text="Week of month (1-4, or 5 for 'last')"
+ )
+ day_of_week = models.PositiveSmallIntegerField(
+ null=True,
+ blank=True,
+ help_text="Day of week (0=Monday, 6=Sunday)"
+ )
+
+ # For CALCULATED holidays (e.g., Easter)
+ calculation_rule = models.CharField(
+ max_length=50,
+ blank=True,
+ help_text="Calculation rule (e.g., 'easter', 'easter-2' for Good Friday)"
+ )
+
+ is_active = models.BooleanField(default=True)
+
+ class Meta:
+ ordering = ['country', 'name']
+ indexes = [
+ models.Index(fields=['country', 'is_active']),
+ ]
+
+ def __str__(self):
+ return f"{self.name} ({self.country})"
+
+ def get_date_for_year(self, year):
+ """
+ Calculate the actual date for this holiday in a given year.
+
+ Returns:
+ date object or None if cannot be calculated
+ """
+ from datetime import date, timedelta
+
+ if self.holiday_type == self.Type.FIXED:
+ if self.month and self.day:
+ try:
+ return date(year, self.month, self.day)
+ except ValueError:
+ return None
+
+ elif self.holiday_type == self.Type.FLOATING:
+ if self.month and self.week_of_month is not None and self.day_of_week is not None:
+ # Find Nth weekday of month
+ first_day = date(year, self.month, 1)
+ first_weekday = first_day.weekday()
+
+ # Days until first occurrence of target weekday
+ days_until = (self.day_of_week - first_weekday) % 7
+ first_occurrence = first_day + timedelta(days=days_until)
+
+ if self.week_of_month == 5: # "Last" occurrence
+ # Find last occurrence by going to next month and going back
+ if self.month == 12:
+ next_month = date(year + 1, 1, 1)
+ else:
+ next_month = date(year, self.month + 1, 1)
+ last_day = next_month - timedelta(days=1)
+ days_back = (last_day.weekday() - self.day_of_week) % 7
+ return last_day - timedelta(days=days_back)
+ else:
+ return first_occurrence + timedelta(weeks=self.week_of_month - 1)
+
+ elif self.holiday_type == self.Type.CALCULATED:
+ if self.calculation_rule.startswith('easter'):
+ easter_date = self._calculate_easter(year)
+ if '+' in self.calculation_rule:
+ offset = int(self.calculation_rule.split('+')[1])
+ return easter_date + timedelta(days=offset)
+ elif '-' in self.calculation_rule:
+ offset = int(self.calculation_rule.split('-')[1])
+ return easter_date - timedelta(days=offset)
+ return easter_date
+
+ return None
+
+ @staticmethod
+ def _calculate_easter(year):
+ """
+ Calculate Easter Sunday using the Anonymous Gregorian algorithm.
+ """
+ from datetime import date
+
+ a = year % 19
+ b = year // 100
+ c = year % 100
+ d = b // 4
+ e = b % 4
+ f = (b + 8) // 25
+ g = (b - f + 1) // 3
+ h = (19 * a + b - d - g + 15) % 30
+ i = c // 4
+ k = c % 4
+ l = (32 + 2 * e + 2 * i - h - k) % 7
+ m = (a + 11 * h + 22 * l) // 451
+ month = (h + l - 7 * m + 114) // 31
+ day = ((h + l - 7 * m + 114) % 31) + 1
+
+ return date(year, month, day)
+
+
+class TimeBlock(models.Model):
+ """
+ Time blocking model for business closures and resource unavailability.
+
+ Supports two levels:
+ - Business-level: Affects entire business (resource=None)
+ - Resource-level: Affects specific resource (resource=FK)
+
+ Blocks can be one-time or recurring with various patterns.
+ """
+
+ class BlockType(models.TextChoices):
+ HARD = 'HARD', 'Hard Block (prevents booking)'
+ SOFT = 'SOFT', 'Soft Block (warning only, override allowed)'
+
+ class RecurrenceType(models.TextChoices):
+ NONE = 'NONE', 'No recurrence (specific date/range)'
+ WEEKLY = 'WEEKLY', 'Weekly (specific days of week)'
+ MONTHLY = 'MONTHLY', 'Monthly (specific days of month)'
+ YEARLY = 'YEARLY', 'Yearly (specific days of year)'
+ HOLIDAY = 'HOLIDAY', 'Holiday (floating dates)'
+
+ # Core identification
+ title = models.CharField(
+ max_length=200,
+ help_text="Display title (e.g., 'Christmas Day', 'Lunch Break')"
+ )
+ description = models.TextField(
+ blank=True,
+ help_text="Optional description or reason for block"
+ )
+
+ # Level determination
+ resource = models.ForeignKey(
+ 'Resource',
+ on_delete=models.CASCADE,
+ null=True,
+ blank=True,
+ related_name='time_blocks',
+ help_text="Specific resource (null = business-level block)"
+ )
+
+ # Block behavior
+ block_type = models.CharField(
+ max_length=10,
+ choices=BlockType.choices,
+ default=BlockType.HARD,
+ help_text="HARD prevents booking; SOFT shows warning but allows override"
+ )
+
+ # Recurrence configuration
+ recurrence_type = models.CharField(
+ max_length=20,
+ choices=RecurrenceType.choices,
+ default=RecurrenceType.NONE,
+ db_index=True
+ )
+
+ # For NONE: specific date range
+ start_date = models.DateField(
+ null=True,
+ blank=True,
+ help_text="Start date for one-time blocks"
+ )
+ end_date = models.DateField(
+ null=True,
+ blank=True,
+ help_text="End date for one-time blocks (same as start for single day)"
+ )
+
+ # Time window (applies to all patterns)
+ all_day = models.BooleanField(
+ default=True,
+ help_text="If true, blocks entire day; if false, uses start/end time"
+ )
+ start_time = models.TimeField(
+ null=True,
+ blank=True,
+ help_text="Start time (if not all-day)"
+ )
+ end_time = models.TimeField(
+ null=True,
+ blank=True,
+ help_text="End time (if not all-day)"
+ )
+
+ # Recurrence patterns (JSON)
+ recurrence_pattern = models.JSONField(
+ default=dict,
+ blank=True,
+ help_text="""
+ Recurrence configuration:
+ - WEEKLY: {"days_of_week": [0,1,2]} (0=Mon, 6=Sun)
+ - MONTHLY: {"days_of_month": [1, 15]}
+ - YEARLY: {"month": 7, "day": 4} or {"month": 12, "day": 25}
+ - HOLIDAY: {"holiday_code": "thanksgiving_us"}
+ """
+ )
+
+ # Recurrence bounds
+ recurrence_start = models.DateField(
+ null=True,
+ blank=True,
+ help_text="When this recurring block becomes active"
+ )
+ recurrence_end = models.DateField(
+ null=True,
+ blank=True,
+ help_text="When this recurring block ends (null = forever)"
+ )
+
+ # Status
+ is_active = models.BooleanField(default=True, db_index=True)
+
+ # Audit
+ created_by = models.ForeignKey(
+ 'users.User',
+ on_delete=models.SET_NULL,
+ null=True,
+ related_name='created_time_blocks'
+ )
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ ordering = ['-created_at']
+ indexes = [
+ models.Index(fields=['resource', 'is_active']),
+ models.Index(fields=['recurrence_type', 'is_active']),
+ models.Index(fields=['start_date', 'end_date']),
+ ]
+
+ def __str__(self):
+ level = f"Resource: {self.resource.name}" if self.resource else "Business-level"
+ return f"{self.title} ({level})"
+
+ @property
+ def is_business_level(self):
+ """Check if this is a business-level block (affects all resources)."""
+ return self.resource is None
+
+ def blocks_date(self, check_date):
+ """
+ Check if this block applies to a given date.
+
+ Args:
+ check_date: date object to check
+
+ Returns:
+ bool: True if date is blocked
+ """
+ from datetime import date
+
+ if not self.is_active:
+ return False
+
+ # Check recurrence bounds
+ if self.recurrence_start and check_date < self.recurrence_start:
+ return False
+ if self.recurrence_end and check_date > self.recurrence_end:
+ return False
+
+ if self.recurrence_type == self.RecurrenceType.NONE:
+ # One-time block: check date range
+ if self.start_date and self.end_date:
+ return self.start_date <= check_date <= self.end_date
+ elif self.start_date:
+ return check_date == self.start_date
+ return False
+
+ elif self.recurrence_type == self.RecurrenceType.WEEKLY:
+ # Check if check_date's weekday is in the pattern
+ days = self.recurrence_pattern.get('days_of_week', [])
+ return check_date.weekday() in days
+
+ elif self.recurrence_type == self.RecurrenceType.MONTHLY:
+ # Check if check_date's day of month is in the pattern
+ days = self.recurrence_pattern.get('days_of_month', [])
+ return check_date.day in days
+
+ elif self.recurrence_type == self.RecurrenceType.YEARLY:
+ # Check if month and day match
+ month = self.recurrence_pattern.get('month')
+ day = self.recurrence_pattern.get('day')
+ if month and day:
+ return check_date.month == month and check_date.day == day
+ return False
+
+ elif self.recurrence_type == self.RecurrenceType.HOLIDAY:
+ # Check if date matches the holiday for this year
+ holiday_code = self.recurrence_pattern.get('holiday_code')
+ if holiday_code:
+ try:
+ holiday = Holiday.objects.get(code=holiday_code, is_active=True)
+ holiday_date = holiday.get_date_for_year(check_date.year)
+ return holiday_date == check_date
+ except Holiday.DoesNotExist:
+ return False
+ return False
+
+ return False
+
+ def blocks_datetime_range(self, start_dt, end_dt):
+ """
+ Check if this block overlaps with a datetime range.
+
+ Args:
+ start_dt: datetime - start of range to check
+ end_dt: datetime - end of range to check
+
+ Returns:
+ bool: True if any part of the range is blocked
+ """
+ from datetime import datetime, time, timedelta
+
+ # First check if any date in the range is blocked
+ current_date = start_dt.date()
+ end_date = end_dt.date()
+
+ while current_date <= end_date:
+ if self.blocks_date(current_date):
+ # Date is blocked, now check time window
+ if self.all_day:
+ return True
+ else:
+ # Check time overlap
+ if self.start_time and self.end_time:
+ block_start = datetime.combine(current_date, self.start_time)
+ block_end = datetime.combine(current_date, self.end_time)
+
+ # Check overlap: start_dt < block_end AND end_dt > block_start
+ if start_dt < block_end and end_dt > block_start:
+ return True
+
+ current_date += timedelta(days=1)
+
+ return False
+
+ def get_blocked_dates_in_range(self, range_start, range_end):
+ """
+ Generate all blocked dates within a date range.
+ Used for calendar visualization.
+
+ Args:
+ range_start: date - start of range
+ range_end: date - end of range
+
+ Returns:
+ list of dicts with 'date', 'start_time', 'end_time', 'all_day'
+ """
+ from datetime import timedelta
+
+ blocked_dates = []
+ current_date = range_start
+
+ while current_date <= range_end:
+ if self.blocks_date(current_date):
+ blocked_dates.append({
+ 'date': current_date,
+ 'start_time': self.start_time,
+ 'end_time': self.end_time,
+ 'all_day': self.all_day,
+ 'title': self.title,
+ 'block_type': self.block_type,
+ 'block_id': self.id,
+ 'is_business_level': self.is_business_level,
+ })
+ current_date += timedelta(days=1)
+
+ return blocked_dates
\ No newline at end of file
diff --git a/smoothschedule/schedule/serializers.py b/smoothschedule/schedule/serializers.py
index 13048bb..9af695a 100644
--- a/smoothschedule/schedule/serializers.py
+++ b/smoothschedule/schedule/serializers.py
@@ -4,7 +4,7 @@ DRF Serializers for Schedule App with Availability Validation
from rest_framework import serializers
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError as DjangoValidationError
-from .models import Resource, Event, Participant, Service, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin, EmailTemplate
+from .models import Resource, Event, Participant, Service, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin, EmailTemplate, Holiday, TimeBlock
from .services import AvailabilityService
from smoothschedule.users.models import User
@@ -488,29 +488,39 @@ class EventSerializer(serializers.ModelSerializer):
# CRITICAL: This enforces concurrency limits
event_id = self.instance.id if self.instance else None
availability_errors = []
-
+ soft_block_warnings = []
+
for resource_id in resource_ids:
try:
resource = Resource.objects.get(id=resource_id, is_active=True)
except Resource.DoesNotExist:
availability_errors.append(f"Resource ID {resource_id} not found or inactive")
continue
-
+
# Call the availability service
- is_available, reason = AvailabilityService.check_availability(
+ is_available, reason, warnings = AvailabilityService.check_availability(
resource=resource,
start_time=start_time,
end_time=end_time,
exclude_event_id=event_id
)
-
+
if not is_available:
availability_errors.append(f"{resource.name}: {reason}")
-
+ else:
+ # Collect soft block warnings (these can be overridden)
+ soft_block_warnings.extend(warnings)
+
if availability_errors:
raise serializers.ValidationError({
'non_field_errors': availability_errors
})
+
+ # Store soft warnings for the view layer to handle (e.g., show confirmation dialog)
+ # The frontend can pass force_override=true to proceed despite soft blocks
+ if soft_block_warnings and not self.context.get('force_override', False):
+ # Add warnings to context so they can be included in response
+ self.context['soft_block_warnings'] = soft_block_warnings
return attrs
@@ -1108,4 +1118,351 @@ class EmailTemplatePreviewSerializer(serializers.Serializer):
subject = serializers.CharField()
html_content = serializers.CharField(allow_blank=True, required=False, default='')
text_content = serializers.CharField(allow_blank=True, required=False, default='')
- context = serializers.DictField(required=False, default=dict)
\ No newline at end of file
+ context = serializers.DictField(required=False, default=dict)
+
+
+# =============================================================================
+# Time Blocking System Serializers
+# =============================================================================
+
+class HolidaySerializer(serializers.ModelSerializer):
+ """Serializer for Holiday reference data"""
+ next_occurrence = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Holiday
+ fields = [
+ 'code', 'name', 'country', 'holiday_type',
+ 'month', 'day', 'week_of_month', 'day_of_week',
+ 'calculation_rule', 'is_active', 'next_occurrence',
+ ]
+ read_only_fields = fields # Holidays are reference data, not editable via API
+
+ def get_next_occurrence(self, obj):
+ """Get the next occurrence date for this holiday"""
+ from datetime import date
+ today = date.today()
+ current_year_date = obj.get_date_for_year(today.year)
+
+ if current_year_date and current_year_date >= today:
+ return current_year_date.isoformat()
+
+ # If this year's date has passed, get next year's
+ next_year_date = obj.get_date_for_year(today.year + 1)
+ return next_year_date.isoformat() if next_year_date else None
+
+
+class HolidayListSerializer(serializers.ModelSerializer):
+ """Lightweight serializer for holiday dropdowns"""
+
+ class Meta:
+ model = Holiday
+ fields = ['code', 'name', 'country']
+
+
+class TimeBlockSerializer(serializers.ModelSerializer):
+ """Full serializer for TimeBlock CRUD operations"""
+ resource_name = serializers.CharField(source='resource.name', read_only=True, allow_null=True)
+ created_by_name = serializers.SerializerMethodField()
+ level = serializers.SerializerMethodField()
+ pattern_display = serializers.SerializerMethodField()
+ holiday_name = serializers.SerializerMethodField()
+ conflict_count = serializers.SerializerMethodField()
+
+ class Meta:
+ model = TimeBlock
+ fields = [
+ 'id', 'title', 'description',
+ 'resource', 'resource_name', 'level',
+ 'block_type', 'recurrence_type',
+ 'start_date', 'end_date', 'all_day', 'start_time', 'end_time',
+ 'recurrence_pattern', 'pattern_display', 'holiday_name',
+ 'recurrence_start', 'recurrence_end',
+ 'is_active', 'created_by', 'created_by_name',
+ 'conflict_count', 'created_at', 'updated_at',
+ ]
+ read_only_fields = ['created_by', 'created_at', 'updated_at']
+
+ def get_created_by_name(self, obj):
+ if obj.created_by:
+ return obj.created_by.get_full_name() or obj.created_by.email
+ return None
+
+ def get_level(self, obj):
+ """Return 'business' if no resource, otherwise 'resource'"""
+ return 'business' if obj.resource is None else 'resource'
+
+ def get_pattern_display(self, obj):
+ """Get human-readable description of the recurrence pattern"""
+ if obj.recurrence_type == TimeBlock.RecurrenceType.NONE:
+ if obj.start_date == obj.end_date:
+ return obj.start_date.strftime('%B %d, %Y') if obj.start_date else 'One-time'
+ return f"{obj.start_date} to {obj.end_date}" if obj.start_date else 'One-time'
+
+ if obj.recurrence_type == TimeBlock.RecurrenceType.WEEKLY:
+ days = obj.recurrence_pattern.get('days_of_week', [])
+ day_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
+ selected = [day_names[d] for d in days if 0 <= d <= 6]
+ return f"Weekly on {', '.join(selected)}" if selected else 'Weekly'
+
+ if obj.recurrence_type == TimeBlock.RecurrenceType.MONTHLY:
+ days = obj.recurrence_pattern.get('days_of_month', [])
+ if days:
+ day_strs = [self._ordinal(d) for d in days]
+ return f"Monthly on the {', '.join(day_strs)}"
+ return 'Monthly'
+
+ if obj.recurrence_type == TimeBlock.RecurrenceType.YEARLY:
+ month = obj.recurrence_pattern.get('month')
+ day = obj.recurrence_pattern.get('day')
+ if month and day:
+ from calendar import month_name
+ return f"Yearly on {month_name[month]} {day}"
+ return 'Yearly'
+
+ if obj.recurrence_type == TimeBlock.RecurrenceType.HOLIDAY:
+ holiday_code = obj.recurrence_pattern.get('holiday_code')
+ if holiday_code:
+ try:
+ holiday = Holiday.objects.get(code=holiday_code)
+ return f"Holiday: {holiday.name}"
+ except Holiday.DoesNotExist:
+ return f"Holiday: {holiday_code}"
+ return 'Holiday'
+
+ return str(obj.recurrence_type)
+
+ def _ordinal(self, n):
+ """Convert number to ordinal string (1 -> 1st, 2 -> 2nd, etc.)"""
+ if 11 <= n <= 13:
+ suffix = 'th'
+ else:
+ suffix = {1: 'st', 2: 'nd', 3: 'rd'}.get(n % 10, 'th')
+ return f"{n}{suffix}"
+
+ def get_holiday_name(self, obj):
+ """Get holiday name if this is a holiday block"""
+ if obj.recurrence_type != TimeBlock.RecurrenceType.HOLIDAY:
+ return None
+ holiday_code = obj.recurrence_pattern.get('holiday_code')
+ if holiday_code:
+ try:
+ holiday = Holiday.objects.get(code=holiday_code)
+ return holiday.name
+ except Holiday.DoesNotExist:
+ return None
+ return None
+
+ def get_conflict_count(self, obj):
+ """Count events that conflict with this block"""
+ # This is an expensive operation, only compute on detail view
+ request = self.context.get('request')
+ if request and request.method == 'GET':
+ # Only compute for detail endpoints (single object)
+ view = self.context.get('view')
+ if view and hasattr(view, 'action') and view.action == 'retrieve':
+ return self._count_conflicts(obj)
+ return None
+
+ def _count_conflicts(self, obj):
+ """Count events that would conflict with this time block"""
+ from datetime import date, timedelta
+ today = date.today()
+ end_date = today + timedelta(days=90) # Look 90 days ahead
+
+ blocked_dates = obj.get_blocked_dates_in_range(today, end_date)
+ if not blocked_dates:
+ return 0
+
+ # Count events on blocked dates
+ from schedule.models import Event
+ event_count = 0
+ for blocked_date in blocked_dates:
+ # Check if any events fall on this date
+ events = Event.objects.filter(
+ start_time__date=blocked_date,
+ status__in=['SCHEDULED', 'CONFIRMED']
+ )
+ if obj.resource:
+ # Filter to events with this resource as participant
+ from django.contrib.contenttypes.models import ContentType
+ resource_ct = ContentType.objects.get_for_model(Resource)
+ events = events.filter(
+ participants__content_type=resource_ct,
+ participants__object_id=obj.resource_id
+ )
+ event_count += events.count()
+
+ return event_count
+
+ def validate(self, attrs):
+ """Validate time block configuration"""
+ recurrence_type = attrs.get('recurrence_type', TimeBlock.RecurrenceType.NONE)
+ pattern = attrs.get('recurrence_pattern', {})
+
+ # Validate NONE type requires dates
+ if recurrence_type == TimeBlock.RecurrenceType.NONE:
+ if not attrs.get('start_date'):
+ raise serializers.ValidationError({
+ 'start_date': 'Start date is required for one-time blocks'
+ })
+
+ # Validate WEEKLY requires days_of_week
+ if recurrence_type == TimeBlock.RecurrenceType.WEEKLY:
+ days = pattern.get('days_of_week', [])
+ if not days:
+ raise serializers.ValidationError({
+ 'recurrence_pattern': 'days_of_week is required for weekly blocks'
+ })
+ for d in days:
+ if not isinstance(d, int) or d < 0 or d > 6:
+ raise serializers.ValidationError({
+ 'recurrence_pattern': 'days_of_week must be integers 0-6'
+ })
+
+ # Validate MONTHLY requires days_of_month
+ if recurrence_type == TimeBlock.RecurrenceType.MONTHLY:
+ days = pattern.get('days_of_month', [])
+ if not days:
+ raise serializers.ValidationError({
+ 'recurrence_pattern': 'days_of_month is required for monthly blocks'
+ })
+ for d in days:
+ if not isinstance(d, int) or d < 1 or d > 31:
+ raise serializers.ValidationError({
+ 'recurrence_pattern': 'days_of_month must be integers 1-31'
+ })
+
+ # Validate YEARLY requires month and day
+ if recurrence_type == TimeBlock.RecurrenceType.YEARLY:
+ if not pattern.get('month') or not pattern.get('day'):
+ raise serializers.ValidationError({
+ 'recurrence_pattern': 'month and day are required for yearly blocks'
+ })
+ month = pattern.get('month')
+ day = pattern.get('day')
+ if not (1 <= month <= 12):
+ raise serializers.ValidationError({
+ 'recurrence_pattern': 'month must be 1-12'
+ })
+ if not (1 <= day <= 31):
+ raise serializers.ValidationError({
+ 'recurrence_pattern': 'day must be 1-31'
+ })
+
+ # Validate HOLIDAY requires holiday_code
+ if recurrence_type == TimeBlock.RecurrenceType.HOLIDAY:
+ holiday_code = pattern.get('holiday_code')
+ if not holiday_code:
+ raise serializers.ValidationError({
+ 'recurrence_pattern': 'holiday_code is required for holiday blocks'
+ })
+ if not Holiday.objects.filter(code=holiday_code, is_active=True).exists():
+ raise serializers.ValidationError({
+ 'recurrence_pattern': f"Holiday '{holiday_code}' not found"
+ })
+
+ # Validate time fields
+ all_day = attrs.get('all_day', True)
+ if not all_day:
+ if not attrs.get('start_time'):
+ raise serializers.ValidationError({
+ 'start_time': 'Start time is required when all_day is false'
+ })
+ if not attrs.get('end_time'):
+ raise serializers.ValidationError({
+ 'end_time': 'End time is required when all_day is false'
+ })
+ if attrs.get('start_time') >= attrs.get('end_time'):
+ raise serializers.ValidationError({
+ 'end_time': 'End time must be after start time'
+ })
+
+ # Check permission for hard blocks if user is staff
+ request = self.context.get('request')
+ if request and request.user.is_authenticated:
+ user = request.user
+ block_type = attrs.get('block_type', TimeBlock.BlockType.SOFT)
+ resource = attrs.get('resource')
+
+ # Staff creating hard blocks need permission
+ if user.role == 'TENANT_STAFF':
+ if block_type == TimeBlock.BlockType.HARD:
+ permissions = user.permissions or {}
+ if not permissions.get('can_create_hard_blocks', False):
+ raise serializers.ValidationError({
+ 'block_type': 'You do not have permission to create hard blocks'
+ })
+
+ # Staff can only create blocks for their own resource
+ if resource:
+ if not hasattr(user, 'resource') or user.resource != resource:
+ raise serializers.ValidationError({
+ 'resource': 'Staff can only create blocks for their own resource'
+ })
+ else:
+ # Staff cannot create business-level blocks
+ permissions = user.permissions or {}
+ if not permissions.get('can_create_business_blocks', False):
+ raise serializers.ValidationError({
+ 'resource': 'Staff cannot create business-level blocks'
+ })
+
+ return attrs
+
+ def create(self, validated_data):
+ """Set created_by from request context"""
+ request = self.context.get('request')
+ if request and hasattr(request, 'user') and request.user.is_authenticated:
+ validated_data['created_by'] = request.user
+ return super().create(validated_data)
+
+
+class TimeBlockListSerializer(serializers.ModelSerializer):
+ """Serializer for time block lists - includes fields needed for editing"""
+ resource_name = serializers.CharField(source='resource.name', read_only=True, allow_null=True)
+ level = serializers.SerializerMethodField()
+ pattern_display = serializers.SerializerMethodField()
+
+ class Meta:
+ model = TimeBlock
+ fields = [
+ 'id', 'title', 'description', 'resource', 'resource_name', 'level',
+ 'block_type', 'recurrence_type', 'start_date', 'end_date',
+ 'all_day', 'start_time', 'end_time', 'recurrence_pattern',
+ 'recurrence_start', 'recurrence_end', 'pattern_display',
+ 'is_active', 'created_at',
+ ]
+
+ def get_level(self, obj):
+ return 'business' if obj.resource is None else 'resource'
+
+ def get_pattern_display(self, obj):
+ """Simple pattern description for list view"""
+ if obj.recurrence_type == TimeBlock.RecurrenceType.NONE:
+ return 'One-time'
+ return obj.recurrence_type.replace('_', ' ').title()
+
+
+class BlockedDateSerializer(serializers.Serializer):
+ """Serializer for blocked date responses (calendar view)"""
+ date = serializers.DateField()
+ block_type = serializers.CharField()
+ title = serializers.CharField()
+ resource_id = serializers.IntegerField(allow_null=True)
+ all_day = serializers.BooleanField()
+ start_time = serializers.TimeField(allow_null=True)
+ end_time = serializers.TimeField(allow_null=True)
+ time_block_id = serializers.IntegerField()
+
+
+class CheckConflictsSerializer(serializers.Serializer):
+ """Input serializer for checking block conflicts"""
+ recurrence_type = serializers.ChoiceField(choices=TimeBlock.RecurrenceType.choices)
+ recurrence_pattern = serializers.JSONField(required=False, default=dict)
+ start_date = serializers.DateField(required=False, allow_null=True)
+ end_date = serializers.DateField(required=False, allow_null=True)
+ resource_id = serializers.IntegerField(required=False, allow_null=True)
+ all_day = serializers.BooleanField(default=True)
+ start_time = serializers.TimeField(required=False, allow_null=True)
+ end_time = serializers.TimeField(required=False, allow_null=True)
\ No newline at end of file
diff --git a/smoothschedule/schedule/services.py b/smoothschedule/schedule/services.py
index 25bdabd..86872e5 100644
--- a/smoothschedule/schedule/services.py
+++ b/smoothschedule/schedule/services.py
@@ -1,80 +1,158 @@
"""
-Availability Service - Resource capacity checking with concurrency management
+Availability Service - Resource capacity checking with concurrency management and time blocks
"""
from django.contrib.contenttypes.models import ContentType
-from .models import Event, Participant, Resource
+from django.db.models import Q
+from .models import Event, Participant, Resource, TimeBlock
class AvailabilityService:
"""
- Service for checking resource availability with concurrency limits.
-
+ Service for checking resource availability with concurrency limits and time blocks.
+
CRITICAL Features:
- Handles max_concurrent_events==0 as unlimited capacity
- Filters out CANCELED events to prevent ghost bookings
- Uses correct overlap logic: start < query_end AND end > query_start
+ - Checks business-level and resource-level time blocks
+ - Returns soft block warnings separately from hard blocks
"""
-
+
@staticmethod
def check_availability(resource, start_time, end_time, exclude_event_id=None):
"""
Check if resource has capacity for a new/updated event.
-
+
Args:
resource (Resource): The resource to check
start_time (datetime): Proposed event start
end_time (datetime): Proposed event end
exclude_event_id (int, optional): Event ID to exclude (when updating)
-
+
Returns:
- tuple: (is_available: bool, reason: str)
+ tuple: (is_available: bool, reason: str, soft_block_warnings: list)
+ - is_available: False if hard-blocked or capacity exceeded
+ - reason: Human-readable explanation
+ - soft_block_warnings: List of soft block warnings (can be overridden)
"""
- # Step 1: Calculate search window with buffer
+ soft_block_warnings = []
+
+ # Step 1: Check time blocks (business-level first, then resource-level)
+ block_result = AvailabilityService._check_time_blocks(
+ resource, start_time, end_time
+ )
+
+ if block_result['hard_blocked']:
+ return False, block_result['reason'], []
+
+ soft_block_warnings.extend(block_result['soft_warnings'])
+
+ # Step 2: Calculate search window with buffer
query_start = start_time - resource.buffer_duration
query_end = end_time + resource.buffer_duration
-
- # Step 2: Find all events for this resource
+
+ # Step 3: Find all events for this resource
resource_content_type = ContentType.objects.get_for_model(Resource)
-
+
resource_participants = Participant.objects.filter(
content_type=resource_content_type,
object_id=resource.id,
role=Participant.Role.RESOURCE
).select_related('event')
-
- # Step 3: Filter for overlapping events
+
+ # Step 4: Filter for overlapping events
overlapping_events = []
for participant in resource_participants:
event = participant.event
-
+
# Skip if this is the event being updated
# CRITICAL: Convert exclude_event_id to int for comparison (frontend may send string)
if exclude_event_id and event.id == int(exclude_event_id):
continue
-
+
# CRITICAL: Skip cancelled events (prevents ghost bookings)
if event.status == Event.Status.CANCELED:
continue
-
+
# CRITICAL: Check overlap using correct logic
# Overlap exists when: event.start < query_end AND event.end > query_start
if event.start_time < query_end and event.end_time > query_start:
overlapping_events.append(event)
-
+
current_count = len(overlapping_events)
-
- # Step 4: Check capacity limit
-
+
+ # Step 5: Check capacity limit
+
# CRITICAL: Handle infinite capacity (0 = unlimited)
if resource.max_concurrent_events == 0:
- return True, "Unlimited capacity resource"
-
+ return True, "Unlimited capacity resource", soft_block_warnings
+
# Check if we've hit the limit
if current_count >= resource.max_concurrent_events:
return False, (
f"Resource capacity exceeded. "
f"{current_count}/{resource.max_concurrent_events} slots occupied."
- )
-
+ ), []
+
# Available!
- return True, f"Available ({current_count + 1}/{resource.max_concurrent_events} slots)"
+ return True, f"Available ({current_count + 1}/{resource.max_concurrent_events} slots)", soft_block_warnings
+
+ @staticmethod
+ def _check_time_blocks(resource, start_time, end_time):
+ """
+ Check if a time period is blocked by any time blocks.
+
+ Checks both business-level blocks (resource=null) and resource-level blocks.
+ Business-level blocks are checked first as they apply to all resources.
+
+ Args:
+ resource (Resource): The resource to check
+ start_time (datetime): Proposed event start
+ end_time (datetime): Proposed event end
+
+ Returns:
+ dict: {
+ 'hard_blocked': bool,
+ 'reason': str,
+ 'soft_warnings': list of warning strings
+ }
+ """
+ result = {
+ 'hard_blocked': False,
+ 'reason': '',
+ 'soft_warnings': []
+ }
+
+ # Get active time blocks (business-level + resource-level)
+ blocks = TimeBlock.objects.filter(
+ Q(resource__isnull=True) | Q(resource=resource),
+ is_active=True
+ ).order_by('resource') # Business blocks first (null sorts first)
+
+ for block in blocks:
+ # Check if this block applies to the requested datetime range
+ if block.blocks_datetime_range(start_time, end_time):
+ if block.block_type == TimeBlock.BlockType.HARD:
+ # Hard block - immediately return unavailable
+ level = "Business closed" if block.resource is None else f"{resource.name} unavailable"
+ result['hard_blocked'] = True
+ result['reason'] = f"{level}: {block.title}"
+ return result
+ else:
+ # Soft block - add warning but continue
+ level = "Business advisory" if block.resource is None else f"{resource.name} advisory"
+ result['soft_warnings'].append(f"{level}: {block.title}")
+
+ return result
+
+ @staticmethod
+ def check_availability_simple(resource, start_time, end_time, exclude_event_id=None):
+ """
+ Simple availability check that returns just (bool, str) for backwards compatibility.
+
+ Use check_availability() for full soft block warning support.
+ """
+ is_available, reason, _ = AvailabilityService.check_availability(
+ resource, start_time, end_time, exclude_event_id
+ )
+ return is_available, reason
diff --git a/smoothschedule/schedule/urls.py b/smoothschedule/schedule/urls.py
index aa22a14..d279f47 100644
--- a/smoothschedule/schedule/urls.py
+++ b/smoothschedule/schedule/urls.py
@@ -8,7 +8,8 @@ from .views import (
CustomerViewSet, ServiceViewSet, StaffViewSet, ResourceTypeViewSet,
ScheduledTaskViewSet, TaskExecutionLogViewSet, PluginViewSet,
PluginTemplateViewSet, PluginInstallationViewSet, EventPluginViewSet,
- GlobalEventPluginViewSet, EmailTemplateViewSet
+ GlobalEventPluginViewSet, EmailTemplateViewSet,
+ HolidayViewSet, TimeBlockViewSet
)
from .export_views import ExportViewSet
@@ -31,6 +32,8 @@ router.register(r'event-plugins', EventPluginViewSet, basename='eventplugin')
router.register(r'global-event-plugins', GlobalEventPluginViewSet, basename='globaleventplugin')
router.register(r'email-templates', EmailTemplateViewSet, basename='emailtemplate')
router.register(r'export', ExportViewSet, basename='export')
+router.register(r'holidays', HolidayViewSet, basename='holiday')
+router.register(r'time-blocks', TimeBlockViewSet, basename='timeblock')
# URL patterns
urlpatterns = [
diff --git a/smoothschedule/schedule/views.py b/smoothschedule/schedule/views.py
index 7a45b6d..7470105 100644
--- a/smoothschedule/schedule/views.py
+++ b/smoothschedule/schedule/views.py
@@ -8,14 +8,16 @@ from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response
from rest_framework.decorators import action
from django.core.exceptions import ValidationError as DjangoValidationError
-from .models import Resource, Event, Participant, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin, EmailTemplate
+from .models import Resource, Event, Participant, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin, EmailTemplate, Holiday, TimeBlock
from .serializers import (
ResourceSerializer, EventSerializer, ParticipantSerializer,
CustomerSerializer, ServiceSerializer, ResourceTypeSerializer, StaffSerializer,
ScheduledTaskSerializer, TaskExecutionLogSerializer, PluginInfoSerializer,
PluginTemplateSerializer, PluginTemplateListSerializer, PluginInstallationSerializer,
EventPluginSerializer, GlobalEventPluginSerializer,
- EmailTemplateSerializer, EmailTemplateListSerializer, EmailTemplatePreviewSerializer
+ EmailTemplateSerializer, EmailTemplateListSerializer, EmailTemplatePreviewSerializer,
+ HolidaySerializer, HolidayListSerializer,
+ TimeBlockSerializer, TimeBlockListSerializer, BlockedDateSerializer, CheckConflictsSerializer
)
from .models import Service
from core.permissions import HasQuota
@@ -1739,4 +1741,410 @@ class EmailTemplateViewSet(viewsets.ModelViewSet):
# Return all presets organized by category
return Response({
'presets': get_all_presets()
- })
\ No newline at end of file
+ })
+
+
+# =============================================================================
+# Time Blocking System ViewSets
+# =============================================================================
+
+class HolidayViewSet(viewsets.ReadOnlyModelViewSet):
+ """
+ API endpoint for viewing holidays.
+
+ Holidays are reference data seeded by the system and cannot be modified
+ through the API. Use the `seed_holidays` management command to populate.
+
+ Endpoints:
+ - GET /api/holidays/ - List all holidays
+ - GET /api/holidays/{code}/ - Get holiday details
+ - GET /api/holidays/dates/ - Get holiday dates for a year
+ """
+ queryset = Holiday.objects.filter(is_active=True)
+ serializer_class = HolidaySerializer
+ permission_classes = [IsAuthenticated]
+ lookup_field = 'code'
+
+ def get_queryset(self):
+ """Filter by country if specified"""
+ queryset = super().get_queryset()
+
+ country = self.request.query_params.get('country')
+ if country:
+ queryset = queryset.filter(country=country.upper())
+
+ return queryset.order_by('country', 'name')
+
+ def get_serializer_class(self):
+ if self.action == 'list':
+ return HolidayListSerializer
+ return HolidaySerializer
+
+ @action(detail=False, methods=['get'])
+ def dates(self, request):
+ """
+ Get all holiday dates for a specific year.
+
+ Query params:
+ - year: The year to get dates for (default: current year)
+ - country: Filter by country code (default: all)
+
+ Response:
+ {
+ "year": 2025,
+ "holidays": [
+ {"code": "new_years_day", "name": "New Year's Day", "date": "2025-01-01"},
+ ...
+ ]
+ }
+ """
+ from datetime import date
+
+ year = request.query_params.get('year')
+ if year:
+ try:
+ year = int(year)
+ except ValueError:
+ return Response(
+ {'error': 'Invalid year'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+ else:
+ year = date.today().year
+
+ queryset = self.get_queryset()
+ holidays = []
+
+ for holiday in queryset:
+ holiday_date = holiday.get_date_for_year(year)
+ if holiday_date:
+ holidays.append({
+ 'code': holiday.code,
+ 'name': holiday.name,
+ 'date': holiday_date.isoformat(),
+ })
+
+ # Sort by date
+ holidays.sort(key=lambda h: h['date'])
+
+ return Response({
+ 'year': year,
+ 'holidays': holidays
+ })
+
+
+class TimeBlockViewSet(viewsets.ModelViewSet):
+ """
+ API endpoint for managing time blocks.
+
+ Time blocks allow businesses to block off time for various purposes:
+ - Business closures (holidays, vacations)
+ - Resource-specific unavailability (maintenance, personal time)
+ - Recurring patterns (weekly lunch breaks, monthly meetings)
+
+ Block Types:
+ - HARD: Prevents booking entirely
+ - SOFT: Shows warning but allows override
+
+ Block Levels:
+ - Business-level: resource=null, applies to all resources
+ - Resource-level: resource=ID, applies only to that resource
+
+ Endpoints:
+ - GET /api/time-blocks/ - List blocks (filterable)
+ - POST /api/time-blocks/ - Create block
+ - GET /api/time-blocks/{id}/ - Get block details
+ - PATCH /api/time-blocks/{id}/ - Update block
+ - DELETE /api/time-blocks/{id}/ - Delete block
+ - GET /api/time-blocks/blocked-dates/ - Get expanded dates for calendar
+ - POST /api/time-blocks/check-conflicts/ - Preview conflicts before creating
+ - GET /api/time-blocks/my-blocks/ - Staff view of their own blocks
+ """
+ queryset = TimeBlock.objects.select_related('resource', 'created_by').all()
+ serializer_class = TimeBlockSerializer
+ permission_classes = [IsAuthenticated]
+
+ def get_queryset(self):
+ """Filter time blocks based on query parameters"""
+ queryset = super().get_queryset()
+ user = self.request.user
+
+ # Filter by level (business or resource)
+ level = self.request.query_params.get('level')
+ if level == 'business':
+ queryset = queryset.filter(resource__isnull=True)
+ elif level == 'resource':
+ queryset = queryset.filter(resource__isnull=False)
+
+ # Filter by specific resource
+ resource_id = self.request.query_params.get('resource_id')
+ if resource_id:
+ queryset = queryset.filter(resource_id=resource_id)
+
+ # Filter by block type
+ block_type = self.request.query_params.get('block_type')
+ if block_type:
+ queryset = queryset.filter(block_type=block_type.upper())
+
+ # Filter by recurrence type
+ recurrence_type = self.request.query_params.get('recurrence_type')
+ if recurrence_type:
+ queryset = queryset.filter(recurrence_type=recurrence_type.upper())
+
+ # Filter by active status
+ is_active = self.request.query_params.get('is_active')
+ if is_active is not None:
+ queryset = queryset.filter(is_active=is_active.lower() == 'true')
+
+ # Staff can only see their own resource's blocks + business blocks
+ if user.role == 'TENANT_STAFF':
+ from django.db.models import Q
+ if hasattr(user, 'resource') and user.resource:
+ queryset = queryset.filter(
+ Q(resource__isnull=True) | # Business blocks
+ Q(resource=user.resource) # Their resource blocks
+ )
+ else:
+ # Staff without linked resource only see business blocks
+ queryset = queryset.filter(resource__isnull=True)
+
+ return queryset.order_by('-created_at')
+
+ def get_serializer_class(self):
+ if self.action == 'list':
+ return TimeBlockListSerializer
+ return TimeBlockSerializer
+
+ @action(detail=False, methods=['get'])
+ def blocked_dates(self, request):
+ """
+ Get expanded blocked dates for calendar visualization.
+
+ Query params:
+ - start_date: Start of range (required, YYYY-MM-DD)
+ - end_date: End of range (required, YYYY-MM-DD)
+ - resource_id: Filter to specific resource (optional)
+ - include_business: Include business-level blocks (default: true)
+
+ Response:
+ {
+ "blocked_dates": [
+ {
+ "date": "2025-01-01",
+ "block_type": "HARD",
+ "title": "New Year's Day",
+ "resource_id": null,
+ "all_day": true,
+ "start_time": null,
+ "end_time": null,
+ "time_block_id": 123
+ },
+ ...
+ ]
+ }
+ """
+ from datetime import datetime
+
+ start_date_str = request.query_params.get('start_date')
+ end_date_str = request.query_params.get('end_date')
+
+ if not start_date_str or not end_date_str:
+ return Response(
+ {'error': 'start_date and end_date are required'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ try:
+ start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
+ end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
+ except ValueError:
+ return Response(
+ {'error': 'Invalid date format. Use YYYY-MM-DD'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ # Get active blocks
+ queryset = self.get_queryset().filter(is_active=True)
+
+ resource_id = request.query_params.get('resource_id')
+ include_business = request.query_params.get('include_business', 'true').lower() == 'true'
+
+ from django.db.models import Q
+ if resource_id:
+ if include_business:
+ queryset = queryset.filter(
+ Q(resource__isnull=True) | Q(resource_id=resource_id)
+ )
+ else:
+ queryset = queryset.filter(resource_id=resource_id)
+ elif not include_business:
+ queryset = queryset.filter(resource__isnull=False)
+
+ blocked_dates = []
+ for block in queryset:
+ dates = block.get_blocked_dates_in_range(start_date, end_date)
+ for blocked_info in dates:
+ blocked_dates.append({
+ 'date': blocked_info['date'].isoformat(),
+ 'block_type': block.block_type,
+ 'title': block.title,
+ 'resource_id': block.resource_id,
+ 'all_day': block.all_day,
+ 'start_time': block.start_time.isoformat() if block.start_time else None,
+ 'end_time': block.end_time.isoformat() if block.end_time else None,
+ 'time_block_id': block.id,
+ })
+
+ # Sort by date
+ blocked_dates.sort(key=lambda d: (d['date'], d['resource_id'] or 0))
+
+ return Response({
+ 'blocked_dates': blocked_dates,
+ 'start_date': start_date_str,
+ 'end_date': end_date_str,
+ })
+
+ @action(detail=False, methods=['post'])
+ def check_conflicts(self, request):
+ """
+ Check for conflicts before creating a time block.
+
+ Request body:
+ {
+ "recurrence_type": "NONE",
+ "recurrence_pattern": {},
+ "start_date": "2025-01-15",
+ "end_date": "2025-01-15",
+ "resource_id": null,
+ "all_day": true
+ }
+
+ Response:
+ {
+ "has_conflicts": true,
+ "conflict_count": 3,
+ "conflicts": [
+ {
+ "event_id": 123,
+ "title": "Appointment - John Doe",
+ "start_time": "2025-01-15T10:00:00",
+ "end_time": "2025-01-15T11:00:00"
+ },
+ ...
+ ]
+ }
+ """
+ serializer = CheckConflictsSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ data = serializer.validated_data
+
+ from datetime import date, timedelta
+
+ # Create a temporary TimeBlock to use its date calculation methods
+ temp_block = TimeBlock(
+ recurrence_type=data['recurrence_type'],
+ recurrence_pattern=data.get('recurrence_pattern', {}),
+ start_date=data.get('start_date'),
+ end_date=data.get('end_date'),
+ all_day=data.get('all_day', True),
+ start_time=data.get('start_time'),
+ end_time=data.get('end_time'),
+ )
+
+ # Get blocked dates for next 90 days
+ today = date.today()
+ check_end = today + timedelta(days=90)
+ blocked_dates = temp_block.get_blocked_dates_in_range(today, check_end)
+
+ if not blocked_dates:
+ return Response({
+ 'has_conflicts': False,
+ 'conflict_count': 0,
+ 'conflicts': []
+ })
+
+ # Find conflicting events
+ resource_id = data.get('resource_id')
+ conflicts = []
+
+ for blocked_date in blocked_dates:
+ events = Event.objects.filter(
+ start_time__date=blocked_date,
+ status__in=['SCHEDULED', 'CONFIRMED']
+ )
+
+ if resource_id:
+ from django.contrib.contenttypes.models import ContentType
+ resource_ct = ContentType.objects.get_for_model(Resource)
+ events = events.filter(
+ participants__content_type=resource_ct,
+ participants__object_id=resource_id
+ )
+
+ for event in events[:10]: # Limit to 10 per date
+ conflicts.append({
+ 'event_id': event.id,
+ 'title': event.title,
+ 'start_time': event.start_time.isoformat(),
+ 'end_time': event.end_time.isoformat(),
+ })
+
+ return Response({
+ 'has_conflicts': len(conflicts) > 0,
+ 'conflict_count': len(conflicts),
+ 'conflicts': conflicts[:20], # Limit total to 20
+ })
+
+ @action(detail=False, methods=['get'])
+ def my_blocks(self, request):
+ """
+ Get time blocks for the current staff member's resource.
+
+ This endpoint is for staff members to view their own availability.
+ Returns both their resource blocks and business-level blocks.
+
+ Response includes blocks organized by type for easy display.
+ """
+ user = request.user
+
+ # Check if user is staff with a linked resource
+ if not hasattr(user, 'resource') or not user.resource:
+ return Response({
+ 'business_blocks': [],
+ 'my_blocks': [],
+ 'message': 'You do not have a linked resource'
+ })
+
+ from django.db.models import Q
+
+ # Get business blocks
+ business_blocks = TimeBlock.objects.filter(
+ resource__isnull=True,
+ is_active=True
+ ).order_by('-created_at')
+
+ # Get blocks for user's resource
+ my_blocks = TimeBlock.objects.filter(
+ resource=user.resource,
+ is_active=True
+ ).order_by('-created_at')
+
+ return Response({
+ 'business_blocks': TimeBlockListSerializer(business_blocks, many=True).data,
+ 'my_blocks': TimeBlockListSerializer(my_blocks, many=True).data,
+ 'resource_id': user.resource.id,
+ 'resource_name': user.resource.name,
+ })
+
+ @action(detail=True, methods=['post'])
+ def toggle(self, request, pk=None):
+ """Toggle the is_active status of a time block"""
+ block = self.get_object()
+ block.is_active = not block.is_active
+ block.save(update_fields=['is_active', 'updated_at'])
+
+ return Response({
+ 'id': block.id,
+ 'is_active': block.is_active,
+ 'message': f"Block {'activated' if block.is_active else 'deactivated'}"
+ })
\ No newline at end of file
diff --git a/smoothschedule/templates/contracts/email_contract_reminder.html b/smoothschedule/templates/contracts/email_contract_reminder.html
new file mode 100644
index 0000000..4c17951
--- /dev/null
+++ b/smoothschedule/templates/contracts/email_contract_reminder.html
@@ -0,0 +1,46 @@
+
+
+
+
+
+ Reminder: Contract Signature Required
+
+
+
+
Reminder: Signature Required
+
Your signature is still needed for a contract from {{ business_name }}
+
+
+
+
Hi {{ customer_first_name|default:customer_name }},
+
+
This is a friendly reminder that {{ business_name }} is still waiting for your signature on a contract.
+ Or copy and paste this link into your browser:
+ {{ signing_url }}
+
+
+
+
+
+ If you have any questions about this contract, please contact {{ business_name }} directly.
+
+
+
+
diff --git a/smoothschedule/templates/contracts/email_contract_reminder.txt b/smoothschedule/templates/contracts/email_contract_reminder.txt
new file mode 100644
index 0000000..aeb89f5
--- /dev/null
+++ b/smoothschedule/templates/contracts/email_contract_reminder.txt
@@ -0,0 +1,19 @@
+Reminder: Signature Required for {{ contract_title }}
+
+Hi {{ customer_first_name|default:customer_name }},
+
+This is a friendly reminder that {{ business_name }} is still waiting for your signature on a contract.
+
+CONTRACT DETAILS
+----------------
+Title: {{ contract_title }}
+{% if expires_at %}Please sign by: {{ expires_at|date:"F j, Y" }}{% endif %}
+
+Please take a moment to review and sign the contract using the link below.
+
+Review & Sign Contract: {{ signing_url }}
+
+If you have any questions about this contract, please contact {{ business_name }} directly.
+
+---
+{{ business_name }}
diff --git a/smoothschedule/templates/contracts/email_contract_request.html b/smoothschedule/templates/contracts/email_contract_request.html
new file mode 100644
index 0000000..6e99c58
--- /dev/null
+++ b/smoothschedule/templates/contracts/email_contract_request.html
@@ -0,0 +1,46 @@
+
+
+
+
+
+ Contract Signature Required
+
+
+
+
Signature Required
+
{{ business_name }} has sent you a contract to review and sign
+
+
+
+
Hi {{ customer_first_name|default:customer_name }},
+
+
{{ business_name }} has sent you a contract that requires your signature.
+ Or copy and paste this link into your browser:
+ {{ signing_url }}
+
+
+
+
+
+ If you have any questions about this contract, please contact {{ business_name }} directly.
+
+
+
+
diff --git a/smoothschedule/templates/contracts/email_contract_request.txt b/smoothschedule/templates/contracts/email_contract_request.txt
new file mode 100644
index 0000000..6e9d75f
--- /dev/null
+++ b/smoothschedule/templates/contracts/email_contract_request.txt
@@ -0,0 +1,19 @@
+Signature Required: {{ contract_title }}
+
+Hi {{ customer_first_name|default:customer_name }},
+
+{{ business_name }} has sent you a contract that requires your signature.
+
+CONTRACT DETAILS
+----------------
+Title: {{ contract_title }}
+{% if expires_at %}Please sign by: {{ expires_at|date:"F j, Y" }}{% endif %}
+
+Please review the contract carefully and sign it electronically using the link below.
+
+Review & Sign Contract: {{ signing_url }}
+
+If you have any questions about this contract, please contact {{ business_name }} directly.
+
+---
+{{ business_name }}
diff --git a/smoothschedule/templates/contracts/email_contract_signed_business.html b/smoothschedule/templates/contracts/email_contract_signed_business.html
new file mode 100644
index 0000000..5fd1b53
--- /dev/null
+++ b/smoothschedule/templates/contracts/email_contract_signed_business.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+ Contract Signed by Customer
+
+
+
+
Contract Signed
+
A customer has signed a contract
+
+
+
+
Hi {{ staff_name }},
+
+
Great news! {{ customer_name }} has signed a contract.
+
+
+
{{ contract_title }}
+
+
+ Customer: {{ customer_name }}
+
+
+ Signed on: {{ signed_at|date:"F j, Y \a\t g:i A" }}
+
+
+
+
+
The signed contract is now available in your contracts dashboard.
+
+
+
+
+ This is an automated notification from {{ business_name }}.
+
+
+
+
diff --git a/smoothschedule/templates/contracts/email_contract_signed_business.txt b/smoothschedule/templates/contracts/email_contract_signed_business.txt
new file mode 100644
index 0000000..3f531c8
--- /dev/null
+++ b/smoothschedule/templates/contracts/email_contract_signed_business.txt
@@ -0,0 +1,16 @@
+Contract Signed by Customer
+
+Hi {{ staff_name }},
+
+Great news\! {{ customer_name }} has signed a contract.
+
+CONTRACT DETAILS
+----------------
+Title: {{ contract_title }}
+Customer: {{ customer_name }}
+Signed on: {{ signed_at|date:"F j, Y \a\t g:i A" }}
+
+The signed contract is now available in your contracts dashboard.
+
+---
+This is an automated notification from {{ business_name }}.
diff --git a/smoothschedule/templates/contracts/email_contract_signed_customer.html b/smoothschedule/templates/contracts/email_contract_signed_customer.html
new file mode 100644
index 0000000..f31384b
--- /dev/null
+++ b/smoothschedule/templates/contracts/email_contract_signed_customer.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+ Contract Signed Successfully
+
+
+
+
Contract Signed Successfully
+
Your signature has been recorded
+
+
+
+
Hi {{ customer_first_name|default:customer_name }},
+
+
Thank you for signing the contract with {{ business_name }}. Your signature has been successfully recorded.
+
+
+
{{ contract_title }}
+
+ Signed on: {{ signed_at|date:"F j, Y \a\t g:i A" }}
+
+
+
+
A copy of the signed contract has been provided for your records. {{ business_name }} also has a copy on file.
+
+
+
+
+ If you have any questions, please contact {{ business_name }}.
+
+
+
+
diff --git a/smoothschedule/templates/contracts/email_contract_signed_customer.txt b/smoothschedule/templates/contracts/email_contract_signed_customer.txt
new file mode 100644
index 0000000..03959cc
--- /dev/null
+++ b/smoothschedule/templates/contracts/email_contract_signed_customer.txt
@@ -0,0 +1,17 @@
+Contract Signed Successfully
+
+Hi {{ customer_first_name|default:customer_name }},
+
+Thank you for signing the contract with {{ business_name }}. Your signature has been successfully recorded.
+
+CONTRACT DETAILS
+----------------
+Title: {{ contract_title }}
+Signed on: {{ signed_at|date:"F j, Y \a\t g:i A" }}
+
+A copy of the signed contract has been provided for your records. {{ business_name }} also has a copy on file.
+
+If you have any questions, please contact {{ business_name }}.
+
+---
+{{ business_name }}
diff --git a/smoothschedule/templates/contracts/emails/reminder.html b/smoothschedule/templates/contracts/emails/reminder.html
new file mode 100644
index 0000000..416fa40
--- /dev/null
+++ b/smoothschedule/templates/contracts/emails/reminder.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+ Note: This signing link will expire on {{ expires_at|date:"F d, Y \a\t g:i A" }}.
+
+ {% endif %}
+
+
If you have any questions, please contact {{ business_name }}.
+
+
+
+
+ This is an automated message from {{ business_name }}. Please do not reply to this email.
+
+
+
+
diff --git a/smoothschedule/templates/contracts/emails/signing_request.txt b/smoothschedule/templates/contracts/emails/signing_request.txt
new file mode 100644
index 0000000..6ac68ba
--- /dev/null
+++ b/smoothschedule/templates/contracts/emails/signing_request.txt
@@ -0,0 +1,13 @@
+Hello {{ customer.get_full_name|default:customer.email }},
+
+Please review and sign the following contract: {{ contract.title }}
+
+To sign this contract, please visit:
+{{ signing_url }}
+
+{% if expires_at %}This signing link will expire on {{ expires_at|date:"F d, Y \a\t g:i A" }}.{% endif %}
+
+If you have any questions, please contact {{ business_name }}.
+
+Thank you,
+{{ business_name }}
diff --git a/smoothschedule/templates/contracts/pdf_preview_template.html b/smoothschedule/templates/contracts/pdf_preview_template.html
new file mode 100644
index 0000000..567761f
--- /dev/null
+++ b/smoothschedule/templates/contracts/pdf_preview_template.html
@@ -0,0 +1,230 @@
+
+
+
+
+
+ {{ template.name }} - Preview
+
+
+
+ {% if is_preview %}
+
+ {{ preview_notice }}
+
+ {% endif %}
+
+
+
+ {% if business_logo_url %}
+
+ {% else %}
+
{{ business_name }}
+ {% endif %}
+
+
+
+
Template: {{ template.name }}
+
Version: {{ template.version }}
+
Scope: {{ template.get_scope_display }}
+
+
+
+
+
{{ template.name }}
+
+
+
+ Customer:
+ John Smith (sample)
+
+
+ Customer Email:
+ john.smith@example.com
+
+ {% if template.scope == 'APPOINTMENT' %}
+
+ Appointment:
+ Sample Service - (date will be filled in)
+
+ {% endif %}
+
+ Contract Created:
+ (will be filled when sent)
+
+
+
+
+ {{ content_html|safe }}
+
+
+
+
Signature Section
+
+ This section will contain:
+ • Electronic consent checkboxes
+ • Signer name input
+ • Full audit trail with IP address, timestamp, and document hash
+
+ This document has been cryptographically signed and verified.
+ The document hash (SHA-256) ensures the content has not been tampered with since signing.
+ Any modification to the content would result in a different hash value.
+